Compare commits

...
144 Commits
Author SHA1 Message Date
Alex Turchyn 44dcc7f312 Merge from docusealco/wip 2026-09-28 14:39:31 +03:00
Pete Matsyburka 218c22da21 add ratelimit 2026-09-28 09:40:15 +03:00
Pete Matsyburka a8433e9bd3 adjust devise lock 2026-09-28 09:26:40 +03:00
Pete Matsyburka 800101f747 add i18n 2026-09-27 11:19:32 +03:00
Pete Matsyburka f637ee907c update routes 2026-09-27 09:43:48 +03:00
Pete Matsyburka 6a9b123cdc adjust verify pdf 2026-09-26 19:57:12 +03:00
Pete Matsyburka 0a2f8dfab3 validate 2fa email 2026-09-26 17:16:37 +03:00
Pete Matsyburka 3dc00ed7fc adjust 2fa rate limit 2026-09-26 17:09:36 +03:00
Pete Matsyburka af160b3f3f fix edit image 2026-09-26 17:07:47 +03:00
Pete Matsyburka 1a9e4026d9 guard test mode key only 2026-09-26 17:06:38 +03:00
Pete Matsyburka 5ef32bcf9e non prefillable types 2026-09-26 16:00:28 +03:00
Pete Matsyburka 2d6f4c521e refactor prefill 2026-09-26 15:17:33 +03:00
Pete Matsyburka 439f95d87e date formulas 2026-09-26 11:25:22 +03:00
Pete Matsyburka ca7b9671f2 refactor routes 2026-09-26 11:25:21 +03:00
Pete Matsyburka 20ec614d3a remove enquiries_controller 2026-09-25 17:18:29 +03:00
Pete Matsyburka 887ebbbcf6 adjust zoom 2026-09-25 17:06:49 +03:00
Pete Matsyburka 4225295295 fix editor 2026-09-25 10:36:26 +03:00
Pete Matsyburka 89367bc905 add i18n 2026-09-25 09:34:39 +03:00
Pete Matsyburka 4bacaa26e1 fix revisions 2026-09-25 08:52:35 +03:00
Pete Matsyburka 0f9fcf3fdc one field per party validation 2026-09-24 19:56:31 +03:00
Pete Matsyburka 437f61dc26 adjust html title 2026-09-24 16:13:25 +03:00
Pete Matsyburka e55542a000 adjust editor 2026-09-24 10:27:11 +03:00
Pete Matsyburka e004ac2d40 fix ui 2026-09-23 19:57:08 +03:00
Pete Matsyburka 92bfedd01d fix payment links 2026-09-23 18:48:24 +03:00
Pete Matsyburka e7fd1e4006 fix search button 2026-09-23 17:00:17 +03:00
Pete Matsyburka 56bba60d3f use open mobile modal 2026-09-23 16:38:05 +03:00
Pete Matsyburka 6b6a917f60 remove respond_to html 2026-09-23 11:00:18 +03:00
Pete Matsyburka df7b3f9dfe format html 2026-09-23 11:00:18 +03:00
Pete Matsyburka 1b21b0c65b adjust ui 2026-09-23 11:00:18 +03:00
Pete Matsyburka 258133d65c fix ci 2026-09-23 11:00:18 +03:00
Pete Matsyburka 8f33c8d8b6 adjust ui 2026-09-23 11:00:18 +03:00
Pete Matsyburka e11f4bddbe review fixes 2026-09-23 11:00:18 +03:00
Pete Matsyburka d071e8c429 ui fixes 2026-09-23 11:00:17 +03:00
Pete Matsyburka 79aa034183 native prefetch 2026-09-23 11:00:17 +03:00
Pete Matsyburka f5dad673de adjust ui 2026-09-23 11:00:17 +03:00
Pete Matsyburka 7e8375aee8 fix dropdowns 2026-09-23 11:00:17 +03:00
Pete Matsyburka 1999611b26 fix share modal 2026-09-23 11:00:17 +03:00
Pete Matsyburka 188c3a8c84 adjust template dropdown 2026-09-23 11:00:17 +03:00
Pete Matsyburka d35538e2ba adjust native 2026-09-23 11:00:17 +03:00
Pete Matsyburka a86f896694 remove focus lost 2026-09-23 11:00:17 +03:00
Pete Matsyburka 35eeeba0c3 adjust settings ui 2026-09-23 11:00:17 +03:00
Alex Turchyn e3e5c237d4 adjust mobile tables 2026-09-23 11:00:17 +03:00
Pete Matsyburka 9e89cb92a9 fix morph 2026-09-23 11:00:17 +03:00
Pete Matsyburka 827850646d use background color 2026-09-23 11:00:17 +03:00
Pete Matsyburka eafae4cda2 add haptics 2026-09-23 11:00:17 +03:00
Pete Matsyburka e012b7f5ac adjust title 2026-09-23 11:00:17 +03:00
Pete Matsyburka da9e7c6366 add send menu 2026-09-23 11:00:17 +03:00
Pete Matsyburka 20cc5d1e2e adjust mobile dropdown 2026-09-23 11:00:17 +03:00
Pete Matsyburka ae2cedbd70 drawer close 2026-09-23 11:00:17 +03:00
Pete Matsyburka 1f46f74400 adjust modal title 2026-09-23 11:00:17 +03:00
Pete Matsyburka 930445258d native dropdown 2026-09-23 11:00:17 +03:00
Pete Matsyburka be533e492e visit replace 2026-09-23 11:00:17 +03:00
Pete Matsyburka 66758d45bc adjust native 2026-09-23 11:00:17 +03:00
Alex Turchyn 6f31bd7af8 hide google drive in native app 2026-09-23 11:00:17 +03:00
Pete Matsyburka f4d2fa4c28 lock swipe 2026-09-23 11:00:17 +03:00
Pete Matsyburka 5d3369d556 fix download 2026-09-23 11:00:17 +03:00
Pete Matsyburka 5ea55c733f fix style 2026-09-23 11:00:17 +03:00
Pete Matsyburka 547bc9aa94 adjust mobile ui 2026-09-23 11:00:17 +03:00
Pete Matsyburka 53ca45a67a adjust flash 2026-09-23 11:00:17 +03:00
Pete Matsyburka ef28b80589 fix infinite scroll reload 2026-09-23 11:00:17 +03:00
Pete Matsyburka 05fa75f9a2 adjust downlaod 2026-09-23 11:00:17 +03:00
Pete Matsyburka 87b57279a4 update submission card 2026-09-23 11:00:17 +03:00
Pete Matsyburka f09732df90 share via copy 2026-09-23 11:00:17 +03:00
Pete Matsyburka a588c2f4a4 adjust signing form 2026-09-23 11:00:17 +03:00
Pete Matsyburka 960d36f628 add submission tabs 2026-09-23 11:00:17 +03:00
Pete Matsyburka 456dbae1cd use turbo remove 2026-09-23 11:00:17 +03:00
Pete Matsyburka 0fc1c0d1ed fix owerflow 2026-09-23 11:00:17 +03:00
Pete Matsyburka 714324ea99 native refresh 2026-09-23 11:00:17 +03:00
Pete Matsyburka 5a29ed22db turbo cache control 2026-09-23 11:00:17 +03:00
Pete Matsyburka 061580594c fix flash and morph 2026-09-23 11:00:17 +03:00
Alex Turchyn 8723ce7f7a migrate to turbo 8 2026-09-23 10:59:54 +03:00
Pete Matsyburka 522df7f476 fix overlap 2026-09-23 10:59:54 +03:00
Pete Matsyburka 026637c5ab show fields list on tablet 2026-09-23 10:59:54 +03:00
Pete Matsyburka fde993a579 adjust payment settings 2026-09-23 10:59:54 +03:00
Pete Matsyburka a960ea268a adjust builder 2026-09-23 10:59:54 +03:00
Pete Matsyburka d59776e8f5 adjust template preview 2026-09-23 10:59:54 +03:00
Pete Matsyburka 27e5c2c81e native modals 2026-09-23 10:59:54 +03:00
Alex Turchyn 0609a70544 upload native files via the page form 2026-09-23 10:59:54 +03:00
Pete Matsyburka 3ed15e383b adjust mobile builder 2026-09-23 10:59:54 +03:00
Pete Matsyburka d29be7c2cf add builder drawer 2026-09-23 10:59:54 +03:00
Pete Matsyburka 79f72c92b3 native platform prop 2026-09-23 10:59:54 +03:00
Pete Matsyburka 7e6331d1ad fix modal size 2026-09-23 10:59:54 +03:00
Pete Matsyburka 0986dfba73 fix native title 2026-09-23 10:59:54 +03:00
Alex Turchyn 7cf0b5aaf7 hide pro badge in the iOS app 2026-09-23 10:59:54 +03:00
Pete Matsyburka ae5f87f8fb add search 2026-09-23 10:59:54 +03:00
Pete Matsyburka 8259ebdb6c archived nav 2026-09-23 10:59:54 +03:00
Pete Matsyburka a4ca0abba1 adjsut card size 2026-09-23 10:59:54 +03:00
Pete Matsyburka ca009252ac rename template 2026-09-23 10:59:54 +03:00
Pete Matsyburka 9678cd1286 add sort menu 2026-09-23 10:59:54 +03:00
Pete Matsyburka 8cb0a7a020 add infinite scroll 2026-09-23 10:59:54 +03:00
Pete Matsyburka d3cc2e1ca8 adjust style 2026-09-23 10:59:54 +03:00
Pete Matsyburka 08ac60c7e6 adjust signign form 2026-09-23 10:59:54 +03:00
Alex Turchyn 49afb2e87c make template form preview native 2026-09-23 10:59:54 +03:00
Alex Turchyn be621339c3 expand native sheet when content overflows 2026-09-23 10:59:54 +03:00
Alex Turchyn 5f5b49a1ba open self sign form in the app 2026-09-23 10:59:54 +03:00
Alex Turchyn c0dd4b85ed hide pro badges only in iOS app 2026-09-23 10:59:54 +03:00
Alex Turchyn 95f7cd5220 ignore get forms in native-modal 2026-09-23 10:59:54 +03:00
Alex Turchyn f0753de99e hide pro badge in the native app 2026-09-23 10:59:54 +03:00
Pete Matsyburka 38ecda82fa adjust nav 2026-09-23 10:59:54 +03:00
Pete Matsyburka 073859a4b7 adjust nav 2026-09-23 10:59:54 +03:00
Pete Matsyburka 44972fcc39 fix folder nav 2026-09-23 10:59:54 +03:00
Pete Matsyburka 1232dfb248 fix folder nav 2026-09-23 10:59:54 +03:00
Pete Matsyburka 5de334522c adjust settings 2026-09-23 10:59:54 +03:00
Pete Matsyburka 7a6b4acea6 native navbar 2026-09-23 10:59:54 +03:00
Alex Turchyn 198a599a2b add native-modal element 2026-09-23 10:59:54 +03:00
Alex Turchyn d36f40cafd make template editor header native 2026-09-23 10:59:54 +03:00
Alex Turchyn 9abdb68bc5 make submission header buttons native 2026-09-23 10:59:54 +03:00
Alex Turchyn 359f695eb5 declare search for native app 2026-09-23 10:59:54 +03:00
Alex Turchyn 2b9e5a86b6 align native sheet padding with pages 2026-09-23 10:59:54 +03:00
Alex Turchyn c07d76e2a4 open all modals as sheets in the native app 2026-09-23 10:59:54 +03:00
Alex Turchyn f5b29642ee make template preferences button native 2026-09-23 10:59:54 +03:00
Alex Turchyn 3f6cdcd15b remove native-header element 2026-09-23 10:59:53 +03:00
Alex Turchyn bde8097288 add json sign in for native app 2026-09-23 10:59:53 +03:00
Alex Turchyn 617d75a4ed add native titles 2026-09-23 10:59:53 +03:00
Alex Turchyn aa1874cdb4 adjust more UI elevent for native app 2026-09-23 10:59:53 +03:00
Alex Turchyn 22b2e7475a add native app 2026-09-23 10:59:53 +03:00
Pete Matsyburka ad3367dae0 add date placeholder 2026-09-23 10:01:51 +03:00
Alex Turchyn 47c090e1f1 Merge from docusealco/wip 2026-09-21 14:53:39 +03:00
Pete Matsyburka 678997fb36 adjust embed script 2026-09-21 09:06:26 +03:00
Pete Matsyburka 24f195c7e5 normalize extension 2026-09-21 09:06:25 +03:00
Pete Matsyburka 5741bb4db2 normalize page index 2026-09-20 17:37:02 +03:00
Pete Matsyburka 1d169668f0 fix ci 2026-09-20 14:58:06 +03:00
Pete Matsyburka ef3d5bec7e mcp use template access 2026-09-20 13:30:28 +03:00
Pete Matsyburka d578ca3cc9 only dss changes 2026-09-20 13:30:28 +03:00
Pete Matsyburka 5432978c0d adjust load vips 2026-09-20 13:30:27 +03:00
Pete Matsyburka de1af49348 fix variable highlight 2026-09-18 09:18:50 +03:00
Alex Turchyn 2f0bcb4a80 hide tooltips on mobile 2026-09-17 23:45:59 +03:00
Pete Matsyburka 11f27c8cf0 reset rotation 2026-09-14 16:06:16 +03:00
Alex Turchyn 6e2430e5a2 Merge from docusealco/wip 2026-09-14 15:54:52 +03:00
Pete Matsyburka 85fbae1813 validate webhook header 2026-09-14 15:07:56 +03:00
Pete Matsyburka 9941122d90 adjust i18n 2026-09-14 11:08:40 +03:00
Pete Matsyburka 71be6db358 adjust delegated values 2026-09-12 18:34:39 +03:00
Pete Matsyburka 994c1b3155 keep pasted field roles 2026-09-12 18:29:56 +03:00
Pete Matsyburka 92b297a620 fix friendly name 2026-09-11 13:00:26 +03:00
Alex Turchyn bd03498b0f adjust show archived 2026-09-11 09:56:10 +03:00
Pete Matsyburka 660b24710e do not email archived user 2026-09-10 19:17:27 +03:00
Pete Matsyburka 1416cf72e8 sms retry 30 seconds 2026-09-10 15:29:27 +03:00
Alex Turchyn ae7b0ce608 fix settings collapse spacing 2026-09-09 16:09:19 +03:00
Pete Matsyburka a3828ec390 xlsx write native boolean 2026-09-09 13:35:20 +03:00
Pete Matsyburka b8c1691bd7 optimize xlsx export 2026-09-09 12:46:28 +03:00
Pete Matsyburka 4b00c896e4 reset signature on delegate 2026-09-08 18:52:12 +03:00
Pete Matsyburka 781da32a07 replace filename slashes 2026-09-08 13:53:35 +03:00
Pete Matsyburka 307036eae6 fix invite party 2026-09-08 09:12:47 +03:00
Pete Matsyburka 9944a9b1a5 adjust bind address 2026-09-07 17:57:07 +03:00
272 changed files with 6383 additions and 2658 deletions

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:
+1 -5
View File
@@ -152,11 +152,7 @@ jobs:
bundle config path vendor/bundle
bundle install --jobs 4 --retry 4
yarn install
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260813/pdfium-musl-$(uname -m).zip"
case "$(uname -m)" in
x86_64) echo "c5c7dde243ecb66ab0819c8193515ef38ad53549fe260f3c2dfd93ea56eda2e7 pdfium-linux.zip" ;;
aarch64) echo "64c4483449b1b4dccc696ad0c5c96e0b7f74dcc57b4f23c676b7a70671b0bbb5 pdfium-linux.zip" ;;
esac | sha256sum -c -
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-musl-$(uname -m).zip"
unzip -q pdfium-linux.zip -d /tmp/pdfium-linux
cp /tmp/pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
rm -rf pdfium-linux.zip /tmp/pdfium-linux
+3 -3
View File
@@ -9,10 +9,10 @@ RUN apk --no-cache add wget unzip && \
wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \
wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260813/pdfium-musl-$(uname -m).zip" && \
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260920/pdfium-musl-$(uname -m).zip" && \
case "$(uname -m)" in \
x86_64) echo "c5c7dde243ecb66ab0819c8193515ef38ad53549fe260f3c2dfd93ea56eda2e7 pdfium-linux.zip" ;; \
aarch64) echo "64c4483449b1b4dccc696ad0c5c96e0b7f74dcc57b4f23c676b7a70671b0bbb5 pdfium-linux.zip" ;; \
x86_64) echo "bbca8a648dbd1ba81f9c6c223f21dc394cf1ad381c6c17d60c6dcc4a7d3a2ae0 pdfium-linux.zip" ;; \
aarch64) echo "73b72ed79b9bfdd494dee7e863482c24971a8d4b11f6cd1289aa170d31304a1f pdfium-linux.zip" ;; \
esac | sha256sum -c - && \
mkdir -p /pdfium-linux && \
unzip -q pdfium-linux.zip -d /pdfium-linux
+2 -1
View File
@@ -36,7 +36,7 @@ gem 'rotp'
gem 'rouge', require: false
gem 'rqrcode', require: false
gem 'ruby-vips'
gem 'rubyXL', require: false
gem 'rubyzip', require: false
gem 'shakapacker'
gem 'sidekiq'
gem 'sqlite3', require: false
@@ -45,6 +45,7 @@ gem 'trilogy', require: false
gem 'turbo-rails'
gem 'twitter_cldr', require: false
gem 'tzinfo-data'
gem 'xlsxtream', require: false
group :development, :test do
gem 'better_html'
+5 -4
View File
@@ -497,9 +497,6 @@ GEM
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
rubyXL (3.4.35)
nokogiri (>= 1.10.8)
rubyzip (>= 3.2.2)
rubyzip (3.2.2)
securerandom (0.4.1)
semantic_range (3.1.1)
@@ -575,10 +572,13 @@ GEM
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xlsxtream (3.1.0)
zip_kit (>= 6.2, < 7)
xpath (3.2.0)
nokogiri (~> 1.8)
yaml (0.4.0)
zeitwerk (2.8.2)
zip_kit (6.3.4)
PLATFORMS
aarch64-linux
@@ -638,7 +638,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-vips
rubyXL
rubyzip
shakapacker
sidekiq
simplecov
@@ -650,6 +650,7 @@ DEPENDENCIES
tzinfo-data
web-console
webmock
xlsxtream
RUBY VERSION
ruby 4.0.5
@@ -23,7 +23,7 @@ module Api
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
if FilenameUtils.dangerous_extension(blob.filename)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
@@ -19,7 +19,7 @@ module Api
return head :not_found unless blob
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
if FilenameUtils.dangerous_extension(blob.filename)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
@@ -173,7 +173,6 @@ module Api
end
def filter_submissions(submissions, params)
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
if params[:template_folder].present?
@@ -1,10 +1,7 @@
# frozen_string_literal: true
module Api
class SubmitterEmailClicksController < ApiBaseController
skip_before_action :authenticate_user!
skip_authorization_check
class SubmitterEmailClicksController < ActionController::API
def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
@@ -1,10 +1,7 @@
# frozen_string_literal: true
module Api
class SubmitterFormViewsController < ApiBaseController
skip_before_action :authenticate_user!
skip_authorization_check
class SubmitterFormViewsController < ActionController::API
def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
+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
@@ -46,6 +46,8 @@ module Mcp
authorize!(:create, @template)
Templates.maybe_assign_access(@template)
if mcp_params['url'].present?
tempfile = Tempfile.new
tempfile.binmode
+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')
@@ -26,8 +26,9 @@ class PreviewDocumentPageController < ActionController::API
@template = attachment.record
preview_image = attachment.preview_images.joins(:blob)
.find_by(blob: { filename: ["#{params[:id]}.png", "#{params[:id]}.jpg"] })
preview_image =
attachment.preview_images.joins(:blob)
.find_by(blob: { filename: ["#{params[:id].to_i}.png", "#{params[:id].to_i}.jpg"] })
if preview_image
return redirect_to preview_image.url(time: ActiveStorage::Attachment.service_url_time),
+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
@@ -26,7 +26,8 @@ class SubmitFormDeclineController < ApplicationController
user = @submitter.submission.created_by_user || @submitter.template.author
if user.user_configs.find_by(key: UserConfig::RECEIVE_DECLINED_EMAIL)&.value != false
if Users.send_emails?(user) &&
user.user_configs.find_by(key: UserConfig::RECEIVE_DECLINED_EMAIL)&.value != false
SubmitterMailer.declined_email(@submitter, user).deliver_later!
end
@@ -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)
@@ -32,7 +33,8 @@ class SubmitFormDelegateController < ApplicationController
SubmissionEvents.create_with_tracking_data(@submitter, 'delegate_form', request,
{ old_email: @submitter.email, email: })
@submitter.update!(email:, phone: nil, name: nil, slug: SecureRandom.base58(14))
@submitter.update!(email:, phone: nil, name: nil, slug: SecureRandom.base58(14),
values: Submitters.fetch_values_for_delegate(@submitter))
end
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => @submitter.id)
@@ -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(':')
@@ -23,7 +23,7 @@ class SubmitFormInviteController < ApplicationController
@submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: @submitter.account_id)
SubmissionEvents.create_with_tracking_data(@submitter, 'invite_party', request, { uuid: @submitter.uuid })
SubmissionEvents.create_with_tracking_data(@submitter, 'invite_party', request, { uuid: attrs[:uuid] })
end
@submitter.submission.update!(submitters_order: :preserved)
@@ -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
@related_submissions_pagy, @related_submissions = load_related_submissions
render_infinite_scroll(@pagy, @templates) if turbo_infinite_scroll?
end
private
def render_infinite_scroll(pagy, templates)
render turbo_stream: [
turbo_stream.before('infinite_scroll', partial: 'templates/template', collection: templates,
locals: { with_folder: true }),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
def load_related_submissions
related_submissions =
Submission.accessible_by(current_ability)
@@ -16,7 +16,19 @@ class TemplatesArchivedSubmissionsController < ApplicationController
end
@pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))
render_infinite_scroll(@pagy, @submissions, @template) if turbo_infinite_scroll?
rescue ActiveRecord::RecordNotFound
redirect_to root_path
end
private
def render_infinite_scroll(pagy, submissions, template)
render turbo_stream: [
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
locals: { template:, archived: true }),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
end
+15 -1
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
redirect_back(fallback_location: root_path, notice:)
if @template.destroyed? && turbo_native_app?
redirect_to templates_archived_index_path, notice:
else
redirect_back(fallback_location: root_path, notice:)
end
end
private
def render_infinite_scroll(pagy, submissions, template)
render turbo_stream: [
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
locals: { template:, archived: template.archived_at? }),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
def template_params
params.require(:template).permit(
:name,
@@ -43,10 +43,26 @@ class TemplatesDashboardController < ApplicationController
@related_submissions_pagy, @related_submissions = load_related_submissions
end
end
render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll?
end
private
def render_infinite_scroll(pagy, templates, template_folders)
render_params =
if templates.present?
{ partial: 'templates/template', collection: templates }
else
{ partial: 'template_folders/folder', collection: template_folders, as: :folder }
end
render turbo_stream: [
turbo_stream.before('infinite_scroll', **render_params),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
def load_templates(templates, folders_count, show_shared_inline: false)
templates = templates.preload(:author, :template_accesses)
@@ -94,16 +110,16 @@ class TemplatesDashboardController < ApplicationController
pinned_count = (show_default_in_grid ? 1 : 0) + (show_shared_in_grid ? 1 : 0)
pagy = Pagy::Offset.new(count: folders_count + pinned_count,
page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max,
limit: FOLDERS_PER_PAGE,
raise_range_error: true)
pagy, = pagy(template_folders, count: folders_count + pinned_count,
page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max,
limit: FOLDERS_PER_PAGE,
raise_range_error: true)
show_default_folder = show_default_in_grid && pagy.page == 1
show_shared_folder = show_shared_in_grid && pagy.page == 1
folder_offset = pagy.page == 1 ? 0 : pagy.offset - pinned_count
folder_limit = pagy.page == 1 ? FOLDERS_PER_PAGE - pinned_count : FOLDERS_PER_PAGE
folder_limit = pagy.page == 1 ? pagy.limit - pinned_count : pagy.limit
template_folders = template_folders.offset(folder_offset).limit(folder_limit)
+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
@related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived)
render_infinite_scroll(@pagy, @templates) if turbo_infinite_scroll?
end
private
def render_infinite_scroll(pagy, templates)
render turbo_stream: [
turbo_stream.before('infinite_scroll', partial: 'templates/template', collection: templates),
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
]
end
def load_related_submissions(is_archived:)
shared_templates = Templates.shared(current_user)
shared_templates = is_archived ? shared_templates.archived : shared_templates.active
@@ -63,7 +63,7 @@ class TemplatesUploadsController < ApplicationController
tempfile.write(DownloadUtils.call(params[:url], validate: true).body)
tempfile.rewind
filename = URI.decode_www_form_component(params[:filename]) if params[:filename].present?
filename = URI.decode_www_form_component(params[:filename]).tr('/', '-') if params[:filename].present?
filename ||= File.basename(URI.decode_www_form_component(params[:url]))
file = ActionDispatch::Http::UploadedFile.new(
@@ -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
+2 -4
View File
@@ -11,11 +11,9 @@ class UserInitialsController < ApplicationController
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank?
extension = File.extname(file.original_filename).delete_prefix('.').downcase
extension = FilenameUtils.dangerous_extension(file.original_filename)
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed." if extension
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
@@ -11,11 +11,9 @@ class UserSignaturesController < ApplicationController
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank?
extension = File.extname(file.original_filename).delete_prefix('.').downcase
extension = FilenameUtils.dangerous_extension(file.original_filename)
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed." if extension
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
+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)
+9 -1
View File
@@ -3,11 +3,19 @@
class WebhookSecretController < ApplicationController
load_and_authorize_resource :webhook_url, parent: false
HEADER_NAME_REGEXP = /\A[\w-]+\z/
def show; end
def update
key = webhook_secret_params[:key]
if key.present? && !HEADER_NAME_REGEXP.match?(key)
return redirect_back(fallback_location: settings_webhook_path(@webhook_url), alert: I18n.t('unable_to_save'))
end
@webhook_url.update!(secret: {
webhook_secret_params[:key] => webhook_secret_params[:value]
key => webhook_secret_params[:value]
}.compact_blank)
redirect_back(fallback_location: settings_webhook_path(@webhook_url),
+189 -38
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,39 +263,130 @@ safeRegisterElement('template-builder', class extends HTMLElement {
this.appendChild(this.appElem)
}
onSubmit = (e) => {
if (e.detail.success) {
if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
e.detail.fetchResponse.response.json().then((data) => {
this.component.template.submitters = data.submitters
})
onSubmit = async (e) => {
const form = e.detail.formSubmission?.formElement
if (!e.detail.success || !form) return
const data = {}
new FormData(form).forEach((value, key) => { data[key] = value })
this.applySubmission(form.id, form.action, await e.detail.fetchResponse.responseText, data)
}
onSheetSubmit = (e) => {
this.applySubmission(e.detail.form, e.detail.action, e.detail.body, {})
this.component.syncNative()
}
onSync = (e) => {
const { schema, documents, fields, submitters, pendingFieldAttachmentUuids } = e.detail
const selectedAreas = this.component.selectedAreasRef.value.map((area) => {
const field = this.component.template.fields.find((f) => f.areas?.includes(area))
return field && { uuid: field.uuid, index: field.areas.indexOf(area) }
})
const selectedSubmitterUuid = this.component.selectedSubmitter?.uuid
Object.assign(this.component.template, { schema, documents, fields, submitters })
this.component.selectedAreasRef.value = selectedAreas.map((item) => item && fields.find((f) => f.uuid === item.uuid)?.areas?.[item.index]).filter(Boolean)
this.component.selectedSubmitter = submitters.find((s) => s.uuid === selectedSubmitterUuid) || submitters[0]
this.component.pendingFieldAttachmentUuids = pendingFieldAttachmentUuids
}
onScrollTo = (e) => {
this.component.scrollIntoDocument(e.detail)
}
onSyncCustomFields = (e) => {
this.component.customFields.splice(0, this.component.customFields.length, ...e.detail)
}
onSelectSubmitter = (e) => {
const submitter = this.component.template.submitters.find((s) => s.uuid === e.detail.uuid)
if (submitter) this.component.selectedSubmitter = submitter
}
onSelectField = (e) => {
const { uuid, attachment_uuid: attachmentUuid, page, x, y } = e.detail
const field = this.component.template.fields.find((f) => f.uuid === uuid)
const areas = field?.areas || []
const area = areas.find((a) => a.attachment_uuid === attachmentUuid && a.page === page && a.x === x && a.y === y) || areas[0]
if (area) this.component.scrollToArea(area)
}
onDrawField = (e) => {
const { uuid, type, option, custom_field_uuid: customFieldUuid } = e.detail
if (customFieldUuid) {
const field = this.component.customFields.find((f) => f.uuid === customFieldUuid)
if (field) {
this.component.drawCustomField = field
this.component.showDrawField = true
}
} else if (uuid) {
const field = this.component.template.fields.find((f) => f.uuid === uuid)
if (e.detail?.formSubmission?.formElement?.action?.endsWith('/prefillable_fields')) {
e.detail.fetchResponse.response.text().then((data) => {
const doc = new DOMParser().parseFromString(data, 'text/html')
const fragment = doc.querySelector('turbo-stream template').content
const prefillableUuidsIndex = {}
fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => {
prefillableUuidsIndex[field.value] = true
})
this.component.template.fields.forEach((field) => {
if (prefillableUuidsIndex[field.uuid]) {
field.prefillable = true
field.readonly = true
} else if (field.prefillable) {
delete field.prefillable
delete field.readonly
}
})
})
if (field) {
this.component.drawField = field
this.component.drawOption = option || null
}
} else if (type) {
this.component.startFieldDraw({ type })
}
}
applySubmission (formId, action, body, data) {
if (formId === 'submitters_form') {
this.component.template.submitters = JSON.parse(body).submitters
} else if (action.endsWith('/prefillable_fields')) {
const doc = new DOMParser().parseFromString(body, 'text/html')
const fragment = doc.querySelector('turbo-stream template').content
const prefillableUuidsIndex = {}
fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => {
prefillableUuidsIndex[field.value] = true
})
this.component.template.fields.forEach((field) => {
if (prefillableUuidsIndex[field.uuid]) {
field.prefillable = true
field.readonly = true
} else if (field.prefillable) {
delete field.prefillable
delete field.readonly
}
})
} else if (data['template[name]']) {
this.component.template.name = data['template[name]']
document.title = data['template[name]']
}
}
onBuilderView = (event) => {
const root = document.documentElement.cloneNode(true)
const builder = root.querySelector('template-builder')
const style = document.createElement('style')
builder.innerHTML = ''
builder.setAttribute('data-native-view', event.detail.view)
root.querySelector('meta[name="viewport"]')?.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no')
style.textContent = 'html { overflow-x: hidden; overscroll-behavior-x: none; } html, body { max-width: 100%; } body { -webkit-user-select: none; user-select: none; } input, textarea, [contenteditable] { -webkit-user-select: text; user-select: text; }'
root.querySelector('head').appendChild(style)
event.detail.result = '<!DOCTYPE html>' + root.outerHTML
}
onBeforeCache = () => {
this.app?.unmount()
this.appElem?.remove()
@@ -251,6 +394,14 @@ safeRegisterElement('template-builder', class extends HTMLElement {
disconnectedCallback () {
document.removeEventListener('turbo:submit-end', this.onSubmit)
document.removeEventListener('template-builder:update', this.onSheetSubmit)
document.removeEventListener('template-builder:sync', this.onSync)
document.removeEventListener('template-builder:scroll-to', this.onScrollTo)
document.removeEventListener('template-builder:select-field', this.onSelectField)
document.removeEventListener('template-builder:select-submitter', this.onSelectSubmitter)
document.removeEventListener('template-builder:sync-custom-fields', this.onSyncCustomFields)
document.removeEventListener('template-builder:draw-field', this.onDrawField)
document.removeEventListener('native:builder-view', this.onBuilderView)
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
this.app?.unmount()
+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}`)
+3 -3
View File
@@ -59,10 +59,10 @@ export async function convertImagesInInput (input) {
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input[type="file"]')
const form = input.form
this.addEventListener('change', async (e) => {
const input = e.target
const form = input.form
input.addEventListener('change', async () => {
await convertImagesInInput(input)
form.querySelector('[type="submit"]')?.setAttribute('disabled', true)
+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' })
}
}
}
+3 -1
View File
@@ -353,7 +353,7 @@ function buildExtensions ({ Node, Mark, Extension, Plugin, Decoration, Decoratio
const buildDecorations = (doc) => {
const decorations = []
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g
const regex = /\{\{?[^{}\n]+\}\}?/g
doc.descendants((node, pos) => {
if (!node.isText) return
@@ -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
})
}
}
+3 -1
View File
@@ -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()
@@ -150,7 +152,7 @@ export default actionable(targetable(class extends HTMLElement {
const buildDecorations = (doc) => {
const decorations = []
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g
const regex = /\{\{?[^{}\n]+\}\}?/g
doc.descendants((node, pos) => {
if (!node.isText) return
+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)
+16 -7
View File
@@ -1,20 +1,29 @@
export default class extends HTMLElement {
connectedCallback () {
this.button.addEventListener('click', (event) => {
if (!this.input.value && document.activeElement !== this.input) {
event.preventDefault()
this.input.focus()
}
})
this.addEventListener('click', this.onClick)
document.addEventListener('turbo:before-cache', this.onBeforeCache)
if (this.input?.value) {
this.input.focus()
this.input.setSelectionRange(this.input.value.length, this.input.value.length)
}
}
disconnectedCallback () {
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
}
onClick = (event) => {
if (!event.target.closest('button')) return
if (!this.input.value && document.activeElement !== this.input) {
event.preventDefault()
this.input.focus()
}
}
onBeforeCache = () => {
this.input.value = this.input.getAttribute('value') || ''
}
+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)
}
}
}
+14 -10
View File
@@ -1,19 +1,23 @@
export default class extends HTMLElement {
connectedCallback () {
this.addEventListener('click', this.onClick)
}
onClick = (event) => {
const button = this.querySelector('a, button, label')
if (!button?.contains(event.target)) return
const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button
button.addEventListener('click', () => {
this.dataset.classes.split(' ').forEach((cls) => {
if (this.dataset.action === 'remove') {
target.classList.remove(cls)
} else if (this.dataset.action === 'add') {
target.classList.add(cls)
} else {
target.classList.toggle(cls)
}
})
this.dataset.classes.split(' ').forEach((cls) => {
if (this.dataset.action === 'remove') {
target.classList.remove(cls)
} else if (this.dataset.action === 'add') {
target.classList.add(cls)
} else {
target.classList.toggle(cls)
}
})
}
}
+5 -11
View File
@@ -1,27 +1,21 @@
export default class extends HTMLElement {
connectedCallback () {
document.addEventListener('turbo:submit-end', this.onSubmitEnd)
this.form.addEventListener('submit', this.onSubmit)
this.form.addEventListener('turbo:before-fetch-response', this.onFetchResponse)
}
disconnectedCallback () {
document.removeEventListener('turbo:submit-end', this.onSubmitEnd)
this.form.removeEventListener('submit', this.onSubmit)
this.form.removeEventListener('turbo:before-fetch-response', this.onFetchResponse)
}
onSubmit = () => {
this.element.classList.add('invisible')
}
onSubmitEnd = (event) => {
if (event.target === this.form) {
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
if (resp?.status / 100 === 2) {
this.element.classList.remove('invisible')
}
onFetchResponse = (event) => {
if (event.detail.fetchResponse.succeeded) {
this.element.classList.remove('invisible')
}
}
+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
+13 -2
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 + '/api/attachments', {
return fetch(this.baseUrl + `/s/${this.submitterSlug}/upload`, {
method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData
}).then((resp) => {
if (resp.status === 404) {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData
})
} else {
return resp
}
}).then(async (resp) => {
const data = await resp.json()
+78 -29
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 + '/api/attachments', {
return fetch(this.baseUrl + `/s/${this.submitterSlug}/upload`, {
method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData
}).then((resp) => {
if (resp.status === 404) {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData
})
} else {
return resp
}
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
@@ -104,6 +104,11 @@ export default {
required: false,
default: () => ({})
},
fieldsUuidIndex: {
type: Object,
required: false,
default: () => ({})
},
values: {
type: Object,
required: true
@@ -137,7 +142,7 @@ export default {
}
},
computed: {
fieldsUuidIndex () {
formulaFieldsUuidIndex () {
return this.fields.reduce((acc, field) => {
acc[field.uuid] = field
@@ -216,17 +221,31 @@ export default {
},
calculateFormula () {
const transformedFormula = this.normalizeFormula(this.field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
return this.readonlyValues[uuid] || this.values[uuid] || 0.0
const value = this.readonlyValues[uuid] || this.values[uuid]
if (this.fieldsUuidIndex[uuid]?.type === 'date') {
return this.dateFormulaValue(value)
}
return value || 0.0
})
return this.math.evaluate(transformedFormula.toLowerCase())
},
dateFormulaValue (value) {
if (!value) return 'null'
if (value === '{{date}}') return 'today()'
const [year, month, day = 1] = value.slice(0, 10).split('-').map(Number)
return Date.UTC(year, month - 1, day) / 86400000
},
normalizeFormula (formula, depth = 0) {
if (depth > 10) return formula
return formula.replace(/{{(.*?)}}/g, (match, uuid) => {
if (this.fieldsUuidIndex[uuid]) {
return `(${this.normalizeFormula(this.fieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
if (this.formulaFieldsUuidIndex[uuid]) {
return `(${this.normalizeFormula(this.formulaFieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
} else {
return match
}
@@ -261,7 +261,7 @@ export default {
}
},
resendCode () {
if (this.codeSentAt && Date.now() - this.codeSentAt < 15000) {
if (this.codeSentAt && Date.now() - this.codeSentAt < 30000) {
this.startResendCodeCountdown()
} else {
this.isResendLoading = true
@@ -274,7 +274,7 @@ export default {
}
},
startResendCodeCountdown () {
this.resendCodeCountdown = 15 - parseInt((Date.now() - this.codeSentAt) / 1000)
this.resendCodeCountdown = 30 - parseInt((Date.now() - this.codeSentAt) / 1000)
this.interval = setInterval(() => {
this.resendCodeCountdown--
+101 -31
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 + '/api/attachments', {
return fetch(this.baseUrl + `/s/${this.submitterSlug}/upload`, {
method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData
}).then((resp) => {
if (resp.status === 404) {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
headers: { ...this.fetchOptions.headers },
body: formData
})
} else {
return resp
}
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
+167 -58
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,62 +75,115 @@
@click.prevent="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>{{ t('editable') }}</label>
<span
v-if="field.type !== 'payment' && !isValueInput"
class="dropdown dropdown-end field-area-settings-dropdown"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
ref="settingsButton"
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center"
style="height: 25px"
@focus="isSettingsFocus = true"
@blur="maybeBlurSettings"
>
<IconDotsVertical class="w-5 h-5" />
</label>
<ul
v-if="renderDropdown"
ref="settingsDropdown"
tabindex="0"
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
:style="{ backgroundColor: 'white' }"
@dragstart.prevent.stop
@click="closeDropdown"
@focusout="maybeBlurSettings"
>
<FieldSettings
v-if="isMobile"
:field="field"
:default-field="defaultField"
:editable="editable"
:background-color="'white'"
:with-required="false"
:with-areas="false"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@add-custom-field="$emit('add-custom-field')"
@click-condition="isShowConditionsModal = true"
@save="$emit('change')"
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
/>
<div
v-else
class="whitespace-normal"
>
The dots menu is retired in favor of the field context menu. Right-click the field to access field settings. Double-click the field to set a default value.
</div>
</ul>
</span>
</div>
<span
v-if="isShowSettingsMenu"
class="dropdown dropdown-end field-area-settings-dropdown"
:class="isMobile ? '-ml-1' : { 'ml-1.5': !isShowControls }"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
ref="settingsButton"
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center"
:class="{ 'pr-0.5': isMobile }"
style="height: 24px"
@focus="onSettingsFocus"
@blur="maybeBlurSettings"
>
<IconDotsVertical class="w-5 h-5" />
</label>
<ul
v-if="renderDropdown"
ref="settingsDropdown"
tabindex="0"
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
:style="{ backgroundColor: 'white' }"
@dragstart.prevent.stop
@click="closeDropdown"
@focusout="maybeBlurSettings"
>
<FieldSettings
v-if="isMobile"
:field="field"
:default-field="defaultField"
:editable="editable"
:background-color="'white'"
:with-required="false"
:with-areas="false"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
@click-formula="openFormulaModal"
@click-font="openFontModal"
@click-description="openDescriptionModal"
@add-custom-field="$emit('add-custom-field')"
@click-condition="openConditionsModal"
@save="$emit('change')"
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
/>
<div
v-else
class="whitespace-normal"
>
The dots menu is retired in favor of the field context menu. Right-click the field to access field settings. Double-click the field to set a default value.
</div>
<li
v-if="isMobile && editable"
class="field-settings-remove"
>
<a
href="#"
class="text-sm py-1 px-2 text-red-600"
@click.prevent="$emit('remove')"
>
<IconTrashX
:width="20"
:stroke-width="1.6"
/>
{{ t('remove') }}
</a>
</li>
</ul>
</span>
<PaymentSettings
v-else-if="isMobile && editable && field.type === 'payment'"
class="-ml-1"
:field="field"
:with-force-open="false"
:background-color="'white'"
:menu-classes="'rounded-t-none pt-1'"
@click-condition="openConditionsModal"
@click-description="openDescriptionModal"
@click-formula="openFormulaModal"
>
<label
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center pr-0.5"
style="height: 24px"
>
<IconDotsVertical class="w-5 h-5" />
</label>
<template #menu>
<li class="field-settings-remove">
<a
href="#"
class="text-sm py-1 px-2 text-red-600"
@click.prevent="$emit('remove')"
>
<IconTrashX
:width="20"
:stroke-width="1.6"
/>
{{ t('remove') }}
</a>
</li>
</template>
</PaymentSettings>
<button
v-else-if="editable"
v-else-if="editable && !isShowControls"
class="pr-1"
:title="t('remove')"
@click.prevent="$emit('remove')"
@@ -195,18 +249,21 @@ import FieldSubmitter from './field_submitter'
import FieldType from './field_type'
import Field from './field'
import FieldSettings from './field_settings'
import PaymentSettings from './payment_settings'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconX, IconDotsVertical } from '@tabler/icons-vue'
import { IconX, IconDotsVertical, IconTrashX } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
name: 'AreaTitle',
components: {
IconTrashX,
FieldType,
FieldSettings,
PaymentSettings,
FormulaModal,
FontModal,
IconDotsVertical,
@@ -215,7 +272,7 @@ export default {
FieldSubmitter,
IconX
},
inject: ['t'],
inject: ['t', 'isMobile', 'nativePlatform'],
props: {
template: {
type: Object,
@@ -292,6 +349,17 @@ export default {
}
},
computed: {
isShowControls () {
if (this.isMobile) return false
return this.isSettingsFocus || this.isSelectInput || (this.isValueInput && this.field.type !== 'heading') || (this.isNameFocus && !['checkbox', 'phone'].includes(this.field.type))
},
isShowSettingsMenu () {
if (this.field.type === 'payment' || this.isValueInput) return false
if (this.isMobile) return this.editable
return this.isShowControls
},
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
submitter () {
@@ -318,6 +386,34 @@ export default {
}
},
methods: {
openFormulaModal () {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'formula', detent: 'medium', uuid: this.field.uuid })
} else {
this.isShowFormulaModal = true
}
},
openFontModal () {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'font', detent: 'medium', uuid: this.field.uuid })
} else {
this.isShowFontModal = true
}
},
openConditionsModal () {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'conditions', detent: 'medium', uuid: this.field.uuid })
} else {
this.isShowConditionsModal = true
}
},
openDescriptionModal () {
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'description', detent: 'medium', uuid: this.field.uuid })
} else {
this.isShowDescriptionModal = true
}
},
buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
@@ -327,6 +423,19 @@ export default {
this.isSettingsFocus = false
}
},
onSettingsFocus () {
this.isSettingsFocus = true
if (this.isMobile) {
this.$nextTick(() => {
const dropdown = this.$refs.settingsDropdown
const padding = parseFloat(getComputedStyle(dropdown).paddingLeft) + 6
const left = dropdown.parentElement.getBoundingClientRect().right - dropdown.offsetWidth - padding
dropdown.style.right = left < 0 ? `${left}px` : ''
})
}
},
onNameFocus (e) {
this.selectedAreasRef.value = [this.area]
File diff suppressed because it is too large. Load diff
@@ -1,13 +1,17 @@
<template>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
:class="{ 'modal modal-open items-start !animate-none overflow-y-auto': !inline }"
>
<div
v-if="!inline"
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 px-6 mt-20 w-full max-w-xl'">
<div
v-if="!inline"
class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
>
<span class="modal-title">
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item) }}
</span>
@@ -183,6 +187,11 @@ export default {
type: Array,
required: false,
default: () => []
},
inline: {
type: Boolean,
required: false,
default: false
}
},
emits: ['close', 'save'],
@@ -10,7 +10,7 @@
:data-placeholder="placeholder"
:data-empty="isEmpty"
:style="{ minWidth }"
:class="[iconInline ? (isEmpty ? 'inline-block' : 'inline') : 'block', hideIcon ? 'focus:block' : '']"
:class="[iconInline ? (isEmpty ? 'inline-block' : 'inline') : 'block', hideIcon ? 'focus:block' : '', contenteditableClasses]"
class="peer relative inline-block outline-none before:pointer-events-none before:absolute before:left-0 before:top-0 before:select-none before:whitespace-pre before:text-neutral-400 before:content-[attr(data-placeholder)] before:opacity-0 data-[empty=true]:before:opacity-100"
@paste.prevent="onPaste"
@keydown.enter.prevent="blurContenteditable"
@@ -23,15 +23,15 @@
{{ value }}
</span>
<span
v-if="withButton"
v-if="withButton && (!isMobile || editableOnButton)"
class="relative inline"
:class="{ 'peer-focus:hidden': hideIcon, 'peer-focus:invisible': !hideIcon }"
>
<IconPencil
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle pl-1"
class="cursor-pointer flex-none align-middle pl-1"
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
:title="t('edit')"
:class="{ invisible: !editable, 'absolute top-1/2 -translate-y-1/2': !iconInline || floatIcon, 'inline align-bottom': iconInline, 'left-0': floatIcon }"
:class="{ invisible: !editable, 'opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100': !isMobile, 'absolute top-1/2 -translate-y-1/2': !iconInline || floatIcon, 'inline align-bottom': iconInline, 'left-0': floatIcon }"
:width="iconWidth + 4"
:stroke-width="iconStrokeWidth"
@click="clickEdit"
@@ -48,7 +48,7 @@ export default {
components: {
IconPencil
},
inject: ['t'],
inject: ['t', 'isMobile'],
props: {
modelValue: {
type: String,
@@ -109,6 +109,11 @@ export default {
type: Number,
required: false,
default: 2
},
contenteditableClasses: {
type: String,
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus', 'blur', 'click-contenteditable'],
@@ -1,105 +0,0 @@
<template>
<div class="flex space-x-2">
<Contenteditable
class="w-full block mr-6 document-preview-name"
:model-value="item.name"
:icon-width="16"
@update:model-value="onUpdateName"
/>
<ReplaceButton
v-if="withReplaceButton"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
:authenticity-token="authenticityToken"
:with-google-drive="withGoogleDrive"
:google-drive-file-id="item.google_drive_file_id"
@click.stop
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
/>
<button
v-if="withArrows"
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
style="width: 24px; height: 24px"
@click.stop="$emit('up', item)"
>
&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'],
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff. Show more