mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 14:42:32 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ class SubmitFormEmail2fasController < ApplicationController
|
||||
|
||||
def create
|
||||
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
|
||||
RateLimit.call("verify-2fa-code-1h-#{@submitter.id}", limit: 10, ttl: 1.hour, enabled: true)
|
||||
RateLimit.call("verify-2fa-code-1d-#{@submitter.id}", limit: 20, ttl: 1.day, enabled: true)
|
||||
|
||||
value = [@submitter.email.downcase.strip, @submitter.slug].join(':')
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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') {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', 'isMobile', '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],
|
||||
|
||||
@@ -491,6 +491,23 @@ const SpanMark = Mark.create({
|
||||
}
|
||||
})
|
||||
|
||||
const LayoutSpanMark = SpanMark.extend({
|
||||
name: 'layoutSpan',
|
||||
parseHTML () {
|
||||
return [{
|
||||
tag: 'span',
|
||||
priority: 55,
|
||||
getAttrs (dom) {
|
||||
if (['left', 'right'].includes(dom.style.float) || ['inline-block', 'block'].includes(dom.style.display)) {
|
||||
return collectSpanDomAttrs(dom)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
const LinkMark = Mark.create({
|
||||
name: 'link',
|
||||
excludes: '',
|
||||
@@ -790,6 +807,7 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlFor
|
||||
ColNode,
|
||||
DynamicImageNode,
|
||||
EmptySpanNode,
|
||||
LayoutSpanMark,
|
||||
LinkMark,
|
||||
SpanMark,
|
||||
CustomBold,
|
||||
|
||||
@@ -282,7 +282,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
reloadContent () {
|
||||
this.editor.commands.setContent(this.section.innerHTML, { emitUpdate: false })
|
||||
this.editor.commands.setContent(this.section.innerHTML, { emitUpdate: false, parseOptions: this.editor.options.parseOptions })
|
||||
},
|
||||
findAreaNodePos (areaUuid) {
|
||||
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
:menu-classes="'mt-1.5'"
|
||||
:menu-style="{ backgroundColor: dropdownBgColor }"
|
||||
@update:model-value="[maybeUpdateOptions(), $emit('save')]"
|
||||
@click="scrollToFirstArea"
|
||||
@click="scrollOnEdit && scrollToFirstArea()"
|
||||
/>
|
||||
<Contenteditable
|
||||
ref="name"
|
||||
@@ -29,7 +29,8 @@
|
||||
:icon-inline="true"
|
||||
:icon-width="18"
|
||||
:icon-stroke-width="1.6"
|
||||
@focus="[onNameFocus(), scrollToFirstArea()]"
|
||||
:contenteditable-classes="isMobile ? 'py-2 -my-2' : ''"
|
||||
@focus="[onNameFocus(), scrollOnEdit && scrollToFirstArea()]"
|
||||
@blur="onNameBlur"
|
||||
/>
|
||||
</div>
|
||||
@@ -60,7 +61,8 @@
|
||||
<button
|
||||
v-if="field && !field.areas?.length"
|
||||
:title="t('draw')"
|
||||
class="relative cursor-pointer text-transparent group-hover:text-base-content"
|
||||
class="relative cursor-pointer"
|
||||
:class="iconColorClass"
|
||||
@click="$emit('set-draw', { field })"
|
||||
>
|
||||
<IconNewSection
|
||||
@@ -70,9 +72,10 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="field.preferences?.formula"
|
||||
class="relative cursor-pointer text-transparent group-hover:text-base-content"
|
||||
class="relative cursor-pointer"
|
||||
:class="iconColorClass"
|
||||
:title="t('formula')"
|
||||
@click="isShowFormulaModal = true"
|
||||
@click="openFormulaModal"
|
||||
>
|
||||
<IconMathFunction
|
||||
:width="18"
|
||||
@@ -81,9 +84,10 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="field.conditions?.length"
|
||||
class="relative cursor-pointer text-transparent group-hover:text-base-content"
|
||||
class="relative cursor-pointer"
|
||||
:class="iconColorClass"
|
||||
:title="t('condition')"
|
||||
@click="isShowConditionsModal = true"
|
||||
@click="openConditionsModal"
|
||||
>
|
||||
<IconRouteAltLeft
|
||||
:width="18"
|
||||
@@ -93,13 +97,56 @@
|
||||
<PaymentSettings
|
||||
v-if="field.type === 'payment'"
|
||||
:field="field"
|
||||
:background-color="dropdownBgColor"
|
||||
:with-custom-fields="withCustomFields"
|
||||
@click-condition="isShowConditionsModal = true"
|
||||
@click-description="isShowDescriptionModal = true"
|
||||
@click-condition="openConditionsModal"
|
||||
@click-description="openDescriptionModal"
|
||||
@add-custom-field="$emit('add-custom-field', $event)"
|
||||
@click-formula="isShowFormulaModal = true"
|
||||
@click-formula="openFormulaModal"
|
||||
@save="$emit('save')"
|
||||
/>
|
||||
>
|
||||
<template
|
||||
v-if="isMobile"
|
||||
#default
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
:title="t('settings')"
|
||||
class="cursor-pointer flex items-center pr-0.5 py-2 -my-2 pl-2 -ml-2"
|
||||
:class="iconColorClass"
|
||||
@touchstart="onHandleTouchstart"
|
||||
@touchmove="onHandleTouchmove"
|
||||
@touchend="onHandleTouchend"
|
||||
@touchcancel="onHandleTouchend"
|
||||
@mousedown="dropdownWasOpen = $event.currentTarget.parentElement.contains($event.currentTarget.getRootNode().activeElement)"
|
||||
@click="dropdownWasOpen && closeDropdown()"
|
||||
>
|
||||
<IconDotsVertical
|
||||
:width="18"
|
||||
:height="18"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
<template #menu>
|
||||
<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>
|
||||
</template>
|
||||
</PaymentSettings>
|
||||
<span
|
||||
v-else
|
||||
class="dropdown dropdown-end field-settings-dropdown"
|
||||
@@ -109,9 +156,23 @@
|
||||
<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-0.5 py-2 -my-2 pl-2 -ml-2': isMobile }]"
|
||||
@touchstart="onHandleTouchstart"
|
||||
@touchmove="onHandleTouchmove"
|
||||
@touchend="onHandleTouchend"
|
||||
@touchcancel="onHandleTouchend"
|
||||
@mousedown="dropdownWasOpen = $event.currentTarget.parentElement.contains($event.currentTarget.getRootNode().activeElement)"
|
||||
@click="dropdownWasOpen && closeDropdown()"
|
||||
>
|
||||
<IconDotsVertical
|
||||
v-if="isMobile"
|
||||
:width="18"
|
||||
:height="18"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<IconSettings
|
||||
v-else
|
||||
:width="18"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
@@ -133,20 +194,38 @@
|
||||
:with-prefillable="withPrefillable"
|
||||
:background-color="dropdownBgColor"
|
||||
:with-custom-fields="withCustomFields"
|
||||
@click-formula="isShowFormulaModal = true"
|
||||
@click-font="isShowFontModal = true"
|
||||
@click-description="isShowDescriptionModal = true"
|
||||
@click-condition="isShowConditionsModal = true"
|
||||
@click-formula="openFormulaModal"
|
||||
@click-font="openFontModal"
|
||||
@click-description="openDescriptionModal"
|
||||
@click-condition="openConditionsModal"
|
||||
@set-draw="$emit('set-draw', $event)"
|
||||
@add-custom-field="$emit('add-custom-field', $event)"
|
||||
@remove-area="removeArea"
|
||||
@save="$emit('save')"
|
||||
@scroll-to="$emit('scroll-to', $event)"
|
||||
/>
|
||||
<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
|
||||
class="relative text-transparent group-hover:text-base-content pr-1 field-remove-button"
|
||||
v-if="!isMobile"
|
||||
class="relative pr-1 field-remove-button"
|
||||
:class="iconColorClass"
|
||||
:title="t('remove')"
|
||||
@click="$emit('remove', field)"
|
||||
>
|
||||
@@ -324,7 +403,7 @@ import FormulaModal from './formula_modal'
|
||||
import FontModal from './font_modal'
|
||||
import ConditionsModal from './conditions_modal'
|
||||
import DescriptionModal from './description_modal'
|
||||
import { IconRouteAltLeft, IconMathFunction, IconNewSection, IconTrashX, IconSettings, IconChevronDown } from '@tabler/icons-vue'
|
||||
import { IconRouteAltLeft, IconMathFunction, IconNewSection, IconTrashX, IconSettings, IconDotsVertical, IconChevronDown } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
export default {
|
||||
@@ -332,6 +411,7 @@ export default {
|
||||
components: {
|
||||
Contenteditable,
|
||||
IconSettings,
|
||||
IconDotsVertical,
|
||||
FieldSettings,
|
||||
PaymentSettings,
|
||||
IconChevronDown,
|
||||
@@ -345,12 +425,17 @@ export default {
|
||||
IconMathFunction,
|
||||
FieldType
|
||||
},
|
||||
inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex', 'dateFormats'],
|
||||
inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex', 'dateFormats', 'isMobile', 'nativePlatform'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
scrollOnEdit: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -382,9 +467,11 @@ export default {
|
||||
default: true
|
||||
}
|
||||
},
|
||||
emits: ['set-draw', 'remove', 'scroll-to', 'save', 'add-custom-field'],
|
||||
emits: ['set-draw', 'remove', 'scroll-to', 'save', 'add-custom-field', 'touch-drag', 'touch-drag-end'],
|
||||
data () {
|
||||
return {
|
||||
touchDrag: null,
|
||||
dropdownWasOpen: false,
|
||||
isExpandOptions: false,
|
||||
isNameFocus: false,
|
||||
isShowFormulaModal: false,
|
||||
@@ -396,6 +483,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
iconColorClass () {
|
||||
return this.isMobile ? 'text-base-content' : 'text-transparent group-hover:text-base-content'
|
||||
},
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldLabels: FieldType.computed.fieldLabels,
|
||||
dropdownBgColor () {
|
||||
@@ -433,11 +523,70 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onHandleTouchstart (e) {
|
||||
if (!this.isMobile) return
|
||||
|
||||
this.touchDrag = { startY: e.touches[0].clientY, moved: false }
|
||||
},
|
||||
onHandleTouchmove (e) {
|
||||
if (!this.touchDrag) return
|
||||
|
||||
const { clientX, clientY } = e.touches[0]
|
||||
|
||||
if (!this.touchDrag.moved && Math.abs(clientY - this.touchDrag.startY) < 6) return
|
||||
|
||||
this.touchDrag.moved = true
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
this.$emit('touch-drag', { field: this.field, x: clientX, y: clientY })
|
||||
},
|
||||
onHandleTouchend (e) {
|
||||
if (!this.touchDrag) return
|
||||
|
||||
if (this.touchDrag.moved) {
|
||||
e.preventDefault()
|
||||
|
||||
e.currentTarget.blur()
|
||||
|
||||
this.$emit('touch-drag-end')
|
||||
}
|
||||
|
||||
this.touchDrag = null
|
||||
},
|
||||
removeArea (area) {
|
||||
this.field.areas.splice(this.field.areas.indexOf(area), 1)
|
||||
|
||||
this.$emit('save')
|
||||
},
|
||||
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) {
|
||||
if (field.type === 'payment' && field.preferences?.price && !field.preferences?.formula) {
|
||||
const { price, currency } = field.preferences || {}
|
||||
|
||||
@@ -515,7 +515,7 @@ export default {
|
||||
ContextSubmenu,
|
||||
ContextModal
|
||||
},
|
||||
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats', 'locale'],
|
||||
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats', 'locale', 'withFormula', 'withConditions', 'nativePlatform'],
|
||||
props: {
|
||||
contextMenu: {
|
||||
type: Object,
|
||||
@@ -608,10 +608,10 @@ export default {
|
||||
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)
|
||||
},
|
||||
showCondition () {
|
||||
return this.withCondition && !['stamp', 'heading'].includes(this.field.type)
|
||||
return this.withCondition && (this.withConditions || this.nativePlatform !== 'ios') && !['stamp', 'heading'].includes(this.field.type)
|
||||
},
|
||||
showFormula () {
|
||||
return this.field.type === 'number' || this.field.type === 'payment' || !!this.field.preferences?.formula
|
||||
return (this.field.type === 'number' || this.field.type === 'date' || this.field.type === 'payment' || !!this.field.preferences?.formula) && (this.withFormula || this.nativePlatform !== 'ios')
|
||||
},
|
||||
showRequired () {
|
||||
return this.withRequired && !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type)
|
||||
|
||||
@@ -484,7 +484,7 @@
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="withCondition && field.type != 'stamp' && field.type != 'heading'"
|
||||
v-if="withCondition && (withConditions || nativePlatform !== 'ios') && field.type != 'stamp' && field.type != 'heading'"
|
||||
class="field-settings-condition"
|
||||
>
|
||||
<label
|
||||
@@ -500,7 +500,7 @@
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type == 'number' || field.preferences?.formula"
|
||||
v-if="(field.type == 'number' || field.type == 'date' || field.preferences?.formula) && (withFormula || nativePlatform !== 'ios')"
|
||||
class="field-settings-formula"
|
||||
>
|
||||
<label
|
||||
@@ -610,7 +610,7 @@ export default {
|
||||
IconTypography,
|
||||
IconX
|
||||
},
|
||||
inject: ['template', 't', 'dateFormats', 'locale'],
|
||||
inject: ['template', 't', 'dateFormats', 'locale', 'withFormula', 'withConditions', 'nativePlatform'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
|
||||
@@ -125,7 +125,10 @@
|
||||
@update:model-value="$emit('name-change', selectedSubmitter)"
|
||||
/>
|
||||
</div>
|
||||
<span class="flex items-center transition-all duration-75 group-hover:border border-base-content/20 border-dashed w-6 h-6 justify-center rounded flex-shrink-0">
|
||||
<span
|
||||
class="flex items-center transition-all duration-75 group-hover:border border-base-content/20 border-dashed w-6 h-6 justify-center rounded flex-shrink-0"
|
||||
:class="{ border: isMobile }"
|
||||
>
|
||||
<component
|
||||
:is="editable ? 'IconPlus' : 'IconChevronDown'"
|
||||
width="18"
|
||||
@@ -164,7 +167,10 @@
|
||||
v-if="!compact && submitters.length > 1 && editable"
|
||||
class="flex"
|
||||
>
|
||||
<div class="flex-col pr-1 flex invisible group-hover:visible -mt-1 h-0">
|
||||
<div
|
||||
class="flex-col pr-1 flex -mt-1 h-0"
|
||||
:class="{ 'invisible group-hover:visible': !isMobile }"
|
||||
>
|
||||
<button
|
||||
:title="t('up')"
|
||||
class="relative w-2"
|
||||
@@ -184,7 +190,8 @@
|
||||
</div>
|
||||
<button
|
||||
v-if="!compact && submitters.length > 1 && editable"
|
||||
class="invisible group-hover:visible px-2"
|
||||
class="px-2"
|
||||
:class="{ 'invisible group-hover:visible': !isMobile }"
|
||||
@click.prevent.stop="remove(submitter)"
|
||||
>
|
||||
<IconTrashX :width="18" />
|
||||
@@ -237,7 +244,7 @@ export default {
|
||||
IconTrashX,
|
||||
IconChevronUp
|
||||
},
|
||||
inject: ['t', 'save'],
|
||||
inject: ['t', 'save', 'isMobile'],
|
||||
props: {
|
||||
submitters: {
|
||||
type: Array,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
:class="withStickySubmitters ? 'sticky top-0 z-[1]' : ''"
|
||||
:class="{ 'sticky top-0 z-[1]': withStickySubmitters, 'bg-base-100': withStickySubmitters && ['', null, 'transparent'].includes(backgroundColor), 'pt-3': withTopPadding }"
|
||||
:style="withStickySubmitters ? { backgroundColor } : {}"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
@@ -48,6 +48,7 @@
|
||||
:key="field.uuid"
|
||||
:data-uuid="field.uuid"
|
||||
:field="field"
|
||||
:scroll-on-edit="scrollOnEdit"
|
||||
:type-index="getFieldTypeIndex(field)"
|
||||
:editable="editable"
|
||||
:with-signature-id="withSignatureId"
|
||||
@@ -63,6 +64,8 @@
|
||||
@remove="removeField"
|
||||
@scroll-to="$emit('scroll-to-area', $event)"
|
||||
@set-draw="$emit('set-draw', $event)"
|
||||
@touch-drag="onTouchDrag"
|
||||
@touch-drag-end="onTouchDragEnd"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -169,6 +172,7 @@
|
||||
@click="setFieldsTab('custom')"
|
||||
>{{ t('custom') }}</a>
|
||||
</div>
|
||||
<div ref="addFieldsMarker" />
|
||||
<div
|
||||
v-if="!isShowVariables && showCustomTab && editable && (customFields.length || newCustomField)"
|
||||
ref="customFields"
|
||||
@@ -271,7 +275,7 @@
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
v-else-if="type == 'phone' && (fieldTypes.length === 0 || fieldTypes.includes(type))"
|
||||
v-else-if="type == 'phone' && nativePlatform !== 'ios' && (fieldTypes.length === 0 || fieldTypes.includes(type))"
|
||||
class="tooltip tooltip-bottom flex"
|
||||
:class="{'tooltip-bottom-end': withPayment, 'tooltip-bottom': !withPayment }"
|
||||
:data-tip="t('unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification')"
|
||||
@@ -326,10 +330,17 @@
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isShowVariables && fields.length < 4 && editable && withHelp && !showTourStartForm"
|
||||
v-if="!isShowVariables && (isMobile ? !fields.length : fields.length < 4) && editable && withHelp && !showTourStartForm"
|
||||
class="text-xs p-2 border border-base-200 rounded"
|
||||
:class="{ 'text-center': isMobile }"
|
||||
>
|
||||
<ul class="list-disc list-outside ml-3">
|
||||
<template v-if="isMobile">
|
||||
{{ t('tap_on_the_field_type_above_to_start_drawing_the_field') }}
|
||||
</template>
|
||||
<ul
|
||||
v-else
|
||||
class="list-disc list-outside ml-3"
|
||||
>
|
||||
<li>
|
||||
{{ t('draw_a_text_field_on_the_page_with_a_mouse') }}
|
||||
</li>
|
||||
@@ -355,24 +366,16 @@
|
||||
width="22"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<span
|
||||
v-if="analyzingProgress"
|
||||
class="hidden md:inline"
|
||||
>
|
||||
<span v-if="analyzingProgress">
|
||||
{{ Math.round(analyzingProgress * 100) }}% {{ t('analyzing_') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="hidden md:inline"
|
||||
>
|
||||
<span v-else>
|
||||
{{ fieldPagesLoaded }} / {{ numberOfPages }} {{ t('processing_') }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<IconSparkles width="22" />
|
||||
<span
|
||||
class="hidden md:inline"
|
||||
>
|
||||
<span>
|
||||
{{ t('autodetect_fields') }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -395,6 +398,26 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isMobile && withAddFieldsPill"
|
||||
class="sticky bottom-4 h-0 z-[5]"
|
||||
>
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="translate-y-6 opacity-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-to-class="translate-y-6 opacity-0"
|
||||
>
|
||||
<button
|
||||
v-if="isShowAddFieldsPill"
|
||||
class="absolute bottom-0 left-0 right-0 mx-auto w-fit btn btn-neutral btn-sm rounded-full normal-case font-normal text-white gap-1 px-4 shadow"
|
||||
@click.prevent="scrollToFieldTypes"
|
||||
>
|
||||
<IconPlus class="w-4 h-4" />
|
||||
{{ t('add_fields') }}
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -403,13 +426,14 @@ import CustomField from './custom_field'
|
||||
import FieldType from './field_type'
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles, IconBracketsContain } from '@tabler/icons-vue'
|
||||
import { IconLock, IconCirclePlus, IconPlus, IconInnerShadowTop, IconSparkles, IconBracketsContain } from '@tabler/icons-vue'
|
||||
import IconDrag from './icon_drag'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
export default {
|
||||
name: 'TemplateFields',
|
||||
components: {
|
||||
IconPlus,
|
||||
Field,
|
||||
CustomField,
|
||||
FieldType,
|
||||
@@ -422,7 +446,7 @@ export default {
|
||||
IconBracketsContain,
|
||||
DynamicVariables: defineAsyncComponent(() => import(/* webpackChunkName: "dynamic-editor" */ './dynamic_variables'))
|
||||
},
|
||||
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'baseFetch', 'selectedAreasRef', 'getFieldTypeIndex'],
|
||||
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 'nativePlatform', 't', 'isMobile', 'fieldsDragFieldRef', 'customDragFieldRef', 'baseFetch', 'selectedAreasRef', 'getFieldTypeIndex'],
|
||||
props: {
|
||||
fields: {
|
||||
type: Array,
|
||||
@@ -482,6 +506,16 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
scrollOnEdit: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withAddFieldsPill: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
defaultFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -512,6 +546,11 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withTopPadding: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
fieldTypes: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -539,6 +578,7 @@ export default {
|
||||
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter', 'rebuild-variables-schema', 'remove-field', 'remove-submitter'],
|
||||
data () {
|
||||
return {
|
||||
isShowAddFieldsPill: false,
|
||||
fieldPagesLoaded: null,
|
||||
analyzingProgress: 0,
|
||||
newCustomField: null,
|
||||
@@ -622,8 +662,22 @@ export default {
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
if (this.isMobile) {
|
||||
this.fieldTypesObserver = new IntersectionObserver(([entry]) => {
|
||||
this.isShowAddFieldsPill = !entry.isIntersecting && entry.boundingClientRect.top > 0
|
||||
}, { rootMargin: '0px 100%' })
|
||||
|
||||
this.fieldTypesObserver.observe(this.$refs.addFieldsMarker)
|
||||
}
|
||||
},
|
||||
unmounted () {
|
||||
this.fieldTypesObserver?.disconnect()
|
||||
},
|
||||
methods: {
|
||||
scrollToFieldTypes () {
|
||||
this.$refs.addFieldsMarker.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
||||
},
|
||||
toggleVariables () {
|
||||
this.$emit('rebuild-variables-schema')
|
||||
this.isShowVariables = !this.isShowVariables
|
||||
@@ -692,6 +746,10 @@ export default {
|
||||
const fields = await resp.json()
|
||||
|
||||
this.customFields.splice(0, this.customFields.length, ...fields)
|
||||
|
||||
if (this.nativePlatform) {
|
||||
window.webkit?.messageHandlers?.modal?.postMessage({ action: 'dispatch', name: 'template-builder:sync-custom-fields', detail: JSON.stringify(fields), dismiss: 'false' })
|
||||
}
|
||||
})
|
||||
},
|
||||
buildExistingFields () {
|
||||
@@ -823,6 +881,10 @@ export default {
|
||||
|
||||
this.save()
|
||||
|
||||
if (this.nativePlatform) {
|
||||
window.webkit?.messageHandlers?.flash?.postMessage({ style: 'notice', message: this.t('fields_detected').replace('{count}', (data.fields || fields).length) })
|
||||
}
|
||||
|
||||
break
|
||||
} else if (data.fields) {
|
||||
data.fields.forEach((f) => {
|
||||
@@ -878,19 +940,58 @@ export default {
|
||||
},
|
||||
onFieldDragover (e) {
|
||||
if (this.fieldsDragFieldRef.value) {
|
||||
const targetField = e.target.closest('[data-uuid]')
|
||||
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.fieldsDragFieldRef.value.uuid}"]`)
|
||||
this.moveFieldElement(this.fieldsDragFieldRef.value, e.target.closest('[data-uuid]'))
|
||||
}
|
||||
},
|
||||
onTouchDrag ({ field, x, y }) {
|
||||
const dragField = this.$refs.fields.querySelector(`[data-uuid="${field.uuid}"]`)
|
||||
|
||||
if (dragField && targetField && targetField !== dragField) {
|
||||
const fields = Array.from(this.$refs.fields.children)
|
||||
const currentIndex = fields.indexOf(dragField)
|
||||
const targetIndex = fields.indexOf(targetField)
|
||||
if (!dragField) return
|
||||
|
||||
if (currentIndex < targetIndex) {
|
||||
targetField.after(dragField)
|
||||
} else {
|
||||
targetField.before(dragField)
|
||||
}
|
||||
if (!this.touchPreview) {
|
||||
const rect = dragField.getBoundingClientRect()
|
||||
const el = dragField.cloneNode(true)
|
||||
const root = this.$el.getRootNode()
|
||||
|
||||
Object.assign(el.style, { position: 'fixed', left: `${rect.left}px`, top: `${rect.top}px`, width: `${rect.width}px`, margin: 0, zIndex: 50, pointerEvents: 'none', opacity: 0.9 })
|
||||
|
||||
;(root === document ? document.body : root).appendChild(el)
|
||||
|
||||
dragField.style.opacity = 0.4
|
||||
|
||||
this.touchPreview = { el, dragField, offsetY: y - rect.top }
|
||||
}
|
||||
|
||||
this.touchPreview.el.style.top = `${y - this.touchPreview.offsetY}px`
|
||||
|
||||
const root = this.$el.getRootNode()
|
||||
const targetField = (root.elementFromPoint ? root : document).elementFromPoint(x, y)?.closest('[data-uuid]')
|
||||
|
||||
if (targetField && this.$refs.fields.contains(targetField)) {
|
||||
this.moveFieldElement(field, targetField)
|
||||
}
|
||||
},
|
||||
onTouchDragEnd () {
|
||||
if (this.touchPreview) {
|
||||
this.touchPreview.el.remove()
|
||||
this.touchPreview.dragField.style.opacity = ''
|
||||
this.touchPreview = null
|
||||
}
|
||||
|
||||
this.reorderFields()
|
||||
},
|
||||
moveFieldElement (field, targetField) {
|
||||
const dragField = this.$refs.fields.querySelector(`[data-uuid="${field.uuid}"]`)
|
||||
|
||||
if (dragField && targetField && targetField !== dragField) {
|
||||
const fields = Array.from(this.$refs.fields.children)
|
||||
const currentIndex = fields.indexOf(dragField)
|
||||
const targetIndex = fields.indexOf(targetField)
|
||||
|
||||
if (currentIndex < targetIndex) {
|
||||
targetField.after(dragField)
|
||||
} else {
|
||||
targetField.before(dragField)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
{{ t('font') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
|
||||
</span>
|
||||
@@ -19,7 +23,7 @@
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div>
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span>
|
||||
<div class="dropdown modal-field-font-dropdown">
|
||||
<label
|
||||
@@ -168,7 +172,7 @@
|
||||
:class="textClasses"
|
||||
>
|
||||
<span
|
||||
contenteditable="true"
|
||||
:contenteditable="!inline"
|
||||
class="outline-none whitespace-nowrap truncate"
|
||||
>
|
||||
{{ field.default_value || field.name || buildDefaultName(field) }}
|
||||
@@ -215,6 +219,11 @@ export default {
|
||||
buildDefaultName: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['close', 'save'],
|
||||
|
||||
@@ -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">
|
||||
{{ t('formula') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
|
||||
</span>
|
||||
@@ -67,8 +71,22 @@
|
||||
>
|
||||
<div
|
||||
target="blank"
|
||||
class="text-sm mb-2 inline space-x-2 font-mono"
|
||||
class="text-sm mb-2 flex flex-wrap gap-x-2 gap-y-1 font-mono"
|
||||
>
|
||||
<template v-if="withDateFunctions">
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor('today()')"
|
||||
>
|
||||
today()
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor('datedif(, , "y")', 8)"
|
||||
>
|
||||
datedif(start, end, "y")
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor(' + ')"
|
||||
@@ -101,13 +119,13 @@
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor('round()')"
|
||||
@click="insertTextUnderCursor('round()', 6)"
|
||||
>
|
||||
round(n, d)
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor('abs()')"
|
||||
@click="insertTextUnderCursor('abs()', 4)"
|
||||
>
|
||||
abs(n)
|
||||
</button>
|
||||
@@ -153,6 +171,11 @@ export default {
|
||||
buildDefaultName: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['close', 'save'],
|
||||
@@ -162,6 +185,11 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
withDateFunctions () {
|
||||
return this.field.type === 'date' || [...this.formula.matchAll(/{{(.*?)}}/g)].some(([, name]) => {
|
||||
return this.template.fields.some((f) => f.type === 'date' && (f.name || this.buildDefaultName(f)).trim() === name.trim())
|
||||
})
|
||||
},
|
||||
fields () {
|
||||
return this.template.fields.reduce((acc, f) => {
|
||||
const isAllowed = this.field.type === 'text' ? this.isTextField(f) : this.isNumberField(f)
|
||||
@@ -182,7 +210,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
isNumberField (field) {
|
||||
return field.type === 'number' || (['radio', 'select'].includes(field.type) && field.options?.every((o) => String(o.value).match(/^[\d.-]+$/)))
|
||||
return ['number', 'date'].includes(field.type) || (['radio', 'select'].includes(field.type) && field.options?.every((o) => String(o.value).match(/^[\d.-]+$/)))
|
||||
},
|
||||
isTextField (field) {
|
||||
return ['text', 'number', 'select', 'radio', 'cells', 'phone'].includes(field.type)
|
||||
@@ -235,18 +263,19 @@ export default {
|
||||
this.$emit('close')
|
||||
}
|
||||
},
|
||||
insertTextUnderCursor (textToInsert) {
|
||||
insertTextUnderCursor (textToInsert, cursorOffset = textToInsert.length) {
|
||||
const textarea = this.$refs.textarea
|
||||
|
||||
const selectionEnd = textarea.selectionEnd
|
||||
const cursorPos = selectionEnd
|
||||
const { selectionStart, selectionEnd } = textarea
|
||||
const selectedText = cursorOffset < textToInsert.length ? textarea.value.substring(selectionStart, selectionEnd) : ''
|
||||
const cursorPos = selectedText ? selectionStart : selectionEnd
|
||||
|
||||
const newText = textarea.value.substring(0, cursorPos) + textToInsert + textarea.value.substring(cursorPos)
|
||||
const newText = textarea.value.substring(0, cursorPos) + textToInsert.slice(0, cursorOffset) + selectedText + textToInsert.slice(cursorOffset) + textarea.value.substring(selectionEnd)
|
||||
|
||||
this.formula = newText
|
||||
|
||||
this.$nextTick(() => {
|
||||
textarea.setSelectionRange(cursorPos + textToInsert.length, cursorPos + textToInsert.length)
|
||||
textarea.setSelectionRange(cursorPos + cursorOffset + selectedText.length, cursorPos + cursorOffset + selectedText.length)
|
||||
|
||||
textarea.focus()
|
||||
})
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
const en = {
|
||||
name: 'Name',
|
||||
fixed: 'Fixed',
|
||||
default: 'Default',
|
||||
save_as_custom_field: 'Save as custom field',
|
||||
kba: 'KBA',
|
||||
field_has_already_been_added_for_party: '{field} field has already been added for {party}',
|
||||
analyzing_: 'Analyzing...',
|
||||
download: 'Download',
|
||||
downloading_: 'Downloading...',
|
||||
view: 'View',
|
||||
autodetect_fields: 'Autodetect Fields',
|
||||
fields_detected: '{count} fields detected',
|
||||
payment_link: 'Payment link',
|
||||
strikeout: 'Strikeout',
|
||||
draw_strikethrough_the_document: 'Draw strikethrough the document',
|
||||
@@ -97,8 +100,11 @@ const en = {
|
||||
add_documents_or_images: 'Add documents or images',
|
||||
add_a_new_document: 'Add a new document',
|
||||
edit_documents: 'Edit documents',
|
||||
documents: 'Documents',
|
||||
move_forward: 'Move forward',
|
||||
move_backward: 'Move backward',
|
||||
backward: 'Backward',
|
||||
forward: 'Forward',
|
||||
remove_page: 'Remove page',
|
||||
merge_with_previous: 'Merge with previous',
|
||||
merge_with_next: 'Merge with next',
|
||||
@@ -185,6 +191,7 @@ const en = {
|
||||
draw_a_text_field_on_the_page_with_a_mouse: 'Draw a text field on the page with a mouse',
|
||||
drag_and_drop_any_other_field_type_on_the_page: 'Drag & drop any other field type on the page',
|
||||
click_on_the_field_type_above_to_start_drawing_it: 'Click on the field type above to start drawing it',
|
||||
tap_on_the_field_type_above_to_start_drawing_the_field: 'Tap a field type above to draw it on the page',
|
||||
please_draw_fields_to_prepare_the_document: 'Please draw fields to prepare the document.',
|
||||
only_pdf_and_images_are_supported: 'Only PDF and images are supported.',
|
||||
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification.',
|
||||
@@ -195,6 +202,8 @@ const en = {
|
||||
replace: 'Replace',
|
||||
uploading_: 'Uploading...',
|
||||
add_document: 'Add Document',
|
||||
add_fields: 'Add Fields',
|
||||
fields: 'Fields',
|
||||
none: 'None',
|
||||
ssn: 'SSN',
|
||||
ein: 'EIN',
|
||||
@@ -243,11 +252,14 @@ const en = {
|
||||
}
|
||||
|
||||
const es = {
|
||||
name: 'Nombre',
|
||||
fixed: 'Fijo',
|
||||
default: 'Predeterminado',
|
||||
save_as_custom_field: 'Guardar como personalizado',
|
||||
kba: 'KBA',
|
||||
field_has_already_been_added_for_party: 'El campo {field} ya se ha agregado para {party}',
|
||||
autodetect_fields: 'Autodetectar campos',
|
||||
fields_detected: '{count} campos detectados',
|
||||
analyzing_: 'Analizando...',
|
||||
download: 'Descargar',
|
||||
downloading_: 'Descargando...',
|
||||
@@ -338,8 +350,11 @@ const es = {
|
||||
add_documents_or_images: 'Agregar documentos o imágenes',
|
||||
add_a_new_document: 'Agregar un nuevo documento',
|
||||
edit_documents: 'Editar documentos',
|
||||
documents: 'Documentos',
|
||||
move_forward: 'Mover adelante',
|
||||
move_backward: 'Mover atrás',
|
||||
backward: 'Atrás',
|
||||
forward: 'Adelante',
|
||||
remove_page: 'Eliminar página',
|
||||
merge_with_previous: 'Combinar con el anterior',
|
||||
merge_with_next: 'Combinar con el siguiente',
|
||||
@@ -423,6 +438,7 @@ const es = {
|
||||
draw_a_text_field_on_the_page_with_a_mouse: 'Dibujar un campo de texto en la página con el mouse',
|
||||
drag_and_drop_any_other_field_type_on_the_page: 'Arrastra y suelta cualquier otro tipo de campo en la página',
|
||||
click_on_the_field_type_above_to_start_drawing_it: 'Haz clic en el tipo de campo de arriba para comenzar a dibujarlo',
|
||||
tap_on_the_field_type_above_to_start_drawing_the_field: 'Toca un tipo de campo de arriba para dibujarlo en la página',
|
||||
please_draw_fields_to_prepare_the_document: 'Por favor, dibuja los campos para preparar el documento.',
|
||||
only_pdf_and_images_are_supported: 'Solo se admiten PDF e imágenes.',
|
||||
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Desbloquea el campo de número de teléfono verificado por SMS con un plan pago. Usa el campo de texto para números de teléfono sin verificación.',
|
||||
@@ -433,6 +449,8 @@ const es = {
|
||||
replace: 'Reemplazar',
|
||||
uploading_: 'Subiendo...',
|
||||
add_document: 'Subir',
|
||||
add_fields: 'Agregar campos',
|
||||
fields: 'Campos',
|
||||
any: 'Cualquier',
|
||||
drawn: 'Dibujado',
|
||||
drawn_or_typed: 'Dibujado o Escrito',
|
||||
@@ -487,11 +505,14 @@ const es = {
|
||||
}
|
||||
|
||||
const it = {
|
||||
name: 'Nome',
|
||||
fixed: 'Fisso',
|
||||
default: 'Predefinito',
|
||||
save_as_custom_field: 'Salva come personalizzato',
|
||||
kba: 'KBA',
|
||||
field_has_already_been_added_for_party: 'Il campo {field} è già stato aggiunto per {party}',
|
||||
autodetect_fields: 'Rileva campi',
|
||||
fields_detected: '{count} campi rilevati',
|
||||
analyzing_: 'Analisi...',
|
||||
download: 'Scarica',
|
||||
downloading_: 'Download in corso...',
|
||||
@@ -585,8 +606,11 @@ const it = {
|
||||
add_documents_or_images: 'Aggiungi documenti o immagini',
|
||||
add_a_new_document: 'Aggiungi un nuovo documento',
|
||||
edit_documents: 'Modifica documenti',
|
||||
documents: 'Documenti',
|
||||
move_forward: 'Sposta avanti',
|
||||
move_backward: 'Sposta indietro',
|
||||
backward: 'Indietro',
|
||||
forward: 'Avanti',
|
||||
remove_page: 'Rimuovi pagina',
|
||||
merge_with_previous: 'Unisci al precedente',
|
||||
merge_with_next: 'Unisci al successivo',
|
||||
@@ -673,6 +697,7 @@ const it = {
|
||||
draw_a_text_field_on_the_page_with_a_mouse: 'Disegna un campo di testo sulla pagina con il mouse',
|
||||
drag_and_drop_any_other_field_type_on_the_page: 'Trascina e rilascia qualsiasi altro tipo di campo sulla pagina',
|
||||
click_on_the_field_type_above_to_start_drawing_it: 'Clicca sul tipo di campo sopra per iniziare a disegnarlo',
|
||||
tap_on_the_field_type_above_to_start_drawing_the_field: 'Tocca un tipo di campo sopra per disegnarlo sulla pagina',
|
||||
please_draw_fields_to_prepare_the_document: 'Per favore, disegna i campi per preparare il documento.',
|
||||
only_pdf_and_images_are_supported: 'Sono supportati solo PDF e immagini.',
|
||||
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Sblocca il campo numero di telefono verificato tramite SMS con un piano a pagamento. Usa il campo di testo per numeri di telefono senza verifica.',
|
||||
@@ -683,6 +708,8 @@ const it = {
|
||||
replace: 'Sostituisci',
|
||||
uploading_: 'Caricamento in corso...',
|
||||
add_document: 'Aggiungi',
|
||||
add_fields: 'Aggiungi campi',
|
||||
fields: 'Campi',
|
||||
none: 'Nessuno',
|
||||
ssn: 'SSN',
|
||||
ein: 'EIN',
|
||||
@@ -731,11 +758,14 @@ const it = {
|
||||
}
|
||||
|
||||
const pt = {
|
||||
name: 'Nome',
|
||||
fixed: 'Fixo',
|
||||
default: 'Padrão',
|
||||
save_as_custom_field: 'Salvar como personalizado',
|
||||
kba: 'KBA',
|
||||
field_has_already_been_added_for_party: 'O campo {field} já foi adicionado para {party}',
|
||||
autodetect_fields: 'Detectar campos',
|
||||
fields_detected: '{count} campos detectados',
|
||||
analyzing_: 'Analisando...',
|
||||
download: 'Baixar',
|
||||
downloading_: 'Baixando...',
|
||||
@@ -826,8 +856,11 @@ const pt = {
|
||||
add_documents_or_images: 'Adicionar documentos ou imagens',
|
||||
add_a_new_document: 'Adicionar um novo documento',
|
||||
edit_documents: 'Editar documentos',
|
||||
documents: 'Documentos',
|
||||
move_forward: 'Mover para frente',
|
||||
move_backward: 'Mover para trás',
|
||||
backward: 'Para trás',
|
||||
forward: 'Para frente',
|
||||
remove_page: 'Remover página',
|
||||
merge_with_previous: 'Mesclar com o anterior',
|
||||
merge_with_next: 'Mesclar com o próximo',
|
||||
@@ -911,6 +944,7 @@ const pt = {
|
||||
draw_a_text_field_on_the_page_with_a_mouse: 'Desenhar um campo de texto na página com o mouse',
|
||||
drag_and_drop_any_other_field_type_on_the_page: 'Arraste e solte qualquer outro tipo de campo na página',
|
||||
click_on_the_field_type_above_to_start_drawing_it: 'Clique no tipo de campo acima para começar a desenhá-lo',
|
||||
tap_on_the_field_type_above_to_start_drawing_the_field: 'Toque em um tipo de campo acima para desenhá-lo na página',
|
||||
please_draw_fields_to_prepare_the_document: 'Por favor, desenhe os campos para preparar o documento.',
|
||||
only_pdf_and_images_are_supported: 'Apenas PDFs e imagens são suportados.',
|
||||
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Desbloqueie o campo de número de telefone verificado por SMS com um plano pago. Use o campo de texto para números de telefone sem verificação.',
|
||||
@@ -921,6 +955,8 @@ const pt = {
|
||||
replace: 'Substituir',
|
||||
uploading_: 'Carregando...',
|
||||
add_document: 'Enviar',
|
||||
add_fields: 'Adicionar campos',
|
||||
fields: 'Campos',
|
||||
any: 'Qualquer',
|
||||
drawn: 'Desenhado',
|
||||
drawn_or_typed: 'Desenhado ou Digitado',
|
||||
@@ -975,11 +1011,14 @@ const pt = {
|
||||
}
|
||||
|
||||
const fr = {
|
||||
name: 'Nom',
|
||||
fixed: 'Fixe',
|
||||
default: 'Par défaut',
|
||||
save_as_custom_field: 'Enregistrer comme personnalisé',
|
||||
kba: 'KBA',
|
||||
field_has_already_been_added_for_party: 'Le champ {field} a déjà été ajouté pour {party}',
|
||||
autodetect_fields: 'Détecter les champs',
|
||||
fields_detected: '{count} champs détectés',
|
||||
analyzing_: 'Analyse...',
|
||||
download: 'Télécharger',
|
||||
downloading_: 'Téléchargement...',
|
||||
@@ -1073,8 +1112,11 @@ const fr = {
|
||||
add_documents_or_images: 'Ajouter des documents ou des images',
|
||||
add_a_new_document: 'Ajouter un nouveau document',
|
||||
edit_documents: 'Modifier les documents',
|
||||
documents: 'Documents',
|
||||
move_forward: 'Déplacer en avant',
|
||||
move_backward: 'Déplacer en arrière',
|
||||
backward: 'Reculer',
|
||||
forward: 'Avancer',
|
||||
remove_page: 'Supprimer la page',
|
||||
merge_with_previous: 'Fusionner avec le précédent',
|
||||
merge_with_next: 'Fusionner avec le suivant',
|
||||
@@ -1161,6 +1203,7 @@ const fr = {
|
||||
draw_a_text_field_on_the_page_with_a_mouse: 'Dessinez un champ de texte sur la page avec la souris',
|
||||
drag_and_drop_any_other_field_type_on_the_page: 'Glissez-déposez tout autre type de champ sur la page',
|
||||
click_on_the_field_type_above_to_start_drawing_it: 'Cliquez sur le type de champ ci-dessus pour commencer à le dessiner',
|
||||
tap_on_the_field_type_above_to_start_drawing_the_field: 'Appuyez sur un type de champ ci-dessus pour le dessiner sur la page',
|
||||
please_draw_fields_to_prepare_the_document: 'Veuillez dessiner des champs pour préparer le document.',
|
||||
only_pdf_and_images_are_supported: 'Seuls les PDF et les images sont pris en charge.',
|
||||
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Débloquez le champ de numéro de téléphone vérifié par SMS avec un forfait payant. Utilisez un champ texte pour les numéros sans vérification.',
|
||||
@@ -1171,6 +1214,8 @@ const fr = {
|
||||
replace: 'Remplacer',
|
||||
uploading_: 'Téléversement...',
|
||||
add_document: 'Téléverser',
|
||||
add_fields: 'Ajouter des champs',
|
||||
fields: 'Champs',
|
||||
none: 'Aucun',
|
||||
ssn: 'SSN',
|
||||
ein: 'EIN',
|
||||
@@ -1219,11 +1264,14 @@ const fr = {
|
||||
}
|
||||
|
||||
const de = {
|
||||
name: 'Name',
|
||||
fixed: 'Fest',
|
||||
default: 'Standard',
|
||||
save_as_custom_field: 'Als benutzerdefiniert speichern',
|
||||
kba: 'KBA',
|
||||
field_has_already_been_added_for_party: '{field}-Feld wurde bereits für {party} hinzugefügt',
|
||||
autodetect_fields: 'Felder erkennen',
|
||||
fields_detected: '{count} Felder erkannt',
|
||||
analyzing_: 'Analysiere...',
|
||||
download: 'Download',
|
||||
downloading_: 'Download...',
|
||||
@@ -1317,8 +1365,11 @@ const de = {
|
||||
add_documents_or_images: 'Dokumente oder Bilder hinzufügen',
|
||||
add_a_new_document: 'Neues Dokument hinzufügen',
|
||||
edit_documents: 'Dokumente bearbeiten',
|
||||
documents: 'Dokumente',
|
||||
move_forward: 'Nach vorne',
|
||||
move_backward: 'Nach hinten',
|
||||
backward: 'Zurück',
|
||||
forward: 'Vorwärts',
|
||||
remove_page: 'Seite entfernen',
|
||||
merge_with_previous: 'Mit vorherigem zusammenführen',
|
||||
merge_with_next: 'Mit nächstem zusammenführen',
|
||||
@@ -1405,6 +1456,7 @@ const de = {
|
||||
draw_a_text_field_on_the_page_with_a_mouse: 'Ein Textfeld mit der Maus auf der Seite zeichnen',
|
||||
drag_and_drop_any_other_field_type_on_the_page: 'Jeden anderen Feldtyp auf die Seite ziehen und ablegen',
|
||||
click_on_the_field_type_above_to_start_drawing_it: 'Klicken Sie oben auf den Feldtyp, um mit dem Zeichnen zu beginnen',
|
||||
tap_on_the_field_type_above_to_start_drawing_the_field: 'Tippen Sie oben auf einen Feldtyp, um ihn auf der Seite zu zeichnen',
|
||||
please_draw_fields_to_prepare_the_document: 'Bitte zeichnen Sie Felder, um das Dokument vorzubereiten.',
|
||||
only_pdf_and_images_are_supported: 'Nur PDF und Bilder werden unterstützt.',
|
||||
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'SMS-verifiziertes Telefonnummernfeld mit kostenpflichtigem Plan freischalten. Verwenden Sie für Telefonnummern ohne Verifizierung das Textfeld.',
|
||||
@@ -1415,6 +1467,8 @@ const de = {
|
||||
replace: 'Ersetzen',
|
||||
uploading_: 'Hochladen...',
|
||||
add_document: 'Hochladen',
|
||||
add_fields: 'Felder hinzufügen',
|
||||
fields: 'Felder',
|
||||
none: 'Keine',
|
||||
ssn: 'SSN',
|
||||
ein: 'EIN',
|
||||
@@ -1463,11 +1517,14 @@ const de = {
|
||||
}
|
||||
|
||||
const nl = {
|
||||
name: 'Naam',
|
||||
fixed: 'Vast',
|
||||
default: 'Standaard',
|
||||
save_as_custom_field: 'Opslaan als aangepast',
|
||||
kba: 'KBA',
|
||||
field_has_already_been_added_for_party: '{field}-veld is al toegevoegd voor {party}',
|
||||
autodetect_fields: 'Velden detecteren',
|
||||
fields_detected: '{count} velden gedetecteerd',
|
||||
analyzing_: 'Analyseren...',
|
||||
download: 'Downloaden',
|
||||
downloading_: 'Downloaden...',
|
||||
@@ -1561,8 +1618,11 @@ const nl = {
|
||||
add_documents_or_images: 'Documenten of afbeeldingen toevoegen',
|
||||
add_a_new_document: 'Nieuw document toevoegen',
|
||||
edit_documents: 'Documenten bewerken',
|
||||
documents: 'Documenten',
|
||||
move_forward: 'Naar voren',
|
||||
move_backward: 'Naar achteren',
|
||||
backward: 'Achteruit',
|
||||
forward: 'Vooruit',
|
||||
remove_page: 'Pagina verwijderen',
|
||||
merge_with_previous: 'Samenvoegen met vorige',
|
||||
merge_with_next: 'Samenvoegen met volgende',
|
||||
@@ -1649,6 +1709,7 @@ const nl = {
|
||||
draw_a_text_field_on_the_page_with_a_mouse: 'Teken met de muis een tekstveld op de pagina',
|
||||
drag_and_drop_any_other_field_type_on_the_page: 'Sleep elk ander veldtype naar de pagina',
|
||||
click_on_the_field_type_above_to_start_drawing_it: 'Klik op het bovenstaande veldtype om te beginnen met tekenen',
|
||||
tap_on_the_field_type_above_to_start_drawing_the_field: 'Tik op een veldtype hierboven om het op de pagina te tekenen',
|
||||
please_draw_fields_to_prepare_the_document: 'Teken velden om het document voor te bereiden.',
|
||||
only_pdf_and_images_are_supported: 'Alleen PDF en afbeeldingen worden ondersteund.',
|
||||
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Ontgrendel het SMS-geverifieerde veld voor telefoonnummer met een betaald abonnement. Gebruik het tekstveld voor telefoonnummers zonder verificatie.',
|
||||
@@ -1659,6 +1720,8 @@ const nl = {
|
||||
replace: 'Vervangen',
|
||||
uploading_: 'Bezig met uploaden...',
|
||||
add_document: 'Doc. toevoegen',
|
||||
add_fields: 'Velden toevoegen',
|
||||
fields: 'Velden',
|
||||
none: 'Geen',
|
||||
ssn: 'SSN',
|
||||
ein: 'EIN',
|
||||
|
||||
Loaded 100 of 229 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user