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
228 files changed
+5097 -1431

No files matched your search

+5
View File
@@ -1,6 +1,11 @@
---
EnableDefaultLinters: true
linters:
AllowedScriptType:
enabled: true
allowed_types:
- text/javascript
- application/json
ErbSafety:
enabled: false
HardCodedString:
@@ -173,7 +173,6 @@ module Api
end
def filter_submissions(submissions, params)
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
if params[:template_folder].present?
@@ -1,10 +1,7 @@
# frozen_string_literal: true
module Api
class SubmitterEmailClicksController < ApiBaseController
skip_before_action :authenticate_user!
skip_authorization_check
class SubmitterEmailClicksController < ActionController::API
def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
@@ -1,10 +1,7 @@
# frozen_string_literal: true
module Api
class SubmitterFormViewsController < ApiBaseController
skip_before_action :authenticate_user!
skip_authorization_check
class SubmitterFormViewsController < ActionController::API
def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
+28 -2
View File
@@ -13,13 +13,17 @@ class ApplicationController < ActionController::Base
before_action :maybe_redirect_to_setup, unless: :signed_in?
before_action :authenticate_user!, unless: :devise_controller?
before_action :set_csp, if: -> { request.get? && !request.headers['HTTP_X_TURBO'] }
before_action :set_csp, if: lambda {
request.get? && !request.headers['HTTP_X_TURBO_REQUEST_ID'] && !turbo_infinite_scroll?
}
helper_method :button_title,
:current_account,
:true_ability,
:form_link_host,
:svg_icon
:svg_icon,
:turbo_native_ios?,
:turbo_native_android?
impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
@@ -64,6 +68,28 @@ class ApplicationController < ActionController::Base
private
def turbo_infinite_scroll?
request.headers['HTTP_X_TURBO_INFINITE_SCROLL'].present?
end
def turbo_native_app?
return @turbo_native_app if defined?(@turbo_native_app)
@turbo_native_app = request.user_agent.to_s.include?('Hotwire Native')
end
def turbo_native_ios?
return @turbo_native_ios if defined?(@turbo_native_ios)
@turbo_native_ios = request.user_agent.to_s.include?('Hotwire Native iOS')
end
def turbo_native_android?
return @turbo_native_android if defined?(@turbo_native_android)
@turbo_native_android = request.user_agent.to_s.include?('Hotwire Native Android')
end
def with_locale(&)
return yield unless current_account
-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
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
if current_user.validate_and_consume_otp!(params[:otp_attempt])
if current_user.valid_for_authentication? { current_user.validate_and_consume_otp!(params[:otp_attempt]) }
current_user.update!(otp_required_for_login: false, otp_secret: nil)
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed')
+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
before_action :configure_permitted_parameters
skip_before_action :verify_authenticity_token, if: -> { request.xhr? && request.format.json? }
respond_to :json, only: :create
around_action :with_browser_locale
def create
@@ -11,12 +15,26 @@ class SessionsController < Devise::SessionsController
if Docuseal.multitenant? && !User.exists?(email:)
Rollbar.warning('Sign in new user') if defined?(Rollbar)
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
notice: I18n.t('create_a_new_account')
respond_to do |format|
format.html do
redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
notice: t('create_a_new_account')
end
format.json do
render json: { sign_up: true, notice: t('create_a_new_account') }, status: :unprocessable_content
end
end
return
end
if User.exists?(email:, otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_content
respond_to do |format|
format.html { render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_content }
format.json { render json: { otp_required: true, email_otp: false }, status: :unprocessable_content }
end
return
end
super
@@ -29,5 +29,17 @@ class SubmissionsArchivedController < ApplicationController
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
associations: :author).call
render_infinite_scroll(@pagy, @submissions) if turbo_infinite_scroll?
end
private
def render_infinite_scroll(pagy, submissions)
render turbo_stream: [
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
locals: { with_template: true, archived: true }),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
end
+18 -1
View File
@@ -18,6 +18,16 @@ class SubmissionsController < ApplicationController
'payment' => 'credit_card', 'verification' => 'id', 'kba' => 'user_scan'
}.freeze
STATUS_BADGES = {
'awaiting' => 'badge-info', 'sent' => 'badge-info', 'completed' => 'badge-success',
'opened' => 'badge-warning', 'declined' => 'badge-error'
}.freeze
STATUS_ICONS = {
'awaiting' => 'clock', 'sent' => 'send', 'completed' => 'circle_check',
'opened' => 'mail_opened', 'declined' => 'x_circle'
}.freeze
def show
@submission = Submissions.preload_with_pages(@submission)
@@ -83,7 +93,14 @@ class SubmissionsController < ApplicationController
I18n.t('submission_has_been_archived')
end
redirect_back(fallback_location: @submission.template_id ? template_path(@submission.template) : root_path, notice:)
if turbo_native_app? && request.format.turbo_stream?
flash.now[:notice] = notice
render turbo_stream: [turbo_stream.remove(@submission), turbo_stream.append_all('body', partial: 'shared/flash')]
else
redirect_back(fallback_location: @submission.template_id ? template_path(@submission.template) : root_path,
notice:)
end
end
private
@@ -30,5 +30,17 @@ class SubmissionsDashboardController < ApplicationController
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
associations: :author).call
render_infinite_scroll(@pagy, @submissions) if turbo_infinite_scroll?
end
private
def render_infinite_scroll(pagy, submissions)
render turbo_stream: [
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
locals: { with_template: true }),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
end
@@ -0,0 +1,13 @@
# frozen_string_literal: true
class SubmitFormClickEmailController < ActionController::API
def create
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
if params[:t].present? && params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email')
SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request)
end
render json: {}
end
end
@@ -29,7 +29,9 @@ class SubmitFormCompletedDownloadController < ApplicationController
Submissions::EnsureResultGenerated.call(last_submitter)
if !signature_valid && !current_user_submitter?(last_submitter)
return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request, with_order: true)
return head :not_found
end
if last_submitter.completed_at < TTL.ago
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
+11 -13
View File
@@ -31,24 +31,22 @@ class SubmitFormController < ApplicationController
Submissions.preload_with_pages(submission)
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
@attachments_index = build_attachments_index(submission)
if current_user && current_user.email == @submitter.email
@current_user_data = current_user.as_json(only: %i[first_name last_name], methods: %i[full_name])
initials = UserConfigs.load_initials(current_user)
@prefill_initials = Submitters.build_prefill_attachment_data(@submitter, initials) if initials
end
return unless @form_configs[:prefill_signature]
if (user_signature = UserConfigs.load_signature(current_user))
@signature_attachment = ActiveStorage::Attachment.find_or_create_by!(
blob_id: user_signature.blob_id,
name: 'attachments',
record: @submitter
)
end
signature = UserConfigs.load_signature(current_user) ||
Submitters::FindRememberedSignature.call(@submitter, params, cookies)
@signature_attachment ||=
Submitters::MaybeAssignDefaultBrowserSignature.call(@submitter, params, cookies, @attachments_index.values)
@attachments_index[@signature_attachment.uuid] = @signature_attachment if @signature_attachment
@prefill_signature = Submitters.build_prefill_attachment_data(@submitter, signature) if signature
end
def update
@@ -15,7 +15,8 @@ class SubmitFormDelegateController < ApplicationController
@submitter.viewer? ||
!Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
request,
with_order: true)
@submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true)
@@ -19,7 +19,8 @@ class SubmitFormDownloadController < ApplicationController
AccountConfig.exists?(account_id: @submitter.account_id,
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
value: false) ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
!Submitters::AuthorizedForForm.call(@submitter, current_user, request,
with_order: true)
last_completed_submitter = @submitter.submission.submitters
.where.not(id: @submitter.id)
@@ -15,7 +15,7 @@ class SubmitFormDrawSignatureController < ApplicationController
return redirect_to submit_form_path(@submitter.slug) if @submitter.viewer?
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
!Submitters::AuthorizedForForm.call(@submitter, current_user, request, with_order: true)
return redirect_to submit_form_path(@submitter.slug)
end
@@ -10,6 +10,8 @@ class SubmitFormEmail2fasController < ApplicationController
def create
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
RateLimit.call("verify-2fa-code-1h-#{@submitter.id}", limit: 10, ttl: 1.hour, enabled: true)
RateLimit.call("verify-2fa-code-1d-#{@submitter.id}", limit: 20, ttl: 1.day, enabled: true)
value = [@submitter.email.downcase.strip, @submitter.slug].join(':')
@@ -39,6 +39,6 @@ class SubmitFormMetadataController < ApplicationController
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at? &&
!submitter.account.archived_at? &&
Submitters::AuthorizedForForm.call(submitter, current_user, request)
Submitters::AuthorizedForForm.call(submitter, current_user, request, with_order: true)
end
end
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class SubmitFormPrefillAttachmentsController < ActionController::API
include ActiveStorage::SetCurrent
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
unless can_upload?(submitter)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
uuid, slug = ApplicationRecord.signed_id_verifier.verify(params[:prefill_token], purpose: :prefill_attachment)
raise ActiveRecord::RecordNotFound if slug != submitter.slug
source_attachment = ActiveStorage::Attachment.find_by!(uuid:)
attachment = submitter.attachments_attachments.find_or_create_by!(blob_id: source_attachment.blob_id)
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
end
private
def can_upload?(submitter)
!submitter.declined_at? &&
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at?
end
end
@@ -0,0 +1,86 @@
# frozen_string_literal: true
class SubmitFormUploadController < ActionController::API
include ActionController::Cookies
include ActiveStorage::SetCurrent
before_action :load_submitter
before_action :authorize_upload!
COOKIE_STORE_LIMIT = 10
def create
file = params[:file]
if params[:type].in?(%w[initials signature])
image = ImageUtils.load_vips(file.read, content_type: file.content_type)
if ImageUtils.blank?(image)
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content
end
if ImageUtils.error?(image)
Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} error, try to sign on another device" },
status: :unprocessable_content
end
metadata = { analyzed: true, identified: true, width: image.width, height: image.height }
end
attachment = Submitters.create_attachment!(@submitter, file, metadata:)
if params[:remember_signature] == 'true' && @submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
end
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
rescue Submitters::MaliciousFileExtension => e
Rollbar.error(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content
end
private
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end
def authorize_upload!
unless can_upload?(@submitter)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
return if Submitters::AuthorizedForForm.call(@submitter, current_user, request)
render json: { error: I18n.t('verification_required_refresh_the_page_and_pass_2fa') },
status: :unprocessable_content
end
def can_upload?(submitter)
!submitter.declined_at? &&
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at?
end
def build_new_cookie_signatures_json(submitter, attachment)
values =
begin
JSON.parse(cookies.encrypted[:signature_uuids].presence || '{}')
rescue JSON::ParserError
{}
end
values[submitter.email] = attachment.uuid
values = values.to_a.last(COOKIE_STORE_LIMIT).to_h if values.size > COOKIE_STORE_LIMIT
values.to_json
end
end
@@ -0,0 +1,18 @@
# frozen_string_literal: true
class SubmitFormViewController < ActionController::API
def create
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :forbidden if Submitter.signed_id_verifier.verified(params[:v], purpose: :view_form) != @submitter.slug
@submitter.opened_at = Time.current
@submitter.save
SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request)
WebhookUrls.enqueue_events(@submitter, 'form.viewed')
render json: {}
end
end
+24 -4
View File
@@ -13,10 +13,7 @@ class TemplateFoldersController < ApplicationController
.where(folder: [@template_folder, *(params[:q].present? ? @template_folder.subfolders : [])])
.preload(:author, :template_accesses)
@template_folders =
@template_folder.subfolders.where(id: Template.accessible_by(current_ability).active.select(:folder_id))
@template_folders = TemplateFolders.search(@template_folders, params[:q])
@template_folders = filter_folders(@template_folder)
@template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
if @templates.exists?
@@ -40,6 +37,8 @@ class TemplateFoldersController < ApplicationController
@templates = @templates.none
end
render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll?
end
def edit; end
@@ -55,6 +54,27 @@ class TemplateFoldersController < ApplicationController
private
def filter_folders(template_folder)
template_folders =
template_folder.subfolders.where(id: Template.accessible_by(current_ability).active.select(:folder_id))
TemplateFolders.search(template_folders, params[:q])
end
def render_infinite_scroll(pagy, templates, template_folders)
render_params =
if templates.present?
{ partial: 'templates/template', collection: templates }
else
{ partial: 'template_folders/folder', collection: template_folders, as: :folder }
end
render turbo_stream: [
turbo_stream.before('infinite_scroll', **render_params),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
def selected_order
@selected_order ||=
if can?(:manage, :countless)
@@ -12,13 +12,23 @@ class TemplatesArchivedController < ApplicationController
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
return unless params[:q].present? && @templates.blank?
if params[:q].present? && @templates.blank?
@related_submissions_pagy, @related_submissions = load_related_submissions
end
render_infinite_scroll(@pagy, @templates) if turbo_infinite_scroll?
end
private
def render_infinite_scroll(pagy, templates)
render turbo_stream: [
turbo_stream.before('infinite_scroll', partial: 'templates/template', collection: templates,
locals: { with_folder: true }),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
def load_related_submissions
related_submissions =
Submission.accessible_by(current_ability)
@@ -16,7 +16,19 @@ class TemplatesArchivedSubmissionsController < ApplicationController
end
@pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))
render_infinite_scroll(@pagy, @submissions, @template) if turbo_infinite_scroll?
rescue ActiveRecord::RecordNotFound
redirect_to root_path
end
private
def render_infinite_scroll(pagy, submissions, template)
render turbo_stream: [
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
locals: { template:, archived: true }),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
end
+14
View File
@@ -21,6 +21,8 @@ class TemplatesController < ApplicationController
@pagy, @submissions =
pagy_auto(submissions.select_for_list.preload(:template_accesses, submitters: :start_form_submission_events))
render_infinite_scroll(@pagy, @submissions, @template) if turbo_infinite_scroll?
rescue ActiveRecord::RecordNotFound
redirect_to root_path
end
@@ -81,11 +83,23 @@ class TemplatesController < ApplicationController
I18n.t('template_has_been_archived')
end
if @template.destroyed? && turbo_native_app?
redirect_to templates_archived_index_path, notice:
else
redirect_back(fallback_location: root_path, notice:)
end
end
private
def render_infinite_scroll(pagy, submissions, template)
render turbo_stream: [
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
locals: { template:, archived: template.archived_at? }),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
def template_params
params.require(:template).permit(
:name,
@@ -43,10 +43,26 @@ class TemplatesDashboardController < ApplicationController
@related_submissions_pagy, @related_submissions = load_related_submissions
end
end
render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll?
end
private
def render_infinite_scroll(pagy, templates, template_folders)
render_params =
if templates.present?
{ partial: 'templates/template', collection: templates }
else
{ partial: 'template_folders/folder', collection: template_folders, as: :folder }
end
render turbo_stream: [
turbo_stream.before('infinite_scroll', **render_params),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
def load_templates(templates, folders_count, show_shared_inline: false)
templates = templates.preload(:author, :template_accesses)
@@ -94,7 +110,7 @@ class TemplatesDashboardController < ApplicationController
pinned_count = (show_default_in_grid ? 1 : 0) + (show_shared_in_grid ? 1 : 0)
pagy = Pagy::Offset.new(count: folders_count + pinned_count,
pagy, = pagy(template_folders, count: folders_count + pinned_count,
page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max,
limit: FOLDERS_PER_PAGE,
raise_range_error: true)
@@ -103,7 +119,7 @@ class TemplatesDashboardController < ApplicationController
show_shared_folder = show_shared_in_grid && pagy.page == 1
folder_offset = pagy.page == 1 ? 0 : pagy.offset - pinned_count
folder_limit = pagy.page == 1 ? FOLDERS_PER_PAGE - pinned_count : FOLDERS_PER_PAGE
folder_limit = pagy.page == 1 ? pagy.limit - pinned_count : pagy.limit
template_folders = template_folders.offset(folder_offset).limit(folder_limit)
+11 -2
View File
@@ -19,13 +19,22 @@ class TemplatesSharedController < ApplicationController
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
return unless params[:q].present? && @templates.blank?
if params[:q].present? && @templates.blank?
@related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived)
end
render_infinite_scroll(@pagy, @templates) if turbo_infinite_scroll?
end
private
def render_infinite_scroll(pagy, templates)
render turbo_stream: [
turbo_stream.before('infinite_scroll', partial: 'templates/template', collection: templates),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
def load_related_submissions(is_archived:)
shared_templates = Templates.shared(current_user)
shared_templates = is_archived ? shared_templates.archived : shared_templates.active
@@ -2,6 +2,8 @@
class TestingApiSettingsController < ApplicationController
def index
raise CanCan::AccessDenied unless current_account.testing?
authorize!(:manage, current_user.access_token)
@webhook_url = current_account.webhook_urls.first_or_initialize
+1 -1
View File
@@ -20,7 +20,7 @@ class UsersController < ApplicationController
respond_to do |format|
format.html do
@pagy, @users = pagy(@users)
@pagy, @users = pagy(@users, limit: turbo_native_app? ? 1_000 : Pagy::OPTIONS[:limit])
end
if current_ability.can?(:manage, current_account)
+172 -21
View File
@@ -1,5 +1,5 @@
import '@hotwired/turbo'
import { encodeMethodIntoRequestBody } from '@hotwired/turbo-rails/app/javascript/turbo/fetch_requests'
import '@hotwired/turbo-rails'
import './native'
import { createApp, reactive } from 'vue'
import TemplateBuilder from './template_builder/builder'
@@ -9,6 +9,15 @@ import ToggleVisible from './elements/toggle_visible'
import ToggleCookies from './elements/toggle_cookies'
import DisableHidden from './elements/disable_hidden'
import TurboModal from './elements/turbo_modal'
import NativeAction from './elements/native_action'
import NativeMenu from './elements/native_menu'
import NativeSearch from './elements/native_search'
import NativeModal from './elements/native_modal'
import NativeEvent from './elements/native_event'
import NativeUpload from './elements/native_upload'
import FormPrompt from './elements/form_prompt'
import InfiniteScroll from './elements/infinite_scroll'
import ModalButton from './elements/modal_button'
import FileDropzone from './elements/file_dropzone'
import MenuActive from './elements/menu_active'
import ClipboardCopy from './elements/clipboard_copy'
@@ -59,10 +68,8 @@ import ConfirmUpload from './elements/confirm_upload'
import ScrollFade from './elements/scroll_fade'
import OpenModalMobile from './elements/open_modal_mobile'
import HistoryBack from './elements/history_back'
import * as TurboInstantClick from './lib/turbo_instant_click'
TurboInstantClick.start()
import DatePlaceholder from './elements/date_placeholder'
import FlashMessage from './elements/flash_message'
document.addEventListener('turbo:before-cache', () => {
window.flash?.remove()
@@ -74,10 +81,18 @@ document.addEventListener('keyup', (e) => {
}
})
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:before-fetch-request', (event) => {
event.detail.fetchOptions.headers['X-Turbo'] = 'true'
document.addEventListener('turbo:morph', () => {
if (document.activeElement?.closest('.dropdown')) {
document.activeElement.blur()
}
})
document.addEventListener('turbo:before-fetch-response', (event) => {
if (event.detail.fetchResponse.header('content-disposition')?.includes('attachment')) {
event.preventDefault()
}
})
document.addEventListener('turbo:submit-end', async (event) => {
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
@@ -85,11 +100,23 @@ document.addEventListener('turbo:submit-end', async (event) => {
return
}
const filename = decodeURIComponent(resp.headers.get('content-disposition').split('"')[1])
const download = window.webkit?.messageHandlers?.download
if (download) {
const reader = new FileReader()
reader.onload = () => download.postMessage({ name: filename, data: reader.result.split(',')[1] })
reader.readAsDataURL(await resp.blob())
return
}
const url = URL.createObjectURL(await resp.blob())
const link = document.createElement('a')
link.href = url
link.setAttribute('download', decodeURIComponent(resp.headers.get('content-disposition').split('"')[1]))
link.setAttribute('download', filename)
document.body.appendChild(link)
@@ -105,6 +132,15 @@ const safeRegisterElement = (name, element, options = {}) => !window.customEleme
safeRegisterElement('toggle-visible', ToggleVisible)
safeRegisterElement('disable-hidden', DisableHidden)
safeRegisterElement('turbo-modal', TurboModal)
safeRegisterElement('native-action', NativeAction)
safeRegisterElement('native-menu', NativeMenu)
safeRegisterElement('native-search', NativeSearch)
safeRegisterElement('native-modal', NativeModal)
safeRegisterElement('native-event', NativeEvent)
safeRegisterElement('native-upload', NativeUpload)
safeRegisterElement('form-prompt', FormPrompt)
safeRegisterElement('infinite-scroll', InfiniteScroll)
safeRegisterElement('modal-button', ModalButton)
safeRegisterElement('file-dropzone', FileDropzone)
safeRegisterElement('menu-active', MenuActive)
safeRegisterElement('clipboard-copy', ClipboardCopy)
@@ -156,16 +192,30 @@ safeRegisterElement('confirm-upload', ConfirmUpload)
safeRegisterElement('scroll-fade', ScrollFade)
safeRegisterElement('open-modal-mobile', OpenModalMobile)
safeRegisterElement('history-back', HistoryBack)
safeRegisterElement('date-placeholder', DatePlaceholder)
safeRegisterElement('flash-message', FlashMessage)
safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () {
document.addEventListener('turbo:submit-end', this.onSubmit)
document.addEventListener('template-builder:update', this.onSheetSubmit)
document.addEventListener('template-builder:sync', this.onSync)
document.addEventListener('template-builder:scroll-to', this.onScrollTo)
document.addEventListener('template-builder:select-field', this.onSelectField)
document.addEventListener('template-builder:select-submitter', this.onSelectSubmitter)
document.addEventListener('template-builder:sync-custom-fields', this.onSyncCustomFields)
document.addEventListener('template-builder:draw-field', this.onDrawField)
document.addEventListener('native:builder-view', this.onBuilderView)
document.addEventListener('turbo:before-cache', this.onBeforeCache)
this.appElem = document.createElement('div')
this.appElem.classList.add('md:h-screen')
if (this.dataset.nativeView) {
this.appElem.classList.add('min-w-0')
}
const template = reactive(JSON.parse(this.dataset.template))
this.app = createApp(TemplateBuilder, {
@@ -203,7 +253,9 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withDownload: true,
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
acceptFileTypes: this.dataset.acceptFileTypes,
showTourStartForm: this.dataset.showTourStartForm === 'true'
showTourStartForm: this.dataset.showTourStartForm === 'true',
nativePlatform: this.dataset.nativePlatform,
nativeView: this.dataset.nativeView
})
this.component = this.app.mount(this.appElem)
@@ -211,17 +263,89 @@ safeRegisterElement('template-builder', class extends HTMLElement {
this.appendChild(this.appElem)
}
onSubmit = (e) => {
if (e.detail.success) {
if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
e.detail.fetchResponse.response.json().then((data) => {
this.component.template.submitters = data.submitters
})
onSubmit = async (e) => {
const form = e.detail.formSubmission?.formElement
if (!e.detail.success || !form) return
const data = {}
new FormData(form).forEach((value, key) => { data[key] = value })
this.applySubmission(form.id, form.action, await e.detail.fetchResponse.responseText, data)
}
if (e.detail?.formSubmission?.formElement?.action?.endsWith('/prefillable_fields')) {
e.detail.fetchResponse.response.text().then((data) => {
const doc = new DOMParser().parseFromString(data, 'text/html')
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 (field) {
this.component.drawField = field
this.component.drawOption = option || null
}
} else if (type) {
this.component.startFieldDraw({ type })
}
}
applySubmission (formId, action, body, data) {
if (formId === 'submitters_form') {
this.component.template.submitters = JSON.parse(body).submitters
} else if (action.endsWith('/prefillable_fields')) {
const doc = new DOMParser().parseFromString(body, 'text/html')
const fragment = doc.querySelector('turbo-stream template').content
const prefillableUuidsIndex = {}
@@ -239,9 +363,28 @@ safeRegisterElement('template-builder', class extends HTMLElement {
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 = () => {
@@ -251,6 +394,14 @@ safeRegisterElement('template-builder', class extends HTMLElement {
disconnectedCallback () {
document.removeEventListener('turbo:submit-end', this.onSubmit)
document.removeEventListener('template-builder:update', this.onSheetSubmit)
document.removeEventListener('template-builder:sync', this.onSync)
document.removeEventListener('template-builder:scroll-to', this.onScrollTo)
document.removeEventListener('template-builder:select-field', this.onSelectField)
document.removeEventListener('template-builder:select-submitter', this.onSelectSubmitter)
document.removeEventListener('template-builder:sync-custom-fields', this.onSyncCustomFields)
document.removeEventListener('template-builder:draw-field', this.onDrawField)
document.removeEventListener('native:builder-view', this.onBuilderView)
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
this.app?.unmount()
+68
View File
@@ -166,3 +166,71 @@ button[disabled] .enabled, button.btn-disabled .enabled {
markdown-editor [contenteditable] p {
margin-bottom: 18px;
}
native-action {
display: contents;
}
@media (max-width: 767px) {
.table-stack,
.table-stack tbody,
.table-stack tr,
.table-stack td {
display: block;
}
.table-stack,
div:has(> .table-stack) {
overflow: visible;
}
.table-stack thead {
display: none;
}
.table-stack tr {
@apply bg-base-200 rounded-2xl py-3 mb-3 border-0;
}
.table-stack td {
@apply px-4 py-1;
}
.table-stack td[data-label] {
@apply flow-root text-right;
overflow-wrap: anywhere;
}
.table-stack td[data-label]::before {
content: attr(data-label);
@apply float-left mr-4 text-sm leading-6 text-base-content/60;
}
.table-stack td:first-child {
@apply font-semibold;
}
.table-stack td:last-child {
@apply grid grid-cols-2 gap-2 pt-3;
}
.table-stack td:last-child:has(> .dropdown) {
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
}
.table-stack td:last-child:not(:has(*)) {
display: none;
}
.table-stack td:last-child > * {
margin-left: 0 !important;
}
.table-stack td:last-child > :last-child:nth-child(odd):not(.dropdown) {
@apply col-span-2;
}
.table-stack td:last-child .btn {
@apply btn-sm w-full;
}
}
+1 -2
View File
@@ -126,11 +126,10 @@ window.customElements.define('draw-signature', class extends HTMLElement {
const formData = new FormData()
formData.append('file', file)
formData.append('submitter_slug', this.dataset.slug)
formData.append('name', 'attachments')
formData.append('remember_signature', 'true')
return fetch('/api/attachments', {
return fetch(`/s/${this.dataset.slug}/upload`, {
method: 'POST',
body: formData
}).then(resp => resp.json())
@@ -1,5 +1,7 @@
export default class extends HTMLElement {
connectedCallback () {
this.id = `autoresize-textarea-${Math.random().toString(32).split('.')[1]}`
this.resize()
this.textarea.addEventListener('input', () => this.resize())
+2
View File
@@ -1,5 +1,7 @@
export default class extends HTMLElement {
connectedCallback () {
this.id = `bar-chart-${Math.random().toString(32).split('.')[1]}`
this.chartLabels = JSON.parse(this.dataset.labels || '[]')
this.chartDatasets = JSON.parse(this.dataset.datasets || '[]')
+10 -1
View File
@@ -4,9 +4,18 @@ export default class extends HTMLElement {
this.addEventListener('click', (e) => {
const text = this.dataset.text || this.innerText.trim()
const download = window.webkit?.messageHandlers?.download
if (download && /^https?:\/\//.test(text)) {
e.preventDefault()
return download.postMessage({ url: text })
}
if (navigator.clipboard) {
navigator.clipboard.writeText(text)
navigator.clipboard.writeText(text).then(() => {
window.webkit?.messageHandlers?.flash?.postMessage({ style: 'notice', message: this.dataset.copied })
})
} else {
if (e.target.tagName !== 'INPUT') {
alert(`Clipboard not available. Make sure you're using https://\nCopy text: ${text}`)
+2 -2
View File
@@ -59,10 +59,10 @@ export async function convertImagesInInput (input) {
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input[type="file"]')
this.addEventListener('change', async (e) => {
const input = e.target
const form = input.form
input.addEventListener('change', async () => {
await convertImagesInInput(input)
form.querySelector('[type="submit"]')?.setAttribute('disabled', true)
+63 -31
View File
@@ -26,17 +26,41 @@ export default targetable(class extends HTMLElement {
window.addEventListener('dragleave', this.onWindowDragleave)
this.fileDropzone?.addEventListener('drop', this.onDropFile)
this.folderDropzone?.addEventListener('drop', this.onDropNewFolder)
this.addEventListener('drop', this.onDrop)
this.addEventListener('dragstart', this.onDragstart)
this.addEventListener('dragover', this.onDragover)
this.addEventListener('dragleave', this.onDragleave)
}
this.folderCards.forEach((el) => el.addEventListener('drop', (e) => this.onDropFolder(e, el)))
this.templateCards.forEach((el) => el.addEventListener('drop', this.onDropTemplate))
this.templateCards.forEach((el) => el.addEventListener('dragstart', this.onTemplateDragStart))
dropTarget (e) {
return e.target.closest([
'[data-target="dashboard-dropzone.fileDropzone"]',
'[data-target="dashboard-dropzone.folderDropzone"]',
'[data-targets="dashboard-dropzone.folderCards"]',
'[data-targets="dashboard-dropzone.templateCards"]'
].join(', '))
}
return [this.fileDropzone, this.folderDropzone, ...this.folderCards, ...this.templateCards].forEach((el) => {
el?.addEventListener('dragover', this.onDragover)
el?.addEventListener('dragleave', this.onDragleave)
})
onDrop = (e) => {
const el = this.dropTarget(e)
if (!el) return
if (el === this.fileDropzone) {
this.onDropFile(e)
} else if (el === this.folderDropzone) {
this.onDropNewFolder(e)
} else if (el.dataset.targets === 'dashboard-dropzone.folderCards') {
this.onDropFolder(e, el)
} else {
this.onDropTemplate(e, el)
}
}
onDragstart = (e) => {
if (e.target.closest('[data-targets="dashboard-dropzone.templateCards"]')) {
this.onTemplateDragStart(e)
}
}
disconnectedCallback () {
@@ -122,13 +146,13 @@ export default targetable(class extends HTMLElement {
}
}).finally(() => {
window.Turbo.cache.clear()
window.Turbo.visit(location.href)
window.Turbo.visit(location.href, { action: 'replace' })
})
}
}
}
onDropTemplate = (e) => {
onDropTemplate = (e, el) => {
e.preventDefault()
if (e.dataTransfer.files.length) {
@@ -136,10 +160,10 @@ export default targetable(class extends HTMLElement {
loading.classList.add('bottom-5', 'left-0', 'flex', 'justify-center', 'w-full', 'absolute')
loading.innerHTML = loadingIconHtml
e.target.appendChild(loading)
e.target.classList.add('opacity-50')
el.appendChild(loading)
el.classList.add('opacity-50')
const id = e.target.href.split('/').pop()
const id = el.href.split('/').pop()
this.uploadFiles(e.dataTransfer.files, `/templates/${id}/clone_and_replace`)
}
@@ -173,16 +197,20 @@ export default targetable(class extends HTMLElement {
}
}
onDragover (e) {
if (e.dataTransfer?.types?.includes('Files') || this.dataset.targets !== 'dashboard-dropzone.templateCards') {
this.style.backgroundColor = '#F7F3F0'
onDragover = (e) => {
const el = this.dropTarget(e)
if (this.classList.contains('before:border-base-300')) {
this.classList.remove('before:border-base-300')
this.classList.add('before:border-base-content/30')
} else if (this.classList.contains('border-base-300')) {
this.classList.remove('border-base-300')
this.classList.add('border-base-content/30')
if (!el) return
if (e.dataTransfer?.types?.includes('Files') || el.dataset.targets !== 'dashboard-dropzone.templateCards') {
el.style.backgroundColor = '#F7F3F0'
if (el.classList.contains('before:border-base-300')) {
el.classList.remove('before:border-base-300')
el.classList.add('before:border-base-content/30')
} else if (el.classList.contains('border-base-300')) {
el.classList.remove('border-base-300')
el.classList.add('border-base-content/30')
}
}
}
@@ -205,15 +233,19 @@ export default targetable(class extends HTMLElement {
a.remove()
}
onDragleave () {
this.style.backgroundColor = null
onDragleave = (e) => {
const el = this.dropTarget(e)
if (this.classList.contains('before:border-base-content/30')) {
this.classList.remove('before:border-base-content/30')
this.classList.add('before:border-base-300')
} else if (this.classList.contains('border-base-content/30')) {
this.classList.remove('border-base-content/30')
this.classList.add('border-base-300')
if (!el) return
el.style.backgroundColor = null
if (el.classList.contains('before:border-base-content/30')) {
el.classList.remove('before:border-base-content/30')
el.classList.add('before:border-base-300')
} else if (el.classList.contains('border-base-content/30')) {
el.classList.remove('border-base-content/30')
el.classList.add('border-base-300')
}
}
@@ -0,0 +1,10 @@
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input')
const placeholder = this.querySelector('span')
input.addEventListener('change', () => {
placeholder.classList.toggle('hidden', !!input.value)
})
}
}
+12
View File
@@ -2,6 +2,18 @@ export default class extends HTMLElement {
static observedAttributes = ['class']
connectedCallback () {
this.addEventListener('turbo:morph-element', this.onMorph)
this.trigger()
}
disconnectedCallback () {
this.removeEventListener('turbo:morph-element', this.onMorph)
}
onMorph = (event) => {
if (event.target !== this) return
this.trigger()
}
@@ -21,6 +21,16 @@ export default targetable(class extends HTMLElement {
downloadFiles () {
if (!this.dataset.src) return
const download = window.webkit?.messageHandlers?.download
if (download) {
this.toggleState()
document.addEventListener('native:download-end', () => this.toggleState(), { once: true })
return download.postMessage({ src: this.dataset.src })
}
this.toggleState()
fetch(this.dataset.src).then(async (response) => {
+2
View File
@@ -39,6 +39,8 @@ export default targetable(class extends HTMLElement {
]
connectedCallback () {
this.id = `email-editor-${Math.random().toString(32).split('.')[1]}`
this.mount()
if (this.input.value) {
+1 -1
View File
@@ -35,7 +35,7 @@ export default class extends HTMLElement {
counter.innerText = `${count} / ${this.dataset.limit}`
}
if (this.dataset.bulkEnabled !== 'true') {
if (this.dataset.bulkEnabled !== 'true' && !/Hotwire Native iOS/.test(navigator.userAgent)) {
if (!bulkMessage) {
bulkMessage = document.createElement('span')
+9
View File
@@ -14,6 +14,15 @@ export default actionable(targetable(class extends HTMLElement {
this.addEventListener('dragover', (e) => e.preventDefault())
this.addEventListener('drop', this.onDrop)
document.addEventListener('turbo:submit-end', this.toggleLoading)
if (this.area) {
this.initArea()
} else {
queueMicrotask(() => this.initArea())
}
}
initArea () {
this.area?.addEventListener('dragover', this.onDragover)
this.area?.addEventListener('dragleave', this.onDragleave)
}
+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 () {
if (!this.textarea || !this.editorElement) return
this.id = `html-editor-${Math.random().toString(32).split('.')[1]}`
this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform()
@@ -0,0 +1,52 @@
import { renderStreamMessage, session } from '@hotwired/turbo'
export default class extends HTMLElement {
connectedCallback () {
if (this.dataset.limit) this.replaceLimitParam()
if (!this.dataset.src) {
this.remove()
return
}
this.observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
this.load()
}
}, { rootMargin: '400px' })
this.observer.observe(this)
}
disconnectedCallback () {
this.observer?.disconnect()
}
replaceLimitParam () {
const url = new URL(window.location.href)
url.searchParams.set('limit', this.dataset.limit)
session.history.replace(url, session.history.restorationIdentifier)
session.view.lastRenderedLocation = url
}
load () {
if (this.loading || !this.dataset.src) return
this.loading = true
fetch(this.dataset.src, {
headers: { 'X-Turbo-Infinite-Scroll': 'true' }
}).then(async (response) => {
if (!response.ok) return
delete this.dataset.src
renderStreamMessage(await response.text())
}).finally(() => {
this.loading = false
})
}
}
@@ -143,6 +143,8 @@ export default actionable(targetable(class extends HTMLElement {
async connectedCallback () {
if (!this.textarea || !this.editorElement) return
this.id = `markdown-editor-${Math.random().toString(32).split('.')[1]}`
this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform()
+2 -3
View File
@@ -1,14 +1,13 @@
export default class extends HTMLElement {
connectedCallback () {
const maskedToken = this.input.value
this.input.addEventListener('focus', () => {
this.maskedToken = this.input.value
this.input.value = this.dataset.token
this.input.select()
})
this.input.addEventListener('focusout', () => {
this.input.value = maskedToken
this.input.value = this.maskedToken
})
}
+2
View File
@@ -1,5 +1,7 @@
export default class extends HTMLElement {
connectedCallback () {
this.id = `menu-active-${Math.random().toString(32).split('.')[1]}`
this.querySelectorAll('a').forEach((link) => {
if (document.location.pathname.startsWith(link.pathname) && !link.getAttribute('href').startsWith('http')) {
link.classList.add('bg-base-300')
+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 {
connectedCallback () {
this.addEventListener('pointerdown', this.onPointerdown)
}
onPointerdown = (event) => {
if (this.dataset.disabled === 'true') return
if (/Hotwire Native/.test(navigator.userAgent)) return
if (!this.isMobile()) return
if (window.innerWidth >= 768) return
this.querySelectorAll('a[data-turbo-frame]').forEach((link) => {
link.dataset.turboFrame = '_top'
})
const link = event.target.closest('a[data-turbo-frame]')
if (link) link.dataset.turboFrame = '_top'
}
isMobile () {
+2
View File
@@ -1,5 +1,7 @@
export default class extends HTMLElement {
connectedCallback () {
this.id = `scroll-fade-${Math.random().toString(32).split('.')[1]}`
this.addEventListener('scroll', this.updateFade, { passive: true })
window.addEventListener('resize', this.updateFade)
+17 -8
View File
@@ -1,18 +1,27 @@
export default class extends HTMLElement {
connectedCallback () {
this.button.addEventListener('click', (event) => {
this.addEventListener('click', this.onClick)
document.addEventListener('turbo:before-cache', this.onBeforeCache)
if (this.input?.value) {
this.input.focus()
this.input.setSelectionRange(this.input.value.length, this.input.value.length)
}
}
disconnectedCallback () {
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
}
onClick = (event) => {
if (!event.target.closest('button')) return
if (!this.input.value && document.activeElement !== this.input) {
event.preventDefault()
this.input.focus()
}
})
document.addEventListener('turbo:before-cache', this.onBeforeCache)
}
disconnectedCallback () {
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
}
onBeforeCache = () => {
+2
View File
@@ -1,5 +1,7 @@
export default class extends HTMLElement {
connectedCallback () {
this.id = `set-timezone-${Math.random().toString(32).split('.')[1]}`
if (this.dataset.inputId) {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
@@ -5,6 +5,8 @@ export default targetable(class extends HTMLElement {
static [target.static] = ['canvas', 'input', 'clear', 'button']
async connectedCallback () {
this.lockSwipe(true)
const { default: SignaturePad } = await import('signature_pad')
this.setCanvasSize()
@@ -61,11 +63,17 @@ export default targetable(class extends HTMLElement {
}
disconnectedCallback () {
this.lockSwipe(false)
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
}
lockSwipe (locked) {
window.webkit?.messageHandlers?.native?.postMessage({ type: 'swipe', op: locked ? 'lock' : 'unlock' })
}
setCanvasSize () {
const scale = 3
+14 -6
View File
@@ -1,5 +1,19 @@
export default class extends HTMLElement {
connectedCallback () {
if (this.lastElementChild) {
this.init()
} else {
queueMicrotask(() => this.init())
}
}
disconnectedCallback () {
if (this.interval) {
clearInterval(this.interval)
}
}
init () {
const form = this.querySelector('form') || (this.querySelector('input, button, select') || this.lastElementChild).form
if (this.dataset.interval) {
@@ -24,10 +38,4 @@ export default class extends HTMLElement {
form.requestSubmit()
}
}
disconnectedCallback () {
if (this.interval) {
clearInterval(this.interval)
}
}
}
+6 -2
View File
@@ -1,10 +1,15 @@
export default class extends HTMLElement {
connectedCallback () {
this.addEventListener('click', this.onClick)
}
onClick = (event) => {
const button = this.querySelector('a, button, label')
if (!button?.contains(event.target)) return
const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button
button.addEventListener('click', () => {
this.dataset.classes.split(' ').forEach((cls) => {
if (this.dataset.action === 'remove') {
target.classList.remove(cls)
@@ -14,6 +19,5 @@ export default class extends HTMLElement {
target.classList.toggle(cls)
}
})
})
}
}
+4 -10
View File
@@ -1,29 +1,23 @@
export default class extends HTMLElement {
connectedCallback () {
document.addEventListener('turbo:submit-end', this.onSubmitEnd)
this.form.addEventListener('submit', this.onSubmit)
this.form.addEventListener('turbo:before-fetch-response', this.onFetchResponse)
}
disconnectedCallback () {
document.removeEventListener('turbo:submit-end', this.onSubmitEnd)
this.form.removeEventListener('submit', this.onSubmit)
this.form.removeEventListener('turbo:before-fetch-response', this.onFetchResponse)
}
onSubmit = () => {
this.element.classList.add('invisible')
}
onSubmitEnd = (event) => {
if (event.target === this.form) {
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
if (resp?.status / 100 === 2) {
onFetchResponse = (event) => {
if (event.detail.fetchResponse.succeeded) {
this.element.classList.remove('invisible')
}
}
}
get element () {
return document.getElementById(this.dataset.elementId)
+9 -1
View File
@@ -1,3 +1,5 @@
import './native'
import { createApp, reactive } from 'vue'
import Form from './submission_form/form'
@@ -8,6 +10,7 @@ import ScrollButtons from './elements/scroll_buttons'
import PageContainer from './elements/page_container'
import SubmitForm from './elements/submit_form'
import ModalButton from './elements/modal_button'
import NativeAction from './elements/native_action'
const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
@@ -18,6 +21,7 @@ safeRegisterElement('scroll-buttons', ScrollButtons)
safeRegisterElement('page-container', PageContainer)
safeRegisterElement('submit-form', SubmitForm)
safeRegisterElement('modal-button', ModalButton)
safeRegisterElement('native-action', NativeAction)
safeRegisterElement('submission-form', class extends HTMLElement {
connectedCallback () {
this.appElem = document.createElement('div')
@@ -28,7 +32,9 @@ safeRegisterElement('submission-form', class extends HTMLElement {
optionalInviteSubmitters: JSON.parse(this.dataset.optionalInviteSubmitters),
schema: JSON.parse(this.dataset.schema),
canSendEmail: this.dataset.canSendEmail === 'true',
previousSignatureValue: this.dataset.previousSignatureValue,
prefillSignature: this.dataset.prefillSignature ? JSON.parse(this.dataset.prefillSignature) : null,
prefillInitials: this.dataset.prefillInitials ? JSON.parse(this.dataset.prefillInitials) : null,
currentUser: this.dataset.currentUser ? JSON.parse(this.dataset.currentUser) : null,
goToLast: this.dataset.goToLast === 'true',
isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false',
@@ -37,10 +43,12 @@ safeRegisterElement('submission-form', class extends HTMLElement {
language: this.dataset.language,
paymentProvider: this.dataset.paymentProvider,
dryRun: this.dataset.dryRun === 'true',
viewToken: this.dataset.viewToken,
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
withSignatureId: this.dataset.withSignatureId === 'true',
requireSigningReason: this.dataset.requireSigningReason === 'true',
withConfetti: this.dataset.withConfetti === 'true',
nativePlatform: this.dataset.nativePlatform,
withFieldLabels: this.dataset.withFieldLabels !== 'false',
withDisclosure: this.dataset.withDisclosure === 'true',
reuseSignature: this.dataset.reuseSignature !== 'false',
-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,
default: () => []
},
fieldsUuidIndex: {
type: Object,
required: false,
default: () => ({})
},
values: {
type: Object,
required: false,
@@ -195,7 +200,7 @@ export default {
},
isMobileContainer: FieldAreas.computed.isMobileContainer,
isInlineSize: FieldAreas.computed.isInlineSize,
fieldsUuidIndex () {
formulaFieldsUuidIndex () {
return this.formulaFields.reduce((acc, field) => {
acc[field.uuid] = field
@@ -289,6 +294,7 @@ export default {
methods: {
normalizeFormula: FormulaAreas.methods.normalizeFormula,
calculateFormula: FormulaAreas.methods.calculateFormula,
dateFormulaValue: FormulaAreas.methods.dateFormulaValue,
evalTextFormula: FormulaAreas.methods.evalTextFormula,
scrollInContainer: FieldAreas.methods.scrollInContainer,
scrollIntoArea: FieldAreas.methods.scrollIntoArea,
@@ -633,6 +633,32 @@ defineFunction('contains', {
return String(v[0]).indexOf(String(v[1])) !== -1
}
})
defineFunction('today', {
min: 0,
max: 0,
call: () => {
const now = new Date()
return Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) / 86400000
}
})
defineFunction('datedif', {
min: 3,
max: 3,
call: (v) => {
if (v[0] == null || v[1] == null) return null
const start = toNumber(v[0])
const end = toNumber(v[1])
const unit = String(v[2]).toLowerCase()
if (unit === 'd') return end - start
const from = new Date(Math.min(start, end) * 86400000)
const to = new Date(Math.max(start, end) * 86400000)
const months = (to.getUTCFullYear() - from.getUTCFullYear()) * 12 + to.getUTCMonth() - from.getUTCMonth() - (to.getUTCDate() < from.getUTCDate() ? 1 : 0)
const sign = end < start ? -1 : 1
if (unit === 'm') return sign * months
if (unit === 'y') return sign * Math.floor(months / 12)
return null
}
})
const OP_CLASSES = {
add: Addition,
+15 -1
View File
@@ -56,7 +56,7 @@
</span>
</button>
<button
v-if="!isWebView && withDownloadButton"
v-if="(!isWebView || isNativeApp) && withDownloadButton"
class="base-button flex items-center space-x-1 w-full completed-form-download-button"
:disabled="isDownloading"
@click.prevent="download"
@@ -186,6 +186,11 @@ export default {
type: Object,
required: false,
default: () => ({})
},
nativePlatform: {
type: String,
required: false,
default: ''
}
},
data () {
@@ -195,6 +200,9 @@ export default {
}
},
computed: {
isNativeApp () {
return !!this.nativePlatform && !!window.webkit?.messageHandlers?.download
},
isWebView () {
return /webview|wv|ip((?!.*Safari)|(?=.*like Safari))/i.test(window.navigator.userAgent)
}
@@ -230,6 +238,12 @@ export default {
download () {
this.isDownloading = true
if (this.isNativeApp) {
document.addEventListener('native:download-end', () => { this.isDownloading = false }, { once: true })
return window.webkit.messageHandlers.download.postMessage({ src: this.baseUrl + `/s/${this.submitterSlug}/documents` })
}
fetch(this.baseUrl + `/s/${this.submitterSlug}/documents`, {
method: 'GET',
...this.fetchOptions
+12 -1
View File
@@ -87,7 +87,7 @@ export default {
IconCloudUpload,
IconInnerShadowTop
},
inject: ['baseUrl', 't'],
inject: ['baseUrl', 'fetchOptions', 't'],
props: {
message: {
type: String,
@@ -197,9 +197,20 @@ export default {
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
return fetch(this.baseUrl + `/s/${this.submitterSlug}/upload`, {
method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData
}).then((resp) => {
if (resp.status === 404) {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData
})
} else {
return resp
}
}).then(async (resp) => {
const data = await resp.json()
+77 -28
View File
@@ -31,6 +31,7 @@
:readonly-conditional-fields="readonlyConditionalFields"
:readonly-conditional-field-values="readonlyConditionalFieldValues"
:formula-fields="formulaFields"
:fields-uuid-index="fieldsUuidIndex"
:values="values"
:readonly-values="readonlyFieldValues"
:submitter="submitter"
@@ -71,6 +72,7 @@
<FormulaFieldAreas
v-if="!withAccessibilityAreas && !isAccessibilityMode && formulaFields.length"
:fields="formulaFields"
:fields-uuid-index="fieldsUuidIndex"
:readonly-values="readonlyFieldValues"
:values="values"
/>
@@ -489,8 +491,8 @@
:reason="values[currentField.preferences?.reason_field_uuid]"
:field="currentField"
:values="values"
:previous-value="previousSignatureValueFor(currentField) || previousSignatureValue"
:touch-attachment-uuid="previousSignatureValue"
:previous-value="previousSignatureValueFor(currentField)"
:prefill-data="prefillSignature"
:with-typed-signature="withTypedSignature"
:remember-signature="rememberSignature"
:attachments-index="attachmentsIndex"
@@ -503,8 +505,8 @@
:with-qr-button="withQrButton"
:submitter="submitter"
:show-field-names="showFieldNames"
:native-platform="nativePlatform"
@update:reason="values[currentField.preferences?.reason_field_uuid] = $event"
@touch-attachment="attachmentsIndex[previousSignatureValue] ? attachmentsIndex[previousSignatureValue].created_at = new Date() : null"
@attached="attachments.push($event)"
@start="scrollIntoField(currentField)"
@minimize="minimizeForm"
@@ -518,9 +520,11 @@
:dry-run="dryRun"
:submitter="submitter"
:previous-value="previousInitialsValue"
:prefill-data="prefillInitials"
:attachments-index="attachmentsIndex"
:show-field-names="showFieldNames"
:submitter-slug="submitterSlug"
:native-platform="nativePlatform"
@attached="attachments.push($event)"
@start="scrollIntoField(currentField)"
@focus="scrollIntoField(currentField)"
@@ -558,6 +562,7 @@
:field="currentField"
:submitter-slug="submitterSlug"
:fields="formulaFields"
:fields-uuid-index="fieldsUuidIndex"
:values="values"
:readonly-values="readonlyFieldValues"
:fetch-options="fetchOptions"
@@ -652,6 +657,7 @@
:with-confetti="withConfetti"
:can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug"
:native-platform="nativePlatform"
/>
<nav
v-if="stepFields.length < 80"
@@ -772,6 +778,7 @@ export default {
provide () {
return {
baseUrl: this.baseUrl,
fetchOptions: this.fetchOptions,
scrollIntoArea: this.scrollIntoArea,
scrollIntoField: this.scrollIntoField,
t: this.t
@@ -909,6 +916,11 @@ export default {
required: false,
default: false
},
nativePlatform: {
type: String,
required: false,
default: ''
},
autoscrollFields: {
type: Boolean,
required: false,
@@ -969,10 +981,20 @@ export default {
required: false,
default: ''
},
previousSignatureValue: {
type: String,
prefillSignature: {
type: Object,
required: false,
default: ''
default: null
},
prefillInitials: {
type: Object,
required: false,
default: null
},
currentUser: {
type: Object,
required: false,
default: null
},
allowToSkip: {
type: Boolean,
@@ -994,6 +1016,11 @@ export default {
required: false,
default: false
},
viewToken: {
type: String,
required: false,
default: ''
},
attribution: {
type: Boolean,
required: false,
@@ -1329,6 +1356,30 @@ export default {
screen?.orientation?.addEventListener('change', this.onOrientationChange)
if (this.currentUser) {
this.fields.forEach((field) => {
if (field.readonly || field.type !== 'text') {
return
}
const fieldName = (field.name || '').toLowerCase()
let value
if (fieldName === 'full name' || fieldName === 'legal name') {
value = this.currentUser.full_name
} else if (fieldName === 'first name') {
value = this.currentUser.first_name
} else if (fieldName === 'last name') {
value = this.currentUser.last_name
}
if (value) {
this.values[field.uuid] ??= value
}
})
}
this.fields.forEach((field) => {
if (field.default_value && !field.readonly) {
this.values[field.uuid] ||= field.default_value
@@ -1523,15 +1574,10 @@ export default {
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
window.history.replaceState({}, document.title, newUrl)
return fetch(this.baseUrl + '/api/submitter_email_clicks', {
return fetch(this.baseUrl + `/s/${this.submitterSlug}/click_email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
t,
submitter_slug: this.submitterSlug
})
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers },
body: JSON.stringify({ t })
})
} else {
return Promise.resolve({})
@@ -1547,29 +1593,28 @@ export default {
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
window.history.replaceState({}, document.title, newUrl)
return fetch(this.baseUrl + '/api/submitter_sms_clicks', {
return fetch(this.baseUrl + `/s/${this.submitterSlug}/click_sms`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
c,
submitter_slug: this.submitterSlug
})
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers },
body: JSON.stringify({ c })
})
} else {
return Promise.resolve({})
}
},
trackViewForm () {
fetch(this.baseUrl + '/api/submitter_form_views', {
fetch(this.baseUrl + `/s/${this.submitterSlug}/view`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
submitter_slug: this.submitterSlug
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ v: this.viewToken })
}).then((resp) => {
if (resp.status === 404) {
return fetch(this.baseUrl + '/api/submitter_form_views', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ submitter_slug: this.submitterSlug })
})
}
})
},
previousSignatureValueFor (field) {
@@ -1767,6 +1812,10 @@ export default {
this.isCompleted = true
this.isFormVisible = true
if (this.nativePlatform) {
window.webkit?.messageHandlers?.native?.postMessage({ type: 'stale' })
}
if (resp?.text) {
const respData = await resp.text()
@@ -48,6 +48,11 @@ export default {
type: Object,
required: false,
default: () => ({})
},
fieldsUuidIndex: {
type: Object,
required: false,
default: () => ({})
}
},
data () {
@@ -59,7 +64,7 @@ export default {
isInlineSize () {
return CSS.supports('container-type: size')
},
fieldsUuidIndex () {
formulaFieldsUuidIndex () {
return this.fields.reduce((acc, field) => {
acc[field.uuid] = field
@@ -82,8 +87,8 @@ export default {
if (depth > 10) return formula
return formula.replace(/{{(.*?)}}/g, (match, uuid) => {
if (this.fieldsUuidIndex[uuid]) {
return `(${this.normalizeFormula(this.fieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
if (this.formulaFieldsUuidIndex[uuid]) {
return `(${this.normalizeFormula(this.formulaFieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
} else {
return match
}
@@ -91,16 +96,36 @@ export default {
},
calculateFormula (field) {
const transformedFormula = this.normalizeFormula(field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
return this.readonlyValues[uuid] || this.values[uuid] || 0.0
const value = this.readonlyValues[uuid] || this.values[uuid]
if (this.fieldsUuidIndex[uuid]?.type === 'date') {
return this.dateFormulaValue(value)
}
return value || 0.0
})
return this.math.evaluate(transformedFormula.toLowerCase())
const value = this.math.evaluate(transformedFormula.toLowerCase())
if (field.type === 'date') {
return Number.isFinite(value) ? new Date(Math.floor(value) * 86400000).toISOString().slice(0, 10) : ''
}
return value
},
dateFormulaValue (value) {
if (!value) return 'null'
if (value === '{{date}}') return 'today()'
const [year, month, day = 1] = value.slice(0, 10).split('-').map(Number)
return Date.UTC(year, month - 1, day) / 86400000
},
evalTextFormula (field, depth = 0) {
if (depth > 10) return ''
return field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
const formulaField = this.fieldsUuidIndex[uuid]
const formulaField = this.formulaFieldsUuidIndex[uuid]
if (formulaField?.preferences?.formula) {
if (formulaField.type === 'text') {
@@ -87,7 +87,7 @@
</button>
</span>
<button
v-if="modelValue || computedPreviousValue"
v-if="modelValue || computedPreviousValue || computedPrefillData"
type="button"
class="btn font-medium btn-outline btn-sm clear-canvas-button"
@click="remove"
@@ -139,8 +139,8 @@
:name="`values[${field.uuid}]`"
>
<img
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
v-if="modelValue || computedPreviousValue || computedPrefillData"
:src="previousValueUrl"
:alt="field.name || t('initials')"
class="mx-auto bg-white border border-base-300 rounded max-h-44"
>
@@ -150,20 +150,23 @@
class="absolute top-0 right-0 left-0 bottom-0"
/>
<label
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
v-if="!isDrawInitials && !modelValue && !computedPreviousValue && !computedPrefillData"
for="initials_text_input"
class="absolute top-0 right-0 left-0 bottom-0"
/>
<canvas
v-show="!modelValue && !computedPreviousValue"
v-show="!modelValue && !computedPreviousValue && !computedPrefillData"
ref="canvas"
role="img"
:aria-label="t('initials_drawing_area')"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
@touchstart.passive="lockSwipe"
@touchend.passive="unlockSwipe"
@touchcancel.passive="unlockSwipe"
/>
</div>
<input
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
v-if="!isDrawInitials && !modelValue && !computedPreviousValue && !computedPrefillData"
id="initials_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6 text-center"
@@ -199,7 +202,7 @@ export default {
MarkdownContent,
IconArrowsDiagonalMinimize2
},
inject: ['baseUrl', 't'],
inject: ['baseUrl', 'fetchOptions', 't'],
props: {
field: {
type: Object,
@@ -233,6 +236,16 @@ export default {
required: false,
default: ''
},
prefillData: {
type: Object,
required: false,
default: null
},
nativePlatform: {
type: String,
required: false,
default: ''
},
modelValue: {
type: String,
required: false,
@@ -255,10 +268,24 @@ export default {
} else {
return null
}
},
computedPrefillData () {
if (this.isUsePreviousValue && this.field.required === true && !this.previousValue && !this.modelValue) {
return this.prefillData
} else {
return null
}
},
previousValueUrl () {
if (this.computedPrefillData) {
return this.computedPrefillData.url
} else {
return this.attachmentsIndex[this.modelValue || this.computedPreviousValue].url
}
}
},
created () {
this.isInitialsStarted = !!this.computedPreviousValue
this.isInitialsStarted = !!(this.computedPreviousValue || this.computedPrefillData)
},
async mounted () {
this.$nextTick(() => {
@@ -311,6 +338,8 @@ export default {
this.intersectionObserver?.disconnect()
},
methods: {
lockSwipe: SignatureStep.methods.lockSwipe,
unlockSwipe: SignatureStep.methods.unlockSwipe,
drawOnCanvas: SignatureStep.methods.drawOnCanvas,
drawImage (event) {
this.remove()
@@ -397,7 +426,48 @@ export default {
this.updateWrittenInitials({ target: this.$refs.textInput })
}
},
submitPrefillAttachment () {
if (this.dryRun) {
const attachment = { uuid: Math.random().toString(), url: this.prefillData.url }
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
return Promise.resolve(attachment)
}
const formData = new FormData()
formData.append('prefill_token', this.prefillData.token)
return fetch(this.baseUrl + `/s/${this.submitterSlug}/prefill_attachments`, {
method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData
}).then(async (resp) => {
if (!resp.ok) {
const data = await resp.json()
return Promise.reject(new Error(data.error))
}
const attachment = await resp.json()
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
return attachment
}).catch((error) => {
this.remove()
return Promise.reject(error)
})
},
async submit () {
if (this.computedPrefillData) {
return this.submitPrefillAttachment()
}
if (this.modelValue || this.computedPreviousValue) {
if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue)
@@ -431,9 +501,20 @@ export default {
formData.append('name', 'attachments')
formData.append('type', 'initials')
return fetch(this.baseUrl + `/s/${this.submitterSlug}/upload`, {
method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData
}).then((resp) => {
if (resp.status === 404) {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData
})
} else {
return resp
}
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
@@ -104,6 +104,11 @@ export default {
required: false,
default: () => ({})
},
fieldsUuidIndex: {
type: Object,
required: false,
default: () => ({})
},
values: {
type: Object,
required: true
@@ -137,7 +142,7 @@ export default {
}
},
computed: {
fieldsUuidIndex () {
formulaFieldsUuidIndex () {
return this.fields.reduce((acc, field) => {
acc[field.uuid] = field
@@ -216,17 +221,31 @@ export default {
},
calculateFormula () {
const transformedFormula = this.normalizeFormula(this.field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
return this.readonlyValues[uuid] || this.values[uuid] || 0.0
const value = this.readonlyValues[uuid] || this.values[uuid]
if (this.fieldsUuidIndex[uuid]?.type === 'date') {
return this.dateFormulaValue(value)
}
return value || 0.0
})
return this.math.evaluate(transformedFormula.toLowerCase())
},
dateFormulaValue (value) {
if (!value) return 'null'
if (value === '{{date}}') return 'today()'
const [year, month, day = 1] = value.slice(0, 10).split('-').map(Number)
return Date.UTC(year, month - 1, day) / 86400000
},
normalizeFormula (formula, depth = 0) {
if (depth > 10) return formula
return formula.replace(/{{(.*?)}}/g, (match, uuid) => {
if (this.fieldsUuidIndex[uuid]) {
return `(${this.normalizeFormula(this.fieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
if (this.formulaFieldsUuidIndex[uuid]) {
return `(${this.normalizeFormula(this.formulaFieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
} else {
return match
}
+100 -30
View File
@@ -45,7 +45,7 @@
<span
v-else-if="withTypedSignature && format !== 'drawn_or_upload' && format !== 'typed_or_upload' && format !== 'typed' && format !== 'drawn' && format !== 'upload'"
class="md:tooltip ml-2"
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue || computedPrefillData }"
:data-tip="t('type_text')"
>
<button
@@ -68,7 +68,7 @@
<span
v-if="format !== 'typed' && format !== 'drawn' && format !== 'upload' && format !== 'drawn_or_typed'"
class="md:tooltip"
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue || computedPrefillData }"
:data-tip="t('take_photo')"
>
<button
@@ -95,7 +95,7 @@
</button>
</span>
<button
v-if="modelValue || computedPreviousValue"
v-if="modelValue || computedPreviousValue || computedPrefillData"
type="button"
class="btn btn-outline btn-sm font-medium reupload-button"
@click="remove"
@@ -107,7 +107,7 @@
{{ t(format === 'upload' ? 'reupload' : 'redraw') }}
</button>
<span
v-if="withQrButton && !modelValue && !computedPreviousValue && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
v-if="withQrButton && !modelValue && !computedPreviousValue && !computedPrefillData && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
class="md:tooltip before:translate-x-[-90%]"
:data-tip="t('sign_on_the_touchscreen')"
>
@@ -154,20 +154,14 @@
type="hidden"
:name="`values[${field.uuid}]`"
>
<input
v-if="isTouchAttachment"
:value="touchAttachmentUuid"
type="hidden"
name="touch_attachment_uuid"
>
<img
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
v-if="modelValue || computedPreviousValue || computedPrefillData"
:src="previousValueUrl"
:alt="field.name || t('signature')"
class="mx-auto bg-white border border-base-300 rounded max-h-44"
>
<FileDropzone
v-if="format === 'upload' && !modelValue && !computedPreviousValue"
v-if="format === 'upload' && !modelValue && !computedPreviousValue && !computedPrefillData"
:message="`${t('upload')} ${field.name || t('signature')}`"
:submitter-slug="submitterSlug"
:dry-run="dryRun"
@@ -179,7 +173,7 @@
class="relative select-none"
>
<div
v-if="!modelValue && !computedPreviousValue && !isShowQr && !isTextSignature && isSignatureStarted"
v-if="!modelValue && !computedPreviousValue && !computedPrefillData && !isShowQr && !isTextSignature && isSignatureStarted"
class="absolute top-0.5 right-0.5"
>
<button
@@ -199,12 +193,15 @@
class="absolute top-0 right-0 left-0 bottom-0"
/>
<canvas
v-show="!modelValue && !computedPreviousValue"
v-show="!modelValue && !computedPreviousValue && !computedPrefillData"
ref="canvas"
role="img"
:aria-label="t('signature_drawing_area')"
style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
@touchstart.passive="lockSwipe"
@touchend.passive="unlockSwipe"
@touchcancel.passive="unlockSwipe"
/>
<div
v-if="isShowQr"
@@ -241,7 +238,7 @@
</div>
</div>
<input
v-if="isTextSignature && !modelValue && !computedPreviousValue"
v-if="isTextSignature && !modelValue && !computedPreviousValue && !computedPrefillData"
id="signature_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6"
@@ -373,7 +370,7 @@ export default {
IconSignature,
IconArrowsDiagonalMinimize2
},
inject: ['baseUrl', 't'],
inject: ['baseUrl', 'fetchOptions', 't'],
props: {
field: {
type: Object,
@@ -437,10 +434,10 @@ export default {
required: false,
default: ''
},
touchAttachmentUuid: {
type: String,
prefillData: {
type: Object,
required: false,
default: ''
default: null
},
reason: {
type: String,
@@ -457,20 +454,24 @@ export default {
required: false,
default: ''
},
nativePlatform: {
type: String,
required: false,
default: ''
},
modelValue: {
type: String,
required: false,
default: ''
}
},
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason', 'touch-attachment'],
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason'],
data () {
return {
isSignatureStarted: false,
isShowQr: false,
isOtherReason: false,
isUsePreviousValue: true,
isTouchAttachment: false,
isTextSignature: !this.signatureSrc && (!!this.signatureText || this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload'),
uploadImageInputKey: Math.random().toString()
}
@@ -498,10 +499,24 @@ export default {
} else {
return null
}
},
computedPrefillData () {
if (this.isUsePreviousValue && this.field.required === true && !this.previousValue && !this.modelValue) {
return this.prefillData
} else {
return null
}
},
previousValueUrl () {
if (this.computedPrefillData) {
return this.computedPrefillData.url
} else {
return this.attachmentsIndex[this.modelValue || this.computedPreviousValue].url
}
}
},
created () {
this.isSignatureStarted = !!this.computedPreviousValue
this.isSignatureStarted = !!(this.computedPreviousValue || this.computedPrefillData)
if (this.withSigningReason) {
this.field.preferences ||= {}
@@ -576,6 +591,16 @@ export default {
this.stopCheckSignature()
},
methods: {
lockSwipe () {
if (this.nativePlatform) {
window.webkit?.messageHandlers?.native?.postMessage({ type: 'swipe', op: 'lock' })
}
},
unlockSwipe () {
if (this.nativePlatform) {
window.webkit?.messageHandlers?.native?.postMessage({ type: 'swipe', op: 'unlock' })
}
},
setCanvasSize () {
const canvas = this.$refs.canvas
@@ -872,15 +897,49 @@ export default {
console.error(e)
}
},
async submit () {
if (this.modelValue || this.computedPreviousValue) {
if (this.touchAttachmentUuid && this.computedPreviousValue === this.touchAttachmentUuid && !Object.values(this.values).includes(this.touchAttachmentUuid)) {
this.isTouchAttachment = true
this.$emit('touch-attachment', this.touchAttachmentUuid)
} else {
this.isTouchAttachment = false
submitPrefillAttachment () {
if (this.dryRun) {
const attachment = { uuid: Math.random().toString(), url: this.prefillData.url }
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
return Promise.resolve(attachment)
}
const formData = new FormData()
formData.append('prefill_token', this.prefillData.token)
return fetch(this.baseUrl + `/s/${this.submitterSlug}/prefill_attachments`, {
method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData
}).then(async (resp) => {
if (!resp.ok) {
const data = await resp.json()
return Promise.reject(new Error(data.error))
}
const attachment = await resp.json()
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
return attachment
}).catch((error) => {
this.remove()
return Promise.reject(error)
})
},
async submit () {
if (this.computedPrefillData) {
return this.submitPrefillAttachment()
}
if (this.modelValue || this.computedPreviousValue) {
if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue)
}
@@ -932,9 +991,20 @@ export default {
formData.append('remember_signature', this.rememberSignature)
formData.append('type', 'signature')
return fetch(this.baseUrl + `/s/${this.submitterSlug}/upload`, {
method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData
}).then((resp) => {
if (resp.status === 404) {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData
})
} else {
return resp
}
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
+122 -13
View File
@@ -1,8 +1,9 @@
<template>
<div
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex field-area-controls"
style="top: -25px; height: 25px"
:style="{ zIndex: isMobile ? 5 : 10 }"
@mousedown.stop="$emit('click-title')"
@pointerdown.stop
>
@@ -40,7 +41,7 @@
@blur="onNameBlur"
>{{ optionIndexText }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || defaultName }}</span>
<div
v-if="isSettingsFocus || isSelectInput || (isValueInput && field.type !== 'heading') || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
v-if="isShowControls"
class="flex items-center ml-1.5"
>
<input
@@ -74,9 +75,11 @@
@click.prevent="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>{{ t('editable') }}</label>
</div>
<span
v-if="field.type !== 'payment' && !isValueInput"
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"
>
@@ -85,8 +88,9 @@
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center"
style="height: 25px"
@focus="isSettingsFocus = true"
:class="{ 'pr-0.5': isMobile }"
style="height: 24px"
@focus="onSettingsFocus"
@blur="maybeBlurSettings"
>
<IconDotsVertical class="w-5 h-5" />
@@ -111,11 +115,11 @@
:with-areas="false"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@click-formula="openFormulaModal"
@click-font="openFontModal"
@click-description="openDescriptionModal"
@add-custom-field="$emit('add-custom-field')"
@click-condition="isShowConditionsModal = true"
@click-condition="openConditionsModal"
@save="$emit('change')"
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
/>
@@ -125,11 +129,61 @@
>
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>
</div>
<PaymentSettings
v-else-if="isMobile && editable && field.type === 'payment'"
class="-ml-1"
:field="field"
:with-force-open="false"
:background-color="'white'"
:menu-classes="'rounded-t-none pt-1'"
@click-condition="openConditionsModal"
@click-description="openDescriptionModal"
@click-formula="openFormulaModal"
>
<label
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center pr-0.5"
style="height: 24px"
>
<IconDotsVertical class="w-5 h-5" />
</label>
<template #menu>
<li class="field-settings-remove">
<a
href="#"
class="text-sm py-1 px-2 text-red-600"
@click.prevent="$emit('remove')"
>
<IconTrashX
:width="20"
:stroke-width="1.6"
/>
{{ t('remove') }}
</a>
</li>
</template>
</PaymentSettings>
<button
v-else-if="editable"
v-else-if="editable && !isShowControls"
class="pr-1"
:title="t('remove')"
@click.prevent="$emit('remove')"
@@ -195,18 +249,21 @@ import FieldSubmitter from './field_submitter'
import FieldType from './field_type'
import Field from './field'
import FieldSettings from './field_settings'
import PaymentSettings from './payment_settings'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconX, IconDotsVertical } from '@tabler/icons-vue'
import { IconX, IconDotsVertical, IconTrashX } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
name: 'AreaTitle',
components: {
IconTrashX,
FieldType,
FieldSettings,
PaymentSettings,
FormulaModal,
FontModal,
IconDotsVertical,
@@ -215,7 +272,7 @@ export default {
FieldSubmitter,
IconX
},
inject: ['t'],
inject: ['t', 'isMobile', 'nativePlatform'],
props: {
template: {
type: Object,
@@ -292,6 +349,17 @@ export default {
}
},
computed: {
isShowControls () {
if (this.isMobile) return false
return this.isSettingsFocus || this.isSelectInput || (this.isValueInput && this.field.type !== 'heading') || (this.isNameFocus && !['checkbox', 'phone'].includes(this.field.type))
},
isShowSettingsMenu () {
if (this.field.type === 'payment' || this.isValueInput) return false
if (this.isMobile) return this.editable
return this.isShowControls
},
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
submitter () {
@@ -318,6 +386,34 @@ export default {
}
},
methods: {
openFormulaModal () {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'formula', detent: 'medium', uuid: this.field.uuid })
} else {
this.isShowFormulaModal = true
}
},
openFontModal () {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'font', detent: 'medium', uuid: this.field.uuid })
} else {
this.isShowFontModal = true
}
},
openConditionsModal () {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'conditions', detent: 'medium', uuid: this.field.uuid })
} else {
this.isShowConditionsModal = true
}
},
openDescriptionModal () {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'description', detent: 'medium', uuid: this.field.uuid })
} else {
this.isShowDescriptionModal = true
}
},
buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
@@ -327,6 +423,19 @@ export default {
this.isSettingsFocus = false
}
},
onSettingsFocus () {
this.isSettingsFocus = true
if (this.isMobile) {
this.$nextTick(() => {
const dropdown = this.$refs.settingsDropdown
const padding = parseFloat(getComputedStyle(dropdown).paddingLeft) + 6
const left = dropdown.parentElement.getBoundingClientRect().right - dropdown.offsetWidth - padding
dropdown.style.right = left < 0 ? `${left}px` : ''
})
}
},
onNameFocus (e) {
this.selectedAreasRef.value = [this.area]
+522 -110
View File
@@ -2,13 +2,12 @@
<div
ref="dragContainer"
style="max-width: 1600px"
class="mx-auto pl-3 h-full"
:class="isMobile ? 'pl-4' : 'md:pl-4'"
:class="nativeView ? (isSheetView ? 'bg-base-100' : 'px-4') : 'mx-auto pl-3 md:pl-4 h-full'"
@dragover="onDragover"
@drop="isDragFile = false"
>
<HoverDropzone
v-if="sortedDocuments.length && withUploadButton && editable"
v-if="sortedDocuments.length && withUploadButton && editable && !isSheetView"
:is-dragging="isDragFile"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
@@ -26,16 +25,143 @@
:is-default="defaultFields.includes(toRaw(dragField))"
:is-required="defaultRequiredFields.includes(toRaw(dragField))"
/>
<MobileDrawer
v-if="isDocumentsView || withDocumentsDrawer"
side="left"
:inline="isDocumentsView"
:open="isDocumentsDrawerOpen"
:background-color="backgroundColor"
@close="isDocumentsDrawerOpen = false"
>
<div class="pt-3 space-y-3">
<DocumentPreview
v-for="(item, index) in template.schema"
:key="item.attachment_uuid"
:with-arrows="template.schema.length > 1"
:with-visible-controls="true"
:item="item"
:document="sortedDocuments[index]"
:accept-file-types="acceptFileTypes"
:with-replace-button="withUploadButton"
:with-google-drive="withGoogleDrive"
:authenticity-token="authenticityToken"
:editable="editable"
:dynamic-documents="dynamicDocuments"
:with-dynamic-documents="withDynamicDocuments"
:template="template"
@scroll-to="onDrawerScrollTo(item)"
@remove="onDocumentRemove"
@replace="onDocumentReplace"
@up="moveDocument(item, -1)"
@reorder="reorderFields"
@edit="openDocumentsEditor(item)"
@down="moveDocument(item, 1)"
@change="save"
/>
</div>
<div
v-if="pendingFieldAttachmentUuids.length && editable"
v-if="editable && (withUploadButton || withAddPageButton)"
class="pt-3 space-y-2"
:class="isDocumentsView ? 'pb-6' : 'sticky bottom-0 bg-base-100 pb-3'"
:style="['', null, 'transparent'].includes(backgroundColor) ? {} : { backgroundColor }"
>
<Upload
v-if="withUploadButton"
:accept-file-types="acceptFileTypes"
:authenticity-token="authenticityToken"
:with-google-drive="withGoogleDrive"
:template-id="template.id"
@success="updateFromUpload"
/>
<button
v-if="withAddPageButton"
class="btn btn-outline w-full add-blank-page-button"
@click.prevent="addBlankPage"
>
<IconInnerShadowTop
v-if="isLoadingBlankPage"
class="animate-spin w-5 h-5"
/>
<IconPlus
v-else
class="w-5 h-5"
/>
{{ t('add_blank_page') }}
</button>
</div>
</MobileDrawer>
<MobileDrawer
v-if="isFieldsView || withFieldsDrawer"
side="right"
:inline="isFieldsView"
:open="isFieldsDrawerOpen"
:background-color="backgroundColor"
@close="isFieldsDrawerOpen = false"
>
<div class="pb-6">
<Fields
ref="fields"
:with-top-padding="true"
:fields="template.fields"
:submitters="template.submitters"
:selected-submitter="selectedSubmitter"
:with-help="withHelp"
:default-submitters="defaultSubmitters"
:draw-field-type="drawFieldType"
:custom-fields="customFields"
:with-custom-fields="withCustomFields"
:with-fields-search="withFieldsSearch"
:default-fields="[...defaultRequiredFields, ...defaultFields]"
:with-custom-fields-tab="false"
:template="template"
:default-required-fields="defaultRequiredFields"
:detect-custom-fields-index="detectCustomFieldsIndex"
:field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters"
:with-fields-detection="withFieldsDetection"
:with-detect-existing-fields="withDetectExistingFields"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:only-defined-fields="onlyDefinedFields"
:editable="editable"
:scroll-on-edit="false"
@add-field="addField"
@set-draw="onDrawerSetDraw"
@remove-field="onRemoveField"
@remove-submitter="onRemoveSubmitter"
@select-submitter="onDrawerSelectSubmitter"
@set-draw-type="onDrawerSetDrawType"
@set-draw-custom-field="onDrawerSetDrawCustomField"
@set-drag="dragField = $event"
@set-drag-placeholder="$refs.dragPlaceholder.dragPlaceholder = $event"
@drag-end="[dragField = null, $refs.dragPlaceholder.dragPlaceholder = null]"
@change-submitter="onDrawerSelectSubmitter"
@scroll-to-area="onDrawerScrollToArea"
@rebuild-variables-schema="rebuildVariablesSchema"
/>
</div>
</MobileDrawer>
<NativeModals
v-if="isSheetView"
:default-fields="[...defaultRequiredFields, ...defaultFields]"
:custom-fields="customFields"
:editable="editable"
:authenticity-token="authenticityToken"
:accept-file-types="acceptFileTypes"
:base-url="baseUrl"
:page-preview-format="pagePreviewFormat"
@documents-modified="onDocumentsModified"
/>
<div
v-if="pendingFieldAttachmentUuids.length && editable && !nativeView"
class="top-1.5 sticky h-0 z-20 max-w-2xl mx-auto"
>
<div class="alert border-base-content/30 py-2 px-2.5">
<div class="alert border-base-content/30 py-2 px-2.5 max-sm:flex max-sm:items-center max-sm:justify-between max-sm:gap-2 max-sm:text-left max-sm:text-sm">
<IconInfoCircle
class="stroke-info shrink-0 w-6 h-6"
/>
<span>{{ t('uploaded_pdf_contains_form_fields_keep_or_remove_them') }}</span>
<div>
<div class="max-sm:flex max-sm:gap-1 max-sm:shrink-0">
<button
class="btn btn-sm"
@click.prevent="removePendingFields"
@@ -55,10 +181,10 @@
v-if="beforeRevisionSnapshot"
class="top-1.5 sticky h-0 z-20 max-w-2xl mx-auto"
>
<div class="alert border-base-content/30 py-2 px-2.5">
<div class="alert border-base-content/30 py-2 px-2.5 max-sm:flex max-sm:items-center max-sm:justify-between max-sm:gap-2 max-sm:text-left max-sm:text-sm">
<IconInfoCircle class="stroke-info shrink-0 w-6 h-6" />
<span>{{ t('viewing_revision_from').replace('{date}', formatRevisionTime(beforeRevisionSnapshot.revision.created_at)) }}</span>
<div>
<div class="max-sm:flex max-sm:gap-1 max-sm:shrink-0">
<button
class="btn btn-sm"
@click.prevent="cancelRevision"
@@ -76,10 +202,10 @@
</div>
</div>
<div
v-if="$slots.buttons || withTitle"
v-if="($slots.buttons || withTitle) && !nativeView"
id="title_container"
class="flex justify-between py-1.5 items-center pr-4 top-0 z-10 title-container"
:class="{ sticky: withStickySubmitters || isBreakpointLg }"
:class="{ sticky: withStickySubmitters || isBreakpointLg, hidden: !!nativePlatform }"
:style="{ backgroundColor }"
>
<div class="flex items-center space-x-3">
@@ -98,16 +224,67 @@
@update:model-value="updateName"
/>
</div>
<div class="space-x-3 flex items-center flex-shrink-0">
<div class="flex items-center gap-3 flex-shrink-0">
<slot
v-if="$slots.buttons"
name="buttons"
/>
<template v-else>
<form-prompt
v-if="!!nativePlatform && editable"
data-refresh="false"
>
<native-action
data-placement="title"
:data-label="t('name')"
>
<form
class="hidden"
method="post"
:action="`/templates/${template.id}`"
>
<input
type="hidden"
name="_method"
value="put"
autocomplete="off"
>
<input
type="hidden"
name="authenticity_token"
:value="authenticityToken"
autocomplete="off"
>
<label for="template_name_prompt">{{ t('name') }}</label>
<input
id="template_name_prompt"
type="hidden"
name="template[name]"
:value="template.name"
autocomplete="off"
>
</form>
</native-action>
</form-prompt>
<native-action
v-if="withSendButton && !!nativePlatform"
:data-label="t('send')"
data-icon="send"
>
<a
:href="`/templates/${template.id}/submissions/new?with_link=true`"
data-turbo-frame="modal"
@click="maybeShowErrorTemplateAlert"
/>
</native-action>
<native-action
v-if="withSignYourselfButton && undefinedSubmitters.length < 2 && (!template.variables_schema || Object.keys(template.variables_schema).length === 0)"
target="_blank"
data-turbo="false"
:data-label="t('sign_yourself')"
data-icon="writing"
>
<form
:target="!!nativePlatform ? null : '_blank'"
:data-turbo="!!nativePlatform ? null : 'false'"
class="inline"
method="post"
:action="`/start_form_self/${template.id}`"
@@ -138,8 +315,13 @@
</span>
</button>
</form>
<a
</native-action>
<native-action
v-else-if="withSignYourselfButton"
:data-label="t('sign_yourself')"
data-icon="writing"
>
<a
id="sign_yourself_button"
:href="`/templates/${template.id}/submissions/new?selfsign=true`"
class="btn btn-primary btn-ghost text-base hidden md:flex"
@@ -154,11 +336,17 @@
{{ t('sign_yourself') }}
</span>
</a>
</native-action>
<native-action
v-if="withSendButton && !nativePlatform"
:data-label="t('send')"
data-icon="users_plus"
data-placement="bar"
>
<a
v-if="withSendButton"
id="send_button"
:href="`/templates/${template.id}/submissions/new?with_link=true`"
:data-turbo-frame="isMobile && isBreakpointLg ? '_top' : 'modal'"
:data-turbo-frame="!nativePlatform && isMobile && isBreakpointLg ? '_top' : 'modal'"
class="white-button md:!px-6"
@click="maybeShowErrorTemplateAlert"
>
@@ -170,10 +358,16 @@
{{ t('send') }}
</span>
</a>
</native-action>
<span
v-if="editable"
id="save_button_container"
class="flex"
>
<native-action
:data-label="t('save')"
data-icon="check"
data-placement="bar"
>
<button
class="base-button !rounded-r-none !pr-2"
@@ -194,6 +388,7 @@
{{ t('save') }}
</span>
</button>
</native-action>
<div
class="dropdown dropdown-end"
:class="{ 'dropdown-open': isDownloading || isPreviewLoading }"
@@ -209,10 +404,15 @@
<ul
tabindex="0"
class="dropdown-content p-2 mt-2 shadow menu text-base bg-base-100 rounded-box text-right"
>
<native-action
:data-label="t('save_and_preview')"
data-icon="eye"
>
<li>
<a
:href="`/templates/${template.id}/form`"
:data-native-actions="template.submitters.length > 1 ? 'users' : null"
data-turbo="false"
class="flex items-center justify-center space-x-2"
@click.exact="isPreviewLoading = true"
@@ -228,10 +428,15 @@
<span class="whitespace-nowrap">{{ t('save_and_preview') }}</span>
</a>
</li>
</native-action>
<native-action
:data-label="t('preferences')"
data-icon="adjustments_horizontal"
>
<li>
<a
:href="`/templates/${template.id}/preferences`"
:data-turbo-frame="isMobile && isBreakpointLg ? '_top' : 'modal'"
:data-turbo-frame="!nativePlatform && isMobile && isBreakpointLg ? '_top' : 'modal'"
class="flex space-x-2"
@click="closeDropdown"
>
@@ -239,7 +444,13 @@
<span class="whitespace-nowrap">{{ t('preferences') }}</span>
</a>
</li>
<li v-if="withRevisionsMenu">
</native-action>
<native-action
v-if="withRevisionsMenu"
:data-label="t('revisions')"
data-icon="history"
>
<li>
<button
class="flex space-x-2"
@click.prevent="openRevisionsModal"
@@ -251,7 +462,13 @@
<span class="whitespace-nowrap">{{ t('revisions') }}</span>
</button>
</li>
<li v-if="withDownload">
</native-action>
<native-action
v-if="withDownload"
:data-label="t('download')"
data-icon="download"
>
<li>
<button
class="flex space-x-2"
:disabled="isDownloading"
@@ -275,6 +492,7 @@
>{{ t('download') }}</span>
</button>
</li>
</native-action>
</ul>
</div>
</span>
@@ -295,16 +513,17 @@
</div>
</div>
<div
v-if="!nativeView"
id="main_container"
class="flex main-container"
:class="$slots.buttons || withTitle ? (isMobile ? 'max-h-[calc(100%_-_60px)]' : 'md:max-h-[calc(100%_-_60px)]') : (isMobile ? 'max-h-[100%]' : 'md:max-h-[100%]')"
:class="($slots.buttons || withTitle) && !nativePlatform ? (isMobile ? 'max-h-[calc(100%_-_60px)]' : 'md:max-h-[calc(100%_-_60px)]') : (isMobile ? 'max-h-[100%]' : 'md:max-h-[100%]')"
>
<div
v-if="withDocumentsList"
v-if="withDocumentsList && !withDocumentsDrawer"
id="documents_container"
ref="previews"
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
class="overflow-y-auto overflow-x-hidden w-52 flex-none pr-3 mt-0.5 pt-0.5 hidden lg:block"
:style="{ 'display': isBreakpointLg || isMobile ? 'none' : 'initial' }"
class="overflow-y-auto overflow-x-hidden w-52 flex-none pr-3 mt-0.5 pt-0.5"
>
<DocumentPreview
v-for="(item, index) in template.schema"
@@ -326,7 +545,7 @@
@replace="onDocumentReplace"
@up="moveDocument(item, -1)"
@reorder="reorderFields"
@edit="editModalDocumentUuid = item.attachment_uuid"
@edit="openDocumentsEditor(item)"
@down="moveDocument(item, 1)"
@change="save"
/>
@@ -461,60 +680,14 @@
@delete-selected-areas="deleteSelectedAreas"
@autodetect-fields="detectFieldsForPage"
/>
<DocumentControls
v-if="isBreakpointLg && editable"
:with-arrows="template.schema.length > 1"
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
:with-replace-button="withUploadButton"
:with-google-drive="withGoogleDrive"
:authenticity-token="authenticityToken"
:accept-file-types="acceptFileTypes"
:document="document"
:template="template"
class="pb-2 mb-2 border-b border-base-300 border-dashed"
@remove="onDocumentRemove"
@replace="onDocumentReplace"
@up="moveDocument(template.schema.find((item) => item.attachment_uuid === document.uuid), -1)"
@down="moveDocument(template.schema.find((item) => item.attachment_uuid === document.uuid), 1)"
@change="save"
/>
</template>
<div
v-if="sortedDocuments.length && isBreakpointLg && editable"
class="pb-4 space-y-2"
>
<Upload
v-if="withUploadButton"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
:authenticity-token="authenticityToken"
:with-google-drive="withGoogleDrive"
@success="updateFromUpload"
/>
<button
v-if="withAddPageButton"
id="add_blank_page_button"
class="btn btn-outline w-full mt-4 add-blank-page-button"
@click.prevent="addBlankPage"
>
<IconInnerShadowTop
v-if="isLoadingBlankPage"
class="animate-spin w-5 h-5"
/>
<IconPlus
v-else
class="w-5 h-5"
/>
{{ t('add_blank_page') }}
</button>
</div>
</template>
</div>
</div>
<div
v-if="withFieldsList && !isMobile"
v-if="withFieldsList && !(nativePlatform ? isBreakpointLg : isBreakpointMd)"
id="fields_list_container"
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block fields-list-container"
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 fields-list-container"
:class="drawField || drawCustomField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'"
>
<div
@@ -566,6 +739,7 @@
:detect-custom-fields-index="detectCustomFieldsIndex"
:field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters"
:with-add-fields-pill="!nativePlatform && !showDrawField && !drawField && !drawCustomField"
:with-fields-detection="withFieldsDetection"
:with-detect-existing-fields="withDetectExistingFields"
:with-signature-id="withSignatureId"
@@ -578,7 +752,7 @@
@remove-field="onRemoveField"
@remove-submitter="onRemoveSubmitter"
@select-submitter="selectedSubmitter = $event"
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
@set-draw-type="isMobile ? startFieldDraw({ type: $event }) : [drawFieldType = $event, showDrawField = true]"
@set-draw-custom-field="[drawCustomField = $event, showDrawField = true]"
@set-drag="dragField = $event"
@set-drag-placeholder="$refs.dragPlaceholder.dragPlaceholder = $event"
@@ -590,28 +764,88 @@
</div>
</div>
</div>
<div class="sticky bottom-0 z-10">
<MobileDrawField
v-if="drawField && (isBreakpointLg || isMobile)"
:draw-field="drawField"
:fields="template.fields"
:submitters="template.submitters"
:selected-submitter="selectedSubmitter"
:class="{ 'md:hidden': !isMobile }"
:editable="editable"
@cancel="[drawField = null, drawOption = null]"
@change-submitter="[selectedSubmitter = $event, drawField.submitter_uuid = $event.uuid]"
<div
v-if="!nativeView"
class="sticky bottom-0 z-20"
>
<native-action
v-if="nativePlatform && withDocumentsList && (isBreakpointLg || isMobile) && sortedDocuments.length && editable"
data-placement="drawer"
data-side="leading"
data-icon="files"
data-native="drawer"
data-haptic="true"
:data-label="t('documents')"
data-view="documents"
/>
<MobileFields
v-if="sortedDocuments.length && !drawField && editable"
:fields="template.fields"
:default-fields="[...defaultRequiredFields, ...defaultFields]"
:default-required-fields="defaultRequiredFields"
:field-types="fieldTypes"
:class="{ 'md:hidden': !isMobile }"
:selected-submitter="selectedSubmitter"
@select="startFieldDraw($event)"
<native-action
v-if="nativePlatform && withFieldsList && isBreakpointLg && sortedDocuments.length && editable"
data-placement="drawer"
data-side="trailing"
data-icon="plus"
data-native="drawer"
data-haptic="true"
:data-label="t('fields')"
data-view="fields"
/>
<native-action
v-if="nativePlatform && (drawField || drawCustomField) && (isBreakpointLg || isMobile)"
:key="drawCustomField?.uuid || drawField?.uuid"
data-placement="draw"
:data-label="t('draw_field').replace('{field}', drawCustomField ? drawCustomField.name : fieldNames[drawField.type])"
>
<button
class="hidden"
@click="[drawField = null, drawOption = null, drawCustomField = null, showDrawField = false]"
/>
</native-action>
<button
v-if="drawField && isBreakpointMd && !nativePlatform"
class="btn btn-neutral text-white rounded-full no-animation transition-none absolute bottom-6 -left-3 md:-left-4 right-0 mx-auto w-fit z-10 flex-nowrap gap-2 px-5 max-[400px]:px-4"
style="max-width: calc(100% - 11rem); --btn-focus-scale: 1"
@click.prevent="[drawField = null, drawOption = null]"
>
<component
:is="fieldIcons[drawField.type]"
:width="22"
:height="22"
:stroke-width="1.6"
class="flex-shrink-0 max-[400px]:hidden"
/>
<span
class="truncate normal-case font-normal leading-normal"
style="font-size: 15px"
>{{ t('draw_field').replace('{field}', fieldNames[drawField.type]) }}</span>
<IconX
:width="18"
:height="18"
:stroke-width="2.5"
class="flex-shrink-0"
/>
</button>
<button
v-if="withDocumentsDrawer"
class="btn btn-neutral text-white btn-circle btn-lg absolute bottom-4 left-1 md:left-0 z-10"
:title="t('documents')"
@click.prevent="isDocumentsDrawerOpen = true"
>
<IconFiles
:width="26"
:height="26"
:stroke-width="1.6"
/>
</button>
<button
v-if="withFieldsDrawer"
class="btn btn-neutral text-white btn-circle btn-lg absolute bottom-4 right-4 z-10"
:title="t('fields')"
@click.prevent="isFieldsDrawerOpen = true"
>
<IconPlus
:width="28"
:height="28"
/>
</button>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-out"
@@ -660,9 +894,9 @@
>
<div
v-if="zoomLevel > 1"
class="sticky bottom-0 z-40 pointer-events-none"
class="sticky bottom-0 z-10 pointer-events-none"
>
<div class="absolute left-0 right-0 bottom-4 flex justify-center">
<div class="absolute left-0 right-0 bottom-6 md:bottom-4 flex justify-center">
<div class="join shadow pointer-events-auto">
<span class="join-item bg-base-content text-white pl-2 pr-2.5 h-9 items-center text-sm font-medium cursor-default w-16 flex justify-end">
<span>
@@ -690,7 +924,7 @@
:revisions="revisions"
:locale="locale"
@close="isRevisionsModalOpen = false"
@apply="onRevisionApply"
@apply="onRevisionApply($event)"
/>
<DocumentsEditorModal
v-if="editModalDocumentUuid"
@@ -713,17 +947,17 @@ import Dropzone from './dropzone'
import HoverDropzone from './hover_dropzone'
import DragPlaceholder from './drag_placeholder'
import Fields from './fields'
import MobileDrawField from './mobile_draw_field'
import MobileDrawer from './mobile_drawer'
import FieldType from './field_type'
import Document from './document'
import Logo from './logo'
import Contenteditable from './contenteditable'
import DocumentPreview from './preview'
import DocumentControls from './controls'
import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter'
import RevisionsModal from './revisions_modal'
import DocumentsEditorModal from './documents_editor_modal'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory, IconX } from '@tabler/icons-vue'
import NativeModals from './native_modals'
import { IconPlus, IconFiles, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory, IconX } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
import * as i18n from './i18n'
@@ -747,15 +981,14 @@ export default {
Document,
Fields,
IconInfoCircle,
MobileDrawField,
MobileDrawer,
IconPlus,
IconFiles,
IconWritingSign,
MobileFields,
Logo,
Dropzone,
HoverDropzone,
DocumentPreview,
DocumentControls,
IconInnerShadowTop,
Contenteditable,
IconUsersPlus,
@@ -767,7 +1000,8 @@ export default {
IconDeviceFloppy,
IconX,
RevisionsModal,
DocumentsEditorModal
DocumentsEditorModal,
NativeModals
},
provide () {
return {
@@ -791,6 +1025,8 @@ export default {
isPaypalConnected: this.isPaypalConnected,
withFormula: this.withFormula,
withConditions: this.withConditions,
nativePlatform: this.nativePlatform,
isMobile: this.isMobile,
withCustomFields: this.withCustomFields,
isInlineSize: this.isInlineSize,
defaultDrawFieldType: this.defaultDrawFieldType,
@@ -1008,6 +1244,16 @@ export default {
required: false,
default: true
},
nativePlatform: {
type: String,
required: false,
default: ''
},
nativeView: {
type: String,
required: false,
default: ''
},
withFieldsSearch: {
type: Boolean,
required: false,
@@ -1123,6 +1369,7 @@ export default {
return {
documentRefs: [],
isBreakpointLg: false,
isBreakpointMd: false,
isDownloading: false,
isPreviewLoading: false,
isLoadingBlankPage: false,
@@ -1142,6 +1389,8 @@ export default {
isDragFile: false,
isMathLoaded: false,
isRevisionsModalOpen: false,
isDocumentsDrawerOpen: false,
isFieldsDrawerOpen: false,
editModalDocumentUuid: null,
revisions: [],
beforeRevisionSnapshot: null,
@@ -1186,6 +1435,26 @@ export default {
lastSelectedArea () {
return this.selectedAreasRef.value[this.selectedAreasRef.value.length - 1]
},
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
isDocumentsView () {
return this.nativeView === 'documents'
},
isFieldsView () {
return this.nativeView === 'fields'
},
isSheetView () {
return this.nativeView === 'sheet'
},
withMobileDrawers () {
return !this.nativePlatform && !this.nativeView && this.sortedDocuments.length > 0
},
withDocumentsDrawer () {
return this.withMobileDrawers && this.withDocumentsList && (this.isBreakpointLg || this.isMobile)
},
withFieldsDrawer () {
return this.withMobileDrawers && this.withFieldsList && this.editable && this.isBreakpointMd
},
isMobile () {
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
@@ -1379,6 +1648,7 @@ export default {
})
document.addEventListener('keyup', this.onKeyUp)
document.addEventListener('template-builder:apply-revision', this.onApplyRevisionEvent)
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('resize', this.onWindowResize)
@@ -1402,6 +1672,7 @@ export default {
},
unmounted () {
document.removeEventListener('keyup', this.onKeyUp)
document.removeEventListener('template-builder:apply-revision', this.onApplyRevisionEvent)
window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('resize', this.onWindowResize)
@@ -1528,10 +1799,28 @@ export default {
},
calculateFormula (field) {
const transformedFormula = this.normalizeFormula(field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
if (this.fieldsUuidIndex[uuid]?.type === 'date') {
return this.dateFormulaValue(this.fieldsUuidIndex[uuid].default_value)
}
return this.fieldsUuidIndex[uuid]?.default_value || 0.0
})
return this.math.evaluate(transformedFormula.toLowerCase())
const value = this.math.evaluate(transformedFormula.toLowerCase())
if (field.type === 'date') {
return Number.isFinite(value) ? new Date(Math.floor(value) * 86400000).toISOString().slice(0, 10) : ''
}
return value
},
dateFormulaValue (value) {
if (!value) return 'null'
if (value === '{{date}}') return 'today()'
const [year, month, day = 1] = value.slice(0, 10).split('-').map(Number)
return Date.UTC(year, month - 1, day) / 86400000
},
evalTextFormula (field, depth = 0) {
if (depth > 10) return ''
@@ -1665,6 +1954,10 @@ export default {
this.debouncedSave()
},
download () {
const download = window.webkit?.messageHandlers?.download
if (this.nativePlatform && download) return download.postMessage({ src: `/templates/${this.template.id}/documents` })
this.isDownloading = true
this.baseFetch(`/templates/${this.template.id}/documents`).then(async (response) => {
@@ -1937,6 +2230,10 @@ export default {
this.loadRevisionsPromise ||= this.baseFetch(`/templates/${this.template.id}/versions`)
},
openRevisionsModal () {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
return window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'revisions', detent: 'medium' })
}
this.closeDropdown()
this.loadRevisionsPromise ||= this.baseFetch(`/templates/${this.template.id}/versions`)
@@ -1949,10 +2246,20 @@ export default {
this.loadRevisionsPromise = null
})
},
openDocumentsEditor (item) {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'documents', viewport: 'fixed', uuid: item.attachment_uuid })
} else {
this.editModalDocumentUuid = item.attachment_uuid
}
},
onApplyRevisionEvent (e) {
this.onRevisionApply(e.detail)
},
onRevisionApply (revision) {
this.beforeRevisionSnapshot = {
template: JSON.parse(JSON.stringify(this.template)),
dynamicDocuments: JSON.parse(JSON.stringify(this.dynamicDocuments)),
template: this.beforeRevisionSnapshot?.template || JSON.parse(JSON.stringify(this.template)),
dynamicDocuments: this.beforeRevisionSnapshot?.dynamicDocuments || JSON.parse(JSON.stringify(this.dynamicDocuments)),
revision
}
@@ -2011,6 +2318,10 @@ export default {
this.save()
},
addField (type, area = null) {
if (this.isVerificationFieldAdded(type)) {
return
}
const field = {
name: '',
uuid: v4(),
@@ -2054,12 +2365,26 @@ export default {
this.save()
},
isVerificationFieldAdded (type) {
const addedField = ['kba', 'verification'].includes(type) &&
this.template.fields.find((f) => ['kba', 'verification'].includes(f.type) && f.submitter_uuid === this.selectedSubmitter.uuid)
if (addedField) {
alert(this.t('field_has_already_been_added_for_party').replace('{field}', this.fieldNames[addedField.type]).replace('{party}', this.selectedSubmitter.name))
}
return !!addedField
},
startFieldDraw ({ name, type }) {
const existingField = this.template.fields?.find((f) => f.submitter_uuid === this.selectedSubmitter.uuid && name && name === f.name)
if (existingField) {
this.drawField = existingField
} else {
if (this.isVerificationFieldAdded(type)) {
return
}
const field = {
name: name || '',
uuid: v4(),
@@ -2120,11 +2445,20 @@ export default {
},
onWindowResize (e) {
const breakpointLg = 1024
const breakpointMd = 768
const width = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth
this.isBreakpointLg = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth < breakpointLg
this.isBreakpointLg = width < breakpointLg
this.isBreakpointMd = width < breakpointMd
},
onPagesWheel (event) {
if (!event.ctrlKey && !event.metaKey) return
const isFastScroll = Math.abs(event.deltaY) > 1
if ((!event.ctrlKey && !event.metaKey) || (isFastScroll && event.timeStamp - this.lastFastScrollAt < 150)) {
if (isFastScroll) this.lastFastScrollAt = event.timeStamp
return
}
event.preventDefault()
@@ -2155,7 +2489,73 @@ export default {
scrollIntoDocument (item) {
const ref = this.documentRefs.find((e) => e.document.uuid === item.attachment_uuid)
ref.$el.scrollIntoView({ behavior: 'smooth', block: 'start' })
ref?.$el.scrollIntoView({ behavior: 'smooth', block: 'start' })
},
postNativeEvent (name, detail, dismiss = false) {
if (this.nativePlatform) {
window.webkit?.messageHandlers?.modal?.postMessage({ action: 'dispatch', name, detail: JSON.stringify(detail), dismiss: dismiss ? 'true' : 'false' })
}
},
onDrawerScrollTo (item) {
if (this.nativeView) {
this.postNativeEvent('template-builder:scroll-to', { attachment_uuid: item.attachment_uuid }, true)
} else {
this.isDocumentsDrawerOpen = false
this.scrollIntoDocument(item)
}
},
onDrawerSetDraw ({ field, option }) {
if (this.nativeView) {
this.postNativeEvent('template-builder:draw-field', { uuid: field.uuid, option }, true)
} else {
this.isFieldsDrawerOpen = false
this.drawField = field
this.drawOption = option
}
},
onDrawerSetDrawType (type) {
if (this.nativeView) {
this.postNativeEvent('template-builder:draw-field', { type }, true)
} else {
this.isFieldsDrawerOpen = false
this.startFieldDraw({ type })
}
},
onDrawerSelectSubmitter (submitter) {
this.selectedSubmitter = submitter
if (this.nativeView) {
this.$nextTick(() => this.postNativeEvent('template-builder:select-submitter', { uuid: submitter.uuid }))
}
},
onDrawerSetDrawCustomField (field) {
if (this.nativeView) {
this.postNativeEvent('template-builder:draw-field', { custom_field_uuid: field.uuid }, true)
} else {
this.isFieldsDrawerOpen = false
this.drawCustomField = field
this.showDrawField = true
}
},
onDrawerScrollToArea (area) {
if (this.nativeView) {
const field = this.template.fields.find((f) => (f.areas || []).includes(area))
this.postNativeEvent('template-builder:select-field', { uuid: field?.uuid, attachment_uuid: area.attachment_uuid, page: area.page, x: area.x, y: area.y }, true)
} else {
this.isFieldsDrawerOpen = false
this.scrollToArea(area)
}
},
syncNative () {
const { schema, documents, fields, submitters } = this.template
const detail = JSON.stringify({ schema, documents, fields, submitters, pendingFieldAttachmentUuids: this.pendingFieldAttachmentUuids })
if (this.nativeView) {
window.webkit?.messageHandlers?.modal?.postMessage({ action: 'dispatch', name: 'template-builder:sync', detail, dismiss: 'false' })
} else {
window.webkit?.messageHandlers?.native?.postMessage({ type: 'drawer', op: 'sync', detail })
}
},
clearDrawField () {
this.drawField = null
@@ -2731,6 +3131,10 @@ export default {
return this.dropCustomField(area)
}
if (!this.fieldsDragFieldRef.value && this.isVerificationFieldAdded(this.dragField.type)) {
return
}
const field = this.fieldsDragFieldRef.value || {
name: '',
uuid: v4(),
@@ -3091,6 +3495,10 @@ export default {
})
}
})
if (this.nativeView && this.pendingFieldAttachmentUuids.length) {
this.syncNative()
}
},
updateName (value) {
this.template.name = value
@@ -3541,6 +3949,10 @@ export default {
this.pushUndo()
if (this.nativePlatform) {
this.syncNative()
}
if (!this.autosave && !force) {
return Promise.resolve({})
}
@@ -1,13 +1,17 @@
<template>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
:class="{ 'modal modal-open items-start !animate-none overflow-y-auto': !inline }"
>
<div
v-if="!inline"
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 px-6 mt-20 w-full max-w-xl'">
<div
v-if="!inline"
class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
>
<span class="modal-title">
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item) }}
</span>
@@ -183,6 +187,11 @@ export default {
type: Array,
required: false,
default: () => []
},
inline: {
type: Boolean,
required: false,
default: false
}
},
emits: ['close', 'save'],
@@ -10,7 +10,7 @@
:data-placeholder="placeholder"
:data-empty="isEmpty"
:style="{ minWidth }"
:class="[iconInline ? (isEmpty ? 'inline-block' : 'inline') : 'block', hideIcon ? 'focus:block' : '']"
:class="[iconInline ? (isEmpty ? 'inline-block' : 'inline') : 'block', hideIcon ? 'focus:block' : '', contenteditableClasses]"
class="peer relative inline-block outline-none before:pointer-events-none before:absolute before:left-0 before:top-0 before:select-none before:whitespace-pre before:text-neutral-400 before:content-[attr(data-placeholder)] before:opacity-0 data-[empty=true]:before:opacity-100"
@paste.prevent="onPaste"
@keydown.enter.prevent="blurContenteditable"
@@ -23,15 +23,15 @@
{{ value }}
</span>
<span
v-if="withButton"
v-if="withButton && (!isMobile || editableOnButton)"
class="relative inline"
:class="{ 'peer-focus:hidden': hideIcon, 'peer-focus:invisible': !hideIcon }"
>
<IconPencil
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle pl-1"
class="cursor-pointer flex-none align-middle pl-1"
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
:title="t('edit')"
:class="{ invisible: !editable, 'absolute top-1/2 -translate-y-1/2': !iconInline || floatIcon, 'inline align-bottom': iconInline, 'left-0': floatIcon }"
:class="{ invisible: !editable, 'opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100': !isMobile, 'absolute top-1/2 -translate-y-1/2': !iconInline || floatIcon, 'inline align-bottom': iconInline, 'left-0': floatIcon }"
:width="iconWidth + 4"
:stroke-width="iconStrokeWidth"
@click="clickEdit"
@@ -48,7 +48,7 @@ export default {
components: {
IconPencil
},
inject: ['t'],
inject: ['t', 'isMobile'],
props: {
modelValue: {
type: String,
@@ -109,6 +109,11 @@ export default {
type: Number,
required: false,
default: 2
},
contenteditableClasses: {
type: String,
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus', 'blur', 'click-contenteditable'],
@@ -1,105 +0,0 @@
<template>
<div class="flex space-x-2">
<Contenteditable
class="w-full block mr-6 document-preview-name"
:model-value="item.name"
:icon-width="16"
@update:model-value="onUpdateName"
/>
<ReplaceButton
v-if="withReplaceButton"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
:authenticity-token="authenticityToken"
:with-google-drive="withGoogleDrive"
:google-drive-file-id="item.google_drive_file_id"
@click.stop
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
/>
<button
v-if="withArrows"
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
style="width: 24px; height: 24px"
@click.stop="$emit('up', item)"
>
&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
v-if="field.type === 'payment' && !isNew"
:field="field"
:background-color="dropdownBgColor"
:with-condition="false"
:with-force-open="false"
@click-description="isShowDescriptionModal = true"
@click-formula="isShowFormulaModal = true"
@click-description="openDescriptionModal"
@click-formula="openFormulaModal"
/>
<span
v-else-if="!isNew"
@@ -61,9 +62,17 @@
<label
tabindex="0"
:title="t('settings')"
class="cursor-pointer text-transparent group-hover:text-base-content"
class="cursor-pointer flex items-center"
:class="[iconColorClass, { 'pr-1': isMobile }]"
>
<IconDotsVertical
v-if="isMobile"
:width="18"
:height="18"
:stroke-width="1.6"
/>
<IconSettings
v-else
:width="18"
:stroke-width="1.6"
/>
@@ -85,11 +94,27 @@
:with-areas="false"
:with-copy-to-all-pages="false"
:with-condition="false"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@click-formula="openFormulaModal"
@click-font="openFontModal"
@click-description="openDescriptionModal"
@save="$emit('save')"
/>
<li
v-if="isMobile"
class="field-settings-remove"
>
<a
href="#"
class="text-sm py-1 px-2 text-red-600"
@click.prevent="$emit('remove', field)"
>
<IconTrashX
:width="20"
:stroke-width="1.6"
/>
{{ t('remove') }}
</a>
</li>
</ul>
</span>
<button
@@ -104,8 +129,9 @@
/>
</button>
<button
v-if="isNew || !isMobile || field.type === 'payment'"
class="relative group-hover:text-base-content pr-1 field-remove-button"
:class="isNew ? 'text-base-content' : 'text-transparent group-hover:text-base-content'"
:class="isNew ? 'text-base-content' : iconColorClass"
:title="t('remove')"
@click="onRemoveClick"
>
@@ -164,7 +190,7 @@ import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import DescriptionModal from './description_modal'
import { IconTrashX, IconSettings, IconCheck } from '@tabler/icons-vue'
import { IconTrashX, IconSettings, IconCheck, IconDotsVertical } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
export default {
@@ -172,6 +198,7 @@ export default {
components: {
Contenteditable,
IconSettings,
IconDotsVertical,
IconCheck,
FieldSettings,
PaymentSettings,
@@ -182,7 +209,7 @@ export default {
IconTrashX,
FieldType
},
inject: ['backgroundColor', 't'],
inject: ['backgroundColor', 't', 'isMobile', 'nativePlatform'],
props: {
field: {
type: Object,
@@ -217,6 +244,9 @@ export default {
dropdownBgColor () {
return ['', null, 'transparent'].includes(this.backgroundColor) ? 'white' : this.backgroundColor
},
iconColorClass () {
return this.isMobile ? 'text-base-content' : 'text-transparent group-hover:text-base-content'
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
}
@@ -230,6 +260,27 @@ export default {
}
},
methods: {
openFormulaModal () {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'formula', detent: 'medium', custom_field_uuid: this.field.uuid })
} else {
this.isShowFormulaModal = true
}
},
openFontModal () {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'font', detent: 'medium', custom_field_uuid: this.field.uuid })
} else {
this.isShowFontModal = true
}
},
openDescriptionModal () {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'description', detent: 'medium', custom_field_uuid: this.field.uuid })
} else {
this.isShowDescriptionModal = true
}
},
buildDefaultName () {
return this.t('custom')
},
@@ -1,13 +1,17 @@
<template>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
:class="{ 'modal modal-open items-start !animate-none overflow-y-auto': !inline }"
>
<div
v-if="!inline"
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl'">
<div
v-if="!inline"
class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
>
<span class="modal-title">
{{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
</span>
@@ -86,6 +90,11 @@ export default {
buildDefaultName: {
type: Function,
required: true
},
inline: {
type: Boolean,
required: false,
default: false
}
},
emits: ['close', 'save'],
@@ -1,7 +1,7 @@
<template>
<div class="flex flex-1 min-h-0">
<div class="flex flex-col md:flex-row flex-1 min-h-0">
<div
class="flex-1 min-h-0 flex items-center justify-center px-6 py-4"
class="flex-1 min-h-0 flex items-center justify-center px-4 md:px-6 py-4"
style="container-type: size"
>
<div
@@ -46,9 +46,10 @@
/>
</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">
<div class="grid grid-cols-3 gap-2 md:block md:space-y-2">
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
class="btn btn-sm normal-case font-normal rounded order-last disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
:disabled="!!isProcessing"
@click.prevent="submit(true)"
>
@@ -63,7 +64,7 @@
{{ t('crop_and_scan') }}
</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 order-last disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
:disabled="!!isProcessing"
@click.prevent="submit(false)"
>
@@ -73,32 +74,30 @@
/>
<IconCrop
v-else
width="22"
height="22"
style="margin-left: -3px"
class="w-4 h-4 md:w-[22px] md:h-[22px] md:-ml-[3px]"
:stroke-width="1.5"
/>
<span :style="{ 'margin-left': isProcessing === 'crop' ? '0px' : '-3px' }">
<span :class="{ 'md:-ml-[3px]': isProcessing !== 'crop' }">
{{ t('crop') }}
</span>
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
class="btn btn-sm normal-case font-normal rounded order-last flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
@click.prevent="$emit('cancel')"
>
<IconX class="w-4 h-4" />
{{ t('cancel') }}
</button>
<div class="border-t !mt-3 !mb-1" />
<div class="hidden md:block border-t !mt-3 !mb-1" />
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
class="btn btn-sm normal-case font-normal rounded flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
@click.prevent="rotateCw"
>
<IconRotateClockwise class="w-4 h-4" />
{{ t('rotate') }}
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
class="btn btn-sm normal-case font-normal rounded flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
:class="{ 'btn-active': flipH }"
@click.prevent="toggleFlip('flipH')"
>
@@ -106,7 +105,7 @@
{{ t('flip_horizontal') }}
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
class="btn btn-sm normal-case font-normal rounded flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
:class="{ 'btn-active': flipV }"
@click.prevent="toggleFlip('flipV')"
>
@@ -115,6 +114,7 @@
</button>
</div>
</div>
</div>
</template>
<script>
@@ -1,19 +1,23 @@
<template>
<div
class="modal modal-open items-center !animate-none"
:class="inline ? 'flex flex-col h-full' : 'modal modal-open items-center !animate-none'"
@dragover.prevent="onModalDragover"
@dragleave="onModalDragleave"
@drop.prevent="onModalDrop"
>
<div
v-if="!inline"
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div
class="modal-box relative flex flex-col p-0 w-full h-full rounded-2xl"
style="max-width: 1240px; max-height: 92vh"
:class="inline ? 'relative flex flex-col flex-1 min-h-0' : 'modal-box relative flex flex-col p-0 w-full h-full rounded-none md:rounded-2xl overflow-x-hidden !max-h-full md:!max-h-[92vh]'"
:style="inline ? null : { maxWidth: '1240px' }"
>
<div
v-if="!inline"
class="flex justify-between items-center border-b py-2 px-6 font-medium"
>
<div class="flex justify-between items-center border-b py-2 px-6 font-medium">
<span
class="modal-title"
style="padding-top: 2px"
@@ -26,10 +30,13 @@
@click.prevent="$emit('close')"
>&times;</a>
</div>
<div class="flex flex-1 min-h-0 relative">
<div
class="flex flex-col md:flex-row flex-1 min-h-0 relative"
>
<CropView
v-if="cropPageItem"
class="absolute inset-0 z-10 bg-base-100"
:style="inline ? { paddingTop: '70px' } : null"
:template-id="template.id"
:page="cropPageItem"
:image-url="thumbUrl(cropPageItem)"
@@ -40,6 +47,7 @@
<RedactView
v-else-if="redactPageItem"
class="absolute inset-0 z-10 bg-base-100"
:style="inline ? { paddingTop: '70px' } : null"
:template-id="template.id"
:page="redactPageItem"
:image-url="thumbUrl(redactPageItem)"
@@ -53,12 +61,16 @@
class="contents"
:class="{ invisible: cropPageItem || redactPageItem }"
>
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-4">
<div
class="flex-1 overflow-y-auto overflow-x-hidden px-4 md:px-6 py-4 space-y-4"
:style="inline ? { paddingTop: '86px' } : null"
>
<div
v-for="(doc, docIndex) in layout"
:key="doc.attachmentUuid"
:data-uuid="doc.attachmentUuid"
class="relative"
:style="inline ? { scrollMarginTop: '86px' } : null"
>
<div
class="flex items-center pb-2"
@@ -76,7 +88,7 @@
:class="{ 'rotate-90': !collapsedDocumentsIndex[doc.attachmentUuid] }"
/>
<span
class="truncate"
class="truncate min-w-0"
:class="{ 'line-through': !doc.pages.length }"
>{{ doc.name }}</span>
</button>
@@ -170,7 +182,7 @@
</div>
<div
v-if="(!collapsedDocumentsIndex[doc.attachmentUuid] || layout.length === 1) && doc.pages.length"
class="grid grid-cols-4 gap-3"
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"
@dragover.prevent="onGridDragover(docIndex, $event)"
@drop.prevent="onPageDrop"
>
@@ -178,7 +190,8 @@
v-for="(page, pageIndex) in doc.pages"
:key="page.id"
:data-page-id="page.id"
class="relative cursor-pointer group"
class="relative min-w-0 cursor-pointer group"
:style="inline ? { scrollMarginTop: '86px' } : null"
draggable="true"
@click="selectedPageId = page.id"
@mousedown="onPageMousedown(page, $event)"
@@ -245,26 +258,31 @@
</template>
</div>
</div>
<div class="w-56 flex-none border-l px-4 py-4 flex flex-col">
<div class="space-y-2">
<div
class="flex-none px-4 border-t pt-3 flex flex-col gap-3 md:w-56 md:border-t-0 md:border-l md:py-4 md:gap-0"
:class="{ 'pb-3': !inline }"
>
<div class="grid grid-cols-4 gap-2 md:block md:space-y-2">
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
:disabled="!selectedPageId"
@click.prevent="movePage(-1)"
>
<IconArrowBackUp class="w-4 h-4" />
{{ t('move_backward') }}
<span class="md:hidden">{{ t('backward') }}</span>
<span class="hidden md:inline">{{ t('move_backward') }}</span>
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
:disabled="!selectedPageId"
@click.prevent="movePage(1)"
>
<IconArrowForwardUp class="w-4 h-4" />
{{ t('move_forward') }}
<span class="md:hidden">{{ t('forward') }}</span>
<span class="hidden md:inline">{{ t('move_forward') }}</span>
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
:disabled="!selectedPageId"
@click.prevent="rotatePage"
>
@@ -272,7 +290,7 @@
{{ t('rotate') }}
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
:disabled="!selectedPageId || isReplacing"
@click.prevent="replaceSelected"
>
@@ -303,7 +321,7 @@
@change="onReplaceDocFilesPicked"
>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
:disabled="!selectedPageId"
@click.prevent="openRedactView"
>
@@ -312,29 +330,28 @@
</button>
<button
v-if="isSelectedPageCroppable"
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
@click.prevent="openCropView"
>
<IconCrop
width="22"
height="22"
style="margin-left: -3px"
class="w-4 h-4 md:w-[22px] md:h-[22px] md:-ml-[3px]"
:stroke-width="1.5"
/>
<span style="margin-left: -3px">
<span class="md:-ml-[3px]">
{{ t('crop') }}
</span>
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300 text-red-600"
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 text-red-600 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
:disabled="!selectedPageId"
@click.prevent="removePage"
>
<IconTrashX class="w-4 h-4" />
{{ t('remove_page') }}
<span class="md:hidden">{{ t('remove') }}</span>
<span class="hidden md:inline">{{ t('remove_page') }}</span>
</button>
</div>
<div class="mt-auto space-y-2">
<div class="grid grid-cols-2 gap-2 md:block md:mt-auto md:space-y-2">
<Upload
:template-id="template.id"
:accept-file-types="acceptFileTypes"
@@ -432,6 +449,11 @@ export default {
type: String,
required: false,
default: 'image/*, application/pdf'
},
inline: {
type: Boolean,
required: false,
default: false
}
},
emits: ['close', 'saved'],
@@ -689,12 +711,14 @@ export default {
if (targetIndex < 0) {
if (docIndex > 0) {
this.layout[docIndex - 1].pages.push(page)
this.collapsedDocumentsIndex[this.layout[docIndex - 1].attachmentUuid] = false
} else {
this.layout[docIndex].pages.unshift(page)
}
} else if (targetIndex > this.layout[docIndex].pages.length) {
if (docIndex < this.layout.length - 1) {
this.layout[docIndex + 1].pages.unshift(page)
this.collapsedDocumentsIndex[this.layout[docIndex + 1].attachmentUuid] = false
} else {
this.layout[docIndex].pages.push(page)
}
@@ -703,6 +727,12 @@ export default {
}
this.pushUndo()
this.scrollIntoList(`[data-page-id="${page.id}"]`)
},
scrollIntoList (selector) {
this.$nextTick(() => {
this.$el.querySelector(selector)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
})
},
removePage () {
const position = this.selectedPosition
@@ -728,6 +758,7 @@ export default {
this.layout.splice(docIndex + direction, 0, doc)
this.pushUndo()
this.scrollIntoList(`[data-uuid="${doc.attachmentUuid}"]`)
},
mergeDocument (docIndex, direction) {
const doc = this.layout[docIndex]
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-1 min-h-0">
<div class="flex-1 overflow-y-auto px-6 py-4">
<div class="flex flex-col md:flex-row flex-1 min-h-0">
<div class="flex-1 overflow-y-auto overflow-x-hidden px-4 md:px-6 py-4">
<div
ref="pageEl"
class="relative mx-auto select-none cursor-crosshair"
@@ -48,8 +48,8 @@
</div>
</div>
</div>
<div class="w-56 flex-none border-l px-4 py-4 space-y-2">
<div class="flex items-center justify-between mb-1">
<div class="flex-none px-4 border-t py-3 md:w-56 md:border-t-0 md:border-l md:py-4 md:space-y-2">
<div class="flex items-center justify-between mb-3 md:mb-1">
<span class="text-sm pl-1">{{ t('color') }}</span>
<div
class="join rounded"
@@ -69,17 +69,18 @@
</button>
</div>
</div>
<div class="grid grid-cols-3 gap-2 md:block md:space-y-2">
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
:disabled="!hasRedactions && !wasReset"
@click.prevent="apply"
>
<IconCheck class="w-4 h-4" />
{{ t('apply') }}
</button>
<div class="border-t !mt-3 !mb-1" />
<div class="hidden md:block border-t !mt-3 !mb-1" />
<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="!hasRedactions"
@click.prevent="reset"
>
@@ -87,7 +88,7 @@
{{ t('reset') }}
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
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" />
@@ -95,6 +96,7 @@
</button>
</div>
</div>
</div>
</template>
<script>
@@ -5,7 +5,7 @@
:style="dragPlaceholderStyle"
:field="field"
:with-options="false"
class="fixed z-20 pointer-events-none"
class="fixed z-50 pointer-events-none"
:editable="false"
/>
<CustomField
@@ -13,13 +13,13 @@
ref="dragPlaceholder"
:style="dragPlaceholderStyle"
:field="field"
class="fixed z-20 pointer-events-none opacity-90"
class="fixed z-50 pointer-events-none opacity-90"
/>
<div
v-else-if="dragPlaceholder && (isDefault || isRequired) && !isMask && field"
ref="dragPlaceholder"
:style="[dragPlaceholderStyle, { backgroundColor: backgroundColor }]"
class="fixed z-20 border border-base-300 rounded group default-field fields-list-item pointer-events-none"
class="fixed z-50 border border-base-300 rounded group default-field fields-list-item pointer-events-none"
>
<div class="flex items-center justify-between relative cursor-grab">
<div class="flex items-center p-1 space-x-1">
@@ -45,7 +45,7 @@
<button
v-else-if="dragPlaceholder && !isMask && field"
ref="dragPlaceholder"
class="fixed field-type-button z-20 flex items-center justify-center border border-dashed w-full rounded border-base-content/20 opacity-90 pointer-events-none"
class="fixed field-type-button z-50 flex items-center justify-center border border-dashed w-full rounded border-base-content/20 opacity-90 pointer-events-none"
:style="[dragPlaceholderStyle, { backgroundColor }]"
>
<div
+6 -1
View File
@@ -38,7 +38,12 @@
v-if="withDescription"
class="text-sm"
>
<template v-if="isMobile">
{{ t('click_to_upload') }}
</template>
<template v-else>
<span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }}
</template>
</div>
<button
v-if="withGoogleDrive"
@@ -83,7 +88,7 @@ export default {
IconFiles,
IconBrandGoogleDrive
},
inject: ['baseFetch', 't'],
inject: ['baseFetch', 't', 'isMobile'],
props: {
templateId: {
type: [Number, String],
@@ -491,6 +491,23 @@ const SpanMark = Mark.create({
}
})
const LayoutSpanMark = SpanMark.extend({
name: 'layoutSpan',
parseHTML () {
return [{
tag: 'span',
priority: 55,
getAttrs (dom) {
if (['left', 'right'].includes(dom.style.float) || ['inline-block', 'block'].includes(dom.style.display)) {
return collectSpanDomAttrs(dom)
}
return false
}
}]
}
})
const LinkMark = Mark.create({
name: 'link',
excludes: '',
@@ -790,6 +807,7 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlFor
ColNode,
DynamicImageNode,
EmptySpanNode,
LayoutSpanMark,
LinkMark,
SpanMark,
CustomBold,
@@ -282,7 +282,7 @@ export default {
},
methods: {
reloadContent () {
this.editor.commands.setContent(this.section.innerHTML, { emitUpdate: false })
this.editor.commands.setContent(this.section.innerHTML, { emitUpdate: false, parseOptions: this.editor.options.parseOptions })
},
findAreaNodePos (areaUuid) {
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
+168 -19
View File
@@ -20,7 +20,7 @@
:menu-classes="'mt-1.5'"
:menu-style="{ backgroundColor: dropdownBgColor }"
@update:model-value="[maybeUpdateOptions(), $emit('save')]"
@click="scrollToFirstArea"
@click="scrollOnEdit && scrollToFirstArea()"
/>
<Contenteditable
ref="name"
@@ -29,7 +29,8 @@
:icon-inline="true"
:icon-width="18"
:icon-stroke-width="1.6"
@focus="[onNameFocus(), scrollToFirstArea()]"
:contenteditable-classes="isMobile ? 'py-2 -my-2' : ''"
@focus="[onNameFocus(), scrollOnEdit && scrollToFirstArea()]"
@blur="onNameBlur"
/>
</div>
@@ -60,7 +61,8 @@
<button
v-if="field && !field.areas?.length"
:title="t('draw')"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
class="relative cursor-pointer"
:class="iconColorClass"
@click="$emit('set-draw', { field })"
>
<IconNewSection
@@ -70,9 +72,10 @@
</button>
<button
v-if="field.preferences?.formula"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
class="relative cursor-pointer"
:class="iconColorClass"
:title="t('formula')"
@click="isShowFormulaModal = true"
@click="openFormulaModal"
>
<IconMathFunction
:width="18"
@@ -81,9 +84,10 @@
</button>
<button
v-if="field.conditions?.length"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
class="relative cursor-pointer"
:class="iconColorClass"
:title="t('condition')"
@click="isShowConditionsModal = true"
@click="openConditionsModal"
>
<IconRouteAltLeft
:width="18"
@@ -93,13 +97,56 @@
<PaymentSettings
v-if="field.type === 'payment'"
:field="field"
:background-color="dropdownBgColor"
:with-custom-fields="withCustomFields"
@click-condition="isShowConditionsModal = true"
@click-description="isShowDescriptionModal = true"
@click-condition="openConditionsModal"
@click-description="openDescriptionModal"
@add-custom-field="$emit('add-custom-field', $event)"
@click-formula="isShowFormulaModal = true"
@click-formula="openFormulaModal"
@save="$emit('save')"
>
<template
v-if="isMobile"
#default
>
<label
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center pr-0.5 py-2 -my-2 pl-2 -ml-2"
:class="iconColorClass"
@touchstart="onHandleTouchstart"
@touchmove="onHandleTouchmove"
@touchend="onHandleTouchend"
@touchcancel="onHandleTouchend"
@mousedown="dropdownWasOpen = $event.currentTarget.parentElement.contains($event.currentTarget.getRootNode().activeElement)"
@click="dropdownWasOpen && closeDropdown()"
>
<IconDotsVertical
:width="18"
:height="18"
:stroke-width="1.6"
/>
</label>
</template>
<template #menu>
<li
v-if="isMobile"
class="field-settings-remove"
>
<a
href="#"
class="text-sm py-1 px-2 text-red-600"
@click.prevent="$emit('remove', field)"
>
<IconTrashX
:width="20"
:stroke-width="1.6"
/>
{{ t('remove') }}
</a>
</li>
</template>
</PaymentSettings>
<span
v-else
class="dropdown dropdown-end field-settings-dropdown"
@@ -109,9 +156,23 @@
<label
tabindex="0"
:title="t('settings')"
class="cursor-pointer text-transparent group-hover:text-base-content"
class="cursor-pointer flex items-center"
:class="[iconColorClass, { 'pr-0.5 py-2 -my-2 pl-2 -ml-2': isMobile }]"
@touchstart="onHandleTouchstart"
@touchmove="onHandleTouchmove"
@touchend="onHandleTouchend"
@touchcancel="onHandleTouchend"
@mousedown="dropdownWasOpen = $event.currentTarget.parentElement.contains($event.currentTarget.getRootNode().activeElement)"
@click="dropdownWasOpen && closeDropdown()"
>
<IconDotsVertical
v-if="isMobile"
:width="18"
:height="18"
:stroke-width="1.6"
/>
<IconSettings
v-else
:width="18"
:stroke-width="1.6"
/>
@@ -133,20 +194,38 @@
:with-prefillable="withPrefillable"
:background-color="dropdownBgColor"
:with-custom-fields="withCustomFields"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@click-condition="isShowConditionsModal = true"
@click-formula="openFormulaModal"
@click-font="openFontModal"
@click-description="openDescriptionModal"
@click-condition="openConditionsModal"
@set-draw="$emit('set-draw', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@remove-area="removeArea"
@save="$emit('save')"
@scroll-to="$emit('scroll-to', $event)"
/>
<li
v-if="isMobile"
class="field-settings-remove"
>
<a
href="#"
class="text-sm py-1 px-2 text-red-600"
@click.prevent="$emit('remove', field)"
>
<IconTrashX
:width="20"
:stroke-width="1.6"
/>
{{ t('remove') }}
</a>
</li>
</ul>
</span>
<button
class="relative text-transparent group-hover:text-base-content pr-1 field-remove-button"
v-if="!isMobile"
class="relative pr-1 field-remove-button"
:class="iconColorClass"
:title="t('remove')"
@click="$emit('remove', field)"
>
@@ -324,7 +403,7 @@ import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconRouteAltLeft, IconMathFunction, IconNewSection, IconTrashX, IconSettings, IconChevronDown } from '@tabler/icons-vue'
import { IconRouteAltLeft, IconMathFunction, IconNewSection, IconTrashX, IconSettings, IconDotsVertical, IconChevronDown } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
@@ -332,6 +411,7 @@ export default {
components: {
Contenteditable,
IconSettings,
IconDotsVertical,
FieldSettings,
PaymentSettings,
IconChevronDown,
@@ -345,12 +425,17 @@ export default {
IconMathFunction,
FieldType
},
inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex', 'dateFormats'],
inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex', 'dateFormats', 'isMobile', 'nativePlatform'],
props: {
field: {
type: Object,
required: true
},
scrollOnEdit: {
type: Boolean,
required: false,
default: true
},
withSignatureId: {
type: Boolean,
required: false,
@@ -382,9 +467,11 @@ export default {
default: true
}
},
emits: ['set-draw', 'remove', 'scroll-to', 'save', 'add-custom-field'],
emits: ['set-draw', 'remove', 'scroll-to', 'save', 'add-custom-field', 'touch-drag', 'touch-drag-end'],
data () {
return {
touchDrag: null,
dropdownWasOpen: false,
isExpandOptions: false,
isNameFocus: false,
isShowFormulaModal: false,
@@ -396,6 +483,9 @@ export default {
}
},
computed: {
iconColorClass () {
return this.isMobile ? 'text-base-content' : 'text-transparent group-hover:text-base-content'
},
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
dropdownBgColor () {
@@ -433,11 +523,70 @@ export default {
}
},
methods: {
onHandleTouchstart (e) {
if (!this.isMobile) return
this.touchDrag = { startY: e.touches[0].clientY, moved: false }
},
onHandleTouchmove (e) {
if (!this.touchDrag) return
const { clientX, clientY } = e.touches[0]
if (!this.touchDrag.moved && Math.abs(clientY - this.touchDrag.startY) < 6) return
this.touchDrag.moved = true
e.preventDefault()
this.$emit('touch-drag', { field: this.field, x: clientX, y: clientY })
},
onHandleTouchend (e) {
if (!this.touchDrag) return
if (this.touchDrag.moved) {
e.preventDefault()
e.currentTarget.blur()
this.$emit('touch-drag-end')
}
this.touchDrag = null
},
removeArea (area) {
this.field.areas.splice(this.field.areas.indexOf(area), 1)
this.$emit('save')
},
openFormulaModal () {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'formula', detent: 'medium', uuid: this.field.uuid })
} else {
this.isShowFormulaModal = true
}
},
openFontModal () {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'font', detent: 'medium', uuid: this.field.uuid })
} else {
this.isShowFontModal = true
}
},
openConditionsModal () {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'conditions', detent: 'medium', uuid: this.field.uuid })
} else {
this.isShowConditionsModal = true
}
},
openDescriptionModal () {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'description', detent: 'medium', uuid: this.field.uuid })
} else {
this.isShowDescriptionModal = true
}
},
buildDefaultName (field) {
if (field.type === 'payment' && field.preferences?.price && !field.preferences?.formula) {
const { price, currency } = field.preferences || {}
@@ -515,7 +515,7 @@ export default {
ContextSubmenu,
ContextModal
},
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats', 'locale'],
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats', 'locale', 'withFormula', 'withConditions', 'nativePlatform'],
props: {
contextMenu: {
type: Object,
@@ -608,10 +608,10 @@ export default {
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)
},
showCondition () {
return this.withCondition && !['stamp', 'heading'].includes(this.field.type)
return this.withCondition && (this.withConditions || this.nativePlatform !== 'ios') && !['stamp', 'heading'].includes(this.field.type)
},
showFormula () {
return this.field.type === 'number' || this.field.type === 'payment' || !!this.field.preferences?.formula
return (this.field.type === 'number' || this.field.type === 'date' || this.field.type === 'payment' || !!this.field.preferences?.formula) && (this.withFormula || this.nativePlatform !== 'ios')
},
showRequired () {
return this.withRequired && !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type)
@@ -484,7 +484,7 @@
</label>
</li>
<li
v-if="withCondition && field.type != 'stamp' && field.type != 'heading'"
v-if="withCondition && (withConditions || nativePlatform !== 'ios') && field.type != 'stamp' && field.type != 'heading'"
class="field-settings-condition"
>
<label
@@ -500,7 +500,7 @@
</label>
</li>
<li
v-if="field.type == 'number' || field.preferences?.formula"
v-if="(field.type == 'number' || field.type == 'date' || field.preferences?.formula) && (withFormula || nativePlatform !== 'ios')"
class="field-settings-formula"
>
<label
@@ -610,7 +610,7 @@ export default {
IconTypography,
IconX
},
inject: ['template', 't', 'dateFormats', 'locale'],
inject: ['template', 't', 'dateFormats', 'locale', 'withFormula', 'withConditions', 'nativePlatform'],
props: {
field: {
type: Object,
@@ -125,7 +125,10 @@
@update:model-value="$emit('name-change', selectedSubmitter)"
/>
</div>
<span class="flex items-center transition-all duration-75 group-hover:border border-base-content/20 border-dashed w-6 h-6 justify-center rounded flex-shrink-0">
<span
class="flex items-center transition-all duration-75 group-hover:border border-base-content/20 border-dashed w-6 h-6 justify-center rounded flex-shrink-0"
:class="{ border: isMobile }"
>
<component
:is="editable ? 'IconPlus' : 'IconChevronDown'"
width="18"
@@ -164,7 +167,10 @@
v-if="!compact && submitters.length > 1 && editable"
class="flex"
>
<div class="flex-col pr-1 flex invisible group-hover:visible -mt-1 h-0">
<div
class="flex-col pr-1 flex -mt-1 h-0"
:class="{ 'invisible group-hover:visible': !isMobile }"
>
<button
:title="t('up')"
class="relative w-2"
@@ -184,7 +190,8 @@
</div>
<button
v-if="!compact && submitters.length > 1 && editable"
class="invisible group-hover:visible px-2"
class="px-2"
:class="{ 'invisible group-hover:visible': !isMobile }"
@click.prevent.stop="remove(submitter)"
>
<IconTrashX :width="18" />
@@ -237,7 +244,7 @@ export default {
IconTrashX,
IconChevronUp
},
inject: ['t', 'save'],
inject: ['t', 'save', 'isMobile'],
props: {
submitters: {
type: Array,
+121 -20
View File
@@ -1,6 +1,6 @@
<template>
<div
:class="withStickySubmitters ? 'sticky top-0 z-[1]' : ''"
:class="{ 'sticky top-0 z-[1]': withStickySubmitters, 'bg-base-100': withStickySubmitters && ['', null, 'transparent'].includes(backgroundColor), 'pt-3': withTopPadding }"
:style="withStickySubmitters ? { backgroundColor } : {}"
class="flex items-center gap-1"
>
@@ -48,6 +48,7 @@
:key="field.uuid"
:data-uuid="field.uuid"
:field="field"
:scroll-on-edit="scrollOnEdit"
:type-index="getFieldTypeIndex(field)"
:editable="editable"
:with-signature-id="withSignatureId"
@@ -63,6 +64,8 @@
@remove="removeField"
@scroll-to="$emit('scroll-to-area', $event)"
@set-draw="$emit('set-draw', $event)"
@touch-drag="onTouchDrag"
@touch-drag-end="onTouchDragEnd"
/>
</div>
<div
@@ -169,6 +172,7 @@
@click="setFieldsTab('custom')"
>{{ t('custom') }}</a>
</div>
<div ref="addFieldsMarker" />
<div
v-if="!isShowVariables && showCustomTab && editable && (customFields.length || newCustomField)"
ref="customFields"
@@ -271,7 +275,7 @@
</div>
</button>
<div
v-else-if="type == 'phone' && (fieldTypes.length === 0 || fieldTypes.includes(type))"
v-else-if="type == 'phone' && nativePlatform !== 'ios' && (fieldTypes.length === 0 || fieldTypes.includes(type))"
class="tooltip tooltip-bottom flex"
:class="{'tooltip-bottom-end': withPayment, 'tooltip-bottom': !withPayment }"
:data-tip="t('unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification')"
@@ -326,10 +330,17 @@
</template>
</div>
<div
v-if="!isShowVariables && fields.length < 4 && editable && withHelp && !showTourStartForm"
v-if="!isShowVariables && (isMobile ? !fields.length : fields.length < 4) && editable && withHelp && !showTourStartForm"
class="text-xs p-2 border border-base-200 rounded"
:class="{ 'text-center': isMobile }"
>
<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"
>
<ul class="list-disc list-outside ml-3">
<li>
{{ t('draw_a_text_field_on_the_page_with_a_mouse') }}
</li>
@@ -355,24 +366,16 @@
width="22"
class="animate-spin"
/>
<span
v-if="analyzingProgress"
class="hidden md:inline"
>
<span v-if="analyzingProgress">
{{ Math.round(analyzingProgress * 100) }}% {{ t('analyzing_') }}
</span>
<span
v-else
class="hidden md:inline"
>
<span v-else>
{{ fieldPagesLoaded }} / {{ numberOfPages }} {{ t('processing_') }}
</span>
</template>
<template v-else>
<IconSparkles width="22" />
<span
class="hidden md:inline"
>
<span>
{{ t('autodetect_fields') }}
</span>
</template>
@@ -395,6 +398,26 @@
</label>
</div>
</div>
<div
v-if="isMobile && withAddFieldsPill"
class="sticky bottom-4 h-0 z-[5]"
>
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="translate-y-6 opacity-0"
leave-active-class="transition duration-100 ease-in"
leave-to-class="translate-y-6 opacity-0"
>
<button
v-if="isShowAddFieldsPill"
class="absolute bottom-0 left-0 right-0 mx-auto w-fit btn btn-neutral btn-sm rounded-full normal-case font-normal text-white gap-1 px-4 shadow"
@click.prevent="scrollToFieldTypes"
>
<IconPlus class="w-4 h-4" />
{{ t('add_fields') }}
</button>
</Transition>
</div>
</template>
<script>
@@ -403,13 +426,14 @@ import CustomField from './custom_field'
import FieldType from './field_type'
import FieldSubmitter from './field_submitter'
import { defineAsyncComponent } from 'vue'
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles, IconBracketsContain } from '@tabler/icons-vue'
import { IconLock, IconCirclePlus, IconPlus, IconInnerShadowTop, IconSparkles, IconBracketsContain } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
import { v4 } from 'uuid'
export default {
name: 'TemplateFields',
components: {
IconPlus,
Field,
CustomField,
FieldType,
@@ -422,7 +446,7 @@ export default {
IconBracketsContain,
DynamicVariables: defineAsyncComponent(() => import(/* webpackChunkName: "dynamic-editor" */ './dynamic_variables'))
},
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'baseFetch', 'selectedAreasRef', 'getFieldTypeIndex'],
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 'nativePlatform', 't', 'isMobile', 'fieldsDragFieldRef', 'customDragFieldRef', 'baseFetch', 'selectedAreasRef', 'getFieldTypeIndex'],
props: {
fields: {
type: Array,
@@ -482,6 +506,16 @@ export default {
required: false,
default: true
},
scrollOnEdit: {
type: Boolean,
required: false,
default: true
},
withAddFieldsPill: {
type: Boolean,
required: false,
default: true
},
defaultFields: {
type: Array,
required: false,
@@ -512,6 +546,11 @@ export default {
required: false,
default: true
},
withTopPadding: {
type: Boolean,
required: false,
default: false
},
fieldTypes: {
type: Array,
required: false,
@@ -539,6 +578,7 @@ export default {
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter', 'rebuild-variables-schema', 'remove-field', 'remove-submitter'],
data () {
return {
isShowAddFieldsPill: false,
fieldPagesLoaded: null,
analyzingProgress: 0,
newCustomField: null,
@@ -622,8 +662,22 @@ export default {
} catch (e) {
console.error(e)
}
if (this.isMobile) {
this.fieldTypesObserver = new IntersectionObserver(([entry]) => {
this.isShowAddFieldsPill = !entry.isIntersecting && entry.boundingClientRect.top > 0
}, { rootMargin: '0px 100%' })
this.fieldTypesObserver.observe(this.$refs.addFieldsMarker)
}
},
unmounted () {
this.fieldTypesObserver?.disconnect()
},
methods: {
scrollToFieldTypes () {
this.$refs.addFieldsMarker.scrollIntoView({ block: 'start', behavior: 'smooth' })
},
toggleVariables () {
this.$emit('rebuild-variables-schema')
this.isShowVariables = !this.isShowVariables
@@ -692,6 +746,10 @@ export default {
const fields = await resp.json()
this.customFields.splice(0, this.customFields.length, ...fields)
if (this.nativePlatform) {
window.webkit?.messageHandlers?.modal?.postMessage({ action: 'dispatch', name: 'template-builder:sync-custom-fields', detail: JSON.stringify(fields), dismiss: 'false' })
}
})
},
buildExistingFields () {
@@ -823,6 +881,10 @@ export default {
this.save()
if (this.nativePlatform) {
window.webkit?.messageHandlers?.flash?.postMessage({ style: 'notice', message: this.t('fields_detected').replace('{count}', (data.fields || fields).length) })
}
break
} else if (data.fields) {
data.fields.forEach((f) => {
@@ -878,8 +940,48 @@ export default {
},
onFieldDragover (e) {
if (this.fieldsDragFieldRef.value) {
const targetField = e.target.closest('[data-uuid]')
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.fieldsDragFieldRef.value.uuid}"]`)
this.moveFieldElement(this.fieldsDragFieldRef.value, e.target.closest('[data-uuid]'))
}
},
onTouchDrag ({ field, x, y }) {
const dragField = this.$refs.fields.querySelector(`[data-uuid="${field.uuid}"]`)
if (!dragField) return
if (!this.touchPreview) {
const rect = dragField.getBoundingClientRect()
const el = dragField.cloneNode(true)
const root = this.$el.getRootNode()
Object.assign(el.style, { position: 'fixed', left: `${rect.left}px`, top: `${rect.top}px`, width: `${rect.width}px`, margin: 0, zIndex: 50, pointerEvents: 'none', opacity: 0.9 })
;(root === document ? document.body : root).appendChild(el)
dragField.style.opacity = 0.4
this.touchPreview = { el, dragField, offsetY: y - rect.top }
}
this.touchPreview.el.style.top = `${y - this.touchPreview.offsetY}px`
const root = this.$el.getRootNode()
const targetField = (root.elementFromPoint ? root : document).elementFromPoint(x, y)?.closest('[data-uuid]')
if (targetField && this.$refs.fields.contains(targetField)) {
this.moveFieldElement(field, targetField)
}
},
onTouchDragEnd () {
if (this.touchPreview) {
this.touchPreview.el.remove()
this.touchPreview.dragField.style.opacity = ''
this.touchPreview = null
}
this.reorderFields()
},
moveFieldElement (field, targetField) {
const dragField = this.$refs.fields.querySelector(`[data-uuid="${field.uuid}"]`)
if (dragField && targetField && targetField !== dragField) {
const fields = Array.from(this.$refs.fields.children)
@@ -892,7 +994,6 @@ export default {
targetField.before(dragField)
}
}
}
},
reorderFields () {
Array.from(this.$refs.fields.children).forEach((el, index) => {
+14 -5
View File
@@ -1,13 +1,17 @@
<template>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
:class="{ 'modal modal-open items-start !animate-none overflow-y-auto': !inline }"
>
<div
v-if="!inline"
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl'">
<div
v-if="!inline"
class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
>
<span class="modal-title">
{{ t('font') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
</span>
@@ -19,7 +23,7 @@
</div>
<div class="mt-4">
<div>
<div class="flex items-center space-x-1.5">
<div class="flex flex-wrap items-center gap-1.5">
<span>
<div class="dropdown modal-field-font-dropdown">
<label
@@ -168,7 +172,7 @@
:class="textClasses"
>
<span
contenteditable="true"
:contenteditable="!inline"
class="outline-none whitespace-nowrap truncate"
>
{{ field.default_value || field.name || buildDefaultName(field) }}
@@ -215,6 +219,11 @@ export default {
buildDefaultName: {
type: Function,
required: true
},
inline: {
type: Boolean,
required: false,
default: false
}
},
emits: ['close', 'save'],
@@ -1,13 +1,17 @@
<template>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
:class="{ 'modal modal-open items-start !animate-none overflow-y-auto': !inline }"
>
<div
v-if="!inline"
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl'">
<div
v-if="!inline"
class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
>
<span class="modal-title">
{{ t('formula') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
</span>
@@ -67,8 +71,22 @@
>
<div
target="blank"
class="text-sm mb-2 inline space-x-2 font-mono"
class="text-sm mb-2 flex flex-wrap gap-x-2 gap-y-1 font-mono"
>
<template v-if="withDateFunctions">
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor('today()')"
>
today()
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor('datedif(, , &quot;y&quot;)', 8)"
>
datedif(start, end, "y")
</button>
</template>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' + ')"
@@ -101,13 +119,13 @@
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor('round()')"
@click="insertTextUnderCursor('round()', 6)"
>
round(n, d)
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor('abs()')"
@click="insertTextUnderCursor('abs()', 4)"
>
abs(n)
</button>
@@ -153,6 +171,11 @@ export default {
buildDefaultName: {
type: Function,
required: true
},
inline: {
type: Boolean,
required: false,
default: false
}
},
emits: ['close', 'save'],
@@ -162,6 +185,11 @@ export default {
}
},
computed: {
withDateFunctions () {
return this.field.type === 'date' || [...this.formula.matchAll(/{{(.*?)}}/g)].some(([, name]) => {
return this.template.fields.some((f) => f.type === 'date' && (f.name || this.buildDefaultName(f)).trim() === name.trim())
})
},
fields () {
return this.template.fields.reduce((acc, f) => {
const isAllowed = this.field.type === 'text' ? this.isTextField(f) : this.isNumberField(f)
@@ -182,7 +210,7 @@ export default {
},
methods: {
isNumberField (field) {
return field.type === 'number' || (['radio', 'select'].includes(field.type) && field.options?.every((o) => String(o.value).match(/^[\d.-]+$/)))
return ['number', 'date'].includes(field.type) || (['radio', 'select'].includes(field.type) && field.options?.every((o) => String(o.value).match(/^[\d.-]+$/)))
},
isTextField (field) {
return ['text', 'number', 'select', 'radio', 'cells', 'phone'].includes(field.type)
@@ -235,18 +263,19 @@ export default {
this.$emit('close')
}
},
insertTextUnderCursor (textToInsert) {
insertTextUnderCursor (textToInsert, cursorOffset = textToInsert.length) {
const textarea = this.$refs.textarea
const selectionEnd = textarea.selectionEnd
const cursorPos = selectionEnd
const { selectionStart, selectionEnd } = textarea
const selectedText = cursorOffset < textToInsert.length ? textarea.value.substring(selectionStart, selectionEnd) : ''
const cursorPos = selectedText ? selectionStart : selectionEnd
const newText = textarea.value.substring(0, cursorPos) + textToInsert + textarea.value.substring(cursorPos)
const newText = textarea.value.substring(0, cursorPos) + textToInsert.slice(0, cursorOffset) + selectedText + textToInsert.slice(cursorOffset) + textarea.value.substring(selectionEnd)
this.formula = newText
this.$nextTick(() => {
textarea.setSelectionRange(cursorPos + textToInsert.length, cursorPos + textToInsert.length)
textarea.setSelectionRange(cursorPos + cursorOffset + selectedText.length, cursorPos + cursorOffset + selectedText.length)
textarea.focus()
})
+63
View File
@@ -1,13 +1,16 @@
const en = {
name: 'Name',
fixed: 'Fixed',
default: 'Default',
save_as_custom_field: 'Save as custom field',
kba: 'KBA',
field_has_already_been_added_for_party: '{field} field has already been added for {party}',
analyzing_: 'Analyzing...',
download: 'Download',
downloading_: 'Downloading...',
view: 'View',
autodetect_fields: 'Autodetect Fields',
fields_detected: '{count} fields detected',
payment_link: 'Payment link',
strikeout: 'Strikeout',
draw_strikethrough_the_document: 'Draw strikethrough the document',
@@ -97,8 +100,11 @@ const en = {
add_documents_or_images: 'Add documents or images',
add_a_new_document: 'Add a new document',
edit_documents: 'Edit documents',
documents: 'Documents',
move_forward: 'Move forward',
move_backward: 'Move backward',
backward: 'Backward',
forward: 'Forward',
remove_page: 'Remove page',
merge_with_previous: 'Merge with previous',
merge_with_next: 'Merge with next',
@@ -185,6 +191,7 @@ const en = {
draw_a_text_field_on_the_page_with_a_mouse: 'Draw a text field on the page with a mouse',
drag_and_drop_any_other_field_type_on_the_page: 'Drag & drop any other field type on the page',
click_on_the_field_type_above_to_start_drawing_it: 'Click on the field type above to start drawing it',
tap_on_the_field_type_above_to_start_drawing_the_field: 'Tap a field type above to draw it on the page',
please_draw_fields_to_prepare_the_document: 'Please draw fields to prepare the document.',
only_pdf_and_images_are_supported: 'Only PDF and images are supported.',
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification.',
@@ -195,6 +202,8 @@ const en = {
replace: 'Replace',
uploading_: 'Uploading...',
add_document: 'Add Document',
add_fields: 'Add Fields',
fields: 'Fields',
none: 'None',
ssn: 'SSN',
ein: 'EIN',
@@ -243,11 +252,14 @@ const en = {
}
const es = {
name: 'Nombre',
fixed: 'Fijo',
default: 'Predeterminado',
save_as_custom_field: 'Guardar como personalizado',
kba: 'KBA',
field_has_already_been_added_for_party: 'El campo {field} ya se ha agregado para {party}',
autodetect_fields: 'Autodetectar campos',
fields_detected: '{count} campos detectados',
analyzing_: 'Analizando...',
download: 'Descargar',
downloading_: 'Descargando...',
@@ -338,8 +350,11 @@ const es = {
add_documents_or_images: 'Agregar documentos o imágenes',
add_a_new_document: 'Agregar un nuevo documento',
edit_documents: 'Editar documentos',
documents: 'Documentos',
move_forward: 'Mover adelante',
move_backward: 'Mover atrás',
backward: 'Atrás',
forward: 'Adelante',
remove_page: 'Eliminar página',
merge_with_previous: 'Combinar con el anterior',
merge_with_next: 'Combinar con el siguiente',
@@ -423,6 +438,7 @@ const es = {
draw_a_text_field_on_the_page_with_a_mouse: 'Dibujar un campo de texto en la página con el mouse',
drag_and_drop_any_other_field_type_on_the_page: 'Arrastra y suelta cualquier otro tipo de campo en la página',
click_on_the_field_type_above_to_start_drawing_it: 'Haz clic en el tipo de campo de arriba para comenzar a dibujarlo',
tap_on_the_field_type_above_to_start_drawing_the_field: 'Toca un tipo de campo de arriba para dibujarlo en la página',
please_draw_fields_to_prepare_the_document: 'Por favor, dibuja los campos para preparar el documento.',
only_pdf_and_images_are_supported: 'Solo se admiten PDF e imágenes.',
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Desbloquea el campo de número de teléfono verificado por SMS con un plan pago. Usa el campo de texto para números de teléfono sin verificación.',
@@ -433,6 +449,8 @@ const es = {
replace: 'Reemplazar',
uploading_: 'Subiendo...',
add_document: 'Subir',
add_fields: 'Agregar campos',
fields: 'Campos',
any: 'Cualquier',
drawn: 'Dibujado',
drawn_or_typed: 'Dibujado o Escrito',
@@ -487,11 +505,14 @@ const es = {
}
const it = {
name: 'Nome',
fixed: 'Fisso',
default: 'Predefinito',
save_as_custom_field: 'Salva come personalizzato',
kba: 'KBA',
field_has_already_been_added_for_party: 'Il campo {field} è già stato aggiunto per {party}',
autodetect_fields: 'Rileva campi',
fields_detected: '{count} campi rilevati',
analyzing_: 'Analisi...',
download: 'Scarica',
downloading_: 'Download in corso...',
@@ -585,8 +606,11 @@ const it = {
add_documents_or_images: 'Aggiungi documenti o immagini',
add_a_new_document: 'Aggiungi un nuovo documento',
edit_documents: 'Modifica documenti',
documents: 'Documenti',
move_forward: 'Sposta avanti',
move_backward: 'Sposta indietro',
backward: 'Indietro',
forward: 'Avanti',
remove_page: 'Rimuovi pagina',
merge_with_previous: 'Unisci al precedente',
merge_with_next: 'Unisci al successivo',
@@ -673,6 +697,7 @@ const it = {
draw_a_text_field_on_the_page_with_a_mouse: 'Disegna un campo di testo sulla pagina con il mouse',
drag_and_drop_any_other_field_type_on_the_page: 'Trascina e rilascia qualsiasi altro tipo di campo sulla pagina',
click_on_the_field_type_above_to_start_drawing_it: 'Clicca sul tipo di campo sopra per iniziare a disegnarlo',
tap_on_the_field_type_above_to_start_drawing_the_field: 'Tocca un tipo di campo sopra per disegnarlo sulla pagina',
please_draw_fields_to_prepare_the_document: 'Per favore, disegna i campi per preparare il documento.',
only_pdf_and_images_are_supported: 'Sono supportati solo PDF e immagini.',
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Sblocca il campo numero di telefono verificato tramite SMS con un piano a pagamento. Usa il campo di testo per numeri di telefono senza verifica.',
@@ -683,6 +708,8 @@ const it = {
replace: 'Sostituisci',
uploading_: 'Caricamento in corso...',
add_document: 'Aggiungi',
add_fields: 'Aggiungi campi',
fields: 'Campi',
none: 'Nessuno',
ssn: 'SSN',
ein: 'EIN',
@@ -731,11 +758,14 @@ const it = {
}
const pt = {
name: 'Nome',
fixed: 'Fixo',
default: 'Padrão',
save_as_custom_field: 'Salvar como personalizado',
kba: 'KBA',
field_has_already_been_added_for_party: 'O campo {field} já foi adicionado para {party}',
autodetect_fields: 'Detectar campos',
fields_detected: '{count} campos detectados',
analyzing_: 'Analisando...',
download: 'Baixar',
downloading_: 'Baixando...',
@@ -826,8 +856,11 @@ const pt = {
add_documents_or_images: 'Adicionar documentos ou imagens',
add_a_new_document: 'Adicionar um novo documento',
edit_documents: 'Editar documentos',
documents: 'Documentos',
move_forward: 'Mover para frente',
move_backward: 'Mover para trás',
backward: 'Para trás',
forward: 'Para frente',
remove_page: 'Remover página',
merge_with_previous: 'Mesclar com o anterior',
merge_with_next: 'Mesclar com o próximo',
@@ -911,6 +944,7 @@ const pt = {
draw_a_text_field_on_the_page_with_a_mouse: 'Desenhar um campo de texto na página com o mouse',
drag_and_drop_any_other_field_type_on_the_page: 'Arraste e solte qualquer outro tipo de campo na página',
click_on_the_field_type_above_to_start_drawing_it: 'Clique no tipo de campo acima para começar a desenhá-lo',
tap_on_the_field_type_above_to_start_drawing_the_field: 'Toque em um tipo de campo acima para desenhá-lo na página',
please_draw_fields_to_prepare_the_document: 'Por favor, desenhe os campos para preparar o documento.',
only_pdf_and_images_are_supported: 'Apenas PDFs e imagens são suportados.',
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Desbloqueie o campo de número de telefone verificado por SMS com um plano pago. Use o campo de texto para números de telefone sem verificação.',
@@ -921,6 +955,8 @@ const pt = {
replace: 'Substituir',
uploading_: 'Carregando...',
add_document: 'Enviar',
add_fields: 'Adicionar campos',
fields: 'Campos',
any: 'Qualquer',
drawn: 'Desenhado',
drawn_or_typed: 'Desenhado ou Digitado',
@@ -975,11 +1011,14 @@ const pt = {
}
const fr = {
name: 'Nom',
fixed: 'Fixe',
default: 'Par défaut',
save_as_custom_field: 'Enregistrer comme personnalisé',
kba: 'KBA',
field_has_already_been_added_for_party: 'Le champ {field} a déjà été ajouté pour {party}',
autodetect_fields: 'Détecter les champs',
fields_detected: '{count} champs détectés',
analyzing_: 'Analyse...',
download: 'Télécharger',
downloading_: 'Téléchargement...',
@@ -1073,8 +1112,11 @@ const fr = {
add_documents_or_images: 'Ajouter des documents ou des images',
add_a_new_document: 'Ajouter un nouveau document',
edit_documents: 'Modifier les documents',
documents: 'Documents',
move_forward: 'Déplacer en avant',
move_backward: 'Déplacer en arrière',
backward: 'Reculer',
forward: 'Avancer',
remove_page: 'Supprimer la page',
merge_with_previous: 'Fusionner avec le précédent',
merge_with_next: 'Fusionner avec le suivant',
@@ -1161,6 +1203,7 @@ const fr = {
draw_a_text_field_on_the_page_with_a_mouse: 'Dessinez un champ de texte sur la page avec la souris',
drag_and_drop_any_other_field_type_on_the_page: 'Glissez-déposez tout autre type de champ sur la page',
click_on_the_field_type_above_to_start_drawing_it: 'Cliquez sur le type de champ ci-dessus pour commencer à le dessiner',
tap_on_the_field_type_above_to_start_drawing_the_field: 'Appuyez sur un type de champ ci-dessus pour le dessiner sur la page',
please_draw_fields_to_prepare_the_document: 'Veuillez dessiner des champs pour préparer le document.',
only_pdf_and_images_are_supported: 'Seuls les PDF et les images sont pris en charge.',
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Débloquez le champ de numéro de téléphone vérifié par SMS avec un forfait payant. Utilisez un champ texte pour les numéros sans vérification.',
@@ -1171,6 +1214,8 @@ const fr = {
replace: 'Remplacer',
uploading_: 'Téléversement...',
add_document: 'Téléverser',
add_fields: 'Ajouter des champs',
fields: 'Champs',
none: 'Aucun',
ssn: 'SSN',
ein: 'EIN',
@@ -1219,11 +1264,14 @@ const fr = {
}
const de = {
name: 'Name',
fixed: 'Fest',
default: 'Standard',
save_as_custom_field: 'Als benutzerdefiniert speichern',
kba: 'KBA',
field_has_already_been_added_for_party: '{field}-Feld wurde bereits für {party} hinzugefügt',
autodetect_fields: 'Felder erkennen',
fields_detected: '{count} Felder erkannt',
analyzing_: 'Analysiere...',
download: 'Download',
downloading_: 'Download...',
@@ -1317,8 +1365,11 @@ const de = {
add_documents_or_images: 'Dokumente oder Bilder hinzufügen',
add_a_new_document: 'Neues Dokument hinzufügen',
edit_documents: 'Dokumente bearbeiten',
documents: 'Dokumente',
move_forward: 'Nach vorne',
move_backward: 'Nach hinten',
backward: 'Zurück',
forward: 'Vorwärts',
remove_page: 'Seite entfernen',
merge_with_previous: 'Mit vorherigem zusammenführen',
merge_with_next: 'Mit nächstem zusammenführen',
@@ -1405,6 +1456,7 @@ const de = {
draw_a_text_field_on_the_page_with_a_mouse: 'Ein Textfeld mit der Maus auf der Seite zeichnen',
drag_and_drop_any_other_field_type_on_the_page: 'Jeden anderen Feldtyp auf die Seite ziehen und ablegen',
click_on_the_field_type_above_to_start_drawing_it: 'Klicken Sie oben auf den Feldtyp, um mit dem Zeichnen zu beginnen',
tap_on_the_field_type_above_to_start_drawing_the_field: 'Tippen Sie oben auf einen Feldtyp, um ihn auf der Seite zu zeichnen',
please_draw_fields_to_prepare_the_document: 'Bitte zeichnen Sie Felder, um das Dokument vorzubereiten.',
only_pdf_and_images_are_supported: 'Nur PDF und Bilder werden unterstützt.',
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'SMS-verifiziertes Telefonnummernfeld mit kostenpflichtigem Plan freischalten. Verwenden Sie für Telefonnummern ohne Verifizierung das Textfeld.',
@@ -1415,6 +1467,8 @@ const de = {
replace: 'Ersetzen',
uploading_: 'Hochladen...',
add_document: 'Hochladen',
add_fields: 'Felder hinzufügen',
fields: 'Felder',
none: 'Keine',
ssn: 'SSN',
ein: 'EIN',
@@ -1463,11 +1517,14 @@ const de = {
}
const nl = {
name: 'Naam',
fixed: 'Vast',
default: 'Standaard',
save_as_custom_field: 'Opslaan als aangepast',
kba: 'KBA',
field_has_already_been_added_for_party: '{field}-veld is al toegevoegd voor {party}',
autodetect_fields: 'Velden detecteren',
fields_detected: '{count} velden gedetecteerd',
analyzing_: 'Analyseren...',
download: 'Downloaden',
downloading_: 'Downloaden...',
@@ -1561,8 +1618,11 @@ const nl = {
add_documents_or_images: 'Documenten of afbeeldingen toevoegen',
add_a_new_document: 'Nieuw document toevoegen',
edit_documents: 'Documenten bewerken',
documents: 'Documenten',
move_forward: 'Naar voren',
move_backward: 'Naar achteren',
backward: 'Achteruit',
forward: 'Vooruit',
remove_page: 'Pagina verwijderen',
merge_with_previous: 'Samenvoegen met vorige',
merge_with_next: 'Samenvoegen met volgende',
@@ -1649,6 +1709,7 @@ const nl = {
draw_a_text_field_on_the_page_with_a_mouse: 'Teken met de muis een tekstveld op de pagina',
drag_and_drop_any_other_field_type_on_the_page: 'Sleep elk ander veldtype naar de pagina',
click_on_the_field_type_above_to_start_drawing_it: 'Klik op het bovenstaande veldtype om te beginnen met tekenen',
tap_on_the_field_type_above_to_start_drawing_the_field: 'Tik op een veldtype hierboven om het op de pagina te tekenen',
please_draw_fields_to_prepare_the_document: 'Teken velden om het document voor te bereiden.',
only_pdf_and_images_are_supported: 'Alleen PDF en afbeeldingen worden ondersteund.',
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Ontgrendel het SMS-geverifieerde veld voor telefoonnummer met een betaald abonnement. Gebruik het tekstveld voor telefoonnummers zonder verificatie.',
@@ -1659,6 +1720,8 @@ const nl = {
replace: 'Vervangen',
uploading_: 'Bezig met uploaden...',
add_document: 'Doc. toevoegen',
add_fields: 'Velden toevoegen',
fields: 'Velden',
none: 'Geen',
ssn: 'SSN',
ein: 'EIN',
Loaded 100 of 228 files, more files were not shown because too many files have changed in this diff. Show more