Compare commits

...
105 Commits
Author SHA1 Message Date
Alex Turchyn 6c416dd76e Merge from docusealco/wip 2026-11-02 14:44:26 +02:00
Pete Matsyburka 1cc4ca6251 adjust events log 2026-11-02 12:28:21 +02:00
Pete Matsyburka a27423c845 update gem 2026-11-02 11:19:14 +02:00
Pete Matsyburka 2692b8a1b5 add index 2026-11-02 10:18:16 +02:00
Pete Matsyburka 7a75a6c887 optimize query 2026-11-02 09:48:37 +02:00
Pete Matsyburka 236b882558 update security.md 2026-11-01 13:42:44 +02:00
Pete Matsyburka 7ee648dacb add index 2026-11-01 12:38:59 +02:00
Pete Matsyburka 9bfa6f7f4a fix rubocop 2026-10-30 11:03:27 +02:00
Pete Matsyburka f9c12b9d78 mobile fixes 2026-10-29 21:24:07 +02:00
Pete Matsyburka 0a3f182e70 fix timezone 2026-10-29 10:51:45 +02:00
Pete Matsyburka 9c5c1f47f8 update gems 2026-10-29 10:36:11 +02:00
Pete Matsyburka 14c0ad2d16 add json log formatter 2026-10-29 09:59:21 +02:00
Pete Matsyburka 6736d8fe68 mobile fixes 2026-10-28 19:01:18 +02:00
Pete Matsyburka 4ecd830587 android fixes 2026-10-28 15:35:43 +02:00
Alex Turchyn a0ecc8e823 support the android app 2026-10-28 10:47:26 +02:00
Pete Matsyburka 8334b612a8 fix enable shared link 2026-10-28 09:33:46 +02:00
Pete Matsyburka 349c0b0537 optimize query 2026-10-27 15:44:08 +02:00
Pete Matsyburka 2af09afc66 add lang 2026-10-27 15:31:15 +02:00
Pete Matsyburka e22a3d9ef3 use uuid v7 2026-10-27 11:24:42 +02:00
Pete Matsyburka d3c92cecfa carry lang param 2026-10-26 17:57:03 +02:00
Pete Matsyburka af4672084d optimize query 2026-10-26 17:14:45 +02:00
Pete Matsyburka b7103cf010 cleanup 2026-10-26 16:33:55 +02:00
Alex Turchyn 13a1835c00 Merge from docusealco/wip 2026-10-26 12:27:46 +02:00
Pete Matsyburka c35407d7a9 cleanup routes 2026-10-25 21:43:38 +02:00
Pete Matsyburka e55ccc0b76 update dep 2026-10-25 16:09:58 +02:00
Pete Matsyburka b8d00870b4 optimize query 2026-10-25 15:02:22 +02:00
Pete Matsyburka 5413fbc248 add template_id to submitters 2026-10-25 09:39:47 +02:00
Pete Matsyburka 2d2eb7ce4d ruby 4.0.9 2026-10-23 23:42:14 +03:00
Pete Matsyburka ed2ddbf74e remove legacy route 2026-10-23 11:10:14 +03:00
Pete Matsyburka b99e5d1262 adjust autosave 2026-10-23 10:02:32 +03:00
Pete Matsyburka 87f6878eda fix brakeman 2026-10-23 08:29:39 +03:00
Pete Matsyburka 99b815eb4b fix generation 2026-10-23 08:25:07 +03:00
Pete Matsyburka e40a29b9ab update certs 2026-10-22 18:47:09 +03:00
Pete Matsyburka 151890bb72 add autosave config 2026-10-22 17:17:47 +03:00
Pete Matsyburka 78edd87cc1 add main tag 2026-10-22 15:01:54 +03:00
Pete Matsyburka 88c99a2b35 fix turbo morph 2026-10-22 14:48:03 +03:00
Pete Matsyburka 282b8cfa73 fix search 2026-10-22 11:02:24 +03:00
Pete Matsyburka e9888c7466 add validation 2026-10-21 15:41:53 +03:00
Pete Matsyburka 0647befb3a escape email variables 2026-10-21 09:54:29 +03:00
Pete Matsyburka 85aa3d0451 mask email field value 2026-10-21 09:35:38 +03:00
Pete Matsyburka 6d2b3bb654 adjust test webhook 2026-10-20 20:15:37 +03:00
Pete Matsyburka b394854e9d adjust send form 2026-10-20 09:50:21 +03:00
Pete Matsyburka 2e586e930e adjust signature validation 2026-10-19 19:03:26 +03:00
Alex Turchyn 8f8f69f0bc Merge from docusealco/wip 2026-10-19 14:57:12 +03:00
Pete Matsyburka 04663d55eb update deps 2026-10-19 11:10:48 +03:00
Pete Matsyburka 93d0b935e2 fix dynamic document 2026-10-17 13:17:52 +03:00
Pete Matsyburka 829b2b2855 disable jemalloc 2026-10-17 10:42:20 +03:00
Alex Turchyn cf6de444f4 disable the review form for native apps 2026-10-16 23:22:32 +03:00
Pete Matsyburka 126f5d58a2 convert docx 2026-10-16 20:18:10 +03:00
Pete Matsyburka 09b93067f4 fix close modal 2026-10-16 19:56:36 +03:00
Pete Matsyburka f14458f238 fix generate 2026-10-16 19:56:36 +03:00
Pete Matsyburka 445139f05c adjust mobile nav 2026-10-16 19:56:36 +03:00
Pete Matsyburka e5e4965f05 adjust kba 2026-10-16 19:56:36 +03:00
Pete Matsyburka 1c8f7b0316 use jemalloc 2026-10-15 15:28:54 +03:00
Alex Turchyn 9fe3656652 fix icons shrinking 2026-10-14 08:11:45 +03:00
Pete Matsyburka 17ae46bd61 make remember signature permanent 2026-10-13 16:45:42 +03:00
Pete Matsyburka a036483938 tenants test flag 2026-10-13 09:23:38 +03:00
Alex Turchyn 3c65b0e4ba Merge from docusealco/wip 2026-10-12 15:29:17 +03:00
Pete Matsyburka ced5ab67ab adjust generate previews 2026-10-12 09:33:42 +03:00
Pete Matsyburka 28c4177128 adjsut detailed send button 2026-10-11 17:52:54 +03:00
Pete Matsyburka 5d86a08b5f update send modal 2026-10-10 15:57:08 +03:00
Pete Matsyburka a69fb81429 fix multiselect 2026-10-09 12:57:12 +03:00
Pete Matsyburka e6ac3380d4 dynamic editor image drag 2026-10-09 12:25:21 +03:00
Pete Matsyburka 3a535e7112 adjust date timezone 2026-10-08 16:06:41 +03:00
Pete Matsyburka 7464f9cfad download event log 2026-10-08 10:04:12 +03:00
Pete Matsyburka 58e24ef758 update submitter slug 2026-10-08 10:04:01 +03:00
Pete Matsyburka c1d809f989 update demo 2026-10-07 09:00:58 +03:00
Pete Matsyburka 80b24f0e2a show signature when completed 2026-10-06 12:12:17 +03:00
Pete Matsyburka 2e253904a3 invalid email error 2026-10-06 11:01:04 +03:00
Pete Matsyburka a7f381caf0 update gem 2026-10-05 12:31:08 +03:00
Alex Turchyn c6a7555f54 Merge from docusealco/wip 2026-10-05 12:27:31 +03:00
Pete Matsyburka 0772880bbc download dynamic document 2026-10-05 11:53:57 +03:00
Pete Matsyburka 73339d8cd9 fix editor undo 2026-10-05 11:45:16 +03:00
Pete Matsyburka 25504e8cb1 update routes 2026-10-04 11:04:14 +03:00
Pete Matsyburka 63a874528a improve text selection 2026-10-03 20:07:24 +03:00
Pete Matsyburka c1c99621ff sticky table tools 2026-10-03 18:35:29 +03:00
Pete Matsyburka 5c168f4a29 add page number style 2026-10-03 14:57:11 +03:00
Pete Matsyburka bef825c4e0 optimize add list style 2026-10-03 14:05:38 +03:00
Pete Matsyburka a990d22831 remove pinch to zoom 2026-10-03 14:05:38 +03:00
Pete Matsyburka 1687a372ae adjust editor 2026-10-03 14:05:38 +03:00
Alex Turchyn 9b267d4a07 upload inserted images as attachments 2026-10-03 14:05:38 +03:00
Pete Matsyburka 2189c2e9fe adjust fonts 2026-10-03 14:05:38 +03:00
Alex Turchyn 1e43fe8969 use Word line spacing units and join adjacent lists 2026-10-03 14:05:38 +03:00
Alex Turchyn f77a716658 remove spacing between list items like Word 2026-10-03 14:05:38 +03:00
Alex Turchyn a70ac571e3 simplify dynamic editor code 2026-10-03 14:05:38 +03:00
Alex Turchyn 4f5e72f983 fix table editing 2026-10-03 14:05:38 +03:00
Alex Turchyn 8f47ec6d4e add accessible names to table size counters 2026-10-03 14:05:38 +03:00
Alex Turchyn 9e1bbf2944 fix image resize handles 2026-10-03 14:05:38 +03:00
Alex Turchyn aa7d6e9aaa add pinch zoom and fix touch editing 2026-10-03 14:05:38 +03:00
Alex Turchyn 57c8be33ce adjust dynamic editor menus on mobile 2026-10-03 14:05:38 +03:00
Alex Turchyn ed7109bfc6 store list styles in dynamic document 2026-10-03 14:05:38 +03:00
Alex Turchyn 9e2590489b improve insert menu accessibility 2026-10-03 14:05:38 +03:00
Alex Turchyn d793f00b37 improve dynamic editor 2026-10-03 14:05:38 +03:00
Pete Matsyburka 5896c9a2aa strip edited image 2026-10-03 09:10:31 +03:00
Pete Matsyburka 78b0ce9828 add iban validation 2026-10-01 19:45:55 +03:00
Pete Matsyburka e187644716 refactor otp 2026-10-01 08:57:19 +03:00
Pete Matsyburka a847e7aa9c custom otp email 2026-09-30 19:56:12 +03:00
Pete Matsyburka 0905e7d215 reduce limit 2026-09-30 19:23:58 +03:00
Pete Matsyburka 91147d2f48 fix verify pdf 2026-09-30 19:22:41 +03:00
Pete Matsyburka acce6195b0 rescue validation errors 2026-09-30 13:38:47 +03:00
Pete Matsyburka ce9ab691ec fix document metadata 2026-09-30 12:57:48 +03:00
Pete Matsyburka 4190544edc use ivar 2026-09-28 23:28:13 +03:00
Pete Matsyburka db8e5efbd5 adjust i18n 2026-09-28 17:54:47 +03:00
Pete Matsyburka a5c6f4e40c update gem 2026-09-28 15:32:26 +03:00
Pete Matsyburka 7537d05d1f fix builder resize 2026-09-28 15:30:09 +03:00
195 changed files with 4359 additions and 1053 deletions

No files matched your search

+4 -4
View File
@@ -12,7 +12,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.5 ruby-version: 4.0.9
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -37,7 +37,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.5 ruby-version: 4.0.9
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -89,7 +89,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.5 ruby-version: 4.0.9
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -113,7 +113,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
container: container:
image: ruby:4.0.5-alpine image: ruby:4.0.9-alpine
services: services:
postgres: postgres:
+8 -5
View File
@@ -1,4 +1,4 @@
FROM ruby:4.0.5-alpine AS download FROM ruby:4.0.9-alpine AS download
WORKDIR /fonts WORKDIR /fonts
@@ -17,7 +17,7 @@ RUN apk --no-cache add wget unzip && \
mkdir -p /pdfium-linux && \ mkdir -p /pdfium-linux && \
unzip -q pdfium-linux.zip -d /pdfium-linux unzip -q pdfium-linux.zip -d /pdfium-linux
FROM ruby:4.0.5-alpine AS webpack FROM ruby:4.0.9-alpine AS webpack
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -44,7 +44,7 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:4.0.5-alpine AS app FROM ruby:4.0.9-alpine AS app
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test" ENV BUNDLE_WITHOUT="development:test"
@@ -52,7 +52,8 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app WORKDIR /app
RUN apk add --no-cache libpq vips redis onnxruntime leptonica && \ RUN apk upgrade --no-cache ca-certificates-bundle && \
apk add --no-cache libpq vips redis onnxruntime leptonica jemalloc font-liberation font-carlito && \
rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
@@ -91,7 +92,7 @@ COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
RUN mkdir -p /app/public/fonts && ln -s /fonts/DancingScript-Regular.otf /app/public/fonts/ && \ RUN mkdir -p /app/public/fonts && ln -s /fonts/DancingScript-Regular.otf /app/public/fonts/ && \
mkdir -p /usr/share/fonts/noto && ln -s /fonts/GoNotoKurrent-Regular.ttf /usr/share/fonts/noto/ && ln -s /fonts/GoNotoKurrent-Bold.ttf /usr/share/fonts/noto/ && fc-cache -f && \ mkdir -p /usr/share/fonts/noto/bold && ln -s /fonts/GoNotoKurrent-Regular.ttf /usr/share/fonts/noto/ && ln -s /fonts/GoNotoKurrent-Bold.ttf /usr/share/fonts/noto/bold/ && fc-cache -f && \
bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \ bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \
chown -R docuseal:docuseal /app/tmp/cache chown -R docuseal:docuseal /app/tmp/cache
@@ -100,6 +101,8 @@ ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal ENV WORKDIR=/data/docuseal
ENV VIPS_MAX_COORD=17000 ENV VIPS_MAX_COORD=17000
ENV VIPS_BLOCK_UNTRUSTED=1 ENV VIPS_BLOCK_UNTRUSTED=1
# ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
ENV MALLOC_CONF=dirty_decay_ms:1000,narenas:2,background_thread:true
EXPOSE 3000 EXPOSE 3000
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"] CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
+2 -1
View File
@@ -2,7 +2,7 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '4.0.5' ruby '4.0.9'
gem 'addressable' gem 'addressable'
gem 'arabic-letter-connector', require: false gem 'arabic-letter-connector', require: false
@@ -45,6 +45,7 @@ gem 'trilogy', require: false
gem 'turbo-rails' gem 'turbo-rails'
gem 'twitter_cldr', require: false gem 'twitter_cldr', require: false
gem 'tzinfo-data' gem 'tzinfo-data'
gem 'wordcraft', require: false, group: :ce
gem 'xlsxtream', require: false gem 'xlsxtream', require: false
group :development, :test do group :development, :test do
+95 -84
View File
@@ -3,29 +3,29 @@ GEM
specs: specs:
action_text-trix (2.1.19) action_text-trix (2.1.19)
railties railties
actioncable (8.1.3.1) actioncable (8.1.4)
actionpack (= 8.1.3.1) actionpack (= 8.1.4)
activesupport (= 8.1.3.1) activesupport (= 8.1.4)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.1.3.1) actionmailbox (8.1.4)
actionpack (= 8.1.3.1) actionpack (= 8.1.4)
activejob (= 8.1.3.1) activejob (= 8.1.4)
activerecord (= 8.1.3.1) activerecord (= 8.1.4)
activestorage (= 8.1.3.1) activestorage (= 8.1.4)
activesupport (= 8.1.3.1) activesupport (= 8.1.4)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.1.3.1) actionmailer (8.1.4)
actionpack (= 8.1.3.1) actionpack (= 8.1.4)
actionview (= 8.1.3.1) actionview (= 8.1.4)
activejob (= 8.1.3.1) activejob (= 8.1.4)
activesupport (= 8.1.3.1) activesupport (= 8.1.4)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.1.3.1) actionpack (8.1.4)
actionview (= 8.1.3.1) actionview (= 8.1.4)
activesupport (= 8.1.3.1) activesupport (= 8.1.4)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
@@ -33,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.1.3.1) actiontext (8.1.4)
action_text-trix (~> 2.1.15) action_text-trix (~> 2.1.15)
actionpack (= 8.1.3.1) actionpack (= 8.1.4)
activerecord (= 8.1.3.1) activerecord (= 8.1.4)
activestorage (= 8.1.3.1) activestorage (= 8.1.4)
activesupport (= 8.1.3.1) activesupport (= 8.1.4)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.1.3.1) actionview (8.1.4)
activesupport (= 8.1.3.1) activesupport (= 8.1.4)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (8.1.3.1) activejob (8.1.4)
activesupport (= 8.1.3.1) activesupport (= 8.1.4)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.1.3.1) activemodel (8.1.4)
activesupport (= 8.1.3.1) activesupport (= 8.1.4)
activerecord (8.1.3.1) activerecord (8.1.4)
activemodel (= 8.1.3.1) activemodel (= 8.1.4)
activesupport (= 8.1.3.1) activesupport (= 8.1.4)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.1.3.1) activestorage (8.1.4)
actionpack (= 8.1.3.1) actionpack (= 8.1.4)
activejob (= 8.1.3.1) activejob (= 8.1.4)
activerecord (= 8.1.3.1) activerecord (= 8.1.4)
activesupport (= 8.1.3.1) activesupport (= 8.1.4)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.1.3.1) activesupport (8.1.4)
base64 base64
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -116,7 +116,7 @@ GEM
erubi (~> 1.4) erubi (~> 1.4)
parser (>= 2.4) parser (>= 2.4)
smart_properties smart_properties
bigdecimal (4.1.2) bigdecimal (4.1.3)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.23.0) bootsnap (1.23.0)
msgpack (~> 1.2) msgpack (~> 1.2)
@@ -149,14 +149,14 @@ GEM
crack (1.0.1) crack (1.0.1)
bigdecimal bigdecimal
rexml rexml
crass (1.0.7) crass (1.0.8)
csv (3.3.5) csv (3.3.5)
csv-safe (3.3.1) csv-safe (3.3.1)
csv (~> 3.0) csv (~> 3.0)
cuprite (0.17) cuprite (0.17)
capybara (~> 3.0) capybara (~> 3.0)
ferrum (~> 0.17.0) ferrum (~> 0.17.0)
date (3.5.1) date (3.6.0)
debug (1.11.1) debug (1.11.1)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
@@ -179,7 +179,7 @@ GEM
dotenv (3.2.0) dotenv (3.2.0)
drb (2.2.3) drb (2.2.3)
email_typo (0.2.3) email_typo (0.2.3)
erb (6.0.6) erb (6.0.7)
erb_lint (0.9.0) erb_lint (0.9.0)
activesupport activesupport
better_html (>= 2.0.1) better_html (>= 2.0.1)
@@ -195,8 +195,8 @@ GEM
railties (>= 6.1.0) railties (>= 6.1.0)
faker (3.6.1) faker (3.6.1)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.14.3) faraday (2.15.1)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.6)
json json
logger logger
faraday-follow_redirects (0.5.0) faraday-follow_redirects (0.5.0)
@@ -264,14 +264,14 @@ GEM
strscan (>= 3.1.2) strscan (>= 3.1.2)
i18n (1.15.2) i18n (1.15.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
io-console (0.8.2) io-console (0.9.5)
irb (1.18.0) irb (1.18.0)
pp (>= 0.6.0) pp (>= 0.6.0)
prism (>= 1.3.0) prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jmespath (1.6.2) jmespath (1.6.2)
json (2.21.2) json (3.1.0)
jwt (3.2.0) jwt (3.2.0)
base64 base64
language_server-protocol (3.17.0.5) language_server-protocol (3.17.0.5)
@@ -310,17 +310,17 @@ GEM
drb (~> 2.0) drb (~> 2.0)
prism (~> 1.5) prism (~> 1.5)
msgpack (1.8.4) msgpack (1.8.4)
multi_json (1.19.1) multi_json (1.21.2)
net-http (0.9.1) net-http (0.9.1)
uri (>= 0.11.1) uri (>= 0.11.1)
net-imap (0.6.6) net-imap (0.6.8)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
net-protocol net-protocol
net-protocol (0.2.2) net-protocol (0.4.0)
timeout timeout
net-smtp (0.5.1) net-smtp (0.5.2)
net-protocol net-protocol
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.19.4-aarch64-linux-gnu) nokogiri (1.19.4-aarch64-linux-gnu)
@@ -361,7 +361,7 @@ GEM
prettyprint prettyprint
pretender (1.0.0) pretender (1.0.0)
actionpack (>= 7.2) actionpack (>= 7.2)
prettyprint (0.2.0) prettyprint (0.2.1)
prism (1.9.0) prism (1.9.0)
pry (0.16.0) pry (0.16.0)
coderay (~> 1.1) coderay (~> 1.1)
@@ -373,30 +373,30 @@ GEM
puma (8.0.2) puma (8.0.2)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.6) rack (3.2.7)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (2.1.2) rack-session (2.1.2)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.3.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.3.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rails (8.1.3.1) rails (8.1.4)
actioncable (= 8.1.3.1) actioncable (= 8.1.4)
actionmailbox (= 8.1.3.1) actionmailbox (= 8.1.4)
actionmailer (= 8.1.3.1) actionmailer (= 8.1.4)
actionpack (= 8.1.3.1) actionpack (= 8.1.4)
actiontext (= 8.1.3.1) actiontext (= 8.1.4)
actionview (= 8.1.3.1) actionview (= 8.1.4)
activejob (= 8.1.3.1) activejob (= 8.1.4)
activemodel (= 8.1.3.1) activemodel (= 8.1.4)
activerecord (= 8.1.3.1) activerecord (= 8.1.4)
activestorage (= 8.1.3.1) activestorage (= 8.1.4)
activesupport (= 8.1.3.1) activesupport (= 8.1.4)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.1.3.1) railties (= 8.1.4)
rails-dom-testing (2.3.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@@ -407,9 +407,9 @@ GEM
rails-i18n (8.1.0) rails-i18n (8.1.0)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9) railties (>= 8.0.0, < 9)
railties (8.1.3.1) railties (8.1.4)
actionpack (= 8.1.3.1) actionpack (= 8.1.4)
activesupport (= 8.1.3.1) activesupport (= 8.1.4)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
@@ -418,11 +418,11 @@ GEM
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.4.2) rake (13.4.2)
rbs (4.1.0) rbs (4.2.0)
logger logger
prism (>= 1.6.0) prism (>= 1.6.0)
tsort tsort
rdoc (8.0.0) rdoc (8.1.0)
erb erb
prism (>= 1.6.0) prism (>= 1.6.0)
rbs (>= 4.0.0) rbs (>= 4.0.0)
@@ -430,7 +430,7 @@ GEM
redis-client (0.29.0) redis-client (0.29.0)
connection_pool connection_pool
regexp_parser (2.11.3) regexp_parser (2.11.3)
reline (0.6.3) reline (0.7.0)
io-console (~> 0.5) io-console (~> 0.5)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
@@ -466,14 +466,14 @@ GEM
rspec-mocks (>= 3.13.0, < 5.0.0) rspec-mocks (>= 3.13.0, < 5.0.0)
rspec-support (>= 3.13.0, < 5.0.0) rspec-support (>= 3.13.0, < 5.0.0)
rspec-support (3.13.7) rspec-support (3.13.7)
rubocop (1.86.0) rubocop (1.92.1)
json (~> 2.3) json (>= 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
parallel (~> 1.10) parallel (>= 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 2.11.3, < 3.0)
rubocop-ast (>= 1.49.0, < 2.0) rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
@@ -497,7 +497,7 @@ GEM
ruby-vips (2.3.0) ruby-vips (2.3.0)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
rubyzip (3.2.2) rubyzip (3.7.0)
securerandom (0.4.1) securerandom (0.4.1)
semantic_range (3.1.1) semantic_range (3.1.1)
shakapacker (9.7.0) shakapacker (9.7.0)
@@ -524,11 +524,11 @@ GEM
simplecov-html (0.13.2) simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
smart_properties (1.17.0) smart_properties (1.17.0)
sqlite3 (2.9.5-aarch64-linux-gnu) sqlite3 (2.9.6-aarch64-linux-gnu)
sqlite3 (2.9.5-aarch64-linux-musl) sqlite3 (2.9.6-aarch64-linux-musl)
sqlite3 (2.9.5-arm64-darwin) sqlite3 (2.9.6-arm64-darwin)
sqlite3 (2.9.5-x86_64-linux-gnu) sqlite3 (2.9.6-x86_64-linux-gnu)
sqlite3 (2.9.5-x86_64-linux-musl) sqlite3 (2.9.6-x86_64-linux-musl)
strip_attributes (2.0.1) strip_attributes (2.0.1)
activemodel (>= 3.0, < 9.0) activemodel (>= 3.0, < 9.0)
strscan (3.1.8) strscan (3.1.8)
@@ -555,7 +555,7 @@ GEM
unicode-emoji (~> 4.1) unicode-emoji (~> 4.1)
unicode-emoji (4.2.0) unicode-emoji (4.2.0)
uniform_notifier (1.18.0) uniform_notifier (1.18.0)
uri (1.1.1) uri (1.1.2)
useragent (0.16.11) useragent (0.16.11)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
@@ -572,12 +572,22 @@ GEM
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
wordcraft (0.11.0-aarch64-linux-gnu)
ffi (>= 1.15)
wordcraft (0.11.0-aarch64-linux-musl)
ffi (>= 1.15)
wordcraft (0.11.0-arm64-darwin)
ffi (>= 1.15)
wordcraft (0.11.0-x86_64-linux-gnu)
ffi (>= 1.15)
wordcraft (0.11.0-x86_64-linux-musl)
ffi (>= 1.15)
xlsxtream (3.1.0) xlsxtream (3.1.0)
zip_kit (>= 6.2, < 7) zip_kit (>= 6.2, < 7)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
yaml (0.4.0) yaml (0.4.0)
zeitwerk (2.8.2) zeitwerk (2.8.3)
zip_kit (6.3.4) zip_kit (6.3.4)
PLATFORMS PLATFORMS
@@ -650,10 +660,11 @@ DEPENDENCIES
tzinfo-data tzinfo-data
web-console web-console
webmock webmock
wordcraft
xlsxtream xlsxtream
RUBY VERSION RUBY VERSION
ruby 4.0.5 ruby 4.0.9
BUNDLED WITH BUNDLED WITH
4.0.3 4.0.3
+1 -1
View File
@@ -40,7 +40,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- Files storage on disk or AWS S3, Google Storage, Azure Cloud - Files storage on disk or AWS S3, Google Storage, Azure Cloud
- Automatic PDF eSignature - Automatic PDF eSignature
- PDF signature verification - PDF signature verification
- Users management - User management
- Mobile-optimized - Mobile-optimized
- 7 UI languages with signing available in 14 languages - 7 UI languages with signing available in 14 languages
- API and Webhooks for integrations - API and Webhooks for integrations
+1
View File
@@ -9,6 +9,7 @@ Out of scope vulnerabilities:
- CSRF - CSRF
- DNSSEC, CAA, CSP headers - DNSSEC, CAA, CSP headers
- DNS or email security related - DNS or email security related
- SSRF
- Rate Limiting - Rate Limiting
Note: We reserve the right to classify any reported vulnerability as out of scope for the bug bounty program. Note: We reserve the right to classify any reported vulnerability as out of scope for the bug bounty program.
+1 -2
View File
@@ -54,14 +54,13 @@ class AccountsController < ApplicationController
email: true_user.email.sub('@', '+removed@')) email: true_user.email.sub('@', '+removed@'))
true_user.account.update!(archived_at: Time.current) true_user.account.update!(archived_at: Time.current)
# rubocop:disable Layout/LineLength # rubocop:disable-next Layout/LineLength
render turbo_stream: turbo_stream.replace( render turbo_stream: turbo_stream.replace(
:account_delete_button, :account_delete_button,
html: helpers.tag.p(I18n.t('your_account_will_be_permanently_deleted_within_2_months_please_contact_us_if_you_want_to_keep_your_account')) + html: helpers.tag.p(I18n.t('your_account_will_be_permanently_deleted_within_2_months_please_contact_us_if_you_want_to_keep_your_account')) +
helpers.tag.br + helpers.tag.br +
helpers.tag.p(I18n.t('your_email_address_has_been_released_immediately_you_can_now_be_added_to_your_company_team_without_waiting_for_the_deletion_period_to_end')) helpers.tag.p(I18n.t('your_email_address_has_been_released_immediately_you_can_now_be_added_to_your_company_team_without_waiting_for_the_deletion_period_to_end'))
) )
# rubocop:enable Layout/LineLength
end end
private private
@@ -11,7 +11,7 @@ module Api
before_action :set_noindex_headers before_action :set_noindex_headers
before_action :set_security_headers before_action :set_security_headers
# rubocop:disable Metrics # rubocop:disable-next Metrics
def show def show
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid]) blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
@@ -53,7 +53,6 @@ module Api
end end
end end
end end
# rubocop:enable Metrics
private private
@@ -1,53 +0,0 @@
# frozen_string_literal: true
module Api
class ActiveStorageBlobsProxyLegacyController < ApiBaseController
include ActiveStorage::Streaming
skip_before_action :authenticate_user!
skip_authorization_check
before_action :set_cors_headers
before_action :set_noindex_headers
before_action :set_security_headers
# rubocop:disable Metrics
def show
Rollbar.info('Blob legacy') if defined?(Rollbar)
blob = ActiveStorage::Blob.find_signed(params[:signed_blob_id] || params[:signed_id])
return head :not_found unless blob
if FilenameUtils.dangerous_extension(blob.filename)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
end
is_permitted = blob.attachments.any? do |a|
(current_user && a.record.account.id == current_user.account_id) ||
a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } ||
a.name == 'logo'
end
unless is_permitted
Rollbar.error("Blob account not found: #{blob.id}") if defined?(Rollbar)
return render json: { error: 'Not authenticated' }, status: :unauthorized
end
if request.headers['Range'].present?
send_blob_byte_range_data blob, request.headers['Range']
else
http_cache_forever public: true do
response.headers['Accept-Ranges'] = 'bytes'
response.headers['Content-Length'] = blob.byte_size.to_s
send_blob_stream blob, disposition: params[:disposition]
end
end
end
# rubocop:enable Metrics
end
end
@@ -1,74 +0,0 @@
# frozen_string_literal: true
module Api
class AttachmentsController < ActionController::API
include ActionController::Cookies
include ActiveStorage::SetCurrent
COOKIE_STORE_LIMIT = 10
def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
unless can_upload?(@submitter)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
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
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
end
@@ -98,7 +98,7 @@ module Api
render json: build_create_json(submissions) render json: build_create_json(submissions)
rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError, rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError,
DownloadUtils::UnableToDownload => e Submitters::SubmitValues::ValidationError, DownloadUtils::UnableToDownload => e
Rollbar.warning(e) if defined?(Rollbar) Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content render json: { error: e.message }, status: :unprocessable_content
@@ -1,15 +0,0 @@
# frozen_string_literal: true
module Api
class SubmitterEmailClicksController < ActionController::API
def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
if params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email')
SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request)
end
render json: {}
end
end
end
+22 -7
View File
@@ -36,7 +36,7 @@ module Api
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true, params:) render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true, params:)
end end
# rubocop:disable Metrics/MethodLength # rubocop:disable-next Metrics/MethodLength
def update def update
submission = @submitter.submission submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name'] role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
@@ -78,12 +78,12 @@ module Api
render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_urls: true, render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_urls: true,
with_events: false, params:) with_events: false, params:)
rescue Submitters::NormalizeValues::BaseError, DownloadUtils::UnableToDownload => e rescue Submitters::NormalizeValues::BaseError, Submitters::SubmitValues::ValidationError,
DownloadUtils::UnableToDownload => e
Rollbar.warning(e) if defined?(Rollbar) Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content render json: { error: e.message }, status: :unprocessable_content
end end
# rubocop:enable Metrics/MethodLength
def submitter_params def submitter_params
submitter_params = params.key?(:submitter) ? params.require(:submitter) : params submitter_params = params.key?(:submitter) ? params.require(:submitter) : params
@@ -178,14 +178,29 @@ module Api
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present? submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
submitters = submitters.where(slug: params[:slug]) if params[:slug].present? submitters = submitters.where(slug: params[:slug]) if params[:slug].present?
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present? submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
submitters = filter_submitters_by_template_id(submitters, params) if params[:template_id].present?
if params[:template_id].present?
submitters = submitters.joins(:submission).where(submissions: { template_id: params[:template_id] })
end
maybe_filter_by_completed_at(submitters, params) maybe_filter_by_completed_at(submitters, params)
end end
def filter_submitters_by_template_id(submitters, params)
return submitters.where(template_id: params[:template_id]) if params[:template_id].is_a?(Array)
template = Template.find_by(id: params[:template_id])
return submitters.none if template.nil? || !can?(:read, template)
if template.account_id == current_user.account_id &&
params[:completed_after].blank? && params[:completed_before].blank?
account_id_bind = Submitter.predicate_builder.build_bind_attribute('account_id', current_user.account_id)
submitters = submitters.unscope(where: :account_id)
.where((Submitter.arel_table[:account_id] + 0).eq(account_id_bind))
end
submitters.where(template_id: template.id)
end
def assign_external_id(submitter, attrs) def assign_external_id(submitter, attrs)
submitter.external_id = attrs[:application_key] if attrs.key?(:application_key) submitter.external_id = attrs[:application_key] if attrs.key?(:application_key)
submitter.external_id = attrs[:external_id] if attrs.key?(:external_id) submitter.external_id = attrs[:external_id] if attrs.key?(:external_id)
+6 -1
View File
@@ -118,7 +118,12 @@ class ApplicationController < ActionController::Base
end end
def sign_in_for_demo def sign_in_for_demo
sign_in(User.active.order('random()').take) unless signed_in? return if signed_in?
account = Account.create!(name: "Demo #{SecureRandom.hex(3)}")
sign_in(account.users.create!(first_name: 'Demo', last_name: 'User',
email: "#{SecureRandom.hex}@docuseal.com", password: SecureRandom.hex))
end end
def current_account def current_account
@@ -30,7 +30,7 @@ module Mcp
} }
}.freeze }.freeze
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
def call def call
account = current_user.account account = current_user.account
@@ -87,6 +87,5 @@ module Mcp
edit_url: edit_template_url(@template) edit_url: edit_template_url(@template)
) )
end end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
end end
end end
@@ -66,7 +66,7 @@ module Mcp
} }
}.freeze }.freeze
# rubocop:disable Metrics # rubocop:disable-next Metrics
def call def call
@template = Template.accessible_by(current_ability).find(mcp_params['template_id']) @template = Template.accessible_by(current_ability).find(mcp_params['template_id'])
@@ -115,6 +115,5 @@ module Mcp
rescue Submissions::CreateFromSubmitters::BaseError => e rescue Submissions::CreateFromSubmitters::BaseError => e
render_tool_error(e.message) render_tool_error(e.message)
end end
# rubocop:enable Metrics
end end
end end
+1 -2
View File
@@ -1,9 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class PasswordsController < Devise::PasswordsController class PasswordsController < Devise::PasswordsController
# rubocop:disable Rails/LexicallyScopedActionFilter # rubocop:disable-next Rails/LexicallyScopedActionFilter
skip_before_action :require_no_authentication, only: %i[edit update] skip_before_action :require_no_authentication, only: %i[edit update]
# rubocop:enable Rails/LexicallyScopedActionFilter
around_action :with_browser_locale around_action :with_browser_locale
@@ -53,7 +53,7 @@ class PreviewDocumentPageController < ActionController::API
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |file| File.open(file_path, File::RDWR | File::CREAT, 0o644) do |file|
file.flock(File::LOCK_EX) file.flock(File::LOCK_EX)
# rubocop:disable Style/ZeroLengthPredicate # rubocop:disable-next Style/ZeroLengthPredicate
if file.size.zero? if file.size.zero?
cleanup_stale_tempfiles cleanup_stale_tempfiles
@@ -63,7 +63,6 @@ class PreviewDocumentPageController < ActionController::API
else else
FileUtils.touch(file_path) FileUtils.touch(file_path)
end end
# rubocop:enable Style/ZeroLengthPredicate
file.flock(File::LOCK_UN) file.flock(File::LOCK_UN)
@@ -14,7 +14,7 @@ class SendSubmissionEmailController < ApplicationController
template = Template.find_by!(slug: params[:template_slug]) template = Template.find_by!(slug: params[:template_slug])
@submitter = @submitter =
Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase) Submitter.completed.where(template_id: template.id).find_by(email: params[:email].to_s.downcase)
elsif params[:submission_slug] elsif params[:submission_slug]
submission = Submission.find_by!(slug: params[:submission_slug]) submission = Submission.find_by!(slug: params[:submission_slug])
+10 -6
View File
@@ -34,7 +34,7 @@ class StartFormController < ApplicationController
@submitter = find_or_initialize_submitter(@template, submitter_params) @submitter = find_or_initialize_submitter(@template, submitter_params)
if @submitter.completed_at? if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, submitter_params.compact_blank) redirect_to start_form_completed_path(@template.slug, submitter_params.compact_blank.merge(lang:))
else else
if Templates.filter_undefined_submitters(@template.submitters).size > 1 && @submitter.new_record? if Templates.filter_undefined_submitters(@template.submitters).size > 1 && @submitter.new_record?
@error_message = multiple_submitters_error_message @error_message = multiple_submitters_error_message
@@ -59,7 +59,7 @@ class StartFormController < ApplicationController
Submitters::StartForm.assign_start_form_cookie(@submitter, request) Submitters::StartForm.assign_start_form_cookie(@submitter, request)
redirect_to submit_form_path(@submitter.slug) redirect_to submit_form_path(@submitter.slug, lang:)
else else
render :show, status: :unprocessable_content render :show, status: :unprocessable_content
end end
@@ -80,7 +80,7 @@ class StartFormController < ApplicationController
raise ActionController::RoutingError, I18n.t('not_found') if required_params.any? { |_, v| v.blank? } || raise ActionController::RoutingError, I18n.t('not_found') if required_params.any? { |_, v| v.blank? } ||
required_params.except('name').compact_blank.blank? required_params.except('name').compact_blank.blank?
@submitter = Submitter.where(submission: @template.submissions) @submitter = Submitter.where(template_id: @template.id)
.where.not(completed_at: nil) .where.not(completed_at: nil)
.find_by!(required_params.except('name')) .find_by!(required_params.except('name'))
end end
@@ -150,7 +150,7 @@ class StartFormController < ApplicationController
return render :show, status: :unprocessable_content if submitter.errors.present? return render :show, status: :unprocessable_content if submitter.errors.present?
if Submitters::StartForm.verify_2fa_and_save_submitter(submitter, request, is_new_record: submitter.new_record?) if Submitters::StartForm.verify_2fa_and_save_submitter(submitter, request, is_new_record: submitter.new_record?)
redirect_to submit_form_path(submitter.slug) redirect_to submit_form_path(submitter.slug, lang:)
else else
if defined?(Rollbar) && template.preferences['shared_link_2fa'] != true if defined?(Rollbar) && template.preferences['shared_link_2fa'] != true
Rollbar.info("2FA link requested: #{submitter.id}") Rollbar.info("2FA link requested: #{submitter.id}")
@@ -167,10 +167,14 @@ class StartFormController < ApplicationController
render :error, status: :unprocessable_content render :error, status: :unprocessable_content
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
redirect_to start_form_path(template.slug, params: submitter_params.merge(email_verification: true)), redirect_to start_form_path(template.slug, lang:, params: submitter_params.merge(email_verification: true)),
alert: e.message alert: e.message
rescue RateLimit::LimitApproached rescue RateLimit::LimitApproached
redirect_to start_form_path(template.slug, params: submitter_params.merge(email_verification: true)), redirect_to start_form_path(template.slug, lang:, params: submitter_params.merge(email_verification: true)),
alert: I18n.t(:too_many_attempts) alert: I18n.t(:too_many_attempts)
end end
def lang
params[:lang].presence
end
end end
@@ -16,21 +16,21 @@ class StartFormEmail2faSendController < ApplicationController
.submitters.new(**submitter_params, account_id: @template.account_id) .submitters.new(**submitter_params, account_id: @template.account_id)
if @submitter.email.blank? if @submitter.email.blank?
return redirect_to start_form_path(@template.slug), alert: I18n.t(:provide_your_email_to_start) return redirect_to start_form_path(@template.slug, lang:), alert: I18n.t(:provide_your_email_to_start)
end end
Submitters.send_shared_link_email_verification_code(@submitter, request:) Submitters.send_shared_link_email_verification_code(@submitter, request:)
redir_params = { notice: I18n.t(:code_has_been_resent) } if params[:resend] redir_params = { notice: I18n.t(:code_has_been_resent) } if params[:resend]
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)), redirect_to start_form_path(@template.slug, lang:, params: submitter_params.merge(email_verification: true)),
**redir_params **redir_params
rescue Submitters::BouncedEmail => e rescue Submitters::BouncedEmail => e
@error_message = e.message @error_message = e.message
render 'start_form/error', status: :unprocessable_content render 'start_form/error', status: :unprocessable_content
rescue Submitters::UnableToSendCode => e rescue Submitters::UnableToSendCode => e
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)), redirect_to start_form_path(@template.slug, lang:, params: submitter_params.merge(email_verification: true)),
alert: e.message alert: e.message
end end
@@ -66,4 +66,8 @@ class StartFormEmail2faSendController < ApplicationController
attrs[:email] = Submissions.normalize_email(attrs[:email]) attrs[:email] = Submissions.normalize_email(attrs[:email])
end end
end end
def lang
params[:lang].presence
end
end end
@@ -66,6 +66,8 @@ class SubmissionsController < ApplicationController
create_submissions(@template, submissions_params, params) create_submissions(@template, submissions_params, params)
end end
raise Submissions::CreateFromSubmitters::BaseError, 'Invalid email address' if submissions.blank?
WebhookUrls.enqueue_events(submissions, 'submission.created') WebhookUrls.enqueue_events(submissions, 'submission.created')
Submissions.send_signature_requests(submissions) Submissions.send_signature_requests(submissions)
@@ -25,5 +25,7 @@ class SubmissionsExportController < ApplicationController
end end
end end
def new; end def new
render layout: turbo_native_android? ? 'plain' : true
end
end end
@@ -14,6 +14,6 @@ class SubmissionsFiltersController < ApplicationController
def show def show
return head :not_found unless ALLOWED_NAMES.include?(params[:name]) return head :not_found unless ALLOWED_NAMES.include?(params[:name])
render params[:name] render params[:name], layout: turbo_native_android? ? 'plain' : true
end end
end end
+7 -3
View File
@@ -20,7 +20,7 @@ class SubmitFormController < ApplicationController
return render :email_2fa unless Submitters::AuthorizedForForm.pass_email_2fa?(@submitter, request) return render :email_2fa unless Submitters::AuthorizedForForm.pass_email_2fa?(@submitter, request)
if @submitter.completed_at? || submission.completed_at? if @submitter.completed_at? || submission.completed_at?
return redirect_to submit_form_completed_path(@submitter.slug) return redirect_to submit_form_completed_path(@submitter.slug, lang:)
end end
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS) @form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
@@ -96,7 +96,7 @@ class SubmitFormController < ApplicationController
return if Submitters::AuthorizedForForm.call(@submitter, current_user, request) return if Submitters::AuthorizedForForm.call(@submitter, current_user, request)
redirect_to submit_form_path(params[:submit_form_slug]) redirect_to submit_form_path(params[:submit_form_slug], lang:)
end end
def success; end def success; end
@@ -114,7 +114,7 @@ class SubmitFormController < ApplicationController
def maybe_require_link_2fa def maybe_require_link_2fa
return if Submitters::AuthorizedForForm.pass_link_2fa?(@submitter, current_user, request) return if Submitters::AuthorizedForForm.pass_link_2fa?(@submitter, current_user, request)
redirect_to start_form_path(@submitter.submission.template.slug) redirect_to start_form_path(@submitter.submission.template.slug, lang:)
end end
def maybe_render_locked_page def maybe_render_locked_page
@@ -144,4 +144,8 @@ class SubmitFormController < ApplicationController
ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments) ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments)
.preload(:blob).index_by(&:uuid) .preload(:blob).index_by(&:uuid)
end end
def lang
params[:lang].presence
end
end end
@@ -10,29 +10,27 @@ class SubmitFormEmail2fasController < ApplicationController
def create def create
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true) RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
RateLimit.call("verify-2fa-code-1h-#{@submitter.id}", limit: 10, ttl: 1.hour, enabled: true)
RateLimit.call("verify-2fa-code-1d-#{@submitter.id}", limit: 20, ttl: 1.day, enabled: true)
value = [@submitter.email.downcase.strip, @submitter.slug].join(':') value = [@submitter.email.downcase.squish, @submitter.slug].join(':')
if EmailVerificationCodes.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value) if OtpChallenges.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value, purpose: 'form_email_2fa')
SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email }) SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
cookies.encrypted[:email_2fa_slug] = cookies.encrypted[:email_2fa_slug] =
{ value: @submitter.slug, expires: Submitters::StartForm::COOKIES_TTL.from_now, { value: @submitter.slug, expires: Submitters::StartForm::COOKIES_TTL.from_now,
**Submitters::StartForm::COOKIES_DEFAULTS } **Submitters::StartForm::COOKIES_DEFAULTS }
redirect_to submit_form_path(@submitter.slug) redirect_to submit_form_path(@submitter.slug, lang:)
else else
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:invalid_code) redirect_to submit_form_path(@submitter.slug, status: :error, lang:), alert: I18n.t(:invalid_code)
end end
rescue RateLimit::LimitApproached rescue RateLimit::LimitApproached
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:too_many_attempts) redirect_to submit_form_path(@submitter.slug, status: :error, lang:), alert: I18n.t(:too_many_attempts)
end end
def update def update
if @submitter.submission_events.where(event_type: 'send_2fa_email').exists?(created_at: 15.seconds.ago..) if @submitter.submission_events.where(event_type: 'send_2fa_email').exists?(created_at: 15.seconds.ago..)
return redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:rate_limit_exceeded) return redirect_to submit_form_path(@submitter.slug, status: :error, lang:), alert: I18n.t(:rate_limit_exceeded)
end end
RateLimit.call("send-email-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true) RateLimit.call("send-email-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
@@ -40,19 +38,24 @@ class SubmitFormEmail2fasController < ApplicationController
if Docuseal.multitenant? && Submitters.email_bounced_recently?(@submitter.email) if Docuseal.multitenant? && Submitters.email_bounced_recently?(@submitter.email)
Rollbar.warning("Bounced OTP email for submitter: #{@submitter.id}") if defined?(Rollbar) Rollbar.warning("Bounced OTP email for submitter: #{@submitter.id}") if defined?(Rollbar)
return redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:verification_email_bounced) return redirect_to submit_form_path(@submitter.slug, status: :error, lang:),
alert: I18n.t(:verification_email_bounced)
end end
SendSubmitterVerificationEmailJob.perform_async('submitter_id' => @submitter.id, 'locale' => I18n.locale.to_s) SendSubmitterVerificationEmailJob.perform_async('submitter_id' => @submitter.id, 'locale' => I18n.locale.to_s)
redir_params = params[:resend] ? { alert: I18n.t(:code_has_been_resent) } : {} redir_params = params[:resend] ? { alert: I18n.t(:code_has_been_resent) } : {}
redirect_to submit_form_path(@submitter.slug, status: :sent), **redir_params redirect_to submit_form_path(@submitter.slug, status: :sent, lang:), **redir_params
rescue RateLimit::LimitApproached rescue RateLimit::LimitApproached
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:too_many_attempts) redirect_to submit_form_path(@submitter.slug, status: :error, lang:), alert: I18n.t(:too_many_attempts)
end end
def load_submitter def load_submitter
@submitter = Submitter.find_by!(slug: params[:submitter_slug]) @submitter = Submitter.find_by!(slug: params[:submitter_slug])
end end
def lang
params[:lang].presence
end
end end
@@ -17,12 +17,12 @@ class SubmitFormMetadataController < ApplicationController
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
end end
ActiveRecord::Associations::Preloader.new(records: documents, associations: %i[blob record]).call ActiveRecord::Associations::Preloader.new(records: documents, associations: :blob).call
text_runs = documents.to_h do |document| text_runs = documents.to_h do |document|
[ [
document.uuid, document.uuid,
DocumentMetadatas.find_or_create_for_document(document, account_id: document.record.account_id).text_runs DocumentMetadatas.find_or_create_for_document(document, account_id: submission.account_id).text_runs
] ]
end end
@@ -4,19 +4,19 @@ class SubmitFormPrefillAttachmentsController < ActionController::API
include ActiveStorage::SetCurrent include ActiveStorage::SetCurrent
def create def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug]) @submitter = Submitter.find_by!(slug: params[:submit_form_slug])
unless can_upload?(submitter) unless can_upload?(@submitter)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end end
uuid, slug = ApplicationRecord.signed_id_verifier.verify(params[:prefill_token], purpose: :prefill_attachment) uuid, slug = ApplicationRecord.signed_id_verifier.verify(params[:prefill_token], purpose: :prefill_attachment)
raise ActiveRecord::RecordNotFound if slug != submitter.slug raise ActiveRecord::RecordNotFound if slug != @submitter.slug
source_attachment = ActiveStorage::Attachment.find_by!(uuid:) source_attachment = ActiveStorage::Attachment.find_by!(uuid:)
attachment = submitter.attachments_attachments.find_or_create_by!(blob_id: source_attachment.blob_id) 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]) render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
end end
@@ -34,7 +34,7 @@ class SubmitFormUploadController < ActionController::API
attachment = Submitters.create_attachment!(@submitter, file, metadata:) attachment = Submitters.create_attachment!(@submitter, file, metadata:)
if params[:remember_signature] == 'true' && @submitter.email.present? if params[:remember_signature] == 'true' && @submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment) cookies.permanent.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
end end
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type]) render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
+17
View File
@@ -81,9 +81,26 @@ class SubmittersController < ApplicationController
submitter.name = attrs[:name] if attrs.key?(:name) submitter.name = attrs[:name] if attrs.key?(:name)
if submitter_changed?(submitter)
submitter.submitter_versions.new(slug: submitter.slug, email: submitter.email_was,
name: submitter.name_was, phone: submitter.phone_was)
submitter.slug = SecureRandom.base58(14)
submitter.values = Submitters.fetch_values_for_delegate(submitter)
end
submitter submitter
end end
def submitter_changed?(submitter)
if submitter.email_was.blank? && submitter.phone_was.blank?
submitter.name_was.present? && submitter.name_changed?
else
(submitter.email_was.present? && submitter.email_changed?) ||
(submitter.phone_was.present? && submitter.phone_changed?)
end
end
def submitter_params def submitter_params
params.require(:submitter).permit(:email, :name, :phone).transform_values(&:strip) params.require(:submitter).permit(:email, :name, :phone).transform_values(&:strip)
end end
@@ -6,7 +6,17 @@ class TemplateDocumentsController < ApplicationController
FILES_TTL = 5.minutes FILES_TTL = 5.minutes
def index def index
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) } documents = @template.schema.map do |item|
if item['dynamic']
dynamic_document = @template.schema_dynamic_documents.find { |e| e.uuid == item['attachment_uuid'] }
DynamicDocuments::EnsureVersionGenerated.call(dynamic_document).document_attachment
else
@template.schema_documents.find { |e| e.uuid == item['attachment_uuid'] }
end
end
render json: documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) }
end end
def create def create
@@ -41,7 +41,9 @@ class TemplateFoldersController < ApplicationController
render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll? render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll?
end end
def edit; end def edit
render layout: turbo_native_android? ? 'plain' : true
end
def update def update
if @template_folder != current_account.default_template_folder && if @template_folder != current_account.default_template_folder &&
@@ -8,7 +8,9 @@ class TemplateSharingsTestingController < ApplicationController
end end
def create def create
testing_account = Accounts.find_or_create_testing_user(true_user.account).account account = can?(:manage, :tenants_test) ? current_account : true_user.account
testing_account = Accounts.find_or_create_testing_user(account).account
if params[:value] == '1' if params[:value] == '1'
TemplateSharing.create!(ability: :manage, account: testing_account, template: @template) TemplateSharing.create!(ability: :manage, account: testing_account, template: @template)
@@ -7,6 +7,8 @@ class TemplatesCloneController < ApplicationController
authorize!(:create, Template) authorize!(:create, Template)
@template = Template.new(name: "#{@base_template.name} (#{I18n.t('clone')})") @template = Template.new(name: "#{@base_template.name} (#{I18n.t('clone')})")
render layout: turbo_native_android? ? 'plain' : true
end end
def create def create
@@ -3,7 +3,9 @@
class TemplatesFoldersController < ApplicationController class TemplatesFoldersController < ApplicationController
load_and_authorize_resource :template load_and_authorize_resource :template
def edit; end def edit
render layout: turbo_native_android? ? 'plain' : true
end
def update def update
authorize!(:update, @template) authorize!(:update, @template)
+3 -1
View File
@@ -4,7 +4,9 @@ class UserInitialsController < ApplicationController
before_action :load_user_config before_action :load_user_config
authorize_resource :user_config authorize_resource :user_config
def edit; end def edit
render layout: turbo_native_android? ? 'plain' : true
end
def update def update
file = params[:file] file = params[:file]
@@ -4,7 +4,9 @@ class UserSignaturesController < ApplicationController
before_action :load_user_config before_action :load_user_config
authorize_resource :user_config authorize_resource :user_config
def edit; end def edit
render layout: turbo_native_android? ? 'plain' : true
end
def update def update
file = params[:file] file = params[:file]
+6 -2
View File
@@ -31,9 +31,13 @@ class UsersController < ApplicationController
end end
end end
def new; end def new
render layout: turbo_native_android? ? 'plain' : true
end
def edit; end def edit
render layout: turbo_native_android? ? 'plain' : true
end
def create def create
existing_user = User.accessible_by(current_ability).find_by(email: @user.email) existing_user = User.accessible_by(current_ability).find_by(email: @user.email)
@@ -66,7 +66,7 @@ class WebhookSettingsController < ApplicationController
SendTestWebhookRequestJob.perform_async( SendTestWebhookRequestJob.perform_async(
'submitter_id' => submitter.id, 'submitter_id' => submitter.id,
'event_uuid' => SecureRandom.uuid, 'event_uuid' => SecureRandom.uuid_v7,
'webhook_url_id' => @webhook_url.id 'webhook_url_id' => @webhook_url.id
) )
+7
View File
@@ -35,6 +35,9 @@ import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea' import EmailsTextarea from './elements/emails_textarea'
import ToggleSubmit from './elements/toggle_submit' import ToggleSubmit from './elements/toggle_submit'
import ToggleOnSubmit from './elements/toggle_on_submit' import ToggleOnSubmit from './elements/toggle_on_submit'
import ToggleOnChecked from './elements/toggle_on_checked'
import ToggleSendTitle from './elements/toggle_send_title'
import AppendTemplate from './elements/append_template'
import CheckOnClick from './elements/check_on_click' import CheckOnClick from './elements/check_on_click'
import PasswordInput from './elements/password_input' import PasswordInput from './elements/password_input'
import SearchInput from './elements/search_input' import SearchInput from './elements/search_input'
@@ -159,6 +162,9 @@ safeRegisterElement('emails-textarea', EmailsTextarea)
safeRegisterElement('toggle-cookies', ToggleCookies) safeRegisterElement('toggle-cookies', ToggleCookies)
safeRegisterElement('toggle-submit', ToggleSubmit) safeRegisterElement('toggle-submit', ToggleSubmit)
safeRegisterElement('toggle-on-submit', ToggleOnSubmit) safeRegisterElement('toggle-on-submit', ToggleOnSubmit)
safeRegisterElement('toggle-on-checked', ToggleOnChecked)
safeRegisterElement('toggle-send-title', ToggleSendTitle)
safeRegisterElement('append-template', AppendTemplate)
safeRegisterElement('password-input', PasswordInput) safeRegisterElement('password-input', PasswordInput)
safeRegisterElement('search-input', SearchInput) safeRegisterElement('search-input', SearchInput)
safeRegisterElement('toggle-attribute', ToggleAttribute) safeRegisterElement('toggle-attribute', ToggleAttribute)
@@ -222,6 +228,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
template, template,
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')), customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
dateFormats: JSON.parse(this.dataset.dateFormats || '[]'), dateFormats: JSON.parse(this.dataset.dateFormats || '[]'),
autosave: this.dataset.autosave !== 'false',
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')), dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
backgroundColor: '#faf7f5', backgroundColor: '#faf7f5',
locale: this.dataset.locale, locale: this.dataset.locale,
@@ -0,0 +1,29 @@
export default class extends HTMLElement {
connectedCallback () {
this.addEventListener('click', this.onClick)
}
disconnectedCallback () {
this.removeEventListener('click', this.onClick)
}
onClick = (e) => {
e.preventDefault()
const target = document.getElementById(this.dataset.targetId)
const item = this.querySelector('template').content.firstElementChild.cloneNode(true)
item.querySelector('a').addEventListener('click', (e) => {
e.preventDefault()
item.remove()
target.classList.toggle('hidden', !target.childElementCount)
})
target.append(item)
target.classList.remove('hidden')
item.querySelector('input').focus()
}
}
+18 -1
View File
@@ -2,7 +2,7 @@ import { actionable } from '@github/catalyst/lib/actionable'
import { targets, targetable } from '@github/catalyst/lib/targetable' import { targets, targetable } from '@github/catalyst/lib/targetable'
export default actionable(targetable(class extends HTMLElement { export default actionable(targetable(class extends HTMLElement {
static [targets.static] = ['items'] static [targets.static] = ['items', 'groups']
addItem (e) { addItem (e) {
e.preventDefault() e.preventDefault()
@@ -24,11 +24,28 @@ export default actionable(targetable(class extends HTMLElement {
duplicateItem.querySelectorAll('a.hidden').forEach((button) => button.classList.toggle('hidden')) duplicateItem.querySelectorAll('a.hidden').forEach((button) => button.classList.toggle('hidden'))
originalItem.parentNode.append(duplicateItem) originalItem.parentNode.append(duplicateItem)
this.toggleMultipleClasses()
duplicateItem.querySelector("input:not([type='hidden'])")?.focus()
} }
removeItem (e) { removeItem (e) {
e.preventDefault() e.preventDefault()
this.items.find((item) => item.contains(e.target))?.remove() this.items.find((item) => item.contains(e.target))?.remove()
this.toggleMultipleClasses()
}
toggleMultipleClasses () {
const isMultiple = this.items.length > 1
const elements = [...this.items, ...this.groups]
elements.forEach((element) => {
const classes = element.dataset.multipleClasses || this.dataset.multipleClasses
classes?.split(' ').forEach((cls) => element.classList.toggle(cls, isMultiple))
})
} }
})) }))
+1 -1
View File
@@ -22,7 +22,7 @@ export default class extends HTMLElement {
} }
async flashFrom (fetchResponse) { async flashFrom (fetchResponse) {
const html = await fetchResponse?.responseHTML const html = await fetchResponse?.responseHTML.catch(() => null)
const flash = html && new DOMParser().parseFromString(html, 'text/html').getElementById('native_flash') const flash = html && new DOMParser().parseFromString(html, 'text/html').getElementById('native_flash')
if (flash?.dataset.alert) return { alert: flash.dataset.alert } if (flash?.dataset.alert) return { alert: flash.dataset.alert }
+1 -1
View File
@@ -55,7 +55,7 @@ export default targetable(class extends HTMLElement {
this.input.files = dataTransfer.files this.input.files = dataTransfer.files
if (this.input.webkitEntries.length) { if (this.input.webkitEntries?.length) {
this.input.dataset.file = `${dataTransfer.files[0].name}` this.input.dataset.file = `${dataTransfer.files[0].name}`
} }
@@ -0,0 +1,23 @@
export default class extends HTMLElement {
connectedCallback () {
this.form.addEventListener('change', this.toggle)
this.toggle()
}
disconnectedCallback () {
this.form.removeEventListener('change', this.toggle)
}
toggle = () => {
const selector = this.dataset.names.split(' ').map((name) => `[name="${name}"]:checked:enabled`).join(', ')
const isChecked = !!this.form.querySelector(selector)
this.firstElementChild.classList.toggle('hidden', !isChecked)
this.lastElementChild.classList.toggle('hidden', isChecked)
}
get form () {
return this.closest('form')
}
}
@@ -0,0 +1,55 @@
export default class extends HTMLElement {
connectedCallback () {
this.events.forEach((event) => this.form.addEventListener(event, this.toggle))
this.toggle()
}
disconnectedCallback () {
this.events.forEach((event) => this.form.removeEventListener(event, this.toggle))
}
toggle = (event) => {
if (event?.type === 'input' && event.target.matches(this.fieldSelector('name'))) return
if (event?.type === 'focusin' && !this.isKeepSendUsed && this.isFocusFromName(event)) {
this.isKeepSendUsed = true
this.keepSendInput = event.target
} else if (event?.type === 'focusout' && event.target === this.keepSendInput) {
this.keepSendInput = null
}
const isNameOnly = (field) => this.isFilled('name') && !this.isFilled(field) && !this.keepSendInput?.matches(this.fieldSelector(field))
const isSend = (this.isChecked('send_email') && !isNameOnly('email')) ||
(this.isChecked('send_sms') && !isNameOnly('phone'))
this.firstElementChild.classList.toggle('hidden', !isSend)
this.lastElementChild.classList.toggle('hidden', isSend)
}
isChecked (name) {
return !!this.form.querySelector(`[name="${name}"]:checked:enabled`)
}
isFilled (field) {
return [...this.form.querySelectorAll(this.fieldSelector(field))].some((input) => input.value.trim())
}
isFocusFromName ({ target, relatedTarget }) {
return !!relatedTarget?.matches(this.fieldSelector('name')) && !!relatedTarget.value.trim() &&
['email', 'phone'].some((field) => target.matches(this.fieldSelector(field)))
}
fieldSelector (field) {
return `[name$="[][${field}]"]`
}
get events () {
return ['change', 'input', 'linked-input.update', 'focusin', 'focusout']
}
get form () {
return this.closest('form')
}
}
+3
View File
@@ -24,6 +24,9 @@ const ICONS = {
user: 'person', user: 'person',
users: 'person.2', users: 'person.2',
users_plus: 'person.badge.plus', users_plus: 'person.badge.plus',
user_plus: 'person.badge.plus',
eye_plus: 'eye',
mail_plus: 'envelope',
photo: 'photo', photo: 'photo',
settings: 'gearshape', settings: 'gearshape',
logout: 'rectangle.portrait.and.arrow.right', logout: 'rectangle.portrait.and.arrow.right',
+14
View File
@@ -13,6 +13,7 @@ const en = {
must_be_valid_email: 'Must be a valid email address', must_be_valid_email: 'Must be a valid email address',
must_be_valid_url: 'Must be a valid URL (starting with http:// or https://)', must_be_valid_url: 'Must be a valid URL (starting with http:// or https://)',
must_be_valid_zip: 'Must be a valid ZIP code (XXXXX or XXXXX-XXXX)', must_be_valid_zip: 'Must be a valid ZIP code (XXXXX or XXXXX-XXXX)',
must_be_valid_iban: 'Must be a valid IBAN',
must_contain_numbers_only: 'Must contain numbers only', must_contain_numbers_only: 'Must contain numbers only',
must_contain_letters_only: 'Must contain letters only', must_contain_letters_only: 'Must contain letters only',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.', complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
@@ -132,6 +133,7 @@ const es = {
must_be_valid_email: 'Debe ser una dirección de correo electrónico válida', must_be_valid_email: 'Debe ser una dirección de correo electrónico válida',
must_be_valid_url: 'Debe ser una URL válida (que comience con http:// o https://)', must_be_valid_url: 'Debe ser una URL válida (que comience con http:// o https://)',
must_be_valid_zip: 'Debe ser un ZIP válido (XXXXX o XXXXX-XXXX)', must_be_valid_zip: 'Debe ser un ZIP válido (XXXXX o XXXXX-XXXX)',
must_be_valid_iban: 'Debe ser un IBAN válido',
must_contain_numbers_only: 'Debe contener solo números', must_contain_numbers_only: 'Debe contener solo números',
must_contain_letters_only: 'Debe contener solo letras', must_contain_letters_only: 'Debe contener solo letras',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.', complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
@@ -251,6 +253,7 @@ const it = {
must_be_valid_email: 'Deve essere un indirizzo email valido', must_be_valid_email: 'Deve essere un indirizzo email valido',
must_be_valid_url: 'Deve essere un URL valido (che inizia con http:// o https://)', must_be_valid_url: 'Deve essere un URL valido (che inizia con http:// o https://)',
must_be_valid_zip: 'Deve essere uno ZIP valido (XXXXX o XXXXX-XXXX)', must_be_valid_zip: 'Deve essere uno ZIP valido (XXXXX o XXXXX-XXXX)',
must_be_valid_iban: 'Deve essere un IBAN valido',
must_contain_numbers_only: 'Deve contenere solo numeri', must_contain_numbers_only: 'Deve contenere solo numeri',
must_contain_letters_only: 'Deve contenere solo lettere', must_contain_letters_only: 'Deve contenere solo lettere',
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.", complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
@@ -370,6 +373,7 @@ const de = {
must_be_valid_email: 'Muss eine gültige E-Mail-Adresse sein', must_be_valid_email: 'Muss eine gültige E-Mail-Adresse sein',
must_be_valid_url: 'Muss eine gültige URL sein (beginnend mit http:// oder https://)', must_be_valid_url: 'Muss eine gültige URL sein (beginnend mit http:// oder https://)',
must_be_valid_zip: 'Muss eine gültige ZIP sein (XXXXX oder XXXXX-XXXX)', must_be_valid_zip: 'Muss eine gültige ZIP sein (XXXXX oder XXXXX-XXXX)',
must_be_valid_iban: 'Muss eine gültige IBAN sein',
must_contain_numbers_only: 'Darf nur Zahlen enthalten', must_contain_numbers_only: 'Darf nur Zahlen enthalten',
must_contain_letters_only: 'Darf nur Buchstaben enthalten', must_contain_letters_only: 'Darf nur Buchstaben enthalten',
complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.', complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
@@ -489,6 +493,7 @@ const fr = {
must_be_valid_email: 'Doit être une adresse e-mail valide', must_be_valid_email: 'Doit être une adresse e-mail valide',
must_be_valid_url: 'Doit être une URL valide (commençant par http:// ou https://)', must_be_valid_url: 'Doit être une URL valide (commençant par http:// ou https://)',
must_be_valid_zip: 'Doit être un ZIP valide (XXXXX ou XXXXX-XXXX)', must_be_valid_zip: 'Doit être un ZIP valide (XXXXX ou XXXXX-XXXX)',
must_be_valid_iban: 'Doit être un IBAN valide',
must_contain_numbers_only: 'Doit contenir uniquement des chiffres', must_contain_numbers_only: 'Doit contenir uniquement des chiffres',
must_contain_letters_only: 'Doit contenir uniquement des lettres', must_contain_letters_only: 'Doit contenir uniquement des lettres',
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.", complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
@@ -608,6 +613,7 @@ const pl = {
must_be_valid_email: 'Musi być prawidłowym adresem e-mail', must_be_valid_email: 'Musi być prawidłowym adresem e-mail',
must_be_valid_url: 'Musi być prawidłowym adresem URL (zaczynającym się od http:// lub https://)', must_be_valid_url: 'Musi być prawidłowym adresem URL (zaczynającym się od http:// lub https://)',
must_be_valid_zip: 'Musi być prawidłowym ZIP (XXXXX lub XXXXX-XXXX)', must_be_valid_zip: 'Musi być prawidłowym ZIP (XXXXX lub XXXXX-XXXX)',
must_be_valid_iban: 'Musi być prawidłowym numerem IBAN',
must_contain_numbers_only: 'Może zawierać tylko cyfry', must_contain_numbers_only: 'Może zawierać tylko cyfry',
must_contain_letters_only: 'Może zawierać tylko litery', must_contain_letters_only: 'Może zawierać tylko litery',
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.', complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
@@ -727,6 +733,7 @@ const uk = {
must_be_valid_email: 'Має бути дійсною адресою електронної пошти', must_be_valid_email: 'Має бути дійсною адресою електронної пошти',
must_be_valid_url: 'Має бути дійсним URL (починаючи з http:// або https://)', must_be_valid_url: 'Має бути дійсним URL (починаючи з http:// або https://)',
must_be_valid_zip: 'Має бути дійсним ZIP (XXXXX або XXXXX-XXXX)', must_be_valid_zip: 'Має бути дійсним ZIP (XXXXX або XXXXX-XXXX)',
must_be_valid_iban: 'Має бути дійсним IBAN',
must_contain_numbers_only: 'Має містити лише цифри', must_contain_numbers_only: 'Має містити лише цифри',
must_contain_letters_only: 'Має містити лише літери', must_contain_letters_only: 'Має містити лише літери',
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.", complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
@@ -846,6 +853,7 @@ const cs = {
must_be_valid_email: 'Musí být platná e-mailová adresa', must_be_valid_email: 'Musí být platná e-mailová adresa',
must_be_valid_url: 'Musí být platná URL (začínající http:// nebo https://)', must_be_valid_url: 'Musí být platná URL (začínající http:// nebo https://)',
must_be_valid_zip: 'Musí být platný ZIP (XXXXX nebo XXXXX-XXXX)', must_be_valid_zip: 'Musí být platný ZIP (XXXXX nebo XXXXX-XXXX)',
must_be_valid_iban: 'Musí být platný IBAN',
must_contain_numbers_only: 'Musí obsahovat pouze čísla', must_contain_numbers_only: 'Musí obsahovat pouze čísla',
must_contain_letters_only: 'Musí obsahovat pouze písmena', must_contain_letters_only: 'Musí obsahovat pouze písmena',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.', complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
@@ -965,6 +973,7 @@ const pt = {
must_be_valid_email: 'Deve ser um endereço de e-mail válido', must_be_valid_email: 'Deve ser um endereço de e-mail válido',
must_be_valid_url: 'Deve ser uma URL válida (começando com http:// ou https://)', must_be_valid_url: 'Deve ser uma URL válida (começando com http:// ou https://)',
must_be_valid_zip: 'Deve ser um ZIP válido (XXXXX ou XXXXX-XXXX)', must_be_valid_zip: 'Deve ser um ZIP válido (XXXXX ou XXXXX-XXXX)',
must_be_valid_iban: 'Deve ser um IBAN válido',
must_contain_numbers_only: 'Deve conter apenas números', must_contain_numbers_only: 'Deve conter apenas números',
must_contain_letters_only: 'Deve conter apenas letras', must_contain_letters_only: 'Deve conter apenas letras',
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.', complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
@@ -1084,6 +1093,7 @@ const he = {
must_be_valid_email: 'חייבת להיות כתובת אימייל חוקית', must_be_valid_email: 'חייבת להיות כתובת אימייל חוקית',
must_be_valid_url: 'חייבת להיות כתובת URL חוקית (המתחילה ב-http:// או https://)', must_be_valid_url: 'חייבת להיות כתובת URL חוקית (המתחילה ב-http:// או https://)',
must_be_valid_zip: 'חייב להיות ZIP חוקי (XXXXX או XXXXX-XXXX)', must_be_valid_zip: 'חייב להיות ZIP חוקי (XXXXX או XXXXX-XXXX)',
must_be_valid_iban: 'חייב להיות IBAN חוקי',
must_contain_numbers_only: 'חייב להכיל מספרים בלבד', must_contain_numbers_only: 'חייב להכיל מספרים בלבד',
must_contain_letters_only: 'חייב להכיל אותיות בלבד', must_contain_letters_only: 'חייב להכיל אותיות בלבד',
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.', complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
@@ -1203,6 +1213,7 @@ const nl = {
must_be_valid_email: 'Moet een geldig e-mailadres zijn', must_be_valid_email: 'Moet een geldig e-mailadres zijn',
must_be_valid_url: 'Moet een geldige URL zijn (beginnend met http:// of https://)', must_be_valid_url: 'Moet een geldige URL zijn (beginnend met http:// of https://)',
must_be_valid_zip: 'Moet een geldige ZIP zijn (XXXXX of XXXXX-XXXX)', must_be_valid_zip: 'Moet een geldige ZIP zijn (XXXXX of XXXXX-XXXX)',
must_be_valid_iban: 'Moet een geldig IBAN zijn',
must_contain_numbers_only: 'Mag alleen cijfers bevatten', must_contain_numbers_only: 'Mag alleen cijfers bevatten',
must_contain_letters_only: 'Mag alleen letters bevatten', must_contain_letters_only: 'Mag alleen letters bevatten',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.', complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
@@ -1322,6 +1333,7 @@ const ar = {
must_be_valid_email: 'يجب أن يكون عنوان بريد إلكتروني صالحًا', must_be_valid_email: 'يجب أن يكون عنوان بريد إلكتروني صالحًا',
must_be_valid_url: 'يجب أن يكون عنوان URL صالحًا (يبدأ بـ http:// أو https://)', must_be_valid_url: 'يجب أن يكون عنوان URL صالحًا (يبدأ بـ http:// أو https://)',
must_be_valid_zip: 'يجب أن يكون ZIP صالحًا (XXXXX أو XXXXX-XXXX)', must_be_valid_zip: 'يجب أن يكون ZIP صالحًا (XXXXX أو XXXXX-XXXX)',
must_be_valid_iban: 'يجب أن يكون IBAN صالحًا',
must_contain_numbers_only: 'يجب أن يحتوي على أرقام فقط', must_contain_numbers_only: 'يجب أن يحتوي على أرقام فقط',
must_contain_letters_only: 'يجب أن يحتوي على أحرف فقط', must_contain_letters_only: 'يجب أن يحتوي على أحرف فقط',
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.', complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
@@ -1441,6 +1453,7 @@ const ko = {
must_be_valid_email: '유효한 이메일 주소여야 합니다', must_be_valid_email: '유효한 이메일 주소여야 합니다',
must_be_valid_url: '유효한 URL이어야 합니다 (http:// 또는 https://로 시작)', must_be_valid_url: '유효한 URL이어야 합니다 (http:// 또는 https://로 시작)',
must_be_valid_zip: '유효한 ZIP이어야 합니다 (XXXXX 또는 XXXXX-XXXX)', must_be_valid_zip: '유효한 ZIP이어야 합니다 (XXXXX 또는 XXXXX-XXXX)',
must_be_valid_iban: '유효한 IBAN이어야 합니다',
must_contain_numbers_only: '숫자만 포함해야 합니다', must_contain_numbers_only: '숫자만 포함해야 합니다',
must_contain_letters_only: '문자만 포함해야 합니다', must_contain_letters_only: '문자만 포함해야 합니다',
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.', complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
@@ -1560,6 +1573,7 @@ const ja = {
must_be_valid_email: '有効なメールアドレスである必要があります', must_be_valid_email: '有効なメールアドレスである必要があります',
must_be_valid_url: '有効なURLである必要があります (http://またはhttps://で始まる)', must_be_valid_url: '有効なURLである必要があります (http://またはhttps://で始まる)',
must_be_valid_zip: '有効なZIPである必要があります (XXXXX または XXXXX-XXXX)', must_be_valid_zip: '有効なZIPである必要があります (XXXXX または XXXXX-XXXX)',
must_be_valid_iban: '有効なIBANである必要があります',
must_contain_numbers_only: '数字のみを含む必要があります', must_contain_numbers_only: '数字のみを含む必要があります',
must_contain_letters_only: '文字のみを含む必要があります', must_contain_letters_only: '文字のみを含む必要があります',
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。', complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
+20 -12
View File
@@ -3,11 +3,19 @@
v-if="!error" v-if="!error"
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label" class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
> >
<MarkdownContent <span class="flex items-end">
v-if="field.title" <MarkdownContent
:string="field.title" v-if="field.title"
/> :string="field.title"
<template v-else>{{ field.name || 'Knowledge Based Authentication' }}</template> />
<template v-else>{{ field.name || 'Knowledge Based Authentication' }}</template>
<span
class="tooltip tooltip-bottom ml-2 mb-1"
data-tip="To verify your identity, the details you enter are checked against third-party records, which may include credit bureau data, and used to generate questions based on those records. Only these identity details are shared with the identity verification provider; this document and your tax return information are not shared with it or with any credit reporting company. If the check is recorded on your credit report, it appears as a soft inquiry, which does not affect your credit score and is not shown to lenders. This check does not give the sender of this document or the IRS access to your credit report."
>
<IconInfoCircle class="w-4 h-4 text-neutral-500" />
</span>
</span>
<span <span
v-if="questions" v-if="questions"
class="float-right font-normal text-neutral-500 mt-1 whitespace-nowrap text-right text-sm" class="float-right font-normal text-neutral-500 mt-1 whitespace-nowrap text-right text-sm"
@@ -282,13 +290,14 @@
<script> <script>
import MarkdownContent from './markdown_content' import MarkdownContent from './markdown_content'
import { IconInnerShadowTop } from '@tabler/icons-vue' import { IconInnerShadowTop, IconInfoCircle } from '@tabler/icons-vue'
export default { export default {
name: 'KbaStep', name: 'KbaStep',
components: { components: {
MarkdownContent, MarkdownContent,
IconInnerShadowTop IconInnerShadowTop,
IconInfoCircle
}, },
inject: ['baseUrl', 't'], inject: ['baseUrl', 't'],
props: { props: {
@@ -483,7 +492,7 @@ export default {
this.clearCountdown() this.clearCountdown()
try { try {
const payload = { ...this.form, submitter_slug: this.submitterSlug } const payload = { ...this.form }
if (payload.dob) { if (payload.dob) {
payload.dob = payload.dob.replace(/-/g, '') payload.dob = payload.dob.replace(/-/g, '')
@@ -497,7 +506,7 @@ export default {
payload.phone = payload.phone.replace(/^\+1/, '') payload.phone = payload.phone.replace(/^\+1/, '')
} }
const resp = await fetch(this.baseUrl + '/api/kba', { const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/kba`, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
...this.fetchOptions, ...this.fetchOptions,
@@ -548,13 +557,12 @@ export default {
}, {}) }, {})
try { try {
const resp = await fetch(this.baseUrl + `/api/kba/${this.field.uuid}`, { const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/kba/${this.field.uuid}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
token: this.token, token: this.token,
answers: formattedAnswers, answers: formattedAnswers,
reference: this.reference, reference: this.reference
submitter_slug: this.submitterSlug
}), }),
...this.fetchOptions, ...this.fetchOptions,
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers } headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
@@ -156,7 +156,7 @@ export default {
return this.provider === 'paypal' return this.provider === 'paypal'
}, },
paymentEndpoint () { paymentEndpoint () {
return this.isPaypal ? '/api/paypal_payments' : '/api/stripe_payments' return `/s/${this.submitterSlug}/${this.isPaypal ? 'paypal_payments' : 'stripe_payments'}`
}, },
sessionId () { sessionId () {
return this.queryParams.get('stripe_session_id') || this.queryParams.get('paypal_order_id') || (this.isPaypal && this.queryParams.get('token')) return this.queryParams.get('stripe_session_id') || this.queryParams.get('paypal_order_id') || (this.isPaypal && this.queryParams.get('token'))
@@ -255,9 +255,6 @@ export default {
if (this.sessionId) { if (this.sessionId) {
return fetch(this.baseUrl + this.paymentEndpoint + '/' + this.sessionId, { return fetch(this.baseUrl + this.paymentEndpoint + '/' + this.sessionId, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({
submitter_slug: this.submitterSlug
}),
...this.fetchOptions, ...this.fetchOptions,
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers } headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
}).then(async (resp) => { }).then(async (resp) => {
@@ -294,7 +291,6 @@ export default {
fetch(this.baseUrl + this.paymentEndpoint, { fetch(this.baseUrl + this.paymentEndpoint, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug,
field_uuid: this.field.uuid, field_uuid: this.field.uuid,
check_status: checkStatus, check_status: checkStatus,
referer: this.normalizedPaymentUrl() referer: this.normalizedPaymentUrl()
@@ -285,7 +285,7 @@ export default {
}, 1000) }, 1000)
}, },
sendVerificationCode () { sendVerificationCode () {
return fetch(this.baseUrl + '/api/send_phone_verification_code', { const options = {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug, submitter_slug: this.submitterSlug,
@@ -293,6 +293,14 @@ export default {
phone: this.fullInternationalPhoneValue phone: this.fullInternationalPhoneValue
}), }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}
return fetch(this.baseUrl + `/s/${this.submitterSlug}/send_phone_verification_code`, options).then((resp) => {
if (resp.status === 404) {
return fetch(this.baseUrl + '/api/send_phone_verification_code', options)
} else {
return resp
}
}).then(async (resp) => { }).then(async (resp) => {
if ([422, 429].includes(resp.status)) { if ([422, 429].includes(resp.status)) {
const data = await resp.json() const data = await resp.json()
@@ -34,6 +34,7 @@
<input <input
v-if="!isTextArea" v-if="!isTextArea"
:id="field.uuid" :id="field.uuid"
ref="input"
v-model="text" v-model="text"
:maxlength="cellsMaxLegth" :maxlength="cellsMaxLegth"
dir="auto" dir="auto"
@@ -46,6 +47,7 @@
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`" :placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text" type="text"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@input="validateValue($event.target)"
@focus="$emit('focus')" @focus="$emit('focus')"
> >
<textarea <textarea
@@ -137,6 +139,9 @@ export default {
return null return null
} }
}, },
isIbanValidation () {
return this.patternMessageKeys[this.field.validation?.pattern] === 'must_be_valid_iban'
},
patternMessageKeys () { patternMessageKeys () {
return { return {
'^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'must_be_valid_ssn', '^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'must_be_valid_ssn',
@@ -144,6 +149,7 @@ export default {
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'must_be_valid_email', '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'must_be_valid_email',
'^https?://.*': 'must_be_valid_url', '^https?://.*': 'must_be_valid_url',
'^[0-9]{5}(?:-[0-9]{4})?$': 'must_be_valid_zip', '^[0-9]{5}(?:-[0-9]{4})?$': 'must_be_valid_zip',
'^[A-Z]{2}[0-9]{2}(?: ?[A-Z0-9]){11,30}$': 'must_be_valid_iban',
'^[0-9]+$': 'must_contain_numbers_only', '^[0-9]+$': 'must_contain_numbers_only',
'^[a-zA-Z]+$': 'must_contain_letters_only' '^[a-zA-Z]+$': 'must_contain_letters_only'
} }
@@ -186,8 +192,21 @@ export default {
}) })
} }
} }
this.validateValue(this.$refs.input)
}, },
methods: { methods: {
validateValue (input) {
if (this.isIbanValidation) {
input.setCustomValidity(this.isValidIban(input.value) ? '' : this.t('must_be_valid_iban'))
}
},
isValidIban (value) {
const iban = value.replace(/\s/g, '')
const digits = (iban.slice(4) + iban.slice(0, 4)).replace(/[A-Z]/g, (char) => char.charCodeAt(0) - 55)
return !iban || digits.split('').reduce((remainder, digit) => (remainder * 10 + parseInt(digit)) % 97, 0) === 1
},
resizeTextarea () { resizeTextarea () {
const textarea = this.$refs.textarea const textarea = this.$refs.textarea
@@ -162,10 +162,9 @@ export default {
}, },
methods: { methods: {
start () { start () {
return fetch(this.baseUrl + `/api/identity_verification/${this.field.uuid}`, { return fetch(this.baseUrl + `/s/${this.submitterSlug}/identity_verification/${this.field.uuid}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug,
redirect_url: document.location.href redirect_url: document.location.href
}), }),
...this.fetchOptions, ...this.fetchOptions,
@@ -206,11 +205,8 @@ export default {
}) })
}, },
async submit () { async submit () {
const resp = await fetch(this.baseUrl + '/api/identity_verification', { const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/identity_verification`, {
method: 'POST', method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug
}),
...this.fetchOptions, ...this.fetchOptions,
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers } headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
}) })
+1 -3
View File
@@ -319,7 +319,7 @@ export default {
default: false default: false
} }
}, },
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title', 'multi-select'], emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title'],
data () { data () {
return { return {
isContenteditable: false, isContenteditable: false,
@@ -798,8 +798,6 @@ export default {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(this.area), 1) this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(this.area), 1)
} }
this.$emit('multi-select', e)
return return
} }
@@ -272,7 +272,7 @@ export default {
FieldSubmitter, FieldSubmitter,
IconX IconX
}, },
inject: ['t', 'isMobile', 'nativePlatform'], inject: ['t', 'nativePlatform'],
props: { props: {
template: { template: {
type: Object, type: Object,
+186 -55
View File
@@ -3,8 +3,10 @@
ref="dragContainer" ref="dragContainer"
style="max-width: 1600px" style="max-width: 1600px"
:class="nativeView ? (isSheetView ? 'bg-base-100' : 'px-4') : 'mx-auto pl-3 md:pl-4 h-full'" :class="nativeView ? (isSheetView ? 'bg-base-100' : 'px-4') : 'mx-auto pl-3 md:pl-4 h-full'"
@dragstart="isInternalDrag = true"
@dragend="isInternalDrag = false"
@dragover="onDragover" @dragover="onDragover"
@drop="isDragFile = false" @drop="[isDragFile = false, isInternalDrag = false]"
> >
<HoverDropzone <HoverDropzone
v-if="sortedDocuments.length && withUploadButton && editable && !isSheetView" v-if="sortedDocuments.length && withUploadButton && editable && !isSheetView"
@@ -413,9 +415,9 @@
<a <a
:href="`/templates/${template.id}/form`" :href="`/templates/${template.id}/form`"
:data-native-actions="template.submitters.length > 1 ? 'users' : null" :data-native-actions="template.submitters.length > 1 ? 'users' : null"
data-turbo="false" data-turbo-prefetch="false"
class="flex items-center justify-center space-x-2" class="flex items-center justify-center space-x-2"
@click.exact="isPreviewLoading = true" @click.exact="onSaveAndPreviewClick"
> >
<IconInnerShadowTop <IconInnerShadowTop
v-if="isPreviewLoading" v-if="isPreviewLoading"
@@ -638,6 +640,7 @@
:draw-field-type="drawFieldType" :draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField" :draw-custom-field="drawCustomField"
:draw-option="drawOption" :draw-option="drawOption"
:autosave="autosave"
@update="onDynamicDocumentUpdate" @update="onDynamicDocumentUpdate"
@draw="clearDrawField" @draw="clearDrawField"
@add-custom-field="addCustomField" @add-custom-field="addCustomField"
@@ -769,7 +772,7 @@
class="sticky bottom-0 z-20" class="sticky bottom-0 z-20"
> >
<native-action <native-action
v-if="nativePlatform && withDocumentsList && (isBreakpointLg || isMobile) && sortedDocuments.length && editable" v-if="nativePlatform && withDocumentsList && (isBreakpointLg || isMobile) && editable"
data-placement="drawer" data-placement="drawer"
data-side="leading" data-side="leading"
data-icon="files" data-icon="files"
@@ -779,7 +782,7 @@
data-view="documents" data-view="documents"
/> />
<native-action <native-action
v-if="nativePlatform && withFieldsList && isBreakpointLg && sortedDocuments.length && editable" v-if="nativePlatform && withFieldsList && isBreakpointLg && editable"
data-placement="drawer" data-placement="drawer"
data-side="trailing" data-side="trailing"
data-icon="plus" data-icon="plus"
@@ -906,7 +909,7 @@
<button <button
type="button" type="button"
class="join-item bg-base-content text-white h-9 pl-2 pr-3 inline-flex items-center justify-center cursor-pointer hover:opacity-90 border-l border-white/20" class="join-item bg-base-content text-white h-9 pl-2 pr-3 inline-flex items-center justify-center cursor-pointer hover:opacity-90 border-l border-white/20"
@click="zoomLevel = 1" @click="resetZoomLevel"
> >
<IconX class="w-4 h-4 stroke-2" /> <IconX class="w-4 h-4 stroke-2" />
</button> </button>
@@ -1007,6 +1010,9 @@ export default {
return { return {
template: this.template, template: this.template,
save: this.save, save: this.save,
pushUndo: this.pushUndo,
undo: this.undo,
redo: this.redo,
t: this.t, t: this.t,
assignDropAreaSize: this.assignDropAreaSize, assignDropAreaSize: this.assignDropAreaSize,
currencies: this.currencies, currencies: this.currencies,
@@ -1374,6 +1380,7 @@ export default {
isPreviewLoading: false, isPreviewLoading: false,
isLoadingBlankPage: false, isLoadingBlankPage: false,
isSaving: false, isSaving: false,
hasUnsavedChanges: false,
isDetectingPageFields: false, isDetectingPageFields: false,
detectFieldsQueue: [], detectFieldsQueue: [],
detectingAnalyzingProgress: null, detectingAnalyzingProgress: null,
@@ -1387,6 +1394,7 @@ export default {
drawOption: null, drawOption: null,
dragField: null, dragField: null,
isDragFile: false, isDragFile: false,
isInternalDrag: false,
isMathLoaded: false, isMathLoaded: false,
isRevisionsModalOpen: false, isRevisionsModalOpen: false,
isDocumentsDrawerOpen: false, isDocumentsDrawerOpen: false,
@@ -1640,20 +1648,27 @@ export default {
this.selectedSubmitter = this.template.submitters[0] this.selectedSubmitter = this.template.submitters[0]
}, },
mounted () { mounted () {
this.undoStack = [JSON.stringify(this.template)] this.undoStack = []
this.redoStack = [] this.redoStack = []
this.undoStack.push(this.buildUndoData())
this.$nextTick(() => { this.$nextTick(() => {
this.onWindowResize() this.resizeObserver = new ResizeObserver(this.onResize)
this.resizeObserver.observe(this.$el.getRootNode().querySelector('div[data-v-app]'))
}) })
document.addEventListener('keyup', this.onKeyUp) document.addEventListener('keyup', this.onKeyUp)
document.addEventListener('template-builder:apply-revision', this.onApplyRevisionEvent) document.addEventListener('template-builder:apply-revision', this.onApplyRevisionEvent)
window.addEventListener('keydown', this.onKeyDown) window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('resize', this.onWindowResize)
window.addEventListener('dragleave', this.onWindowDragLeave) window.addEventListener('dragleave', this.onWindowDragLeave)
if (!this.autosave) {
window.addEventListener('beforeunload', this.onBeforeUnload)
document.addEventListener('turbo:before-visit', this.onBeforeVisit)
}
this.$nextTick(() => { this.$nextTick(() => {
if (document.location.search?.includes('stripe_connect_success')) { if (document.location.search?.includes('stripe_connect_success')) {
document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus() document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
@@ -1675,8 +1690,13 @@ export default {
document.removeEventListener('template-builder:apply-revision', this.onApplyRevisionEvent) document.removeEventListener('template-builder:apply-revision', this.onApplyRevisionEvent)
window.removeEventListener('keydown', this.onKeyDown) window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('resize', this.onWindowResize) this.resizeObserver.disconnect()
window.removeEventListener('dragleave', this.onWindowDragLeave) window.removeEventListener('dragleave', this.onWindowDragLeave)
if (!this.autosave) {
window.removeEventListener('beforeunload', this.onBeforeUnload)
document.removeEventListener('turbo:before-visit', this.onBeforeVisit)
}
}, },
beforeUpdate () { beforeUpdate () {
this.documentRefs = [] this.documentRefs = []
@@ -2034,8 +2054,18 @@ export default {
ref.x = e.clientX - ref.offsetX ref.x = e.clientX - ref.offsetX
ref.y = e.clientY - ref.offsetY ref.y = e.clientY - ref.offsetY
} else if (e.dataTransfer?.types?.includes('Files') && !this.editModalDocumentUuid) { } else if (e.dataTransfer?.types?.includes('Files') && !this.editModalDocumentUuid && !this.isInternalDrag) {
this.isDragFile = true const items = Array.from(e.dataTransfer.items)
if (this.dynamicDocuments.length && items.length && items.every((item) => item.type.startsWith('image/'))) {
if (!e.defaultPrevented) {
e.preventDefault()
e.dataTransfer.dropEffect = 'none'
}
} else {
this.isDragFile = true
}
} }
}, },
onWindowDragLeave (event) { onWindowDragLeave (event) {
@@ -2223,6 +2253,37 @@ export default {
this.insertField(field) this.insertField(field)
} }
}, },
onSaveAndPreviewClick (event) {
this.isPreviewLoading = true
if (!this.autosave) {
event.preventDefault()
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
dynamicDocumentRefs.forEach((ref) => ref.update())
this.rebuildVariablesSchema({ disable: false })
Promise.all([this.save({ force: true }), ...dynamicDocumentRefs.map((ref) => ref.saveBody())]).then(() => {
window.Turbo.visit(`/templates/${this.template.id}/form`)
})
}
},
onBeforeUnload (event) {
if (this.hasUnsavedChanges) {
event.preventDefault()
event.returnValue = ''
return ''
}
},
onBeforeVisit (event) {
if (this.hasUnsavedChanges && !window.confirm(this.t('close_without_saving_changes'))) {
event.preventDefault()
}
},
closeDropdown () { closeDropdown () {
document.activeElement.blur() document.activeElement.blur()
}, },
@@ -2287,6 +2348,8 @@ export default {
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic) const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
this.pushUndo()
dynamicDocumentRefs.forEach((ref) => ref.update()) dynamicDocumentRefs.forEach((ref) => ref.update())
this.rebuildVariablesSchema({ disable: false }) this.rebuildVariablesSchema({ disable: false })
@@ -2414,42 +2477,85 @@ export default {
this.drawOption = null this.drawOption = null
}, },
undo () { undo () {
this.selectedAreasRef.value = []
if (this.undoStack.length > 1) { if (this.undoStack.length > 1) {
this.undoStack.pop() this.undoStack.pop()
const stringData = this.undoStack[this.undoStack.length - 1] const undoData = this.undoStack[this.undoStack.length - 1]
const currentStringData = JSON.stringify(this.template) const currentUndoData = this.buildUndoData()
if (stringData && stringData !== currentStringData) { if (!this.isSameUndoData(undoData, currentUndoData)) {
this.redoStack.push(currentStringData) this.redoStack.push(currentUndoData)
Object.assign(this.template, JSON.parse(stringData)) this.applyUndoData(undoData, currentUndoData)
this.save()
} }
} }
}, },
redo () { redo () {
const stringData = this.redoStack.pop() this.selectedAreasRef.value = []
this.lastRedoData = stringData
const currentStringData = JSON.stringify(this.template)
if (stringData && stringData !== currentStringData) { const undoData = this.redoStack.pop()
if (this.undoStack[this.undoStack.length - 1] !== currentStringData) { const currentUndoData = this.buildUndoData()
this.undoStack.push(currentStringData)
if (undoData && !this.isSameUndoData(undoData, currentUndoData)) {
if (!this.isSameUndoData(this.undoStack[this.undoStack.length - 1], currentUndoData)) {
this.undoStack.push(currentUndoData)
} }
Object.assign(this.template, JSON.parse(stringData)) this.undoStack.push(undoData)
this.save() this.applyUndoData(undoData, currentUndoData)
} }
}, },
onWindowResize (e) { buildUndoData () {
const template = JSON.stringify(toRaw(this.template))
const lastUndoData = this.undoStack[this.undoStack.length - 1]
return { template: template === lastUndoData?.template ? lastUndoData.template : template, sectionDocs: this.buildSectionDocs() }
},
buildSectionDocs () {
return new Map(this.documentRefs.flatMap((ref) => ref.isDynamic ? ref.getSectionDocs() : []))
},
isSameUndoData (undoData, currentUndoData) {
return undoData.template === currentUndoData.template && this.isSameSectionDocs(undoData, currentUndoData)
},
isSameSectionDocs (undoData, currentUndoData) {
return [...currentUndoData.sectionDocs].every(([sectionKey, doc]) => undoData.sectionDocs.get(sectionKey)?.eq(doc))
},
applyUndoData (undoData, currentUndoData) {
const isTemplateChanged = undoData.template !== currentUndoData.template
if (isTemplateChanged) {
Object.assign(this.template, JSON.parse(undoData.template))
}
this.documentRefs.forEach((ref) => {
if (ref.isDynamic) ref.restoreSectionDocs(undoData.sectionDocs)
})
this.buildSectionDocs().forEach((doc, sectionKey) => undoData.sectionDocs.set(sectionKey, doc))
if (isTemplateChanged) {
this.save()
this.$nextTick(this.syncUndoTemplate)
}
},
syncUndoTemplate () {
const lastUndoData = this.undoStack[this.undoStack.length - 1]
const template = JSON.stringify(toRaw(this.template))
if (template !== lastUndoData.template) lastUndoData.template = template
},
onResize () {
const breakpointLg = 1024 const breakpointLg = 1024
const breakpointMd = 768 const breakpointMd = 768
const width = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth const width = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth
this.isBreakpointLg = width < breakpointLg if (width) {
this.isBreakpointMd = width < breakpointMd this.isBreakpointLg = width < breakpointLg
this.isBreakpointMd = width < breakpointMd
}
}, },
onPagesWheel (event) { onPagesWheel (event) {
const isFastScroll = Math.abs(event.deltaY) > 1 const isFastScroll = Math.abs(event.deltaY) > 1
@@ -2462,23 +2568,33 @@ export default {
event.preventDefault() event.preventDefault()
const oldZoom = this.zoomLevel this.setZoomLevel(this.zoomLevel - event.deltaY * 0.006, event.clientX, event.clientY)
const nextZoom = Math.max(1, Math.min(3, oldZoom - event.deltaY * 0.006)) },
resetZoomLevel () {
if (nextZoom === oldZoom) return
const rect = this.$refs.pagesContainer.getBoundingClientRect() const rect = this.$refs.pagesContainer.getBoundingClientRect()
const cursorX = event.clientX - rect.left
const cursorY = event.clientY - rect.top this.setZoomLevel(1, rect.left + rect.width / 2, (Math.max(rect.top, 0) + Math.min(rect.bottom, window.innerHeight)) / 2)
const ratio = nextZoom / oldZoom },
const nextScrollLeft = (this.$refs.pagesContainer.scrollLeft + cursorX) * ratio - cursorX setZoomLevel (zoomLevel, clientX, clientY) {
const nextScrollTop = (this.$refs.pagesContainer.scrollTop + cursorY) * ratio - cursorY const nextZoom = Math.max(1, Math.min(3, zoomLevel))
if (nextZoom === this.zoomLevel) return
const container = this.$refs.pagesContainer
const rect = this.$refs.documents.getBoundingClientRect()
this.zoomLevel = nextZoom this.zoomLevel = nextZoom
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.pagesContainer.scrollLeft = nextScrollLeft this.documentRefs.filter((ref) => ref.isDynamic).forEach((ref) => ref.updateContainerWidth())
this.$refs.pagesContainer.scrollTop = nextScrollTop
this.$nextTick(() => {
const nextRect = this.$refs.documents.getBoundingClientRect()
const scrollContainer = container.scrollHeight > container.clientHeight ? container : document.scrollingElement
container.scrollLeft += nextRect.left - clientX + (clientX - rect.left) * nextRect.width / rect.width
scrollContainer.scrollTop += nextRect.top - clientY + (clientY - rect.top) * nextRect.height / rect.height
})
}) })
}, },
setDocumentRefs (el) { setDocumentRefs (el) {
@@ -2597,15 +2713,11 @@ export default {
event.stopImmediatePropagation() event.stopImmediatePropagation()
event.preventDefault() event.preventDefault()
this.selectedAreasRef.value = []
this.redo() this.redo()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'z') { } else if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
event.stopImmediatePropagation() event.stopImmediatePropagation()
event.preventDefault() event.preventDefault()
this.selectedAreasRef.value = []
this.undo() this.undo()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'c' && document.activeElement === document.body) { } else if ((event.ctrlKey || event.metaKey) && event.key === 'c' && document.activeElement === document.body) {
if (this.selectedAreasRef.value.length > 1) { if (this.selectedAreasRef.value.length > 1) {
@@ -2980,16 +3092,29 @@ export default {
return false return false
} }
}, },
pushUndo () { pushUndo ({ amend = false } = {}) {
const stringData = JSON.stringify(this.template) const undoData = this.buildUndoData()
const lastUndoData = this.undoStack[this.undoStack.length - 1]
const isTemplateChanged = undoData.template !== lastUndoData.template
const isSectionDocsChanged = !this.isSameSectionDocs(lastUndoData, undoData)
if (this.undoStack[this.undoStack.length - 1] !== stringData) { if (this.isUndoBatch || (amend && (!isSectionDocsChanged || (!this.redoStack.length && !isTemplateChanged)))) {
this.undoStack.push(stringData) this.undoStack[this.undoStack.length - 1] = undoData
} else if (isTemplateChanged || isSectionDocsChanged) {
this.undoStack.push(undoData)
if (this.lastRedoData !== stringData) { this.redoStack = []
this.redoStack = [] } else {
} return
} }
this.isUndoBatch = true
this.$nextTick(() => {
this.isUndoBatch = false
this.syncUndoTemplate()
})
}, },
setDefaultAreaSize (area, type) { setDefaultAreaSize (area, type) {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid) const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
@@ -3954,12 +4079,14 @@ export default {
} }
if (!this.autosave && !force) { if (!this.autosave && !force) {
this.hasUnsavedChanges = true
return Promise.resolve({}) return Promise.resolve({})
} }
this.$nextTick(() => { this.$nextTick(() => {
if (this.$el.closest('template-builder')) { if (this.$el.closest('template-builder')) {
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template) this.$el.closest('template-builder').dataset.template = JSON.stringify(toRaw(this.template))
} }
}) })
@@ -3976,6 +4103,8 @@ export default {
}), }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}).then(() => { }).then(() => {
this.hasUnsavedChanges = false
if (this.onSave) { if (this.onSave) {
this.onSave(this.template) this.onSave(this.template)
} }
@@ -3992,10 +4121,12 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
if (this.$el.closest('template-builder')) { if (this.$el.closest('template-builder')) {
this.$el.closest('template-builder').dataset.dynamicDocuments = JSON.stringify(this.dynamicDocuments) this.$el.closest('template-builder').dataset.dynamicDocuments = JSON.stringify(toRaw(this.dynamicDocuments))
} }
}) })
this.pushUndo({ amend: true })
this.save() this.save()
}, },
rebuildVariablesSchema ({ disable = true } = {}) { rebuildVariablesSchema ({ disable = true } = {}) {
@@ -7,7 +7,7 @@
class="absolute top-0 bottom-0 right-0 left-0" class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')" @click.prevent="$emit('close')"
/> />
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 px-6 mt-20 w-full max-w-xl'"> <div :class="inline ? (nativePlatform === 'android' ? 'px-4 pt-1.5 pb-6' : 'px-4 pt-3 pb-6') : 'modal-box pt-4 pb-6 px-6 mt-20 w-full max-w-xl'">
<div <div
v-if="!inline" v-if="!inline"
class="flex justify-between items-center border-b pb-2 mb-2 font-medium" class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
@@ -33,7 +33,7 @@
>{{ t('available_in_pro') }}</a> >{{ t('available_in_pro') }}</a>
</div> </div>
<form @submit.prevent="validateSaveAndClose"> <form @submit.prevent="validateSaveAndClose">
<div class="my-4"> <div :class="inline && nativePlatform === 'android' ? 'mt-1 mb-4' : 'my-4'">
<div <div
v-for="(condition, cindex) in conditions" v-for="(condition, cindex) in conditions"
:key="cindex" :key="cindex"
@@ -168,7 +168,7 @@
<script> <script>
export default { export default {
name: 'ConditionModal', name: 'ConditionModal',
inject: ['t', 'template', 'withConditions'], inject: ['t', 'template', 'withConditions', 'nativePlatform'],
props: { props: {
item: { item: {
type: Object, type: Object,
@@ -7,7 +7,7 @@
class="absolute top-0 bottom-0 right-0 left-0" class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')" @click.prevent="$emit('close')"
/> />
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl'"> <div :class="inline ? (nativePlatform === 'android' ? 'px-4 pt-1.5 pb-6' : '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 <div
v-if="!inline" v-if="!inline"
class="flex justify-between items-center border-b pb-2 mb-2 font-medium" class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
@@ -27,7 +27,7 @@
<div> <div>
<label <label
dir="auto" dir="auto"
class="label text-sm" :class="['label text-sm', { 'pt-0 mt-1': inline && nativePlatform === 'android' }]"
for="description_field" for="description_field"
> >
{{ t('description') }} {{ t('description') }}
@@ -71,7 +71,7 @@
<script> <script>
export default { export default {
name: 'DescriptionModal', name: 'DescriptionModal',
inject: ['t', 'template'], inject: ['t', 'template', 'nativePlatform'],
props: { props: {
field: { field: {
type: Object, type: Object,
@@ -36,7 +36,7 @@
<CropView <CropView
v-if="cropPageItem" v-if="cropPageItem"
class="absolute inset-0 z-10 bg-base-100" class="absolute inset-0 z-10 bg-base-100"
:style="inline ? { paddingTop: '70px' } : null" :style="inline && nativePlatform === 'ios' ? { paddingTop: '70px' } : null"
:template-id="template.id" :template-id="template.id"
:page="cropPageItem" :page="cropPageItem"
:image-url="thumbUrl(cropPageItem)" :image-url="thumbUrl(cropPageItem)"
@@ -47,7 +47,7 @@
<RedactView <RedactView
v-else-if="redactPageItem" v-else-if="redactPageItem"
class="absolute inset-0 z-10 bg-base-100" class="absolute inset-0 z-10 bg-base-100"
:style="inline ? { paddingTop: '70px' } : null" :style="inline && nativePlatform === 'ios' ? { paddingTop: '70px' } : null"
:template-id="template.id" :template-id="template.id"
:page="redactPageItem" :page="redactPageItem"
:image-url="thumbUrl(redactPageItem)" :image-url="thumbUrl(redactPageItem)"
@@ -63,14 +63,14 @@
> >
<div <div
class="flex-1 overflow-y-auto overflow-x-hidden px-4 md:px-6 py-4 space-y-4" class="flex-1 overflow-y-auto overflow-x-hidden px-4 md:px-6 py-4 space-y-4"
:style="inline ? { paddingTop: '86px' } : null" :style="inline && nativePlatform === 'ios' ? { paddingTop: '86px' } : null"
> >
<div <div
v-for="(doc, docIndex) in layout" v-for="(doc, docIndex) in layout"
:key="doc.attachmentUuid" :key="doc.attachmentUuid"
:data-uuid="doc.attachmentUuid" :data-uuid="doc.attachmentUuid"
class="relative" class="relative"
:style="inline ? { scrollMarginTop: '86px' } : null" :style="inline && nativePlatform === 'ios' ? { scrollMarginTop: '86px' } : null"
> >
<div <div
class="flex items-center pb-2" class="flex items-center pb-2"
@@ -191,7 +191,7 @@
:key="page.id" :key="page.id"
:data-page-id="page.id" :data-page-id="page.id"
class="relative min-w-0 cursor-pointer group" class="relative min-w-0 cursor-pointer group"
:style="inline ? { scrollMarginTop: '86px' } : null" :style="inline && nativePlatform === 'ios' ? { scrollMarginTop: '86px' } : null"
draggable="true" draggable="true"
@click="selectedPageId = page.id" @click="selectedPageId = page.id"
@mousedown="onPageMousedown(page, $event)" @mousedown="onPageMousedown(page, $event)"
@@ -260,7 +260,7 @@
</div> </div>
<div <div
class="flex-none px-4 border-t pt-3 flex flex-col gap-3 md:w-56 md:border-t-0 md:border-l md:py-4 md:gap-0" class="flex-none px-4 border-t pt-3 flex flex-col gap-3 md:w-56 md:border-t-0 md:border-l md:py-4 md:gap-0"
:class="{ 'pb-3': !inline }" :class="{ 'pb-3': !inline || nativePlatform === 'android' }"
> >
<div class="grid grid-cols-4 gap-2 md:block md:space-y-2"> <div class="grid grid-cols-4 gap-2 md:block md:space-y-2">
<button <button
@@ -419,7 +419,7 @@ export default {
IconEraser, IconEraser,
IconCrop IconCrop
}, },
inject: ['t', 'baseFetch'], inject: ['t', 'baseFetch', 'nativePlatform'],
props: { props: {
template: { template: {
type: Object, type: Object,
@@ -0,0 +1,177 @@
<template>
<div
class="absolute z-[5] pointer-events-none select-none"
:style="{ top: coords.top + 'px', left: coords.left + 'px', width: coords.width + 'px', height: coords.height + 'px' }"
>
<div
class="absolute left-0 bottom-0"
:style="{ top: Math.max(coords.top - toolbarHeight - 2, 0) - coords.top + 'px' }"
>
<div
ref="toolbar"
class="sticky top-1.5 flex flex-col sm:flex-row gap-0.5 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg pointer-events-auto"
@mousedown.prevent
>
<div
v-for="(row, rowIndex) in actionRows"
:key="rowIndex"
class="flex items-center gap-0.5"
>
<template
v-for="(group, groupIndex) in row"
:key="groupIndex"
>
<div
v-if="rowIndex > 0 || groupIndex > 0"
class="w-px h-5 bg-neutral-200 mx-1"
:class="{ 'hidden sm:block': rowIndex > 0 && groupIndex === 0 }"
/>
<button
v-for="action in group"
:key="action.title"
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="{ 'text-red-600': action.danger, 'bg-neutral-200': action.active, 'hover:bg-neutral-100': !action.active && !isDisabled(action), 'opacity-50 cursor-not-allowed': isDisabled(action) }"
:title="action.structural && !coords.isRegular ? t('irregular_table_structure') : t(action.title)"
:disabled="isDisabled(action)"
@click="$emit('command', action.command, action.arg)"
>
<component
:is="action.icon"
:width="16"
:height="16"
:stroke-width="1.6"
/>
</button>
</template>
</div>
</div>
</div>
<div
v-if="size"
class="absolute top-0 left-0 border border-dashed border-blue-500"
:style="{ width: size.width + 'px', height: size.height + 'px' }"
/>
<div
v-if="coords.type === 'table'"
class="absolute w-3 h-3 bg-white border-2 border-blue-500 rounded-sm pointer-events-auto cursor-nwse-resize touch-none before:absolute before:-inset-3"
:style="{ left: (size?.width ?? coords.width) - 6 + 'px', top: (size?.height ?? coords.height) - 6 + 'px' }"
:title="t('resize')"
@pointerdown.prevent.stop="startResize"
/>
</div>
</template>
<script>
import { h } from 'vue'
import { IconAlignLeft, IconAlignCenter, IconAlignRight, IconRowInsertTop, IconRowInsertBottom, IconColumnInsertLeft, IconColumnInsertRight, IconRowRemove, IconColumnRemove, IconTrash } from '@tabler/icons-vue'
const IconMergeCells = (props) => h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', ...props }, [
h('path', { d: 'M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2z' }),
h('path', { d: 'M6 12h4m-2 -2l2 2l-2 2' }),
h('path', { d: 'M18 12h-4m2 -2l-2 2l2 2' })
])
const IconSplitCell = (props) => h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', ...props }, [
h('path', { d: 'M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2z' }),
h('path', { d: 'M12 4v16' }),
h('path', { d: 'M10 12h-4m2 -2l-2 2l2 2' }),
h('path', { d: 'M14 12h4m-2 -2l2 2l-2 2' })
])
export default {
name: 'DynamicBlockMenu',
inject: ['t'],
props: {
coords: {
type: Object,
required: true
},
getResizeLimits: {
type: Function,
required: true
}
},
emits: ['command', 'resize'],
data () {
return {
size: null,
toolbarHeight: 0
}
},
computed: {
actionRows () {
const alignActions = [
{ command: 'setBlockAlign', arg: 'left', icon: IconAlignLeft, title: 'align_left', active: this.coords.align === 'left' },
{ command: 'setBlockAlign', arg: 'center', icon: IconAlignCenter, title: 'align_center', active: this.coords.align === 'center' },
{ command: 'setBlockAlign', arg: 'right', icon: IconAlignRight, title: 'align_right', active: this.coords.align === 'right' }
]
if (this.coords.type !== 'table') {
return [[alignActions, [{ command: 'deleteSelection', icon: IconTrash, title: 'delete_image', danger: true }]]]
}
return [
[
alignActions,
[
{ command: 'addTableRow', arg: false, icon: IconRowInsertTop, title: 'insert_row_above', structural: true },
{ command: 'addTableRow', arg: true, icon: IconRowInsertBottom, title: 'insert_row_below', structural: true },
{ command: 'addTableColumn', arg: false, icon: IconColumnInsertLeft, title: 'insert_column_left', structural: true },
{ command: 'addTableColumn', arg: true, icon: IconColumnInsertRight, title: 'insert_column_right', structural: true }
]
],
[
[
{ command: 'mergeTableCells', icon: IconMergeCells, title: 'merge_cells', structural: true, disabled: !this.coords.canMerge },
{ command: 'splitTableCell', icon: IconSplitCell, title: 'split_cell', structural: true, disabled: !this.coords.canSplit }
],
[
{ command: 'deleteTableRow', icon: IconRowRemove, title: 'delete_row', danger: true, structural: true },
{ command: 'deleteTableColumn', icon: IconColumnRemove, title: 'delete_column', danger: true, structural: true },
{ command: 'deleteTable', icon: IconTrash, title: 'delete_table', danger: true }
]
]
]
}
},
mounted () {
this.toolbarHeight = this.$refs.toolbar.offsetHeight
},
updated () {
this.toolbarHeight = this.$refs.toolbar.offsetHeight
},
beforeUnmount () {
window.removeEventListener('pointermove', this.onResizeMove)
window.removeEventListener('pointerup', this.onResizeEnd)
},
methods: {
isDisabled (action) {
return (action.structural && !this.coords.isRegular) || action.disabled
},
startResize (event) {
this.resizeStart = { x: event.clientX, y: event.clientY, width: this.coords.width, height: this.coords.height, ...this.getResizeLimits() }
this.size = { width: this.coords.width, height: this.coords.height }
window.addEventListener('pointermove', this.onResizeMove)
window.addEventListener('pointerup', this.onResizeEnd)
},
onResizeMove (event) {
const { x, y, width, height, minHeight, maxWidth } = this.resizeStart
this.size = {
width: Math.min(Math.max(width + event.clientX - x, 20), maxWidth),
height: Math.max(height + event.clientY - y, minHeight)
}
},
onResizeEnd () {
window.removeEventListener('pointermove', this.onResizeMove)
window.removeEventListener('pointerup', this.onResizeEnd)
if (this.size.width !== this.coords.width || this.size.height !== this.coords.height) {
this.$emit('resize', this.size)
}
this.size = null
}
}
}
</script>
@@ -22,6 +22,12 @@
v-if="shadow" v-if="shadow"
:to="shadow" :to="shadow"
> >
<component
:is="'style'"
v-if="listsStyle"
>
{{ listsStyle }}
</component>
<DynamicSection <DynamicSection
v-for="section in sections" v-for="section in sections"
:ref="setSectionRefs" :ref="setSectionRefs"
@@ -44,15 +50,17 @@
@draw="$emit('draw', $event)" @draw="$emit('draw', $event)"
@add-custom-field="$emit('add-custom-field', $event)" @add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)" @set-draw="$emit('set-draw', $event)"
@add-attachment="document.attachments.push($event)"
@add-list="ensureListsStyle"
/> />
</Teleport> </Teleport>
</div> </div>
</template> </template>
<script> <script>
import { ref } from 'vue' import { ref, computed } from 'vue'
import DynamicSection from './dynamic_section.vue' import DynamicSection from './dynamic_section.vue'
import { dynamicStylesheet, tiptapStylesheet } from './dynamic_editor.js' import { dynamicStylesheet, tiptapStylesheet, listsCss } from './dynamic_editor.js'
import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js' import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js'
export default { export default {
@@ -61,6 +69,11 @@ export default {
DynamicSection DynamicSection
}, },
inject: ['baseFetch', 'template'], inject: ['baseFetch', 'template'],
provide () {
return {
documentFonts: computed(() => this.documentFonts)
}
},
props: { props: {
document: { document: {
type: Object, type: Object,
@@ -71,6 +84,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
autosave: {
type: Boolean,
required: false,
default: true
},
selectedSubmitter: { selectedSubmitter: {
type: Object, type: Object,
required: false, required: false,
@@ -107,6 +125,7 @@ export default {
return { return {
containerWidth: 1040, containerWidth: 1040,
isMounted: false, isMounted: false,
listsStyle: null,
sectionRefs: [] sectionRefs: []
} }
}, },
@@ -134,6 +153,40 @@ export default {
styles () { styles () {
return this.headDom.querySelectorAll('style') return this.headDom.querySelectorAll('style')
}, },
documentFonts () {
const fonts = []
const firstFamily = (value) => value.split(',')[0].trim().replace(/^["']|["']$/g, '')
const add = (label, value) => {
if (label && !fonts.some((font) => font.label === label)) fonts.push({ label, value })
}
this.styles.forEach((style) => {
const css = style.textContent
for (const type of ['minor', 'major']) {
for (const [, name, value] of css.matchAll(new RegExp(`(--[\\w-]+-${type}HAnsi-font):\\s*([^;}]+)`, 'g'))) {
add(firstFamily(value), `var(${name})`)
}
}
for (const [, value] of css.matchAll(/@font-face\s*\{[^}]*?font-family:\s*([^;}]+)/g)) {
add(firstFamily(value), value.trim())
}
})
const skipFamilies = ['symbol', 'wingdings', 'wingdings 2', 'wingdings 3', 'webdings', 'serif', 'sans-serif', 'monospace', 'inherit', 'initial']
const addUsed = (value) => {
if (value && !value.startsWith('var(') && !skipFamilies.includes(firstFamily(value).toLowerCase())) add(firstFamily(value), value.trim())
}
this.styles.forEach((style) => {
for (const [, value] of style.textContent.replace(/@font-face\s*\{[^}]*\}/g, '').matchAll(/font-family:\s*([^;}]+)/g)) addUsed(value)
})
this.bodyDom.querySelectorAll('[style*="font-family"]').forEach((el) => addUsed(el.style.fontFamily))
return fonts
},
shadow () { shadow () {
if (this.isMounted) { if (this.isMounted) {
return this.$refs.shadow.attachShadow({ mode: 'open' }) return this.$refs.shadow.attachShadow({ mode: 'open' })
@@ -142,16 +195,21 @@ export default {
} }
} }
}, },
created () {
this.updatedSectionEditors = new Map()
this.loadListsStyle()
},
mounted () { mounted () {
this.isMounted = true this.isMounted = true
this.shadow.adoptedStyleSheets.push(dynamicStylesheet, tiptapStylesheet) this.shadow.adoptedStyleSheets.push(dynamicStylesheet, tiptapStylesheet)
this.containerWidth = this.$refs.container.clientWidth this.updateContainerWidth()
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
if (this.$refs.container) { if (this.$refs.container) {
this.containerWidth = this.$refs.container.clientWidth this.updateContainerWidth()
} }
}) })
@@ -169,6 +227,9 @@ export default {
}, },
methods: { methods: {
mergeSchemaProperties, mergeSchemaProperties,
updateContainerWidth () {
this.containerWidth = this.$refs.container.clientWidth
},
removeArea (area) { removeArea (area) {
this.sectionRefs.forEach((sectionRef) => { this.sectionRefs.forEach((sectionRef) => {
const pos = sectionRef.findAreaNodePos(area.uuid) const pos = sectionRef.findAreaNodePos(area.uuid)
@@ -184,8 +245,18 @@ export default {
} }
}, },
reloadContent () { reloadContent () {
this.loadListsStyle()
this.sectionRefs.forEach((ref) => ref.reloadContent()) this.sectionRefs.forEach((ref) => ref.reloadContent())
}, },
getSectionDocs () {
return this.sectionRefs.map((ref) => [ref.sectionKey, ref.editor.state.doc])
},
restoreSectionDocs (sectionDocs) {
this.sectionRefs.forEach((ref) => {
if (sectionDocs.has(ref.sectionKey)) ref.restoreDoc(sectionDocs.get(ref.sectionKey))
})
},
onBeforeUnload (event) { onBeforeUnload (event) {
if (this.saveTimer) { if (this.saveTimer) {
event.preventDefault() event.preventDefault()
@@ -211,27 +282,49 @@ export default {
onSectionUpdate (section, { editor }) { onSectionUpdate (section, { editor }) {
clearTimeout(this.saveTimer) clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(async () => { this.updatedSectionEditors.set(section.id, editor)
await this.updateSectionAndSave(section, editor)
delete this.saveTimer this.saveTimer = setTimeout(() => this.autosave ? this.updateSectionsAndSave() : this.updateSections(), 1000)
}, 1000) },
loadListsStyle () {
this.listsStyle = this.bodyDom.querySelector('body > style[data-lists]')?.textContent || null
},
ensureListsStyle () {
if (this.listsStyle) return
const style = this.bodyDom.createElement('style')
style.dataset.lists = ''
style.textContent = listsCss
this.bodyDom.body.append(style)
this.listsStyle = listsCss
}, },
updateVariablesSchema () { updateVariablesSchema () {
this.document.variables_schema = buildVariablesSchema(this.bodyDom.body) this.document.variables_schema = buildVariablesSchema(this.bodyDom.body)
}, },
updateSectionAndSave (section, editor) { updateSections () {
const target = this.bodyDom.getElementById(section.id) delete this.saveTimer
if (target) { this.updatedSectionEditors.forEach((editor, sectionId) => {
target.innerHTML = this.getHtmlForSave(editor) const target = this.bodyDom.getElementById(sectionId)
}
if (target) {
target.innerHTML = this.getHtmlForSave(editor)
}
})
this.updatedSectionEditors.clear()
this.document.body = this.bodyDom.body.innerHTML this.document.body = this.bodyDom.body.innerHTML
this.updateVariablesSchema() this.updateVariablesSchema()
this.$emit('update', this.document) this.$emit('update', this.document)
},
updateSectionsAndSave () {
this.updateSections()
return this.saveBody() return this.saveBody()
}, },
@@ -245,6 +338,8 @@ export default {
delete this.saveTimer delete this.saveTimer
this.updatedSectionEditors.clear()
this.sectionRefs.forEach(({ section, editor }) => { this.sectionRefs.forEach(({ section, editor }) => {
const target = this.bodyDom.getElementById(section.id) const target = this.bodyDom.getElementById(section.id)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,205 @@
<template>
<div class="absolute top-7 left-0 min-w-36 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg">
<div
class="relative"
@pointerenter="$event.pointerType === 'mouse' ? submenu = 'field' : null"
>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
:class="{ 'bg-neutral-100': submenu === 'field' }"
@click="submenu = submenu === 'field' ? null : 'field'"
>
<IconForms
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('field') }}
<IconChevronRight
:width="14"
:height="14"
class="ml-auto"
/>
</button>
<div
v-if="submenu === 'field'"
class="absolute top-0 left-full min-w-40 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="(icon, type) in fieldTypesList"
:key="type"
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100"
@click="[$emit('insert-field', type), $emit('close')]"
>
<component
:is="icon"
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ fieldNames[type] }}
</button>
</div>
</div>
<div
class="relative"
@pointerenter="$event.pointerType === 'mouse' ? submenu = 'table' : null"
>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
:class="{ 'bg-neutral-100': submenu === 'table' }"
@click="submenu = submenu === 'table' ? null : 'table'"
>
<IconTable
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('table') }}
<IconChevronRight
:width="14"
:height="14"
class="ml-auto"
/>
</button>
<div
v-if="submenu === 'table'"
class="absolute top-0 left-full w-max p-2 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<div
v-if="!isMobile"
class="grid grid-cols-8 gap-0.5 mb-2"
>
<template
v-for="row in 8"
:key="row"
>
<button
v-for="col in 8"
:key="col"
class="w-4 h-4 border rounded-sm"
:class="row <= tableSize.rows && col <= tableSize.cols ? 'bg-neutral-200 border-neutral-400' : 'bg-white border-neutral-200'"
:aria-label="`${row} × ${col}`"
@pointerenter="$event.pointerType === 'mouse' ? tableSize = { rows: row, cols: col } : null"
@click="[$emit('insert-table', { rows: row, cols: col }), $emit('close')]"
/>
</template>
</div>
<div class="flex items-center justify-center gap-1 text-xs">
<template
v-for="key in ['rows', 'cols']"
:key="key"
>
<span v-if="key === 'cols'">×</span>
<div class="flex items-center border border-neutral-200 rounded-md">
<button
class="inline-flex items-center justify-center w-6 h-6 rounded-md hover:bg-neutral-100"
:aria-label="t(`decrease_${key}`)"
@click="tableSize[key] = Math.max(tableSize[key] - 1, 1)"
>
<IconMinus
:width="12"
:height="12"
/>
</button>
<span class="w-5 text-center">{{ tableSize[key] }}</span>
<button
class="inline-flex items-center justify-center w-6 h-6 rounded-md hover:bg-neutral-100"
:aria-label="t(`increase_${key}`)"
@click="tableSize[key] = Math.min(tableSize[key] + 1, 20)"
>
<IconPlus
:width="12"
:height="12"
/>
</button>
</div>
</template>
</div>
<button
v-if="isMobile || tableSize.rows > 8 || tableSize.cols > 8"
class="w-full mt-2 px-2 py-1 rounded-md bg-neutral-100 hover:bg-neutral-200"
@click="[$emit('insert-table', { ...tableSize }), $emit('close')]"
>
{{ t('insert') }}
</button>
</div>
</div>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100"
@mouseenter="submenu = null"
@click="[$emit('insert-variable'), $emit('close')]"
>
<IconBracketsContain
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('variable') }}
</button>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100"
@mouseenter="submenu = null"
@click="[$emit('insert-image'), $emit('close')]"
>
<IconPhoto
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('image') }}
</button>
</div>
</template>
<script>
import { IconPlus, IconMinus, IconForms, IconTable, IconPhoto, IconChevronRight, IconBracketsContain } from '@tabler/icons-vue'
import FieldType from './field_type.vue'
export default {
name: 'DynamicInsertMenu',
components: {
IconPlus,
IconMinus,
IconForms,
IconTable,
IconPhoto,
IconChevronRight,
IconBracketsContain
},
inject: ['withPhone', 'withPayment', 'withVerification', 'withKba', 't', 'fieldTypes', 'isMobile'],
emits: ['insert-field', 'insert-table', 'insert-variable', 'insert-image', 'close'],
data () {
return {
submenu: null,
tableSize: { rows: 3, cols: 3 }
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
skipTypes: FieldType.computed.skipTypes,
fieldIconsSorted: FieldType.computed.fieldIconsSorted,
fieldTypesList () {
return Object.fromEntries(Object.entries(this.fieldIconsSorted).filter(([type]) => {
return this.fieldTypes.includes(type) || ((this.withPhone || type !== 'phone') && (this.withPayment || type !== 'payment') && (this.withVerification || type !== 'verification') && (this.withKba || type !== 'kba'))
}))
}
},
mounted () {
document.addEventListener('mousedown', this.onDocumentMouseDown)
},
beforeUnmount () {
document.removeEventListener('mousedown', this.onDocumentMouseDown)
this.$emit('close')
},
methods: {
onDocumentMouseDown (event) {
if (!event.composedPath().includes(this.$el.parentElement)) {
this.$emit('close')
}
}
}
}
</script>
+575 -109
View File
@@ -1,107 +1,360 @@
<template> <template>
<div <div
v-if="visible" v-if="visible"
class="absolute z-10 flex items-center gap-0.5 px-1.5 py-1 bg-white border border-base-300 rounded-lg shadow select-none" ref="menu"
:style="{ top: (coords.top - 42) + 'px', left: coords.left + 'px', transform: 'translateX(-50%)' }" class="absolute z-10 flex flex-col gap-1 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg select-none text-sm"
:style="{ top: coords.top + 'px', left: menuLeft + 'px' }"
@mousedown.prevent @mousedown.prevent
> >
<button <div class="flex items-center gap-0.5">
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700" <div class="relative">
:class="isBold ? 'bg-base-200' : 'bg-transparent'" <button
title="Bold" class="flex items-center justify-between gap-1 w-24 sm:w-36 h-7 px-2 border border-neutral-200 rounded-md hover:bg-neutral-100"
@click="toggleBold" :title="t('font')"
> @click="toggleDropdown('font')"
<IconBold >
:width="16" <span
:height="16" class="truncate"
/> :style="{ fontFamily }"
</button> >{{ fontFamily }}</span>
<button <IconChevronDown
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700" :width="14"
:class="isItalic ? 'bg-base-200' : 'bg-transparent'" :height="14"
title="Italic" />
@click="toggleItalic" </button>
> <div
<IconItalic v-if="dropdown === 'font'"
:width="16" ref="dropdown"
:height="16" :class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
/> class="absolute z-10 top-full mt-1 w-48 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-64 overflow-y-auto"
</button> >
<button <button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700" v-for="font in fonts"
:class="isUnderline ? 'bg-base-200' : 'bg-transparent'" :key="font.label"
title="Underline" class="block w-full text-left px-2 py-1 rounded-md truncate"
@click="toggleUnderline" :class="font.label === fontFamily ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
> :style="{ fontFamily: font.value }"
<IconUnderline @click="setSpanStyle('font-family', font.value)"
:width="16" >
:height="16" {{ font.label }}
/> </button>
</button> </div>
<button </div>
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700" <div class="relative">
:class="isStrike ? 'bg-base-200' : 'bg-transparent'" <button
title="Strikethrough" class="flex items-center justify-between gap-1 w-12 sm:w-16 h-7 px-2 border border-neutral-200 rounded-md hover:bg-neutral-100"
@click="toggleStrike" :title="t('font_size')"
> @click="toggleDropdown('size')"
<IconStrikethrough >
:width="16" <span>{{ fontSize }}</span>
:height="16" <IconChevronDown
/> :width="14"
</button> :height="14"
<div class="w-px h-5 bg-base-300 mx-1" /> />
<button </button>
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent" <div
title="Wrap in variable" v-if="dropdown === 'size'"
@click="wrapVariable" ref="dropdown"
> :class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
<IconBracketsContain class="absolute z-10 top-full mt-1 w-16 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-64 overflow-y-auto"
:width="16" >
:height="16" <button
:stroke-width="1.6" v-for="size in fontSizes"
/> :key="size"
<span class="px-0.5"> class="block w-full text-left px-2 py-1 rounded-md"
Variable :class="size === fontSize ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
</span> @click="setFontSize(size)"
</button> >
<div class="w-px h-5 bg-base-300 mx-1" /> {{ size }}
<button </button>
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent" </div>
title="Wrap in condition" </div>
@click="wrapCondition" <button
> class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100"
<svg :title="t('increase_font_size')"
xmlns="http://www.w3.org/2000/svg" @click="setFontSize(fontSizes.find((size) => size > fontSize) || fontSize)"
width="16" >
height="16" <IconTextIncrease
viewBox="0 0 24 24" :width="16"
fill="none" :height="16"
stroke="currentColor" />
stroke-width="1.6" </button>
stroke-linecap="round" <button
stroke-linejoin="round" class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100"
class="tabler-icon tabler-icon-brackets-contain" :title="t('decrease_font_size')"
><path d="M7 4h-4v16h4" /><path d="M17 4h4v16h-4" /> @click="setFontSize([...fontSizes].reverse().find((size) => size < fontSize) || fontSize)"
<text >
x="12" <IconTextDecrease
y="16.5" :width="16"
text-anchor="middle" :height="16"
fill="currentColor" />
stroke="none" </button>
font-size="14" <div class="w-px h-5 bg-neutral-200 mx-1" />
font-weight="600" <div class="relative">
font-family="ui-sans-serif, system-ui, sans-serif" <button
>if</text> class="inline-flex items-center justify-center h-7 px-1 rounded-md hover:bg-neutral-100"
</svg> :title="t('line_spacing')"
<span class="px-0.5"> @click="toggleDropdown('lineHeight')"
Condition >
</span> <IconLineHeight
</button> :width="16"
:height="16"
/>
<IconChevronDown
:width="12"
:height="12"
/>
</button>
<div
v-if="dropdown === 'lineHeight'"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-20 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="value in lineHeights"
:key="value"
class="block w-full text-left px-2 py-1 rounded-md"
:class="value === lineHeight ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
@click="setBlockStyle('line-height', `calc(${value} * 1.15)`)"
>
{{ value }}
</button>
</div>
</div>
<div class="relative">
<button
class="inline-flex items-center justify-center h-7 px-1 rounded-md hover:bg-neutral-100"
:title="t('align')"
@click="toggleDropdown('align')"
>
<component
:is="alignIcons[textAlign]"
:width="16"
:height="16"
/>
<IconChevronDown
:width="12"
:height="12"
/>
</button>
<div
v-if="dropdown === 'align'"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-max flex gap-0.5 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="(icon, align) in alignIcons"
:key="align"
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="align === textAlign ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t(align)"
@click="setBlockStyle('text-align', align)"
>
<component
:is="icon"
:width="16"
:height="16"
/>
</button>
</div>
</div>
<div class="relative">
<button
class="inline-flex items-center justify-center h-7 px-1 rounded-md"
:class="listStyle ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t('bullet_list')"
@click="toggleDropdown('list')"
>
<IconList
:width="16"
:height="16"
/>
<IconChevronDown
:width="12"
:height="12"
/>
</button>
<div
v-if="dropdown === 'list'"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-28 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="({ label, marker }, style) in listStyles"
:key="style"
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
:class="style === listStyle ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
@click="toggleList(style)"
>
<span class="w-4 text-center">{{ marker }}</span>
{{ label }}
</button>
</div>
</div>
</div>
<div class="flex items-center gap-0.5">
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="isBold ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t('bold')"
@click="toggleBold"
>
<IconBold
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="isItalic ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t('italic')"
@click="toggleItalic"
>
<IconItalic
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="isUnderline ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t('underline')"
@click="toggleUnderline"
>
<IconUnderline
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="isStrike ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t('strikethrough')"
@click="toggleStrike"
>
<IconStrikethrough
:width="16"
:height="16"
/>
</button>
<div class="w-px h-5 bg-neutral-200 mx-1" />
<div
v-for="colorProperty in ['color', 'background-color']"
:key="colorProperty"
class="relative"
>
<button
class="inline-flex items-center justify-center h-7 px-1 rounded-md hover:bg-neutral-100"
:title="colorProperty === 'color' ? t('text_color') : t('background_color')"
@click="toggleDropdown(colorProperty)"
>
<span class="flex flex-col items-center">
<component
:is="colorProperty === 'color' ? 'IconLetterA' : 'IconHighlight'"
:width="16"
:height="14"
/>
<span
class="w-4 h-1 rounded-sm border border-neutral-200"
:style="{ backgroundColor: colorProperty === 'color' ? color : backgroundColor }"
/>
</span>
<IconChevronDown
:width="12"
:height="12"
/>
</button>
<div
v-if="dropdown === colorProperty"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-max p-2 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
class="flex items-center gap-1 w-full mb-2 px-2 py-1 rounded-md hover:bg-neutral-100"
@click="setSpanStyle(colorProperty, null)"
>
<IconX
:width="14"
:height="14"
/>
{{ colorProperty === 'color' ? t('default') : t('none') }}
</button>
<div class="grid grid-cols-10 gap-1">
<button
v-for="value in colors"
:key="value"
class="w-4 h-4 rounded-sm border border-neutral-200 hover:scale-125 transition-transform"
:style="{ backgroundColor: value }"
:title="value"
@click="setSpanStyle(colorProperty, value)"
/>
</div>
</div>
</div>
<div class="w-px h-5 bg-neutral-200 mx-1 sm:mx-0.5" />
<button
class="inline-flex items-center justify-center text-xs h-7 px-1 rounded-md"
:class="isCellSelection ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-100'"
:title="t('create_variable')"
:disabled="isCellSelection"
@click="wrapVariable"
>
<IconBracketsContain
:width="16"
:height="16"
:stroke-width="1.6"
/>
<span class="hidden sm:inline px-0.5 font-mono">
{{ t('variable') }}
</span>
</button>
<div class="w-px h-5 bg-neutral-200 mx-1 sm:mx-0.5" />
<button
class="inline-flex items-center justify-center text-xs h-7 px-1 rounded-md"
:class="isCellSelection ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-100'"
:title="t('create_condition')"
:disabled="isCellSelection"
@click="wrapCondition"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
class="tabler-icon tabler-icon-brackets-contain"
><path d="M7 4h-4v16h4" /><path d="M17 4h4v16h-4" />
<text
x="12"
y="16.5"
text-anchor="middle"
fill="currentColor"
stroke="none"
font-size="14"
font-weight="600"
font-family="ui-sans-serif, system-ui, sans-serif"
>if</text>
</svg>
<span class="hidden sm:inline px-0.5 font-mono">
{{ t('condition') }}
</span>
</button>
</div>
</div> </div>
</template> </template>
<script> <script>
import { IconBold, IconItalic, IconUnderline, IconStrikethrough, IconBracketsContain } from '@tabler/icons-vue' import { IconBold, IconItalic, IconUnderline, IconStrikethrough, IconBracketsContain, IconChevronDown, IconTextIncrease, IconTextDecrease, IconLineHeight, IconAlignLeft, IconAlignCenter, IconAlignRight, IconAlignJustified, IconLetterA, IconHighlight, IconList, IconX } from '@tabler/icons-vue'
import { CellSelection } from '@tiptap/pm/tables'
import { findNumbering, numberingListStyle } from './dynamic_editor.js'
export default { export default {
name: 'DynamicMenu', name: 'DynamicMenu',
@@ -110,8 +363,17 @@ export default {
IconItalic, IconItalic,
IconUnderline, IconUnderline,
IconStrikethrough, IconStrikethrough,
IconBracketsContain IconBracketsContain,
IconChevronDown,
IconTextIncrease,
IconTextDecrease,
IconLineHeight,
IconLetterA,
IconHighlight,
IconList,
IconX
}, },
inject: ['t', 'documentFonts'],
props: { props: {
editor: { editor: {
type: Object, type: Object,
@@ -123,23 +385,90 @@ export default {
default: null default: null
} }
}, },
emits: ['add-variable', 'add-condition'], emits: ['add-variable', 'add-condition', 'add-list'],
data () { data () {
return { return {
isMouseDown: false, isMouseDown: false,
isBold: this.editor.isActive('bold'), dropdown: null,
isItalic: this.editor.isActive('italic'), isDropdownAlignedRight: false,
isUnderline: this.editor.isActive('underline'), menuWidth: 0,
isStrike: this.editor.isActive('strike') isBold: false,
isItalic: false,
isUnderline: false,
isStrike: false,
fontFamily: '',
fontSize: 12,
color: '',
backgroundColor: '',
lineHeight: '',
textAlign: 'left',
listStyle: null,
isCellSelection: false
} }
}, },
computed: { computed: {
visible () { visible () {
return !!this.coords && !this.isMouseDown return !!this.coords && !this.isMouseDown
},
menuLeft () {
return Math.max(4, Math.min(this.coords.left - this.menuWidth / 2, this.coords.width - this.menuWidth - 4))
},
standardFonts () {
return [
{ label: 'Arial', value: 'Arial' },
{ label: 'Times New Roman', value: "'Times New Roman'" },
{ label: 'Courier New', value: "'Courier New'" }
]
},
fonts () {
return [
...this.documentFonts.filter((font) => !this.standardFonts.some((standardFont) => standardFont.label === font.label)),
...this.standardFonts
]
},
fontSizes () {
return [8, 9, 10, 10.5, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72]
},
lineHeights () {
return ['1', '1.15', '1.5', '2', '2.5', '3']
},
alignIcons () {
return {
left: IconAlignLeft,
center: IconAlignCenter,
right: IconAlignRight,
justify: IconAlignJustified
}
},
listStyles () {
return {
disc: { label: this.t('dots'), marker: '•' },
dash: { label: this.t('dash'), marker: '–' },
decimal: { label: this.t('numbers'), marker: '1.' }
}
},
colors () {
return [
'#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#efefef', '#f3f3f3', '#ffffff',
'#980000', '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#0000ff', '#9900ff', '#ff00ff',
'#e6b8af', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#cfe2f3', '#d9d2e9', '#ead1dc',
'#cc4125', '#e06666', '#f6b26b', '#ffd966', '#93c47d', '#76a5af', '#6d9eeb', '#6fa8dc', '#8e7cc3', '#c27ba0',
'#85200c', '#990000', '#b45f06', '#bf9000', '#38761d', '#134f5c', '#1155cc', '#0b5394', '#351c75', '#741b47'
]
}
},
watch: {
visible (value) {
this.dropdown = null
if (value) {
this.readState()
this.$nextTick(this.measure)
}
} }
}, },
mounted () { mounted () {
this.editor.view.dom.addEventListener('mousedown', this.onMouseDown) this.editor.view.dom.parentElement.addEventListener('mousedown', this.onMouseDown)
document.addEventListener('mouseup', this.onMouseUp) document.addEventListener('mouseup', this.onMouseUp)
@@ -147,18 +476,79 @@ export default {
}, },
beforeUnmount () { beforeUnmount () {
if (!this.editor.isDestroyed) { if (!this.editor.isDestroyed) {
this.editor.view.dom.removeEventListener('mousedown', this.onMouseDown) this.editor.view.dom.parentElement.removeEventListener('mousedown', this.onMouseDown)
this.editor.off('transaction', this.onTransaction) this.editor.off('transaction', this.onTransaction)
} }
document.removeEventListener('mouseup', this.onMouseUp) document.removeEventListener('mouseup', this.onMouseUp)
}, },
methods: { methods: {
measure () {
if (this.$refs.menu) {
this.menuWidth = this.$refs.menu.offsetWidth
}
},
toggleDropdown (name) {
this.dropdown = this.dropdown === name ? null : name
this.isDropdownAlignedRight = false
this.$nextTick(() => {
const dropdownEl = [this.$refs.dropdown].flat()[0]
if (!dropdownEl) return
const dropdownRect = dropdownEl.getBoundingClientRect()
const containerRect = this.$el.parentElement.getBoundingClientRect()
this.isDropdownAlignedRight = dropdownRect.right > containerRect.right
})
},
toggleBold () { toggleBold () {
this.editor.chain().focus().toggleBold().run() this.toggleStyledMark('bold', 'font-weight', this.isBold, (style) => parseInt(style.fontWeight) >= 600)
}, },
toggleItalic () { toggleItalic () {
this.editor.chain().focus().toggleItalic().run() this.toggleStyledMark('italic', 'font-style', this.isItalic, (style) => style.fontStyle === 'italic')
},
toggleStyledMark (markName, property, isActive, isStyled) {
const isSegmentStyled = ({ from }) => isStyled(getComputedStyle(this.textElementAt(from)))
if (isActive) {
this.editor.chain().focus().unsetMark(markName).setListMarkerStyle(property, null).run()
if (this.selectedTextSegments().some(isSegmentStyled)) {
this.editor.chain().setSpanStyle(property, 'normal').run()
}
} else {
this.editor.chain().focus().setSpanStyle(property, null).run()
const { state, view } = this.editor
const tr = state.tr
this.selectedTextSegments().forEach((segment) => {
if (!isSegmentStyled(segment)) tr.addMark(segment.from, segment.to, state.schema.marks[markName].create())
})
if (tr.docChanged) view.dispatch(tr)
this.editor.commands.setListMarkerStyle(property, markName)
}
},
selectedTextSegments () {
const { state } = this.editor
const segments = []
state.selection.ranges.forEach(({ $from, $to }) => state.doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
if (node.isText) segments.push({ from: Math.max(pos, $from.pos), to: Math.min(pos + node.nodeSize, $to.pos) })
}))
return segments
},
textElementAt (pos) {
const { node, offset } = this.editor.view.domAtPos(pos, 1)
if (node.nodeType === Node.TEXT_NODE) return node.parentElement
return node.childNodes[offset]?.nodeType === Node.ELEMENT_NODE ? node.childNodes[offset] : node
}, },
toggleUnderline () { toggleUnderline () {
this.editor.chain().focus().toggleUnderline().run() this.editor.chain().focus().toggleUnderline().run()
@@ -166,6 +556,30 @@ export default {
toggleStrike () { toggleStrike () {
this.editor.chain().focus().toggleStrike().run() this.editor.chain().focus().toggleStrike().run()
}, },
setSpanStyle (property, value) {
this.dropdown = null
const chain = this.editor.chain().focus().setSpanStyle(property, value)
if (property !== 'background-color') chain.setListMarkerStyle(property, value)
chain.run()
},
setFontSize (size) {
this.setSpanStyle('font-size', `${size}pt`)
},
setBlockStyle (property, value) {
this.dropdown = null
this.editor.chain().focus().setBlockStyle(property, value).run()
},
toggleList (style) {
this.dropdown = null
if (style !== this.listStyle) this.$emit('add-list')
this.editor.chain().focus().toggleList(style).run()
},
wrapVariable () { wrapVariable () {
const { from, to } = this.editor.state.selection const { from, to } = this.editor.state.selection
const replacement = '[[variable]]' const replacement = '[[variable]]'
@@ -201,10 +615,62 @@ export default {
}, 1) }, 1)
}, },
onTransaction () { onTransaction () {
this.isBold = this.editor.isActive('bold') if (this.visible) {
this.isItalic = this.editor.isActive('italic') this.readState()
this.isUnderline = this.editor.isActive('underline') }
this.isStrike = this.editor.isActive('strike') },
readState () {
const { view, state } = this.editor
const { ranges } = state.selection
const selectionFrom = Math.min(...ranges.map((range) => range.$from.pos))
const selectionTo = Math.max(...ranges.map((range) => range.$to.pos))
let from = null
state.doc.nodesBetween(selectionFrom, selectionTo, (node, pos) => {
if (from === null && node.isText) from = Math.max(pos, selectionFrom)
})
if (from === null) from = selectionFrom
const $from = state.doc.resolve(from)
const el = this.textElementAt(from)
const style = getComputedStyle(el)
const blockDom = $from.parent.isTextblock ? view.nodeDOM($from.before()) : view.dom
const decorations = []
this.isCellSelection = state.selection instanceof CellSelection
this.isBold = parseInt(style.fontWeight) >= 600
this.isItalic = style.fontStyle === 'italic'
this.fontFamily = style.fontFamily.split(',')[0].replace(/["']/g, '').trim()
this.fontSize = Math.round(parseFloat(style.fontSize) * 0.75 * 2) / 2
this.color = style.color
this.backgroundColor = 'transparent'
for (let inlineEl = el; inlineEl && view.dom.contains(inlineEl); inlineEl = inlineEl.parentElement) {
const { textDecorationLine, backgroundColor } = getComputedStyle(inlineEl)
decorations.push(textDecorationLine)
if (inlineEl === blockDom) break
if (this.backgroundColor === 'transparent' && backgroundColor !== 'rgba(0, 0, 0, 0)' && backgroundColor !== 'transparent') {
this.backgroundColor = backgroundColor
}
}
this.isUnderline = this.editor.isActive('underline') || decorations.some((line) => line.includes('underline'))
this.isStrike = this.editor.isActive('strike') || decorations.some((line) => line.includes('line-through'))
if ($from.parent.isTextblock) {
const blockStyle = getComputedStyle(blockDom)
const align = blockStyle.textAlign.replace('start', 'left').replace('end', 'right')
this.textAlign = this.alignIcons[align] ? align : 'left'
this.lineHeight = String(Math.round(parseFloat(blockStyle.lineHeight) / parseFloat(blockStyle.fontSize) / 1.15 * 100) / 100)
}
this.listStyle = findNumbering($from.parent) ? numberingListStyle(view, $from.before()) : null
} }
} }
} }
@@ -2,13 +2,17 @@
<div <div
class="relative bg-white select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute" class="relative bg-white select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute"
:class="{ 'cursor-crosshair': isDrawMode && editable }" :class="{ 'cursor-crosshair': isDrawMode && editable }"
@mousedown="onMarginMouseDown"
> >
<div <div
v-if="isDrawMode && editable && cursorHighlightCoords" v-if="isDrawMode && editable && cursorHighlightCoords"
class="absolute pointer-events-none z-10 bg-black" class="absolute pointer-events-none z-10 bg-black"
:style="{ width: '1px', height: cursorHighlightCoords.height + 'px', left: cursorHighlightCoords.x + 'px', top: cursorHighlightCoords.y + 'px' }" :style="{ width: '1px', height: cursorHighlightCoords.height + 'px', left: cursorHighlightCoords.x + 'px', top: cursorHighlightCoords.y + 'px' }"
/> />
<div :style="{ zoom: containerWidth / sectionWidthPx }"> <div
:style="{ zoom, '--zoom': zoom }"
:data-zoomed-out="zoom < 0.75 || null"
>
<section <section
:id="section.id" :id="section.id"
ref="editorElement" ref="editorElement"
@@ -17,6 +21,13 @@
:style="section.style.cssText" :style="section.style.cssText"
/> />
</div> </div>
<input
ref="imageInput"
type="file"
accept="image/*"
class="hidden"
@change="onImageSelected"
>
<Teleport <Teleport
v-if="editor" v-if="editor"
:to="container" :to="container"
@@ -30,6 +41,7 @@
:area="selectedArea" :area="selectedArea"
:field="selectedField" :field="selectedField"
:editable="editable" :editable="editable"
:is-mobile="isMobile"
:template="template" :template="template"
:selected-areas-ref="selectedAreasRef" :selected-areas-ref="selectedAreasRef"
:get-field-type-index="getFieldTypeIndex" :get-field-type-index="getFieldTypeIndex"
@@ -41,9 +53,50 @@
v-if="editable" v-if="editable"
v-show="!selectedAreasRef.value.length" v-show="!selectedAreasRef.value.length"
:editor="editor" :editor="editor"
:coords="dynamicMenuCoords" :coords="isInsertMenuOpen ? null : dynamicMenuCoords"
@add-variable="dynamicMenuCoords = null" @add-variable="dynamicMenuCoords = null"
@add-condition="dynamicMenuCoords = null" @add-condition="dynamicMenuCoords = null"
@add-list="$emit('add-list')"
/>
<div
v-if="editable && insertMenuCoords && !isDrawMode"
class="absolute z-10 select-none text-sm"
:style="{ top: insertMenuCoords.top - 12 + 'px', left: insertMenuCoords.left - 12 + 'px' }"
@mousedown.prevent
>
<button
class="flex items-center justify-center w-6 h-6 rounded-md border border-neutral-200"
:class="isInsertMenuOpen ? 'bg-neutral-100 text-base-content' : 'bg-white text-base-content/40 hover:bg-neutral-100 hover:text-base-content'"
:title="t('insert')"
@click="isInsertMenuOpen = !isInsertMenuOpen"
>
<IconX
v-if="isInsertMenuOpen"
:width="14"
:height="14"
/>
<IconPlus
v-else
:width="14"
:height="14"
/>
</button>
<DynamicInsertMenu
v-if="isInsertMenuOpen"
:key="insertMenuCoords.top"
@close="isInsertMenuOpen = false"
@insert-field="onInsertField"
@insert-table="onInsertTable"
@insert-variable="editor.chain().focus().insertContent('[[variable]]').run()"
@insert-image="$refs.imageInput.click()"
/>
</div>
<DynamicBlockMenu
v-if="editable && blockMenuCoords && !isDrawMode"
:coords="blockMenuCoords"
:get-resize-limits="getBlockResizeLimits"
@command="onBlockCommand"
@resize="onBlockResize"
/> />
<FieldContextMenu <FieldContextMenu
v-if="contextMenu && contextMenuField" v-if="contextMenu && contextMenuField"
@@ -64,20 +117,29 @@
<script> <script>
import { shallowRef } from 'vue' import { shallowRef } from 'vue'
import { DOMSerializer, Fragment } from '@tiptap/pm/model' import { DOMSerializer, Fragment } from '@tiptap/pm/model'
import { Selection, TextSelection } from '@tiptap/pm/state'
import { findTable, CellSelection } from '@tiptap/pm/tables'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import { IconPlus, IconX } from '@tabler/icons-vue'
import FieldContextMenu from './field_context_menu.vue' import FieldContextMenu from './field_context_menu.vue'
import AreaTitle from './area_title.vue' import AreaTitle from './area_title.vue'
import DynamicMenu from './dynamic_menu.vue' import DynamicMenu from './dynamic_menu.vue'
import { buildEditor } from './dynamic_editor.js' import DynamicInsertMenu from './dynamic_insert_menu.vue'
import DynamicBlockMenu from './dynamic_block_menu.vue'
import { buildEditor, isRegularTable, resizeTable, tableRowMinHeights } from './dynamic_editor.js'
export default { export default {
name: 'DynamicSection', name: 'DynamicSection',
components: { components: {
DynamicMenu, DynamicMenu,
DynamicInsertMenu,
DynamicBlockMenu,
FieldContextMenu, FieldContextMenu,
AreaTitle AreaTitle,
IconPlus,
IconX
}, },
inject: ['template', 'save', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor'], inject: ['template', 'save', 'pushUndo', 'undo', 'redo', 'baseFetch', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor', 'isMobile'],
props: { props: {
section: { section: {
type: Object, type: Object,
@@ -147,12 +209,15 @@ export default {
default: null default: null
} }
}, },
emits: ['update', 'draw', 'set-draw', 'add-custom-field'], emits: ['update', 'draw', 'set-draw', 'add-custom-field', 'add-attachment', 'add-list'],
data () { data () {
return { return {
isAreaDrag: false, isAreaDrag: false,
areaToolbarCoords: null, areaToolbarCoords: null,
dynamicMenuCoords: null, dynamicMenuCoords: null,
insertMenuCoords: null,
isInsertMenuOpen: false,
blockMenuCoords: null,
contextMenu: null, contextMenu: null,
cursorHighlightCoords: null cursorHighlightCoords: null
} }
@@ -196,6 +261,9 @@ export default {
editor () { editor () {
return this.editorRef.value return this.editorRef.value
}, },
sectionKey () {
return `${this.attachmentUuid}:${this.section.id}`
},
sectionWidthPx () { sectionWidthPx () {
const pt = parseFloat(this.section.style.width) const pt = parseFloat(this.section.style.width)
@@ -235,9 +303,19 @@ export default {
if (this.dynamicMenuCoords && this.editor && !this.editor.state.selection.empty) { if (this.dynamicMenuCoords && this.editor && !this.editor.state.selection.empty) {
this.$nextTick(() => this.setDynamicMenuCoords(this.editor)) this.$nextTick(() => this.setDynamicMenuCoords(this.editor))
} }
if (this.insertMenuCoords && this.editor) {
this.$nextTick(() => this.setInsertMenuCoords(this.editor))
}
if (this.blockMenuCoords && this.editor) {
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
}
} }
}, },
mounted () { mounted () {
this.lastUpdateTime = 0
this.editorRef.value = buildEditor({ this.editorRef.value = buildEditor({
dynamicAreaProps: { dynamicAreaProps: {
template: this.template, template: this.template,
@@ -250,17 +328,36 @@ export default {
onAreaResize: this.onAreaResize, onAreaResize: this.onAreaResize,
onAreaDragStart: this.onAreaDragStart onAreaDragStart: this.onAreaDragStart
}, },
attachmentsIndex: this.attachmentsIndex, getAttachmentsIndex: () => this.attachmentsIndex,
onFieldDrop: this.onFieldDrop, onFieldDrop: this.onFieldDrop,
onFieldDestroy: this.onFieldDestroy, onFieldDestroy: this.onFieldDestroy,
onUndo: this.undo,
onRedo: this.redo,
renderHtmlForSaveRef: this.renderHtmlForSaveRef, renderHtmlForSaveRef: this.renderHtmlForSaveRef,
editorOptions: { editorOptions: {
element: this.$refs.editorElement, element: this.$refs.editorElement,
editable: this.editable, editable: this.editable,
content: this.section.innerHTML, content: this.section.innerHTML,
onUpdate: (event) => this.$emit('update', event), onUpdate: (event) => {
this.$emit('update', event)
if (event.transaction.getMeta('addToHistory') !== false) {
this.pushUndo({ amend: event.transaction.time - this.lastUpdateTime < 500 })
this.lastUpdateTime = event.transaction.time
}
if (this.blockMenuCoords) {
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
}
},
onSelectionUpdate: this.onSelectionUpdate, onSelectionUpdate: this.onSelectionUpdate,
onBlur: () => { this.dynamicMenuCoords = null } onFocus: this.onSelectionUpdate,
onBlur: () => {
this.dynamicMenuCoords = null
this.insertMenuCoords = null
this.blockMenuCoords = null
}
} }
}) })
@@ -269,6 +366,8 @@ export default {
this.editor.view.dom.addEventListener('mousemove', this.onEditorMouseMove) this.editor.view.dom.addEventListener('mousemove', this.onEditorMouseMove)
this.editor.view.dom.addEventListener('mouseleave', this.onEditorMouseLeave) this.editor.view.dom.addEventListener('mouseleave', this.onEditorMouseLeave)
this.editor.view.dom.addEventListener('keydown', this.onEditorKeyDown) this.editor.view.dom.addEventListener('keydown', this.onEditorKeyDown)
this.$nextTick(() => this.pushUndo({ amend: true }))
}, },
beforeUnmount () { beforeUnmount () {
if (this.editor) { if (this.editor) {
@@ -284,6 +383,19 @@ export default {
reloadContent () { reloadContent () {
this.editor.commands.setContent(this.section.innerHTML, { emitUpdate: false, parseOptions: this.editor.options.parseOptions }) this.editor.commands.setContent(this.section.innerHTML, { emitUpdate: false, parseOptions: this.editor.options.parseOptions })
}, },
restoreDoc (doc) {
const { state, view } = this.editor
const { content } = doc.type.schema === state.schema ? doc : state.schema.nodeFromJSON(doc.toJSON())
const diffEnd = content.findDiffEnd(state.doc.content)
if (diffEnd) {
const tr = state.tr.replaceWith(0, state.doc.content.size, content).setMeta('addToHistory', false)
view.dispatch(tr.setSelection(Selection.near(tr.doc.resolve(diffEnd.a))).scrollIntoView())
this.lastUpdateTime = 0
}
},
findAreaNodePos (areaUuid) { findAreaNodePos (areaUuid) {
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`) const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
@@ -402,16 +514,163 @@ export default {
this.selectedAreasRef.value = [area] this.selectedAreasRef.value = [area]
} }
} }
this.blockMenuCoords = null
} else { } else {
this.areaToolbarCoords = null this.areaToolbarCoords = null
this.selectedAreasRef.value = [] this.selectedAreasRef.value = []
if (editor.state.selection.empty) { this.setBlockMenuCoords(editor)
if (selection.node || selection.empty || !editor.isFocused) {
this.dynamicMenuCoords = null this.dynamicMenuCoords = null
} else { } else {
this.setDynamicMenuCoords(editor) this.setDynamicMenuCoords(editor)
} }
} }
if (selection instanceof CellSelection) {
this.insertMenuCoords = null
} else if (editor.isFocused) {
this.setInsertMenuCoords(editor)
}
},
setInsertMenuCoords (editor) {
const coords = editor.view.coordsAtPos(editor.state.selection.head)
const containerRect = this.container.getBoundingClientRect()
const sectionEl = this.$refs.editorElement
const sectionRect = sectionEl.getBoundingClientRect()
const paddingLeft = parseFloat(getComputedStyle(sectionEl).paddingLeft) * this.zoom
this.insertMenuCoords = {
top: (coords.top + coords.bottom) / 2 - containerRect.top,
left: sectionRect.left - containerRect.left + Math.max(paddingLeft / 2, 14)
}
},
setBlockMenuCoords (editor) {
const { selection } = editor.state
const isImage = selection.node?.type.name === 'image'
const table = isImage ? null : findTable(selection.$from)
if (!isImage && !table) {
this.blockMenuCoords = null
return
}
const pos = isImage ? selection.from : table.pos
const dom = editor.view.nodeDOM(pos)
const rect = dom.getBoundingClientRect()
const containerRect = this.container.getBoundingClientRect()
const coords = {
type: isImage ? 'image' : 'table',
pos,
align: 'left',
top: rect.top - containerRect.top,
left: rect.left - containerRect.left,
width: rect.width,
height: rect.height
}
if (isImage) {
coords.align = getComputedStyle(dom.parentElement).textAlign.replace('start', 'left').replace('end', 'right')
} else {
const { marginLeft, marginRight, float } = dom.style
if (float === 'left' || float === 'right') {
coords.align = float
} else if (marginLeft === 'auto') {
coords.align = marginRight === 'auto' ? 'center' : 'right'
} else if (dom.getAttribute('align')) {
coords.align = dom.getAttribute('align')
}
coords.isRegular = isRegularTable(table.node)
coords.canMerge = editor.can().mergeTableCells()
coords.canSplit = editor.can().splitTableCell()
}
this.blockMenuCoords = coords
},
getBlockResizeLimits () {
const dom = this.editor.view.nodeDOM(this.blockMenuCoords.pos)
const rect = dom.getBoundingClientRect()
return {
minHeight: tableRowMinHeights(dom, this.zoom, false).reduce((acc, value) => acc + value, 0),
maxWidth: Math.max(this.$refs.editorElement.getBoundingClientRect().right - rect.left, rect.width)
}
},
onBlockResize ({ width, height }) {
resizeTable(this.editor.view, this.blockMenuCoords.pos, { width: width / this.zoom, height: height / this.zoom }, this.zoom)
this.editor.commands.focus()
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
},
onBlockCommand (command, arg) {
this.editor.chain().focus()[command](arg).run()
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
},
onInsertField (type) {
const { from, to } = this.editor.state.selection
this.insertFieldAtRange({ sourceField: { type }, from, to })
},
onInsertTable ({ rows, cols }) {
const $pos = this.editor.state.tr.deleteSelection().selection.$head
const cell = '<td style="border: 1px solid #000000; padding: 4pt; vertical-align: top"><p style="margin: 0"></p></td>'
const html = `<table style="width: 100%; border-collapse: collapse"><tbody>${`<tr>${cell.repeat(cols)}</tr>`.repeat(rows)}</tbody></table>`
const isEmpty = $pos.parent.isTextblock && !$pos.parent.content.size
const from = !$pos.parent.isTextblock ? $pos.pos : isEmpty ? $pos.before() : $pos.after()
const to = isEmpty ? $pos.after() : from
this.editor.chain().focus()
.deleteSelection()
.insertContentAt({ from, to }, html)
.setTextSelection(from + 4)
.run()
},
async onImageSelected (event) {
const file = event.target.files[0]
event.target.value = ''
if (!file) return
const image = await this.uploadImage(file)
if (image) {
this.editor.chain().focus().insertContent(image).run()
}
},
async insertImageFiles (files, pos) {
const images = (await Promise.all(files.map(this.uploadImage))).filter(Boolean)
if (images.length) {
this.editor.chain().focus().insertContentAt(pos, images).run()
}
},
async uploadImage (file) {
const formData = new FormData()
formData.append('file', file)
const response = await this.baseFetch(`/templates/${this.template.id}/dynamic_documents/${this.attachmentUuid}/attachments`, {
method: 'POST',
body: formData
})
const data = await response.json()
if (!response.ok) return alert(data.error)
this.$emit('add-attachment', data)
await this.$nextTick()
return { type: 'image', attrs: { htmlAttrs: { src: `blob:${data.uuid}`, style: 'max-width: 100%' } } }
}, },
setDynamicMenuCoords (editor) { setDynamicMenuCoords (editor) {
const { from, to } = editor.state.selection const { from, to } = editor.state.selection
@@ -419,11 +678,11 @@ export default {
const start = view.coordsAtPos(from) const start = view.coordsAtPos(from)
const end = view.coordsAtPos(to) const end = view.coordsAtPos(to)
const containerRect = this.container.getBoundingClientRect() const containerRect = this.container.getBoundingClientRect()
const left = (start.left + end.right) / 2 - containerRect.left
this.dynamicMenuCoords = { this.dynamicMenuCoords = {
top: Math.min(start.top, end.top) - containerRect.top, top: Math.max(start.bottom, end.bottom) - containerRect.top + 8,
left: Math.max(80, Math.min(left, containerRect.width - 80)) left: (start.left + end.right) / 2 - containerRect.left,
width: containerRect.width
} }
}, },
onFieldDestroy (node) { onFieldDestroy (node) {
@@ -493,6 +752,8 @@ export default {
if (event.key === 'Escape') { if (event.key === 'Escape') {
this.editor.chain().setNodeSelection(0).blur().run() this.editor.chain().setNodeSelection(0).blur().run()
this.deselectArea() this.deselectArea()
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.stopPropagation()
} }
}, },
onEditorMouseMove (event) { onEditorMouseMove (event) {
@@ -608,25 +869,23 @@ export default {
} }
} }
const newField = { const newField = payload.parentField || {
...field, ...field,
uuid: v4(), uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid, submitter_uuid: this.selectedSubmitter.uuid,
areas: [area] areas: []
} }
newField.areas.push(area)
if (['radio', 'multiple'].includes(field.type) && field.options?.length) { if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
const oldOptionUuid = area.option_uuid const oldOptionIndex = field.options.findIndex((opt) => opt.uuid === area.option_uuid)
const optionsMap = {}
newField.options = field.options.map((opt) => { if (!payload.parentField) {
const newUuid = v4() newField.options = field.options.map((opt) => ({ ...opt, uuid: v4() }))
optionsMap[opt.uuid] = newUuid }
return { ...opt, uuid: newUuid } area.option_uuid = (newField.options[oldOptionIndex] || newField.options[0]).uuid
})
area.option_uuid = optionsMap[oldOptionUuid] || newField.options[0]?.uuid
} }
return { field: newField, area } return { field: newField, area }
@@ -642,6 +901,8 @@ export default {
const text = clipboardData.getData('text/plain') const text = clipboardData.getData('text/plain')
const clipboardHtml = html || (text.includes('<dynamic-field') ? text : '') const clipboardHtml = html || (text.includes('<dynamic-field') ? text : '')
if (/class="[^"]*doc-list-/.test(html)) this.$emit('add-list')
if (!clipboardHtml || !clipboardHtml.includes('data-field=')) { if (!clipboardHtml || !clipboardHtml.includes('data-field=')) {
return return
} }
@@ -662,10 +923,13 @@ export default {
let lastArea = null let lastArea = null
const parentFieldsIndex = {}
fieldNodes.forEach((fieldNode) => { fieldNodes.forEach((fieldNode) => {
const fieldValue = fieldNode.dataset.field const fieldValue = fieldNode.dataset.field
const areaValue = fieldNode.dataset.area const areaValue = fieldNode.dataset.area
const templateId = fieldNode.dataset.templateId const templateId = fieldNode.dataset.templateId
const sourceFieldUuid = fieldNode.getAttribute('uuid')
if (!fieldValue || !areaValue) { if (!fieldValue || !areaValue) {
return return
@@ -674,11 +938,16 @@ export default {
const { field, area } = this.buildCopiedField({ const { field, area } = this.buildCopiedField({
field: JSON.parse(fieldValue), field: JSON.parse(fieldValue),
area: JSON.parse(areaValue), area: JSON.parse(areaValue),
templateId: Number(templateId) templateId: Number(templateId),
parentField: parentFieldsIndex[sourceFieldUuid]
}) })
this.insertFieldInTemplate(field, insertIndex) if (!parentFieldsIndex[sourceFieldUuid]) {
insertIndex += 1 parentFieldsIndex[sourceFieldUuid] = field
this.insertFieldInTemplate(field, insertIndex)
insertIndex += 1
}
fieldNode.setAttribute('uuid', field.uuid) fieldNode.setAttribute('uuid', field.uuid)
fieldNode.setAttribute('area-uuid', area.uuid) fieldNode.setAttribute('area-uuid', area.uuid)
@@ -718,7 +987,7 @@ export default {
onSelectedAreaChange () { onSelectedAreaChange () {
this.save() this.save()
}, },
onFieldDrop (view, event, _slice, moved) { onFieldDrop (view, event, slice, moved) {
this.isAreaDrag = false this.isAreaDrag = false
if (moved) { if (moved) {
@@ -726,8 +995,9 @@ export default {
} }
const draggedField = this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField const draggedField = this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField
const imageFiles = slice.size ? [] : Array.from(event.dataTransfer.files).filter((file) => file.type.startsWith('image/'))
if (!draggedField) return false if (!draggedField && !imageFiles.length) return false
event.preventDefault() event.preventDefault()
@@ -735,17 +1005,63 @@ export default {
if (!pos) return false if (!pos) return false
this.insertFieldAtRange({ if (draggedField) {
sourceField: draggedField, this.insertFieldAtRange({
existingField: this.fieldsDragFieldRef?.value, sourceField: draggedField,
from: pos.pos existingField: this.fieldsDragFieldRef?.value,
}) from: pos.pos
})
this.fieldsDragFieldRef.value = null this.fieldsDragFieldRef.value = null
this.customDragFieldRef.value = null this.customDragFieldRef.value = null
} else {
this.insertImageFiles(imageFiles, pos.pos)
}
return true return true
}, },
closestPosAtCoords ({ clientX, clientY }) {
const view = this.editor.view
const rect = view.dom.getBoundingClientRect()
const left = Math.min(Math.max(clientX, rect.left + 1), rect.right - 1)
const top = Math.min(Math.max(clientY, rect.top + 1), rect.bottom - 1)
const caret = document.caretPositionFromPoint?.(left, top, { shadowRoots: [view.root] })
if (caret && view.dom.contains(caret.offsetNode)) {
return view.posAtDOM(caret.offsetNode, caret.offset)
} else {
return view.posAtCoords({ left, top }).pos
}
},
onMarginMouseDown (event) {
const view = this.editor.view
if (event.button !== 0 || !this.editable || this.isDrawMode || view.dom.contains(event.target)) {
return
}
event.preventDefault()
const anchor = this.closestPosAtCoords(event)
const selectTo = (e) => {
const { doc, tr } = view.state
view.dispatch(tr.setSelection(TextSelection.between(doc.resolve(anchor), doc.resolve(this.closestPosAtCoords(e)))))
}
const onMouseUp = () => {
window.removeEventListener('mousemove', selectTo)
window.removeEventListener('mouseup', onMouseUp)
}
selectTo(event)
view.focus()
window.addEventListener('mousemove', selectTo)
window.addEventListener('mouseup', onMouseUp)
},
onEditorPointerDown (event) { onEditorPointerDown (event) {
if (!this.isDrawMode || !this.editable || this.isDraggingField) { if (!this.isDrawMode || !this.editable || this.isDraggingField) {
return return
@@ -19,4 +19,5 @@
:host { :host {
all: initial; all: initial;
-webkit-text-size-adjust: 100%;
} }
@@ -608,10 +608,10 @@ export default {
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type) return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)
}, },
showCondition () { showCondition () {
return this.withCondition && (this.withConditions || this.nativePlatform !== 'ios') && !['stamp', 'heading'].includes(this.field.type) return this.withCondition && (this.withConditions || !this.nativePlatform) && !['stamp', 'heading'].includes(this.field.type)
}, },
showFormula () { showFormula () {
return (this.field.type === 'number' || this.field.type === 'date' || this.field.type === 'payment' || !!this.field.preferences?.formula) && (this.withFormula || this.nativePlatform !== 'ios') return (this.field.type === 'number' || this.field.type === 'date' || this.field.type === 'payment' || !!this.field.preferences?.formula) && (this.withFormula || !this.nativePlatform)
}, },
showRequired () { showRequired () {
return this.withRequired && !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type) return this.withRequired && !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type)
@@ -484,7 +484,7 @@
</label> </label>
</li> </li>
<li <li
v-if="withCondition && (withConditions || nativePlatform !== 'ios') && field.type != 'stamp' && field.type != 'heading'" v-if="withCondition && (withConditions || !nativePlatform) && field.type != 'stamp' && field.type != 'heading'"
class="field-settings-condition" class="field-settings-condition"
> >
<label <label
@@ -500,7 +500,7 @@
</label> </label>
</li> </li>
<li <li
v-if="(field.type == 'number' || field.type == 'date' || field.preferences?.formula) && (withFormula || nativePlatform !== 'ios')" v-if="(field.type == 'number' || field.type == 'date' || field.preferences?.formula) && (withFormula || !nativePlatform)"
class="field-settings-formula" class="field-settings-formula"
> >
<label <label
@@ -745,6 +745,7 @@ export default {
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'email', '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'email',
'^https?://.*': 'url', '^https?://.*': 'url',
'^[0-9]{5}(?:-[0-9]{4})?$': 'zip', '^[0-9]{5}(?:-[0-9]{4})?$': 'zip',
'^[A-Z]{2}[0-9]{2}(?: ?[A-Z0-9]){11,30}$': 'iban',
'^[0-9]+$': 'numbers_only', '^[0-9]+$': 'numbers_only',
'^[a-zA-Z]+$': 'letters_only' '^[a-zA-Z]+$': 'letters_only'
} }
+1 -1
View File
@@ -275,7 +275,7 @@
</div> </div>
</button> </button>
<div <div
v-else-if="type == 'phone' && nativePlatform !== 'ios' && (fieldTypes.length === 0 || fieldTypes.includes(type))" v-else-if="type == 'phone' && !nativePlatform && (fieldTypes.length === 0 || fieldTypes.includes(type))"
class="tooltip tooltip-bottom flex" class="tooltip tooltip-bottom flex"
:class="{'tooltip-bottom-end': withPayment, 'tooltip-bottom': !withPayment }" :class="{'tooltip-bottom-end': withPayment, 'tooltip-bottom': !withPayment }"
:data-tip="t('unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification')" :data-tip="t('unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification')"
@@ -7,7 +7,7 @@
class="absolute top-0 bottom-0 right-0 left-0" class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')" @click.prevent="$emit('close')"
/> />
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl'"> <div :class="inline ? (nativePlatform === 'android' ? 'px-4 pt-1.5 pb-6' : '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 <div
v-if="!inline" v-if="!inline"
class="flex justify-between items-center border-b pb-2 mb-2 font-medium" class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
@@ -21,7 +21,7 @@
@click.prevent="$emit('close')" @click.prevent="$emit('close')"
>&times;</a> >&times;</a>
</div> </div>
<div class="mt-4"> <div :class="inline && nativePlatform === 'android' ? 'mt-1' : 'mt-4'">
<div> <div>
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
<span> <span>
@@ -200,7 +200,7 @@ export default {
components: { components: {
IconChevronDown IconChevronDown
}, },
inject: ['t', 'template'], inject: ['t', 'template', 'nativePlatform'],
props: { props: {
field: { field: {
type: Object, type: Object,
@@ -7,7 +7,7 @@
class="absolute top-0 bottom-0 right-0 left-0" class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')" @click.prevent="$emit('close')"
/> />
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl'"> <div :class="inline ? (nativePlatform === 'android' ? 'px-4 pt-1.5 pb-6' : '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 <div
v-if="!inline" v-if="!inline"
class="flex justify-between items-center border-b pb-2 mb-2 font-medium" class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
@@ -32,7 +32,7 @@
class="link" class="link"
>{{ t('available_in_pro') }}</a> >{{ t('available_in_pro') }}</a>
</div> </div>
<div class="flex flex-wrap mb-2 gap-y-1 pt-1"> <div :class="['flex flex-wrap mb-2 gap-y-1', inline && nativePlatform === 'android' ? 'mt-1' : 'pt-1']">
<button <button
v-for="f in fields" v-for="f in fields"
:key="f.uuid" :key="f.uuid"
@@ -152,7 +152,7 @@ export default {
IconCodePlus, IconCodePlus,
IconMathFunction IconMathFunction
}, },
inject: ['t', 'template', 'withFormula'], inject: ['t', 'template', 'withFormula', 'nativePlatform'],
props: { props: {
field: { field: {
type: Object, type: Object,
+280 -7
View File
@@ -70,6 +70,7 @@ const en = {
remove_condition: 'Remove condition', remove_condition: 'Remove condition',
add_condition: 'Add condition', add_condition: 'Add condition',
are_you_sure_: 'Are you sure?', are_you_sure_: 'Are you sure?',
close_without_saving_changes: 'Close without saving changes?',
sign_yourself: 'Sign Yourself', sign_yourself: 'Sign Yourself',
set_signing_date: 'Set signing date', set_signing_date: 'Set signing date',
signing_date: 'Signing Date', signing_date: 'Signing Date',
@@ -210,6 +211,7 @@ const en = {
email: 'Email', email: 'Email',
url: 'URL', url: 'URL',
zip: 'ZIP', zip: 'ZIP',
iban: 'IBAN',
custom: 'Custom', custom: 'Custom',
numbers_only: 'Numbers only', numbers_only: 'Numbers only',
letters_only: 'Letters only', letters_only: 'Letters only',
@@ -248,7 +250,44 @@ const en = {
no_revisions_yet: 'No revisions yet', no_revisions_yet: 'No revisions yet',
viewing_revision_from: 'Viewing revision from {date}', viewing_revision_from: 'Viewing revision from {date}',
connect_google_drive: 'Connect Google Drive', connect_google_drive: 'Connect Google Drive',
submitting: 'Submitting' submitting: 'Submitting',
font_size: 'Font size',
increase_font_size: 'Increase font size',
decrease_font_size: 'Decrease font size',
line_spacing: 'Line spacing',
justify: 'Justify',
bullet_list: 'List',
dots: 'Dots',
dash: 'Dash',
numbers: 'Numbers',
bold: 'Bold',
italic: 'Italic',
underline: 'Underline',
strikethrough: 'Strikethrough',
text_color: 'Text color',
background_color: 'Background color',
variable: 'Variable',
create_variable: 'Wrap in variable',
create_condition: 'Wrap in condition',
insert: 'Insert',
field: 'Field',
table: 'Table',
decrease_rows: 'Fewer rows',
increase_rows: 'More rows',
decrease_cols: 'Fewer columns',
increase_cols: 'More columns',
align_center: 'Align Center',
insert_row_above: 'Insert row above',
insert_row_below: 'Insert row below',
insert_column_left: 'Insert column left',
insert_column_right: 'Insert column right',
merge_cells: 'Merge cells',
split_cell: 'Split cell',
delete_row: 'Delete row',
delete_column: 'Delete column',
delete_table: 'Delete table',
delete_image: 'Delete image',
irregular_table_structure: 'Rows and columns can\'t be edited: irregular table layout'
} }
const es = { const es = {
@@ -333,6 +372,7 @@ const es = {
down: 'Abajo', down: 'Abajo',
set_signing_date: 'Establecer fecha de firma', set_signing_date: 'Establecer fecha de firma',
are_you_sure_: '¿Estás seguro?', are_you_sure_: '¿Estás seguro?',
close_without_saving_changes: '¿Cerrar sin guardar los cambios?',
sign_yourself: 'Firma tú mismo', sign_yourself: 'Firma tú mismo',
checked: 'Marcado', checked: 'Marcado',
current_date: 'Fecha actual', current_date: 'Fecha actual',
@@ -463,6 +503,7 @@ const es = {
email: 'Correo electrónico', email: 'Correo electrónico',
url: 'URL', url: 'URL',
zip: 'ZIP', zip: 'ZIP',
iban: 'IBAN',
custom: 'Personalizado', custom: 'Personalizado',
numbers_only: 'Solo números', numbers_only: 'Solo números',
letters_only: 'Solo letras', letters_only: 'Solo letras',
@@ -501,7 +542,44 @@ const es = {
no_revisions_yet: 'Aún no hay revisiones', no_revisions_yet: 'Aún no hay revisiones',
viewing_revision_from: 'Viendo revisión del {date}', viewing_revision_from: 'Viendo revisión del {date}',
connect_google_drive: 'Conectar Google Drive', connect_google_drive: 'Conectar Google Drive',
submitting: 'Enviando' submitting: 'Enviando',
font_size: 'Tamaño',
increase_font_size: 'Aumentar tamaño',
decrease_font_size: 'Reducir tamaño',
line_spacing: 'Interlineado',
justify: 'Justificar',
bullet_list: 'Lista',
dots: 'Puntos',
dash: 'Guiones',
numbers: 'Números',
bold: 'Negrita',
italic: 'Cursiva',
underline: 'Subrayado',
strikethrough: 'Tachado',
text_color: 'Color de texto',
background_color: 'Color de fondo',
variable: 'Variable',
create_variable: 'Crear variable',
create_condition: 'Crear condición',
insert: 'Insertar',
field: 'Campo',
table: 'Tabla',
decrease_rows: 'Menos filas',
increase_rows: 'Más filas',
decrease_cols: 'Menos columnas',
increase_cols: 'Más columnas',
align_center: 'Centrar',
insert_row_above: 'Fila arriba',
insert_row_below: 'Fila abajo',
insert_column_left: 'Columna izquierda',
insert_column_right: 'Columna derecha',
merge_cells: 'Combinar celdas',
split_cell: 'Dividir celda',
delete_row: 'Eliminar fila',
delete_column: 'Eliminar columna',
delete_table: 'Eliminar tabla',
delete_image: 'Eliminar imagen',
irregular_table_structure: 'No se pueden editar filas ni columnas: tabla irregular'
} }
const it = { const it = {
@@ -576,6 +654,7 @@ const it = {
remove_condition: 'Rimuovi condizione', remove_condition: 'Rimuovi condizione',
add_condition: 'Aggiungi condizione', add_condition: 'Aggiungi condizione',
are_you_sure_: 'Sei sicuro?', are_you_sure_: 'Sei sicuro?',
close_without_saving_changes: 'Chiudere senza salvare le modifiche?',
sign_yourself: 'Firma te stesso', sign_yourself: 'Firma te stesso',
set_signing_date: 'Imposta data di firma', set_signing_date: 'Imposta data di firma',
signing_date: 'Data di firma', signing_date: 'Data di firma',
@@ -716,6 +795,7 @@ const it = {
email: 'Email', email: 'Email',
url: 'URL', url: 'URL',
zip: 'CAP', zip: 'CAP',
iban: 'IBAN',
custom: 'Personalizzato', custom: 'Personalizzato',
numbers_only: 'Solo numeri', numbers_only: 'Solo numeri',
letters_only: 'Solo lettere', letters_only: 'Solo lettere',
@@ -754,7 +834,44 @@ const it = {
no_revisions_yet: 'Nessuna revisione ancora', no_revisions_yet: 'Nessuna revisione ancora',
viewing_revision_from: 'Visualizzazione revisione del {date}', viewing_revision_from: 'Visualizzazione revisione del {date}',
connect_google_drive: 'Connetti Google Drive', connect_google_drive: 'Connetti Google Drive',
submitting: 'Invio' submitting: 'Invio',
font_size: 'Dimensione',
increase_font_size: 'Aumenta dimensione',
decrease_font_size: 'Riduci dimensione',
line_spacing: 'Interlinea',
justify: 'Giustifica',
bullet_list: 'Elenco',
dots: 'Punti',
dash: 'Trattini',
numbers: 'Numeri',
bold: 'Grassetto',
italic: 'Corsivo',
underline: 'Sottolineato',
strikethrough: 'Barrato',
text_color: 'Colore testo',
background_color: 'Colore sfondo',
variable: 'Variabile',
create_variable: 'Crea variabile',
create_condition: 'Crea condizione',
insert: 'Inserisci',
field: 'Campo',
table: 'Tabella',
decrease_rows: 'Meno righe',
increase_rows: 'Più righe',
decrease_cols: 'Meno colonne',
increase_cols: 'Più colonne',
align_center: 'Centra',
insert_row_above: 'Riga sopra',
insert_row_below: 'Riga sotto',
insert_column_left: 'Colonna a sinistra',
insert_column_right: 'Colonna a destra',
merge_cells: 'Unisci celle',
split_cell: 'Dividi cella',
delete_row: 'Elimina riga',
delete_column: 'Elimina colonna',
delete_table: 'Elimina tabella',
delete_image: 'Elimina immagine',
irregular_table_structure: 'Righe e colonne non modificabili: tabella irregolare'
} }
const pt = { const pt = {
@@ -839,6 +956,7 @@ const pt = {
down: 'Para baixo', down: 'Para baixo',
set_signing_date: 'Definir data de assinatura', set_signing_date: 'Definir data de assinatura',
are_you_sure_: 'Tem certeza?', are_you_sure_: 'Tem certeza?',
close_without_saving_changes: 'Fechar sem salvar as alterações?',
sign_yourself: 'Assine você mesmo', sign_yourself: 'Assine você mesmo',
checked: 'Marcado', checked: 'Marcado',
current_date: 'Data atual', current_date: 'Data atual',
@@ -969,6 +1087,7 @@ const pt = {
email: 'Email', email: 'Email',
url: 'URL', url: 'URL',
zip: 'ZIP', zip: 'ZIP',
iban: 'IBAN',
custom: 'Personalizado', custom: 'Personalizado',
numbers_only: 'Somente números', numbers_only: 'Somente números',
letters_only: 'Somente letras', letters_only: 'Somente letras',
@@ -1007,7 +1126,44 @@ const pt = {
no_revisions_yet: 'Nenhuma revisão ainda', no_revisions_yet: 'Nenhuma revisão ainda',
viewing_revision_from: 'Visualizando revisão de {date}', viewing_revision_from: 'Visualizando revisão de {date}',
connect_google_drive: 'Conectar Google Drive', connect_google_drive: 'Conectar Google Drive',
submitting: 'Enviando' submitting: 'Enviando',
font_size: 'Tamanho',
increase_font_size: 'Aumentar tamanho',
decrease_font_size: 'Diminuir tamanho',
line_spacing: 'Espaçamento',
justify: 'Justificar',
bullet_list: 'Lista',
dots: 'Pontos',
dash: 'Traços',
numbers: 'Números',
bold: 'Negrito',
italic: 'Itálico',
underline: 'Sublinhado',
strikethrough: 'Tachado',
text_color: 'Cor do texto',
background_color: 'Cor de fundo',
variable: 'Variável',
create_variable: 'Criar variável',
create_condition: 'Criar condição',
insert: 'Inserir',
field: 'Campo',
table: 'Tabela',
decrease_rows: 'Menos linhas',
increase_rows: 'Mais linhas',
decrease_cols: 'Menos colunas',
increase_cols: 'Mais colunas',
align_center: 'Centralizar',
insert_row_above: 'Linha acima',
insert_row_below: 'Linha abaixo',
insert_column_left: 'Coluna à esquerda',
insert_column_right: 'Coluna à direita',
merge_cells: 'Mesclar células',
split_cell: 'Dividir célula',
delete_row: 'Excluir linha',
delete_column: 'Excluir coluna',
delete_table: 'Excluir tabela',
delete_image: 'Excluir imagem',
irregular_table_structure: 'Linhas e colunas não editáveis: tabela irregular'
} }
const fr = { const fr = {
@@ -1082,6 +1238,7 @@ const fr = {
remove_condition: 'Supprimer la condition', remove_condition: 'Supprimer la condition',
add_condition: 'Ajouter une condition', add_condition: 'Ajouter une condition',
are_you_sure_: 'Êtes-vous sûr ?', are_you_sure_: 'Êtes-vous sûr ?',
close_without_saving_changes: 'Fermer sans enregistrer les modifications ?',
sign_yourself: 'Signer vous-même', sign_yourself: 'Signer vous-même',
set_signing_date: 'Définir la date de signature', set_signing_date: 'Définir la date de signature',
signing_date: 'Date de signature', signing_date: 'Date de signature',
@@ -1222,6 +1379,7 @@ const fr = {
email: 'E-mail', email: 'E-mail',
url: 'URL', url: 'URL',
zip: 'ZIP', zip: 'ZIP',
iban: 'IBAN',
custom: 'Personnalisé', custom: 'Personnalisé',
numbers_only: 'Chiffres uniquement', numbers_only: 'Chiffres uniquement',
letters_only: 'Lettres uniquement', letters_only: 'Lettres uniquement',
@@ -1260,7 +1418,44 @@ const fr = {
no_revisions_yet: 'Aucune révision pour le moment', no_revisions_yet: 'Aucune révision pour le moment',
viewing_revision_from: 'Affichage de la révision du {date}', viewing_revision_from: 'Affichage de la révision du {date}',
connect_google_drive: 'Connecter Google Drive', connect_google_drive: 'Connecter Google Drive',
submitting: 'Soumission' submitting: 'Soumission',
font_size: 'Taille',
increase_font_size: 'Augmenter la taille',
decrease_font_size: 'Réduire la taille',
line_spacing: 'Interligne',
justify: 'Justifier',
bullet_list: 'Liste',
dots: 'Points',
dash: 'Tirets',
numbers: 'Numéros',
bold: 'Gras',
italic: 'Italique',
underline: 'Souligné',
strikethrough: 'Barré',
text_color: 'Couleur du texte',
background_color: 'Couleur de fond',
variable: 'Variable',
create_variable: 'Créer une variable',
create_condition: 'Créer une condition',
insert: 'Insérer',
field: 'Champ',
table: 'Tableau',
decrease_rows: 'Moins de lignes',
increase_rows: 'Plus de lignes',
decrease_cols: 'Moins de colonnes',
increase_cols: 'Plus de colonnes',
align_center: 'Centrer',
insert_row_above: 'Ligne au-dessus',
insert_row_below: 'Ligne en dessous',
insert_column_left: 'Colonne à gauche',
insert_column_right: 'Colonne à droite',
merge_cells: 'Fusionner',
split_cell: 'Scinder',
delete_row: 'Supprimer la ligne',
delete_column: 'Supprimer la colonne',
delete_table: 'Supprimer le tableau',
delete_image: 'Supprimer l\'image',
irregular_table_structure: 'Lignes et colonnes non modifiables : tableau irrégulier'
} }
const de = { const de = {
@@ -1335,6 +1530,7 @@ const de = {
remove_condition: 'Bedingung entfernen', remove_condition: 'Bedingung entfernen',
add_condition: 'Bedingung hinzufügen', add_condition: 'Bedingung hinzufügen',
are_you_sure_: 'Sind Sie sicher?', are_you_sure_: 'Sind Sie sicher?',
close_without_saving_changes: 'Ohne Speichern der Änderungen schließen?',
sign_yourself: 'Selbst unterschreiben', sign_yourself: 'Selbst unterschreiben',
set_signing_date: 'Unterzeichnungsdatum festlegen', set_signing_date: 'Unterzeichnungsdatum festlegen',
signing_date: 'Unterzeichnungsdatum', signing_date: 'Unterzeichnungsdatum',
@@ -1475,6 +1671,7 @@ const de = {
email: 'E-Mail', email: 'E-Mail',
url: 'URL', url: 'URL',
zip: 'ZIP', zip: 'ZIP',
iban: 'IBAN',
custom: 'Benutzerdefiniert', custom: 'Benutzerdefiniert',
numbers_only: 'Nur Zahlen', numbers_only: 'Nur Zahlen',
letters_only: 'Nur Buchstaben', letters_only: 'Nur Buchstaben',
@@ -1513,7 +1710,44 @@ const de = {
no_revisions_yet: 'Noch keine Revisionen', no_revisions_yet: 'Noch keine Revisionen',
viewing_revision_from: 'Ansicht der Revision vom {date}', viewing_revision_from: 'Ansicht der Revision vom {date}',
connect_google_drive: 'Google Drive verbinden', connect_google_drive: 'Google Drive verbinden',
submitting: 'Übermittlung' submitting: 'Übermittlung',
font_size: 'Schriftgröße',
increase_font_size: 'Schrift vergrößern',
decrease_font_size: 'Schrift verkleinern',
line_spacing: 'Zeilenabstand',
justify: 'Blocksatz',
bullet_list: 'Liste',
dots: 'Punkte',
dash: 'Striche',
numbers: 'Zahlen',
bold: 'Fett',
italic: 'Kursiv',
underline: 'Unterstrichen',
strikethrough: 'Durchgestrichen',
text_color: 'Textfarbe',
background_color: 'Hintergrundfarbe',
variable: 'Variable',
create_variable: 'Variable erstellen',
create_condition: 'Bedingung erstellen',
insert: 'Einfügen',
field: 'Feld',
table: 'Tabelle',
decrease_rows: 'Weniger Zeilen',
increase_rows: 'Mehr Zeilen',
decrease_cols: 'Weniger Spalten',
increase_cols: 'Mehr Spalten',
align_center: 'Zentrieren',
insert_row_above: 'Zeile darüber',
insert_row_below: 'Zeile darunter',
insert_column_left: 'Spalte links',
insert_column_right: 'Spalte rechts',
merge_cells: 'Zellen verbinden',
split_cell: 'Zelle teilen',
delete_row: 'Zeile löschen',
delete_column: 'Spalte löschen',
delete_table: 'Tabelle löschen',
delete_image: 'Bild löschen',
irregular_table_structure: 'Zeilen und Spalten nicht bearbeitbar: unregelmäßige Tabelle'
} }
const nl = { const nl = {
@@ -1588,6 +1822,7 @@ const nl = {
remove_condition: 'Voorwaarde verwijderen', remove_condition: 'Voorwaarde verwijderen',
add_condition: 'Voorwaarde toevoegen', add_condition: 'Voorwaarde toevoegen',
are_you_sure_: 'Weet u het zeker?', are_you_sure_: 'Weet u het zeker?',
close_without_saving_changes: 'Sluiten zonder wijzigingen op te slaan?',
sign_yourself: 'Zelf ondertekenen', sign_yourself: 'Zelf ondertekenen',
set_signing_date: 'Ondertekeningsdatum instellen', set_signing_date: 'Ondertekeningsdatum instellen',
signing_date: 'Ondertekeningsdatum', signing_date: 'Ondertekeningsdatum',
@@ -1728,6 +1963,7 @@ const nl = {
email: 'E-mail', email: 'E-mail',
url: 'URL', url: 'URL',
zip: 'Postcode', zip: 'Postcode',
iban: 'IBAN',
custom: 'Aangepast', custom: 'Aangepast',
numbers_only: 'Alleen cijfers', numbers_only: 'Alleen cijfers',
letters_only: 'Alleen letters', letters_only: 'Alleen letters',
@@ -1766,7 +2002,44 @@ const nl = {
no_revisions_yet: 'Nog geen revisies', no_revisions_yet: 'Nog geen revisies',
viewing_revision_from: 'Revisie van {date} bekijken', viewing_revision_from: 'Revisie van {date} bekijken',
connect_google_drive: 'Verbind Google Drive', connect_google_drive: 'Verbind Google Drive',
submitting: 'Indienen' submitting: 'Indienen',
font_size: 'Tekstgrootte',
increase_font_size: 'Tekst vergroten',
decrease_font_size: 'Tekst verkleinen',
line_spacing: 'Regelafstand',
justify: 'Uitvullen',
bullet_list: 'Lijst',
dots: 'Punten',
dash: 'Streepjes',
numbers: 'Nummers',
bold: 'Vet',
italic: 'Cursief',
underline: 'Onderstrepen',
strikethrough: 'Doorhalen',
text_color: 'Tekstkleur',
background_color: 'Achtergrondkleur',
variable: 'Variabele',
create_variable: 'Maak variabele',
create_condition: 'Maak voorwaarde',
insert: 'Invoegen',
field: 'Veld',
table: 'Tabel',
decrease_rows: 'Minder rijen',
increase_rows: 'Meer rijen',
decrease_cols: 'Minder kolommen',
increase_cols: 'Meer kolommen',
align_center: 'Centreren',
insert_row_above: 'Rij erboven',
insert_row_below: 'Rij eronder',
insert_column_left: 'Kolom links',
insert_column_right: 'Kolom rechts',
merge_cells: 'Cellen samenvoegen',
split_cell: 'Cel splitsen',
delete_row: 'Rij verwijderen',
delete_column: 'Kolom verwijderen',
delete_table: 'Tabel verwijderen',
delete_image: 'Afbeelding verwijderen',
irregular_table_structure: 'Rijen en kolommen niet bewerkbaar: onregelmatige tabel'
} }
export { en, es, it, pt, fr, de, nl } export { en, es, it, pt, fr, de, nl }
-8
View File
@@ -57,7 +57,6 @@
@scroll-to="$emit('scroll-to', $event)" @scroll-to="$emit('scroll-to', $event)"
@add-custom-field="$emit('add-custom-field', $event)" @add-custom-field="$emit('add-custom-field', $event)"
@contextmenu="openAreaContextMenu($event, item.area, item.field)" @contextmenu="openAreaContextMenu($event, item.area, item.field)"
@multi-select="openMultiSelectContextMenu"
@click-title="closeContextMenu" @click-title="closeContextMenu"
/> />
<FieldArea <FieldArea
@@ -450,13 +449,6 @@ export default {
areas: this.selectedAreasRef.value areas: this.selectedAreasRef.value
} }
}, },
openMultiSelectContextMenu (event) {
if (this.selectedAreasRef.value.length >= 2) {
this.openSelectionContextMenu(event)
} else {
this.closeContextMenu()
}
},
handleSelectionCopy () { handleSelectionCopy () {
this.$emit('copy-selected-areas') this.$emit('copy-selected-areas')
@@ -203,7 +203,7 @@
>{{ t('learn_more') }}</a> >{{ t('learn_more') }}</a>
</div> </div>
<li <li
v-if="withFormula || nativePlatform !== 'ios'" v-if="withFormula || !nativePlatform"
class="field-settings-formula mb-1" class="field-settings-formula mb-1"
> >
<label <label
@@ -233,7 +233,7 @@
</label> </label>
</li> </li>
<li <li
v-if="withCondition && (withConditions || nativePlatform !== 'ios')" v-if="withCondition && (withConditions || !nativePlatform)"
class="field-settings-condition mt-1" class="field-settings-condition mt-1"
> >
<label <label
@@ -414,7 +414,7 @@ export default {
checkStripeStatus () { checkStripeStatus () {
this.isLoading = true this.isLoading = true
fetch('/api/stripe_connect').then(async (resp) => { fetch('/stripe_connect').then(async (resp) => {
const { status } = await resp.json() const { status } = await resp.json()
if (status === 'connected') { if (status === 'connected') {
@@ -427,7 +427,7 @@ export default {
}) })
}, },
checkPaypalStatus () { checkPaypalStatus () {
fetch('/api/paypal_connect').then(async (resp) => { fetch('/paypal_connect').then(async (resp) => {
const { status } = await resp.json() const { status } = await resp.json()
if (status === 'connected') { if (status === 'connected') {
@@ -5,7 +5,7 @@
class="absolute top-0 bottom-0 right-0 left-0" class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')" @click.prevent="$emit('close')"
/> />
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 mt-20 w-full'"> <div :class="inline ? (nativePlatform === 'android' ? 'px-4 pt-1.5 pb-6' : 'px-4 pt-3 pb-6') : 'modal-box pt-4 pb-6 mt-20 w-full'">
<div <div
v-if="!inline" v-if="!inline"
class="flex justify-between items-center border-b pb-2 mb-3 font-medium" class="flex justify-between items-center border-b pb-2 mb-3 font-medium"
@@ -63,7 +63,7 @@ import { IconUser, IconInnerShadowTop } from '@tabler/icons-vue'
export default { export default {
name: 'RevisionsModal', name: 'RevisionsModal',
components: { IconUser, IconInnerShadowTop }, components: { IconUser, IconInnerShadowTop },
inject: ['t', 'baseFetch'], inject: ['t', 'baseFetch', 'nativePlatform'],
props: { props: {
template: { template: {
type: Object, type: Object,
@@ -259,7 +259,7 @@ export default {
return true return true
}, },
showCondition () { showCondition () {
return this.withCondition && (this.withConditions || this.nativePlatform !== 'ios') return this.withCondition && (this.withConditions || !this.nativePlatform)
}, },
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels fieldLabels: FieldType.computed.fieldLabels
+2 -2
View File
@@ -100,7 +100,7 @@ class ProcessSubmitterCompletionJob
WebhookUrls.for_account_id(submitter.account_id, %w[form.completed submission.completed]).each do |webhook| WebhookUrls.for_account_id(submitter.account_id, %w[form.completed submission.completed]).each do |webhook|
if webhook.events.include?('form.completed') if webhook.events.include?('form.completed')
event_uuids['form.completed'] ||= SecureRandom.uuid event_uuids['form.completed'] ||= SecureRandom.uuid_v7
SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id, SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
'event_uuid' => event_uuids['form.completed'], 'event_uuid' => event_uuids['form.completed'],
@@ -109,7 +109,7 @@ class ProcessSubmitterCompletionJob
next unless webhook.events.include?('submission.completed') && is_last next unless webhook.events.include?('submission.completed') && is_last
event_uuids['submission.completed'] ||= SecureRandom.uuid event_uuids['submission.completed'] ||= SecureRandom.uuid_v7
SendSubmissionCompletedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id, SendSubmissionCompletedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
'event_uuid' => event_uuids['submission.completed'], 'event_uuid' => event_uuids['submission.completed'],
+8 -6
View File
@@ -30,14 +30,16 @@ class SendTestWebhookRequestJob
raise LocalhostError, "Can't send to localhost." if uri.host.in?(SendWebhookRequest::LOCALHOSTS) raise LocalhostError, "Can't send to localhost." if uri.host.in?(SendWebhookRequest::LOCALHOSTS)
end end
Faraday.post(webhook_url.url, body_json = {
{ event_type: 'form.completed',
event_type: 'form.completed', timestamp: Time.current.iso8601,
timestamp: Time.current.iso8601, data: Submitters::SerializeForWebhook.call(submitter)
data: Submitters::SerializeForWebhook.call(submitter) }.to_json
}.to_json,
Faraday.post(webhook_url.url, body_json,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT, 'User-Agent' => USER_AGENT,
'X-Docuseal-Signature' => WebhookUrls::Signatures.sign(webhook_url.hmac_secret, body: body_json),
**webhook_url.secret.to_h) **webhook_url.secret.to_h)
end end
end end
+11 -2
View File
@@ -181,12 +181,21 @@ class SubmitterMailer < ApplicationMailer
def otp_verification_email(submitter, locale: nil) def otp_verification_email(submitter, locale: nil)
@current_account = submitter.account @current_account = submitter.account
@submitter = submitter @submitter = submitter
@otp_code = EmailVerificationCodes.generate([submitter.email.downcase.strip, submitter.slug].join(':')) @otp_code = OtpChallenges.generate([submitter.email.downcase.squish, submitter.slug].join(':'),
purpose: 'form_email_2fa')
assign_message_metadata('otp_verification_email', submitter) assign_message_metadata('otp_verification_email', submitter)
from =
if Docuseal.multitenant? &&
AccountConfig.exists?(account_id: submitter.account_id, key: 'custom_otp_email', value: true)
from_address_for_submitter(submitter)
else
default_params[:from]
end
I18n.with_locale(locale || submitter.account.locale) do I18n.with_locale(locale || submitter.account.locale) do
mail(to: submitter.email, subject: I18n.t('email_verification')) mail(to: submitter.email, from:, subject: I18n.t('email_verification'))
end end
end end
+12 -2
View File
@@ -5,10 +5,20 @@ class TemplateMailer < ApplicationMailer
@current_account = template.account @current_account = template.account
@template = template @template = template
@otp_code = EmailVerificationCodes.generate([email.downcase.strip, template.slug].join(':')) @otp_code = OtpChallenges.generate([email.downcase.squish, template.slug].join(':'), purpose: 'form_email_2fa')
assign_message_metadata('otp_verification_email', template) assign_message_metadata('otp_verification_email', template)
mail(to: email, subject: I18n.t('email_verification')) from =
if Docuseal.multitenant? &&
AccountConfig.exists?(account_id: template.account_id, key: 'custom_otp_email', value: true)
put_metadata('from_user_id' => template.author_id)
template.author.friendly_name
else
default_params[:from]
end
mail(to: email, from:, subject: I18n.t('email_verification'))
end end
end end
+2 -1
View File
@@ -15,7 +15,8 @@
# #
# Indexes # Indexes
# #
# index_accounts_on_uuid (uuid) UNIQUE # index_accounts_on_created_at (created_at)
# index_accounts_on_uuid (uuid) UNIQUE
# #
class Account < ApplicationRecord class Account < ApplicationRecord
attribute :uuid, :string, default: -> { SecureRandom.uuid } attribute :uuid, :string, default: -> { SecureRandom.uuid }
+1
View File
@@ -61,6 +61,7 @@ class AccountConfig < ApplicationRecord
DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format' DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format'
TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields' TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields'
TEMPLATE_DATE_FORMATS_KEY = 'template_date_formats' TEMPLATE_DATE_FORMATS_KEY = 'template_date_formats'
TEMPLATE_BUILDER_AUTOSAVE_KEY = 'template_builder_autosave'
POLICY_LINKS_KEY = 'policy_links' POLICY_LINKS_KEY = 'policy_links'
ENABLE_MCP_KEY = 'enable_mcp' ENABLE_MCP_KEY = 'enable_mcp'
+21
View File
@@ -0,0 +1,21 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: otp_challenges
#
# id :bigint not null, primary key
# attempts :integer default(0), not null
# expire_at :datetime not null
# key :string not null
# nonce :string not null
# verified_at :datetime
# version :integer not null
# created_at :datetime not null
#
# Indexes
#
# index_otp_challenges_on_key_and_version (key,version) UNIQUE
#
class OtpChallenge < ApplicationRecord
end
+9 -1
View File
@@ -25,15 +25,17 @@
# account_id :bigint not null # account_id :bigint not null
# external_id :string # external_id :string
# submission_id :bigint not null # submission_id :bigint not null
# template_id :bigint
# #
# Indexes # Indexes
# #
# index_submitters_on_account_id_and_completed_at (account_id,completed_at) WHERE (completed_at IS NOT NULL) # index_submitters_on_account_id_and_completed_at (account_id,completed_at) WHERE (completed_at IS NOT NULL)
# index_submitters_on_account_id_and_id (account_id,id) # index_submitters_on_account_id_and_id (account_id,id)
# index_submitters_on_email (email) # index_submitters_on_email_and_template_id (email,template_id) WHERE (email IS NOT NULL)
# index_submitters_on_external_id (external_id) # index_submitters_on_external_id (external_id)
# index_submitters_on_slug (slug) UNIQUE # index_submitters_on_slug (slug) UNIQUE
# index_submitters_on_submission_id (submission_id) # index_submitters_on_submission_id (submission_id)
# index_submitters_on_template_id_and_id (template_id,id)
# #
# Foreign Keys # Foreign Keys
# #
@@ -68,6 +70,8 @@ class Submitter < ApplicationRecord
scope :completed, -> { where.not(completed_at: nil) } scope :completed, -> { where.not(completed_at: nil) }
before_validation :set_template_id, on: :create
after_destroy :anonymize_email_events, if: -> { Docuseal.multitenant? } after_destroy :anonymize_email_events, if: -> { Docuseal.multitenant? }
def status def status
@@ -124,6 +128,10 @@ class Submitter < ApplicationRecord
private private
def set_template_id
self.template_id = submission&.template_id
end
def anonymize_email_events def anonymize_email_events
email_events.each do |event| email_events.each do |event|
event.update!(email: Digest::MD5.base64digest(event.email)) event.update!(email: Digest::MD5.base64digest(event.email))
+1 -1
View File
@@ -22,7 +22,7 @@
# index_webhook_events_on_webhook_url_id_and_id (webhook_url_id,id) # index_webhook_events_on_webhook_url_id_and_id (webhook_url_id,id)
# #
class WebhookEvent < ApplicationRecord class WebhookEvent < ApplicationRecord
attribute :uuid, :string, default: -> { SecureRandom.uuid } attribute :uuid, :string, default: -> { SecureRandom.uuid_v7 }
belongs_to :webhook_url, optional: true belongs_to :webhook_url, optional: true
belongs_to :account, optional: true belongs_to :account, optional: true
+3 -3
View File
@@ -133,7 +133,7 @@
<% end %> <% end %>
<% end %> <% end %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TO_DECLINE_KEY) %> <% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TO_DECLINE_KEY) %>
<% if can?(:manage, account_config) && (!turbo_native_ios? || !Docuseal.multitenant? || can?(:manage, :disable_decline)) %> <% if can?(:manage, account_config) && (!turbo_native_app? || !Docuseal.multitenant? || can?(:manage, :disable_decline)) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %> <%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %> <%= f.hidden_field :key %>
<div class="flex items-center justify-between gap-4 py-2.5"> <div class="flex items-center justify-between gap-4 py-2.5">
@@ -156,7 +156,7 @@
<% end %> <% end %>
<% end %> <% end %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TO_DELEGATE_KEY) %> <% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TO_DELEGATE_KEY) %>
<% if can?(:manage, account_config) && (!turbo_native_ios? || !Docuseal.multitenant? || can?(:manage, :delegate_form)) %> <% if can?(:manage, account_config) && (!turbo_native_app? || !Docuseal.multitenant? || can?(:manage, :delegate_form)) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %> <%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %> <%= f.hidden_field :key %>
<div class="flex items-center justify-between gap-4 py-2.5"> <div class="flex items-center justify-between gap-4 py-2.5">
@@ -196,7 +196,7 @@
<% end %> <% end %>
<% end %> <% end %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::DOWNLOAD_LINKS_EXPIRE_KEY) %> <% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::DOWNLOAD_LINKS_EXPIRE_KEY) %>
<% if can?(:manage, account_config) %> <% if !Docuseal.demo? && can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %> <%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %> <%= f.hidden_field :key %>
<div class="flex items-center justify-between gap-4 py-2.5"> <div class="flex items-center justify-between gap-4 py-2.5">
@@ -1,4 +1,4 @@
<% unless turbo_native_ios? %> <% unless turbo_native_app? %>
<tr class="group"> <tr class="group">
<td> <td>
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M12 18c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
<path d="M16 19h6" />
<path d="M19 16v6" />
</svg>

After

Width:  |  Height:  |  Size: 460 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 19h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v5.5" />
<path d="M16 19h6" />
<path d="M19 16v6" />
<path d="M3 7l9 6l9 -6" />
</svg>

After

Width:  |  Height:  |  Size: 441 B

+2 -3
View File
@@ -1,8 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /> <path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" />
<path d="M3 21v-2a4 4 0 0 1 4 -4h4c.96 0 1.84 .338 2.53 .901" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
<path d="M16 19h6" /> <path d="M16 19h6" />
<path d="M19 16v6" /> <path d="M19 16v6" />
<path d="M6 21v-2a4 4 0 0 1 4 -4h4" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 488 B

After

Width:  |  Height:  |  Size: 421 B

+17
View File
@@ -7,4 +7,21 @@
-webkit-user-drag: none; -webkit-user-drag: none;
-webkit-touch-callout: none; -webkit-touch-callout: none;
} }
<% if turbo_native_android? %>
input[type="date"] {
appearance: none;
-webkit-appearance: none;
}
input[type="date"]::-webkit-calendar-picker-indicator {
display: none;
}
@media (max-width: 767px) {
div:has(> #account_settings_menu) + * {
margin-top: 0 !important;
}
}
<% end %>
</style> </style>
+3 -3
View File
@@ -33,10 +33,10 @@
<% end %> <% end %>
<turbo-frame id="drawer"></turbo-frame> <turbo-frame id="drawer"></turbo-frame>
<%= render turbo_native_app? ? 'shared/native_navbar' : 'shared/navbar' %> <%= render turbo_native_app? ? 'shared/native_navbar' : 'shared/navbar' %>
<% if flash.present? %><%= render 'shared/flash' %><% end %> <main class="max-w-6xl mx-auto px-4 md:px-2 mb-8">
<div class="max-w-6xl mx-auto px-4 md:px-2 mb-8">
<%= yield %> <%= yield %>
</div> </main>
<% if flash.present? %><%= render 'shared/flash' %><% end %>
<%= render 'shared/body_scripts' %> <%= render 'shared/body_scripts' %>
</body> </body>
</html> </html>
+3 -1
View File
@@ -13,6 +13,8 @@
<%= stylesheet_pack_tag 'form', media: 'all', data: { turbo_track: 'dynamic' } %> <%= stylesheet_pack_tag 'form', media: 'all', data: { turbo_track: 'dynamic' } %>
</head> </head>
<body> <body>
<%= yield %> <main>
<%= yield %>
</main>
</body> </body>
</html> </html>
+4 -2
View File
@@ -13,8 +13,10 @@
<%= stylesheet_pack_tag 'application', media: 'all', data: { turbo_track: 'dynamic' } %> <%= stylesheet_pack_tag 'application', media: 'all', data: { turbo_track: 'dynamic' } %>
</head> </head>
<body> <body>
<% if flash.present? %><%= render 'shared/flash' %><% end %> <main>
<%= yield %> <%= yield %>
</main>
<turbo-frame id="modal"></turbo-frame> <turbo-frame id="modal"></turbo-frame>
<% if flash.present? %><%= render 'shared/flash' %><% end %>
</body> </body>
</html> </html>
@@ -22,7 +22,7 @@
<% end %> <% end %>
</div> </div>
<%= render 'bcc_form', config: @bcc_config %> <%= render 'bcc_form', config: @bcc_config %>
<% unless can?(:manage, :personalization_restricted) || (turbo_native_ios? && !can?(:manage, :email_reminders)) %> <% unless can?(:manage, :personalization_restricted) || (turbo_native_app? && !can?(:manage, :email_reminders)) %>
<div class="flex justify-between items-end mb-4 mt-8"> <div class="flex justify-between items-end mb-4 mt-8">
<h2 class="text-2xl md:text-3xl font-bold"> <h2 class="text-2xl md:text-3xl font-bold">
<%= t('sign_request_email_reminders') %> <%= t('sign_request_email_reminders') %>
@@ -10,7 +10,7 @@
<%= render 'documents_copy_email_form' %> <%= render 'documents_copy_email_form' %>
<%= render 'submitter_completed_email_form' %> <%= render 'submitter_completed_email_form' %>
</div> </div>
<% if !turbo_native_ios? || can?(:manage, :custom_logo) %> <% if !turbo_native_app? || can?(:manage, :custom_logo) %>
<p class="text-3xl md:text-4xl font-bold mb-4 mt-8"> <p class="text-3xl md:text-4xl font-bold mb-4 mt-8">
<%= t('company_logo') %> <%= t('company_logo') %>
</p> </p>
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="w-full text-center"> <div class="w-full text-center">
<span class="font-bold"><%= t('demo_environment') %></span> <span class="font-bold"><%= t('demo_environment') %></span>
<br> <br>
<%= t('create_a_new_template_document_form_or_submit_the_existing_one_html', new_template_link: new_template_path, start_form_link: start_form_url(slug: ::Template.first&.slug)) %> 😊 <%= t('create_a_new_template_document_form_html', new_template_link: new_template_path) %>
</div> </div>
</div> </div>
</div> </div>
+10 -8
View File
@@ -1,11 +1,13 @@
<script type="application/json" id="native_path_configuration"><%= render partial: 'layouts/native_path_configuration', formats: :json %></script> <script type="application/json" id="native_path_configuration"><%= render partial: 'layouts/native_path_configuration', formats: :json %></script>
<% if signed_in? %> <% if signed_in? %>
<native-action data-placement="profile" data-icon="user"> <% unless turbo_native_android? %>
<%= link_to t('profile'), settings_profile_index_path %> <native-action data-placement="profile" data-icon="user">
</native-action> <%= link_to t('profile'), settings_profile_index_path %>
<native-action data-placement="profile" data-icon="settings"> </native-action>
<%= link_to t('settings'), settings_path %> <native-action data-placement="profile" data-icon="settings">
</native-action> <%= link_to t('settings'), settings_path %>
</native-action>
<% end %>
<% if current_account.testing? && (can?(:manage, EncryptedConfig) || current_user != true_user) %> <% if current_account.testing? && (can?(:manage, EncryptedConfig) || current_user != true_user) %>
<native-action data-placement="test_mode" data-icon="code" data-label="<%= t('exit_test_mode') %>"> <native-action data-placement="test_mode" data-icon="code" data-label="<%= t('exit_test_mode') %>">
<%= button_to '', testing_account_path, method: :delete, form: { data: { turbo_action: 'replace' } } %> <%= button_to '', testing_account_path, method: :delete, form: { data: { turbo_action: 'replace' } } %>
@@ -22,13 +24,13 @@
<% end %> <% end %>
<% if can?(:create, ::Template) && controller_name.in?(%w[templates_dashboard submissions_dashboard template_folders templates_archived submissions_archived]) %> <% if can?(:create, ::Template) && controller_name.in?(%w[templates_dashboard submissions_dashboard template_folders templates_archived submissions_archived]) %>
<% folder_name = @template_folder&.full_name %> <% folder_name = @template_folder&.full_name %>
<native-action data-placement="create" data-icon="upload" data-native="upload" data-label="<%= t('upload') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>" data-accept="image/*, application/pdf, application/zip, application/json<%= ", #{Templates::CreateAttachments::DOCUMENT_EXTENSIONS.join(', ')}" if Docuseal.advanced_formats? %>"></native-action> <native-action data-placement="create" data-icon="upload" data-native="upload" data-label="<%= t('upload') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>" data-accept="image/*, application/pdf, application/zip, application/json, <%= Templates::CreateAttachments.document_extensions.join(', ') %>"></native-action>
<native-action data-placement="create" data-icon="scan" data-native="scan" data-label="<%= t('scan') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>"></native-action> <native-action data-placement="create" data-icon="scan" data-native="scan" data-label="<%= t('scan') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>"></native-action>
<native-action data-placement="create" data-icon="pencil_plus"> <native-action data-placement="create" data-icon="pencil_plus">
<%= link_to t('create'), new_template_path(folder_name: folder_name), data: { turbo_frame: :modal, native_modal: :medium } %> <%= link_to t('create'), new_template_path(folder_name: folder_name), data: { turbo_frame: :modal, native_modal: :medium } %>
</native-action> </native-action>
<% unless controller_name.in?(%w[templates_archived submissions_archived]) %> <% unless controller_name.in?(%w[templates_archived submissions_archived]) %>
<native-action data-placement="menu" data-menu="<%= t('new') %>" data-menu-icon="pencil_plus" data-icon="upload" data-native="upload" data-label="<%= t('upload') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>" data-accept="image/*, application/pdf, application/zip, application/json<%= ", #{Templates::CreateAttachments::DOCUMENT_EXTENSIONS.join(', ')}" if Docuseal.advanced_formats? %>"></native-action> <native-action data-placement="menu" data-menu="<%= t('new') %>" data-menu-icon="pencil_plus" data-icon="upload" data-native="upload" data-label="<%= t('upload') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>" data-accept="image/*, application/pdf, application/zip, application/json, <%= Templates::CreateAttachments.document_extensions.join(', ') %>"></native-action>
<native-action data-placement="menu" data-menu="<%= t('new') %>" data-menu-icon="pencil_plus" data-icon="scan" data-native="scan" data-label="<%= t('scan') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>"></native-action> <native-action data-placement="menu" data-menu="<%= t('new') %>" data-menu-icon="pencil_plus" data-icon="scan" data-native="scan" data-label="<%= t('scan') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>"></native-action>
<native-action data-placement="menu" data-menu="<%= t('new') %>" data-menu-icon="pencil_plus" data-icon="pencil_plus"> <native-action data-placement="menu" data-menu="<%= t('new') %>" data-menu-icon="pencil_plus" data-icon="pencil_plus">
<%= link_to t('create'), new_template_path(folder_name: folder_name), data: { turbo_frame: :modal, native_modal: :medium } %> <%= link_to t('create'), new_template_path(folder_name: folder_name), data: { turbo_frame: :modal, native_modal: :medium } %>
+2 -2
View File
@@ -1,5 +1,5 @@
<%= render 'shared/navbar_warning' %> <%= render 'shared/navbar_warning' %>
<div class="max-w-6xl mb-4 mx-auto px-4 md:px-2 py-3 flex items-center justify-between"> <nav class="max-w-6xl mb-4 mx-auto px-4 md:px-2 py-3 flex items-center justify-between">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<a href="<%= root_path %>" class="text-2xl font-bold items-center flex space-x-2"> <a href="<%= root_path %>" class="text-2xl font-bold items-center flex space-x-2">
<%= render 'shared/title' %> <%= render 'shared/title' %>
@@ -106,4 +106,4 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
</div> </nav>
+5 -5
View File
@@ -43,7 +43,7 @@
<%= link_to t('storage'), settings_storage_index_path, class: 'text-base hover:bg-base-300', data: nav_link_data %> <%= link_to t('storage'), settings_storage_index_path, class: 'text-base hover:bg-base-300', data: nav_link_data %>
</li> </li>
<% end %> <% end %>
<% if can?(:read, EncryptedConfig.new(key: 'submitter_invitation_sms', account: current_account)) && true_user == current_user && (!turbo_native_ios? || can?(:manage, :sms_verification)) %> <% if can?(:read, EncryptedConfig.new(key: 'submitter_invitation_sms', account: current_account)) && true_user == current_user && (!turbo_native_app? || can?(:manage, :sms_verification)) %>
<li> <li>
<%= link_to 'SMS', settings_sms_path, class: 'text-base hover:bg-base-300', data: nav_link_data %> <%= link_to 'SMS', settings_sms_path, class: 'text-base hover:bg-base-300', data: nav_link_data %>
</li> </li>
@@ -84,7 +84,7 @@
</li> </li>
<% end %> <% end %>
<% end %> <% end %>
<% if !Docuseal.demo? && !turbo_native_ios? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.linked_account_account) %> <% if !Docuseal.demo? && !turbo_native_app? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.linked_account_account) %>
<li> <li>
<%= content_for(:pro_link) || link_to(Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'text-base hover:bg-base-300', data: { turbo: false }) do %> <%= content_for(:pro_link) || link_to(Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'text-base hover:bg-base-300', data: { turbo: false }) do %>
<%= t('plans') %> <%= t('plans') %>
@@ -93,21 +93,21 @@
</li> </li>
<% end %> <% end %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user == true_user || current_account.testing?) %> <% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user == true_user || current_account.testing?) %>
<% if Docuseal.multitenant? || !turbo_native_ios? %> <% unless turbo_native_app? %>
<li> <li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/api") : "#{Docuseal::CONSOLE_URL}/on_premises", class: 'text-base hover:bg-base-300', data: { turbo: false } do %> <%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/api") : "#{Docuseal::CONSOLE_URL}/on_premises", class: 'text-base hover:bg-base-300', data: { turbo: false } do %>
<% if Docuseal.multitenant? %> API <% else %> <%= t('console') %> <% end %> <% if Docuseal.multitenant? %> API <% else %> <%= t('console') %> <% end %>
<% end %> <% end %>
</li> </li>
<% end %> <% end %>
<% if Docuseal.multitenant? %> <% if Docuseal.multitenant? && !turbo_native_app? %>
<li> <li>
<%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/embedding/form"), class: 'text-base hover:bg-base-300', data: { turbo: false } do %> <%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/embedding/form"), class: 'text-base hover:bg-base-300', data: { turbo: false } do %>
<%= t('embedding') %> <%= t('embedding') %>
<% end %> <% end %>
</li> </li>
<% end %> <% end %>
<% if (!Docuseal.multitenant? || can?(:manage, :saml_sso)) && can?(:read, EncryptedConfig.new(key: 'saml_configs', account: current_account)) && true_user == current_user && (!turbo_native_ios? || can?(:manage, :saml_sso)) %> <% if (!Docuseal.multitenant? || can?(:manage, :saml_sso)) && can?(:read, EncryptedConfig.new(key: 'saml_configs', account: current_account)) && true_user == current_user && (!turbo_native_app? || can?(:manage, :saml_sso)) %>
<li> <li>
<%= link_to 'SSO', settings_sso_index_path, class: 'text-base hover:bg-base-300', data: nav_link_data %> <%= link_to 'SSO', settings_sso_index_path, class: 'text-base hover:bg-base-300', data: nav_link_data %>
</li> </li>
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff. Show more