Merge from docusealco/wip

This commit is contained in:
Alex Turchyn authored and GitHub committed 2026-09-28 14:39:31 +03:00
commit 44dcc7f312
229 files changed
+6179 -2513

No files matched your search

+5
View File
@@ -1,6 +1,11 @@
--- ---
EnableDefaultLinters: true EnableDefaultLinters: true
linters: linters:
AllowedScriptType:
enabled: true
allowed_types:
- text/javascript
- application/json
ErbSafety: ErbSafety:
enabled: false enabled: false
HardCodedString: HardCodedString:
@@ -173,7 +173,6 @@ module Api
end end
def filter_submissions(submissions, params) 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? submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
if params[:template_folder].present? if params[:template_folder].present?
@@ -1,10 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
module Api module Api
class SubmitterEmailClicksController < ApiBaseController class SubmitterEmailClicksController < ActionController::API
skip_before_action :authenticate_user!
skip_authorization_check
def create def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug]) @submitter = Submitter.find_by!(slug: params[:submitter_slug])
@@ -1,10 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
module Api module Api
class SubmitterFormViewsController < ApiBaseController class SubmitterFormViewsController < ActionController::API
skip_before_action :authenticate_user!
skip_authorization_check
def create def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug]) @submitter = Submitter.find_by!(slug: params[:submitter_slug])
+28 -2
View File
@@ -13,13 +13,17 @@ class ApplicationController < ActionController::Base
before_action :maybe_redirect_to_setup, unless: :signed_in? before_action :maybe_redirect_to_setup, unless: :signed_in?
before_action :authenticate_user!, unless: :devise_controller? 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, helper_method :button_title,
:current_account, :current_account,
:true_ability, :true_ability,
:form_link_host, :form_link_host,
:svg_icon :svg_icon,
:turbo_native_ios?,
:turbo_native_android?
impersonates :user, with: ->(uuid) { User.find_by(uuid:) } impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
@@ -64,6 +68,28 @@ class ApplicationController < ActionController::Base
private 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(&) def with_locale(&)
return yield unless current_account return yield unless current_account
-22
View File
@@ -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
+1 -1
View File
@@ -33,7 +33,7 @@ class MfaSetupController < ApplicationController
def destroy def destroy
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true) 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) current_user.update!(otp_required_for_login: false, otp_secret: nil)
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed') redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed')
+132
View File
@@ -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
+21 -3
View File
@@ -3,6 +3,10 @@
class SessionsController < Devise::SessionsController class SessionsController < Devise::SessionsController
before_action :configure_permitted_parameters 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 around_action :with_browser_locale
def create def create
@@ -11,12 +15,26 @@ class SessionsController < Devise::SessionsController
if Docuseal.multitenant? && !User.exists?(email:) if Docuseal.multitenant? && !User.exists?(email:)
Rollbar.warning('Sign in new user') if defined?(Rollbar) Rollbar.warning('Sign in new user') if defined?(Rollbar)
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)), respond_to do |format|
notice: I18n.t('create_a_new_account') 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 end
if User.exists?(email:, otp_required_for_login: true) && sign_in_params[:otp_attempt].blank? 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 end
super super
@@ -29,5 +29,17 @@ class SubmissionsArchivedController < ApplicationController
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template), ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
associations: :author).call 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
end end
+18 -1
View File
@@ -18,6 +18,16 @@ class SubmissionsController < ApplicationController
'payment' => 'credit_card', 'verification' => 'id', 'kba' => 'user_scan' 'payment' => 'credit_card', 'verification' => 'id', 'kba' => 'user_scan'
}.freeze }.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 def show
@submission = Submissions.preload_with_pages(@submission) @submission = Submissions.preload_with_pages(@submission)
@@ -83,7 +93,14 @@ class SubmissionsController < ApplicationController
I18n.t('submission_has_been_archived') I18n.t('submission_has_been_archived')
end 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 end
private private
@@ -30,5 +30,17 @@ class SubmissionsDashboardController < ApplicationController
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template), ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
associations: :author).call 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
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) Submissions::EnsureResultGenerated.call(last_submitter)
if !signature_valid && !current_user_submitter?(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 if last_submitter.completed_at < TTL.ago
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar) Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
+11 -13
View File
@@ -31,24 +31,22 @@ class SubmitFormController < ApplicationController
Submissions.preload_with_pages(submission) Submissions.preload_with_pages(submission)
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
@attachments_index = build_attachments_index(submission) @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] return unless @form_configs[:prefill_signature]
if (user_signature = UserConfigs.load_signature(current_user)) signature = UserConfigs.load_signature(current_user) ||
@signature_attachment = ActiveStorage::Attachment.find_or_create_by!( Submitters::FindRememberedSignature.call(@submitter, params, cookies)
blob_id: user_signature.blob_id,
name: 'attachments',
record: @submitter
)
end
@signature_attachment ||= @prefill_signature = Submitters.build_prefill_attachment_data(@submitter, signature) if signature
Submitters::MaybeAssignDefaultBrowserSignature.call(@submitter, params, cookies, @attachments_index.values)
@attachments_index[@signature_attachment.uuid] = @signature_attachment if @signature_attachment
end end
def update def update
@@ -15,7 +15,8 @@ class SubmitFormDelegateController < ApplicationController
@submitter.viewer? || @submitter.viewer? ||
!Submitters::AuthorizedForForm.call(@submitter, !Submitters::AuthorizedForForm.call(@submitter,
current_user, current_user,
request) request,
with_order: true)
@submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: 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, AccountConfig.exists?(account_id: @submitter.account_id,
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY, key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
value: false) || 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 last_completed_submitter = @submitter.submission.submitters
.where.not(id: @submitter.id) .where.not(id: @submitter.id)
@@ -15,7 +15,7 @@ class SubmitFormDrawSignatureController < ApplicationController
return redirect_to submit_form_path(@submitter.slug) if @submitter.viewer? return redirect_to submit_form_path(@submitter.slug) if @submitter.viewer?
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? || 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) return redirect_to submit_form_path(@submitter.slug)
end end
@@ -10,6 +10,8 @@ class SubmitFormEmail2fasController < ApplicationController
def create def create
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true) 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(':') value = [@submitter.email.downcase.strip, @submitter.slug].join(':')
@@ -39,6 +39,6 @@ class SubmitFormMetadataController < ApplicationController
!submitter.submission.expired? && !submitter.submission.expired? &&
!submitter.submission.template&.archived_at? && !submitter.submission.template&.archived_at? &&
!submitter.account.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
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
+24 -4
View File
@@ -13,10 +13,7 @@ class TemplateFoldersController < ApplicationController
.where(folder: [@template_folder, *(params[:q].present? ? @template_folder.subfolders : [])]) .where(folder: [@template_folder, *(params[:q].present? ? @template_folder.subfolders : [])])
.preload(:author, :template_accesses) .preload(:author, :template_accesses)
@template_folders = @template_folders = filter_folders(@template_folder)
@template_folder.subfolders.where(id: Template.accessible_by(current_ability).active.select(:folder_id))
@template_folders = TemplateFolders.search(@template_folders, params[:q])
@template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order) @template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
if @templates.exists? if @templates.exists?
@@ -40,6 +37,8 @@ class TemplateFoldersController < ApplicationController
@templates = @templates.none @templates = @templates.none
end end
render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll?
end end
def edit; end def edit; end
@@ -55,6 +54,27 @@ class TemplateFoldersController < ApplicationController
private 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 def selected_order
@selected_order ||= @selected_order ||=
if can?(:manage, :countless) if can?(:manage, :countless)
@@ -12,13 +12,23 @@ class TemplatesArchivedController < ApplicationController
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12) @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 end
private 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 def load_related_submissions
related_submissions = related_submissions =
Submission.accessible_by(current_ability) Submission.accessible_by(current_ability)
@@ -16,7 +16,19 @@ class TemplatesArchivedSubmissionsController < ApplicationController
end end
@pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events)) @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 rescue ActiveRecord::RecordNotFound
redirect_to root_path redirect_to root_path
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: true }),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
end end
+15 -1
View File
@@ -21,6 +21,8 @@ class TemplatesController < ApplicationController
@pagy, @submissions = @pagy, @submissions =
pagy_auto(submissions.select_for_list.preload(:template_accesses, submitters: :start_form_submission_events)) 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 rescue ActiveRecord::RecordNotFound
redirect_to root_path redirect_to root_path
end end
@@ -81,11 +83,23 @@ class TemplatesController < ApplicationController
I18n.t('template_has_been_archived') I18n.t('template_has_been_archived')
end 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 end
private 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 def template_params
params.require(:template).permit( params.require(:template).permit(
:name, :name,
@@ -43,10 +43,26 @@ class TemplatesDashboardController < ApplicationController
@related_submissions_pagy, @related_submissions = load_related_submissions @related_submissions_pagy, @related_submissions = load_related_submissions
end end
end end
render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll?
end end
private 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) def load_templates(templates, folders_count, show_shared_inline: false)
templates = templates.preload(:author, :template_accesses) 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) pinned_count = (show_default_in_grid ? 1 : 0) + (show_shared_in_grid ? 1 : 0)
pagy = Pagy::Offset.new(count: folders_count + pinned_count, pagy, = pagy(template_folders, count: folders_count + pinned_count,
page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max, page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max,
limit: FOLDERS_PER_PAGE, limit: FOLDERS_PER_PAGE,
raise_range_error: true) raise_range_error: true)
show_default_folder = show_default_in_grid && pagy.page == 1 show_default_folder = show_default_in_grid && pagy.page == 1
show_shared_folder = show_shared_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_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) template_folders = template_folders.offset(folder_offset).limit(folder_limit)
+11 -2
View File
@@ -19,13 +19,22 @@ class TemplatesSharedController < ApplicationController
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12) @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 end
private 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:) def load_related_submissions(is_archived:)
shared_templates = Templates.shared(current_user) shared_templates = Templates.shared(current_user)
shared_templates = is_archived ? shared_templates.archived : shared_templates.active shared_templates = is_archived ? shared_templates.archived : shared_templates.active
@@ -2,6 +2,8 @@
class TestingApiSettingsController < ApplicationController class TestingApiSettingsController < ApplicationController
def index def index
raise CanCan::AccessDenied unless current_account.testing?
authorize!(:manage, current_user.access_token) authorize!(:manage, current_user.access_token)
@webhook_url = current_account.webhook_urls.first_or_initialize @webhook_url = current_account.webhook_urls.first_or_initialize
+1 -1
View File
@@ -20,7 +20,7 @@ class UsersController < ApplicationController
respond_to do |format| respond_to do |format|
format.html do format.html do
@pagy, @users = pagy(@users) @pagy, @users = pagy(@users, limit: turbo_native_app? ? 1_000 : Pagy::OPTIONS[:limit])
end end
if current_ability.can?(:manage, current_account) if current_ability.can?(:manage, current_account)
+189 -38
View File
@@ -1,5 +1,5 @@
import '@hotwired/turbo' import '@hotwired/turbo-rails'
import { encodeMethodIntoRequestBody } from '@hotwired/turbo-rails/app/javascript/turbo/fetch_requests' import './native'
import { createApp, reactive } from 'vue' import { createApp, reactive } from 'vue'
import TemplateBuilder from './template_builder/builder' import TemplateBuilder from './template_builder/builder'
@@ -9,6 +9,15 @@ import ToggleVisible from './elements/toggle_visible'
import ToggleCookies from './elements/toggle_cookies' import ToggleCookies from './elements/toggle_cookies'
import DisableHidden from './elements/disable_hidden' import DisableHidden from './elements/disable_hidden'
import TurboModal from './elements/turbo_modal' 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 FileDropzone from './elements/file_dropzone'
import MenuActive from './elements/menu_active' import MenuActive from './elements/menu_active'
import ClipboardCopy from './elements/clipboard_copy' import ClipboardCopy from './elements/clipboard_copy'
@@ -59,10 +68,8 @@ import ConfirmUpload from './elements/confirm_upload'
import ScrollFade from './elements/scroll_fade' import ScrollFade from './elements/scroll_fade'
import OpenModalMobile from './elements/open_modal_mobile' import OpenModalMobile from './elements/open_modal_mobile'
import HistoryBack from './elements/history_back' import HistoryBack from './elements/history_back'
import DatePlaceholder from './elements/date_placeholder'
import * as TurboInstantClick from './lib/turbo_instant_click' import FlashMessage from './elements/flash_message'
TurboInstantClick.start()
document.addEventListener('turbo:before-cache', () => { document.addEventListener('turbo:before-cache', () => {
window.flash?.remove() window.flash?.remove()
@@ -74,10 +81,18 @@ document.addEventListener('keyup', (e) => {
} }
}) })
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody) document.addEventListener('turbo:morph', () => {
document.addEventListener('turbo:before-fetch-request', (event) => { if (document.activeElement?.closest('.dropdown')) {
event.detail.fetchOptions.headers['X-Turbo'] = 'true' 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) => { document.addEventListener('turbo:submit-end', async (event) => {
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
@@ -85,11 +100,23 @@ document.addEventListener('turbo:submit-end', async (event) => {
return 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 url = URL.createObjectURL(await resp.blob())
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.setAttribute('download', decodeURIComponent(resp.headers.get('content-disposition').split('"')[1])) link.setAttribute('download', filename)
document.body.appendChild(link) document.body.appendChild(link)
@@ -105,6 +132,15 @@ const safeRegisterElement = (name, element, options = {}) => !window.customEleme
safeRegisterElement('toggle-visible', ToggleVisible) safeRegisterElement('toggle-visible', ToggleVisible)
safeRegisterElement('disable-hidden', DisableHidden) safeRegisterElement('disable-hidden', DisableHidden)
safeRegisterElement('turbo-modal', TurboModal) 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('file-dropzone', FileDropzone)
safeRegisterElement('menu-active', MenuActive) safeRegisterElement('menu-active', MenuActive)
safeRegisterElement('clipboard-copy', ClipboardCopy) safeRegisterElement('clipboard-copy', ClipboardCopy)
@@ -156,16 +192,30 @@ safeRegisterElement('confirm-upload', ConfirmUpload)
safeRegisterElement('scroll-fade', ScrollFade) safeRegisterElement('scroll-fade', ScrollFade)
safeRegisterElement('open-modal-mobile', OpenModalMobile) safeRegisterElement('open-modal-mobile', OpenModalMobile)
safeRegisterElement('history-back', HistoryBack) safeRegisterElement('history-back', HistoryBack)
safeRegisterElement('date-placeholder', DatePlaceholder)
safeRegisterElement('flash-message', FlashMessage)
safeRegisterElement('template-builder', class extends HTMLElement { safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
document.addEventListener('turbo:submit-end', this.onSubmit) 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) document.addEventListener('turbo:before-cache', this.onBeforeCache)
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
this.appElem.classList.add('md:h-screen') 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)) const template = reactive(JSON.parse(this.dataset.template))
this.app = createApp(TemplateBuilder, { this.app = createApp(TemplateBuilder, {
@@ -203,7 +253,9 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withDownload: true, withDownload: true,
currencies: (this.dataset.currencies || '').split(',').filter(Boolean), currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
acceptFileTypes: this.dataset.acceptFileTypes, 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) this.component = this.app.mount(this.appElem)
@@ -211,39 +263,130 @@ safeRegisterElement('template-builder', class extends HTMLElement {
this.appendChild(this.appElem) this.appendChild(this.appElem)
} }
onSubmit = (e) => { onSubmit = async (e) => {
if (e.detail.success) { const form = e.detail.formSubmission?.formElement
if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
e.detail.fetchResponse.response.json().then((data) => { if (!e.detail.success || !form) return
this.component.template.submitters = data.submitters
}) 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')) { if (field) {
e.detail.fetchResponse.response.text().then((data) => { this.component.drawField = field
const doc = new DOMParser().parseFromString(data, 'text/html') this.component.drawOption = option || null
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 (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 = () => { onBeforeCache = () => {
this.app?.unmount() this.app?.unmount()
this.appElem?.remove() this.appElem?.remove()
@@ -251,6 +394,14 @@ safeRegisterElement('template-builder', class extends HTMLElement {
disconnectedCallback () { disconnectedCallback () {
document.removeEventListener('turbo:submit-end', this.onSubmit) 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) document.removeEventListener('turbo:before-cache', this.onBeforeCache)
this.app?.unmount() this.app?.unmount()
+68
View File
@@ -166,3 +166,71 @@ button[disabled] .enabled, button.btn-disabled .enabled {
markdown-editor [contenteditable] p { markdown-editor [contenteditable] p {
margin-bottom: 18px; 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;
}
}
+1 -2
View File
@@ -126,11 +126,10 @@ window.customElements.define('draw-signature', class extends HTMLElement {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
formData.append('submitter_slug', this.dataset.slug)
formData.append('name', 'attachments') formData.append('name', 'attachments')
formData.append('remember_signature', 'true') formData.append('remember_signature', 'true')
return fetch('/api/attachments', { return fetch(`/s/${this.dataset.slug}/upload`, {
method: 'POST', method: 'POST',
body: formData body: formData
}).then(resp => resp.json()) }).then(resp => resp.json())
@@ -1,5 +1,7 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.id = `autoresize-textarea-${Math.random().toString(32).split('.')[1]}`
this.resize() this.resize()
this.textarea.addEventListener('input', () => this.resize()) this.textarea.addEventListener('input', () => this.resize())
+2
View File
@@ -1,5 +1,7 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.id = `bar-chart-${Math.random().toString(32).split('.')[1]}`
this.chartLabels = JSON.parse(this.dataset.labels || '[]') this.chartLabels = JSON.parse(this.dataset.labels || '[]')
this.chartDatasets = JSON.parse(this.dataset.datasets || '[]') this.chartDatasets = JSON.parse(this.dataset.datasets || '[]')
+10 -1
View File
@@ -4,9 +4,18 @@ export default class extends HTMLElement {
this.addEventListener('click', (e) => { this.addEventListener('click', (e) => {
const text = this.dataset.text || this.innerText.trim() 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) { if (navigator.clipboard) {
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text).then(() => {
window.webkit?.messageHandlers?.flash?.postMessage({ style: 'notice', message: this.dataset.copied })
})
} else { } else {
if (e.target.tagName !== 'INPUT') { if (e.target.tagName !== 'INPUT') {
alert(`Clipboard not available. Make sure you're using https://\nCopy text: ${text}`) alert(`Clipboard not available. Make sure you're using https://\nCopy text: ${text}`)
+3 -3
View File
@@ -59,10 +59,10 @@ export async function convertImagesInInput (input) {
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
const input = this.querySelector('input[type="file"]') this.addEventListener('change', async (e) => {
const form = input.form const input = e.target
const form = input.form
input.addEventListener('change', async () => {
await convertImagesInInput(input) await convertImagesInInput(input)
form.querySelector('[type="submit"]')?.setAttribute('disabled', true) form.querySelector('[type="submit"]')?.setAttribute('disabled', true)
+63 -31
View File
@@ -26,17 +26,41 @@ export default targetable(class extends HTMLElement {
window.addEventListener('dragleave', this.onWindowDragleave) window.addEventListener('dragleave', this.onWindowDragleave)
this.fileDropzone?.addEventListener('drop', this.onDropFile) this.addEventListener('drop', this.onDrop)
this.folderDropzone?.addEventListener('drop', this.onDropNewFolder) 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))) dropTarget (e) {
this.templateCards.forEach((el) => el.addEventListener('drop', this.onDropTemplate)) return e.target.closest([
this.templateCards.forEach((el) => el.addEventListener('dragstart', this.onTemplateDragStart)) '[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) => { onDrop = (e) => {
el?.addEventListener('dragover', this.onDragover) const el = this.dropTarget(e)
el?.addEventListener('dragleave', this.onDragleave)
}) 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 () { disconnectedCallback () {
@@ -122,13 +146,13 @@ export default targetable(class extends HTMLElement {
} }
}).finally(() => { }).finally(() => {
window.Turbo.cache.clear() window.Turbo.cache.clear()
window.Turbo.visit(location.href) window.Turbo.visit(location.href, { action: 'replace' })
}) })
} }
} }
} }
onDropTemplate = (e) => { onDropTemplate = (e, el) => {
e.preventDefault() e.preventDefault()
if (e.dataTransfer.files.length) { 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.classList.add('bottom-5', 'left-0', 'flex', 'justify-center', 'w-full', 'absolute')
loading.innerHTML = loadingIconHtml loading.innerHTML = loadingIconHtml
e.target.appendChild(loading) el.appendChild(loading)
e.target.classList.add('opacity-50') 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`) this.uploadFiles(e.dataTransfer.files, `/templates/${id}/clone_and_replace`)
} }
@@ -173,16 +197,20 @@ export default targetable(class extends HTMLElement {
} }
} }
onDragover (e) { onDragover = (e) => {
if (e.dataTransfer?.types?.includes('Files') || this.dataset.targets !== 'dashboard-dropzone.templateCards') { const el = this.dropTarget(e)
this.style.backgroundColor = '#F7F3F0'
if (this.classList.contains('before:border-base-300')) { if (!el) return
this.classList.remove('before:border-base-300')
this.classList.add('before:border-base-content/30') if (e.dataTransfer?.types?.includes('Files') || el.dataset.targets !== 'dashboard-dropzone.templateCards') {
} else if (this.classList.contains('border-base-300')) { el.style.backgroundColor = '#F7F3F0'
this.classList.remove('border-base-300')
this.classList.add('border-base-content/30') 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() a.remove()
} }
onDragleave () { onDragleave = (e) => {
this.style.backgroundColor = null const el = this.dropTarget(e)
if (this.classList.contains('before:border-base-content/30')) { if (!el) return
this.classList.remove('before:border-base-content/30')
this.classList.add('before:border-base-300') el.style.backgroundColor = null
} else if (this.classList.contains('border-base-content/30')) {
this.classList.remove('border-base-content/30') if (el.classList.contains('before:border-base-content/30')) {
this.classList.add('border-base-300') 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)
})
}
}
+12
View File
@@ -2,6 +2,18 @@ export default class extends HTMLElement {
static observedAttributes = ['class'] static observedAttributes = ['class']
connectedCallback () { 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() this.trigger()
} }
@@ -21,6 +21,16 @@ export default targetable(class extends HTMLElement {
downloadFiles () { downloadFiles () {
if (!this.dataset.src) return 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() this.toggleState()
fetch(this.dataset.src).then(async (response) => { fetch(this.dataset.src).then(async (response) => {
+2
View File
@@ -39,6 +39,8 @@ export default targetable(class extends HTMLElement {
] ]
connectedCallback () { connectedCallback () {
this.id = `email-editor-${Math.random().toString(32).split('.')[1]}`
this.mount() this.mount()
if (this.input.value) { if (this.input.value) {
+1 -1
View File
@@ -35,7 +35,7 @@ export default class extends HTMLElement {
counter.innerText = `${count} / ${this.dataset.limit}` counter.innerText = `${count} / ${this.dataset.limit}`
} }
if (this.dataset.bulkEnabled !== 'true') { if (this.dataset.bulkEnabled !== 'true' && !/Hotwire Native iOS/.test(navigator.userAgent)) {
if (!bulkMessage) { if (!bulkMessage) {
bulkMessage = document.createElement('span') bulkMessage = document.createElement('span')
+9
View File
@@ -14,6 +14,15 @@ export default actionable(targetable(class extends HTMLElement {
this.addEventListener('dragover', (e) => e.preventDefault()) this.addEventListener('dragover', (e) => e.preventDefault())
this.addEventListener('drop', this.onDrop) this.addEventListener('drop', this.onDrop)
document.addEventListener('turbo:submit-end', this.toggleLoading) 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('dragover', this.onDragover)
this.area?.addEventListener('dragleave', this.onDragleave) this.area?.addEventListener('dragleave', this.onDragleave)
} }
+43
View File
@@ -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()
}
}
+33
View File
@@ -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' })
}
}
}
+2
View File
@@ -458,6 +458,8 @@ export default actionable(targetable(class extends HTMLElement {
async connectedCallback () { async connectedCallback () {
if (!this.textarea || !this.editorElement) return if (!this.textarea || !this.editorElement) return
this.id = `html-editor-${Math.random().toString(32).split('.')[1]}`
this.textarea.style.display = 'none' this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform() 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 () { async connectedCallback () {
if (!this.textarea || !this.editorElement) return if (!this.textarea || !this.editorElement) return
this.id = `markdown-editor-${Math.random().toString(32).split('.')[1]}`
this.textarea.style.display = 'none' this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform() this.adjustShortcutsForPlatform()
+2 -3
View File
@@ -1,14 +1,13 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
const maskedToken = this.input.value
this.input.addEventListener('focus', () => { this.input.addEventListener('focus', () => {
this.maskedToken = this.input.value
this.input.value = this.dataset.token this.input.value = this.dataset.token
this.input.select() this.input.select()
}) })
this.input.addEventListener('focusout', () => { this.input.addEventListener('focusout', () => {
this.input.value = maskedToken this.input.value = this.maskedToken
}) })
} }
+2
View File
@@ -1,5 +1,7 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.id = `menu-active-${Math.random().toString(32).split('.')[1]}`
this.querySelectorAll('a').forEach((link) => { this.querySelectorAll('a').forEach((link) => {
if (document.location.pathname.startsWith(link.pathname) && !link.getAttribute('href').startsWith('http')) { if (document.location.pathname.startsWith(link.pathname) && !link.getAttribute('href').startsWith('http')) {
link.classList.add('bg-base-300') link.classList.add('bg-base-300')
+44
View File
@@ -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 })
}
}
}
+60
View File
@@ -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' })
}
}
+49
View File
@@ -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
})
}
}
+41
View File
@@ -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)
}
}
+59
View File
@@ -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')
}
}
+61
View File
@@ -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 })
}
}
+8 -3
View File
@@ -1,12 +1,17 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.addEventListener('pointerdown', this.onPointerdown)
}
onPointerdown = (event) => {
if (this.dataset.disabled === 'true') return if (this.dataset.disabled === 'true') return
if (/Hotwire Native/.test(navigator.userAgent)) return
if (!this.isMobile()) return if (!this.isMobile()) return
if (window.innerWidth >= 768) return if (window.innerWidth >= 768) return
this.querySelectorAll('a[data-turbo-frame]').forEach((link) => { const link = event.target.closest('a[data-turbo-frame]')
link.dataset.turboFrame = '_top'
}) if (link) link.dataset.turboFrame = '_top'
} }
isMobile () { isMobile () {
+2
View File
@@ -1,5 +1,7 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.id = `scroll-fade-${Math.random().toString(32).split('.')[1]}`
this.addEventListener('scroll', this.updateFade, { passive: true }) this.addEventListener('scroll', this.updateFade, { passive: true })
window.addEventListener('resize', this.updateFade) window.addEventListener('resize', this.updateFade)
+16 -7
View File
@@ -1,20 +1,29 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.button.addEventListener('click', (event) => { this.addEventListener('click', this.onClick)
if (!this.input.value && document.activeElement !== this.input) {
event.preventDefault()
this.input.focus()
}
})
document.addEventListener('turbo:before-cache', this.onBeforeCache) 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 () { disconnectedCallback () {
document.removeEventListener('turbo:before-cache', this.onBeforeCache) 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 = () => { onBeforeCache = () => {
this.input.value = this.input.getAttribute('value') || '' this.input.value = this.input.getAttribute('value') || ''
} }
+2
View File
@@ -1,5 +1,7 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.id = `set-timezone-${Math.random().toString(32).split('.')[1]}`
if (this.dataset.inputId) { if (this.dataset.inputId) {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
@@ -5,6 +5,8 @@ export default targetable(class extends HTMLElement {
static [target.static] = ['canvas', 'input', 'clear', 'button'] static [target.static] = ['canvas', 'input', 'clear', 'button']
async connectedCallback () { async connectedCallback () {
this.lockSwipe(true)
const { default: SignaturePad } = await import('signature_pad') const { default: SignaturePad } = await import('signature_pad')
this.setCanvasSize() this.setCanvasSize()
@@ -61,11 +63,17 @@ export default targetable(class extends HTMLElement {
} }
disconnectedCallback () { disconnectedCallback () {
this.lockSwipe(false)
if (this.resizeObserver) { if (this.resizeObserver) {
this.resizeObserver.disconnect() this.resizeObserver.disconnect()
} }
} }
lockSwipe (locked) {
window.webkit?.messageHandlers?.native?.postMessage({ type: 'swipe', op: locked ? 'lock' : 'unlock' })
}
setCanvasSize () { setCanvasSize () {
const scale = 3 const scale = 3
+14 -6
View File
@@ -1,5 +1,19 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { 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 const form = this.querySelector('form') || (this.querySelector('input, button, select') || this.lastElementChild).form
if (this.dataset.interval) { if (this.dataset.interval) {
@@ -24,10 +38,4 @@ export default class extends HTMLElement {
form.requestSubmit() form.requestSubmit()
} }
} }
disconnectedCallback () {
if (this.interval) {
clearInterval(this.interval)
}
}
} }
+14 -10
View File
@@ -1,19 +1,23 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.addEventListener('click', this.onClick)
}
onClick = (event) => {
const button = this.querySelector('a, button, label') const button = this.querySelector('a, button, label')
if (!button?.contains(event.target)) return
const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button
button.addEventListener('click', () => { this.dataset.classes.split(' ').forEach((cls) => {
this.dataset.classes.split(' ').forEach((cls) => { if (this.dataset.action === 'remove') {
if (this.dataset.action === 'remove') { target.classList.remove(cls)
target.classList.remove(cls) } else if (this.dataset.action === 'add') {
} else if (this.dataset.action === 'add') { target.classList.add(cls)
target.classList.add(cls) } else {
} else { target.classList.toggle(cls)
target.classList.toggle(cls) }
}
})
}) })
} }
} }
+5 -11
View File
@@ -1,27 +1,21 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
document.addEventListener('turbo:submit-end', this.onSubmitEnd)
this.form.addEventListener('submit', this.onSubmit) this.form.addEventListener('submit', this.onSubmit)
this.form.addEventListener('turbo:before-fetch-response', this.onFetchResponse)
} }
disconnectedCallback () { disconnectedCallback () {
document.removeEventListener('turbo:submit-end', this.onSubmitEnd)
this.form.removeEventListener('submit', this.onSubmit) this.form.removeEventListener('submit', this.onSubmit)
this.form.removeEventListener('turbo:before-fetch-response', this.onFetchResponse)
} }
onSubmit = () => { onSubmit = () => {
this.element.classList.add('invisible') this.element.classList.add('invisible')
} }
onSubmitEnd = (event) => { onFetchResponse = (event) => {
if (event.target === this.form) { if (event.detail.fetchResponse.succeeded) {
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response this.element.classList.remove('invisible')
if (resp?.status / 100 === 2) {
this.element.classList.remove('invisible')
}
} }
} }
+9 -1
View File
@@ -1,3 +1,5 @@
import './native'
import { createApp, reactive } from 'vue' import { createApp, reactive } from 'vue'
import Form from './submission_form/form' import Form from './submission_form/form'
@@ -8,6 +10,7 @@ import ScrollButtons from './elements/scroll_buttons'
import PageContainer from './elements/page_container' import PageContainer from './elements/page_container'
import SubmitForm from './elements/submit_form' import SubmitForm from './elements/submit_form'
import ModalButton from './elements/modal_button' 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) 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('page-container', PageContainer)
safeRegisterElement('submit-form', SubmitForm) safeRegisterElement('submit-form', SubmitForm)
safeRegisterElement('modal-button', ModalButton) safeRegisterElement('modal-button', ModalButton)
safeRegisterElement('native-action', NativeAction)
safeRegisterElement('submission-form', class extends HTMLElement { safeRegisterElement('submission-form', class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
@@ -28,7 +32,9 @@ safeRegisterElement('submission-form', class extends HTMLElement {
optionalInviteSubmitters: JSON.parse(this.dataset.optionalInviteSubmitters), optionalInviteSubmitters: JSON.parse(this.dataset.optionalInviteSubmitters),
schema: JSON.parse(this.dataset.schema), schema: JSON.parse(this.dataset.schema),
canSendEmail: this.dataset.canSendEmail === 'true', 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', goToLast: this.dataset.goToLast === 'true',
isDemo: this.dataset.isDemo === 'true', isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false', attribution: this.dataset.attribution !== 'false',
@@ -37,10 +43,12 @@ safeRegisterElement('submission-form', class extends HTMLElement {
language: this.dataset.language, language: this.dataset.language,
paymentProvider: this.dataset.paymentProvider, paymentProvider: this.dataset.paymentProvider,
dryRun: this.dataset.dryRun === 'true', dryRun: this.dataset.dryRun === 'true',
viewToken: this.dataset.viewToken,
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null, expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
withSignatureId: this.dataset.withSignatureId === 'true', withSignatureId: this.dataset.withSignatureId === 'true',
requireSigningReason: this.dataset.requireSigningReason === 'true', requireSigningReason: this.dataset.requireSigningReason === 'true',
withConfetti: this.dataset.withConfetti === 'true', withConfetti: this.dataset.withConfetti === 'true',
nativePlatform: this.dataset.nativePlatform,
withFieldLabels: this.dataset.withFieldLabels !== 'false', withFieldLabels: this.dataset.withFieldLabels !== 'false',
withDisclosure: this.dataset.withDisclosure === 'true', withDisclosure: this.dataset.withDisclosure === 'true',
reuseSignature: this.dataset.reuseSignature !== 'false', reuseSignature: this.dataset.reuseSignature !== 'false',
-98
View File
@@ -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 }
+208
View File
@@ -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, required: false,
default: () => [] default: () => []
}, },
fieldsUuidIndex: {
type: Object,
required: false,
default: () => ({})
},
values: { values: {
type: Object, type: Object,
required: false, required: false,
@@ -195,7 +200,7 @@ export default {
}, },
isMobileContainer: FieldAreas.computed.isMobileContainer, isMobileContainer: FieldAreas.computed.isMobileContainer,
isInlineSize: FieldAreas.computed.isInlineSize, isInlineSize: FieldAreas.computed.isInlineSize,
fieldsUuidIndex () { formulaFieldsUuidIndex () {
return this.formulaFields.reduce((acc, field) => { return this.formulaFields.reduce((acc, field) => {
acc[field.uuid] = field acc[field.uuid] = field
@@ -289,6 +294,7 @@ export default {
methods: { methods: {
normalizeFormula: FormulaAreas.methods.normalizeFormula, normalizeFormula: FormulaAreas.methods.normalizeFormula,
calculateFormula: FormulaAreas.methods.calculateFormula, calculateFormula: FormulaAreas.methods.calculateFormula,
dateFormulaValue: FormulaAreas.methods.dateFormulaValue,
evalTextFormula: FormulaAreas.methods.evalTextFormula, evalTextFormula: FormulaAreas.methods.evalTextFormula,
scrollInContainer: FieldAreas.methods.scrollInContainer, scrollInContainer: FieldAreas.methods.scrollInContainer,
scrollIntoArea: FieldAreas.methods.scrollIntoArea, scrollIntoArea: FieldAreas.methods.scrollIntoArea,
@@ -633,6 +633,32 @@ defineFunction('contains', {
return String(v[0]).indexOf(String(v[1])) !== -1 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 = { const OP_CLASSES = {
add: Addition, add: Addition,
+15 -1
View File
@@ -56,7 +56,7 @@
</span> </span>
</button> </button>
<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" class="base-button flex items-center space-x-1 w-full completed-form-download-button"
:disabled="isDownloading" :disabled="isDownloading"
@click.prevent="download" @click.prevent="download"
@@ -186,6 +186,11 @@ export default {
type: Object, type: Object,
required: false, required: false,
default: () => ({}) default: () => ({})
},
nativePlatform: {
type: String,
required: false,
default: ''
} }
}, },
data () { data () {
@@ -195,6 +200,9 @@ export default {
} }
}, },
computed: { computed: {
isNativeApp () {
return !!this.nativePlatform && !!window.webkit?.messageHandlers?.download
},
isWebView () { isWebView () {
return /webview|wv|ip((?!.*Safari)|(?=.*like Safari))/i.test(window.navigator.userAgent) return /webview|wv|ip((?!.*Safari)|(?=.*like Safari))/i.test(window.navigator.userAgent)
} }
@@ -230,6 +238,12 @@ export default {
download () { download () {
this.isDownloading = true 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`, { fetch(this.baseUrl + `/s/${this.submitterSlug}/documents`, {
method: 'GET', method: 'GET',
...this.fetchOptions ...this.fetchOptions
+13 -2
View File
@@ -87,7 +87,7 @@ export default {
IconCloudUpload, IconCloudUpload,
IconInnerShadowTop IconInnerShadowTop
}, },
inject: ['baseUrl', 't'], inject: ['baseUrl', 'fetchOptions', 't'],
props: { props: {
message: { message: {
type: String, type: String,
@@ -197,9 +197,20 @@ export default {
formData.append('submitter_slug', this.submitterSlug) formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments') formData.append('name', 'attachments')
return fetch(this.baseUrl + '/api/attachments', { return fetch(this.baseUrl + `/s/${this.submitterSlug}/upload`, {
method: 'POST', method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData 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) => { }).then(async (resp) => {
const data = await resp.json() const data = await resp.json()
+78 -29
View File
@@ -31,6 +31,7 @@
:readonly-conditional-fields="readonlyConditionalFields" :readonly-conditional-fields="readonlyConditionalFields"
:readonly-conditional-field-values="readonlyConditionalFieldValues" :readonly-conditional-field-values="readonlyConditionalFieldValues"
:formula-fields="formulaFields" :formula-fields="formulaFields"
:fields-uuid-index="fieldsUuidIndex"
:values="values" :values="values"
:readonly-values="readonlyFieldValues" :readonly-values="readonlyFieldValues"
:submitter="submitter" :submitter="submitter"
@@ -71,6 +72,7 @@
<FormulaFieldAreas <FormulaFieldAreas
v-if="!withAccessibilityAreas && !isAccessibilityMode && formulaFields.length" v-if="!withAccessibilityAreas && !isAccessibilityMode && formulaFields.length"
:fields="formulaFields" :fields="formulaFields"
:fields-uuid-index="fieldsUuidIndex"
:readonly-values="readonlyFieldValues" :readonly-values="readonlyFieldValues"
:values="values" :values="values"
/> />
@@ -489,8 +491,8 @@
:reason="values[currentField.preferences?.reason_field_uuid]" :reason="values[currentField.preferences?.reason_field_uuid]"
:field="currentField" :field="currentField"
:values="values" :values="values"
:previous-value="previousSignatureValueFor(currentField) || previousSignatureValue" :previous-value="previousSignatureValueFor(currentField)"
:touch-attachment-uuid="previousSignatureValue" :prefill-data="prefillSignature"
:with-typed-signature="withTypedSignature" :with-typed-signature="withTypedSignature"
:remember-signature="rememberSignature" :remember-signature="rememberSignature"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
@@ -503,8 +505,8 @@
:with-qr-button="withQrButton" :with-qr-button="withQrButton"
:submitter="submitter" :submitter="submitter"
:show-field-names="showFieldNames" :show-field-names="showFieldNames"
:native-platform="nativePlatform"
@update:reason="values[currentField.preferences?.reason_field_uuid] = $event" @update:reason="values[currentField.preferences?.reason_field_uuid] = $event"
@touch-attachment="attachmentsIndex[previousSignatureValue] ? attachmentsIndex[previousSignatureValue].created_at = new Date() : null"
@attached="attachments.push($event)" @attached="attachments.push($event)"
@start="scrollIntoField(currentField)" @start="scrollIntoField(currentField)"
@minimize="minimizeForm" @minimize="minimizeForm"
@@ -518,9 +520,11 @@
:dry-run="dryRun" :dry-run="dryRun"
:submitter="submitter" :submitter="submitter"
:previous-value="previousInitialsValue" :previous-value="previousInitialsValue"
:prefill-data="prefillInitials"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:show-field-names="showFieldNames" :show-field-names="showFieldNames"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:native-platform="nativePlatform"
@attached="attachments.push($event)" @attached="attachments.push($event)"
@start="scrollIntoField(currentField)" @start="scrollIntoField(currentField)"
@focus="scrollIntoField(currentField)" @focus="scrollIntoField(currentField)"
@@ -558,6 +562,7 @@
:field="currentField" :field="currentField"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:fields="formulaFields" :fields="formulaFields"
:fields-uuid-index="fieldsUuidIndex"
:values="values" :values="values"
:readonly-values="readonlyFieldValues" :readonly-values="readonlyFieldValues"
:fetch-options="fetchOptions" :fetch-options="fetchOptions"
@@ -652,6 +657,7 @@
:with-confetti="withConfetti" :with-confetti="withConfetti"
:can-send-email="canSendEmail && !!submitter.email" :can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:native-platform="nativePlatform"
/> />
<nav <nav
v-if="stepFields.length < 80" v-if="stepFields.length < 80"
@@ -772,6 +778,7 @@ export default {
provide () { provide () {
return { return {
baseUrl: this.baseUrl, baseUrl: this.baseUrl,
fetchOptions: this.fetchOptions,
scrollIntoArea: this.scrollIntoArea, scrollIntoArea: this.scrollIntoArea,
scrollIntoField: this.scrollIntoField, scrollIntoField: this.scrollIntoField,
t: this.t t: this.t
@@ -909,6 +916,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
nativePlatform: {
type: String,
required: false,
default: ''
},
autoscrollFields: { autoscrollFields: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -969,10 +981,20 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
previousSignatureValue: { prefillSignature: {
type: String, type: Object,
required: false, required: false,
default: '' default: null
},
prefillInitials: {
type: Object,
required: false,
default: null
},
currentUser: {
type: Object,
required: false,
default: null
}, },
allowToSkip: { allowToSkip: {
type: Boolean, type: Boolean,
@@ -994,6 +1016,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
viewToken: {
type: String,
required: false,
default: ''
},
attribution: { attribution: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -1329,6 +1356,30 @@ export default {
screen?.orientation?.addEventListener('change', this.onOrientationChange) 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) => { this.fields.forEach((field) => {
if (field.default_value && !field.readonly) { if (field.default_value && !field.readonly) {
this.values[field.uuid] ||= field.default_value this.values[field.uuid] ||= field.default_value
@@ -1523,15 +1574,10 @@ export default {
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?') const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
window.history.replaceState({}, document.title, newUrl) 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', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers },
'Content-Type': 'application/json' body: JSON.stringify({ t })
},
body: JSON.stringify({
t,
submitter_slug: this.submitterSlug
})
}) })
} else { } else {
return Promise.resolve({}) return Promise.resolve({})
@@ -1547,29 +1593,28 @@ export default {
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?') const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
window.history.replaceState({}, document.title, newUrl) 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', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers },
'Content-Type': 'application/json' body: JSON.stringify({ c })
},
body: JSON.stringify({
c,
submitter_slug: this.submitterSlug
})
}) })
} else { } else {
return Promise.resolve({}) return Promise.resolve({})
} }
}, },
trackViewForm () { trackViewForm () {
fetch(this.baseUrl + '/api/submitter_form_views', { fetch(this.baseUrl + `/s/${this.submitterSlug}/view`, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json' body: JSON.stringify({ v: this.viewToken })
}, }).then((resp) => {
body: JSON.stringify({ if (resp.status === 404) {
submitter_slug: this.submitterSlug return fetch(this.baseUrl + '/api/submitter_form_views', {
}) method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ submitter_slug: this.submitterSlug })
})
}
}) })
}, },
previousSignatureValueFor (field) { previousSignatureValueFor (field) {
@@ -1767,6 +1812,10 @@ export default {
this.isCompleted = true this.isCompleted = true
this.isFormVisible = true this.isFormVisible = true
if (this.nativePlatform) {
window.webkit?.messageHandlers?.native?.postMessage({ type: 'stale' })
}
if (resp?.text) { if (resp?.text) {
const respData = await resp.text() const respData = await resp.text()
@@ -48,6 +48,11 @@ export default {
type: Object, type: Object,
required: false, required: false,
default: () => ({}) default: () => ({})
},
fieldsUuidIndex: {
type: Object,
required: false,
default: () => ({})
} }
}, },
data () { data () {
@@ -59,7 +64,7 @@ export default {
isInlineSize () { isInlineSize () {
return CSS.supports('container-type: size') return CSS.supports('container-type: size')
}, },
fieldsUuidIndex () { formulaFieldsUuidIndex () {
return this.fields.reduce((acc, field) => { return this.fields.reduce((acc, field) => {
acc[field.uuid] = field acc[field.uuid] = field
@@ -82,8 +87,8 @@ export default {
if (depth > 10) return formula if (depth > 10) return formula
return formula.replace(/{{(.*?)}}/g, (match, uuid) => { return formula.replace(/{{(.*?)}}/g, (match, uuid) => {
if (this.fieldsUuidIndex[uuid]) { if (this.formulaFieldsUuidIndex[uuid]) {
return `(${this.normalizeFormula(this.fieldsUuidIndex[uuid].preferences.formula, depth + 1)})` return `(${this.normalizeFormula(this.formulaFieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
} else { } else {
return match return match
} }
@@ -91,16 +96,36 @@ export default {
}, },
calculateFormula (field) { calculateFormula (field) {
const transformedFormula = this.normalizeFormula(field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => { 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) { evalTextFormula (field, depth = 0) {
if (depth > 10) return '' if (depth > 10) return ''
return field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => { return field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
const formulaField = this.fieldsUuidIndex[uuid] const formulaField = this.formulaFieldsUuidIndex[uuid]
if (formulaField?.preferences?.formula) { if (formulaField?.preferences?.formula) {
if (formulaField.type === 'text') { if (formulaField.type === 'text') {
@@ -87,7 +87,7 @@
</button> </button>
</span> </span>
<button <button
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue || computedPrefillData"
type="button" type="button"
class="btn font-medium btn-outline btn-sm clear-canvas-button" class="btn font-medium btn-outline btn-sm clear-canvas-button"
@click="remove" @click="remove"
@@ -139,8 +139,8 @@
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
> >
<img <img
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue || computedPrefillData"
:src="attachmentsIndex[modelValue || computedPreviousValue].url" :src="previousValueUrl"
:alt="field.name || t('initials')" :alt="field.name || t('initials')"
class="mx-auto bg-white border border-base-300 rounded max-h-44" 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" class="absolute top-0 right-0 left-0 bottom-0"
/> />
<label <label
v-if="!isDrawInitials && !modelValue && !computedPreviousValue" v-if="!isDrawInitials && !modelValue && !computedPreviousValue && !computedPrefillData"
for="initials_text_input" for="initials_text_input"
class="absolute top-0 right-0 left-0 bottom-0" class="absolute top-0 right-0 left-0 bottom-0"
/> />
<canvas <canvas
v-show="!modelValue && !computedPreviousValue" v-show="!modelValue && !computedPreviousValue && !computedPrefillData"
ref="canvas" ref="canvas"
role="img" role="img"
:aria-label="t('initials_drawing_area')" :aria-label="t('initials_drawing_area')"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas" class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
@touchstart.passive="lockSwipe"
@touchend.passive="unlockSwipe"
@touchcancel.passive="unlockSwipe"
/> />
</div> </div>
<input <input
v-if="!isDrawInitials && !modelValue && !computedPreviousValue" v-if="!isDrawInitials && !modelValue && !computedPreviousValue && !computedPrefillData"
id="initials_text_input" id="initials_text_input"
ref="textInput" ref="textInput"
class="base-input !text-2xl w-full mt-6 text-center" class="base-input !text-2xl w-full mt-6 text-center"
@@ -199,7 +202,7 @@ export default {
MarkdownContent, MarkdownContent,
IconArrowsDiagonalMinimize2 IconArrowsDiagonalMinimize2
}, },
inject: ['baseUrl', 't'], inject: ['baseUrl', 'fetchOptions', 't'],
props: { props: {
field: { field: {
type: Object, type: Object,
@@ -233,6 +236,16 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
prefillData: {
type: Object,
required: false,
default: null
},
nativePlatform: {
type: String,
required: false,
default: ''
},
modelValue: { modelValue: {
type: String, type: String,
required: false, required: false,
@@ -255,10 +268,24 @@ export default {
} else { } else {
return null 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 () { created () {
this.isInitialsStarted = !!this.computedPreviousValue this.isInitialsStarted = !!(this.computedPreviousValue || this.computedPrefillData)
}, },
async mounted () { async mounted () {
this.$nextTick(() => { this.$nextTick(() => {
@@ -311,6 +338,8 @@ export default {
this.intersectionObserver?.disconnect() this.intersectionObserver?.disconnect()
}, },
methods: { methods: {
lockSwipe: SignatureStep.methods.lockSwipe,
unlockSwipe: SignatureStep.methods.unlockSwipe,
drawOnCanvas: SignatureStep.methods.drawOnCanvas, drawOnCanvas: SignatureStep.methods.drawOnCanvas,
drawImage (event) { drawImage (event) {
this.remove() this.remove()
@@ -397,7 +426,48 @@ export default {
this.updateWrittenInitials({ target: this.$refs.textInput }) 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 () { async submit () {
if (this.computedPrefillData) {
return this.submitPrefillAttachment()
}
if (this.modelValue || this.computedPreviousValue) { if (this.modelValue || this.computedPreviousValue) {
if (this.computedPreviousValue) { if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue) this.$emit('update:model-value', this.computedPreviousValue)
@@ -431,9 +501,20 @@ export default {
formData.append('name', 'attachments') formData.append('name', 'attachments')
formData.append('type', 'initials') formData.append('type', 'initials')
return fetch(this.baseUrl + '/api/attachments', { return fetch(this.baseUrl + `/s/${this.submitterSlug}/upload`, {
method: 'POST', method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData 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) => { }).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) { if (resp.status === 422 || resp.status === 500) {
const data = await resp.json() const data = await resp.json()
@@ -104,6 +104,11 @@ export default {
required: false, required: false,
default: () => ({}) default: () => ({})
}, },
fieldsUuidIndex: {
type: Object,
required: false,
default: () => ({})
},
values: { values: {
type: Object, type: Object,
required: true required: true
@@ -137,7 +142,7 @@ export default {
} }
}, },
computed: { computed: {
fieldsUuidIndex () { formulaFieldsUuidIndex () {
return this.fields.reduce((acc, field) => { return this.fields.reduce((acc, field) => {
acc[field.uuid] = field acc[field.uuid] = field
@@ -216,17 +221,31 @@ export default {
}, },
calculateFormula () { calculateFormula () {
const transformedFormula = this.normalizeFormula(this.field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => { 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()) 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) { normalizeFormula (formula, depth = 0) {
if (depth > 10) return formula if (depth > 10) return formula
return formula.replace(/{{(.*?)}}/g, (match, uuid) => { return formula.replace(/{{(.*?)}}/g, (match, uuid) => {
if (this.fieldsUuidIndex[uuid]) { if (this.formulaFieldsUuidIndex[uuid]) {
return `(${this.normalizeFormula(this.fieldsUuidIndex[uuid].preferences.formula, depth + 1)})` return `(${this.normalizeFormula(this.formulaFieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
} else { } else {
return match return match
} }
+101 -31
View File
@@ -45,7 +45,7 @@
<span <span
v-else-if="withTypedSignature && format !== 'drawn_or_upload' && format !== 'typed_or_upload' && format !== 'typed' && format !== 'drawn' && format !== 'upload'" v-else-if="withTypedSignature && format !== 'drawn_or_upload' && format !== 'typed_or_upload' && format !== 'typed' && format !== 'drawn' && format !== 'upload'"
class="md:tooltip ml-2" class="md:tooltip ml-2"
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }" :class="{ 'hidden sm:inline': modelValue || computedPreviousValue || computedPrefillData }"
:data-tip="t('type_text')" :data-tip="t('type_text')"
> >
<button <button
@@ -68,7 +68,7 @@
<span <span
v-if="format !== 'typed' && format !== 'drawn' && format !== 'upload' && format !== 'drawn_or_typed'" v-if="format !== 'typed' && format !== 'drawn' && format !== 'upload' && format !== 'drawn_or_typed'"
class="md:tooltip" class="md:tooltip"
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }" :class="{ 'hidden sm:inline': modelValue || computedPreviousValue || computedPrefillData }"
:data-tip="t('take_photo')" :data-tip="t('take_photo')"
> >
<button <button
@@ -95,7 +95,7 @@
</button> </button>
</span> </span>
<button <button
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue || computedPrefillData"
type="button" type="button"
class="btn btn-outline btn-sm font-medium reupload-button" class="btn btn-outline btn-sm font-medium reupload-button"
@click="remove" @click="remove"
@@ -107,7 +107,7 @@
{{ t(format === 'upload' ? 'reupload' : 'redraw') }} {{ t(format === 'upload' ? 'reupload' : 'redraw') }}
</button> </button>
<span <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%]" class="md:tooltip before:translate-x-[-90%]"
:data-tip="t('sign_on_the_touchscreen')" :data-tip="t('sign_on_the_touchscreen')"
> >
@@ -154,20 +154,14 @@
type="hidden" type="hidden"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
> >
<input
v-if="isTouchAttachment"
:value="touchAttachmentUuid"
type="hidden"
name="touch_attachment_uuid"
>
<img <img
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue || computedPrefillData"
:src="attachmentsIndex[modelValue || computedPreviousValue].url" :src="previousValueUrl"
:alt="field.name || t('signature')" :alt="field.name || t('signature')"
class="mx-auto bg-white border border-base-300 rounded max-h-44" class="mx-auto bg-white border border-base-300 rounded max-h-44"
> >
<FileDropzone <FileDropzone
v-if="format === 'upload' && !modelValue && !computedPreviousValue" v-if="format === 'upload' && !modelValue && !computedPreviousValue && !computedPrefillData"
:message="`${t('upload')} ${field.name || t('signature')}`" :message="`${t('upload')} ${field.name || t('signature')}`"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:dry-run="dryRun" :dry-run="dryRun"
@@ -179,7 +173,7 @@
class="relative select-none" class="relative select-none"
> >
<div <div
v-if="!modelValue && !computedPreviousValue && !isShowQr && !isTextSignature && isSignatureStarted" v-if="!modelValue && !computedPreviousValue && !computedPrefillData && !isShowQr && !isTextSignature && isSignatureStarted"
class="absolute top-0.5 right-0.5" class="absolute top-0.5 right-0.5"
> >
<button <button
@@ -199,12 +193,15 @@
class="absolute top-0 right-0 left-0 bottom-0" class="absolute top-0 right-0 left-0 bottom-0"
/> />
<canvas <canvas
v-show="!modelValue && !computedPreviousValue" v-show="!modelValue && !computedPreviousValue && !computedPrefillData"
ref="canvas" ref="canvas"
role="img" role="img"
:aria-label="t('signature_drawing_area')" :aria-label="t('signature_drawing_area')"
style="padding: 1px; 0" style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas" class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
@touchstart.passive="lockSwipe"
@touchend.passive="unlockSwipe"
@touchcancel.passive="unlockSwipe"
/> />
<div <div
v-if="isShowQr" v-if="isShowQr"
@@ -241,7 +238,7 @@
</div> </div>
</div> </div>
<input <input
v-if="isTextSignature && !modelValue && !computedPreviousValue" v-if="isTextSignature && !modelValue && !computedPreviousValue && !computedPrefillData"
id="signature_text_input" id="signature_text_input"
ref="textInput" ref="textInput"
class="base-input !text-2xl w-full mt-6" class="base-input !text-2xl w-full mt-6"
@@ -373,7 +370,7 @@ export default {
IconSignature, IconSignature,
IconArrowsDiagonalMinimize2 IconArrowsDiagonalMinimize2
}, },
inject: ['baseUrl', 't'], inject: ['baseUrl', 'fetchOptions', 't'],
props: { props: {
field: { field: {
type: Object, type: Object,
@@ -437,10 +434,10 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
touchAttachmentUuid: { prefillData: {
type: String, type: Object,
required: false, required: false,
default: '' default: null
}, },
reason: { reason: {
type: String, type: String,
@@ -457,20 +454,24 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
nativePlatform: {
type: String,
required: false,
default: ''
},
modelValue: { modelValue: {
type: String, type: String,
required: false, required: false,
default: '' default: ''
} }
}, },
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason', 'touch-attachment'], emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason'],
data () { data () {
return { return {
isSignatureStarted: false, isSignatureStarted: false,
isShowQr: false, isShowQr: false,
isOtherReason: false, isOtherReason: false,
isUsePreviousValue: true, isUsePreviousValue: true,
isTouchAttachment: false,
isTextSignature: !this.signatureSrc && (!!this.signatureText || this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload'), isTextSignature: !this.signatureSrc && (!!this.signatureText || this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload'),
uploadImageInputKey: Math.random().toString() uploadImageInputKey: Math.random().toString()
} }
@@ -498,10 +499,24 @@ export default {
} else { } else {
return null 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 () { created () {
this.isSignatureStarted = !!this.computedPreviousValue this.isSignatureStarted = !!(this.computedPreviousValue || this.computedPrefillData)
if (this.withSigningReason) { if (this.withSigningReason) {
this.field.preferences ||= {} this.field.preferences ||= {}
@@ -576,6 +591,16 @@ export default {
this.stopCheckSignature() this.stopCheckSignature()
}, },
methods: { 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 () { setCanvasSize () {
const canvas = this.$refs.canvas const canvas = this.$refs.canvas
@@ -872,15 +897,49 @@ export default {
console.error(e) console.error(e)
} }
}, },
async submit () { submitPrefillAttachment () {
if (this.modelValue || this.computedPreviousValue) { if (this.dryRun) {
if (this.touchAttachmentUuid && this.computedPreviousValue === this.touchAttachmentUuid && !Object.values(this.values).includes(this.touchAttachmentUuid)) { const attachment = { uuid: Math.random().toString(), url: this.prefillData.url }
this.isTouchAttachment = true
this.$emit('touch-attachment', this.touchAttachmentUuid) this.$emit('attached', attachment)
} else { this.$emit('update:model-value', attachment.uuid)
this.isTouchAttachment = false
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) { if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue) this.$emit('update:model-value', this.computedPreviousValue)
} }
@@ -932,9 +991,20 @@ export default {
formData.append('remember_signature', this.rememberSignature) formData.append('remember_signature', this.rememberSignature)
formData.append('type', 'signature') formData.append('type', 'signature')
return fetch(this.baseUrl + '/api/attachments', { return fetch(this.baseUrl + `/s/${this.submitterSlug}/upload`, {
method: 'POST', method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData 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) => { }).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) { if (resp.status === 422 || resp.status === 500) {
const data = await resp.json() const data = await resp.json()
+167 -58
View File
@@ -1,8 +1,9 @@
<template> <template>
<div <div
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection" 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="top: -25px; height: 25px"
:style="{ zIndex: isMobile ? 5 : 10 }"
@mousedown.stop="$emit('click-title')" @mousedown.stop="$emit('click-title')"
@pointerdown.stop @pointerdown.stop
> >
@@ -40,7 +41,7 @@
@blur="onNameBlur" @blur="onNameBlur"
>{{ optionIndexText }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || defaultName }}</span> >{{ optionIndexText }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || defaultName }}</span>
<div <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" class="flex items-center ml-1.5"
> >
<input <input
@@ -74,62 +75,115 @@
@click.prevent="field.readonly = !(field.readonly ?? true)" @click.prevent="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent @mousedown.prevent
>{{ t('editable') }}</label> >{{ 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> </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 <button
v-else-if="editable" v-else-if="editable && !isShowControls"
class="pr-1" class="pr-1"
:title="t('remove')" :title="t('remove')"
@click.prevent="$emit('remove')" @click.prevent="$emit('remove')"
@@ -195,18 +249,21 @@ import FieldSubmitter from './field_submitter'
import FieldType from './field_type' import FieldType from './field_type'
import Field from './field' import Field from './field'
import FieldSettings from './field_settings' import FieldSettings from './field_settings'
import PaymentSettings from './payment_settings'
import FormulaModal from './formula_modal' import FormulaModal from './formula_modal'
import FontModal from './font_modal' import FontModal from './font_modal'
import ConditionsModal from './conditions_modal' import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_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' import { v4 } from 'uuid'
export default { export default {
name: 'AreaTitle', name: 'AreaTitle',
components: { components: {
IconTrashX,
FieldType, FieldType,
FieldSettings, FieldSettings,
PaymentSettings,
FormulaModal, FormulaModal,
FontModal, FontModal,
IconDotsVertical, IconDotsVertical,
@@ -215,7 +272,7 @@ export default {
FieldSubmitter, FieldSubmitter,
IconX IconX
}, },
inject: ['t'], inject: ['t', 'isMobile', 'nativePlatform'],
props: { props: {
template: { template: {
type: Object, type: Object,
@@ -292,6 +349,17 @@ export default {
} }
}, },
computed: { 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, fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels, fieldLabels: FieldType.computed.fieldLabels,
submitter () { submitter () {
@@ -318,6 +386,34 @@ export default {
} }
}, },
methods: { 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, buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () { closeDropdown () {
this.$el.getRootNode().activeElement.blur() this.$el.getRootNode().activeElement.blur()
@@ -327,6 +423,19 @@ export default {
this.isSettingsFocus = false 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) { onNameFocus (e) {
this.selectedAreasRef.value = [this.area] this.selectedAreasRef.value = [this.area]
File diff suppressed because it is too large. Load diff
@@ -1,13 +1,17 @@
<template> <template>
<div <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 <div
v-if="!inline"
class="absolute top-0 bottom-0 right-0 left-0" class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')" @click.prevent="$emit('close')"
/> />
<div class="modal-box pt-4 pb-6 px-6 mt-20 w-full max-w-xl"> <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 class="flex justify-between items-center border-b pb-2 mb-2 font-medium"> <div
v-if="!inline"
class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
>
<span class="modal-title"> <span class="modal-title">
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item) }} {{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item) }}
</span> </span>
@@ -183,6 +187,11 @@ export default {
type: Array, type: Array,
required: false, required: false,
default: () => [] default: () => []
},
inline: {
type: Boolean,
required: false,
default: false
} }
}, },
emits: ['close', 'save'], emits: ['close', 'save'],
@@ -10,7 +10,7 @@
:data-placeholder="placeholder" :data-placeholder="placeholder"
:data-empty="isEmpty" :data-empty="isEmpty"
:style="{ minWidth }" :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" 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" @paste.prevent="onPaste"
@keydown.enter.prevent="blurContenteditable" @keydown.enter.prevent="blurContenteditable"
@@ -23,15 +23,15 @@
{{ value }} {{ value }}
</span> </span>
<span <span
v-if="withButton" v-if="withButton && (!isMobile || editableOnButton)"
class="relative inline" class="relative inline"
:class="{ 'peer-focus:hidden': hideIcon, 'peer-focus:invisible': !hideIcon }" :class="{ 'peer-focus:hidden': hideIcon, 'peer-focus:invisible': !hideIcon }"
> >
<IconPencil <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' }" :style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
:title="t('edit')" :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" :width="iconWidth + 4"
:stroke-width="iconStrokeWidth" :stroke-width="iconStrokeWidth"
@click="clickEdit" @click="clickEdit"
@@ -48,7 +48,7 @@ export default {
components: { components: {
IconPencil IconPencil
}, },
inject: ['t'], inject: ['t', 'isMobile'],
props: { props: {
modelValue: { modelValue: {
type: String, type: String,
@@ -109,6 +109,11 @@ export default {
type: Number, type: Number,
required: false, required: false,
default: 2 default: 2
},
contenteditableClasses: {
type: String,
required: false,
default: ''
} }
}, },
emits: ['update:model-value', 'focus', 'blur', 'click-contenteditable'], 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)"
>
&uarr;
</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)"
>
&darr;
</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)"
>
&times;
</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 <PaymentSettings
v-if="field.type === 'payment' && !isNew" v-if="field.type === 'payment' && !isNew"
:field="field" :field="field"
:background-color="dropdownBgColor"
:with-condition="false" :with-condition="false"
:with-force-open="false" :with-force-open="false"
@click-description="isShowDescriptionModal = true" @click-description="openDescriptionModal"
@click-formula="isShowFormulaModal = true" @click-formula="openFormulaModal"
/> />
<span <span
v-else-if="!isNew" v-else-if="!isNew"
@@ -61,9 +62,17 @@
<label <label
tabindex="0" tabindex="0"
:title="t('settings')" :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 <IconSettings
v-else
:width="18" :width="18"
:stroke-width="1.6" :stroke-width="1.6"
/> />
@@ -85,11 +94,27 @@
:with-areas="false" :with-areas="false"
:with-copy-to-all-pages="false" :with-copy-to-all-pages="false"
:with-condition="false" :with-condition="false"
@click-formula="isShowFormulaModal = true" @click-formula="openFormulaModal"
@click-font="isShowFontModal = true" @click-font="openFontModal"
@click-description="isShowDescriptionModal = true" @click-description="openDescriptionModal"
@save="$emit('save')" @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> </ul>
</span> </span>
<button <button
@@ -104,8 +129,9 @@
/> />
</button> </button>
<button <button
v-if="isNew || !isMobile || field.type === 'payment'"
class="relative group-hover:text-base-content pr-1 field-remove-button" 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')" :title="t('remove')"
@click="onRemoveClick" @click="onRemoveClick"
> >
@@ -164,7 +190,7 @@ import FieldSettings from './field_settings'
import FormulaModal from './formula_modal' import FormulaModal from './formula_modal'
import FontModal from './font_modal' import FontModal from './font_modal'
import DescriptionModal from './description_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' import IconDrag from './icon_drag'
export default { export default {
@@ -172,6 +198,7 @@ export default {
components: { components: {
Contenteditable, Contenteditable,
IconSettings, IconSettings,
IconDotsVertical,
IconCheck, IconCheck,
FieldSettings, FieldSettings,
PaymentSettings, PaymentSettings,
@@ -182,7 +209,7 @@ export default {
IconTrashX, IconTrashX,
FieldType FieldType
}, },
inject: ['backgroundColor', 't'], inject: ['backgroundColor', 't', 'isMobile', 'nativePlatform'],
props: { props: {
field: { field: {
type: Object, type: Object,
@@ -217,6 +244,9 @@ export default {
dropdownBgColor () { dropdownBgColor () {
return ['', null, 'transparent'].includes(this.backgroundColor) ? 'white' : this.backgroundColor return ['', null, 'transparent'].includes(this.backgroundColor) ? 'white' : this.backgroundColor
}, },
iconColorClass () {
return this.isMobile ? 'text-base-content' : 'text-transparent group-hover:text-base-content'
},
modalContainerEl () { modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container') return this.$el.getRootNode().querySelector('#docuseal_modal_container')
} }
@@ -230,6 +260,27 @@ export default {
} }
}, },
methods: { 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 () { buildDefaultName () {
return this.t('custom') return this.t('custom')
}, },
@@ -1,13 +1,17 @@
<template> <template>
<div <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 <div
v-if="!inline"
class="absolute top-0 bottom-0 right-0 left-0" class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')" @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="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 class="flex justify-between items-center border-b pb-2 mb-2 font-medium"> <div
v-if="!inline"
class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
>
<span class="modal-title"> <span class="modal-title">
{{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
</span> </span>
@@ -86,6 +90,11 @@ export default {
buildDefaultName: { buildDefaultName: {
type: Function, type: Function,
required: true required: true
},
inline: {
type: Boolean,
required: false,
default: false
} }
}, },
emits: ['close', 'save'], emits: ['close', 'save'],
@@ -1,7 +1,7 @@
<template> <template>
<div class="flex flex-1 min-h-0"> <div class="flex flex-col md:flex-row flex-1 min-h-0">
<div <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" style="container-type: size"
> >
<div <div
@@ -46,73 +46,73 @@
/> />
</div> </div>
</div> </div>
<div class="w-56 flex-none border-l px-4 py-4 space-y-2 flex flex-col"> <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">
<button <div class="grid grid-cols-3 gap-2 md:block md:space-y-2">
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300" <button
:disabled="!!isProcessing" 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"
@click.prevent="submit(true)" :disabled="!!isProcessing"
> @click.prevent="submit(true)"
<IconInnerShadowTop >
v-if="isProcessing === 'scan'" <IconInnerShadowTop
class="w-4 h-4 animate-spin" v-if="isProcessing === 'scan'"
/> class="w-4 h-4 animate-spin"
<IconScan />
v-else <IconScan
class="w-4 h-4" v-else
/> class="w-4 h-4"
{{ t('crop_and_scan') }} />
</button> {{ t('crop_and_scan') }}
<button </button>
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300" <button
:disabled="!!isProcessing" 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"
@click.prevent="submit(false)" :disabled="!!isProcessing"
> @click.prevent="submit(false)"
<IconInnerShadowTop >
v-if="isProcessing === 'crop'" <IconInnerShadowTop
class="w-4 h-4 animate-spin" v-if="isProcessing === 'crop'"
/> class="w-4 h-4 animate-spin"
<IconCrop />
v-else <IconCrop
width="22" v-else
height="22" class="w-4 h-4 md:w-[22px] md:h-[22px] md:-ml-[3px]"
style="margin-left: -3px" :stroke-width="1.5"
:stroke-width="1.5" />
/> <span :class="{ 'md:-ml-[3px]': isProcessing !== 'crop' }">
<span :style="{ 'margin-left': isProcessing === 'crop' ? '0px' : '-3px' }"> {{ t('crop') }}
{{ t('crop') }} </span>
</span> </button>
</button> <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"
class="btn btn-sm w-full justify-start normal-case font-normal rounded" @click.prevent="$emit('cancel')"
@click.prevent="$emit('cancel')" >
> <IconX class="w-4 h-4" />
<IconX class="w-4 h-4" /> {{ t('cancel') }}
{{ t('cancel') }} </button>
</button> <div class="hidden md:block border-t !mt-3 !mb-1" />
<div class="border-t !mt-3 !mb-1" /> <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 btn-sm w-full justify-start normal-case font-normal rounded" @click.prevent="rotateCw"
@click.prevent="rotateCw" >
> <IconRotateClockwise class="w-4 h-4" />
<IconRotateClockwise class="w-4 h-4" /> {{ t('rotate') }}
{{ t('rotate') }} </button>
</button> <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 btn-sm w-full justify-start normal-case font-normal rounded" :class="{ 'btn-active': flipH }"
:class="{ 'btn-active': flipH }" @click.prevent="toggleFlip('flipH')"
@click.prevent="toggleFlip('flipH')" >
> <IconFlipVertical class="w-4 h-4" />
<IconFlipVertical class="w-4 h-4" /> {{ t('flip_horizontal') }}
{{ t('flip_horizontal') }} </button>
</button> <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 btn-sm w-full justify-start normal-case font-normal rounded" :class="{ 'btn-active': flipV }"
:class="{ 'btn-active': flipV }" @click.prevent="toggleFlip('flipV')"
@click.prevent="toggleFlip('flipV')" >
> <IconFlipHorizontal class="w-4 h-4" />
<IconFlipHorizontal class="w-4 h-4" /> {{ t('flip_vertical') }}
{{ t('flip_vertical') }} </button>
</button> </div>
</div> </div>
</div> </div>
</template> </template>
@@ -1,19 +1,23 @@
<template> <template>
<div <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" @dragover.prevent="onModalDragover"
@dragleave="onModalDragleave" @dragleave="onModalDragleave"
@drop.prevent="onModalDrop" @drop.prevent="onModalDrop"
> >
<div <div
v-if="!inline"
class="absolute top-0 bottom-0 right-0 left-0" class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')" @click.prevent="$emit('close')"
/> />
<div <div
class="modal-box relative flex flex-col p-0 w-full h-full rounded-2xl" :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="max-width: 1240px; max-height: 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 <span
class="modal-title" class="modal-title"
style="padding-top: 2px" style="padding-top: 2px"
@@ -26,10 +30,13 @@
@click.prevent="$emit('close')" @click.prevent="$emit('close')"
>&times;</a> >&times;</a>
</div> </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 <CropView
v-if="cropPageItem" v-if="cropPageItem"
class="absolute inset-0 z-10 bg-base-100" class="absolute inset-0 z-10 bg-base-100"
:style="inline ? { paddingTop: '70px' } : null"
:template-id="template.id" :template-id="template.id"
:page="cropPageItem" :page="cropPageItem"
:image-url="thumbUrl(cropPageItem)" :image-url="thumbUrl(cropPageItem)"
@@ -40,6 +47,7 @@
<RedactView <RedactView
v-else-if="redactPageItem" v-else-if="redactPageItem"
class="absolute inset-0 z-10 bg-base-100" class="absolute inset-0 z-10 bg-base-100"
:style="inline ? { paddingTop: '70px' } : null"
:template-id="template.id" :template-id="template.id"
:page="redactPageItem" :page="redactPageItem"
:image-url="thumbUrl(redactPageItem)" :image-url="thumbUrl(redactPageItem)"
@@ -53,12 +61,16 @@
class="contents" class="contents"
:class="{ invisible: cropPageItem || redactPageItem }" :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 <div
v-for="(doc, docIndex) in layout" v-for="(doc, docIndex) in layout"
:key="doc.attachmentUuid" :key="doc.attachmentUuid"
:data-uuid="doc.attachmentUuid" :data-uuid="doc.attachmentUuid"
class="relative" class="relative"
:style="inline ? { scrollMarginTop: '86px' } : null"
> >
<div <div
class="flex items-center pb-2" class="flex items-center pb-2"
@@ -76,7 +88,7 @@
:class="{ 'rotate-90': !collapsedDocumentsIndex[doc.attachmentUuid] }" :class="{ 'rotate-90': !collapsedDocumentsIndex[doc.attachmentUuid] }"
/> />
<span <span
class="truncate" class="truncate min-w-0"
:class="{ 'line-through': !doc.pages.length }" :class="{ 'line-through': !doc.pages.length }"
>{{ doc.name }}</span> >{{ doc.name }}</span>
</button> </button>
@@ -170,7 +182,7 @@
</div> </div>
<div <div
v-if="(!collapsedDocumentsIndex[doc.attachmentUuid] || layout.length === 1) && doc.pages.length" 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)" @dragover.prevent="onGridDragover(docIndex, $event)"
@drop.prevent="onPageDrop" @drop.prevent="onPageDrop"
> >
@@ -178,7 +190,8 @@
v-for="(page, pageIndex) in doc.pages" v-for="(page, pageIndex) in doc.pages"
:key="page.id" :key="page.id"
:data-page-id="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" draggable="true"
@click="selectedPageId = page.id" @click="selectedPageId = page.id"
@mousedown="onPageMousedown(page, $event)" @mousedown="onPageMousedown(page, $event)"
@@ -245,26 +258,31 @@
</template> </template>
</div> </div>
</div> </div>
<div class="w-56 flex-none border-l px-4 py-4 flex flex-col"> <div
<div class="space-y-2"> 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 <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" :disabled="!selectedPageId"
@click.prevent="movePage(-1)" @click.prevent="movePage(-1)"
> >
<IconArrowBackUp class="w-4 h-4" /> <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>
<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" :disabled="!selectedPageId"
@click.prevent="movePage(1)" @click.prevent="movePage(1)"
> >
<IconArrowForwardUp class="w-4 h-4" /> <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>
<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" :disabled="!selectedPageId"
@click.prevent="rotatePage" @click.prevent="rotatePage"
> >
@@ -272,7 +290,7 @@
{{ t('rotate') }} {{ t('rotate') }}
</button> </button>
<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" :disabled="!selectedPageId || isReplacing"
@click.prevent="replaceSelected" @click.prevent="replaceSelected"
> >
@@ -303,7 +321,7 @@
@change="onReplaceDocFilesPicked" @change="onReplaceDocFilesPicked"
> >
<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" :disabled="!selectedPageId"
@click.prevent="openRedactView" @click.prevent="openRedactView"
> >
@@ -312,29 +330,28 @@
</button> </button>
<button <button
v-if="isSelectedPageCroppable" 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" @click.prevent="openCropView"
> >
<IconCrop <IconCrop
width="22" class="w-4 h-4 md:w-[22px] md:h-[22px] md:-ml-[3px]"
height="22"
style="margin-left: -3px"
:stroke-width="1.5" :stroke-width="1.5"
/> />
<span style="margin-left: -3px"> <span class="md:-ml-[3px]">
{{ t('crop') }} {{ t('crop') }}
</span> </span>
</button> </button>
<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" :disabled="!selectedPageId"
@click.prevent="removePage" @click.prevent="removePage"
> >
<IconTrashX class="w-4 h-4" /> <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> </button>
</div> </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 <Upload
:template-id="template.id" :template-id="template.id"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
@@ -432,6 +449,11 @@ export default {
type: String, type: String,
required: false, required: false,
default: 'image/*, application/pdf' default: 'image/*, application/pdf'
},
inline: {
type: Boolean,
required: false,
default: false
} }
}, },
emits: ['close', 'saved'], emits: ['close', 'saved'],
@@ -689,12 +711,14 @@ export default {
if (targetIndex < 0) { if (targetIndex < 0) {
if (docIndex > 0) { if (docIndex > 0) {
this.layout[docIndex - 1].pages.push(page) this.layout[docIndex - 1].pages.push(page)
this.collapsedDocumentsIndex[this.layout[docIndex - 1].attachmentUuid] = false
} else { } else {
this.layout[docIndex].pages.unshift(page) this.layout[docIndex].pages.unshift(page)
} }
} else if (targetIndex > this.layout[docIndex].pages.length) { } else if (targetIndex > this.layout[docIndex].pages.length) {
if (docIndex < this.layout.length - 1) { if (docIndex < this.layout.length - 1) {
this.layout[docIndex + 1].pages.unshift(page) this.layout[docIndex + 1].pages.unshift(page)
this.collapsedDocumentsIndex[this.layout[docIndex + 1].attachmentUuid] = false
} else { } else {
this.layout[docIndex].pages.push(page) this.layout[docIndex].pages.push(page)
} }
@@ -703,6 +727,12 @@ export default {
} }
this.pushUndo() this.pushUndo()
this.scrollIntoList(`[data-page-id="${page.id}"]`)
},
scrollIntoList (selector) {
this.$nextTick(() => {
this.$el.querySelector(selector)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
})
}, },
removePage () { removePage () {
const position = this.selectedPosition const position = this.selectedPosition
@@ -728,6 +758,7 @@ export default {
this.layout.splice(docIndex + direction, 0, doc) this.layout.splice(docIndex + direction, 0, doc)
this.pushUndo() this.pushUndo()
this.scrollIntoList(`[data-uuid="${doc.attachmentUuid}"]`)
}, },
mergeDocument (docIndex, direction) { mergeDocument (docIndex, direction) {
const doc = this.layout[docIndex] const doc = this.layout[docIndex]
@@ -1,6 +1,6 @@
<template> <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 overflow-y-auto px-6 py-4"> <div class="flex-1 overflow-y-auto overflow-x-hidden px-4 md:px-6 py-4">
<div <div
ref="pageEl" ref="pageEl"
class="relative mx-auto select-none cursor-crosshair" class="relative mx-auto select-none cursor-crosshair"
@@ -48,8 +48,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="w-56 flex-none border-l px-4 py-4 space-y-2"> <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-1"> <div class="flex items-center justify-between mb-3 md:mb-1">
<span class="text-sm pl-1">{{ t('color') }}</span> <span class="text-sm pl-1">{{ t('color') }}</span>
<div <div
class="join rounded" class="join rounded"
@@ -69,30 +69,32 @@
</button> </button>
</div> </div>
</div> </div>
<button <div class="grid grid-cols-3 gap-2 md:block md:space-y-2">
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300" <button
:disabled="!hasRedactions && !wasReset" 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="apply" :disabled="!hasRedactions && !wasReset"
> @click.prevent="apply"
<IconCheck class="w-4 h-4" /> >
{{ t('apply') }} <IconCheck class="w-4 h-4" />
</button> {{ t('apply') }}
<div class="border-t !mt-3 !mb-1" /> </button>
<button <div class="hidden md:block border-t !mt-3 !mb-1" />
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300" <button
:disabled="!hasRedactions" 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="reset" :disabled="!hasRedactions"
> @click.prevent="reset"
<IconRotate class="w-4 h-4" /> >
{{ t('reset') }} <IconRotate class="w-4 h-4" />
</button> {{ t('reset') }}
<button </button>
class="btn btn-sm w-full justify-start normal-case font-normal rounded" <button
@click.prevent="$emit('cancel')" 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') }} <IconX class="w-4 h-4" />
</button> {{ t('cancel') }}
</button>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -5,7 +5,7 @@
:style="dragPlaceholderStyle" :style="dragPlaceholderStyle"
:field="field" :field="field"
:with-options="false" :with-options="false"
class="fixed z-20 pointer-events-none" class="fixed z-50 pointer-events-none"
:editable="false" :editable="false"
/> />
<CustomField <CustomField
@@ -13,13 +13,13 @@
ref="dragPlaceholder" ref="dragPlaceholder"
:style="dragPlaceholderStyle" :style="dragPlaceholderStyle"
:field="field" :field="field"
class="fixed z-20 pointer-events-none opacity-90" class="fixed z-50 pointer-events-none opacity-90"
/> />
<div <div
v-else-if="dragPlaceholder && (isDefault || isRequired) && !isMask && field" v-else-if="dragPlaceholder && (isDefault || isRequired) && !isMask && field"
ref="dragPlaceholder" ref="dragPlaceholder"
:style="[dragPlaceholderStyle, { backgroundColor: backgroundColor }]" :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 justify-between relative cursor-grab">
<div class="flex items-center p-1 space-x-1"> <div class="flex items-center p-1 space-x-1">
@@ -45,7 +45,7 @@
<button <button
v-else-if="dragPlaceholder && !isMask && field" v-else-if="dragPlaceholder && !isMask && field"
ref="dragPlaceholder" 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 }]" :style="[dragPlaceholderStyle, { backgroundColor }]"
> >
<div <div
+7 -2
View File
@@ -38,7 +38,12 @@
v-if="withDescription" v-if="withDescription"
class="text-sm" 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> </div>
<button <button
v-if="withGoogleDrive" v-if="withGoogleDrive"
@@ -83,7 +88,7 @@ export default {
IconFiles, IconFiles,
IconBrandGoogleDrive IconBrandGoogleDrive
}, },
inject: ['baseFetch', 't'], inject: ['baseFetch', 't', 'isMobile'],
props: { props: {
templateId: { templateId: {
type: [Number, String], 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({ const LinkMark = Mark.create({
name: 'link', name: 'link',
excludes: '', excludes: '',
@@ -790,6 +807,7 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlFor
ColNode, ColNode,
DynamicImageNode, DynamicImageNode,
EmptySpanNode, EmptySpanNode,
LayoutSpanMark,
LinkMark, LinkMark,
SpanMark, SpanMark,
CustomBold, CustomBold,
@@ -282,7 +282,7 @@ export default {
}, },
methods: { methods: {
reloadContent () { 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) { findAreaNodePos (areaUuid) {
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`) const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
+169 -20
View File
@@ -20,7 +20,7 @@
:menu-classes="'mt-1.5'" :menu-classes="'mt-1.5'"
:menu-style="{ backgroundColor: dropdownBgColor }" :menu-style="{ backgroundColor: dropdownBgColor }"
@update:model-value="[maybeUpdateOptions(), $emit('save')]" @update:model-value="[maybeUpdateOptions(), $emit('save')]"
@click="scrollToFirstArea" @click="scrollOnEdit && scrollToFirstArea()"
/> />
<Contenteditable <Contenteditable
ref="name" ref="name"
@@ -29,7 +29,8 @@
:icon-inline="true" :icon-inline="true"
:icon-width="18" :icon-width="18"
:icon-stroke-width="1.6" :icon-stroke-width="1.6"
@focus="[onNameFocus(), scrollToFirstArea()]" :contenteditable-classes="isMobile ? 'py-2 -my-2' : ''"
@focus="[onNameFocus(), scrollOnEdit && scrollToFirstArea()]"
@blur="onNameBlur" @blur="onNameBlur"
/> />
</div> </div>
@@ -60,7 +61,8 @@
<button <button
v-if="field && !field.areas?.length" v-if="field && !field.areas?.length"
:title="t('draw')" :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 })" @click="$emit('set-draw', { field })"
> >
<IconNewSection <IconNewSection
@@ -70,9 +72,10 @@
</button> </button>
<button <button
v-if="field.preferences?.formula" 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')" :title="t('formula')"
@click="isShowFormulaModal = true" @click="openFormulaModal"
> >
<IconMathFunction <IconMathFunction
:width="18" :width="18"
@@ -81,9 +84,10 @@
</button> </button>
<button <button
v-if="field.conditions?.length" 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')" :title="t('condition')"
@click="isShowConditionsModal = true" @click="openConditionsModal"
> >
<IconRouteAltLeft <IconRouteAltLeft
:width="18" :width="18"
@@ -93,13 +97,56 @@
<PaymentSettings <PaymentSettings
v-if="field.type === 'payment'" v-if="field.type === 'payment'"
:field="field" :field="field"
:background-color="dropdownBgColor"
:with-custom-fields="withCustomFields" :with-custom-fields="withCustomFields"
@click-condition="isShowConditionsModal = true" @click-condition="openConditionsModal"
@click-description="isShowDescriptionModal = true" @click-description="openDescriptionModal"
@add-custom-field="$emit('add-custom-field', $event)" @add-custom-field="$emit('add-custom-field', $event)"
@click-formula="isShowFormulaModal = true" @click-formula="openFormulaModal"
@save="$emit('save')" @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 <span
v-else v-else
class="dropdown dropdown-end field-settings-dropdown" class="dropdown dropdown-end field-settings-dropdown"
@@ -109,9 +156,23 @@
<label <label
tabindex="0" tabindex="0"
:title="t('settings')" :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 <IconSettings
v-else
:width="18" :width="18"
:stroke-width="1.6" :stroke-width="1.6"
/> />
@@ -133,20 +194,38 @@
:with-prefillable="withPrefillable" :with-prefillable="withPrefillable"
:background-color="dropdownBgColor" :background-color="dropdownBgColor"
:with-custom-fields="withCustomFields" :with-custom-fields="withCustomFields"
@click-formula="isShowFormulaModal = true" @click-formula="openFormulaModal"
@click-font="isShowFontModal = true" @click-font="openFontModal"
@click-description="isShowDescriptionModal = true" @click-description="openDescriptionModal"
@click-condition="isShowConditionsModal = true" @click-condition="openConditionsModal"
@set-draw="$emit('set-draw', $event)" @set-draw="$emit('set-draw', $event)"
@add-custom-field="$emit('add-custom-field', $event)" @add-custom-field="$emit('add-custom-field', $event)"
@remove-area="removeArea" @remove-area="removeArea"
@save="$emit('save')" @save="$emit('save')"
@scroll-to="$emit('scroll-to', $event)" @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> </ul>
</span> </span>
<button <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')" :title="t('remove')"
@click="$emit('remove', field)" @click="$emit('remove', field)"
> >
@@ -324,7 +403,7 @@ import FormulaModal from './formula_modal'
import FontModal from './font_modal' import FontModal from './font_modal'
import ConditionsModal from './conditions_modal' import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_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' import { v4 } from 'uuid'
export default { export default {
@@ -332,6 +411,7 @@ export default {
components: { components: {
Contenteditable, Contenteditable,
IconSettings, IconSettings,
IconDotsVertical,
FieldSettings, FieldSettings,
PaymentSettings, PaymentSettings,
IconChevronDown, IconChevronDown,
@@ -345,12 +425,17 @@ export default {
IconMathFunction, IconMathFunction,
FieldType FieldType
}, },
inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex', 'dateFormats'], inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex', 'dateFormats', 'isMobile', 'nativePlatform'],
props: { props: {
field: { field: {
type: Object, type: Object,
required: true required: true
}, },
scrollOnEdit: {
type: Boolean,
required: false,
default: true
},
withSignatureId: { withSignatureId: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -382,9 +467,11 @@ export default {
default: true 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 () { data () {
return { return {
touchDrag: null,
dropdownWasOpen: false,
isExpandOptions: false, isExpandOptions: false,
isNameFocus: false, isNameFocus: false,
isShowFormulaModal: false, isShowFormulaModal: false,
@@ -396,6 +483,9 @@ export default {
} }
}, },
computed: { computed: {
iconColorClass () {
return this.isMobile ? 'text-base-content' : 'text-transparent group-hover:text-base-content'
},
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels, fieldLabels: FieldType.computed.fieldLabels,
dropdownBgColor () { dropdownBgColor () {
@@ -433,11 +523,70 @@ export default {
} }
}, },
methods: { 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) { removeArea (area) {
this.field.areas.splice(this.field.areas.indexOf(area), 1) this.field.areas.splice(this.field.areas.indexOf(area), 1)
this.$emit('save') 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) { buildDefaultName (field) {
if (field.type === 'payment' && field.preferences?.price && !field.preferences?.formula) { if (field.type === 'payment' && field.preferences?.price && !field.preferences?.formula) {
const { price, currency } = field.preferences || {} const { price, currency } = field.preferences || {}
@@ -515,7 +515,7 @@ export default {
ContextSubmenu, ContextSubmenu,
ContextModal ContextModal
}, },
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats', 'locale'], inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats', 'locale', 'withFormula', 'withConditions', 'nativePlatform'],
props: { props: {
contextMenu: { contextMenu: {
type: Object, type: Object,
@@ -608,10 +608,10 @@ export default {
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type) return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)
}, },
showCondition () { 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 () { 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 () { showRequired () {
return this.withRequired && !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type) return this.withRequired && !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type)
@@ -484,7 +484,7 @@
</label> </label>
</li> </li>
<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" class="field-settings-condition"
> >
<label <label
@@ -500,7 +500,7 @@
</label> </label>
</li> </li>
<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" class="field-settings-formula"
> >
<label <label
@@ -610,7 +610,7 @@ export default {
IconTypography, IconTypography,
IconX IconX
}, },
inject: ['template', 't', 'dateFormats', 'locale'], inject: ['template', 't', 'dateFormats', 'locale', 'withFormula', 'withConditions', 'nativePlatform'],
props: { props: {
field: { field: {
type: Object, type: Object,
@@ -125,7 +125,10 @@
@update:model-value="$emit('name-change', selectedSubmitter)" @update:model-value="$emit('name-change', selectedSubmitter)"
/> />
</div> </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 <component
:is="editable ? 'IconPlus' : 'IconChevronDown'" :is="editable ? 'IconPlus' : 'IconChevronDown'"
width="18" width="18"
@@ -164,7 +167,10 @@
v-if="!compact && submitters.length > 1 && editable" v-if="!compact && submitters.length > 1 && editable"
class="flex" 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 <button
:title="t('up')" :title="t('up')"
class="relative w-2" class="relative w-2"
@@ -184,7 +190,8 @@
</div> </div>
<button <button
v-if="!compact && submitters.length > 1 && editable" 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)" @click.prevent.stop="remove(submitter)"
> >
<IconTrashX :width="18" /> <IconTrashX :width="18" />
@@ -237,7 +244,7 @@ export default {
IconTrashX, IconTrashX,
IconChevronUp IconChevronUp
}, },
inject: ['t', 'save'], inject: ['t', 'save', 'isMobile'],
props: { props: {
submitters: { submitters: {
type: Array, type: Array,
+129 -28
View File
@@ -1,6 +1,6 @@
<template> <template>
<div <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 } : {}" :style="withStickySubmitters ? { backgroundColor } : {}"
class="flex items-center gap-1" class="flex items-center gap-1"
> >
@@ -48,6 +48,7 @@
:key="field.uuid" :key="field.uuid"
:data-uuid="field.uuid" :data-uuid="field.uuid"
:field="field" :field="field"
:scroll-on-edit="scrollOnEdit"
:type-index="getFieldTypeIndex(field)" :type-index="getFieldTypeIndex(field)"
:editable="editable" :editable="editable"
:with-signature-id="withSignatureId" :with-signature-id="withSignatureId"
@@ -63,6 +64,8 @@
@remove="removeField" @remove="removeField"
@scroll-to="$emit('scroll-to-area', $event)" @scroll-to="$emit('scroll-to-area', $event)"
@set-draw="$emit('set-draw', $event)" @set-draw="$emit('set-draw', $event)"
@touch-drag="onTouchDrag"
@touch-drag-end="onTouchDragEnd"
/> />
</div> </div>
<div <div
@@ -169,6 +172,7 @@
@click="setFieldsTab('custom')" @click="setFieldsTab('custom')"
>{{ t('custom') }}</a> >{{ t('custom') }}</a>
</div> </div>
<div ref="addFieldsMarker" />
<div <div
v-if="!isShowVariables && showCustomTab && editable && (customFields.length || newCustomField)" v-if="!isShowVariables && showCustomTab && editable && (customFields.length || newCustomField)"
ref="customFields" ref="customFields"
@@ -271,7 +275,7 @@
</div> </div>
</button> </button>
<div <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 tooltip-bottom flex"
:class="{'tooltip-bottom-end': withPayment, 'tooltip-bottom': !withPayment }" :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')" :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> </template>
</div> </div>
<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-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> <li>
{{ t('draw_a_text_field_on_the_page_with_a_mouse') }} {{ t('draw_a_text_field_on_the_page_with_a_mouse') }}
</li> </li>
@@ -355,24 +366,16 @@
width="22" width="22"
class="animate-spin" class="animate-spin"
/> />
<span <span v-if="analyzingProgress">
v-if="analyzingProgress"
class="hidden md:inline"
>
{{ Math.round(analyzingProgress * 100) }}% {{ t('analyzing_') }} {{ Math.round(analyzingProgress * 100) }}% {{ t('analyzing_') }}
</span> </span>
<span <span v-else>
v-else
class="hidden md:inline"
>
{{ fieldPagesLoaded }} / {{ numberOfPages }} {{ t('processing_') }} {{ fieldPagesLoaded }} / {{ numberOfPages }} {{ t('processing_') }}
</span> </span>
</template> </template>
<template v-else> <template v-else>
<IconSparkles width="22" /> <IconSparkles width="22" />
<span <span>
class="hidden md:inline"
>
{{ t('autodetect_fields') }} {{ t('autodetect_fields') }}
</span> </span>
</template> </template>
@@ -395,6 +398,26 @@
</label> </label>
</div> </div>
</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> </template>
<script> <script>
@@ -403,13 +426,14 @@ import CustomField from './custom_field'
import FieldType from './field_type' import FieldType from './field_type'
import FieldSubmitter from './field_submitter' import FieldSubmitter from './field_submitter'
import { defineAsyncComponent } from 'vue' 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 IconDrag from './icon_drag'
import { v4 } from 'uuid' import { v4 } from 'uuid'
export default { export default {
name: 'TemplateFields', name: 'TemplateFields',
components: { components: {
IconPlus,
Field, Field,
CustomField, CustomField,
FieldType, FieldType,
@@ -422,7 +446,7 @@ export default {
IconBracketsContain, IconBracketsContain,
DynamicVariables: defineAsyncComponent(() => import(/* webpackChunkName: "dynamic-editor" */ './dynamic_variables')) 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: { props: {
fields: { fields: {
type: Array, type: Array,
@@ -482,6 +506,16 @@ export default {
required: false, required: false,
default: true default: true
}, },
scrollOnEdit: {
type: Boolean,
required: false,
default: true
},
withAddFieldsPill: {
type: Boolean,
required: false,
default: true
},
defaultFields: { defaultFields: {
type: Array, type: Array,
required: false, required: false,
@@ -512,6 +546,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
withTopPadding: {
type: Boolean,
required: false,
default: false
},
fieldTypes: { fieldTypes: {
type: Array, type: Array,
required: false, 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'], 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 () { data () {
return { return {
isShowAddFieldsPill: false,
fieldPagesLoaded: null, fieldPagesLoaded: null,
analyzingProgress: 0, analyzingProgress: 0,
newCustomField: null, newCustomField: null,
@@ -622,8 +662,22 @@ export default {
} catch (e) { } catch (e) {
console.error(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: { methods: {
scrollToFieldTypes () {
this.$refs.addFieldsMarker.scrollIntoView({ block: 'start', behavior: 'smooth' })
},
toggleVariables () { toggleVariables () {
this.$emit('rebuild-variables-schema') this.$emit('rebuild-variables-schema')
this.isShowVariables = !this.isShowVariables this.isShowVariables = !this.isShowVariables
@@ -692,6 +746,10 @@ export default {
const fields = await resp.json() const fields = await resp.json()
this.customFields.splice(0, this.customFields.length, ...fields) 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 () { buildExistingFields () {
@@ -823,6 +881,10 @@ export default {
this.save() this.save()
if (this.nativePlatform) {
window.webkit?.messageHandlers?.flash?.postMessage({ style: 'notice', message: this.t('fields_detected').replace('{count}', (data.fields || fields).length) })
}
break break
} else if (data.fields) { } else if (data.fields) {
data.fields.forEach((f) => { data.fields.forEach((f) => {
@@ -878,19 +940,58 @@ export default {
}, },
onFieldDragover (e) { onFieldDragover (e) {
if (this.fieldsDragFieldRef.value) { if (this.fieldsDragFieldRef.value) {
const targetField = e.target.closest('[data-uuid]') this.moveFieldElement(this.fieldsDragFieldRef.value, e.target.closest('[data-uuid]'))
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.fieldsDragFieldRef.value.uuid}"]`) }
},
onTouchDrag ({ field, x, y }) {
const dragField = this.$refs.fields.querySelector(`[data-uuid="${field.uuid}"]`)
if (dragField && targetField && targetField !== dragField) { if (!dragField) return
const fields = Array.from(this.$refs.fields.children)
const currentIndex = fields.indexOf(dragField)
const targetIndex = fields.indexOf(targetField)
if (currentIndex < targetIndex) { if (!this.touchPreview) {
targetField.after(dragField) const rect = dragField.getBoundingClientRect()
} else { const el = dragField.cloneNode(true)
targetField.before(dragField) 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)
} }
} }
}, },
+14 -5
View File
@@ -1,13 +1,17 @@
<template> <template>
<div <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 <div
v-if="!inline"
class="absolute top-0 bottom-0 right-0 left-0" class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')" @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="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 class="flex justify-between items-center border-b pb-2 mb-2 font-medium"> <div
v-if="!inline"
class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
>
<span class="modal-title"> <span class="modal-title">
{{ t('font') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }} {{ t('font') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
</span> </span>
@@ -19,7 +23,7 @@
</div> </div>
<div class="mt-4"> <div class="mt-4">
<div> <div>
<div class="flex items-center space-x-1.5"> <div class="flex flex-wrap items-center gap-1.5">
<span> <span>
<div class="dropdown modal-field-font-dropdown"> <div class="dropdown modal-field-font-dropdown">
<label <label
@@ -168,7 +172,7 @@
:class="textClasses" :class="textClasses"
> >
<span <span
contenteditable="true" :contenteditable="!inline"
class="outline-none whitespace-nowrap truncate" class="outline-none whitespace-nowrap truncate"
> >
{{ field.default_value || field.name || buildDefaultName(field) }} {{ field.default_value || field.name || buildDefaultName(field) }}
@@ -215,6 +219,11 @@ export default {
buildDefaultName: { buildDefaultName: {
type: Function, type: Function,
required: true required: true
},
inline: {
type: Boolean,
required: false,
default: false
} }
}, },
emits: ['close', 'save'], emits: ['close', 'save'],
@@ -1,13 +1,17 @@
<template> <template>
<div <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 <div
v-if="!inline"
class="absolute top-0 bottom-0 right-0 left-0" class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')" @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="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 class="flex justify-between items-center border-b pb-2 mb-2 font-medium"> <div
v-if="!inline"
class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
>
<span class="modal-title"> <span class="modal-title">
{{ t('formula') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }} {{ t('formula') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
</span> </span>
@@ -67,8 +71,22 @@
> >
<div <div
target="blank" 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(, , &quot;y&quot;)', 8)"
>
datedif(start, end, "y")
</button>
</template>
<button <button
class="bg-base-200 px-2 rounded-xl" class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' + ')" @click="insertTextUnderCursor(' + ')"
@@ -101,13 +119,13 @@
</button> </button>
<button <button
class="bg-base-200 px-2 rounded-xl" class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor('round()')" @click="insertTextUnderCursor('round()', 6)"
> >
round(n, d) round(n, d)
</button> </button>
<button <button
class="bg-base-200 px-2 rounded-xl" class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor('abs()')" @click="insertTextUnderCursor('abs()', 4)"
> >
abs(n) abs(n)
</button> </button>
@@ -153,6 +171,11 @@ export default {
buildDefaultName: { buildDefaultName: {
type: Function, type: Function,
required: true required: true
},
inline: {
type: Boolean,
required: false,
default: false
} }
}, },
emits: ['close', 'save'], emits: ['close', 'save'],
@@ -162,6 +185,11 @@ export default {
} }
}, },
computed: { 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 () { fields () {
return this.template.fields.reduce((acc, f) => { return this.template.fields.reduce((acc, f) => {
const isAllowed = this.field.type === 'text' ? this.isTextField(f) : this.isNumberField(f) const isAllowed = this.field.type === 'text' ? this.isTextField(f) : this.isNumberField(f)
@@ -182,7 +210,7 @@ export default {
}, },
methods: { methods: {
isNumberField (field) { 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) { isTextField (field) {
return ['text', 'number', 'select', 'radio', 'cells', 'phone'].includes(field.type) return ['text', 'number', 'select', 'radio', 'cells', 'phone'].includes(field.type)
@@ -235,18 +263,19 @@ export default {
this.$emit('close') this.$emit('close')
} }
}, },
insertTextUnderCursor (textToInsert) { insertTextUnderCursor (textToInsert, cursorOffset = textToInsert.length) {
const textarea = this.$refs.textarea const textarea = this.$refs.textarea
const selectionEnd = textarea.selectionEnd const { selectionStart, selectionEnd } = textarea
const cursorPos = selectionEnd 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.formula = newText
this.$nextTick(() => { this.$nextTick(() => {
textarea.setSelectionRange(cursorPos + textToInsert.length, cursorPos + textToInsert.length) textarea.setSelectionRange(cursorPos + cursorOffset + selectedText.length, cursorPos + cursorOffset + selectedText.length)
textarea.focus() textarea.focus()
}) })
+63
View File
@@ -1,13 +1,16 @@
const en = { const en = {
name: 'Name',
fixed: 'Fixed', fixed: 'Fixed',
default: 'Default', default: 'Default',
save_as_custom_field: 'Save as custom field', save_as_custom_field: 'Save as custom field',
kba: 'KBA', kba: 'KBA',
field_has_already_been_added_for_party: '{field} field has already been added for {party}',
analyzing_: 'Analyzing...', analyzing_: 'Analyzing...',
download: 'Download', download: 'Download',
downloading_: 'Downloading...', downloading_: 'Downloading...',
view: 'View', view: 'View',
autodetect_fields: 'Autodetect Fields', autodetect_fields: 'Autodetect Fields',
fields_detected: '{count} fields detected',
payment_link: 'Payment link', payment_link: 'Payment link',
strikeout: 'Strikeout', strikeout: 'Strikeout',
draw_strikethrough_the_document: 'Draw strikethrough the document', draw_strikethrough_the_document: 'Draw strikethrough the document',
@@ -97,8 +100,11 @@ const en = {
add_documents_or_images: 'Add documents or images', add_documents_or_images: 'Add documents or images',
add_a_new_document: 'Add a new document', add_a_new_document: 'Add a new document',
edit_documents: 'Edit documents', edit_documents: 'Edit documents',
documents: 'Documents',
move_forward: 'Move forward', move_forward: 'Move forward',
move_backward: 'Move backward', move_backward: 'Move backward',
backward: 'Backward',
forward: 'Forward',
remove_page: 'Remove page', remove_page: 'Remove page',
merge_with_previous: 'Merge with previous', merge_with_previous: 'Merge with previous',
merge_with_next: 'Merge with next', 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', 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', 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', 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.', 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.', 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.', 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', replace: 'Replace',
uploading_: 'Uploading...', uploading_: 'Uploading...',
add_document: 'Add Document', add_document: 'Add Document',
add_fields: 'Add Fields',
fields: 'Fields',
none: 'None', none: 'None',
ssn: 'SSN', ssn: 'SSN',
ein: 'EIN', ein: 'EIN',
@@ -243,11 +252,14 @@ const en = {
} }
const es = { const es = {
name: 'Nombre',
fixed: 'Fijo', fixed: 'Fijo',
default: 'Predeterminado', default: 'Predeterminado',
save_as_custom_field: 'Guardar como personalizado', save_as_custom_field: 'Guardar como personalizado',
kba: 'KBA', kba: 'KBA',
field_has_already_been_added_for_party: 'El campo {field} ya se ha agregado para {party}',
autodetect_fields: 'Autodetectar campos', autodetect_fields: 'Autodetectar campos',
fields_detected: '{count} campos detectados',
analyzing_: 'Analizando...', analyzing_: 'Analizando...',
download: 'Descargar', download: 'Descargar',
downloading_: 'Descargando...', downloading_: 'Descargando...',
@@ -338,8 +350,11 @@ const es = {
add_documents_or_images: 'Agregar documentos o imágenes', add_documents_or_images: 'Agregar documentos o imágenes',
add_a_new_document: 'Agregar un nuevo documento', add_a_new_document: 'Agregar un nuevo documento',
edit_documents: 'Editar documentos', edit_documents: 'Editar documentos',
documents: 'Documentos',
move_forward: 'Mover adelante', move_forward: 'Mover adelante',
move_backward: 'Mover atrás', move_backward: 'Mover atrás',
backward: 'Atrás',
forward: 'Adelante',
remove_page: 'Eliminar página', remove_page: 'Eliminar página',
merge_with_previous: 'Combinar con el anterior', merge_with_previous: 'Combinar con el anterior',
merge_with_next: 'Combinar con el siguiente', 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', 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', 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', 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.', 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.', 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.', 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', replace: 'Reemplazar',
uploading_: 'Subiendo...', uploading_: 'Subiendo...',
add_document: 'Subir', add_document: 'Subir',
add_fields: 'Agregar campos',
fields: 'Campos',
any: 'Cualquier', any: 'Cualquier',
drawn: 'Dibujado', drawn: 'Dibujado',
drawn_or_typed: 'Dibujado o Escrito', drawn_or_typed: 'Dibujado o Escrito',
@@ -487,11 +505,14 @@ const es = {
} }
const it = { const it = {
name: 'Nome',
fixed: 'Fisso', fixed: 'Fisso',
default: 'Predefinito', default: 'Predefinito',
save_as_custom_field: 'Salva come personalizzato', save_as_custom_field: 'Salva come personalizzato',
kba: 'KBA', kba: 'KBA',
field_has_already_been_added_for_party: 'Il campo {field} è già stato aggiunto per {party}',
autodetect_fields: 'Rileva campi', autodetect_fields: 'Rileva campi',
fields_detected: '{count} campi rilevati',
analyzing_: 'Analisi...', analyzing_: 'Analisi...',
download: 'Scarica', download: 'Scarica',
downloading_: 'Download in corso...', downloading_: 'Download in corso...',
@@ -585,8 +606,11 @@ const it = {
add_documents_or_images: 'Aggiungi documenti o immagini', add_documents_or_images: 'Aggiungi documenti o immagini',
add_a_new_document: 'Aggiungi un nuovo documento', add_a_new_document: 'Aggiungi un nuovo documento',
edit_documents: 'Modifica documenti', edit_documents: 'Modifica documenti',
documents: 'Documenti',
move_forward: 'Sposta avanti', move_forward: 'Sposta avanti',
move_backward: 'Sposta indietro', move_backward: 'Sposta indietro',
backward: 'Indietro',
forward: 'Avanti',
remove_page: 'Rimuovi pagina', remove_page: 'Rimuovi pagina',
merge_with_previous: 'Unisci al precedente', merge_with_previous: 'Unisci al precedente',
merge_with_next: 'Unisci al successivo', 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', 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', 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', 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.', 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.', 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.', 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', replace: 'Sostituisci',
uploading_: 'Caricamento in corso...', uploading_: 'Caricamento in corso...',
add_document: 'Aggiungi', add_document: 'Aggiungi',
add_fields: 'Aggiungi campi',
fields: 'Campi',
none: 'Nessuno', none: 'Nessuno',
ssn: 'SSN', ssn: 'SSN',
ein: 'EIN', ein: 'EIN',
@@ -731,11 +758,14 @@ const it = {
} }
const pt = { const pt = {
name: 'Nome',
fixed: 'Fixo', fixed: 'Fixo',
default: 'Padrão', default: 'Padrão',
save_as_custom_field: 'Salvar como personalizado', save_as_custom_field: 'Salvar como personalizado',
kba: 'KBA', kba: 'KBA',
field_has_already_been_added_for_party: 'O campo {field} já foi adicionado para {party}',
autodetect_fields: 'Detectar campos', autodetect_fields: 'Detectar campos',
fields_detected: '{count} campos detectados',
analyzing_: 'Analisando...', analyzing_: 'Analisando...',
download: 'Baixar', download: 'Baixar',
downloading_: 'Baixando...', downloading_: 'Baixando...',
@@ -826,8 +856,11 @@ const pt = {
add_documents_or_images: 'Adicionar documentos ou imagens', add_documents_or_images: 'Adicionar documentos ou imagens',
add_a_new_document: 'Adicionar um novo documento', add_a_new_document: 'Adicionar um novo documento',
edit_documents: 'Editar documentos', edit_documents: 'Editar documentos',
documents: 'Documentos',
move_forward: 'Mover para frente', move_forward: 'Mover para frente',
move_backward: 'Mover para trás', move_backward: 'Mover para trás',
backward: 'Para trás',
forward: 'Para frente',
remove_page: 'Remover página', remove_page: 'Remover página',
merge_with_previous: 'Mesclar com o anterior', merge_with_previous: 'Mesclar com o anterior',
merge_with_next: 'Mesclar com o próximo', 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', 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', 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', 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.', 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.', 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.', 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', replace: 'Substituir',
uploading_: 'Carregando...', uploading_: 'Carregando...',
add_document: 'Enviar', add_document: 'Enviar',
add_fields: 'Adicionar campos',
fields: 'Campos',
any: 'Qualquer', any: 'Qualquer',
drawn: 'Desenhado', drawn: 'Desenhado',
drawn_or_typed: 'Desenhado ou Digitado', drawn_or_typed: 'Desenhado ou Digitado',
@@ -975,11 +1011,14 @@ const pt = {
} }
const fr = { const fr = {
name: 'Nom',
fixed: 'Fixe', fixed: 'Fixe',
default: 'Par défaut', default: 'Par défaut',
save_as_custom_field: 'Enregistrer comme personnalisé', save_as_custom_field: 'Enregistrer comme personnalisé',
kba: 'KBA', 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', autodetect_fields: 'Détecter les champs',
fields_detected: '{count} champs détectés',
analyzing_: 'Analyse...', analyzing_: 'Analyse...',
download: 'Télécharger', download: 'Télécharger',
downloading_: 'Téléchargement...', downloading_: 'Téléchargement...',
@@ -1073,8 +1112,11 @@ const fr = {
add_documents_or_images: 'Ajouter des documents ou des images', add_documents_or_images: 'Ajouter des documents ou des images',
add_a_new_document: 'Ajouter un nouveau document', add_a_new_document: 'Ajouter un nouveau document',
edit_documents: 'Modifier les documents', edit_documents: 'Modifier les documents',
documents: 'Documents',
move_forward: 'Déplacer en avant', move_forward: 'Déplacer en avant',
move_backward: 'Déplacer en arrière', move_backward: 'Déplacer en arrière',
backward: 'Reculer',
forward: 'Avancer',
remove_page: 'Supprimer la page', remove_page: 'Supprimer la page',
merge_with_previous: 'Fusionner avec le précédent', merge_with_previous: 'Fusionner avec le précédent',
merge_with_next: 'Fusionner avec le suivant', 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', 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', 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', 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.', 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.', 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.', 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', replace: 'Remplacer',
uploading_: 'Téléversement...', uploading_: 'Téléversement...',
add_document: 'Téléverser', add_document: 'Téléverser',
add_fields: 'Ajouter des champs',
fields: 'Champs',
none: 'Aucun', none: 'Aucun',
ssn: 'SSN', ssn: 'SSN',
ein: 'EIN', ein: 'EIN',
@@ -1219,11 +1264,14 @@ const fr = {
} }
const de = { const de = {
name: 'Name',
fixed: 'Fest', fixed: 'Fest',
default: 'Standard', default: 'Standard',
save_as_custom_field: 'Als benutzerdefiniert speichern', save_as_custom_field: 'Als benutzerdefiniert speichern',
kba: 'KBA', kba: 'KBA',
field_has_already_been_added_for_party: '{field}-Feld wurde bereits für {party} hinzugefügt',
autodetect_fields: 'Felder erkennen', autodetect_fields: 'Felder erkennen',
fields_detected: '{count} Felder erkannt',
analyzing_: 'Analysiere...', analyzing_: 'Analysiere...',
download: 'Download', download: 'Download',
downloading_: 'Download...', downloading_: 'Download...',
@@ -1317,8 +1365,11 @@ const de = {
add_documents_or_images: 'Dokumente oder Bilder hinzufügen', add_documents_or_images: 'Dokumente oder Bilder hinzufügen',
add_a_new_document: 'Neues Dokument hinzufügen', add_a_new_document: 'Neues Dokument hinzufügen',
edit_documents: 'Dokumente bearbeiten', edit_documents: 'Dokumente bearbeiten',
documents: 'Dokumente',
move_forward: 'Nach vorne', move_forward: 'Nach vorne',
move_backward: 'Nach hinten', move_backward: 'Nach hinten',
backward: 'Zurück',
forward: 'Vorwärts',
remove_page: 'Seite entfernen', remove_page: 'Seite entfernen',
merge_with_previous: 'Mit vorherigem zusammenführen', merge_with_previous: 'Mit vorherigem zusammenführen',
merge_with_next: 'Mit nächstem 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', 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', 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', 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.', 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.', 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.', 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', replace: 'Ersetzen',
uploading_: 'Hochladen...', uploading_: 'Hochladen...',
add_document: 'Hochladen', add_document: 'Hochladen',
add_fields: 'Felder hinzufügen',
fields: 'Felder',
none: 'Keine', none: 'Keine',
ssn: 'SSN', ssn: 'SSN',
ein: 'EIN', ein: 'EIN',
@@ -1463,11 +1517,14 @@ const de = {
} }
const nl = { const nl = {
name: 'Naam',
fixed: 'Vast', fixed: 'Vast',
default: 'Standaard', default: 'Standaard',
save_as_custom_field: 'Opslaan als aangepast', save_as_custom_field: 'Opslaan als aangepast',
kba: 'KBA', kba: 'KBA',
field_has_already_been_added_for_party: '{field}-veld is al toegevoegd voor {party}',
autodetect_fields: 'Velden detecteren', autodetect_fields: 'Velden detecteren',
fields_detected: '{count} velden gedetecteerd',
analyzing_: 'Analyseren...', analyzing_: 'Analyseren...',
download: 'Downloaden', download: 'Downloaden',
downloading_: 'Downloaden...', downloading_: 'Downloaden...',
@@ -1561,8 +1618,11 @@ const nl = {
add_documents_or_images: 'Documenten of afbeeldingen toevoegen', add_documents_or_images: 'Documenten of afbeeldingen toevoegen',
add_a_new_document: 'Nieuw document toevoegen', add_a_new_document: 'Nieuw document toevoegen',
edit_documents: 'Documenten bewerken', edit_documents: 'Documenten bewerken',
documents: 'Documenten',
move_forward: 'Naar voren', move_forward: 'Naar voren',
move_backward: 'Naar achteren', move_backward: 'Naar achteren',
backward: 'Achteruit',
forward: 'Vooruit',
remove_page: 'Pagina verwijderen', remove_page: 'Pagina verwijderen',
merge_with_previous: 'Samenvoegen met vorige', merge_with_previous: 'Samenvoegen met vorige',
merge_with_next: 'Samenvoegen met volgende', 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', 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', 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', 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.', 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.', 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.', 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', replace: 'Vervangen',
uploading_: 'Bezig met uploaden...', uploading_: 'Bezig met uploaden...',
add_document: 'Doc. toevoegen', add_document: 'Doc. toevoegen',
add_fields: 'Velden toevoegen',
fields: 'Velden',
none: 'Geen', none: 'Geen',
ssn: 'SSN', ssn: 'SSN',
ein: 'EIN', ein: 'EIN',
Loaded 100 of 229 files, more files were not shown because too many files have changed in this diff. Show more