Compare commits

...

151 Commits

Author SHA1 Message Date
Alex Turchyn 911e55ccc3 Merge from docusealco/wip 2026-04-13 13:25:16 +03:00
Pete Matsyburka 41fedfcc40 show verification error 2026-04-12 13:30:13 +03:00
Pete Matsyburka 2f9fc95af3 remove unused module 2026-04-11 18:13:25 +03:00
Pete Matsyburka 11b0b16ca7 prefill signature client side 2026-04-11 18:13:25 +03:00
Pete Matsyburka f8cb7ffdab remove mathjs 2026-04-11 18:13:23 +03:00
Pete Matsyburka 8b59c0aaa0 adjust normalize values 2026-04-10 15:20:35 +03:00
Pete Matsyburka bb2fb7a0c2 refactor download 2026-04-10 12:54:48 +03:00
Pete Matsyburka 8f8b36617a fix first party download from preview 2026-04-10 11:15:16 +03:00
Pete Matsyburka ff53436fd4 fix dynamic editor layout 2026-04-08 17:18:38 +03:00
Pete Matsyburka cf09a4f733 update gem 2026-04-08 12:24:31 +03:00
Pete Matsyburka 2303c21cea download users csv 2026-04-08 12:24:22 +03:00
Pete Matsyburka 89e797b95f update message 2026-04-08 10:29:45 +03:00
Pete Matsyburka 20375c3a42 update gem 2026-04-08 08:40:41 +03:00
Pete Matsyburka 339ceda18d upload attachment if not completed 2026-04-07 18:20:16 +03:00
Pete Matsyburka 97ce32fd52 set attachment name 2026-04-07 16:34:15 +03:00
Alex Turchyn 8c4cbd86f7 Merge from docusealco/wip 2026-04-06 15:22:38 +03:00
Pete Matsyburka eaedaa96bb fix rubocop 2026-04-02 13:28:12 +03:00
Pete Matsyburka bd7ad5fce8 update gems 2026-04-02 13:28:12 +03:00
Pete Matsyburka 759dac1b70 update schema 2026-04-01 19:07:57 +03:00
Pete Matsyburka 38577c6235 fix dynamic doc fields assignment 2026-04-01 16:08:49 +03:00
Pete Matsyburka b24fff0f55 fix rtl stamp 2026-04-01 11:10:09 +03:00
Pete Matsyburka 9e0efeb092 fix unicode in stamp 2026-04-01 10:29:31 +03:00
Pete Matsyburka 41152f90fc fix erblint 2026-03-30 15:18:34 +03:00
Alex Turchyn 4486f8040e Merge from docusealco/wip 2026-03-30 14:35:18 +03:00
Pete Matsyburka a98caad968 use musl build 2026-03-29 11:18:41 +03:00
Pete Matsyburka ffb75238b4 disable confetti by default 2026-03-28 16:53:20 +02:00
Pete Matsyburka cba3e74f3a optimize build 2026-03-28 16:44:08 +02:00
Pete Matsyburka 8d3f0b5b98 silence not found trace 2026-03-28 14:50:38 +02:00
Pete Matsyburka a2598b30f7 optimize build 2026-03-28 13:48:39 +02:00
Pete Matsyburka 8d11f5ddae adjust email 2fa 2026-03-28 12:51:00 +02:00
Pete Matsyburka 6ffc88b4ed fix n+1 2026-03-28 09:27:50 +02:00
Pete Matsyburka 640b451727 adjust shared link 2026-03-28 09:07:52 +02:00
Pete Matsyburka db6e2eb7ca preserve order with verification 2026-03-27 17:16:06 +02:00
Pete Matsyburka 407816b576 redirect delegate completed 2026-03-27 14:51:14 +02:00
Pete Matsyburka 486cc493e6 add i18n 2026-03-27 14:21:47 +02:00
Pete Matsyburka 3eb0b8a89f allow to delegate 2026-03-27 14:21:47 +02:00
Pete Matsyburka c23eca0ade fix field style 2026-03-26 16:09:57 +02:00
Pete Matsyburka 67de153016 increase image limit 2026-03-26 11:40:42 +02:00
Pete Matsyburka ba2da69a67 fix load ico 2026-03-26 08:29:17 +02:00
Pete Matsyburka 111bef14e1 update rails 2026-03-24 23:56:40 +02:00
Alex Turchyn 22d4c1bd10 Merge from docusealco/wip 2026-03-23 14:59:53 +02:00
Pete Matsyburka e3dfb0ae83 add SMTP_ENABLE_TLS 2026-03-23 10:02:54 +02:00
Pete Matsyburka 8d7c3f0e26 fix generation 2026-03-23 09:48:39 +02:00
Pete Matsyburka b11775ebda remove premailer 2026-03-22 18:13:41 +02:00
Pete Matsyburka 608191183a update deps 2026-03-21 13:37:31 +02:00
Pete Matsyburka 2ba903e170 fix specs 2026-03-18 10:18:28 +02:00
Alex Turchyn 342b64d502 add translations 2026-03-18 09:52:58 +02:00
Pete Matsyburka 1a8f048aaa update devise 2026-03-18 09:52:21 +02:00
Pete Matsyburka 7309581b75 add template archived event 2026-03-18 09:52:21 +02:00
Pete Matsyburka 36d59c40a7 add template update event 2026-03-18 08:36:36 +02:00
Pete Matsyburka ed3c7c324b adjust save dynamic 2026-03-16 17:24:07 +02:00
Alex Turchyn fd868360a3 Merge from docusealco/wip 2026-03-16 17:07:07 +02:00
Pete Matsyburka 8dc2ebfbe0 fix builder save 2026-03-16 16:59:45 +02:00
Alex Turchyn 997711b34a Merge from docusealco/wip 2026-03-16 13:36:06 +02:00
Pete Matsyburka ac01b7129d add i18n 2026-03-16 12:42:58 +02:00
Pete Matsyburka f4b7528c0d fix send form 2026-03-16 08:52:42 +02:00
Pete Matsyburka d7b9ae5df5 add datenow size 2026-03-16 08:21:36 +02:00
Pete Matsyburka 4db29126e9 fix variables list 2026-03-16 08:04:02 +02:00
Pete Matsyburka 4cfee2fe45 fix schema documents 2026-03-15 15:02:46 +02:00
Pete Matsyburka db5dbd6ac2 adjust dynamic editor 2026-03-15 14:05:20 +02:00
Pete Matsyburka 44db775b89 fix variable style 2026-03-14 17:47:53 +02:00
Alex Turchyn 8e2e780a1a fix webhook events filtering 2026-03-13 20:04:09 +02:00
Pete Matsyburka 7aba2eb4da disable decline 2026-03-12 08:58:56 +02:00
Pete Matsyburka 3e250bfb28 error update if declined 2026-03-11 23:44:06 +02:00
Pete Matsyburka 9c02ed66f5 fix field scroll 2026-03-11 14:43:16 +02:00
Pete Matsyburka f5ae8c60d3 preload dynamic editor 2026-03-11 10:44:12 +02:00
Pete Matsyburka e90ac391b2 update readme 2026-03-10 19:09:00 +02:00
Pete Matsyburka 0e5f949bcf remove email from signing reason 2026-03-10 11:39:00 +02:00
Pete Matsyburka b7c270bef8 dir auto section 2026-03-10 10:31:45 +02:00
Pete Matsyburka 5f16b506f5 fix n+1 2026-03-10 08:12:32 +02:00
Pete Matsyburka 176148fc71 adjust default initial 2026-03-09 17:29:46 +02:00
Alex Turchyn 861daaedf4 Merge from docusealco/wip 2026-03-09 10:40:29 +02:00
Pete Matsyburka d8430f4e63 fix migration 2026-03-09 09:02:56 +02:00
Pete Matsyburka dd44864485 fix field position 2026-03-06 11:37:53 +02:00
Pete Matsyburka b3bb29f383 fix brakeman 2026-03-05 21:42:55 +02:00
Pete Matsyburka b4d137c0de remove flash 2026-03-05 12:30:26 +02:00
Alex Turchyn 62a969d8fe add MCP support 2026-03-05 12:17:14 +02:00
Pete Matsyburka 961f09e092 set dynamic fields 2026-03-05 10:08:29 +02:00
Pete Matsyburka b2dfa83f18 upload json 2026-03-04 12:27:15 +02:00
Pete Matsyburka 37196ff89f add dynamic documents 2026-03-04 10:13:30 +02:00
Pete Matsyburka b50d982497 smtp timeout env vars 2026-03-03 22:49:55 +02:00
Pete Matsyburka 4a484aca69 hide storage nav if configured with env 2026-03-03 22:01:44 +02:00
Pete Matsyburka 0bf37d571a update color 2026-03-03 18:46:06 +02:00
Alex Turchyn d4e94ac2bc Merge from docusealco/wip 2026-03-02 17:26:04 +02:00
Pete Matsyburka 75bc789bb1 add reply to 2026-03-02 14:47:37 +02:00
Pete Matsyburka bc231d5bf1 add reply to 2026-03-02 14:34:16 +02:00
Alex Turchyn ab9008b3be fix brakeman SQL injection warning 2026-03-01 16:14:15 +02:00
Pete Matsyburka 65491936c3 fix archived export 2026-02-26 17:20:05 +02:00
Pete Matsyburka e589998840 add field type class 2026-02-26 11:08:26 +02:00
Pete Matsyburka 0afba8d11e adjust with prefillable 2026-02-26 11:03:08 +02:00
Pete Matsyburka f622ed168f fix autocomplete 2026-02-25 13:01:03 +02:00
Pete Matsyburka 5a31df40d8 add field settings classes 2026-02-25 11:02:00 +02:00
Pete Matsyburka db3b80c96c fix clone 2026-02-25 11:02:00 +02:00
Pete Matsyburka bd28a761d0 adjust bmp loader 2026-02-25 11:02:00 +02:00
Pete Matsyburka 4f2830a34d increase image resolution 2026-02-25 11:02:00 +02:00
Pete Matsyburka a5716cd518 show html textarea 2026-02-25 11:02:00 +02:00
Pete Matsyburka d035525c7a additional terms 2026-02-25 11:02:00 +02:00
Alex Turchyn 13fa87c449 Merge from docusealco/wip 2026-02-23 13:56:04 +02:00
Pete Matsyburka 246ccfb049 fix highlight 2026-02-23 08:31:38 +02:00
Pete Matsyburka cbffb1e5b8 cloudflare r2 fix 2026-02-22 20:13:19 +02:00
Pete Matsyburka c4b007411d radio select in formula 2026-02-22 19:54:06 +02:00
Pete Matsyburka 04264fe937 fix reason field 2026-02-22 12:15:44 +02:00
Pete Matsyburka 0c37ad12b7 add variables key 2026-02-22 09:46:04 +02:00
Pete Matsyburka 8c5298ed2e refactor link tooltip 2026-02-22 09:28:30 +02:00
Pete Matsyburka 262118e047 update deps 2026-02-22 07:37:05 +02:00
Pete Matsyburka e5b63ea2b0 fix multi party email form 2026-02-21 17:31:04 +02:00
Pete Matsyburka 746757d402 rtl in editor 2026-02-21 16:38:25 +02:00
Pete Matsyburka e6e640328b use br for email content 2026-02-21 16:20:48 +02:00
Pete Matsyburka 845782a69c link input width 2026-02-21 15:48:59 +02:00
Pete Matsyburka fc6baa1b3b adjust autolink 2026-02-21 15:48:58 +02:00
Pete Matsyburka 47822ecc15 remove autolink 2026-02-21 14:55:11 +02:00
Pete Matsyburka 3d0c7f1118 markdown in preferences 2026-02-21 14:44:19 +02:00
Pete Matsyburka e9c8e4d325 adjust editor 2026-02-21 14:32:10 +02:00
Alex Turchyn 39cc82ce0c add markdown editor 2026-02-21 11:15:09 +02:00
Pete Matsyburka 451f83421a fix cell render 2026-02-21 10:49:05 +02:00
Pete Matsyburka 5073f2eefb adjsut sign yourself 2026-02-21 10:17:58 +02:00
Pete Matsyburka 231fff5508 canvas blocked message 2026-02-21 09:53:03 +02:00
Pete Matsyburka f1d146eca3 fix typos 2026-02-20 22:39:03 +02:00
Pete Matsyburka b6635fcc4f change user only when root 2026-02-20 22:25:12 +02:00
Pete Matsyburka fb5e13ee4c adjust recipients form 2026-02-20 18:26:10 +02:00
Pete Matsyburka ba84741a64 invite party via field 2026-02-20 12:41:45 +02:00
Pete Matsyburka 65c275ac17 rename 2026-02-20 10:16:06 +02:00
Pete Matsyburka 825322d489 adjust for custom domain 2026-02-20 09:14:36 +02:00
Pete Matsyburka 739e2abdf8 vips size limit 2026-02-19 14:24:05 +02:00
Pete Matsyburka 1d2394e31e size limit 2026-02-19 14:17:59 +02:00
Pete Matsyburka 1b41af798d update 2026-02-17 19:42:47 +02:00
Pete Matsyburka 118f4a231b detailed time format 2026-02-17 18:09:34 +02:00
Pete Matsyburka e48652f425 fix rubocop 2026-02-17 10:29:00 +02:00
Pete Matsyburka 848f01edf8 add spec 2026-02-17 10:19:29 +02:00
Pete Matsyburka 43fbc42770 fix reason field 2026-02-17 10:04:11 +02:00
Pete Matsyburka 2c736a0eed validate readonly field 2026-02-17 08:53:13 +02:00
Alex Turchyn 33ca930055 Merge from docusealco/wip
Wip
2026-02-16 15:08:07 +02:00
Pete Matsyburka 3b396f8421 fix redirect 2026-02-16 10:00:51 +02:00
Pete Matsyburka aa77e3a8d3 adjust port check 2026-02-16 08:08:41 +02:00
Pete Matsyburka 75316d8d87 fix draw custom field 2026-02-15 22:02:00 +02:00
Pete Matsyburka a999109a5c add port check 2026-02-15 12:18:53 +02:00
Pete Matsyburka bdd33c7d6b fix spec 2026-02-15 09:53:31 +02:00
Pete Matsyburka 61c5ee22a0 hide form credentials 2026-02-15 09:11:40 +02:00
Pete Matsyburka 12c5b909e0 use cancan 2026-02-14 19:13:05 +02:00
Pete Matsyburka 871ef6dda6 add fetch options 2026-02-14 18:50:48 +02:00
Pete Matsyburka 40052a2d7c use query_params 2026-02-14 17:42:08 +02:00
Pete Matsyburka 347be0137d refactor 2fa 2026-02-14 17:35:10 +02:00
Pete Matsyburka fe6baba8bf fix erb lint 2026-02-14 12:10:21 +02:00
Pete Matsyburka ed8c313bd4 timestamp controller multitenant 2026-02-14 12:02:37 +02:00
Pete Matsyburka ca0acb34d6 use url for open modal 2026-02-14 12:02:31 +02:00
Pete Matsyburka 34ea639c25 escape wildcard query 2026-02-14 12:02:25 +02:00
Pete Matsyburka 680ab9dbed raise invalid param 2026-02-14 10:18:58 +02:00
Pete Matsyburka 9377766e52 fix url download 2026-02-14 09:08:52 +02:00
Pete Matsyburka 7a2c37454e update favicon 2026-02-12 15:56:54 +02:00
Pete Matsyburka 8f6f418f54 fix font size 2026-02-12 14:27:15 +02:00
Pete Matsyburka 9b7745c565 hide resend on archived 2026-02-11 17:32:04 +02:00
266 changed files with 10333 additions and 1688 deletions
+17 -1
View File
@@ -51,6 +51,15 @@ Style/NumericPredicate:
Style/MinMaxComparison:
Enabled: false
Style/EmptyClassDefinition:
Enabled: false
Style/OneClassPerFile:
Enabled: false
Layout/MultilineMethodCallIndentation:
Enabled: false
Naming/PredicateMethod:
Enabled: false
@@ -84,7 +93,7 @@ RSpec/AnyInstance:
Enabled: false
Metrics/BlockNesting:
Max: 5
Max: 6
Rails/I18nLocaleTexts:
Enabled: false
@@ -106,3 +115,10 @@ Rails/StrongParametersExpect:
Rails/RedirectBackOrTo:
Enabled: false
Rails/UnknownEnv:
Environments:
- development
- test
- production
- local
+12 -14
View File
@@ -2,20 +2,17 @@ FROM ruby:4.0.1-alpine AS download
WORKDIR /fonts
RUN apk --no-cache add fontforge wget && \
RUN apk --no-cache add wget && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \
wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-musl-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
FROM ruby:4.0.1-alpine AS webpack
ENV RAILS_ENV=production
@@ -36,6 +33,7 @@ COPY ./config/shakapacker.yml ./config/shakapacker.yml
COPY ./postcss.config.js ./postcss.config.js
COPY ./tailwind.config.js ./tailwind.config.js
COPY ./tailwind.form.config.js ./tailwind.form.config.js
COPY ./tailwind.dynamic.config.js ./tailwind.dynamic.config.js
COPY ./tailwind.application.config.js ./tailwind.application.config.js
COPY ./app/javascript ./app/javascript
COPY ./app/views ./app/views
@@ -46,12 +44,11 @@ FROM ruby:4.0.1-alpine AS app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
ENV LD_PRELOAD=/lib/libgcompat.so.0
ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app
RUN apk add --no-cache sqlite-dev libpq-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont onnxruntime && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN apk add --no-cache libpq vips redis vips-heif fontconfig onnxruntime
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
@@ -69,7 +66,7 @@ activate = 1' >> /etc/openssl_legacy.cnf
COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./
RUN apk add --no-cache build-base git && bundle install && apk del --no-cache build-base git && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
RUN apk add --no-cache build-base git libpq-dev yaml-dev && bundle install && apk del --no-cache build-base git libpq-dev yaml-dev && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales,resources/unicode_data/properties}'] + Dir['/usr/local/bundle/gems/*/{test,tests,examples,sample,misc,doc,docs}'] + Dir['/usr/local/bundle/gems/*/ext/**/*.{c,h,o,S}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
COPY --chown=docuseal:docuseal ./bin ./bin
COPY --chown=docuseal:docuseal ./app ./app
@@ -79,23 +76,24 @@ COPY --chown=docuseal:docuseal ./log ./log
COPY --chown=docuseal:docuseal ./lib ./lib
COPY --chown=docuseal:docuseal ./public ./public
COPY --chown=docuseal:docuseal ./tmp ./tmp
COPY --chown=docuseal:docuseal LICENSE README.md Rakefile config.ru .version ./
COPY --chown=docuseal:docuseal LICENSE LICENSE_ADDITIONAL_TERMS README.md Rakefile config.ru .version ./
COPY --chown=docuseal:docuseal .version ./public/version
COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts/LICENSE /fonts/
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /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 && \
bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \
chown -R docuseal:docuseal /app/tmp/cache
WORKDIR /data/docuseal
ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal
ENV VIPS_MAX_COORD=17000
EXPOSE 3000
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
+2 -3
View File
@@ -4,6 +4,7 @@ source 'https://rubygems.org'
ruby '4.0.1'
gem 'addressable'
gem 'arabic-letter-connector', require: false
gem 'aws-sdk-s3', require: false
gem 'aws-sdk-secretsmanager', require: false
@@ -28,12 +29,10 @@ gem 'oj'
gem 'onnxruntime', require: false
gem 'pagy'
gem 'pg', require: false
gem 'premailer-rails'
gem 'pretender'
gem 'puma', require: false
gem 'rack'
gem 'rails'
gem 'rails_autolink'
gem 'rails-i18n'
gem 'rotp'
gem 'rouge', require: false
@@ -44,7 +43,7 @@ gem 'shakapacker'
gem 'sidekiq'
gem 'sqlite3', require: false
gem 'strip_attributes'
gem 'trilogy', github: 'trilogy-libraries/trilogy', glob: 'contrib/ruby/*.gemspec', require: false
gem 'trilogy', require: false
gem 'turbo-rails'
gem 'twitter_cldr', require: false
gem 'tzinfo-data'
+148 -172
View File
@@ -1,39 +1,31 @@
GIT
remote: https://github.com/trilogy-libraries/trilogy.git
revision: 3963d490459df7a2b5bedb42424c3285f25eab22
glob: contrib/ruby/*.gemspec
specs:
trilogy (2.10.0)
bigdecimal
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.16)
action_text-trix (2.1.18)
railties
actioncable (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
actioncable (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
actionmailbox (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
actionmailer (8.1.2)
actionpack (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activesupport (= 8.1.2)
actionmailer (8.1.3)
actionpack (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.2)
actionview (= 8.1.2)
activesupport (= 8.1.2)
actionpack (8.1.3)
actionview (= 8.1.3)
activesupport (= 8.1.3)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -41,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.2)
actiontext (8.1.3)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
actionpack (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.2)
activesupport (= 8.1.2)
actionview (8.1.3)
activesupport (= 8.1.3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.2)
activesupport (= 8.1.2)
activejob (8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.3.6)
activemodel (8.1.2)
activesupport (= 8.1.2)
activerecord (8.1.2)
activemodel (= 8.1.2)
activesupport (= 8.1.2)
activemodel (8.1.3)
activesupport (= 8.1.3)
activerecord (8.1.3)
activemodel (= 8.1.3)
activesupport (= 8.1.3)
timeout (>= 0.4.0)
activestorage (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activesupport (= 8.1.2)
activestorage (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activesupport (= 8.1.3)
marcel (~> 1.0)
activesupport (8.1.2)
activesupport (8.1.3)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -83,16 +75,16 @@ GEM
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.8)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
annotaterb (4.20.0)
annotaterb (4.22.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
arabic-letter-connector (0.1.1)
ast (2.4.3)
aws-eventstream (1.4.0)
aws-partitions (1.1209.0)
aws-sdk-core (3.241.4)
aws-partitions (1.1233.0)
aws-sdk-core (3.244.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -100,15 +92,15 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.121.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.212.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-s3 (1.218.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sdk-secretsmanager (1.128.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-secretsmanager (1.129.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
@@ -116,7 +108,7 @@ GEM
cgi
rexml
base64 (0.3.0)
bcrypt (3.1.21)
bcrypt (3.1.22)
better_html (2.2.0)
actionview (>= 7.0)
activesupport (>= 7.0)
@@ -124,11 +116,11 @@ GEM
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
bigdecimal (4.0.1)
bigdecimal (4.1.0)
bindex (0.8.1)
bootsnap (1.21.1)
bootsnap (1.23.0)
msgpack (~> 1.2)
brakeman (7.1.2)
brakeman (8.0.4)
racc
builder (3.3.0)
bullet (8.1.0)
@@ -158,8 +150,6 @@ GEM
bigdecimal
rexml
crass (1.0.6)
css_parser (1.21.1)
addressable
csv (3.3.5)
csv-safe (3.3.1)
csv (~> 3.0)
@@ -171,16 +161,16 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
declarative (0.0.20)
devise (4.9.4)
devise (5.0.3)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
railties (>= 7.0)
responders
warden (~> 1.2.3)
devise-two-factor (6.3.1)
activesupport (>= 7.0, < 8.2)
devise (>= 4.0, < 5.0)
railties (>= 7.0, < 8.2)
devise-two-factor (6.4.0)
activesupport (>= 7.2, < 8.2)
devise (>= 4.0, < 6.0)
railties (>= 7.2, < 8.2)
rotp (~> 6.0)
diff-lcs (1.6.2)
digest-crc (0.7.0)
@@ -189,7 +179,7 @@ GEM
dotenv (3.2.0)
drb (2.2.3)
email_typo (0.2.3)
erb (6.0.1)
erb (6.0.2)
erb_lint (0.9.0)
activesupport
better_html (>= 2.0.1)
@@ -203,7 +193,7 @@ GEM
factory_bot_rails (6.5.1)
factory_bot (~> 6.5)
railties (>= 6.1.0)
faker (3.6.0)
faker (3.6.1)
i18n (>= 1.8.11, < 2)
faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5)
@@ -213,18 +203,17 @@ GEM
faraday (>= 1, < 3)
faraday-net_http (3.4.2)
net-http (~> 0.5)
ferrum (0.17.1)
ferrum (0.17.2)
addressable (~> 2.5)
base64 (~> 0.2)
concurrent-ruby (~> 1.1)
webrick (~> 1.7)
websocket-driver (~> 0.7)
ffi (1.17.3)
ffi (1.17.3-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-musl)
ffi (1.17.3-arm64-darwin)
ffi (1.17.3-x86_64-linux-gnu)
ffi (1.17.3-x86_64-linux-musl)
ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.4-arm64-darwin)
ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl)
foreman (0.90.0)
thor (~> 1.4)
geom2d (0.4.1)
@@ -240,7 +229,7 @@ GEM
retriable (~> 3.1)
google-apis-iamcredentials_v1 (0.26.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.59.0)
google-apis-storage_v1 (0.61.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
@@ -248,8 +237,8 @@ GEM
google-cloud-env (2.3.1)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.5.0)
google-cloud-storage (1.58.0)
google-cloud-errors (1.6.0)
google-cloud-storage (1.59.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
@@ -259,7 +248,7 @@ GEM
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.2.0)
googleauth (1.16.1)
googleauth (1.16.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
@@ -268,24 +257,24 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
hashdiff (1.2.1)
hexapdf (1.5.0)
hexapdf (1.6.0)
cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1)
strscan (>= 3.1.2)
htmlentities (4.4.2)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.2)
irb (1.16.0)
irb (1.17.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.18.1)
json (2.19.3)
jwt (3.1.2)
base64
language_server-protocol (3.17.0.5)
@@ -307,7 +296,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.25.0)
loofah (2.25.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
@@ -322,13 +311,14 @@ GEM
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (6.0.1)
minitest (6.0.3)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
multi_json (1.19.1)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.2)
net-imap (0.6.3)
date
net-protocol
net-pop (0.1.2)
@@ -338,41 +328,39 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.0-aarch64-linux-gnu)
nokogiri (1.19.2-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.0-aarch64-linux-musl)
nokogiri (1.19.2-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.0-arm64-darwin)
nokogiri (1.19.2-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-gnu)
nokogiri (1.19.2-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-musl)
nokogiri (1.19.2-x86_64-linux-musl)
racc (~> 1.4)
numo-narray-alt (0.9.13)
oj (3.16.13)
numo-narray-alt (0.10.3)
oj (3.16.16)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
onnxruntime (0.10.1)
ffi
onnxruntime (0.10.1-aarch64-linux)
ffi
onnxruntime (0.10.1-arm64-darwin)
ffi
onnxruntime (0.10.1-x86_64-linux)
ffi
openssl (4.0.0)
openssl (4.0.1)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.3)
package_json (0.2.0)
pagy (43.2.8)
pagy (43.4.4)
json
uri
yaml
parallel (1.27.0)
parser (3.3.10.1)
parallel (1.28.0)
parser (3.3.11.1)
ast (~> 2.4.1)
racc
pg (1.6.3)
pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
@@ -380,18 +368,10 @@ GEM
pg (1.6.3-x86_64-linux-musl)
pp (0.6.3)
prettyprint
premailer (1.27.0)
addressable
css_parser (>= 1.19.0)
htmlentities (>= 4.0.0)
premailer-rails (1.12.0)
actionmailer (>= 3)
net-smtp
premailer (~> 1.7, >= 1.7.9)
pretender (0.6.0)
actionpack (>= 7.1)
pretender (1.0.0)
actionpack (>= 7.2)
prettyprint (0.2.0)
prism (1.8.0)
prism (1.9.0)
pry (0.16.0)
coderay (~> 1.1)
method_source (~> 1.0)
@@ -401,51 +381,47 @@ GEM
psych (5.3.1)
date
stringio
public_suffix (7.0.2)
public_suffix (7.0.5)
puma (7.2.0)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.4)
rack (3.2.6)
rack-proxy (0.7.7)
rack
rack-session (2.1.1)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.2)
actioncable (= 8.1.2)
actionmailbox (= 8.1.2)
actionmailer (= 8.1.2)
actionpack (= 8.1.2)
actiontext (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activemodel (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
rails (8.1.3)
actioncable (= 8.1.3)
actionmailbox (= 8.1.3)
actionmailer (= 8.1.3)
actionpack (= 8.1.3)
actiontext (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activemodel (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
bundler (>= 1.15.0)
railties (= 8.1.2)
railties (= 8.1.3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.1.0)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
rails_autolink (1.1.8)
actionview (> 3.1)
activesupport (> 3.1)
railties (> 3.1)
railties (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
railties (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -454,11 +430,11 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rdoc (7.1.0)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
tsort
redis-client (0.26.4)
redis-client (0.28.0)
connection_pool
regexp_parser (2.11.3)
reline (0.6.3)
@@ -472,7 +448,7 @@ GEM
responders (3.2.0)
actionpack (>= 7.0)
railties (>= 7.0)
retriable (3.1.2)
retriable (3.4.1)
rexml (3.4.4)
rotp (6.3.0)
rouge (4.7.0)
@@ -485,19 +461,19 @@ GEM
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.7)
rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (8.0.2)
rspec-rails (8.0.4)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.6)
rubocop (1.82.1)
rspec-core (>= 3.13.0, < 5.0.0)
rspec-expectations (>= 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.7)
rubocop (1.86.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -505,10 +481,10 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.48.0, < 2.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.0)
rubocop-ast (1.49.1)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-performance (1.26.1)
@@ -533,14 +509,14 @@ GEM
rubyzip (>= 3.2.2)
rubyzip (3.2.2)
securerandom (0.4.1)
semantic_range (3.1.0)
shakapacker (9.5.0)
semantic_range (3.1.1)
shakapacker (9.7.0)
activesupport (>= 5.2)
package_json
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
sidekiq (8.1.0)
sidekiq (8.1.2)
connection_pool (>= 3.0.0)
json (>= 2.16.0)
logger (>= 1.7.0)
@@ -558,20 +534,22 @@ GEM
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sqlite3 (2.9.0-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl)
sqlite3 (2.9.0-arm64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl)
sqlite3 (2.9.2-aarch64-linux-gnu)
sqlite3 (2.9.2-aarch64-linux-musl)
sqlite3 (2.9.2-arm64-darwin)
sqlite3 (2.9.2-x86_64-linux-gnu)
sqlite3 (2.9.2-x86_64-linux-musl)
stringio (3.2.0)
strip_attributes (2.0.1)
activemodel (>= 3.0, < 9.0)
strscan (3.1.7)
strscan (3.1.8)
thor (1.5.0)
timeout (0.6.0)
timeout (0.6.1)
trailblazer-option (0.1.2)
trilogy (2.12.2)
bigdecimal
tsort (0.2.0)
turbo-rails (2.0.21)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
twitter_cldr (6.14.0)
@@ -581,7 +559,7 @@ GEM
tzinfo
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2025.3)
tzinfo-data (1.2026.1)
tzinfo (>= 1.0.0)
uber (0.1.0)
unicode-display_width (3.2.0)
@@ -592,12 +570,11 @@ GEM
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
web-console (4.3.0)
actionview (>= 8.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.26.1)
railties (>= 8.0.0)
webmock (3.26.2)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@@ -609,7 +586,7 @@ GEM
xpath (3.2.0)
nokogiri (~> 1.8)
yaml (0.4.0)
zeitwerk (2.7.4)
zeitwerk (2.7.5)
PLATFORMS
aarch64-linux
@@ -619,6 +596,7 @@ PLATFORMS
x86_64-linux-musl
DEPENDENCIES
addressable
annotaterb
arabic-letter-connector
aws-sdk-s3
@@ -655,14 +633,12 @@ DEPENDENCIES
onnxruntime
pagy
pg
premailer-rails
pretender
pry-rails
puma
rack
rails
rails-i18n
rails_autolink
rotp
rouge
rqrcode
@@ -678,7 +654,7 @@ DEPENDENCIES
simplecov
sqlite3
strip_attributes
trilogy!
trilogy
turbo-rails
twitter_cldr
tzinfo-data
+5
View File
@@ -0,0 +1,5 @@
Additional Terms
In accordance with Section 7(b) of the GNU Affero General Public License,
a covered work must retain the original DocuSeal attribution in interactive
user interfaces.
+3 -3
View File
@@ -64,7 +64,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|Heroku|Railway|
|:--:|:---:|
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.com/deploy/IGoDnc?referralCode=ruU7JR)|
|**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
@@ -97,8 +97,8 @@ At DocuSeal we have expertise and technologies to make documents creation, filli
## License
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information.
Unless otherwise noted, all files © 2023 DocuSeal LLC.
Distributed under the AGPLv3 License with Section 7(b) Additional Terms. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) and [LICENSE_ADDITIONAL_TERMS](https://github.com/docusealco/docuseal/blob/master/LICENSE_ADDITIONAL_TERMS) for more information.
Unless otherwise noted, all files © 2023-2026 DocuSeal LLC.
## Tools
@@ -11,6 +11,7 @@ class AccountConfigsController < ApplicationController
AccountConfig::FORCE_MFA,
AccountConfig::ALLOW_TO_RESUBMIT,
AccountConfig::ALLOW_TO_DECLINE_KEY,
AccountConfig::ALLOW_TO_DELEGATE_KEY,
AccountConfig::FORM_PREFILL_SIGNATURE_KEY,
AccountConfig::ESIGNING_PREFERENCE_KEY,
AccountConfig::FORM_WITH_CONFETTI_KEY,
@@ -23,7 +24,8 @@ class AccountConfigsController < ApplicationController
AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::COMBINE_PDF_RESULT_KEY,
AccountConfig::REQUIRE_SIGNING_REASON_KEY,
AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY
AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY,
AccountConfig::ENABLE_MCP_KEY
].freeze
InvalidKey = Class.new(StandardError)
@@ -35,7 +37,7 @@ class AccountConfigsController < ApplicationController
end
def destroy
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
raise InvalidKey unless allowed_keys.include?(@account_config.key)
@account_config.destroy!
@@ -44,8 +46,12 @@ class AccountConfigsController < ApplicationController
private
def allowed_keys
ALLOWED_KEYS
end
def load_account_config
raise InvalidKey unless ALLOWED_KEYS.include?(account_config_params[:key])
raise InvalidKey unless allowed_keys.include?(account_config_params[:key])
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key])
+3 -1
View File
@@ -53,7 +53,9 @@ class AccountsController < ApplicationController
# rubocop:disable Layout/LineLength
render turbo_stream: turbo_stream.replace(
:account_delete_button,
html: helpers.tag.p(I18n.t('your_account_removal_request_will_be_processed_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.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
@@ -10,6 +10,12 @@ module Api
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
unless can_upload?(submitter)
Rollbar.error("Can't upload: #{submitter.id}") if defined?(Rollbar)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
if params[:type].in?(%w[initials signature])
image = Vips::Image.new_from_file(params[:file].path)
@@ -40,6 +46,14 @@ module Api
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
@@ -156,7 +156,7 @@ module Api
params:)
else
submissions_attrs, attachments =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template)
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template, purpose: :api)
submissions = Submissions.create_from_submitters(
template:,
@@ -172,7 +172,10 @@ module Api
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments, submitters)
submitters.each do |submitter|
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request) if submitter.completed_at?
if submitter.completed_at?
Submitters::SubmitValues.maybe_invite_via_field(submitter, request)
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request)
end
end
submissions
+12 -2
View File
@@ -34,18 +34,24 @@ module Api
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true, params:)
end
# rubocop:disable Metrics/MethodLength
def update
if @submitter.completed_at?
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
end
if @submitter.declined_at?
return render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
end
submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
normalized_params, new_attachments = Submissions::NormalizeParamUtils.normalize_submitter_params!(
submitter_params.merge(role:),
@submitter.template || Template.new(submitters: submission.template_submitters, account: @submitter.account),
for_submitter: @submitter
for_submitter: @submitter,
purpose: :api
)
Submissions::CreateFromSubmitters.maybe_set_template_fields(submission, [normalized_params],
@@ -60,7 +66,10 @@ module Api
@submitter.submission.save!
SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request) if @submitter.completed_at?
if @submitter.completed_at?
Submitters::SubmitValues.maybe_invite_via_field(@submitter, request)
SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request)
end
end
if @submitter.completed_at?
@@ -78,6 +87,7 @@ module Api
render json: { error: e.message }, status: :unprocessable_content
end
# rubocop:enable Metrics/MethodLength
def submitter_params
submitter_params = params.key?(:submitter) ? params.require(:submitter) : params
@@ -9,7 +9,7 @@ module Api
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: :preview_images_attachments]
associations: [{ schema_documents: :preview_images_attachments }]
).call
cloned_template = Templates::Clone.call(
+6 -2
View File
@@ -69,6 +69,7 @@ module Api
SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.updated')
WebhookUrls.enqueue_events(@template, 'template.archived') if archived == true
render json: @template.as_json(only: %i[id updated_at])
end
@@ -78,6 +79,8 @@ module Api
@template.destroy!
else
@template.update!(archived_at: Time.current)
WebhookUrls.enqueue_events(@template, 'template.archived')
end
render json: @template.as_json(only: %i[id archived_at])
@@ -107,7 +110,8 @@ module Api
:external_id,
:shared_link,
{
submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email order]],
submitters: [%i[name uuid is_requester invite_by_uuid invite_via_field_uuid
optional_invite_by_uuid linked_to_uuid email order]],
fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description, :prefillable,
@@ -116,7 +120,7 @@ module Api
conditions: [%i[field_uuid value action operation]],
options: [%i[value uuid]],
validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]]
}
]
@@ -42,10 +42,6 @@ class ApplicationController < ActionController::Base
end
def default_url_options
if request.domain == 'docuseal.com'
return { host: 'docuseal.com', protocol: ENV['FORCE_SSL'].present? ? 'https' : 'http' }
end
Docuseal.default_url_options
end
+2 -2
View File
@@ -40,7 +40,7 @@ class ErrorsController < ActionController::Base
render json: { status: error_status_code, error: }.compact, status: error_status_code
end
f.html { render error_status_code.to_s, status: error_status_code }
f.any { render error_status_code.to_s, status: error_status_code }
end
end
@@ -51,7 +51,7 @@ class ErrorsController < ActionController::Base
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
headers['Access-Control-Allow-Headers'] = '*'
headers['Access-Control-Max-Age'] = '1728000'
headers['Access-Control-Allow-Credentials'] = true
headers['Access-Control-Allow-Credentials'] = 'true'
end
def error_status_code
+1 -1
View File
@@ -3,7 +3,7 @@
class EsignSettingsController < ApplicationController
DEFAULT_CERT_NAME = 'DocuSeal Self-Host Autogenerated'
CertFormRecord = Struct.new(:name, :file, :password, keyword_init: true) do
CertFormRecord = Struct.new(:name, :file, :password) do
include ActiveModel::Validations
def to_key
+58
View File
@@ -0,0 +1,58 @@
# frozen_string_literal: true
class McpController < ActionController::API
before_action :authenticate_user!
before_action :verify_mcp_enabled!
before_action do
authorize!(:manage, :mcp)
end
def call
return head :ok if request.raw_post.blank?
body = JSON.parse(request.raw_post)
result = Mcp::HandleRequest.call(body, current_user, current_ability)
if result
render json: result
else
head :accepted
end
rescue CanCan::AccessDenied
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_603, message: 'Forbidden' } }, status: :forbidden
rescue JSON::ParserError
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_700, message: 'Parse error' } }, status: :bad_request
end
private
def authenticate_user!
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end
def verify_mcp_enabled!
return if Docuseal.multitenant?
return if AccountConfig.exists?(account_id: current_user.account_id,
key: AccountConfig::ENABLE_MCP_KEY,
value: true)
render json: { error: 'MCP is disabled' }, status: :forbidden
end
def current_user
@current_user ||= user_from_api_key
end
def user_from_api_key
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
return if token.blank?
sha256 = Digest::SHA256.hexdigest(token)
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
end
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class McpSettingsController < ApplicationController
load_and_authorize_resource :mcp_token, parent: false
before_action do
authorize!(:manage, :mcp)
end
def index
@mcp_tokens = @mcp_tokens.active.order(id: :desc)
end
def create
@mcp_token = current_user.mcp_tokens.new(mcp_token_params)
if @mcp_token.save
@mcp_tokens = [@mcp_token]
render :index, status: :created
else
render turbo_stream: turbo_stream.replace(:modal, template: 'mcp_settings/new'), status: :unprocessable_content
end
end
def destroy
@mcp_token.update!(archived_at: Time.current)
redirect_back fallback_location: settings_mcp_index_path, notice: I18n.t('mcp_token_has_been_removed')
end
private
def mcp_token_params
params.require(:mcp_token).permit(:name)
end
end
@@ -6,9 +6,17 @@ class PreviewDocumentPageController < ActionController::API
FORMAT = Templates::ProcessDocument::FORMAT
def show
attachment_uuid = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid], purpose: :attachment)
result_data =
ApplicationRecord.signed_id_verifier.verified(params[:signed_key], purpose: :attachment)
attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid) if attachment_uuid
attachment =
if result_data.is_a?(Array) && result_data.compact_blank.size == 2
attachment_id, attachment_uuid = result_data
ActiveStorage::Attachment.find_by(id: attachment_id, uuid: attachment_uuid)
elsif result_data
ActiveStorage::Attachment.find_by(uuid: result_data)
end
return head :not_found unless attachment
+2
View File
@@ -172,6 +172,8 @@ class StartFormController < ApplicationController
submitters: [submitter],
source: :link)
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submitter.submission)
submitter.account_id = submitter.submission.account_id
submitter
@@ -16,6 +16,7 @@ class SubmissionEventsController < ApplicationController
'email_verified' => 'email_check',
'click_sms' => 'hand_click',
'decline_form' => 'x',
'delegate_form' => 'user_share',
'start_verification' => 'player_play',
'complete_verification' => 'check',
'invite_party' => 'user_plus'
+17 -13
View File
@@ -26,7 +26,7 @@ class SubmissionsController < ApplicationController
unless @submission.submitters.all?(&:completed_at?)
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [submitters: :start_form_submission_events]
associations: [{ submitters: :start_form_submission_events }]
).call
end
@@ -51,18 +51,7 @@ class SubmissionsController < ApplicationController
emails: params[:emails],
params: params.merge('send_completed_email' => true))
else
submissions_attrs = submissions_params[:submission].to_h.values
submissions_attrs, _, new_fields =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, @template, add_fields: true)
Submissions.create_from_submitters(template: @template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
submissions_attrs:,
new_fields:,
params: params.merge('send_completed_email' => true))
create_submissions(@template, submissions_params, params)
end
WebhookUrls.enqueue_events(submissions, 'submission.created')
@@ -97,6 +86,21 @@ class SubmissionsController < ApplicationController
private
def create_submissions(template, submissions_params, params)
submissions_attrs = submissions_params[:submission].to_h.values
submissions_attrs, _, new_fields =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, template, add_fields: true)
Submissions.create_from_submitters(template: template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
submissions_attrs:,
new_fields:,
params: params.merge('send_completed_email' => true))
end
def save_template_message(template, params)
template.preferences['request_email_subject'] = params[:subject] if params[:subject].present?
template.preferences['request_email_body'] = params[:body] if params[:body].present?
@@ -9,7 +9,7 @@ class SubmissionsDebugController < ApplicationController
def index
@submitter = Submitter.preload({ attachments_attachments: :blob },
submission: { template: { documents_attachments: :blob } })
.find_by(slug: params[:submitter_slug])
.find_by(slug: params[:submit_form_slug])
respond_to do |f|
f.html do
@@ -1,40 +1,17 @@
# frozen_string_literal: true
class SubmissionsDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
TTL = 40.minutes
FILES_TTL = 5.minutes
load_and_authorize_resource :submission
def index
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid =
if @submitter&.slug == params[:submitter_slug]
true
else
@submitter = nil
end
@submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
Submissions::EnsureResultGenerated.call(@submitter)
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
last_submitter = @submission.submitters.where.not(completed_at: nil).order(:completed_at).last
return head :not_found unless last_submitter
Submissions::EnsureResultGenerated.call(last_submitter)
if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter)
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
if params[:combined] == 'true'
url = build_combined_url(@submitter)
url = Submitters.build_combined_url(last_submitter)
if url
render json: [url]
@@ -42,43 +19,7 @@ class SubmissionsDownloadController < ApplicationController
head :not_found
end
else
render json: build_urls(last_submitter)
render json: Submitters.build_document_urls(last_submitter)
end
end
private
def current_user_submitter?(submitter)
current_user && current_user.account.submitters.exists?(id: submitter.id)
end
def build_urls(submitter)
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
Submitters.select_attachments_for_download(submitter).map do |attachment|
ActiveStorage::Blob.proxy_url(
attachment.blob,
expires_at: FILES_TTL.from_now.to_i,
filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format)
)
end
end
def build_combined_url(submitter)
return if submitter.submission.submitters.exists?(completed_at: nil)
return if submitter.submission.submitters.order(:completed_at).last != submitter
attachment = submitter.submission.combined_document_attachment
attachment ||= Submissions::EnsureCombinedGenerated.call(submitter)
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
ActiveStorage::Blob.proxy_url(
attachment.blob,
expires_at: FILES_TTL.from_now.to_i,
filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format)
)
end
end
@@ -5,10 +5,11 @@ class SubmissionsExportController < ApplicationController
load_and_authorize_resource :submission, through: :template, parent: false, only: :index
def index
submissions = @submissions.active
.preload(submitters: { documents_attachments: :blob,
attachments_attachments: :blob })
.order(id: :asc)
submissions = params[:archived] == 'true' ? @submissions.archived : @submissions.active
submissions = submissions.preload(submitters: { documents_attachments: :blob,
attachments_attachments: :blob })
.order(id: :asc)
submissions = Submissions.search(current_user, submissions, params[:q], search_values: true)
submissions = Submissions::Filter.call(submissions, current_user, params)
@@ -10,13 +10,15 @@ class SubmissionsPreviewController < ApplicationController
TTL = 40.minutes
def show
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
@sig_submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid =
if submitter && submitter.submission.slug == params[:slug]
@submission = submitter.submission
if @sig_submitter && @sig_submitter.submission.slug == params[:slug]
@submission = @sig_submitter.submission
true
else
@sig_submitter = nil
end
@submission ||= Submission.find_by!(slug: params[:slug])
@@ -36,7 +38,7 @@ class SubmissionsPreviewController < ApplicationController
@submission = Submissions.preload_with_pages(@submission)
render 'submissions/show', layout: 'plain'
render 'submissions/show', layout: 'plain', locals: { is_preview: true }
end
def completed
@@ -0,0 +1,64 @@
# frozen_string_literal: true
class SubmissionsPreviewDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
TTL = 40.minutes
def index
@submission = Submission.find_by!(slug: params[:submission_slug] || params[:submissions_preview_slug])
last_submitter = @submission.submitters.where.not(completed_at: nil).order(:completed_at).last
return head :not_found unless last_submitter
Submissions::EnsureResultGenerated.call(last_submitter)
unless current_user_submission?(@submission)
if use_2fa?(@submission)
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
if last_submitter.completed_at < TTL.ago
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
end
if params[:combined] == 'true'
respond_with_combined(last_submitter)
else
render json: Submitters.build_document_urls(last_submitter)
end
end
private
def respond_with_combined(submitter)
url = Submitters.build_combined_url(submitter)
if url
render json: [url]
else
head :not_found
end
end
def current_user_submission?(submission)
current_user && current_ability.can?(:read, submission)
end
def use_2fa?(submission)
return true if submission.submitters.any? do |e|
e.preferences['require_phone_2fa'] || e.preferences['require_email_2fa']
end
return true if submission.template&.preferences&.dig('require_phone_2fa')
return true if submission.template&.preferences&.dig('require_email_2fa')
false
end
end
@@ -0,0 +1,70 @@
# frozen_string_literal: true
class SubmitFormCompletedDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
TTL = 40.minutes
FILES_TTL = 5.minutes
def index
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid =
if @submitter&.slug == submitter_slug
true
else
@submitter = nil
end
@submitter ||= Submitter.find_by!(slug: submitter_slug)
Submissions::EnsureResultGenerated.call(@submitter)
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
return head :not_found unless last_submitter
Submissions::EnsureResultGenerated.call(last_submitter)
if !signature_valid && !current_user_submitter?(last_submitter)
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
if last_submitter.completed_at < TTL.ago
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
end
if params[:combined] == 'true'
respond_with_combined(last_submitter)
else
render json: Submitters.build_document_urls(last_submitter)
end
end
private
def submitter_slug
params[:submit_form_slug] || params[:submitter_slug] || params[:submitter_id]
end
def respond_with_combined(submitter)
url = Submitters.build_combined_url(submitter)
if url
render json: [url]
else
head :not_found
end
end
def current_user_submitter?(submitter)
current_user && current_ability.can?(:read, submitter)
end
end
+26 -18
View File
@@ -3,21 +3,22 @@
class SubmitFormController < ApplicationController
layout 'form'
around_action :with_browser_locale, only: %i[show completed success]
around_action :with_browser_locale, only: %i[show completed success delegated]
skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_submitter, only: %i[show update completed]
before_action :maybe_redirect_delegated, only: %i[show completed]
before_action :maybe_render_locked_page, only: :show
before_action :maybe_require_link_2fa, only: %i[show update]
before_action :maybe_require_link_2fa, only: %i[show]
CONFIG_KEYS = [].freeze
def show
submission = @submitter.submission
return render :email_2fa unless Submitters::AuthorizedForForm.pass_email_2fa?(@submitter, request)
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
return render :email_2fa if require_email_2fa?(@submitter)
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
@@ -48,7 +49,7 @@ class SubmitFormController < ApplicationController
end
def update
if require_email_2fa?(@submitter)
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
return render json: { error: I18n.t('verification_required_refresh_the_page_and_pass_2fa') },
status: :unprocessable_content
end
@@ -84,18 +85,23 @@ class SubmitFormController < ApplicationController
def completed
raise ActionController::RoutingError, I18n.t('not_found') if @submitter.account.archived_at?
redirect_to submit_form_path(params[:submit_form_slug]) if require_email_2fa?(@submitter)
return if Submitters::AuthorizedForForm.call(@submitter, current_user, request)
redirect_to submit_form_path(params[:submit_form_slug])
end
def success; end
def delegated
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
@submitter = submitter_version.submitter
end
private
def maybe_require_link_2fa
return if @submitter.submission.source != 'link'
return unless @submitter.submission.template&.preferences&.dig('shared_link_2fa') == true
return if cookies.encrypted[:email_2fa_slug] == @submitter.slug
return if @submitter.email == current_user&.email && current_user&.account_id == @submitter.account_id
return if Submitters::AuthorizedForForm.pass_link_2fa?(@submitter, current_user, request)
redirect_to start_form_path(@submitter.submission.template.slug)
end
@@ -109,20 +115,22 @@ class SubmitFormController < ApplicationController
render :declined if @submitter.declined_at?
end
def maybe_redirect_delegated
return if @submitter
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
submitter_version.submitter.submission_events.find_by!(event_type: :delegate_form)
redirect_to submit_form_delegated_path(submitter_version.slug)
end
def load_submitter
@submitter = Submitter.find_by!(slug: params[:slug] || params[:submit_form_slug])
@submitter = Submitter.find_by(slug: params[:slug] || params[:submit_form_slug])
end
def build_attachments_index(submission)
ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments)
.preload(:blob).index_by(&:uuid)
end
def require_email_2fa?(submitter)
return false if submitter.submission.template&.preferences&.dig('require_email_2fa') != true &&
submitter.preferences['require_email_2fa'] != true
return false if cookies.encrypted[:email_2fa_slug] == submitter.slug
true
end
end
@@ -4,29 +4,38 @@ class SubmitFormDeclineController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
before_action :load_submitter
return redirect_to submit_form_path(submitter.slug) if submitter.declined_at? ||
submitter.completed_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
submitter.submission.template&.archived_at?
def create
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
ApplicationRecord.transaction do
submitter.update!(declined_at: Time.current)
@submitter.update!(declined_at: Time.current)
SubmissionEvents.create_with_tracking_data(submitter, 'decline_form', request, { reason: params[:reason] })
SubmissionEvents.create_with_tracking_data(@submitter, 'decline_form', request, { reason: params[:reason] })
end
user = submitter.submission.created_by_user || submitter.template.author
user = @submitter.submission.created_by_user || @submitter.template.author
if user.user_configs.find_by(key: UserConfig::RECEIVE_DECLINED_EMAIL)&.value != false
SubmitterMailer.declined_email(submitter, user).deliver_later!
SubmitterMailer.declined_email(@submitter, user).deliver_later!
end
WebhookUrls.enqueue_events(submitter, 'form.declined')
WebhookUrls.enqueue_events(@submitter, 'form.declined')
redirect_to submit_form_path(submitter.slug)
redirect_to submit_form_path(@submitter.slug)
end
private
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end
end
@@ -0,0 +1,47 @@
# frozen_string_literal: true
class SubmitFormDelegateController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_submitter
def create
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
@submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true)
email = Submissions.normalize_email(params[:email])
return redirect_to submit_form_path(@submitter.slug) if email.blank?
old_slug = @submitter.slug
ApplicationRecord.transaction do
@submitter.submitter_versions.create!(slug: old_slug, email: @submitter.email,
name: @submitter.name, phone: @submitter.phone)
SubmissionEvents.create_with_tracking_data(@submitter, 'delegate_form', request,
{ old_email: @submitter.email, email: })
@submitter.update!(email:, phone: nil, name: nil, slug: SecureRandom.base58(14))
end
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => @submitter.id)
redirect_to submit_form_delegated_path(old_slug)
end
private
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end
end
@@ -9,7 +9,7 @@ class SubmitFormDownloadController < ApplicationController
def index
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return redirect_to submitter_download_index_path(@submitter.slug) if @submitter.completed_at?
return redirect_to submit_form_documents_path(@submitter.slug) if @submitter.completed_at?
return head :unprocessable_content if @submitter.declined_at? ||
@submitter.submission.archived_at? ||
@@ -17,7 +17,8 @@ class SubmitFormDownloadController < ApplicationController
@submitter.submission.template&.archived_at? ||
AccountConfig.exists?(account_id: @submitter.account_id,
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
value: false)
value: false) ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
last_completed_submitter = @submitter.submission.submitters
.where.not(id: @submitter.id)
@@ -32,7 +33,7 @@ class SubmitFormDownloadController < ApplicationController
end
urls = attachments.map do |attachment|
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.from_now.to_i)
ActiveStorage::Blob.proxy_path(attachment.blob, expires_at: FILES_TTL.from_now.to_i)
end
render json: urls
@@ -12,7 +12,8 @@ class SubmitFormDrawSignatureController < ApplicationController
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at?
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
return redirect_to submit_form_path(@submitter.slug)
end
@@ -19,7 +19,9 @@ class SubmitFormInviteController < ApplicationController
next unless attrs
next if attrs[:email].blank?
submitter.submission.submitters.create!(**attrs, account_id: submitter.account_id)
email = Submissions.normalize_email(attrs[:email])
submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: submitter.account_id)
SubmissionEvents.create_with_tracking_data(submitter, 'invite_party', request, { uuid: submitter.uuid })
end
@@ -45,7 +47,8 @@ class SubmitFormInviteController < ApplicationController
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at?
!submitter.submission.template&.archived_at? &&
Submitters::AuthorizedForForm.call(submitter, current_user, request)
end
def filter_invite_submitters(submitter, key = 'invite_by_uuid')
@@ -7,10 +7,12 @@ class SubmitFormValuesController < ApplicationController
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return render json: {} if submitter.completed_at? || submitter.declined_at?
return render json: {} if submitter.submission.template&.archived_at? ||
return render json: {} if submitter.completed_at? ||
submitter.declined_at? ||
submitter.submission.template&.archived_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired?
submitter.submission.expired? ||
!Submitters::AuthorizedForForm.call(submitter, current_user, request)
value = submitter.values[params['field_uuid']]
attachment = submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
@@ -7,25 +7,34 @@ class SubmittersAutocompleteController < ApplicationController
LIMIT = 100
def index
submitters = search_submitters(@submitters)
field = SELECT_COLUMNS.find { |c| c == params[:field] }
submitters = search_submitters(@submitters, field)
arel_columns = SELECT_COLUMNS.map { |col| Submitter.arel_table[col] }
values = submitters.limit(LIMIT).group(arel_columns).pluck(arel_columns)
values =
if field
max_ids = submitters.group(field).limit(LIMIT).select(Submitter.arel_table[:id].maximum)
submitters.where(id: max_ids).order(id: :desc).pluck(arel_columns)
else
submitters.limit(LIMIT).group(arel_columns).pluck(arel_columns)
end
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
render json: attrs
end
private
def search_submitters(submitters)
if SELECT_COLUMNS.include?(params[:field])
def search_submitters(submitters, field)
if field
if Docuseal.fulltext_search?
Submitters.fulltext_search_field(current_user, submitters, params[:q], params[:field])
Submitters.fulltext_search_field(current_user, submitters, params[:q], field)
else
column = Submitter.arel_table[params[:field].to_sym]
column = Submitter.arel_table[field.to_sym]
term = "#{params[:q].downcase}%"
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class SubmittersDownloadController < ApplicationController
load_and_authorize_resource :submitter
def index
Submissions::EnsureResultGenerated.call(@submitter)
render json: Submitters.build_document_urls(@submitter)
end
end
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class SubmittersSendEmailController < ApplicationController
load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug
load_and_authorize_resource :submitter
def create
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
@@ -6,7 +6,7 @@ class TemplateDocumentsController < ApplicationController
FILES_TTL = 5.minutes
def index
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_url(d.blob, expires_at: FILES_TTL.from_now.to_i) }
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) }
end
def create
@@ -16,7 +16,7 @@ class TemplateDocumentsController < ApplicationController
old_fields_hash = @template.fields.hash
documents = Templates::CreateAttachments.call(@template, params, extract_fields: true)
documents, = Templates::CreateAttachments.call(@template, params, extract_fields: true)
schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base }
@@ -27,7 +27,7 @@ class TemplateDocumentsController < ApplicationController
fields: old_fields_hash == @template.fields.hash ? nil : @template.fields,
submitters: old_fields_hash == @template.fields.hash ? nil : @template.submitters,
documents: documents.as_json(
methods: %i[metadata signed_uuid],
methods: %i[metadata signed_key],
include: {
preview_images: { methods: %i[url metadata filename] }
}
@@ -8,7 +8,7 @@ class TemplatesCloneAndReplaceController < ApplicationController
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: :preview_images_attachments]
associations: [{ schema_documents: :preview_images_attachments }]
).call
cloned_template = Templates::Clone.call(@template, author: current_user)
@@ -12,7 +12,7 @@ class TemplatesCloneController < ApplicationController
def create
ActiveRecord::Associations::Preloader.new(
records: [@base_template],
associations: [schema_documents: :preview_images_attachments]
associations: [{ schema_documents: :preview_images_attachments }]
).call
@template = Templates::Clone.call(@base_template, author: current_user,
@@ -21,7 +21,7 @@ class TemplatesCloneController < ApplicationController
authorize!(:create, @template)
if params[:account_id].present? && true_ability.authorize!(:manage, Account.find(params[:account_id]))
if params[:account_id].present? && true_ability.can?(:manage, Account.find(params[:account_id]))
@template.account_id = params[:account_id]
@template.author = true_user if true_user.account_id == @template.account_id
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
+9 -5
View File
@@ -29,13 +29,13 @@ class TemplatesController < ApplicationController
def edit
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: [:blob, { preview_images_attachments: :blob }]]
associations: [{ schema_documents: [:blob, { preview_images_attachments: :blob }] }]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid],
methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
@@ -84,6 +84,8 @@ class TemplatesController < ApplicationController
else
@template.update!(archived_at: Time.current)
WebhookUrls.enqueue_events(@template, 'template.archived')
I18n.t('template_has_been_archived')
end
@@ -95,9 +97,11 @@ class TemplatesController < ApplicationController
def template_params
params.require(:template).permit(
:name,
{ schema: [[:attachment_uuid, :google_drive_file_id, :name,
{ schema: [[:attachment_uuid, :google_drive_file_id, :name, :dynamic,
{ conditions: [%i[field_uuid value action operation]] }]],
submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid optional_invite_by_uuid email order]],
submitters: [%i[name uuid is_requester linked_to_uuid invite_via_field_uuid
invite_by_uuid optional_invite_by_uuid email order]],
variables_schema: {},
fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description, :prefillable,
@@ -106,7 +110,7 @@ class TemplatesController < ApplicationController
conditions: [%i[field_uuid value action operation]],
options: [%i[value uuid]],
validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]] }
)
end
end
+24 -15
View File
@@ -9,32 +9,34 @@ class TemplatesDebugController < ApplicationController
schema_uuids = @template.schema.index_by { |e| e['attachment_uuid'] }
attachment = @template.documents.find { |a| schema_uuids[a.uuid] }
data = attachment.download
if attachment
data = attachment.download
unless attachment.image?
pdf = HexaPDF::Document.new(io: StringIO.new(data))
unless attachment.image?
pdf = HexaPDF::Document.new(io: StringIO.new(data))
fields = Templates::FindAcroFields.call(pdf, attachment, data)
fields = Templates::FindAcroFields.call(pdf, attachment, data)
end
# fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
attachment.metadata['pdf'] ||= {}
attachment.metadata['pdf']['fields'] = fields
@template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment]))
end
fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
attachment.metadata['pdf'] ||= {}
attachment.metadata['pdf']['fields'] = fields
@template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment]))
debug_file if DEBUG_FILE.present?
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: { preview_images_attachments: :blob }]
associations: [{ schema_documents: { preview_images_attachments: :blob } }]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid],
methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
@@ -58,9 +60,16 @@ class TemplatesDebugController < ApplicationController
params = { files: [file] }
documents = Templates::CreateAttachments.call(@template, params)
documents, dynamic_documents = Templates::CreateAttachments.call(@template, params,
dynamic: DEBUG_FILE.ends_with?('.docx'))
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
schema = documents.map do |doc|
{
attachment_uuid: doc.uuid,
name: doc.filename.base,
dynamic: dynamic_documents.find { |e| e.uuid == doc.uuid }.present?
}
end
@template.update!(schema:)
end
@@ -58,8 +58,8 @@ class TemplatesPreferencesController < ApplicationController
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
completed_notification_email_subject completed_notification_email_body
completed_notification_email_enabled completed_notification_email_attach_audit] +
[completed_message: %i[title body],
submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: []]
[{ completed_message: %i[title body],
submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: [] }]
).tap do |attrs|
attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1'
@@ -6,13 +6,13 @@ class TemplatesPreviewController < ApplicationController
def show
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: { preview_images_attachments: :blob }]
associations: [{ schema_documents: { preview_images_attachments: :blob } }]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid],
methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
@@ -22,7 +22,7 @@ class TemplatesRecipientsController < ApplicationController
def submitters_params
permit_params = { submitters: [%i[name uuid is_requester optional_invite_by_uuid
invite_by_uuid linked_to_uuid email option order]] }
invite_by_uuid invite_via_field_uuid linked_to_uuid email option order]] }
params.require(:template).permit(permit_params).fetch(:submitters, {}).values.filter_map do |s|
next if s[:uuid].blank?
@@ -36,6 +36,7 @@ class TemplatesRecipientsController < ApplicationController
s[:order] = s[:order].to_i if s[:order].present?
s.delete(:invite_by_uuid) if s[:invite_by_uuid].blank?
s.delete(:optional_invite_by_uuid) if s[:optional_invite_by_uuid].blank?
s.delete(:invite_via_field_uuid) if s[:invite_via_field_uuid].blank?
normalize_option_value(s)
end
@@ -53,6 +54,7 @@ class TemplatesRecipientsController < ApplicationController
attrs.delete(:email)
attrs.delete(:linked_to_uuid)
attrs.delete(:invite_by_uuid)
attrs.delete(:invite_via_field_uuid)
attrs.delete(:optional_invite_by_uuid)
when /\Alinked_to_(.*)\z/
attrs[:linked_to_uuid] = ::Regexp.last_match(-1)
@@ -6,6 +6,8 @@ class TemplatesRestoreController < ApplicationController
def create
@template.update!(archived_at: nil)
WebhookUrls.enqueue_events(@template, 'template.updated')
redirect_to template_path(@template), notice: I18n.t('template_has_been_unarchived')
end
end
@@ -12,7 +12,7 @@ class TemplatesUploadsController < ApplicationController
save_template!(@template, url_params)
documents = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true)
documents, = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
if @template.fields.blank?
@@ -56,7 +56,7 @@ class TemplatesUploadsController < ApplicationController
def create_file_params_from_url
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(DownloadUtils.call(params[:url]).body)
tempfile.write(DownloadUtils.call(params[:url], validate: true).body)
tempfile.rewind
filename = URI.decode_www_form_component(params[:filename]) if params[:filename].present?
+13 -1
View File
@@ -16,7 +16,19 @@ class UsersController < ApplicationController
@users.active.where.not(role: 'integration')
end
@pagy, @users = pagy(@users.preload(account: :account_accesses).where(account: current_account).order(id: :desc))
@users = @users.preload(account: :account_accesses).where(account: current_account).order(id: :desc)
respond_to do |format|
format.html do
@pagy, @users = pagy(@users)
end
if current_ability.can?(:manage, current_account)
format.csv do
send_data Users.generate_csv(@users), filename: "users-#{Time.current.iso8601}.csv", type: 'text/csv'
end
end
end
end
def new; end
+1 -1
View File
@@ -15,7 +15,7 @@ class WebhookEventsController < ApplicationController
Submissions::SerializeForApi.call(@webhook_event.record)
when 'template.created', 'template.updated'
Templates::SerializeForApi.call(@webhook_event.record)
when 'submission.archived'
when 'submission.archived', 'template.archived'
@webhook_event.record.as_json(only: %i[id archived_at])
end
end
+8 -1
View File
@@ -40,6 +40,7 @@ import DashboardDropzone from './elements/dashboard_dropzone'
import RequiredCheckboxGroup from './elements/required_checkbox_group'
import PageContainer from './elements/page_container'
import EmailEditor from './elements/email_editor'
import MarkdownEditor from './elements/markdown_editor'
import MountOnClick from './elements/mount_on_click'
import RemoveOnEvent from './elements/remove_on_event'
import ScrollTo from './elements/scroll_to'
@@ -131,6 +132,7 @@ safeRegisterElement('check-on-click', CheckOnClick)
safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
safeRegisterElement('page-container', PageContainer)
safeRegisterElement('email-editor', EmailEditor)
safeRegisterElement('markdown-editor', MarkdownEditor)
safeRegisterElement('mount-on-click', MountOnClick)
safeRegisterElement('remove-on-event', RemoveOnEvent)
safeRegisterElement('scroll-to', ScrollTo)
@@ -153,12 +155,16 @@ safeRegisterElement('template-builder', class extends HTMLElement {
this.appElem.classList.add('md:h-screen')
const template = reactive(JSON.parse(this.dataset.template))
this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)),
template,
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
backgroundColor: '#faf7f5',
locale: this.dataset.locale,
withPhone: this.dataset.withPhone === 'true',
withPrefillable: template.fields?.some((f) => f.prefillable),
withVerification: ['true', 'false'].includes(this.dataset.withVerification) ? this.dataset.withVerification === 'true' : null,
withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null,
withLogo: this.dataset.withLogo !== 'false',
@@ -172,6 +178,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withSendButton: this.dataset.withSendButton !== 'false',
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
withConditions: this.dataset.withConditions === 'true',
withDynamicDocuments: this.dataset.withDynamicDocuments === 'true',
withGoogleDrive: this.dataset.withGoogleDrive === 'true',
withReplaceAndCloneUpload: true,
withDownload: true,
+4
View File
@@ -155,3 +155,7 @@ button[disabled] .enabled, button.btn-disabled .enabled {
.font-courier {
font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco;
}
markdown-editor [contenteditable] p {
margin-bottom: 18px;
}
+382
View File
@@ -0,0 +1,382 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
import { actionable } from '@github/catalyst/lib/actionable'
function loadTiptap () {
return Promise.all([
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/core'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-bold'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-italic'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-paragraph'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-text'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-hard-break'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-document'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-link'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-underline'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extensions'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/markdown'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/state'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/view')
]).then(([core, bold, italic, paragraph, text, hardBreak, document, link, underline, extensions, markdown, pmState, pmView]) => ({
Editor: core.Editor,
Extension: core.Extension,
Bold: bold.default || bold,
Italic: italic.default || italic,
Paragraph: paragraph.default || paragraph,
Text: text.default || text,
HardBreak: hardBreak.default || hardBreak,
Document: document.default || document,
Link: link.default || link,
Underline: underline.default || underline,
UndoRedo: extensions.UndoRedo,
Markdown: markdown.Markdown,
Plugin: pmState.Plugin,
Decoration: pmView.Decoration,
DecorationSet: pmView.DecorationSet
}))
}
class LinkTooltip {
constructor (container, editor, templateEl) {
this.container = container
this.editor = editor
this.tooltip = templateEl.content.firstElementChild.cloneNode(true)
this.input = this.tooltip.querySelector('input')
this.saveButton = this.tooltip.querySelector('[data-role="link-save"]')
this.removeButton = this.tooltip.querySelector('[data-role="link-remove"]')
container.style.position = 'relative'
container.appendChild(this.tooltip)
}
isVisible () {
return !this.tooltip.classList.contains('hidden')
}
normalizeUrl (url) {
if (!url) return url
if (/^{/i.test(url)) return url
if (/^https?:\/\//i.test(url)) return url
if (/^mailto:/i.test(url)) return url
return `https://${url}`
}
show (url, pos, { focus = false } = {}) {
this.input.value = url || ''
this.removeButton.classList.toggle('hidden', !url)
this.tooltip.classList.remove('hidden')
const coords = this.editor.view.coordsAtPos(pos)
const containerRect = this.container.getBoundingClientRect()
this.tooltip.style.left = `${coords.left - containerRect.left}px`
this.tooltip.style.top = `${coords.bottom - containerRect.top + 4}px`
if (focus) this.input.focus()
this.saveHandler = () => {
const inputUrl = this.input.value.trim()
if (inputUrl) {
this.editor.chain().focus().extendMarkRange('link').setLink({ href: this.normalizeUrl(inputUrl) }).run()
}
this.hide()
}
this.removeHandler = () => {
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
this.hide()
}
this.keyHandler = (e) => {
if (e.key === 'Enter') {
e.preventDefault()
this.saveHandler()
} else if (e.key === 'Escape') {
e.preventDefault()
this.hide()
}
}
this.saveButton.addEventListener('click', this.saveHandler, { once: true })
this.removeButton.addEventListener('click', this.removeHandler, { once: true })
this.input.addEventListener('keydown', this.keyHandler)
}
hide () {
if (this.saveHandler) {
this.saveButton.removeEventListener('click', this.saveHandler)
this.saveHandler = null
}
if (this.removeHandler) {
this.removeButton.removeEventListener('click', this.removeHandler)
this.removeHandler = null
}
if (this.keyHandler) {
this.input.removeEventListener('keydown', this.keyHandler)
this.keyHandler = null
}
this.tooltip.classList.add('hidden')
this.currentMark = null
}
}
export default actionable(targetable(class extends HTMLElement {
static [target.static] = [
'textarea',
'editorElement',
'boldButton',
'italicButton',
'underlineButton',
'linkButton',
'linkTooltipTemplate'
]
async connectedCallback () {
if (!this.textarea || !this.editorElement) return
this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform()
const { Editor, Extension, Bold, Italic, Paragraph, Text, HardBreak, UndoRedo, Document, Link, Underline, Markdown, Plugin, Decoration, DecorationSet } = await loadTiptap()
const buildDecorations = (doc) => {
const decorations = []
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g
doc.descendants((node, pos) => {
if (!node.isText) return
let match
while ((match = regex.exec(node.text)) !== null) {
decorations.push(
Decoration.inline(pos + match.index, pos + match.index + match[0].length, {
class: 'bg-amber-100 py-0.5 px-1 rounded'
})
)
}
})
return DecorationSet.create(doc, decorations)
}
const VariableHighlight = Extension.create({
name: 'variableHighlight',
addProseMirrorPlugins () {
return [new Plugin({
state: {
init (_, { doc }) {
return buildDecorations(doc)
},
apply (tr, oldSet) {
return tr.docChanged ? buildDecorations(tr.doc) : oldSet
}
},
props: {
decorations (state) {
return this.getState(state)
}
}
})]
}
})
this.editor = new Editor({
element: this.editorElement,
extensions: [
Markdown,
Document,
Paragraph,
Text,
Bold,
Italic,
HardBreak.extend({
addKeyboardShortcuts () {
return {
Enter: () => this.editor.commands.setHardBreak()
}
}
}),
UndoRedo,
Link.extend({
inclusive: true,
addKeyboardShortcuts: () => ({
'Mod-k': () => {
this.toggleLink()
return true
}
})
}).configure({
openOnClick: false,
HTMLAttributes: {
class: 'link',
'data-turbo': 'false',
style: 'color: #2563eb; text-decoration: underline; cursor: text;'
}
}),
Underline,
VariableHighlight
],
content: (this.textarea.value || '').trim().replace(/ *\n/g, '<br>'),
contentType: 'markdown',
editorProps: {
attributes: {
style: 'min-height: 220px',
dir: 'auto',
class: 'p-3 outline-none focus:outline-none'
}
},
onUpdate: ({ editor }) => {
this.textarea.value = editor.getMarkdown()
this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
},
onSelectionUpdate: ({ editor }) => {
this.updateToolbarState()
this.handleLinkTooltip(editor)
},
onBlur: () => {
setTimeout(() => {
if (!this.linkTooltip.tooltip.contains(document.activeElement)) {
this.linkTooltip.hide()
}
}, 0)
}
})
this.linkTooltip = new LinkTooltip(this, this.editor, this.linkTooltipTemplate)
}
adjustShortcutsForPlatform () {
if ((navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')) {
this.querySelectorAll('.tooltip[data-tip]').forEach(tooltip => {
const tip = tooltip.getAttribute('data-tip')
if (tip && tip.includes('Ctrl')) {
tooltip.setAttribute('data-tip', tip.replace(/Ctrl/g, '⌘'))
}
})
}
}
bold (e) {
e.preventDefault()
this.editor.chain().focus().toggleBold().run()
this.updateToolbarState()
}
italic (e) {
e.preventDefault()
this.editor.chain().focus().toggleItalic().run()
this.updateToolbarState()
}
underline (e) {
e.preventDefault()
this.editor.chain().focus().toggleUnderline().run()
this.updateToolbarState()
}
linkSelection (e) {
e.preventDefault()
this.toggleLink()
this.updateToolbarState()
}
undo (e) {
e.preventDefault()
this.editor.chain().focus().undo().run()
this.updateToolbarState()
}
redo (e) {
e.preventDefault()
this.editor.chain().focus().redo().run()
this.updateToolbarState()
}
updateToolbarState () {
this.boldButton.classList.toggle('bg-base-200', this.editor.isActive('bold'))
this.italicButton.classList.toggle('bg-base-200', this.editor.isActive('italic'))
this.underlineButton.classList.toggle('bg-base-200', this.editor.isActive('underline'))
this.linkButton.classList.toggle('bg-base-200', this.editor.isActive('link'))
}
handleLinkTooltip (editor) {
const { from } = editor.state.selection
const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link')
if (!mark) {
if (this.linkTooltip.isVisible()) this.linkTooltip.hide()
return
}
if (this.linkTooltip.isVisible() && this.linkTooltip.currentMark === mark) return
let linkStart = from
const start = editor.state.doc.resolve(from).start()
for (let i = from - 1; i >= start; i--) {
if (editor.state.doc.resolve(i).marks().some(m => m.eq(mark))) {
linkStart = i
} else {
break
}
}
this.linkTooltip.hide()
this.linkTooltip.show(mark.attrs.href, linkStart > start ? linkStart - 1 : linkStart)
this.linkTooltip.currentMark = mark
}
toggleLink () {
if (this.editor.isActive('link')) {
this.linkTooltip.hide()
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
this.updateToolbarState()
} else {
const { from } = this.editor.state.selection
this.linkTooltip.hide()
this.linkTooltip.show(this.editor.getAttributes('link').href, from, { focus: true })
}
}
insertVariable (e) {
const variable = e.target.closest('[data-variable]')?.dataset.variable
if (variable) {
const { from, to } = this.editor.state.selection
if (variable.includes('link') && from !== to) {
this.editor.chain().focus().setLink({ href: `{${variable}}` }).run()
} else {
this.editor.chain().focus().insertContent(`{${variable}}`).run()
}
}
}
disconnectedCallback () {
this.linkTooltip.hide()
if (this.editor) {
this.editor.destroy()
}
}
}))
+8 -2
View File
@@ -1,12 +1,18 @@
export default class extends HTMLElement {
connectedCallback () {
this.input.addEventListener('change', (event) => {
if (!this.target) return
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
const dataValue = this.dataset.value === 'false' ? false : this.dataset.value || true
if (this.dataset.attribute) {
this.target[this.dataset.attribute] = event.target.checked
this.target[this.dataset.attribute] = value === dataValue
}
if (this.dataset.className) {
this.target.classList.toggle(this.dataset.className, event.target.value !== this.dataset.value)
this.target.classList.toggle(this.dataset.className, value !== dataValue)
if (this.dataset.className === 'hidden' && this.target.tagName === 'INPUT') {
this.target.disabled = event.target.value !== this.dataset.value
}
+10 -2
View File
@@ -1,10 +1,18 @@
export default class extends HTMLElement {
connectedCallback () {
const button = this.querySelector('a, button')
const button = this.querySelector('a, button, label')
const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button
button.addEventListener('click', () => {
this.dataset.classes.split(' ').forEach((cls) => {
button.classList.toggle(cls)
if (this.dataset.action === 'remove') {
target.classList.remove(cls)
} else if (this.dataset.action === 'add') {
target.classList.add(cls)
} else {
target.classList.toggle(cls)
}
})
})
}
+2 -1
View File
@@ -31,12 +31,13 @@ safeRegisterElement('submission-form', class extends HTMLElement {
isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false',
scrollPadding: this.dataset.scrollPadding || '-80px',
signatureText: this.dataset.signatureText,
language: this.dataset.language,
dryRun: this.dataset.dryRun === 'true',
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
withSignatureId: this.dataset.withSignatureId === 'true',
requireSigningReason: this.dataset.requireSigningReason === 'true',
withConfetti: this.dataset.withConfetti !== 'false',
withConfetti: this.dataset.withConfetti === 'true',
withFieldLabels: this.dataset.withFieldLabels !== 'false',
withDisclosure: this.dataset.withDisclosure === 'true',
reuseSignature: this.dataset.reuseSignature !== 'false',
@@ -0,0 +1,874 @@
/*
* Portions of this file are derived from the Dentaku gem
* https://github.com/rubysolo/dentaku
*
* MIT License
*
* Copyright (c) 2012 Solomon White
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Modifications and JavaScript port
* Copyright (c) 2026 DocuSeal, LLC
*/
class CalculatorError extends Error { constructor (msg) { super(msg); this.name = 'CalculatorError' } }
class ParseError extends CalculatorError { constructor (msg) { super(msg); this.name = 'ParseError' } }
class TokenizerError extends CalculatorError { constructor (msg) { super(msg); this.name = 'TokenizerError' } }
class ArgumentError extends CalculatorError { constructor (msg) { super(msg); this.name = 'ArgumentError' } }
class ZeroDivisionError extends CalculatorError { constructor () { super('divided by 0'); this.name = 'ZeroDivisionError' } }
class UnboundVariableError extends CalculatorError {
constructor (vars) {
super('no value provided for variables: ' + vars.join(', '))
this.name = 'UnboundVariableError'
this.unbound = vars
}
}
class Token {
constructor (category, value, raw) {
this.category = category
this.value = value
this.raw = raw == null ? String(value) : String(raw)
}
is (cat) { return this.category === cat }
}
const OPERATOR_NAMES = {
'^': 'pow',
'+': 'add',
'-': 'subtract',
'*': 'multiply',
'/': 'divide',
'%': 'mod',
'|': 'bitor',
'&': 'bitand',
'<<': 'bitshiftleft',
'>>': 'bitshiftright'
}
const COMPARATOR_NAMES = {
'<=': 'le',
'>=': 'ge',
'!=': 'ne',
'<>': 'ne',
'<': 'lt',
'>': 'gt',
'==': 'eq',
'=': 'eq'
}
const GROUPING_NAMES = { '(': 'open', ')': 'close', ',': 'comma' }
function buildScanners (caseSensitive) {
return [
(s) => {
const m = /^\s+/.exec(s)
return m ? { tokens: [], length: m[0].length } : null
},
(s) => {
const m = /^null\b/i.exec(s)
return m ? { tokens: [new Token('null', null, m[0])], length: m[0].length } : null
},
(s) => {
const m = /^((?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?)\b/.exec(s)
if (!m) return null
return { tokens: [new Token('numeric', Number(m[0]), m[0])], length: m[0].length }
},
(s) => {
const m = /^0x[0-9a-f]+\b/i.exec(s)
if (!m) return null
return { tokens: [new Token('numeric', parseInt(m[0].slice(2), 16), m[0])], length: m[0].length }
},
(s) => {
const m = /^"([^"]*)"/.exec(s)
if (!m) return null
return { tokens: [new Token('string', m[1], m[0])], length: m[0].length }
},
(s) => {
const m = /^'([^']*)'/.exec(s)
if (!m) return null
return { tokens: [new Token('string', m[1], m[0])], length: m[0].length }
},
(s, last) => {
if (s[0] !== '-') return null
const ok = last == null ||
last.is('operator') ||
last.is('comparator') ||
last.is('combinator') ||
last.value === 'open' ||
last.value === 'comma' ||
last.value === 'array_start'
if (!ok) return null
return { tokens: [new Token('operator', 'negate', '-')], length: 1 }
},
(s) => {
const m = /^(and|or|&&|\|\|)\s/i.exec(s)
if (!m) return null
const raw = m[1].toLowerCase()
const name = raw === '&&' ? 'and' : raw === '||' ? 'or' : raw
return { tokens: [new Token('combinator', name, m[0])], length: m[0].length }
},
(s) => {
const m = /^(<<|>>|\^|\+|-|\*|\/|%|\||&)/.exec(s)
if (!m) return null
return { tokens: [new Token('operator', OPERATOR_NAMES[m[0]], m[0])], length: m[0].length }
},
(s) => {
const m = /^(\(|\)|,)/.exec(s)
if (!m) return null
return { tokens: [new Token('grouping', GROUPING_NAMES[m[0]], m[0])], length: m[0].length }
},
(s) => {
if (s[0] === '{') return { tokens: [new Token('array', 'array_start', '{')], length: 1 }
if (s[0] === '}') return { tokens: [new Token('array', 'array_end', '}')], length: 1 }
return null
},
(s) => {
const m = /^(<=|>=|!=|<>|==|=|<|>)/.exec(s)
if (!m) return null
return { tokens: [new Token('comparator', COMPARATOR_NAMES[m[0]], m[0])], length: m[0].length }
},
(s) => {
const m = /^(true|false)\b/i.exec(s)
if (!m) return null
return { tokens: [new Token('logical', m[0].toLowerCase() === 'true', m[0])], length: m[0].length }
},
(s) => {
const m = /^(\w+!?)\s*\(/.exec(s)
if (!m) return null
const name = m[1].toLowerCase()
return {
tokens: [
new Token('function', name, m[1]),
new Token('grouping', 'open', '(')
],
length: m[0].length
}
},
(s) => {
const m = /^[A-Za-z_][\w.]*\b/.exec(s)
if (!m) return null
const value = caseSensitive ? m[0] : m[0].toLowerCase()
return { tokens: [new Token('identifier', value, m[0])], length: m[0].length }
},
(s) => {
const m = /^`([^`]*)`/.exec(s)
if (!m) return null
return { tokens: [new Token('identifier', m[1], m[0])], length: m[0].length }
}
]
}
function tokenize (input, options) {
options = options || {}
let remaining = String(input).replace(/\/\*[\s\S]*?\*\//g, '')
const scanners = buildScanners(options.caseSensitive === true)
const tokens = []
let nesting = 0
while (remaining.length > 0) {
let matched = false
for (let i = 0; i < scanners.length; i++) {
const result = scanners[i](remaining, tokens.length ? tokens[tokens.length - 1] : null)
if (result) {
for (const tok of result.tokens) {
if (tok.category === 'grouping' && tok.value === 'open') nesting++
if (tok.category === 'grouping' && tok.value === 'close') {
nesting--
if (nesting < 0) throw new TokenizerError('too many closing parentheses')
}
tokens.push(tok)
}
remaining = remaining.slice(result.length)
matched = true
break
}
}
if (!matched) throw new TokenizerError("parse error at: '" + remaining + "'")
}
if (nesting > 0) throw new TokenizerError('too many opening parentheses')
return tokens
}
function uniq (arr) {
const seen = new Set()
const out = []
for (const x of arr) { if (!seen.has(x)) { seen.add(x); out.push(x) } }
return out
}
function toNumber (val) {
if (typeof val === 'number') return val
if (typeof val === 'boolean') return val ? 1 : 0
if (val == null) throw new ArgumentError("'" + val + "' is not coercible to numeric")
if (typeof val === 'string') {
if (val.length === 0) throw new ArgumentError("'' is not coercible to numeric")
const n = Number(val)
if (isNaN(n)) throw new ArgumentError("'" + val + "' is not coercible to numeric")
return n
}
throw new ArgumentError("'" + val + "' is not coercible to numeric")
}
class Node {
static precedence = 0
static arity = null
static rightAssociative = false
static resolveClass () { return this }
static minParamCount () { return this.arity }
static maxParamCount () { return this.arity }
value () { return null }
dependencies () { return [] }
get type () { return null }
}
class NilNode extends Node { value () { return null } }
class Numeric extends Node {
constructor (token) { super(); this.v = token.value }
value () { return this.v }
get type () { return 'numeric' }
}
class Logical extends Node {
constructor (token) { super(); this.v = token.value }
value () { return this.v }
get type () { return 'logical' }
}
class StringNode extends Node {
constructor (token) { super(); this.v = token.value }
value () { return this.v }
get type () { return 'string' }
}
class Identifier extends Node {
constructor (token) { super(); this.ident = token.value }
value (ctx) {
if (ctx && (this.ident in ctx)) {
const v = ctx[this.ident]
if (v instanceof Node) return v.value(ctx)
if (typeof v === 'function') return v()
return v
}
throw new UnboundVariableError([this.ident])
}
dependencies (ctx) {
if (ctx && (this.ident in ctx)) {
const v = ctx[this.ident]
return v instanceof Node ? v.dependencies(ctx) : []
}
return [this.ident]
}
}
class Grouping extends Node {
constructor (node) { super(); this.node = node }
value (ctx) { return this.node.value(ctx) }
get type () { return this.node.type }
dependencies (ctx) { return this.node.dependencies(ctx) }
}
class Operation extends Node {
constructor (left, right) { super(); this.left = left; this.right = right }
dependencies (ctx) {
const l = this.left ? this.left.dependencies(ctx) : []
const r = this.right ? this.right.dependencies(ctx) : []
return uniq(l.concat(r))
}
}
class Arithmetic extends Operation { get type () { return 'numeric' } }
class Addition extends Arithmetic {
static precedence = 10
value (ctx) {
const l = this.left.value(ctx)
const r = this.right.value(ctx)
if (typeof l === 'string' || typeof r === 'string') return String(l) + String(r)
return toNumber(l) + toNumber(r)
}
}
class Subtraction extends Arithmetic {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) - toNumber(this.right.value(ctx)) }
}
class Multiplication extends Arithmetic {
static precedence = 20
value (ctx) { return toNumber(this.left.value(ctx)) * toNumber(this.right.value(ctx)) }
}
class Division extends Arithmetic {
static precedence = 20
value (ctx) {
const r = toNumber(this.right.value(ctx))
if (r === 0) throw new ZeroDivisionError()
return toNumber(this.left.value(ctx)) / r
}
}
class Modulo extends Arithmetic {
static arity = 2
static precedence = 20
static resolveClass (nextTok) {
if (!nextTok) return Percentage
if (nextTok.category === 'operator') return Percentage
if (nextTok.category === 'comparator') return Percentage
if (nextTok.category === 'combinator') return Percentage
if (nextTok.category === 'grouping' && (nextTok.value === 'close' || nextTok.value === 'comma')) return Percentage
if (nextTok.category === 'array' && nextTok.value === 'array_end') return Percentage
return Modulo
}
value (ctx) {
const r = toNumber(this.right.value(ctx))
if (r === 0) throw new ZeroDivisionError()
return toNumber(this.left.value(ctx)) % r
}
}
class Percentage extends Arithmetic {
static arity = 1
static precedence = 30
constructor (child) { super(child, null) }
value (ctx) { return toNumber(this.left.value(ctx)) * 0.01 }
dependencies (ctx) { return this.left.dependencies(ctx) }
}
class Exponentiation extends Arithmetic {
static precedence = 30
static rightAssociative = true
value (ctx) { return Math.pow(toNumber(this.left.value(ctx)), toNumber(this.right.value(ctx))) }
}
class Negation extends Arithmetic {
static arity = 1
static precedence = 40
static rightAssociative = true
constructor (node) { super(node, null) }
value (ctx) { return -toNumber(this.left.value(ctx)) }
dependencies (ctx) { return this.left.dependencies(ctx) }
}
class BitwiseOr extends Operation {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) | toNumber(this.right.value(ctx)) }
get type () { return 'numeric' }
}
class BitwiseAnd extends Operation {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) & toNumber(this.right.value(ctx)) }
get type () { return 'numeric' }
}
class BitwiseShiftLeft extends Operation {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) << toNumber(this.right.value(ctx)) }
get type () { return 'numeric' }
}
class BitwiseShiftRight extends Operation {
static precedence = 10
value (ctx) { return toNumber(this.left.value(ctx)) >> toNumber(this.right.value(ctx)) }
get type () { return 'numeric' }
}
function cmpCast (v) {
if (typeof v === 'string' && /^-?\d*\.?\d+$/.test(v)) return Number(v)
return v
}
class Comparator extends Operation {
static precedence = 5
get type () { return 'logical' }
}
class LessThan extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) < cmpCast(this.right.value(ctx)) } }
class LessThanOrEqual extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) <= cmpCast(this.right.value(ctx)) } }
class GreaterThan extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) > cmpCast(this.right.value(ctx)) } }
class GreaterThanOrEqual extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) >= cmpCast(this.right.value(ctx)) } }
class NotEqual extends Comparator {
value (ctx) {
const l = cmpCast(this.left.value(ctx))
const r = cmpCast(this.right.value(ctx))
return l !== r
}
}
class Equal extends Comparator {
value (ctx) {
const l = cmpCast(this.left.value(ctx))
const r = cmpCast(this.right.value(ctx))
return l === r
}
}
class Combinator extends Operation {
static precedence = 0
get type () { return 'logical' }
}
class And extends Combinator { value (ctx) { return Boolean(this.left.value(ctx)) && Boolean(this.right.value(ctx)) } }
class Or extends Combinator { value (ctx) { return Boolean(this.left.value(ctx)) || Boolean(this.right.value(ctx)) } }
class ArrayNode extends Node {
static arity = null
static minParamCount () { return 0 }
static maxParamCount () { return Infinity }
constructor (...items) {
super()
this.items = items
}
value (ctx) { return this.items.map((i) => i.value(ctx)) }
dependencies (ctx) {
return uniq([].concat.apply([], this.items.map((i) => i.dependencies(ctx))))
}
get type () { return 'array' }
}
class FunctionNode extends Node {
static arity = null
static minParamCount () { return 0 }
static maxParamCount () { return Infinity }
constructor (...args) {
super()
this.args = args
}
dependencies (ctx) {
return uniq([].concat.apply([], this.args.map((a) => a.dependencies(ctx))))
}
}
const FUNCTION_REGISTRY = Object.create(null)
function registerFunctionClass (name, cls) { FUNCTION_REGISTRY[name.toLowerCase()] = cls }
function getFunctionClass (name) { return FUNCTION_REGISTRY[name.toLowerCase()] }
function defineFunction (name, spec) {
const min = spec.min == null ? 0 : spec.min
const max = spec.max == null ? Infinity : spec.max
const call = spec.call
const type = spec.type || 'numeric'
class F extends FunctionNode {
static arity = (min === max) ? min : null
static minParamCount () { return min }
static maxParamCount () { return max }
value (ctx) {
const vals = this.args.map((a) => a.value(ctx))
return call(vals, ctx)
}
get type () { return type }
}
registerFunctionClass(name, F)
return F
}
function flattenNumeric (vals) {
const out = [];
(function walk (v) {
if (Array.isArray(v)) v.forEach(walk)
else out.push(toNumber(v))
})(vals)
return out
}
defineFunction('abs', { min: 1, max: 1, call: (v) => Math.abs(toNumber(v[0])) })
defineFunction('min', { min: 1, call: (v) => Math.min.apply(null, flattenNumeric(v)) })
defineFunction('max', { min: 1, call: (v) => Math.max.apply(null, flattenNumeric(v)) })
defineFunction('sum', { min: 1, call: (v) => flattenNumeric(v).reduce((a, b) => a + b, 0) })
defineFunction('avg', {
min: 1,
call: (v) => {
const nums = flattenNumeric(v)
if (nums.length === 0) return 0
return nums.reduce((a, b) => a + b, 0) / nums.length
}
})
defineFunction('count', {
min: 1,
max: 1,
call: (v) => Array.isArray(v[0]) ? v[0].length : (v[0] == null ? 0 : 1)
})
defineFunction('round', {
min: 1,
max: 2,
call: (v) => {
const n = toNumber(v[0])
const p = v.length > 1 ? toNumber(v[1]) : 0
const f = Math.pow(10, p)
return Math.round(n * f) / f
}
})
defineFunction('roundup', {
min: 1,
max: 2,
call: (v) => {
const n = toNumber(v[0])
const p = v.length > 1 ? toNumber(v[1]) : 0
const f = Math.pow(10, p)
return (n < 0 ? -Math.floor(-n * f) : Math.ceil(n * f)) / f
}
})
defineFunction('rounddown', {
min: 1,
max: 2,
call: (v) => {
const n = toNumber(v[0])
const p = v.length > 1 ? toNumber(v[1]) : 0
const f = Math.pow(10, p)
return (n < 0 ? -Math.ceil(-n * f) : Math.floor(n * f)) / f
}
})
const MATH_FNS = {
sqrt: Math.sqrt,
cbrt: Math.cbrt,
sin: Math.sin,
cos: Math.cos,
tan: Math.tan,
asin: Math.asin,
acos: Math.acos,
atan: Math.atan,
sinh: Math.sinh,
cosh: Math.cosh,
tanh: Math.tanh,
exp: Math.exp,
log: Math.log,
log2: Math.log2,
log10: Math.log10,
floor: Math.floor,
ceil: Math.ceil
}
Object.keys(MATH_FNS).forEach((name) => {
const fn = MATH_FNS[name]
defineFunction(name, { min: 1, max: 1, call: (v) => fn(toNumber(v[0])) })
})
defineFunction('atan2', { min: 2, max: 2, call: (v) => Math.atan2(toNumber(v[0]), toNumber(v[1])) })
defineFunction('hypot', { min: 1, call: (v) => Math.hypot.apply(null, flattenNumeric(v)) })
defineFunction('pow', { min: 2, max: 2, call: (v) => Math.pow(toNumber(v[0]), toNumber(v[1])) })
defineFunction('pi', { min: 0, max: 0, call: () => Math.PI })
defineFunction('e', { min: 0, max: 0, call: () => Math.E })
defineFunction('not', { min: 1, max: 1, type: 'logical', call: (v) => !v[0] })
defineFunction('and', { min: 1, type: 'logical', call: (v) => v.every((x) => Boolean(x)) })
defineFunction('or', { min: 1, type: 'logical', call: (v) => v.some((x) => Boolean(x)) })
defineFunction('xor', {
min: 2,
type: 'logical',
call: (v) => v.reduce((acc, x) => acc !== Boolean(x), false)
})
class IfFunction extends FunctionNode {
static arity = 3
static minParamCount () { return 3 }
static maxParamCount () { return 3 }
value (ctx) {
return this.args[0].value(ctx) ? this.args[1].value(ctx) : this.args[2].value(ctx)
}
get type () { return this.args[1].type }
dependencies (ctx) {
try {
return this.args[0].value(ctx) ? this.args[1].dependencies(ctx) : this.args[2].dependencies(ctx)
} catch (e) {
return uniq([].concat.apply([], this.args.map((a) => a.dependencies(ctx))))
}
}
}
registerFunctionClass('if', IfFunction)
defineFunction('length', { min: 1, max: 1, call: (v) => v[0] == null ? 0 : String(v[0]).length })
defineFunction('upcase', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).toUpperCase() })
defineFunction('downcase', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).toLowerCase() })
defineFunction('concat', { min: 1, type: 'string', call: (v) => v.map((x) => String(x)).join('') })
defineFunction('left', {
min: 2,
max: 2,
type: 'string',
call: (v) => String(v[0]).slice(0, toNumber(v[1]))
})
defineFunction('right', {
min: 2,
max: 2,
type: 'string',
call: (v) => {
const n = toNumber(v[1])
return n === 0 ? '' : String(v[0]).slice(-n)
}
})
defineFunction('mid', {
min: 3,
max: 3,
type: 'string',
call: (v) => {
const s = String(v[0])
const start = toNumber(v[1]) - 1
const len = toNumber(v[2])
return s.slice(start, start + len)
}
})
defineFunction('trim', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).trim() })
defineFunction('contains', {
min: 2,
max: 2,
type: 'logical',
call: (v) => {
if (Array.isArray(v[0])) return v[0].indexOf(v[1]) !== -1
return String(v[0]).indexOf(String(v[1])) !== -1
}
})
const OP_CLASSES = {
add: Addition,
subtract: Subtraction,
multiply: Multiplication,
divide: Division,
pow: Exponentiation,
negate: Negation,
mod: Modulo,
bitor: BitwiseOr,
bitand: BitwiseAnd,
bitshiftleft: BitwiseShiftLeft,
bitshiftright: BitwiseShiftRight,
lt: LessThan,
gt: GreaterThan,
le: LessThanOrEqual,
ge: GreaterThanOrEqual,
ne: NotEqual,
eq: Equal,
and: And,
or: Or
}
function isOperationClass (cls) {
return cls && (cls.prototype instanceof Operation)
}
function parse (tokens) {
if (!tokens || tokens.length === 0) return new NilNode()
const output = []
const operations = []
const arities = []
const skip = new Set()
function consume (count) {
if (count == null) count = 2
const op = operations.pop()
if (!op) throw new ParseError('invalid statement')
const outputSize = output.length
const opMin = (typeof op.minParamCount === 'function') ? op.minParamCount() : null
const opMax = (typeof op.maxParamCount === 'function') ? op.maxParamCount() : null
const actualArgs = (op.arity != null) ? op.arity : count
const minSize = (op.arity != null) ? op.arity : (opMin != null ? opMin : count)
const maxSize = (op.arity != null) ? op.arity : (opMax != null ? opMax : count)
if (outputSize < minSize || actualArgs < minSize) {
throw new ParseError((op.name || 'operator') + ' has too few operands (given ' + outputSize + ', expected ' + minSize + ')')
}
if ((outputSize > maxSize && operations.length === 0) || actualArgs > maxSize) {
throw new ParseError((op.name || 'operator') + ' has too many operands (given ' + outputSize + ', expected ' + maxSize + ')')
}
if (op === ArrayNode && output.length === 0) {
output.push(new ArrayNode())
return
}
if (outputSize < actualArgs) throw new ParseError('invalid statement')
const args = []
for (let i = 0; i < actualArgs; i++) args.unshift(output.pop())
// eslint-disable-next-line new-cap
output.push(new op(...args))
}
function handleOperator (token, lookahead) {
let cls = OP_CLASSES[token.value]
if (!cls) throw new ParseError('Unknown operator ' + token.value)
if (typeof cls.resolveClass === 'function') cls = cls.resolveClass(lookahead)
const prec = cls.precedence
if (cls.rightAssociative) {
while (operations.length > 0) {
const top = operations[operations.length - 1]
if (isOperationClass(top) && prec < top.precedence) consume()
else break
}
} else {
while (operations.length > 0) {
const top = operations[operations.length - 1]
if (isOperationClass(top) && prec <= top.precedence) consume()
else break
}
}
operations.push(cls)
}
function handleFunction (token) {
const fn = getFunctionClass(token.value)
if (!fn) throw new ParseError('Undefined function ' + token.value)
arities.push(0)
operations.push(fn)
}
function handleArray (token) {
if (token.value === 'array_start') {
operations.push(ArrayNode)
arities.push(0)
} else {
while (operations.length > 0 && operations[operations.length - 1] !== ArrayNode) consume()
if (operations[operations.length - 1] !== ArrayNode) throw new ParseError('Unbalanced bracket')
consume(arities.pop() + 1)
}
}
function handleGrouping (token, lookahead, index) {
if (token.value === 'open') {
if (lookahead && lookahead.value === 'close') {
skip.add(index + 1)
arities.pop()
consume(0)
} else {
operations.push(Grouping)
}
} else if (token.value === 'close') {
while (operations.length > 0 && operations[operations.length - 1] !== Grouping) consume()
const lparen = operations.pop()
if (lparen !== Grouping) throw new ParseError('Unbalanced parenthesis')
const top = operations[operations.length - 1]
if (top && (top.prototype instanceof FunctionNode)) consume(arities.pop() + 1)
} else if (token.value === 'comma') {
if (arities.length === 0) throw new ParseError('invalid statement')
arities[arities.length - 1] += 1
while (operations.length > 0 &&
operations[operations.length - 1] !== Grouping &&
operations[operations.length - 1] !== ArrayNode) consume()
} else {
throw new ParseError('Unknown grouping token ' + token.value)
}
}
for (let i = 0; i < tokens.length; i++) {
if (skip.has(i)) continue
const token = tokens[i]
const lookahead = tokens[i + 1]
switch (token.category) {
case 'numeric': output.push(new Numeric(token)); break
case 'logical': output.push(new Logical(token)); break
case 'string': output.push(new StringNode(token)); break
case 'identifier': output.push(new Identifier(token)); break
case 'null': output.push(new NilNode()); break
case 'operator':
case 'comparator':
case 'combinator':
handleOperator(token, lookahead); break
case 'function': handleFunction(token); break
case 'array': handleArray(token); break
case 'grouping': handleGrouping(token, lookahead, i); break
default:
throw new ParseError('Not implemented for tokens of category ' + token.category)
}
}
while (operations.length > 0) consume()
if (output.length !== 1) throw new ParseError('Invalid statement')
return output[0]
}
class Calculator {
constructor (options) {
options = options || {}
this.caseSensitive = options.caseSensitive === true
this.memory = Object.create(null)
this._astCache = Object.create(null)
}
key (k) { return this.caseSensitive ? k : String(k).toLowerCase() }
normalize (data) {
const out = Object.create(null)
if (!data) return out
for (const k of Object.keys(data)) out[this.key(k)] = data[k]
return out
}
store (data) { Object.assign(this.memory, this.normalize(data)); return this }
bind (data) { return this.store(data) }
clear () { this.memory = Object.create(null); return this }
ast (expression) {
const cached = this._astCache[expression]
if (cached) return cached
const node = parse(tokenize(expression, { caseSensitive: this.caseSensitive }))
this._astCache[expression] = node
return node
}
tokenize (expression) {
return tokenize(expression, { caseSensitive: this.caseSensitive })
}
dependencies (expression) {
return this.ast(expression).dependencies(this.memory)
}
evaluate (expression, data, onError) {
try {
return this.evaluateStrict(expression, data)
} catch (e) {
if (typeof data === 'function') return data(expression, e)
if (typeof onError === 'function') return onError(expression, e)
return undefined
}
}
evaluateStrict (expression, data) {
const ctx = Object.assign(Object.create(null), this.memory, this.normalize(data))
const node = this.ast(expression)
return node.value(ctx)
}
addFunction (name, spec) {
defineFunction(name, {
min: spec.minArgs == null ? (spec.min == null ? 0 : spec.min) : spec.minArgs,
max: spec.maxArgs == null ? (spec.max == null ? Infinity : spec.max) : spec.maxArgs,
type: spec.type || 'numeric',
call: spec.call
})
return this
}
}
export {
Calculator,
tokenize,
parse,
CalculatorError,
ParseError,
TokenizerError,
UnboundVariableError,
ZeroDivisionError,
ArgumentError
}
export function addFunction (name, spec) { defineFunction(name, spec) }
+9 -1
View File
@@ -161,6 +161,11 @@ export default {
required: false,
default: false
},
fetchOptions: {
type: Object,
required: false,
default: () => ({})
},
completedButton: {
type: Object,
required: false,
@@ -214,7 +219,10 @@ export default {
download () {
this.isDownloading = true
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`).then(async (response) => {
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`, {
method: 'GET',
...this.fetchOptions
}).then(async (response) => {
if (response.ok) {
const urls = await response.json()
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
+21 -1
View File
@@ -402,6 +402,8 @@
:remember-signature="rememberSignature"
:attachments-index="attachmentsIndex"
:require-signing-reason="requireSigningReason"
:signature-text="signatureText"
:signature-src="signatureSrc"
:button-text="submitButtonText"
:dry-run="dryRun"
:with-disclosure="withDisclosure"
@@ -530,6 +532,7 @@
v-else-if="isInvite"
:submitters="inviteSubmitters"
:optional-submitters="optionalInviteSubmitters"
:fetch-options="fetchOptions"
:submitter-slug="submitterSlug"
:authenticity-token="authenticityToken"
:url="baseUrl + submitPath + '/invite'"
@@ -543,6 +546,7 @@
:has-signature-fields="stepFields.some((fields) => fields.some((f) => ['signature', 'initials'].includes(f.type)))"
:has-multiple-documents="hasMultipleDocuments"
:completed-button="completedRedirectUrl ? {} : completedButton"
:fetch-options="fetchOptions"
:completed-message="completedRedirectUrl ? {} : completedMessage"
:with-send-copy-button="withSendCopyButton && !completedRedirectUrl"
:with-download-button="withDownloadButton && !completedRedirectUrl && !dryRun"
@@ -678,6 +682,11 @@ export default {
required: false,
default: () => []
},
fetchOptions: {
type: Object,
required: false,
default: () => ({})
},
optionalInviteSubmitters: {
type: Array,
required: false,
@@ -825,6 +834,16 @@ export default {
required: false,
default: ''
},
signatureText: {
type: String,
required: false,
default: ''
},
signatureSrc: {
type: String,
required: false,
default: ''
},
previousSignatureValue: {
type: String,
required: false,
@@ -1467,7 +1486,8 @@ export default {
} else {
return fetch(this.baseUrl + this.submitPath, {
method: 'POST',
body: formData || new FormData(this.$refs.form)
body: formData || new FormData(this.$refs.form),
...this.fetchOptions
}).then((response) => {
if (response.status === 200) {
currentFieldUuids.forEach((fieldUuid) => {
@@ -68,34 +68,9 @@ export default {
}
},
async mounted () {
const {
create,
evaluateDependencies,
addDependencies,
subtractDependencies,
divideDependencies,
multiplyDependencies,
powDependencies,
roundDependencies,
absDependencies,
sinDependencies,
tanDependencies,
cosDependencies
} = await import('mathjs')
const { Calculator } = await import('./calculator')
this.math = create({
evaluateDependencies,
addDependencies,
subtractDependencies,
divideDependencies,
multiplyDependencies,
powDependencies,
roundDependencies,
absDependencies,
sinDependencies,
tanDependencies,
cosDependencies
})
this.math = new Calculator()
this.isMathLoaded = true
},
+15 -1
View File
@@ -1,7 +1,7 @@
const en = {
kba: 'KBA',
please_upload_an_image_file: 'Please upload an image file',
must_be_characters_length: 'Must be {number} characters length',
must_be_characters_length: 'Must be {number} characters long',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
verify_id: 'Verify ID',
identity_verification: 'Identity verification',
@@ -97,6 +97,7 @@ const en = {
upload: 'Upload',
files: 'Files',
signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.',
browser_privacy_settings_block_canvas: 'Your browser privacy settings restrict use of the drawing canvas. Please use a different browser or device, or disable privacy settings that block canvas in order to sign.',
wait_countdown_seconds: 'Wait {countdown} seconds'
}
@@ -199,6 +200,7 @@ const es = {
upload: 'Subir',
files: 'Archivos',
signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.',
browser_privacy_settings_block_canvas: 'La configuración de privacidad de su navegador restringe el uso del lienzo de dibujo. Utilice un navegador o dispositivo diferente, o desactive la configuración de privacidad que bloquea el lienzo para firmar.',
wait_countdown_seconds: 'Espera {countdown} segundos'
}
@@ -301,6 +303,7 @@ const it = {
upload: 'Carica',
files: 'File',
signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.',
browser_privacy_settings_block_canvas: 'Le impostazioni sulla privacy del browser limitano l\'uso dell\'area di disegno. Utilizza un browser o dispositivo diverso oppure disattiva le impostazioni sulla privacy che bloccano il canvas per firmare.',
wait_countdown_seconds: 'Attendi {countdown} secondi'
}
@@ -403,6 +406,7 @@ const de = {
upload: 'Hochladen',
files: 'Dateien',
signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte neu zeichnen.',
browser_privacy_settings_block_canvas: 'Die Datenschutzeinstellungen Ihres Browsers schränken die Nutzung der Zeichenfläche ein. Bitte verwenden Sie einen anderen Browser oder ein anderes Gerät oder deaktivieren Sie die Datenschutzeinstellungen, die Canvas blockieren, um zu unterschreiben.',
wait_countdown_seconds: 'Bitte {countdown} Sekunden warten'
}
@@ -505,6 +509,7 @@ const fr = {
upload: 'Téléverser',
files: 'Fichiers',
signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.',
browser_privacy_settings_block_canvas: 'Les paramètres de confidentialité de votre navigateur empêchent l\'utilisation du canevas de dessin. Veuillez utiliser un autre navigateur ou appareil, ou désactiver les paramètres de confidentialité qui bloquent le canevas pour signer.',
wait_countdown_seconds: 'Veuillez patienter {countdown} secondes'
}
@@ -607,6 +612,7 @@ const pl = {
upload: 'Przesyłanie',
files: 'Pliki',
signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.',
browser_privacy_settings_block_canvas: 'Ustawienia prywatności przeglądarki blokują użycie obszaru rysowania. Użyj innej przeglądarki lub urządzenia albo wyłącz ustawienia prywatności blokujące canvas, aby podpisać.',
wait_countdown_seconds: 'Poczekaj {countdown} sekund'
}
@@ -709,6 +715,7 @@ const uk = {
upload: 'Завантажити',
files: 'Файли',
signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.',
browser_privacy_settings_block_canvas: 'Налаштування конфіденційності вашого браузера блокують використання полотна для малювання. Будь ласка, скористайтеся іншим браузером або пристроєм, або вимкніть налаштування конфіденційності, що блокують canvas, щоб підписати.',
wait_countdown_seconds: 'Зачекайте {countdown} секунд'
}
@@ -811,6 +818,7 @@ const cs = {
upload: 'Nahrát',
files: 'Soubory',
signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.',
browser_privacy_settings_block_canvas: 'Nastavení soukromí vašeho prohlížeče omezuje použití kreslicího plátna. Použijte prosím jiný prohlížeč nebo zařízení, nebo vypněte nastavení soukromí blokující canvas pro podepsání.',
wait_countdown_seconds: 'Počkejte {countdown} sekund'
}
@@ -913,6 +921,7 @@ const pt = {
upload: 'Carregar',
files: 'Arquivos',
signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.',
browser_privacy_settings_block_canvas: 'As configurações de privacidade do seu navegador restringem o uso da área de desenho. Use um navegador ou dispositivo diferente, ou desative as configurações de privacidade que bloqueiam o canvas para assinar.',
wait_countdown_seconds: 'Aguarde {countdown} segundos'
}
@@ -1015,6 +1024,7 @@ const he = {
upload: 'העלאה',
files: 'קבצים',
signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.',
browser_privacy_settings_block_canvas: 'הגדרות הפרטיות של הדפדפן שלך מגבילות את השימוש באזור הציור. אנא השתמש בדפדפן או מכשיר אחר, או בטל את הגדרות הפרטיות החוסמות canvas כדי לחתום.',
wait_countdown_seconds: 'המתן {countdown} שניות'
}
@@ -1117,6 +1127,7 @@ const nl = {
upload: 'Uploaden',
files: 'Bestanden',
signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.',
browser_privacy_settings_block_canvas: 'De privacyinstellingen van uw browser beperken het gebruik van het tekenveld. Gebruik een andere browser of ander apparaat, of schakel de privacyinstellingen uit die canvas blokkeren om te ondertekenen.',
wait_countdown_seconds: 'Wacht {countdown} seconden'
}
@@ -1219,6 +1230,7 @@ const ar = {
upload: 'تحميل',
files: 'الملفات',
signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.',
browser_privacy_settings_block_canvas: 'إعدادات الخصوصية في متصفحك تمنع استخدام لوحة الرسم. يرجى استخدام متصفح أو جهاز مختلف، أو تعطيل إعدادات الخصوصية التي تحظر canvas للتوقيع.',
wait_countdown_seconds: 'انتظر {countdown} ثانية'
}
@@ -1321,6 +1333,7 @@ const ko = {
upload: '업로드',
files: '파일',
signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.',
browser_privacy_settings_block_canvas: '브라우저 개인정보 보호 설정으로 인해 그리기 캔버스를 사용할 수 없습니다. 다른 브라우저나 기기를 사용하거나, 서명을 위해 캔버스를 차단하는 개인정보 보호 설정을 비활성화해 주세요.',
wait_countdown_seconds: '{countdown}초 기다리세요'
}
@@ -1423,6 +1436,7 @@ const ja = {
upload: 'アップロード',
files: 'ファイル',
signature_is_too_small_or_simple_please_redraw: '署名が小さすぎるか単純すぎます。もう一度描いてください。',
browser_privacy_settings_block_canvas: 'ブラウザのプライバシー設定により、描画キャンバスの使用が制限されています。別のブラウザまたはデバイスを使用するか、署名するためにキャンバスをブロックするプライバシー設定を無効にしてください。',
wait_countdown_seconds: '{countdown} 秒お待ちください'
}
@@ -150,6 +150,7 @@
<script>
import { cropCanvasAndExportToPNG } from './crop_canvas'
import { isCanvasBlocked } from './validate_signature'
import { IconReload, IconTextSize, IconUpload, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on'
@@ -358,7 +359,9 @@ export default {
},
initTextInitial () {
if (this.submitter.name) {
this.$refs.textInput.value = this.submitter.name.trim().split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase() || '').join('')
const parts = this.submitter.name.trim().split(/\s+/)
this.$refs.textInput.value = (parts.length > 1 ? [parts[0], parts[parts.length - 1]] : parts).map((part) => part[0]?.toUpperCase() || '').join('')
}
if (this.$refs.textInput.value) {
@@ -419,7 +422,15 @@ export default {
}
}).catch((error) => {
if (this.field.required === true) {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
if (isCanvasBlocked()) {
alert(this.t('browser_privacy_settings_block_canvas'))
if (window.Rollbar) {
window.Rollbar.info('Canvas blocked')
}
} else {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
}
return reject(error)
} else {
@@ -78,6 +78,11 @@ export default {
type: Array,
required: true
},
fetchOptions: {
type: Object,
required: false,
default: () => ({})
},
optionalSubmitters: {
type: Array,
required: false,
@@ -108,7 +113,8 @@ export default {
return fetch(this.url, {
method: 'POST',
body: new FormData(this.$refs.form)
body: new FormData(this.$refs.form),
...this.fetchOptions
}).then((response) => {
if (response.status === 200) {
this.$emit('success')
@@ -165,34 +165,9 @@ export default {
}
if (this.field.preferences?.formula) {
const {
create,
evaluateDependencies,
addDependencies,
subtractDependencies,
divideDependencies,
multiplyDependencies,
powDependencies,
roundDependencies,
absDependencies,
sinDependencies,
tanDependencies,
cosDependencies
} = await import('mathjs')
const { Calculator } = await import('./calculator')
this.math = create({
evaluateDependencies,
addDependencies,
subtractDependencies,
divideDependencies,
multiplyDependencies,
powDependencies,
roundDependencies,
absDependencies,
sinDependencies,
tanDependencies,
cosDependencies
})
this.math = new Calculator()
this.isMathLoaded = true
}
@@ -309,7 +309,7 @@
<script>
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas'
import { isValidSignatureCanvas } from './validate_signature'
import { isValidSignatureCanvas, isCanvasBlocked } from './validate_signature'
import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on'
import FileDropzone from './dropzone'
@@ -408,6 +408,16 @@ export default {
required: false,
default: ''
},
signatureText: {
type: String,
required: false,
default: ''
},
signatureSrc: {
type: String,
required: false,
default: ''
},
modelValue: {
type: String,
required: false,
@@ -422,7 +432,7 @@ export default {
isOtherReason: false,
isUsePreviousValue: true,
isTouchAttachment: false,
isTextSignature: this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload',
isTextSignature: !this.signatureSrc && (!!this.signatureText || this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload'),
uploadImageInputKey: Math.random().toString()
}
},
@@ -482,7 +492,9 @@ export default {
if (entry.isIntersecting) {
this.setCanvasSize()
if (this.isTextSignature) {
if (this.signatureSrc) {
this.$nextTick(() => this.drawSignatureSrc())
} else if (this.isTextSignature) {
this.$nextTick(() => {
if (this.$refs.textInput) {
this.initTypedSignature()
@@ -686,7 +698,9 @@ export default {
}
},
async initTypedSignature () {
if (this.submitter.name) {
if (this.signatureText) {
this.$refs.textInput.value = this.signatureText
} else if (this.submitter.name) {
this.$refs.textInput.value = this.submitter.name
}
@@ -696,6 +710,48 @@ export default {
this.updateWrittenSignature({ target: this.$refs.textInput })
}
},
drawSignatureSrc () {
const canvas = this.$refs.canvas
if (!canvas) return
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const context = canvas.getContext('2d')
const aspectRatio = img.width / img.height
const canvasWidth = canvas.width / scale
const canvasHeight = canvas.height / scale
let targetWidth = canvasWidth
let targetHeight = canvasHeight
if (canvasWidth / canvasHeight > aspectRatio) {
targetWidth = canvasHeight * aspectRatio
} else {
targetHeight = canvasWidth / aspectRatio
}
const x = (canvasWidth - targetWidth) / 2
const y = (canvasHeight - targetHeight) / 2
context.clearRect(0, 0, canvasWidth, canvasHeight)
context.drawImage(img, x, y, targetWidth, targetHeight)
this.isSignatureStarted = true
this.$emit('start')
}
img.onerror = () => {
console.error(`Failed to load signature image from ${this.signatureSrc}. The remote server must send an Access-Control-Allow-Origin header to allow CORS access.`)
}
img.src = this.signatureSrc
},
drawImage (event) {
this.remove()
this.clear()
@@ -790,7 +846,15 @@ export default {
if (this.isSignatureStarted && this.pad.toData().length > 0 && !isValidSignatureCanvas(this.pad.toData())) {
if (this.field.required === true || this.pad.toData().length > 0) {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
if (isCanvasBlocked()) {
alert(this.t('browser_privacy_settings_block_canvas'))
if (window.Rollbar) {
window.Rollbar.info('Canvas blocked')
}
} else {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
}
return Promise.reject(new Error('Image too small or simple'))
} else {
@@ -846,7 +910,15 @@ export default {
}
}).catch((error) => {
if (this.field.required === true) {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
if (isCanvasBlocked()) {
alert(this.t('browser_privacy_settings_block_canvas'))
if (window.Rollbar) {
window.Rollbar.info('Canvas blocked')
}
} else {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
}
return reject(error)
} else {
@@ -35,4 +35,24 @@ function isValidSignatureCanvas (data) {
return validStrokes.length > 0
}
export { isValidSignatureCanvas }
function isCanvasBlocked () {
try {
const testCanvas = document.createElement('canvas')
testCanvas.width = 2
testCanvas.height = 2
const ctx = testCanvas.getContext('2d')
ctx.fillStyle = 'rgb(255, 0, 0)'
ctx.fillRect(0, 0, 2, 2)
const pixel = ctx.getImageData(0, 0, 1, 1).data
return pixel[0] !== 255 || pixel[1] !== 0 || pixel[2] !== 0 || pixel[3] !== 255
} catch (e) {
return true
}
}
export { isValidSignatureCanvas, isCanvasBlocked }
@@ -20,8 +20,11 @@
{{ t('complete_all_required_fields_to_proceed_with_identity_verification') }}
</div>
<div v-else>
<div v-if="errorMessage">
{{ errorMessage }}
</div>
<div
v-if="isLoading"
v-else-if="isLoading"
class="w-full flex space-x-2 justify-center mb-2"
>
<IconInnerShadowTop
@@ -96,6 +99,7 @@ export default {
isMathLoaded: false,
redirectUrl: '',
isLoading: false,
errorMessage: '',
eidEasyData: {}
}
},
@@ -162,6 +166,12 @@ export default {
}).then(async (resp) => {
this.eidEasyData = await resp.json()
if (this.eidEasyData.error) {
this.errorMessage = this.eidEasyData.error
return
}
if (this.eidEasyData.check_completed) {
this.$emit('submit')
}
+24 -294
View File
@@ -31,144 +31,26 @@
/>
</div>
</div>
<div
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
style="top: -25px; height: 25px"
@mousedown.stop
@pointerdown.stop
>
<FieldSubmitter
v-if="field.type != 'heading' && field.type != 'strikethrough'"
v-model="field.submitter_uuid"
class="border-r roles-dropdown"
:compact="true"
:editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
:allow-add-new="!defaultSubmitters.length"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
:submitters="template.submitters"
@update:model-value="save"
@click="selectedAreasRef.value = [area]"
/>
<FieldType
v-model="field.type"
:button-width="27"
:editable="editable && !defaultField"
:button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), save()]"
@click="selectedAreasRef.value = [area]"
/>
<span
v-if="field.type !== 'checkbox' || field.name"
ref="name"
:contenteditable="editable && !defaultField && field.type !== 'heading'"
dir="auto"
class="pr-1 cursor-text outline-none block"
style="min-width: 2px"
@paste.prevent="onPaste"
@keydown.enter.prevent="onNameEnter"
@focus="onNameFocus"
@blur="onNameBlur"
>{{ optionIndexText }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || defaultName }}</span>
<div
v-if="isSettingsFocus || isSelectInput || (isValueInput && field.type !== 'heading') || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
class="flex items-center ml-1.5"
>
<input
v-if="!isValueInput && !isSelectInput"
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
v-if="!isValueInput && !isSelectInput"
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>{{ t('required') }}</label>
<input
v-if="isValueInput || isSelectInput"
:id="`readonly-checkbox-${field.uuid}`"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
:checked="!(field.readonly ?? true)"
@change="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>
<label
v-if="isValueInput || isSelectInput"
:for="`readonly-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>{{ t('editable') }}</label>
<span
v-if="field.type !== 'payment' && !isValueInput"
class="dropdown dropdown-end field-area-settings-dropdown"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
ref="settingsButton"
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center"
style="height: 25px"
@focus="isSettingsFocus = true"
@blur="maybeBlurSettings"
>
<IconDotsVertical class="w-5 h-5" />
</label>
<ul
v-if="renderDropdown"
ref="settingsDropdown"
tabindex="0"
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
:style="{ backgroundColor: 'white' }"
@dragstart.prevent.stop
@click="closeDropdown"
@focusout="maybeBlurSettings"
>
<FieldSettings
v-if="isMobile"
:field="field"
:default-field="defaultField"
:editable="editable"
:background-color="'white'"
:with-required="false"
:with-areas="false"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@add-custom-field="$emit('add-custom-field')"
@click-condition="isShowConditionsModal = true"
@save="save"
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
/>
<div
v-else
class="whitespace-normal"
>
The dots menu is retired in favor of the field context menu. Right-click the field to access field settings. Double-click the field to set a default value.
</div>
</ul>
</span>
</div>
<button
v-else-if="editable"
class="pr-1"
:title="t('remove')"
@click.prevent="$emit('remove')"
>
<IconX width="14" />
</button>
</div>
<AreaTitle
ref="title"
:area="area"
:field="field"
:template="template"
:selected-areas-ref="selectedAreasRef"
:get-field-type-index="getFieldTypeIndex"
:default-field="defaultField"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:default-submitters="defaultSubmitters"
:editable="editable"
:is-mobile="isMobile"
:is-value-input="isValueInput"
:is-select-input="isSelectInput"
@change="save"
@remove="$emit('remove')"
@scroll-to="$emit('scroll-to', $event)"
@add-custom-field="$emit('add-custom-field')"
/>
<div
ref="touchValueTarget"
class="flex h-full w-full field-area"
@@ -333,85 +215,20 @@
@mousedown.stop="startResize"
@touchstart="startTouchResize"
/>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="save"
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="save"
@close="isShowFontModal = false"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="field"
:build-default-name="buildDefaultName"
:default-field="defaultField"
@save="save"
@close="isShowConditionsModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="save"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div>
</template>
<script>
import FieldSubmitter from './field_submitter'
import FieldType from './field_type'
import Field from './field'
import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconX, IconCheck, IconDotsVertical } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import AreaTitle from './area_title'
import { IconCheck } from '@tabler/icons-vue'
export default {
name: 'FieldArea',
components: {
FieldType,
IconCheck,
FieldSettings,
FormulaModal,
FontModal,
IconDotsVertical,
DescriptionModal,
ConditionsModal,
FieldSubmitter,
IconX
AreaTitle
},
inject: ['template', 'save', 't', 'isInlineSize', 'selectedAreasRef', 'isCmdKeyRef', 'getFieldTypeIndex'],
props: {
@@ -493,17 +310,10 @@ export default {
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowConditionsModal: false,
isContenteditable: false,
isSettingsFocus: false,
isShowDescriptionModal: false,
isResize: false,
isDragged: false,
isMoved: false,
renderDropdown: false,
isNameFocus: false,
isHeadingSelected: false,
textOverflowChars: 0,
dragFrom: { x: 0, y: 0 }
@@ -592,9 +402,6 @@ export default {
return (this.field.type === 'heading' && this.isHeadingSelected) || this.isContenteditable ||
(this.inputMode && (['text', 'number'].includes(this.field.type) || (this.field.type === 'date' && this.field.default_value !== '{{date}}')))
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
defaultName () {
return this.buildDefaultName(this.field)
},
@@ -616,13 +423,6 @@ export default {
italic: ['bold_italic', 'italic'].includes(this.field.preferences.font_type)
}
},
optionIndexText () {
if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
} else {
return ''
}
},
cells () {
const cells = []
@@ -705,9 +505,6 @@ export default {
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
buildAreaOptionValue (area) {
const option = this.optionsUuidIndex[area.option_uuid]
@@ -792,23 +589,6 @@ export default {
return number
}
},
maybeBlurSettings (e) {
if (!e.relatedTarget || !this.$refs.settingsDropdown.contains(e.relatedTarget)) {
this.isSettingsFocus = false
}
},
onNameFocus (e) {
this.selectedAreasRef.value = [this.area]
this.isNameFocus = true
this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px'
if (!this.field.name) {
setTimeout(() => {
this.$refs.name.innerText = ' '
}, 1)
}
},
startResizeCell (e) {
this.$el.getRootNode().addEventListener('mousemove', this.onResizeCell)
this.$el.getRootNode().addEventListener('mouseup', this.stopResizeCell)
@@ -843,53 +623,6 @@ export default {
}
}
},
maybeUpdateOptions () {
delete this.field.default_value
if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
delete this.field.options
}
if (this.field.type === 'heading') {
this.field.readonly = true
}
if (this.field.type === 'strikethrough') {
this.field.readonly = true
this.field.default_value = true
}
if (['select', 'multiple', 'radio'].includes(this.field.type)) {
this.field.options ||= [{ value: '', uuid: v4() }]
}
(this.field.areas || []).forEach((area) => {
if (this.field.type === 'cells') {
area.cell_w = area.w * 2 / Math.floor(area.w / area.h)
} else {
delete area.cell_w
}
})
},
onNameBlur (e) {
if (e.relatedTarget === this.$refs.settingsButton) {
this.isSettingsFocus = true
}
const text = this.$refs.name.innerText.trim()
this.isNameFocus = false
this.$refs.name.style.minWidth = ''
if (text) {
this.field.name = text
} else {
this.field.name = ''
this.$refs.name.innerText = this.defaultName
}
this.save()
},
onDefaultValueBlur (e) {
const text = this.$refs.defaultValue.innerText.trim()
@@ -927,9 +660,6 @@ export default {
this.$refs.defaultValue.blur()
}
},
onNameEnter (e) {
this.$refs.name.blur()
},
resize (e) {
if (e.target.id === 'mask') {
this.area.w = e.offsetX / e.target.clientWidth - this.area.x
@@ -1124,7 +854,7 @@ export default {
this.selectedAreasRef.value = [this.area]
}
this.$refs?.name?.blur()
this.$refs?.title?.$refs?.name?.blur()
e.preventDefault()
@@ -0,0 +1,404 @@
<template>
<div
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
style="top: -25px; height: 25px"
@mousedown.stop
@pointerdown.stop
>
<FieldSubmitter
v-if="field.type != 'heading' && field.type != 'strikethrough'"
v-model="field.submitter_uuid"
class="border-r roles-dropdown"
:compact="true"
:editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
:allow-add-new="!defaultSubmitters.length"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
:submitters="template.submitters"
@update:model-value="$emit('change')"
@click="selectedAreasRef.value = [area]"
/>
<FieldType
v-model="field.type"
:button-width="27"
:editable="editable && !defaultField"
:button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), $emit('change')]"
@click="selectedAreasRef.value = [area]"
/>
<span
v-if="field.type !== 'checkbox' || field.name"
ref="name"
:contenteditable="editable && !defaultField && field.type !== 'heading'"
dir="auto"
class="pr-1 cursor-text outline-none block"
style="min-width: 2px"
@paste.prevent="onPaste"
@keydown.enter.prevent="onNameEnter"
@focus="onNameFocus"
@blur="onNameBlur"
>{{ optionIndexText }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || defaultName }}</span>
<div
v-if="isSettingsFocus || isSelectInput || (isValueInput && field.type !== 'heading') || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
class="flex items-center ml-1.5"
>
<input
v-if="!isValueInput && !isSelectInput"
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
v-if="!isValueInput && !isSelectInput"
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>{{ t('required') }}</label>
<input
v-if="isValueInput || isSelectInput"
:id="`readonly-checkbox-${field.uuid}`"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
:checked="!(field.readonly ?? true)"
@change="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>
<label
v-if="isValueInput || isSelectInput"
:for="`readonly-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>{{ t('editable') }}</label>
<span
v-if="field.type !== 'payment' && !isValueInput"
class="dropdown dropdown-end field-area-settings-dropdown"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
ref="settingsButton"
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center"
style="height: 25px"
@focus="isSettingsFocus = true"
@blur="maybeBlurSettings"
>
<IconDotsVertical class="w-5 h-5" />
</label>
<ul
v-if="renderDropdown"
ref="settingsDropdown"
tabindex="0"
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
:style="{ backgroundColor: 'white' }"
@dragstart.prevent.stop
@click="closeDropdown"
@focusout="maybeBlurSettings"
>
<FieldSettings
v-if="isMobile"
:field="field"
:default-field="defaultField"
:editable="editable"
:background-color="'white'"
:with-required="false"
:with-areas="false"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@add-custom-field="$emit('add-custom-field')"
@click-condition="isShowConditionsModal = true"
@save="$emit('change')"
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
/>
<div
v-else
class="whitespace-normal"
>
The dots menu is retired in favor of the field context menu. Right-click the field to access field settings. Double-click the field to set a default value.
</div>
</ul>
</span>
</div>
<button
v-else-if="editable"
class="pr-1"
:title="t('remove')"
@click.prevent="$emit('remove')"
>
<IconX width="14" />
</button>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('change')"
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('change')"
@close="isShowFontModal = false"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="field"
:build-default-name="buildDefaultName"
:default-field="defaultField"
@save="$emit('change')"
@close="isShowConditionsModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('change')"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div>
</template>
<script>
import FieldSubmitter from './field_submitter'
import FieldType from './field_type'
import Field from './field'
import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconX, IconDotsVertical } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
name: 'AreaTitle',
components: {
FieldType,
FieldSettings,
FormulaModal,
FontModal,
IconDotsVertical,
DescriptionModal,
ConditionsModal,
FieldSubmitter,
IconX
},
inject: ['t'],
props: {
template: {
type: Object,
required: true
},
selectedAreasRef: {
type: Object,
required: true
},
getFieldTypeIndex: {
type: Function,
required: true
},
area: {
type: Object,
required: true
},
field: {
type: Object,
required: false,
default: null
},
defaultField: {
type: Object,
required: false,
default: null
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
withPrefillable: {
type: Boolean,
required: false,
default: false
},
defaultSubmitters: {
type: Array,
required: false,
default: () => []
},
editable: {
type: Boolean,
required: false,
default: true
},
isMobile: {
type: Boolean,
required: false,
default: false
},
isValueInput: {
type: Boolean,
required: false,
default: false
},
isSelectInput: {
type: Boolean,
required: false,
default: false
}
},
emits: ['remove', 'scroll-to', 'add-custom-field', 'change'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowConditionsModal: false,
isShowDescriptionModal: false,
isSettingsFocus: false,
renderDropdown: false,
isNameFocus: false
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
submitter () {
return this.template.submitters.find((s) => s.uuid === this.field.submitter_uuid)
},
isSelected () {
return this.selectedAreasRef.value.includes(this.area)
},
isInMultiSelection () {
return this.selectedAreasRef.value.length >= 2 && this.isSelected
},
optionIndexText () {
if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
} else {
return ''
}
},
defaultName () {
return this.buildDefaultName(this.field)
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
}
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
maybeBlurSettings (e) {
if (!e.relatedTarget || !this.$refs.settingsDropdown.contains(e.relatedTarget)) {
this.isSettingsFocus = false
}
},
onNameFocus (e) {
this.selectedAreasRef.value = [this.area]
this.isNameFocus = true
this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px'
if (!this.field.name) {
setTimeout(() => {
this.$refs.name.innerText = ' '
}, 1)
}
},
onNameBlur (e) {
if (e.relatedTarget === this.$refs.settingsButton) {
this.isSettingsFocus = true
}
const text = this.$refs.name.innerText.trim()
this.isNameFocus = false
this.$refs.name.style.minWidth = ''
if (text) {
this.field.name = text
} else {
this.field.name = ''
this.$refs.name.innerText = this.defaultName
}
this.$emit('change')
},
onNameEnter (e) {
this.$refs.name.blur()
},
onPaste (e) {
const text = (e.clipboardData || window.clipboardData).getData('text/plain')
const selection = this.$el.getRootNode().getSelection()
if (selection.rangeCount) {
selection.deleteFromDocument()
selection.getRangeAt(0).insertNode(document.createTextNode(text))
selection.collapseToEnd()
}
},
maybeUpdateOptions () {
delete this.field.default_value
if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
delete this.field.options
}
if (this.field.type === 'heading') {
this.field.readonly = true
}
if (this.field.type === 'strikethrough') {
this.field.readonly = true
this.field.default_value = true
}
if (['select', 'multiple', 'radio'].includes(this.field.type)) {
this.field.options ||= [{ value: '', uuid: v4() }]
}
(this.field.areas || []).forEach((area) => {
if (this.field.type === 'cells') {
area.cell_w = area.w * 2 / Math.floor(area.w / area.h)
} else {
delete area.cell_w
}
})
}
}
}
</script>
+192 -52
View File
@@ -81,7 +81,7 @@
/>
<template v-else>
<form
v-if="withSignYourselfButton && template.submitters.length < 2"
v-if="withSignYourselfButton && undefinedSubmitters.length < 2 && (!template.variables_schema || Object.keys(template.variables_schema).length === 0)"
target="_blank"
data-turbo="false"
class="inline"
@@ -274,6 +274,8 @@
:accept-file-types="acceptFileTypes"
:with-replace-button="withUploadButton"
:editable="editable"
:dynamic-documents="dynamicDocuments"
:with-dynamic-documents="withDynamicDocuments"
:template="template"
@scroll-to="scrollIntoDocument(item)"
@remove="onDocumentRemove"
@@ -352,10 +354,27 @@
</template>
<template v-else>
<template
v-for="document in sortedDocuments"
v-for="(document, index) in sortedDocuments"
:key="document.uuid"
>
<DynamicDocument
v-if="template.schema[index].dynamic"
:ref="setDocumentRefs"
:editable="editable"
:document="dynamicDocuments.find((dynamicDocument) => dynamicDocument.uuid === document.uuid)"
:selected-submitter="selectedSubmitter"
:drag-field="dragField"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField"
:draw-option="drawOption"
@update="onDynamicDocumentUpdate"
@draw="clearDrawField"
@add-custom-field="addCustomField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
/>
<Document
v-else
:ref="setDocumentRefs"
:areas-index="fieldAreasIndex[document.uuid]"
:selected-submitter="selectedSubmitter"
@@ -497,6 +516,8 @@
:show-tour-start-form="showTourStartForm"
@add-field="addField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
@remove-field="onRemoveField"
@remove-submitter="onRemoveSubmitter"
@select-submitter="selectedSubmitter = $event"
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
@set-draw-custom-field="[drawCustomField = $event, showDrawField = true]"
@@ -505,6 +526,7 @@
@change-submitter="selectedSubmitter = $event"
@drag-end="[dragField = null, $refs.dragPlaceholder.dragPlaceholder = null]"
@scroll-to-area="scrollToArea"
@rebuild-variables-schema="rebuildVariablesSchema"
/>
</div>
</div>
@@ -592,12 +614,13 @@ import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import { ref, computed, toRaw } from 'vue'
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
import * as i18n from './i18n'
export default {
name: 'TemplateBuilder',
components: {
DynamicDocument: defineAsyncComponent(() => import(/* webpackChunkName: "dynamic-editor" */ './dynamic_document')),
Upload,
DragPlaceholder,
Document,
@@ -720,6 +743,16 @@ export default {
required: false,
default: false
},
withPrefillable: {
type: Boolean,
required: false,
default: false
},
dynamicDocuments: {
type: Array,
required: false,
default: () => []
},
customFields: {
type: Array,
required: false,
@@ -778,7 +811,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf, application/zip'
default: 'image/*, application/pdf, application/zip, application/json'
},
baseUrl: {
type: String,
@@ -841,6 +874,11 @@ export default {
required: false,
default: ''
},
withDynamicDocuments: {
type: Boolean,
required: false,
default: false
},
withDocumentsList: {
type: Boolean,
required: false,
@@ -938,12 +976,15 @@ export default {
language () {
return this.locale.split('-')[0].toLowerCase()
},
withPrefillable () {
if (this.template.fields) {
return this.template.fields.some((f) => f.prefillable)
} else {
return false
}
undefinedSubmitters () {
return this.template.submitters.filter((submitter) => {
return !submitter.invite_by_uuid &&
!submitter.optional_invite_by_uuid &&
!submitter.invite_via_field_uuid &&
!submitter.linked_to_uuid &&
!submitter.is_requester &&
!submitter.email
})
},
isInlineSize () {
return CSS.supports('container-type: size')
@@ -1123,6 +1164,32 @@ export default {
this.drawCustomField = null
this.showDrawField = false
},
onRemoveField (field) {
if (this.dynamicDocuments.length) {
field.areas?.forEach((area) => {
this.documentRefs.forEach((documentRef) => {
if (documentRef.isDynamic && documentRef.document.uuid === area.attachment_uuid) {
documentRef.removeArea(area)
}
})
})
}
},
onRemoveSubmitter (submitter) {
if (this.dynamicDocuments.length) {
this.template.fields.forEach((field) => {
if (field.submitter_uuid === submitter.uuid) {
field.areas?.forEach((area) => {
this.documentRefs.forEach((documentRef) => {
if (documentRef.isDynamic && documentRef.document.uuid === area.attachment_uuid) {
documentRef.removeArea(area)
}
})
})
}
})
}
},
toggleSelectMode () {
this.isSelectModeRef.value = !this.isSelectModeRef.value
@@ -2480,60 +2547,80 @@ export default {
this.save()
},
removeAreasByAttachmentUuid (attachmentUuid) {
const removedFieldUuids = []
this.selectedAreasRef.value = this.selectedAreasRef.value.filter((area) => area.attachment_uuid !== attachmentUuid)
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === attachmentUuid) {
field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid)
}
})
})
this.template.fields = this.template.fields.reduce((acc, field) => {
if (removedFieldUuids.includes(field.uuid) && !field.areas?.length) {
this.removeFieldConditions(field)
} else {
acc.push(field)
}
return acc
}, [])
},
onDocumentRemove (item) {
if (window.confirm(this.t('are_you_sure_'))) {
this.template.schema.splice(this.template.schema.indexOf(item), 1)
const removedFieldUuids = []
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === item.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid)
}
})
})
this.template.fields = this.template.fields.reduce((acc, f) => {
if (removedFieldUuids.includes(f.uuid) && !f.areas?.length) {
this.removeFieldConditions(f)
} else {
acc.push(f)
}
return acc
}, [])
this.removeAreasByAttachmentUuid(item.attachment_uuid)
this.save()
}
},
onDocumentReplace (data) {
const { replaceSchemaItem, schema, documents } = data
const isReplacingDynamicDocument = !!replaceSchemaItem.dynamic
// eslint-disable-next-line camelcase
const { google_drive_file_id, ...cleanedReplaceSchemaItem } = replaceSchemaItem
const { google_drive_file_id, dynamic, ...cleanedReplaceSchemaItem } = replaceSchemaItem
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...cleanedReplaceSchemaItem, ...schema[0] })
this.template.documents.push(...documents)
if (isReplacingDynamicDocument) {
this.removeAreasByAttachmentUuid(replaceSchemaItem.attachment_uuid)
const dynamicDocumentIndex = this.dynamicDocuments.findIndex((doc) => doc.uuid === replaceSchemaItem.attachment_uuid)
if (dynamicDocumentIndex !== -1) {
this.dynamicDocuments.splice(dynamicDocumentIndex, 1)
}
}
if (data.fields) {
this.template.fields = data.fields
const removedFieldUuids = []
if (isReplacingDynamicDocument) {
this.removeAreasByAttachmentUuid(replaceSchemaItem.attachment_uuid)
} else {
const removedFieldUuids = []
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1)
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid)
}
removedFieldUuids.push(field.uuid)
}
})
})
})
this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
}
}
if (data.submitters) {
@@ -2544,13 +2631,15 @@ export default {
}
}
this.template.fields.forEach((field) => {
(field.areas || []).forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
area.attachment_uuid = schema[0].attachment_uuid
}
if (!isReplacingDynamicDocument) {
this.template.fields.forEach((field) => {
(field.areas || []).forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
area.attachment_uuid = schema[0].attachment_uuid
}
})
})
})
}
if (this.onUpload) {
this.onUpload(this.template)
@@ -2646,7 +2735,15 @@ export default {
} else {
this.isSaving = true
this.save().then(() => {
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
dynamicDocumentRefs.map((ref) => ref.update())
this.rebuildVariablesSchema({ disable: false })
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
Promise.all([this.save(), ...dynamicDocumentSaves]).then(() => {
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
@@ -2657,9 +2754,13 @@ export default {
scrollToArea (area) {
const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid)
documentRef.scrollToArea(area)
if (documentRef.isDynamic) {
this.selectedAreasRef.value = []
} else {
this.selectedAreasRef.value = [area]
}
this.selectedAreasRef.value = [area]
documentRef.scrollToArea(area)
},
baseFetch (path, options = {}) {
return fetch(this.baseUrl + path, {
@@ -2885,7 +2986,8 @@ export default {
name: this.template.name,
schema: this.template.schema,
submitters: this.template.submitters,
fields: this.template.fields
fields: this.template.fields,
variables_schema: this.template.variables_schema
}
}),
headers: { 'Content-Type': 'application/json' }
@@ -2894,6 +2996,44 @@ export default {
this.onSave(this.template)
}
})
},
onDynamicDocumentUpdate () {
this.rebuildVariablesSchema()
this.$nextTick(() => {
if (this.$el.closest('template-builder')) {
this.$el.closest('template-builder').dataset.dynamicDocuments = JSON.stringify(this.dynamicDocuments)
}
})
this.save()
},
rebuildVariablesSchema ({ disable = true } = {}) {
const dynamicDocumentRef = this.documentRefs.find((e) => e.isDynamic)
if (dynamicDocumentRef) {
const parsed = {}
this.documentRefs.forEach((ref) => {
if (ref.isDynamic) {
ref.updateVariablesSchema()
}
})
this.dynamicDocuments.forEach((doc) => {
if (doc.variables_schema) {
dynamicDocumentRef.mergeSchemaProperties(parsed, doc.variables_schema)
}
})
if (!this.template.variables_schema) {
this.template.variables_schema = parsed
} else {
dynamicDocumentRef.syncVariablesSchema(this.template.variables_schema, parsed, { disable })
}
} else {
this.template.variables_schema = {}
}
}
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf, application/zip'
default: 'image/*, application/pdf, application/zip, application/json'
},
withReplaceButton: {
type: Boolean,
+1 -1
View File
@@ -168,7 +168,7 @@ export default {
return this.previewImagesIndex[i] || reactive({
metadata: { ...lazyloadMetadata },
id: Math.random().toString(),
url: this.basePreviewUrl + `/preview/${this.document.signed_uuid || this.document.uuid}/${i}.jpg`
url: this.basePreviewUrl + `/preview/${this.document.signed_key || this.document.signed_uuid || this.document.uuid}/${i}.jpg`
})
})
},
+3 -3
View File
@@ -122,7 +122,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf, application/zip'
default: 'image/*, application/pdf, application/zip, application/json'
}
},
emits: ['success', 'error', 'loading', 'click-google-drive'],
@@ -146,7 +146,7 @@ export default {
message () {
if (this.isLoading) {
return this.t('uploading')
} else if (this.acceptFileTypes === 'image/*, application/pdf, application/zip') {
} else if (this.acceptFileTypes === 'image/*, application/pdf, application/zip, application/json') {
return this.title || this.t('add_pdf_documents_or_images')
} else {
return this.title || this.t('add_documents_or_images')
@@ -161,7 +161,7 @@ export default {
methods: {
upload: Upload.methods.upload,
onDropFiles (e) {
if (this.acceptFileTypes !== 'image/*, application/pdf, application/zip' || [...e.dataTransfer.files].every((f) => f.type.match(/(?:image\/)|(?:application\/pdf)|(?:application\/zip)/))) {
if (this.acceptFileTypes !== 'image/*, application/pdf, application/zip, application/json' || [...e.dataTransfer.files].every((f) => f.type.match(/(?:image\/)|(?:application\/pdf)|(?:application\/zip)|(?:application\/json)/))) {
this.$refs.input.files = e.dataTransfer.files
this.upload()
@@ -0,0 +1,289 @@
<template>
<span
class="items-center select-none cursor-pointer relative overflow-visible text-base-content/80 font-sans"
:class="[bgColorClass, iconOnlyField ? 'justify-center' : '']"
:draggable="editable"
:style="[nodeStyle]"
@mousedown="selectArea"
@dragstart="onDragStart"
@contextmenu.prevent.stop="onContextMenu"
>
<span
class="absolute inset-0 pointer-events-none border-solid"
:class="borderColorClass"
:style="{ borderWidth: (isSelected ? 1 : 0) + 'px' }"
/>
<component
:is="fieldIcons[field?.type || 'text']"
v-if="field && !field.default_value"
width="100%"
height="100%"
:stroke-width="1.5"
:class="iconOnlyField ? 'shrink min-h-0 max-h-full max-w-6 opacity-70 m-auto p-0.5' : 'shrink min-h-0 max-h-full max-w-4 opacity-70 mx-0.5 pl-0.5'"
/>
<span
v-if="field?.default_value"
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 font-normal pl-0.5"
>
<template v-if="field.default_value === '{{date}}'">
{{ t('signing_date') }}
</template>
<template v-else>
{{ field.default_value }}
</template>
</span>
<span
v-else-if="field && !iconOnlyField"
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 opacity-70 font-normal pl-0.5"
>{{ displayLabel }}</span>
<span
class="absolute rounded-full bg-white border border-gray-400 shadow-md cursor-nwse-resize z-10"
:style="{ width: resizeHandleSize + 'px', height: resizeHandleSize + 'px', right: (-4 / zoom) + 'px', bottom: (-4 / zoom) + 'px' }"
@pointerdown.prevent.stop="onResizeStart"
/>
</span>
</template>
<script>
import FieldArea from './area'
import FieldType from './field_type'
export default {
name: 'DynamicArea',
props: {
fieldUuid: {
type: String,
required: true
},
areaUuid: {
type: String,
required: true
},
template: {
type: Object,
required: true
},
nodeStyle: {
type: Object,
required: true
},
selectedAreasRef: {
type: Object,
required: true
},
getPos: {
type: Function,
required: true
},
editor: {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
getZoom: {
type: Function,
required: true
},
onAreaContextMenu: {
type: Function,
required: true
},
onAreaResize: {
type: Function,
required: true
},
onAreaDragStart: {
type: Function,
required: true
},
t: {
type: Function,
required: true
},
findFieldArea: {
type: Function,
required: true
},
getFieldTypeIndex: {
type: Function,
required: true
}
},
data () {
return {
isResizing: false
}
},
computed: {
fieldArea () {
return this.findFieldArea(this.areaUuid)
},
area () {
return this.fieldArea?.area
},
field () {
return this.fieldArea?.field
},
fieldIcons: FieldArea.computed.fieldIcons,
fieldNames: FieldArea.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
borderColors () {
return [
'border-red-500/80',
'border-sky-500/80',
'border-emerald-500/80',
'border-yellow-300/80',
'border-purple-600/80',
'border-pink-500/80',
'border-cyan-500/80',
'border-orange-500/80',
'border-lime-500/80',
'border-indigo-500/80'
]
},
bgColors () {
return [
'bg-red-100',
'bg-sky-100',
'bg-emerald-100',
'bg-yellow-100',
'bg-purple-100',
'bg-pink-100',
'bg-cyan-100',
'bg-orange-100',
'bg-lime-100',
'bg-indigo-100'
]
},
isSelected () {
return this.selectedAreasRef.value.some((a) => a === this.area)
},
zoom () {
return this.getZoom()
},
submitterIndex () {
if (!this.field) return 0
const submitter = this.template.submitters.find((s) => s.uuid === this.field.submitter_uuid)
return submitter ? this.template.submitters.indexOf(submitter) : 0
},
borderColorClass () {
return this.borderColors[this.submitterIndex % this.borderColors.length]
},
bgColorClass () {
return this.bgColors[this.submitterIndex % this.bgColors.length]
},
resizeHandleSize () {
return this.zoom > 0 ? Math.round(10 / this.zoom) : 10
},
iconOnlyField () {
return ['radio', 'multiple', 'checkbox', 'initials'].includes(this.field?.type)
},
defaultName () {
if (!this.field) return 'text'
const typeIndex = this.getFieldTypeIndex(this.field)
return `${this.fieldLabels[this.field.type] || this.fieldNames[this.field.type] || this.field.type} ${typeIndex + 1}`
},
displayLabel () {
return this.field?.name || this.defaultName
}
},
methods: {
selectArea () {
this.editor.commands.setNodeSelection(this.getPos())
},
onDragStart (e) {
if (this.isResizing) {
e.preventDefault()
return
}
const pos = this.getPos()
if (pos == null) {
e.preventDefault()
return
}
const root = this.$el
const rect = root.getBoundingClientRect()
const zoom = this.zoom || 1
const clone = root.cloneNode(true)
clone.querySelector('[class*="cursor-nwse-resize"]')?.remove()
clone.style.cssText = `position:fixed;top:-1000px;width:${rect.width / zoom}px;height:${rect.height / zoom}px;display:${root.style.display};vertical-align:${root.style.verticalAlign};zoom:${zoom}`
document.body.appendChild(clone)
e.dataTransfer.setDragImage(clone, e.offsetX, e.offsetY)
requestAnimationFrame(() => clone.remove())
e.dataTransfer.effectAllowed = 'move'
this.onAreaDragStart()
},
onContextMenu (e) {
this.selectArea()
this.onAreaContextMenu(this.area, e)
},
onResizeStart (e) {
if (!this.editable) return
this.isResizing = true
this.selectArea()
const handle = e.target
handle.setPointerCapture(e.pointerId)
const startX = e.clientX
const startY = e.clientY
const startWidth = this.$el.offsetWidth
const startHeight = this.$el.offsetHeight
const onResizeMove = (e) => {
e.preventDefault()
this.nodeStyle.width = startWidth + (e.clientX - startX) / this.zoom + 'px'
this.nodeStyle.height = startHeight + (e.clientY - startY) / this.zoom + 'px'
this.onAreaResize(this.$el.getBoundingClientRect())
}
const onResizeEnd = () => {
if (!this.isResizing) return
this.isResizing = false
handle.removeEventListener('pointermove', onResizeMove)
handle.removeEventListener('pointerup', onResizeEnd)
const pos = this.getPos()
const tr = this.editor.view.state.tr.setNodeMarkup(pos, undefined, {
...this.editor.view.state.doc.nodeAt(pos)?.attrs,
width: this.nodeStyle.width,
height: this.nodeStyle.height
})
this.editor.view.dispatch(tr)
this.editor.commands.setNodeSelection(pos)
}
handle.addEventListener('pointermove', onResizeMove)
handle.addEventListener('pointerup', onResizeEnd)
}
}
}
</script>
@@ -0,0 +1,316 @@
<template>
<div
ref="container"
class="relative"
style="container-type: inline-size;"
>
<div ref="shadow" />
<template
v-for="style in styles"
:key="style.innerText"
>
<Teleport
v-if="shadow"
:to="style.innerText.includes('@font-face {') ? 'head' : shadow"
>
<component :is="'style'">
{{ style.innerText }}
</component>
</Teleport>
</template>
<Teleport
v-if="shadow"
:to="shadow"
>
<DynamicSection
v-for="section in sections"
:ref="setSectionRefs"
:key="section.id"
:container="$refs.container"
:editable="editable"
:section="section"
:section-refs="sectionRefs"
:container-width="containerWidth"
:attachments-index="attachmentsIndex"
:selected-submitter="selectedSubmitter"
:drag-field="dragField"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField"
:render-html-for-save-ref="renderHtmlForSaveRef"
:draw-option="drawOption"
:attachment-uuid="document.uuid"
@update="onSectionUpdate(section, $event)"
@draw="$emit('draw', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)"
/>
</Teleport>
</div>
</template>
<script>
import { ref } from 'vue'
import DynamicSection from './dynamic_section.vue'
import { dynamicStylesheet, tiptapStylesheet } from './dynamic_editor.js'
import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js'
export default {
name: 'TemplateDynamicDocument',
components: {
DynamicSection
},
inject: ['baseFetch', 'template'],
props: {
document: {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
selectedSubmitter: {
type: Object,
required: false,
default: null
},
dragField: {
type: Object,
required: false,
default: null
},
drawField: {
type: Object,
required: false,
default: null
},
drawFieldType: {
type: String,
required: false,
default: ''
},
drawCustomField: {
type: Object,
required: false,
default: null
},
drawOption: {
type: Object,
required: false,
default: null
}
},
emits: ['update', 'draw', 'set-draw', 'add-custom-field'],
data () {
return {
containerWidth: 1040,
isMounted: false,
sectionRefs: []
}
},
computed: {
renderHtmlForSaveRef: () => ref(false),
isDynamic () {
return true
},
attachmentsIndex () {
return (this.document.attachments || []).reduce((acc, att) => {
acc[att.uuid] = att.url
return acc
}, {})
},
bodyDom () {
return new DOMParser().parseFromString(this.document.body, 'text/html')
},
headDom () {
return new DOMParser().parseFromString(this.document.head, 'text/html')
},
sections () {
return this.bodyDom.querySelectorAll('section')
},
styles () {
return this.headDom.querySelectorAll('style')
},
shadow () {
if (this.isMounted) {
return this.$refs.shadow.attachShadow({ mode: 'open' })
} else {
return null
}
}
},
mounted () {
this.isMounted = true
this.shadow.adoptedStyleSheets.push(dynamicStylesheet, tiptapStylesheet)
this.containerWidth = this.$refs.container.clientWidth
this.resizeObserver = new ResizeObserver(() => {
if (this.$refs.container) {
this.containerWidth = this.$refs.container.clientWidth
}
})
this.resizeObserver.observe(this.$refs.container)
window.addEventListener('beforeunload', this.onBeforeUnload)
},
beforeUnmount () {
window.removeEventListener('beforeunload', this.onBeforeUnload)
this.resizeObserver.unobserve(this.$refs.container)
},
beforeUpdate () {
this.sectionRefs = []
},
methods: {
mergeSchemaProperties,
removeArea (area) {
this.sectionRefs.forEach((sectionRef) => {
const pos = sectionRef.findAreaNodePos(area.uuid)
if (pos !== -1) {
sectionRef.editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run()
}
})
},
setSectionRefs (ref) {
if (ref) {
this.sectionRefs.push(ref)
}
},
onBeforeUnload (event) {
if (this.saveTimer) {
event.preventDefault()
event.returnValue = ''
return ''
}
},
scrollToArea (area) {
this.sectionRefs.forEach(({ editor }) => editor.commands.setNodeSelection(0))
this.sectionRefs.forEach(({ editor }) => {
const el = editor.view.dom.querySelector(`[data-area-uuid="${area.uuid}"]`)
if (el) {
editor.commands.setNodeSelection(editor.view.posAtDOM(el, 0))
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
},
onSectionUpdate (section, { editor }) {
clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(async () => {
await this.updateSectionAndSave(section, editor)
delete this.saveTimer
}, 1000)
},
updateVariablesSchema () {
this.document.variables_schema = buildVariablesSchema(this.bodyDom.body)
},
updateSectionAndSave (section, editor) {
const target = this.bodyDom.getElementById(section.id)
if (target) {
target.innerHTML = this.getHtmlForSave(editor)
}
this.document.body = this.bodyDom.body.innerHTML
this.updateVariablesSchema()
this.$emit('update', this.document)
return this.saveBody()
},
updateAndSave () {
this.update()
return this.saveBody()
},
update () {
clearTimeout(this.saveTimer)
delete this.saveTimer
this.sectionRefs.forEach(({ section, editor }) => {
const target = this.bodyDom.getElementById(section.id)
target.innerHTML = this.getHtmlForSave(editor)
})
this.document.body = this.bodyDom.body.innerHTML
this.updateVariablesSchema()
this.$emit('update', this.document)
},
getHtmlForSave (editor) {
this.renderHtmlForSaveRef.value = true
const result = editor.getHTML()
this.renderHtmlForSaveRef.value = false
return result
},
saveBody () {
clearTimeout(this.saveTimer)
delete this.saveTimer
return this.baseFetch(`/templates/${this.template.id}/dynamic_documents/${this.document.uuid}`, {
method: 'PUT',
body: JSON.stringify({ body: this.bodyDom.body.innerHTML }),
headers: { 'Content-Type': 'application/json' }
})
},
syncVariablesSchema (existing, parsed, { disable = true } = {}) {
for (const key of Object.keys(parsed)) {
if (!existing[key]) {
existing[key] = parsed[key]
}
}
for (const key of Object.keys(existing)) {
if (!parsed[key]) {
if (disable) {
existing[key].disabled = true
} else {
delete existing[key]
}
} else {
delete existing[key].disabled
if (!existing[key].form_type) {
existing[key].type = parsed[key].type
}
if (parsed[key].items) {
if (!existing[key].items) {
existing[key].items = parsed[key].items
} else if (existing[key].items.properties && parsed[key].items.properties) {
this.syncVariablesSchema(existing[key].items.properties, parsed[key].items.properties, { disable })
} else if (!existing[key].items.properties && !parsed[key].items.properties) {
existing[key].items.type = parsed[key].items.type
}
}
if (existing[key].properties && parsed[key].properties) {
this.syncVariablesSchema(existing[key].properties, parsed[key].properties, { disable })
}
}
}
}
}
}
</script>
@@ -0,0 +1,785 @@
import { Editor, Extension, Node, Mark } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import Document from '@tiptap/extension-document'
import Text from '@tiptap/extension-text'
import HardBreak from '@tiptap/extension-hard-break'
import History from '@tiptap/extension-history'
import Gapcursor from '@tiptap/extension-gapcursor'
import Dropcursor from '@tiptap/extension-dropcursor'
import { createApp, reactive } from 'vue'
import DynamicArea from './dynamic_area.vue'
import styles from './dynamic_styles.scss'
export const dynamicStylesheet = new CSSStyleSheet()
dynamicStylesheet.replaceSync(styles[0][1])
export const tiptapStylesheet = new CSSStyleSheet()
tiptapStylesheet.replaceSync(
`.ProseMirror {
word-wrap: break-word;
white-space: pre-wrap;
white-space: break-spaces;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0;
}
.ProseMirror [contenteditable="false"] {
white-space: normal;
}
.ProseMirror [contenteditable="false"] [contenteditable="true"] {
white-space: pre-wrap;
}
.ProseMirror pre {
white-space: pre-wrap;
}
img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
width: 0 !important;
height: 0 !important;
}
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
margin: 0;
}
.ProseMirror-gapcursor:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-hideselection *::selection {
background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection * {
caret-color: transparent;
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
dynamic-variable {
background-color: #fef3c7;
}`)
function collectDomAttrs (dom) {
const attrs = {}
for (let i = 0; i < dom.attributes.length; i++) {
attrs[dom.attributes[i].name] = dom.attributes[i].value
}
return { htmlAttrs: attrs }
}
function collectSpanDomAttrs (dom) {
const result = collectDomAttrs(dom)
if (result.htmlAttrs.style) {
const temp = document.createElement('span')
temp.style.cssText = result.htmlAttrs.style
if (['bold', '700'].includes(temp.style.fontWeight)) {
temp.style.removeProperty('font-weight')
}
if (temp.style.fontStyle === 'italic') {
temp.style.removeProperty('font-style')
}
if (temp.style.textDecoration === 'underline') {
temp.style.removeProperty('text-decoration')
}
if (temp.style.cssText) {
result.htmlAttrs.style = temp.style.cssText
} else {
delete result.htmlAttrs.style
}
}
return result
}
function createBlockNode (name, tag, content) {
return Node.create({
name,
group: 'block',
content: content || 'block+',
addAttributes () {
return {
htmlAttrs: { default: {} }
}
},
parseHTML () {
return [{ tag, getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return [tag, node.attrs.htmlAttrs, 0]
}
})
}
const CustomParagraph = Node.create({
name: 'paragraph',
group: 'block',
content: 'inline*',
addAttributes () {
return {
htmlAttrs: { default: {} }
}
},
parseHTML () {
return [{ tag: 'p', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['p', node.attrs.htmlAttrs, 0]
}
})
const CustomHeading = Node.create({
name: 'heading',
group: 'block',
content: 'inline*',
addAttributes () {
return {
htmlAttrs: { default: {} },
level: { default: 1 }
}
},
parseHTML () {
return [1, 2, 3, 4, 5, 6].map((level) => ({
tag: `h${level}`,
getAttrs: (dom) => ({ ...collectDomAttrs(dom), level })
}))
},
renderHTML ({ node }) {
return [`h${node.attrs.level}`, node.attrs.htmlAttrs, 0]
}
})
const SectionNode = createBlockNode('section', 'section')
const ArticleNode = createBlockNode('article', 'article')
const DivNode = createBlockNode('div', 'div')
const BlockquoteNode = createBlockNode('blockquote', 'blockquote')
const PreNode = createBlockNode('pre', 'pre')
const OrderedListNode = createBlockNode('orderedList', 'ol', '(listItem | block)+')
const BulletListNode = createBlockNode('bulletList', 'ul', '(listItem | block)+')
const ListItemNode = Node.create({
name: 'listItem',
content: 'block+',
addAttributes () {
return {
htmlAttrs: { default: {} }
}
},
parseHTML () {
return [{ tag: 'li', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['li', node.attrs.htmlAttrs, 0]
}
})
const TableNode = Node.create({
name: 'table',
group: 'block',
content: '(colgroup | tableHead | tableBody | tableRow)+',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'table', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['table', node.attrs.htmlAttrs, 0]
}
})
const TableHead = Node.create({
name: 'tableHead',
content: 'tableRow+',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'thead', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['thead', node.attrs.htmlAttrs, 0]
}
})
const TableBody = Node.create({
name: 'tableBody',
content: 'tableRow+',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'tbody', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['tbody', node.attrs.htmlAttrs, 0]
}
})
const TableRow = Node.create({
name: 'tableRow',
content: '(tableCell | tableHeader)+',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'tr', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['tr', node.attrs.htmlAttrs, 0]
}
})
const TableCell = Node.create({
name: 'tableCell',
content: 'block*',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'td', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['td', node.attrs.htmlAttrs, 0]
}
})
const TableHeader = Node.create({
name: 'tableHeader',
content: 'block*',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'th', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['th', node.attrs.htmlAttrs, 0]
}
})
const ImageNode = Node.create({
name: 'image',
inline: true,
group: 'inline',
draggable: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'img', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['img', node.attrs.htmlAttrs]
}
})
const ColGroupNode = Node.create({
name: 'colgroup',
group: 'block',
content: 'col*',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'colgroup', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['colgroup', node.attrs.htmlAttrs, 0]
}
})
const ColNode = Node.create({
name: 'col',
group: 'block',
atom: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'col', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['col', node.attrs.htmlAttrs]
}
})
const CustomBold = Mark.create({
name: 'bold',
parseHTML () {
return [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }, { style: 'font-weight=700' }]
},
renderHTML () {
return ['strong', 0]
},
addCommands () {
return {
toggleBold: () => ({ commands }) => commands.toggleMark(this.name)
}
},
addKeyboardShortcuts () {
return {
'Mod-b': () => this.editor.commands.toggleBold()
}
}
})
const CustomItalic = Mark.create({
name: 'italic',
parseHTML () {
return [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }]
},
renderHTML () {
return ['em', 0]
},
addCommands () {
return {
toggleItalic: () => ({ commands }) => commands.toggleMark(this.name)
}
},
addKeyboardShortcuts () {
return {
'Mod-i': () => this.editor.commands.toggleItalic()
}
}
})
const CustomUnderline = Mark.create({
name: 'underline',
parseHTML () {
return [{ tag: 'u' }, { style: 'text-decoration=underline' }]
},
renderHTML () {
return ['u', 0]
},
addCommands () {
return {
toggleUnderline: () => ({ commands }) => commands.toggleMark(this.name)
}
},
addKeyboardShortcuts () {
return {
'Mod-u': () => this.editor.commands.toggleUnderline()
}
}
})
const CustomStrike = Mark.create({
name: 'strike',
parseHTML () {
return [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }]
},
renderHTML () {
return ['s', 0]
},
addCommands () {
return {
toggleStrike: () => ({ commands }) => commands.toggleMark(this.name)
}
},
addKeyboardShortcuts () {
return {
'Mod-Shift-s': () => this.editor.commands.toggleStrike()
}
}
})
const EmptySpanNode = Node.create({
name: 'emptySpan',
inline: true,
group: 'inline',
atom: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{
tag: 'span',
priority: 60,
getAttrs (dom) {
if (dom.childNodes.length === 0 && dom.attributes.length > 0) {
return collectDomAttrs(dom)
}
return false
}
}]
},
renderHTML ({ node }) {
return ['span', node.attrs.htmlAttrs]
}
})
const SpanMark = Mark.create({
name: 'span',
excludes: '',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'span', getAttrs: collectSpanDomAttrs }]
},
renderHTML ({ mark }) {
return ['span', mark.attrs.htmlAttrs, 0]
}
})
const LinkMark = Mark.create({
name: 'link',
excludes: '',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'a', getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return ['a', mark.attrs.htmlAttrs, 0]
}
})
const SubscriptMark = Mark.create({
name: 'subscript',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'sub', getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return ['sub', mark.attrs.htmlAttrs, 0]
}
})
const SuperscriptMark = Mark.create({
name: 'superscript',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'sup', getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return ['sup', mark.attrs.htmlAttrs, 0]
}
})
const TabHandler = Extension.create({
name: 'tabHandler',
addKeyboardShortcuts () {
return {
Tab: () => {
this.editor.commands.insertContent('\t')
return true
}
}
}
})
const variableHighlightKey = new PluginKey('variableHighlight')
function buildDecorations (doc) {
const decorations = []
const regex = /\[\[[^\]]*\]\]/g
doc.descendants((node, pos) => {
if (!node.isText) return
let match
while ((match = regex.exec(node.text)) !== null) {
const from = pos + match.index
const to = from + match[0].length
decorations.push(Decoration.inline(from, to, { nodeName: 'dynamic-variable' }))
}
})
return DecorationSet.create(doc, decorations)
}
const VariableHighlight = Extension.create({
name: 'variableHighlight',
addProseMirrorPlugins () {
return [
new Plugin({
key: variableHighlightKey,
state: {
init (_, { doc }) {
return buildDecorations(doc)
},
apply (tr, oldSet) {
if (tr.docChanged) {
return buildDecorations(tr.doc)
}
return oldSet
}
},
props: {
decorations (state) {
return this.getState(state)
},
handleTextInput (view, from, to, text) {
if (text !== '[') return false
const { state } = view
const charBefore = state.doc.textBetween(Math.max(from - 1, 0), from)
if (charBefore !== '[') return false
const tr = state.tr.insertText('[]]', from, to)
tr.setSelection(state.selection.constructor.create(tr.doc, from + 1))
view.dispatch(tr)
return true
}
}
})
]
}
})
export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlForSaveRef, onFieldDrop, onFieldDestroy, editorOptions }) {
const FieldNode = Node.create({
name: 'fieldNode',
inline: true,
group: 'inline',
atom: true,
draggable: true,
addAttributes () {
return {
uuid: { default: null },
areaUuid: { default: null },
width: { default: '124px' },
height: { default: null },
verticalAlign: { default: 'text-bottom' },
display: { default: 'inline-flex' }
}
},
parseHTML () {
return [{
tag: 'dynamic-field',
getAttrs (dom) {
return {
uuid: dom.getAttribute('uuid'),
areaUuid: dom.getAttribute('area-uuid'),
width: dom.style.width,
height: dom.style.height,
display: dom.style.display,
verticalAlign: dom.style.verticalAlign
}
}
}]
},
renderHTML ({ node }) {
const attrs = {
uuid: node.attrs.uuid,
'area-uuid': node.attrs.areaUuid,
style: `width: ${node.attrs.width}; height: ${node.attrs.height}; display: ${node.attrs.display}; vertical-align: ${node.attrs.verticalAlign};`
}
if (!renderHtmlForSaveRef.value) {
const fieldArea = dynamicAreaProps.findFieldArea(node.attrs.areaUuid)
if (fieldArea?.field && fieldArea?.area) {
const field = JSON.parse(JSON.stringify(fieldArea.field))
const area = JSON.parse(JSON.stringify(fieldArea.area))
delete field.areas
delete field.uuid
delete field.submitter_uuid
delete area.uuid
delete area.attachment_uuid
attrs['data-field'] = JSON.stringify(field)
attrs['data-area'] = JSON.stringify(area)
attrs['data-template-id'] = dynamicAreaProps.template.id
}
}
return ['dynamic-field', attrs]
},
addNodeView () {
return ({ node, getPos, editor }) => {
const dom = document.createElement('span')
const nodeStyle = reactive({
width: node.attrs.width,
height: node.attrs.height,
verticalAlign: node.attrs.verticalAlign,
display: node.attrs.display
})
dom.dataset.areaUuid = node.attrs.areaUuid
const shadow = dom.attachShadow({ mode: 'open' })
shadow.adoptedStyleSheets = [dynamicStylesheet]
const app = createApp(DynamicArea, {
fieldUuid: node.attrs.uuid,
areaUuid: node.attrs.areaUuid,
nodeStyle,
getPos,
editor,
editable: editorOptions.editable,
...dynamicAreaProps
})
app.mount(shadow)
return {
dom,
update (updatedNode) {
if (updatedNode.attrs.areaUuid === node.attrs.areaUuid) {
nodeStyle.width = updatedNode.attrs.width
nodeStyle.height = updatedNode.attrs.height
nodeStyle.verticalAlign = updatedNode.attrs.verticalAlign
nodeStyle.display = updatedNode.attrs.display
}
},
destroy () {
onFieldDestroy(node)
app.unmount()
}
}
}
}
})
const FieldDropPlugin = Extension.create({
name: 'fieldDrop',
addProseMirrorPlugins () {
return [
new Plugin({
key: new PluginKey('fieldDrop'),
props: {
handleDrop: onFieldDrop
}
})
]
}
})
const DynamicImageNode = ImageNode.extend({
renderHTML ({ node }) {
const { loading, ...attrs } = node.attrs.htmlAttrs
return ['img', attrs]
},
addNodeView () {
return ({ node }) => {
const dom = document.createElement('img')
const attrs = { ...node.attrs.htmlAttrs }
const blobUuid = attrs.src?.startsWith('blob:') && attrs.src.slice(5)
if (blobUuid && attachmentsIndex[blobUuid]) {
attrs.src = attachmentsIndex[blobUuid]
}
dom.setAttribute('loading', 'lazy')
Object.entries(attrs).forEach(([k, v]) => dom.setAttribute(k, v))
return { dom }
}
}
})
return new Editor({
extensions: [
Document,
Text,
HardBreak,
History,
Gapcursor,
Dropcursor,
CustomBold,
CustomItalic,
CustomUnderline,
CustomStrike,
CustomParagraph,
CustomHeading,
SectionNode,
ArticleNode,
DivNode,
BlockquoteNode,
PreNode,
OrderedListNode,
BulletListNode,
ListItemNode,
TableNode,
TableHead,
TableBody,
TableRow,
TableCell,
TableHeader,
ColGroupNode,
ColNode,
DynamicImageNode,
EmptySpanNode,
LinkMark,
SpanMark,
SubscriptMark,
SuperscriptMark,
VariableHighlight,
TabHandler,
FieldNode,
FieldDropPlugin
],
editorProps: {
attributes: {
style: 'outline: none'
}
},
parseOptions: {
preserveWhitespace: true
},
injectCSS: false,
...editorOptions
})
}
@@ -0,0 +1,211 @@
<template>
<div
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"
:style="{ top: (coords.top - 42) + 'px', left: coords.left + 'px', transform: 'translateX(-50%)' }"
@mousedown.prevent
>
<button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
:class="isBold ? 'bg-base-200' : 'bg-transparent'"
title="Bold"
@click="toggleBold"
>
<IconBold
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
:class="isItalic ? 'bg-base-200' : 'bg-transparent'"
title="Italic"
@click="toggleItalic"
>
<IconItalic
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
:class="isUnderline ? 'bg-base-200' : 'bg-transparent'"
title="Underline"
@click="toggleUnderline"
>
<IconUnderline
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
:class="isStrike ? 'bg-base-200' : 'bg-transparent'"
title="Strikethrough"
@click="toggleStrike"
>
<IconStrikethrough
:width="16"
:height="16"
/>
</button>
<div class="w-px h-5 bg-base-300 mx-1" />
<button
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent"
title="Wrap in variable"
@click="wrapVariable"
>
<IconBracketsContain
:width="16"
:height="16"
:stroke-width="1.6"
/>
<span class="px-0.5">
Variable
</span>
</button>
<div class="w-px h-5 bg-base-300 mx-1" />
<button
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent"
title="Wrap in condition"
@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="px-0.5">
Condition
</span>
</button>
</div>
</template>
<script>
import { IconBold, IconItalic, IconUnderline, IconStrikethrough, IconBracketsContain } from '@tabler/icons-vue'
export default {
name: 'DynamicMenu',
components: {
IconBold,
IconItalic,
IconUnderline,
IconStrikethrough,
IconBracketsContain
},
props: {
editor: {
type: Object,
required: true
},
coords: {
type: Object,
required: false,
default: null
}
},
emits: ['add-variable', 'add-condition'],
data () {
return {
isMouseDown: false,
isBold: this.editor.isActive('bold'),
isItalic: this.editor.isActive('italic'),
isUnderline: this.editor.isActive('underline'),
isStrike: this.editor.isActive('strike')
}
},
computed: {
visible () {
return !!this.coords && !this.isMouseDown
}
},
mounted () {
this.editor.view.dom.addEventListener('mousedown', this.onMouseDown)
document.addEventListener('mouseup', this.onMouseUp)
this.editor.on('transaction', this.onTransaction)
},
beforeUnmount () {
if (!this.editor.isDestroyed) {
this.editor.view.dom.removeEventListener('mousedown', this.onMouseDown)
this.editor.off('transaction', this.onTransaction)
}
document.removeEventListener('mouseup', this.onMouseUp)
},
methods: {
toggleBold () {
this.editor.chain().focus().toggleBold().run()
},
toggleItalic () {
this.editor.chain().focus().toggleItalic().run()
},
toggleUnderline () {
this.editor.chain().focus().toggleUnderline().run()
},
toggleStrike () {
this.editor.chain().focus().toggleStrike().run()
},
wrapVariable () {
const { from, to } = this.editor.state.selection
const replacement = '[[variable]]'
const varFrom = from + 2
const varTo = varFrom + 8
this.editor.chain().focus()
.insertContentAt({ from, to }, replacement)
.setTextSelection({ from: varFrom, to: varTo })
.run()
this.$emit('add-variable')
},
wrapCondition () {
const { from, to } = this.editor.state.selection
const endText = '[[end]]'
const ifText = '[[if:variable]]'
this.editor.chain().focus()
.insertContentAt(to, endText)
.insertContentAt(from, ifText)
.setTextSelection({ from: from + 5, to: from + 13 })
.run()
this.$emit('add-condition')
},
onMouseDown () {
this.isMouseDown = true
},
onMouseUp () {
setTimeout(() => {
this.isMouseDown = false
}, 1)
},
onTransaction () {
this.isBold = this.editor.isActive('bold')
this.isItalic = this.editor.isActive('italic')
this.isUnderline = this.editor.isActive('underline')
this.isStrike = this.editor.isActive('strike')
}
}
}
</script>
@@ -0,0 +1,901 @@
<template>
<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="{ 'cursor-crosshair': isDrawMode && editable }"
>
<div
v-if="isDrawMode && editable && cursorHighlightCoords"
class="absolute pointer-events-none z-10 bg-black"
:style="{ width: '1px', height: cursorHighlightCoords.height + 'px', left: cursorHighlightCoords.x + 'px', top: cursorHighlightCoords.y + 'px' }"
/>
<div :style="{ zoom: containerWidth / sectionWidthPx }">
<section
:id="section.id"
ref="editorElement"
dir="auto"
:class="section.classList.value"
:style="section.style.cssText"
/>
</div>
<Teleport
v-if="editor"
:to="container"
>
<div
v-if="areaToolbarCoords && selectedField && selectedArea && !isAreaDrag"
class="absolute z-10"
:style="{ left: areaToolbarCoords.left + 'px', top: areaToolbarCoords.top + 'px' }"
>
<AreaTitle
:area="selectedArea"
:field="selectedField"
:editable="editable"
:template="template"
:selected-areas-ref="selectedAreasRef"
:get-field-type-index="getFieldTypeIndex"
@remove="onRemoveSelectedArea"
@change="onSelectedAreaChange"
/>
</div>
<DynamicMenu
v-if="editable"
v-show="!selectedAreasRef.value.length"
:editor="editor"
:coords="dynamicMenuCoords"
@add-variable="dynamicMenuCoords = null"
@add-condition="dynamicMenuCoords = null"
/>
<FieldContextMenu
v-if="contextMenu && contextMenuField"
:context-menu="contextMenu"
:field="contextMenuField"
:with-copy-to-all-pages="false"
@close="closeContextMenu"
@copy="onContextMenuCopy"
@delete="onContextMenuDelete"
@add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)"
@save="save"
/>
</Teleport>
</div>
</template>
<script>
import { shallowRef } from 'vue'
import { DOMSerializer, Fragment } from '@tiptap/pm/model'
import { v4 } from 'uuid'
import FieldContextMenu from './field_context_menu.vue'
import AreaTitle from './area_title.vue'
import DynamicMenu from './dynamic_menu.vue'
import { buildEditor } from './dynamic_editor.js'
export default {
name: 'DynamicSection',
components: {
DynamicMenu,
FieldContextMenu,
AreaTitle
},
inject: ['template', 'save', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor'],
props: {
section: {
type: Object,
required: true
},
sectionRefs: {
type: Array,
required: false,
default: () => []
},
editable: {
type: Boolean,
required: false,
default: true
},
container: {
type: Object,
required: true
},
containerWidth: {
type: Number,
required: true
},
attachmentsIndex: {
type: Object,
required: false,
default: () => ({})
},
selectedSubmitter: {
type: Object,
required: false,
default: null
},
dragField: {
type: Object,
required: false,
default: null
},
drawField: {
type: Object,
required: false,
default: null
},
drawFieldType: {
type: String,
required: false,
default: ''
},
drawCustomField: {
type: Object,
required: false,
default: null
},
renderHtmlForSaveRef: {
type: Object,
required: false,
default: null
},
drawOption: {
type: Object,
required: false,
default: null
},
attachmentUuid: {
type: String,
required: false,
default: null
}
},
emits: ['update', 'draw', 'set-draw', 'add-custom-field'],
data () {
return {
isAreaDrag: false,
areaToolbarCoords: null,
dynamicMenuCoords: null,
contextMenu: null,
cursorHighlightCoords: null
}
},
computed: {
defaultHeight () {
return CSS.supports('height', '1lh') ? '1lh' : '1em'
},
fieldAreaIndex () {
return (this.template.fields || []).reduce((acc, field) => {
field.areas?.forEach((area) => {
acc[area.uuid] = { area, field }
})
return acc
}, {})
},
defaultSizes () {
return {
checkbox: { width: '18px', height: '18px' },
radio: { width: '18px', height: '18px' },
multiple: { width: '18px', height: '18px' },
signature: { width: '140px', height: '50px' },
initials: { width: '40px', height: '32px' },
stamp: { width: '150px', height: '80px' },
kba: { width: '150px', height: '80px' },
verification: { width: '150px', height: '80px' },
image: { width: '200px', height: '100px' },
date: { width: '100px', height: this.defaultHeight },
datenow: { width: '100px', height: this.defaultHeight },
text: { width: '120px', height: this.defaultHeight },
cells: { width: '120px', height: this.defaultHeight },
file: { width: '120px', height: this.defaultHeight },
payment: { width: '120px', height: this.defaultHeight },
number: { width: '80px', height: this.defaultHeight },
select: { width: '120px', height: this.defaultHeight },
phone: { width: '120px', height: this.defaultHeight }
}
},
editorRef: () => shallowRef(),
editor () {
return this.editorRef.value
},
sectionWidthPx () {
const pt = parseFloat(this.section.style.width)
return pt * (96 / 72)
},
zoom () {
return this.containerWidth / this.sectionWidthPx
},
isDraggingField () {
return !!(this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField)
},
isDrawMode () {
return !!(this.drawField || this.drawCustomField || this.drawFieldType)
},
selectedArea () {
return this.selectedAreasRef.value[0]
},
selectedField () {
if (this.selectedArea) {
return this.fieldAreaIndex[this.selectedArea.uuid]?.field
} else {
return null
}
},
contextMenuField () {
if (this.contextMenu?.areaUuid) {
return this.fieldAreaIndex[this.contextMenu.areaUuid].field
} else {
return null
}
}
},
watch: {
containerWidth () {
this.closeContextMenu()
if (this.dynamicMenuCoords && this.editor && !this.editor.state.selection.empty) {
this.$nextTick(() => this.setDynamicMenuCoords(this.editor))
}
}
},
mounted () {
this.editorRef.value = buildEditor({
dynamicAreaProps: {
template: this.template,
t: this.t,
selectedAreasRef: this.selectedAreasRef,
getFieldTypeIndex: this.getFieldTypeIndex,
findFieldArea: (areaUuid) => this.fieldAreaIndex[areaUuid],
getZoom: () => this.zoom,
onAreaContextMenu: this.onAreaContextMenu,
onAreaResize: this.onAreaResize,
onAreaDragStart: this.onAreaDragStart
},
attachmentsIndex: this.attachmentsIndex,
onFieldDrop: this.onFieldDrop,
onFieldDestroy: this.onFieldDestroy,
renderHtmlForSaveRef: this.renderHtmlForSaveRef,
editorOptions: {
element: this.$refs.editorElement,
editable: this.editable,
content: this.section.innerHTML,
onUpdate: (event) => this.$emit('update', event),
onSelectionUpdate: this.onSelectionUpdate,
onBlur: () => { this.dynamicMenuCoords = null }
}
})
this.editor.view.dom.addEventListener('paste', this.onEditorPaste, true)
this.editor.view.dom.addEventListener('pointerdown', this.onEditorPointerDown, true)
this.editor.view.dom.addEventListener('mousemove', this.onEditorMouseMove)
this.editor.view.dom.addEventListener('mouseleave', this.onEditorMouseLeave)
this.editor.view.dom.addEventListener('keydown', this.onEditorKeyDown)
},
beforeUnmount () {
if (this.editor) {
this.editor.view.dom.removeEventListener('paste', this.onEditorPaste, true)
this.editor.view.dom.removeEventListener('pointerdown', this.onEditorPointerDown, true)
this.editor.view.dom.removeEventListener('mousemove', this.onEditorMouseMove)
this.editor.view.dom.removeEventListener('mouseleave', this.onEditorMouseLeave)
this.editor.view.dom.removeEventListener('keydown', this.onEditorKeyDown)
this.editor.destroy()
}
},
methods: {
findAreaNodePos (areaUuid) {
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
return this.editor.view.posAtDOM(el, 0)
},
getFieldInsertIndex (pos) {
const view = this.editor.view
const fields = this.template.fields || []
if (!fields.length) {
return 0
}
let previousField = null
view.state.doc.nodesBetween(0, pos, (node, nodePos) => {
if (node.type.name !== 'fieldNode') {
return
}
if (nodePos + node.nodeSize <= pos) {
previousField = this.fieldAreaIndex[node.attrs.areaUuid]?.field || previousField
}
})
if (!previousField) {
previousField = this.getPreviousSectionField()
}
if (!previousField) {
return 0
}
const previousFieldIndex = fields.indexOf(previousField)
return previousFieldIndex === -1 ? fields.length : previousFieldIndex + 1
},
insertFieldInTemplate (field, index) {
const currentFieldIndex = this.template.fields.indexOf(field)
if (currentFieldIndex !== -1) {
return currentFieldIndex
}
this.template.fields.splice(index, 0, field)
return index
},
getLastFieldInSection (sectionRef) {
let lastField = null
sectionRef.editor.state.doc.descendants((node) => {
if (node.type.name === 'fieldNode') {
lastField = this.fieldAreaIndex[node.attrs.areaUuid]?.field || lastField
}
})
return lastField
},
getPreviousSectionField () {
const sectionIndex = this.sectionRefs.indexOf(this)
for (let index = sectionIndex - 1; index >= 0; index -= 1) {
const previousField = this.getLastFieldInSection(this.sectionRefs[index])
if (previousField) {
return previousField
}
}
return null
},
removeArea (area) {
const { field } = this.fieldAreaIndex[area.uuid]
const areaIndex = field.areas.indexOf(area)
if (areaIndex !== -1) {
field.areas.splice(areaIndex, 1)
}
if (field.areas.length === 0) {
this.template.fields.splice(this.template.fields.indexOf(field), 1)
}
const pos = this.findAreaNodePos(area.uuid)
this.editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run()
this.save()
},
onSelectionUpdate ({ editor }) {
const { selection } = editor.state
if (selection.node?.type.name === 'fieldNode') {
const { areaUuid } = selection.node.attrs
const field = this.fieldAreaIndex[areaUuid]?.field
if (field) {
const area = field.areas.find((a) => a.uuid === areaUuid)
if (area) {
const dom = editor.view.nodeDOM(selection.from)
const areaEl = dom.shadowRoot.firstElementChild
if (areaEl) {
const rect = areaEl.getBoundingClientRect()
const containerRect = this.container.getBoundingClientRect()
this.areaToolbarCoords = {
left: rect.left - containerRect.left,
top: rect.top - containerRect.top
}
}
this.selectedAreasRef.value = [area]
}
}
} else {
this.areaToolbarCoords = null
this.selectedAreasRef.value = []
if (editor.state.selection.empty) {
this.dynamicMenuCoords = null
} else {
this.setDynamicMenuCoords(editor)
}
}
},
setDynamicMenuCoords (editor) {
const { from, to } = editor.state.selection
const view = editor.view
const start = view.coordsAtPos(from)
const end = view.coordsAtPos(to)
const containerRect = this.container.getBoundingClientRect()
const left = (start.left + end.right) / 2 - containerRect.left
this.dynamicMenuCoords = {
top: Math.min(start.top, end.top) - containerRect.top,
left: Math.max(80, Math.min(left, containerRect.width - 80))
}
},
onFieldDestroy (node) {
this.selectedAreasRef.value = []
const { areaUuid } = node.attrs
let nodeExistsInDoc = false
this.editor.state.doc.descendants((docNode) => {
if (docNode.attrs.areaUuid === areaUuid) {
nodeExistsInDoc = true
return false
}
})
if (nodeExistsInDoc) return
const fieldArea = this.fieldAreaIndex[areaUuid]
if (!fieldArea) return
const field = fieldArea.field
const areaIndex = field.areas.findIndex((a) => a.uuid === areaUuid)
if (areaIndex !== -1) {
field.areas.splice(areaIndex, 1)
}
if (!field.areas?.length) {
this.template.fields.splice(this.template.fields.indexOf(field), 1)
}
this.save()
},
onAreaResize (rect) {
const containerRect = this.container.getBoundingClientRect()
this.areaToolbarCoords = {
left: rect.left - containerRect.left,
top: rect.top - containerRect.top
}
},
onAreaDragStart () {
this.isAreaDrag = true
},
onAreaContextMenu (area, e) {
this.contextMenu = {
x: e.clientX,
y: e.clientY,
areaUuid: area.uuid
}
},
deselectArea () {
this.areaToolbarCoords = null
this.selectedAreasRef.value = []
},
closeContextMenu () {
this.contextMenu = null
},
onEditorMouseLeave () {
this.cursorHighlightCoords = null
},
onEditorKeyDown (event) {
if (event.key === 'Escape') {
this.editor.chain().setNodeSelection(0).blur().run()
this.deselectArea()
}
},
onEditorMouseMove (event) {
if (!this.isDrawMode || !this.editable) {
this.cursorHighlightCoords = null
return
}
const view = this.editor?.view
if (!view) return
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (!pos) {
this.cursorHighlightCoords = null
return
}
const coords = view.coordsAtPos(pos.pos)
const outerRect = this.$el.getBoundingClientRect()
const lineHeight = coords.bottom - coords.top
this.cursorHighlightCoords = {
x: coords.left - outerRect.left,
y: coords.top - outerRect.top,
height: lineHeight
}
},
getFieldNode (areaUuid) {
const pos = this.findAreaNodePos(areaUuid)
return this.editor.state.doc.nodeAt(pos)
},
serializeFieldNodeHtml (areaUuid) {
const node = this.getFieldNode(areaUuid)
if (!node) {
return null
}
const serializer = DOMSerializer.fromSchema(this.editor.state.schema)
const container = document.createElement('div')
container.appendChild(serializer.serializeFragment(Fragment.from(node)))
return container.innerHTML
},
async writeHtmlToClipboard ({ html, text }, clipboardData = null) {
if (clipboardData) {
clipboardData.setData('text/html', html)
clipboardData.setData('text/plain', text || html)
return true
}
if (navigator.clipboard?.write && window.ClipboardItem) {
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([text || html], { type: 'text/plain' })
})
])
return true
}
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text || html)
return true
}
return false
},
async copyFieldAreaToClipboard (areaUuid, clipboardData = null) {
const html = this.serializeFieldNodeHtml(areaUuid)
if (!html) {
return false
}
try {
await this.writeHtmlToClipboard({ html, text: html }, clipboardData)
return true
} catch (e) {
console.error('Failed to copy dynamic field:', e)
return false
}
},
async onContextMenuCopy () {
await this.copyFieldAreaToClipboard(this.contextMenu.areaUuid)
this.closeContextMenu()
},
buildCopiedField (payload) {
const field = JSON.parse(JSON.stringify(payload.field))
const area = {
...JSON.parse(JSON.stringify(payload.area)),
uuid: v4(),
attachment_uuid: this.attachmentUuid
}
if (payload.templateId !== this.template.id) {
delete field.conditions
if (field.preferences) {
delete field.preferences.formula
}
}
const newField = {
...field,
uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
}
if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
const oldOptionUuid = area.option_uuid
const optionsMap = {}
newField.options = field.options.map((opt) => {
const newUuid = v4()
optionsMap[opt.uuid] = newUuid
return { ...opt, uuid: newUuid }
})
area.option_uuid = optionsMap[oldOptionUuid] || newField.options[0]?.uuid
}
return { field: newField, area }
},
onEditorPaste (event) {
const clipboardData = event.clipboardData
if (!clipboardData) {
return
}
const html = clipboardData.getData('text/html')
const text = clipboardData.getData('text/plain')
const clipboardHtml = html || (text.includes('<dynamic-field') ? text : '')
if (!clipboardHtml || !clipboardHtml.includes('data-field=')) {
return
}
const container = document.createElement('div')
container.innerHTML = clipboardHtml
const fieldNodes = [...container.querySelectorAll('dynamic-field[data-field][data-area]')]
if (!fieldNodes.length) {
return
}
event.preventDefault()
const { selection } = this.editor.state
const { from, to } = selection
let insertIndex = this.getFieldInsertIndex(selection.node?.type.name === 'fieldNode' ? to : from)
let lastArea = null
fieldNodes.forEach((fieldNode) => {
const fieldValue = fieldNode.dataset.field
const areaValue = fieldNode.dataset.area
const templateId = fieldNode.dataset.templateId
if (!fieldValue || !areaValue) {
return
}
const { field, area } = this.buildCopiedField({
field: JSON.parse(fieldValue),
area: JSON.parse(areaValue),
templateId: Number(templateId)
})
this.insertFieldInTemplate(field, insertIndex)
insertIndex += 1
fieldNode.setAttribute('uuid', field.uuid)
fieldNode.setAttribute('area-uuid', area.uuid)
fieldNode.removeAttribute('data-field')
fieldNode.removeAttribute('data-area')
fieldNode.removeAttribute('data-template-id')
lastArea = area
})
this.editor.chain().focus().insertContentAt({ from, to }, container.innerHTML).run()
if (lastArea) {
this.editor.commands.setNodeSelection(this.findAreaNodePos(lastArea.uuid))
}
this.closeContextMenu()
this.save()
},
onContextMenuDelete () {
const menu = this.contextMenu
const fieldArea = this.fieldAreaIndex[menu.areaUuid]
if (fieldArea) {
this.removeArea(fieldArea.area)
}
this.closeContextMenu()
this.deselectArea()
},
onRemoveSelectedArea () {
this.removeArea(this.selectedArea)
this.deselectArea()
this.save()
},
onSelectedAreaChange () {
this.save()
},
onFieldDrop (view, event, _slice, moved) {
this.isAreaDrag = false
if (moved) {
return
}
const draggedField = this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField
if (!draggedField) return false
event.preventDefault()
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (!pos) return false
this.insertFieldAtRange({
sourceField: draggedField,
existingField: this.fieldsDragFieldRef?.value,
from: pos.pos
})
this.fieldsDragFieldRef.value = null
this.customDragFieldRef.value = null
return true
},
onEditorPointerDown (event) {
if (!this.isDrawMode || !this.editable || this.isDraggingField) {
return
}
if (event.button === 2) {
return
}
const view = this.editor?.view
if (!view) {
return
}
const selection = view.state.selection
const isTextRangeSelection = !selection.empty && !selection.node
let from = selection.from
let to = selection.to
if (!isTextRangeSelection) {
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (!pos) {
return
}
from = pos.pos
to = pos.pos
}
const sourceField = this.drawField || this.drawCustomField || { type: this.drawFieldType }
event.preventDefault()
event.stopPropagation()
if (this.drawOption && this.drawField) {
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
if (areaWithoutOption && !this.drawField.areas.find((a) => a.option_uuid === this.drawField.options[0].uuid)) {
areaWithoutOption.option_uuid = this.drawField.options[0].uuid
}
}
const inserted = this.insertFieldAtRange({
sourceField,
existingField: this.drawField,
optionUuid: this.drawOption?.uuid,
from,
to
})
if (inserted) {
this.$emit('draw', inserted)
}
},
buildFieldArea ({ optionUuid = null } = {}) {
const area = {
uuid: v4(),
attachment_uuid: this.attachmentUuid
}
if (optionUuid) {
area.option_uuid = optionUuid
}
return area
},
buildNewField (sourceField, area) {
const fieldType = sourceField?.type || 'text'
const newField = {
name: sourceField?.name || '',
uuid: v4(),
required: fieldType !== 'checkbox',
submitter_uuid: this.selectedSubmitter.uuid,
type: fieldType,
areas: [area]
}
if (['select', 'multiple', 'radio'].includes(fieldType)) {
if (sourceField?.options?.length) {
newField.options = sourceField.options.map((opt) => ({
value: typeof opt === 'string' ? opt : opt.value,
uuid: v4()
}))
} else {
newField.options = [{ value: '', uuid: v4() }, { value: '', uuid: v4() }]
}
}
if (fieldType === 'datenow') {
newField.type = 'date'
newField.readonly = true
newField.default_value = '{{date}}'
}
if (['stamp', 'heading', 'strikethrough'].includes(fieldType)) {
newField.readonly = true
if (fieldType === 'strikethrough') {
newField.default_value = true
}
}
return newField
},
insertFieldAtRange ({ sourceField, existingField = null, optionUuid = null, from, to = from }) {
if (!sourceField) {
return null
}
const fieldType = sourceField.type || existingField?.type || 'text'
const dims = this.defaultSizes[fieldType]
const area = this.buildFieldArea({ optionUuid })
const insertIndex = this.getFieldInsertIndex(from)
let field = existingField
const view = this.editor.view
if (field) {
if (!this.template.fields.includes(field)) {
this.insertFieldInTemplate(field, insertIndex)
}
field.areas = field.areas || []
field.areas.push(area)
} else {
field = this.buildNewField(sourceField, area)
this.insertFieldInTemplate(field, insertIndex)
}
const nodeType = view.state.schema.nodes.fieldNode
const fieldNode = nodeType.create({
uuid: field.uuid,
areaUuid: area.uuid,
width: dims.width,
height: dims.height
})
const tr = from !== to
? view.state.tr.replaceWith(from, to, fieldNode)
: view.state.tr.insert(from, fieldNode)
view.dispatch(tr)
this.editor.chain().focus().setNodeSelection(from).run()
this.save()
return { area, field, pos: from }
}
}
}
</script>
@@ -0,0 +1,22 @@
@config "../../../tailwind.dynamic.config.js";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: #e5e7eb;
}
::before,
::after {
--tw-content: '';
}
:host {
all: initial;
}
@@ -0,0 +1,422 @@
<template>
<div class="group">
<div class="flex items-center justify-between py-1.5 px-0.5">
<div class="flex items-center space-x-1 min-w-0">
<FieldType
:model-value="formType"
:editable="editable"
:button-width="18"
:menu-classes="'mt-1.5'"
:menu-style="{ backgroundColor: dropdownBgColor }"
@update:model-value="onTypeChange"
/>
<span
class="truncate"
:title="path"
>{{ displayName }}</span>
<span
v-if="isArray"
class="text-xs bg-base-200 rounded px-1 flex-shrink-0"
>{{ t('list') }}</span>
</div>
<div
v-if="editable"
class="flex items-center flex-shrink-0"
>
<span
class="dropdown dropdown-end"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
tabindex="0"
:title="t('settings')"
class="cursor-pointer text-transparent group-hover:text-base-content"
>
<IconSettings
:width="18"
:stroke-width="1.6"
/>
</label>
<ul
v-if="renderDropdown"
tabindex="0"
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10"
:style="{ backgroundColor: dropdownBgColor }"
@click="closeDropdown"
>
<div
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="onTypeChange($event.target.value)"
>
<option
v-for="varType in variableTypes"
:key="varType"
:value="varType"
:selected="varType === formType"
>{{ t(varType) }}</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>{{ t('type') }}</label>
</div>
<div
v-if="formType === 'number'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[schema.format = $event.target.value, save()]"
>
<option
v-for="format in numberFormats"
:key="format"
:value="format"
:selected="format === schema.format || (format === 'none' && !schema.format)"
>{{ formatNumber(123456789.567, format) }}</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>{{ t('format') }}</label>
</div>
<div
v-if="['text', 'number'].includes(formType)"
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="schema.default_value"
:type="formType === 'number' ? 'number' : 'text'"
:placeholder="t('default_value')"
dir="auto"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
>
<label
v-if="schema.default_value"
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>{{ t('default_value') }}</label>
</div>
<div
v-if="formType === 'date'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
:value="schema.format || 'MM/DD/YYYY'"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[schema.format = $event.target.value, save()]"
>
<option
v-for="format in dateFormats"
:key="format"
:value="format"
>{{ formatDate(new Date(), format) }}</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>{{ t('format') }}</label>
</div>
<li
v-if="formType === 'date'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
:checked="schema.default_value === '{{date}}'"
type="checkbox"
class="toggle toggle-xs"
@change="[schema.default_value = $event.target.checked ? '{{date}}' : undefined, save()]"
>
<span class="label-text">{{ t('current_date') }}</span>
</label>
</li>
<div
v-if="['radio', 'select'].includes(formType)"
class="py-1.5 px-1 relative"
@click.stop
>
<select
dir="auto"
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent"
@change="[schema.default_value = $event.target.value || undefined, save()]"
>
<option
value=""
:selected="!schema.default_value"
>{{ t('none') }}</option>
<option
v-for="opt in (schema.options || [])"
:key="opt"
:value="opt"
:selected="schema.default_value === opt"
>{{ opt }}</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>{{ t('default_value') }}</label>
</div>
<li
v-if="formType === 'checkbox'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
:checked="schema.default_value === true"
type="checkbox"
class="toggle toggle-xs"
@change="[schema.default_value = $event.target.checked || undefined, save()]"
>
<span class="label-text">{{ t('checked') }}</span>
</label>
</li>
<li @click.stop>
<label class="cursor-pointer py-1.5">
<input
:checked="schema.required !== false"
type="checkbox"
class="toggle toggle-xs"
@change="[schema.required = $event.target.checked, save()]"
>
<span class="label-text">{{ t('required') }}</span>
</label>
</li>
</ul>
</span>
</div>
</div>
<div
v-if="['radio', 'select'].includes(formType) && schema.options"
ref="options"
class="pl-2 pr-1 pb-1.5 space-y-1.5"
>
<div
v-for="(option, index) in schema.options"
:key="index"
class="flex space-x-1.5 items-center"
>
<span class="text-sm w-3.5 select-none">{{ index + 1 }}.</span>
<input
:value="option"
class="w-full input input-primary input-xs text-sm bg-transparent"
type="text"
dir="auto"
:placeholder="`${t('option')} ${index + 1}`"
@blur="[schema.options.splice(index, 1, $event.target.value), save()]"
@keydown.enter="$event.target.value ? onOptionEnter(index, $event.target.value) : null"
>
<button
class="text-sm w-3.5"
tabindex="-1"
@click="[schema.options.splice(index, 1), save()]"
>
&times;
</button>
</div>
<button
class="text-center text-sm w-full pb-1"
@click="addOptionAndFocus((schema.options || []).length)"
>
+ {{ t('add_option') }}
</button>
</div>
</div>
</template>
<script>
import FieldType from './field_type'
import { IconSettings } from '@tabler/icons-vue'
export default {
name: 'DynamicVariable',
components: {
FieldType,
IconSettings
},
inject: ['t', 'save', 'backgroundColor'],
provide () {
return {
fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select']
}
},
props: {
path: {
type: String,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
groupKey: {
type: String,
default: ''
},
schema: {
type: Object,
required: true
},
isArray: {
type: Boolean,
default: false
}
},
data () {
return {
renderDropdown: false
}
},
computed: {
displayName () {
if (this.groupKey) {
const prefix = this.groupKey + (this.path.startsWith(this.groupKey + '[].') ? '[].' : '.')
return this.path.slice(prefix.length)
} else {
return this.path
}
},
dropdownBgColor () {
return ['', null, 'transparent'].includes(this.backgroundColor) ? 'white' : this.backgroundColor
},
schemaTypeToFormType () {
return { string: 'text', number: 'number', boolean: 'checkbox', date: 'date' }
},
formType () {
return this.schema.form_type || this.schemaTypeToFormType[this.schema.type] || 'text'
},
variableTypes () {
return ['text', 'number', 'date', 'checkbox', 'radio', 'select']
},
formTypeToSchemaType () {
return { text: 'string', number: 'number', date: 'date', checkbox: 'boolean', radio: 'string', select: 'string' }
},
numberFormats () {
return [
'none',
'usd',
'eur',
'gbp',
'comma',
'dot',
'space'
]
},
dateFormats () {
const formats = [
'MM/DD/YYYY',
'DD/MM/YYYY',
'YYYY-MM-DD',
'DD-MM-YYYY',
'DD.MM.YYYY',
'MMM D, YYYY',
'MMMM D, YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) {
formats.push('YYYY년 MM월 DD일')
}
if (this.schema.format && !formats.includes(this.schema.format)) {
formats.unshift(this.schema.format)
}
return formats
}
},
methods: {
onTypeChange (newType) {
this.schema.type = this.formTypeToSchemaType[newType] || 'string'
this.schema.form_type = newType
if (['radio', 'select'].includes(newType)) {
if (!this.schema.options || !this.schema.options.length) {
this.schema.options = ['', '']
}
} else {
delete this.schema.options
delete this.schema.default_value
delete this.schema.format
}
this.save()
},
onOptionEnter (index, value) {
this.schema.options.splice(index, 1, value)
this.schema.options.splice(index + 1, 0, '')
this.save()
this.$nextTick(() => {
this.$refs.options.querySelectorAll('input')[index + 1]?.focus()
})
},
addOptionAndFocus (index) {
if (!this.schema.options) {
this.schema.options = []
}
this.schema.options.splice(index, 0, '')
this.save()
this.$nextTick(() => {
this.$refs.options.querySelectorAll('input')[index]?.focus()
})
},
formatNumber (number, format) {
if (format === 'comma') {
return new Intl.NumberFormat('en-US').format(number)
} else if (format === 'usd') {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'gbp') {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'eur') {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'dot') {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else {
return number
}
},
formatDate (date, format) {
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
const dayFormats = { D: 'numeric', DD: '2-digit' }
const yearFormats = { YYYY: 'numeric', YY: '2-digit' }
const parts = new Intl.DateTimeFormat([], {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)]
}).formatToParts(date)
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
},
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
}
}
}
</script>
@@ -0,0 +1,136 @@
<template>
<div>
<div
v-if="!schemaEntries.length"
class="text-center py-4 px-2"
>
<p class="font-medium">
{{ t('no_variables') }}
</p>
<p class="text-sm mt-1">
{{ t('no_variables_description') }}
</p>
</div>
<template v-else>
<template
v-for="([key, node], index) in schemaEntries"
:key="key"
>
<div v-if="isGroup(node)">
<hr
v-if="index > 0"
class="border-base-300"
>
<label class="peer flex items-center py-1.5 cursor-pointer select-none">
<span class="w-5 flex justify-center items-center">
<input
type="checkbox"
class="hidden peer"
checked
>
<IconChevronDown
class="hidden peer-checked:block ml-0.5"
:width="14"
:stroke-width="1.6"
/>
<IconChevronRight
class="block peer-checked:hidden ml-0.5"
:width="14"
:stroke-width="1.6"
/>
</span>
<span class="ml-1">{{ key }}</span>
<span
v-if="node.type === 'array'"
class="text-xs bg-base-200 rounded px-1 ml-1"
>{{ t('list') }}</span>
</label>
<div class="hidden peer-has-[:checked]:block pl-5">
<template
v-for="[varNode, varPath] in nestedVariables(node, key)"
:key="varPath"
>
<hr class="border-base-300">
<DynamicVariable
:path="varPath"
:group-key="key"
:editable="editable"
:schema="varNode"
/>
</template>
</div>
</div>
<template v-else>
<hr
v-if="index > 0"
class="border-base-300"
>
<DynamicVariable
:path="key"
:editable="editable"
:schema="node.type === 'array' && node.items ? node.items : node"
:is-array="node.type === 'array'"
/>
</template>
</template>
</template>
</div>
</template>
<script>
import DynamicVariable from './dynamic_variable'
import { IconChevronDown, IconChevronRight } from '@tabler/icons-vue'
export default {
name: 'DynamicVariables',
components: {
DynamicVariable,
IconChevronDown,
IconChevronRight
},
inject: ['t', 'template', 'save', 'backgroundColor'],
props: {
editable: {
type: Boolean,
required: false,
default: true
}
},
computed: {
schemaEntries () {
return Object.entries(this.template.variables_schema || {}).filter(([, node]) => !node.disabled)
}
},
methods: {
isGroup (node) {
return (node.type === 'object' && node.properties) || (node.type === 'array' && node.items?.type === 'object' && node.items?.properties)
},
nestedVariables (node, groupKey) {
const properties = node.type === 'array' ? node.items?.properties : node.properties
if (!properties) return []
const prefix = node.type === 'array' ? `${groupKey}[]` : groupKey
return this.collectLeafVariables(properties, prefix)
},
collectLeafVariables (properties, prefix) {
return Object.entries(properties).reduce((result, [key, node]) => {
if (node.disabled) return result
const path = `${prefix}.${key}`
if (node.type === 'object' && node.properties) {
result.push(...this.collectLeafVariables(node.properties, path))
} else if (node.type === 'array' && node.items?.properties) {
result.push(...this.collectLeafVariables(node.items.properties, `${path}[]`))
} else {
result.push([node, path])
}
return result
}, [])
}
}
}
</script>
@@ -0,0 +1,584 @@
const KEYWORDS = ['if', 'else', 'for', 'end']
const TYPE_PRIORITY = { string: 3, number: 2, boolean: 1 }
const AND_OR_REGEXP = /\s+(AND|OR)\s+/i
const COMPARISON_OPERATORS_REGEXP = />=|<=|!=|==|>|<|=/
function buildTokens (elem, acc = []) {
if (elem.nodeType === Node.TEXT_NODE) {
if (elem.textContent) {
const text = elem.textContent
const re = /[[\]]/g
let match
let found = false
while ((match = re.exec(text)) !== null) {
found = true
acc.push({
elem,
value: match[0],
textLength: text.length,
index: match.index
})
}
if (!found) {
acc.push({ elem, value: '', textLength: 0, index: 0 })
}
}
} else {
for (const child of elem.childNodes) {
buildTokens(child, acc)
}
}
return acc
}
function tokensPair (cur, nxt) {
if (cur.elem === nxt.elem) {
return cur.elem.textContent.slice(cur.index + 1, nxt.index).trim() === ''
} else {
return cur.elem.textContent.slice(cur.index + 1).trim() === '' &&
nxt.elem.textContent.slice(0, nxt.index).trim() === ''
}
}
function buildTags (tokens) {
const normalized = []
for (let i = 0; i < tokens.length - 1; i++) {
const cur = tokens[i]
const nxt = tokens[i + 1]
if (cur.value === '[' && nxt.value === '[' && tokensPair(cur, nxt)) {
normalized.push(['open', cur])
} else if (cur.value === ']' && nxt.value === ']' && tokensPair(cur, nxt)) {
normalized.push(['close', nxt])
}
}
const tags = []
for (let i = 0; i < normalized.length - 1; i++) {
const [curOp, openToken] = normalized[i]
const [nxtOp, closeToken] = normalized[i + 1]
if (curOp === 'open' && nxtOp === 'close') {
tags.push({ openToken, closeToken, value: '' })
}
}
return tags
}
function findTextNodesInBranch (elements, toElem, acc) {
if (!elements || elements.length === 0) return acc
for (const elem of elements) {
if (elem.nodeType === Node.TEXT_NODE) {
acc.push(elem)
} else {
findTextNodesInBranch(Array.from(elem.childNodes), toElem, acc)
}
if (acc.length > 0 && acc[acc.length - 1] === toElem) return acc
}
return acc
}
function findTextNodesBetween (fromElem, toElem, acc = []) {
if (fromElem === toElem) return [fromElem]
let currentElement = fromElem
while (true) {
const parent = currentElement.parentNode
if (!parent) return acc
const children = Array.from(parent.childNodes)
const startIndex = children.indexOf(currentElement)
if (startIndex === -1) return acc
const elementsInBranch = children.slice(startIndex)
findTextNodesInBranch(elementsInBranch, toElem, acc)
if (acc.length > 0 && acc[acc.length - 1] === toElem) return acc
let p = elementsInBranch[0].parentNode
while (p && !p.nextSibling) {
p = p.parentNode
}
if (!p || !p.nextSibling) return acc
currentElement = p.nextSibling
}
}
function mapTagValues (tags) {
for (const tag of tags) {
const textNodes = findTextNodesBetween(tag.openToken.elem, tag.closeToken.elem)
for (const elem of textNodes) {
let part
if (tag.openToken.elem === elem && tag.closeToken.elem === elem) {
part = elem.textContent.slice(tag.openToken.index, tag.closeToken.index + 1)
} else if (tag.openToken.elem === elem) {
part = elem.textContent.slice(tag.openToken.index)
} else if (tag.closeToken.elem === elem) {
part = elem.textContent.slice(0, tag.closeToken.index + 1)
} else {
part = elem.textContent
}
tag.value += part
}
}
return tags
}
function parseTagTypeName (tagString) {
const val = tagString.replace(/[[\]]/g, '').trim()
const parts = val.split(':').map((s) => s.trim())
if (parts.length === 2 && KEYWORDS.includes(parts[0])) {
return [parts[0], parts[1]]
} else if (KEYWORDS.includes(val)) {
return [val, null]
} else {
return ['var', val]
}
}
function isSimpleVariable (str) {
const s = str.trim()
return !AND_OR_REGEXP.test(s) &&
!COMPARISON_OPERATORS_REGEXP.test(s) &&
!s.includes('(') &&
!s.includes('!') &&
!s.includes('&&') &&
!s.includes('||') &&
!s.startsWith('"') &&
!s.startsWith("'") &&
!/^-?\d/.test(s) &&
!/^(true|false)$/i.test(s)
}
function tokenizeCondition (str) {
const tokens = []
let pos = 0
str = str.trim()
while (pos < str.length) {
const rest = str.slice(pos)
let m
if ((m = rest.match(/^\s+/))) {
pos += m[0].length
} else if ((m = rest.match(/^(>=|<=|!=|==|>|<|=)/))) {
tokens.push({ type: 'operator', value: m[1] })
pos += m[1].length
} else if (rest[0] === '!') {
tokens.push({ type: 'not', value: '!' })
pos += 1
} else if (rest[0] === '(') {
tokens.push({ type: 'lparen', value: '(' })
pos += 1
} else if (rest[0] === ')') {
tokens.push({ type: 'rparen', value: ')' })
pos += 1
} else if (rest.startsWith('&&')) {
tokens.push({ type: 'and', value: 'AND' })
pos += 2
} else if ((m = rest.match(/^AND\b/i))) {
tokens.push({ type: 'and', value: 'AND' })
pos += 3
} else if (rest.startsWith('||')) {
tokens.push({ type: 'or', value: 'OR' })
pos += 2
} else if ((m = rest.match(/^OR\b/i))) {
tokens.push({ type: 'or', value: 'OR' })
pos += 2
} else if ((m = rest.match(/^"([^"]*)"/) || rest.match(/^'([^']*)'/))) {
tokens.push({ type: 'string', value: m[1] })
pos += m[0].length
} else if ((m = rest.match(/^(-?\d+\.?\d*)/))) {
tokens.push({ type: 'number', value: m[1].includes('.') ? parseFloat(m[1]) : parseInt(m[1], 10) })
pos += m[1].length
} else if ((m = rest.match(/^(true|false)\b/i))) {
tokens.push({ type: 'boolean', value: m[1].toLowerCase() === 'true' })
pos += m[1].length
} else if ((m = rest.match(/^([\p{L}_][\p{L}\p{N}_.]*)/u))) {
tokens.push({ type: 'variable', value: m[1] })
pos += m[1].length
} else {
pos += 1
}
}
return tokens
}
function parseOrExpr (tokens, pos) {
let left, right
;[left, pos] = parseAndExpr(tokens, pos)
while (pos < tokens.length && tokens[pos].type === 'or') {
pos += 1
;[right, pos] = parseAndExpr(tokens, pos)
left = { type: 'or', left, right }
}
return [left, pos]
}
function parseAndExpr (tokens, pos) {
let left, right
;[left, pos] = parsePrimary(tokens, pos)
while (pos < tokens.length && tokens[pos].type === 'and') {
pos += 1
;[right, pos] = parsePrimary(tokens, pos)
left = { type: 'and', left, right }
}
return [left, pos]
}
function parsePrimary (tokens, pos) {
if (pos >= tokens.length) return [null, pos]
if (tokens[pos].type === 'not') {
const [child, p] = parsePrimary(tokens, pos + 1)
return [{ type: 'not', child }, p]
}
if (tokens[pos].type === 'lparen') {
const [node, p] = parseOrExpr(tokens, pos + 1)
return [node, p < tokens.length && tokens[p].type === 'rparen' ? p + 1 : p]
}
return parseComparisonOrPresence(tokens, pos)
}
function parseComparisonOrPresence (tokens, pos) {
if (pos >= tokens.length || tokens[pos].type !== 'variable') return [null, pos]
const variableName = tokens[pos].value
pos += 1
if (pos < tokens.length && tokens[pos].type === 'operator') {
let operator = tokens[pos].value
if (operator === '=') operator = '=='
pos += 1
if (pos < tokens.length && ['string', 'number', 'variable', 'boolean'].includes(tokens[pos].type)) {
const valueToken = tokens[pos]
return [{
type: 'comparison',
variableName,
operator,
value: valueToken.value,
valueIsVariable: valueToken.type === 'variable'
}, pos + 1]
}
}
return [{ type: 'presence', variableName }, pos]
}
function parseCondition (conditionString) {
const stripped = conditionString.trim()
if (stripped.startsWith('!') && isSimpleVariable(stripped.slice(1))) {
return { type: 'not', child: { type: 'presence', variableName: stripped.slice(1) } }
}
if (isSimpleVariable(stripped)) {
return { type: 'presence', variableName: stripped }
}
const tokens = tokenizeCondition(stripped)
const [ast] = parseOrExpr(tokens, 0)
return ast
}
function extractConditionVariables (node, acc = []) {
if (!node) return acc
switch (node.type) {
case 'or':
case 'and':
extractConditionVariables(node.left, acc)
extractConditionVariables(node.right, acc)
break
case 'not':
extractConditionVariables(node.child, acc)
break
case 'comparison':
acc.push({
name: node.variableName,
type: node.valueIsVariable ? null : (typeof node.value === 'boolean' ? 'boolean' : (typeof node.value === 'number' ? 'number' : 'string'))
})
if (node.valueIsVariable) {
acc.push({ name: node.value, type: null })
}
break
case 'presence':
acc.push({ name: node.variableName, type: 'boolean' })
break
}
return acc
}
const IRREGULAR_PLURALS = {
people: 'person',
men: 'man',
women: 'woman',
children: 'child',
teeth: 'tooth',
feet: 'foot',
mice: 'mouse',
geese: 'goose',
oxen: 'ox',
criteria: 'criterion',
phenomena: 'phenomenon',
alumni: 'alumnus',
data: 'datum',
media: 'medium'
}
function singularize (word) {
if (IRREGULAR_PLURALS[word]) return IRREGULAR_PLURALS[word]
if (word.endsWith('ies')) return word.slice(0, -3) + 'y'
if (word.endsWith('ches') || word.endsWith('shes')) return word.slice(0, -2)
if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes')) return word.slice(0, -2)
if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1)
return word
}
function buildOperators (tags) {
const operators = []
const stack = [{ children: operators, operator: null }]
for (const tag of tags) {
const [type, variableName] = parseTagTypeName(tag.value)
switch (type) {
case 'for':
case 'if': {
const operator = { type, variableName, tag, children: [] }
if (type === 'if') {
try {
operator.condition = parseCondition(variableName)
} catch (e) {
// ignore parse errors
}
}
stack[stack.length - 1].children.push(operator)
stack.push({ children: operator.children, operator })
break
}
case 'else': {
const current = stack[stack.length - 1]
if (current.operator && current.operator.type === 'if') {
current.operator.elseTag = tag
current.operator.elseChildren = []
current.children = current.operator.elseChildren
}
break
}
case 'end': {
const popped = stack.pop()
if (popped.operator) {
popped.operator.endTag = tag
}
break
}
case 'var':
stack[stack.length - 1].children.push({ type, variableName, tag })
break
}
}
return operators
}
function assignNestedSchema (propertiesHash, parentProperties, keyString, value) {
const keys = keyString.split('.')
const lastKey = keys.pop()
let currentLevel = null
if (keys.length > 0 && parentProperties[keys[0]]) {
currentLevel = keys.reduce((current, key) => {
if (!current[key]) current[key] = { type: 'object', properties: {} }
return current[key].properties
}, parentProperties)
}
if (!currentLevel) {
currentLevel = keys.reduce((current, key) => {
if (!current[key]) current[key] = { type: 'object', properties: {} }
return current[key].properties
}, propertiesHash)
}
currentLevel[lastKey] = value
}
function assignNestedSchemaWithPriority (propertiesHash, parentProperties, keyString, newType) {
const keys = keyString.split('.')
const lastKey = keys.pop()
let currentLevel = null
if (keys.length > 0 && parentProperties[keys[0]]) {
currentLevel = keys.reduce((current, key) => {
if (!current[key]) current[key] = { type: 'object', properties: {} }
return current[key].properties
}, parentProperties)
}
if (!currentLevel) {
currentLevel = keys.reduce((current, key) => {
if (!current[key]) current[key] = { type: 'object', properties: {} }
return current[key].properties
}, propertiesHash)
}
const existing = currentLevel[lastKey]
if (existing && (TYPE_PRIORITY[newType] || 0) <= (TYPE_PRIORITY[existing.type] || 0)) return
currentLevel[lastKey] = { type: newType }
}
function processConditionVariables (condition, propertiesHash, parentProperties) {
const variables = extractConditionVariables(condition)
for (const varInfo of variables) {
assignNestedSchemaWithPriority(propertiesHash, parentProperties, varInfo.name, varInfo.type || 'boolean')
}
}
function processOperators (operators, propertiesHash = {}, parentProperties = {}) {
if (!operators || operators.length === 0) return propertiesHash
for (const op of operators) {
switch (op.type) {
case 'var': {
if (!op.variableName.includes('.') && parentProperties[op.variableName]) {
const item = parentProperties[op.variableName]
if (item && item.type === 'object' && item.properties && Object.keys(item.properties).length === 0) {
delete item.properties
item.type = 'string'
}
} else {
assignNestedSchema(propertiesHash, parentProperties, op.variableName, { type: 'string' })
}
break
}
case 'if':
if (op.condition) {
processConditionVariables(op.condition, propertiesHash, parentProperties)
}
processOperators(op.children, propertiesHash, parentProperties)
processOperators(op.elseChildren, propertiesHash, parentProperties)
break
case 'for': {
const parts = op.variableName.split('.')
const singularKey = singularize(parts[parts.length - 1])
let itemProperties = parentProperties[singularKey]?.items
itemProperties = itemProperties || propertiesHash[parts[0]]?.items
itemProperties = itemProperties || { type: 'object', properties: {} }
assignNestedSchema(propertiesHash, parentProperties, op.variableName, { type: 'array', items: itemProperties })
processOperators(op.children, propertiesHash, { ...parentProperties, [singularKey]: itemProperties })
if (itemProperties.type === 'object' && itemProperties.properties && Object.keys(itemProperties.properties).length === 0) {
delete itemProperties.properties
itemProperties.type = 'string'
}
break
}
}
}
return propertiesHash
}
function mergeSchemaProperties (target, source) {
for (const key of Object.keys(source)) {
if (!target[key]) {
target[key] = source[key]
} else if (target[key].type === 'object' && source[key].type === 'object') {
if (!target[key].properties) target[key].properties = {}
if (source[key].properties) {
mergeSchemaProperties(target[key].properties, source[key].properties)
}
} else if (target[key].type === 'array' && source[key].type === 'array') {
if (source[key].items && source[key].items.properties) {
if (!target[key].items) {
target[key].items = source[key].items
} else if (target[key].items.properties) {
mergeSchemaProperties(target[key].items.properties, source[key].items.properties)
}
} else if (source[key].items && !target[key].items) {
target[key].items = source[key].items
}
} else if ((TYPE_PRIORITY[source[key].type] || 0) > (TYPE_PRIORITY[target[key].type] || 0)) {
target[key] = source[key]
}
}
return target
}
function buildVariablesSchema (dom) {
const tokens = buildTokens(dom)
const tags = mapTagValues(buildTags(tokens))
const operators = buildOperators(tags)
return processOperators(operators)
}
export { buildVariablesSchema, mergeSchemaProperties, buildOperators, buildTokens, buildTags, mapTagValues }
@@ -1,6 +1,7 @@
<template>
<div
class="list-field group"
:class="`list-field-${field.type}`"
>
<div
class="border border-base-300 rounded relative group fields-list-item"
@@ -11,7 +11,7 @@
>
<label
v-if="showRequired"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
class="field-settings-required w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
@@ -26,7 +26,7 @@
</label>
<label
v-if="showReadOnly"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
class="field-settings-read-only w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
@@ -41,7 +41,7 @@
</label>
<label
v-if="showPrefillable"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
class="field-settings-prefillable w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
@@ -56,7 +56,7 @@
</label>
<label
v-if="showSetSigningDate"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
class="field-settings-set-signing-date w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
@@ -70,7 +70,7 @@
</label>
<label
v-if="showWithLogo"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
class="field-settings-with-logo w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
@@ -84,7 +84,7 @@
</label>
<label
v-if="showSignatureId"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
class="field-settings-signature-id w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
@@ -99,7 +99,7 @@
</label>
<label
v-if="showChecked"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
class="field-settings-checked w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
@@ -113,6 +113,7 @@
</label>
<ContextSubmenu
v-if="showVerificationMethod"
class="field-settings-verification-method"
:icon="IconId"
:label="t('method')"
:options="methodOptions"
@@ -125,6 +126,7 @@
>
<ContextSubmenu
v-if="showFormatSubmenu"
class="field-settings-format"
:icon="IconAdjustmentsHorizontal"
:label="t('format')"
:options="formatOptions"
@@ -133,6 +135,7 @@
/>
<ContextSubmenu
v-if="showValidationSubmenu && field.type !== 'number'"
class="field-settings-validation"
:icon="IconInputCheck"
:label="t('validation')"
:options="validationMenuItems.map(k => ({ value: k, label: t(k) }))"
@@ -141,7 +144,7 @@
/>
<button
v-if="field.type === 'number'"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
class="field-settings-validation w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openNumberRangeModal"
>
<IconInputCheck class="w-4 h-4" />
@@ -149,6 +152,7 @@
</button>
<ContextSubmenu
v-if="showPaymentSettings"
class="field-settings-currency"
:icon="IconCash"
:label="t('currency')"
:options="currencyOptions"
@@ -157,6 +161,7 @@
/>
<ContextSubmenu
v-if="showPaymentSettings"
class="field-settings-price"
:icon="IconCoins"
:label="t('price')"
:options="priceTypeOptions"
@@ -165,7 +170,7 @@
/>
<button
v-if="showFont"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
class="field-settings-font w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
@@ -173,7 +178,7 @@
</button>
<button
v-if="showDescription"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
class="field-settings-description w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openDescriptionModal"
>
<IconInfoCircle class="w-4 h-4" />
@@ -181,7 +186,7 @@
</button>
<button
v-if="showCondition"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
class="field-settings-condition w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="openConditionModal"
>
<span class="flex items-center space-x-2">
@@ -196,7 +201,7 @@
</button>
<button
v-if="showFormula"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
class="field-settings-formula w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openFormulaModal"
>
<IconMathFunction class="w-4 h-4" />
@@ -207,7 +212,7 @@
class="my-1 border-neutral-200"
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
class="field-settings-copy w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="$emit('copy')"
>
<span class="flex items-center space-x-2">
@@ -217,7 +222,7 @@
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘C' : 'Ctrl+C' }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm text-red-600"
class="field-settings-remove w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm text-red-600"
@click.stop="$emit('delete')"
>
<span class="flex items-center space-x-2">
@@ -228,12 +233,13 @@
</button>
<ContextSubmenu
v-if="showMoreSubmenu"
class="field-settings-more"
:icon="IconDots"
:label="t('more')"
>
<button
v-if="showDrawNewArea"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
class="field-settings-draw-new-area w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click="handleMoreSelect('draw_new_area')"
>
<IconNewSection class="w-4 h-4" />
@@ -241,7 +247,7 @@
</button>
<button
v-if="showCopyToAllPages"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
class="field-settings-copy-to-all-pages w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click="handleMoreSelect('copy_to_all_pages')"
>
<IconCopy class="w-4 h-4" />
@@ -249,7 +255,7 @@
</button>
<button
v-if="showSaveAsCustom"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
class="field-settings-save-as-custom-field w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click="handleMoreSelect('save_as_custom')"
>
<IconForms class="w-4 h-4" />
@@ -1,7 +1,7 @@
<template>
<div
v-if="field.type === 'verification'"
class="py-1.5 px-1 relative"
class="field-settings-verification-method py-1.5 px-1 relative"
@click.stop
>
<select
@@ -28,7 +28,7 @@
</div>
<div
v-if="['select', 'radio'].includes(field.type) && !defaultField"
class="py-1.5 px-1 relative"
class="field-settings-default-value py-1.5 px-1 relative"
@click.stop
>
<select
@@ -62,7 +62,7 @@
</div>
<div
v-if="['text', 'number'].includes(field.type) && !defaultField"
class="py-1.5 px-1 relative"
class="field-settings-default-value py-1.5 px-1 relative"
@click.stop
>
<input
@@ -84,7 +84,7 @@
</div>
<div
v-if="['text', 'cells'].includes(field.type)"
class="py-1.5 px-1 relative"
class="field-settings-validation py-1.5 px-1 relative"
@click.stop
>
<select
@@ -122,7 +122,7 @@
</div>
<div
v-if="['text', 'cells'].includes(field.type) && field.validation && lengthValidation"
class="py-1.5 px-1 relative flex space-x-1"
class="field-settings-length-validation py-1.5 px-1 relative flex space-x-1"
@click.stop
>
<div class="w-1/2 relative">
@@ -166,7 +166,7 @@
</div>
<div
v-if="field.type === 'number'"
class="py-1.5 px-1 relative flex space-x-1"
class="field-settings-number-range py-1.5 px-1 relative flex space-x-1"
@click.stop
>
<div class="w-1/2 relative">
@@ -210,7 +210,7 @@
</div>
<div
v-if="field.type === 'number'"
class="py-1.5 px-1 relative"
class="field-settings-number-format py-1.5 px-1 relative"
@click.stop
>
<select
@@ -237,7 +237,7 @@
</div>
<div
v-if="['text', 'cells'].includes(field.type) && field.validation && !validations[field.validation.pattern] && !lengthValidation"
class="py-1.5 px-1 relative"
class="field-settings-custom-validation py-1.5 px-1 relative"
@click.stop
>
<input
@@ -259,7 +259,7 @@
</div>
<div
v-if="['text', 'cells'].includes(field.type) && field.validation && !validations[field.validation.pattern] && !lengthValidation"
class="py-1.5 px-1 relative"
class="field-settings-error-message py-1.5 px-1 relative"
@click.stop
>
<input
@@ -280,7 +280,7 @@
</div>
<div
v-if="field.type === 'date'"
class="py-1.5 px-1 relative"
class="field-settings-date-format py-1.5 px-1 relative"
@click.stop
>
<select
@@ -307,7 +307,7 @@
</div>
<div
v-if="field.type === 'signature'"
class="py-1.5 px-1 relative"
class="field-settings-signature-format py-1.5 px-1 relative"
@click.stop
>
<select
@@ -340,6 +340,7 @@
</div>
<li
v-if="[true, false].includes(withSignatureId) && field.type === 'signature'"
class="field-settings-signature-id"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -355,6 +356,7 @@
</li>
<li
v-if="withRequired && field.type !== 'phone' && field.type !== 'stamp' && field.type !== 'verification' && field.type !== 'strikethrough' && field.type !== 'heading'"
class="field-settings-required"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -370,6 +372,7 @@
</li>
<li
v-if="field.type == 'stamp'"
class="field-settings-with-logo"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -384,6 +387,7 @@
</li>
<li
v-if="field.type == 'checkbox'"
class="field-settings-checked"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -398,6 +402,7 @@
</li>
<li
v-if="field.type == 'date'"
class="field-settings-set-signing-date"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -412,6 +417,7 @@
</li>
<li
v-if="['text', 'number', 'radio', 'multiple', 'select'].includes(field.type)"
class="field-settings-read-only"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -427,6 +433,7 @@
</li>
<li
v-if="withPrefillable && prefillableFieldTypes.includes(field['type'])"
class="field-settings-prefillable"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -444,7 +451,10 @@
v-if="field.type != 'stamp'"
class="pb-0.5 mt-0.5"
>
<li v-if="['text', 'number', 'date', 'select', 'heading', 'cells'].includes(field.type)">
<li
v-if="['text', 'number', 'date', 'select', 'heading', 'cells'].includes(field.type)"
class="field-settings-font"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-font')"
@@ -459,6 +469,7 @@
</li>
<li
v-if="field.type != 'stamp' && field.type != 'heading' && field.type != 'strikethrough'"
class="field-settings-description"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@@ -474,6 +485,7 @@
</li>
<li
v-if="withCondition && field.type != 'stamp' && field.type != 'heading'"
class="field-settings-condition"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@@ -487,7 +499,10 @@
</span>
</label>
</li>
<li v-if="field.type == 'number'">
<li
v-if="field.type == 'number'"
class="field-settings-formula"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-formula')"
@@ -501,13 +516,14 @@
</label>
</li>
<hr
v-if="withCopyToAllPages || withAreas || withCustomFields"
v-if="(withCopyToAllPages && canCopyToAllPages) || withAreas || withCustomFields"
class="pb-0.5 mt-0.5"
>
<template v-if="withAreas">
<li
v-for="(area, index) in sortedAreas"
:key="index"
class="field-settings-area"
>
<a
href="#"
@@ -527,7 +543,10 @@
/>
</a>
</li>
<li v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)">
<li
v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)"
class="field-settings-draw-new-area"
>
<a
href="#"
class="text-sm py-1 px-2"
@@ -541,7 +560,10 @@
</a>
</li>
</template>
<li v-if="withCopyToAllPages && field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)">
<li
v-if="withCopyToAllPages && canCopyToAllPages && field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)"
class="field-settings-copy-to-all-pages"
>
<a
href="#"
class="text-sm py-1 px-2"
@@ -554,7 +576,10 @@
{{ t('copy_to_all_pages') }}
</a>
</li>
<li v-if="withCustomFields">
<li
v-if="withCustomFields"
class="field-settings-save-as-custom-field"
>
<a
href="#"
class="text-sm py-1 px-2"
@@ -656,6 +681,15 @@ export default {
return acc
}, {})
},
canCopyToAllPages () {
const firstArea = this.field.areas[0]
if (firstArea) {
return firstArea.page !== null && firstArea.page !== undefined
} else {
return false
}
},
numberFormats () {
return [
'none',
@@ -719,7 +753,7 @@ export default {
return ['text', 'number', 'cells', 'date', 'checkbox', 'select', 'radio', 'phone']
},
sortedAreas () {
return (this.field.areas || []).sort((a, b) => {
return (this.field.areas || []).filter((e) => e.page !== null && e.page !== undefined).sort((a, b) => {
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
})
}
+56 -14
View File
@@ -1,19 +1,42 @@
<template>
<div :class="withStickySubmitters ? 'sticky top-0 z-[1]' : ''">
<div
:class="withStickySubmitters ? 'sticky top-0 z-[1]' : ''"
:style="withStickySubmitters ? { backgroundColor } : {}"
class="flex items-center gap-1"
>
<FieldSubmitter
:model-value="selectedSubmitter.uuid"
class="roles-dropdown w-full rounded-lg roles-dropdown"
:style="withStickySubmitters ? { backgroundColor } : {}"
:submitters="submitters"
:menu-style="{ overflow: 'auto', display: 'flex', flexDirection: 'row', maxHeight: 'calc(100vh - 120px)', backgroundColor: ['', null, 'transparent'].includes(backgroundColor) ? 'white' : backgroundColor }"
:editable="editable && !defaultSubmitters.length"
@new-submitter="save"
@remove="removeSubmitter"
@name-change="save"
@update:model-value="$emit('change-submitter', submitters.find((s) => s.uuid === $event))"
@update:model-value="[$emit('change-submitter', submitters.find((s) => s.uuid === $event)), isShowVariables = false]"
/>
<button
v-if="hasDynamicDocuments"
class="flex-shrink-0 rounded-md border hover:border-content flex items-center justify-center self-stretch"
:class="isShowVariables ? 'border-base-content bg-base-content text-base-100' : 'border-base-300'"
style="width: 44px"
:title="t('variables')"
@click.prevent="toggleVariables"
>
<IconBracketsContain
:width="22"
:height="22"
:stroke-width="1.6"
/>
</button>
</div>
<DynamicVariables
v-if="isShowVariables"
:editable="editable"
class="mt-1"
/>
<div
v-if="!isShowVariables"
ref="fields"
class="fields mt-2"
:class="{ 'mb-1': !withCustomFields || !customFields.length }"
@@ -42,7 +65,7 @@
@set-draw="$emit('set-draw', $event)"
/>
</div>
<div v-if="submitterDefaultFields.length && editable">
<div v-if="!isShowVariables && submitterDefaultFields.length && editable">
<hr class="mb-2">
<template v-if="isShowFieldSearch">
<input
@@ -110,7 +133,7 @@
</div>
</div>
<div
v-if="editable && withCustomFields && (customFields.length || newCustomField)"
v-if="!isShowVariables && editable && withCustomFields && (customFields.length || newCustomField)"
class="tabs w-full mb-1.5"
>
<a
@@ -127,7 +150,7 @@
>{{ t('custom') }}</a>
</div>
<div
v-if="showCustomTab && editable && (customFields.length || newCustomField)"
v-if="!isShowVariables && showCustomTab && editable && (customFields.length || newCustomField)"
ref="customFields"
class="custom-fields"
@dragover.prevent="onCustomFieldDragover"
@@ -195,7 +218,7 @@
</div>
</div>
<div
v-if="editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField))"
v-if="!isShowVariables && editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField))"
id="field-types-grid"
class="grid grid-cols-3 gap-1 pb-2 fields-grid"
>
@@ -283,7 +306,7 @@
</template>
</div>
<div
v-if="fields.length < 4 && editable && withHelp && !showTourStartForm"
v-if="!isShowVariables && fields.length < 4 && editable && withHelp && !showTourStartForm"
class="text-xs p-2 border border-base-200 rounded"
>
<ul class="list-disc list-outside ml-3">
@@ -299,7 +322,7 @@
</ul>
</div>
<div
v-if="withFieldsDetection && editable && fields.length < 2"
v-if="!isShowVariables && withFieldsDetection && editable && fields.length < 2 && !template.schema.some((item) => item.dynamic)"
class="my-2"
>
<button
@@ -336,7 +359,7 @@
</button>
</div>
<div
v-show="fields.length < 4 && editable && withHelp && showTourStartForm"
v-show="!isShowVariables && fields.length < 4 && editable && withHelp && showTourStartForm"
class="rounded py-2 px-4 w-full border border-dashed border-base-300"
>
<div class="text-center text-sm">
@@ -359,7 +382,8 @@ import Field from './field'
import CustomField from './custom_field'
import FieldType from './field_type'
import FieldSubmitter from './field_submitter'
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles } from '@tabler/icons-vue'
import { defineAsyncComponent } from 'vue'
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles, IconBracketsContain } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
import { v4 } from 'uuid'
@@ -374,7 +398,9 @@ export default {
IconInnerShadowTop,
FieldSubmitter,
IconDrag,
IconLock
IconLock,
IconBracketsContain,
DynamicVariables: defineAsyncComponent(() => import(/* webpackChunkName: "dynamic-editor" */ './dynamic_variables'))
},
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'baseFetch', 'selectedAreasRef', 'getFieldTypeIndex'],
props: {
@@ -475,7 +501,7 @@ export default {
default: false
}
},
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter'],
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter', 'rebuild-variables-schema', 'remove-field', 'remove-submitter'],
data () {
return {
fieldPagesLoaded: null,
@@ -483,12 +509,16 @@ export default {
newCustomField: null,
showCustomTab: false,
defaultFieldsSearch: '',
customFieldsSearch: ''
customFieldsSearch: '',
isShowVariables: false
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
hasDynamicDocuments () {
return this.template.schema.some((item) => item.dynamic)
},
numberOfPages () {
return this.template.documents.reduce((acc, doc) => {
return acc + doc.metadata?.pdf?.number_of_pages || doc.preview_images.length
@@ -556,6 +586,10 @@ export default {
}
},
methods: {
toggleVariables () {
this.$emit('rebuild-variables-schema')
this.isShowVariables = !this.isShowVariables
},
onDragstart (event, field) {
this.removeDragOverlay(event)
@@ -582,6 +616,10 @@ export default {
delete customField.prefillable
delete customField.conditions
if (Array.isArray(customField.areas)) {
customField.areas = customField.areas.filter((area) => area.page !== null && area.page !== undefined)
}
customField.areas?.forEach((area) => {
delete area.attachment_uuid
delete area.page
@@ -767,6 +805,8 @@ export default {
this.$emit('change-submitter', this.submitters[0])
}
this.$emit('remove-submitter', submitter)
this.save()
},
removeField (field, save = true) {
@@ -792,6 +832,8 @@ export default {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(area), 1)
})
this.$emit('remove-field', field)
if (save) {
this.save()
}
@@ -161,7 +161,7 @@ export default {
computed: {
fields () {
return this.template.fields.reduce((acc, f) => {
if (f !== this.field && ['number'].includes(f.type) && (!f.preferences?.formula || !f.preferences.formula.includes(this.field.uuid))) {
if (f !== this.field && this.isNumberField(f) && (!f.preferences?.formula || !f.preferences.formula.includes(this.field.uuid))) {
acc.push(f)
}
@@ -176,9 +176,12 @@ export default {
this.formula = this.humanizeFormula(this.field.preferences.formula || '')
},
methods: {
isNumberField (field) {
return field.type === 'number' || (['radio', 'select'].includes(field.type) && field.options?.every((o) => String(o.value).match(/^[\d.-]+$/)))
},
humanizeFormula (text) {
return text.replace(/{{(.*?)}}/g, (match, uuid) => {
const foundField = this.fields.find((f) => f.uuid === uuid)
const foundField = this.template.fields.find((f) => f.uuid === uuid)
if (foundField) {
return `{{${foundField.name || this.buildDefaultName(foundField)}}}`
@@ -189,7 +192,7 @@ export default {
},
normalizeFormula (text) {
return text.replace(/{{(.*?)}}/g, (match, name) => {
const foundField = this.fields.find((f) => {
const foundField = this.template.fields.find((f) => {
return (f.name || this.buildDefaultName(f)).trim() === name.trim()
})
@@ -78,7 +78,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf, application/zip'
default: 'image/*, application/pdf, application/zip, application/json'
}
},
emits: ['add', 'replace', 'replace-and-clone', 'error'],
+43 -1
View File
@@ -49,6 +49,10 @@ const en = {
with_logo: 'With logo',
unchecked: 'Unchecked',
price: 'Price',
type: 'Type',
list: 'list',
no_variables: 'No variables yet',
no_variables_description: 'Add [[variable]] marks to your document to create dynamic content variables.',
type_value: 'Type value',
equal: 'Equal',
not_equal: 'Not equal',
@@ -73,6 +77,7 @@ const en = {
up: 'Up',
down: 'Down',
checked: 'Checked',
current_date: 'Current date',
save: 'Save',
cancel: 'Cancel',
any: 'Any',
@@ -104,6 +109,7 @@ const en = {
option: 'Option',
options: 'Options',
condition: 'Condition',
make_dynamic: 'Make dynamic',
first_party: 'First Party',
second_party: 'Second Party',
third_party: 'Third Party',
@@ -193,7 +199,7 @@ const en = {
learn_more: 'Learn more',
and: 'and',
or: 'or',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start a quick tour to learn how to create an send your first document',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start a quick tour to learn how to create and send your first document',
start_tour: 'Start Tour',
or_add_from: 'Or add from',
sync: 'Sync',
@@ -244,6 +250,10 @@ const es = {
search_field: 'Campo de búsqueda',
field_not_found: 'Campo no encontrado',
clear: 'Borrar',
type: 'Tipo',
list: 'lista',
no_variables: 'Aún sin variables',
no_variables_description: 'Agregue marcas [[variable]] a su documento para crear variables de contenido dinámico.',
type_value: 'Escriba valor',
align: 'Alinear',
resize: 'Redimensionar',
@@ -277,6 +287,7 @@ const es = {
remove_condition: 'Eliminar condición',
add_condition: 'Agregar condición',
condition: 'Condición',
make_dynamic: 'Hacer dinámico',
formula: 'Fórmula',
edit: 'Editar',
settings: 'Configuración',
@@ -286,6 +297,7 @@ const es = {
are_you_sure_: '¿Estás seguro?',
sign_yourself: 'Firma tú mismo',
checked: 'Seleccionado',
current_date: 'Fecha actual',
send: 'Enviar',
remove: 'Eliminar',
save: 'Guardar',
@@ -475,6 +487,10 @@ const it = {
with_logo: 'Con logo',
unchecked: 'Non selezionato',
price: 'Prezzo',
type: 'Tipo',
list: 'lista',
no_variables: 'Ancora nessuna variabile',
no_variables_description: 'Aggiungi marcatori [[variable]] al documento per creare variabili di contenuto dinamico.',
type_value: 'Inserisci valore',
equal: 'Uguale',
not_equal: 'Non uguale',
@@ -499,6 +515,7 @@ const it = {
up: 'Su',
down: 'Giù',
checked: 'Selezionato',
current_date: 'Data corrente',
save: 'Salva',
cancel: 'Annulla',
any: 'Qualsiasi',
@@ -530,6 +547,7 @@ const it = {
option: 'Opzione',
options: 'Opzioni',
condition: 'Condizione',
make_dynamic: 'Rendi dinamico',
first_party: 'Prima parte',
second_party: 'Seconda parte',
third_party: 'Terza parte',
@@ -670,6 +688,10 @@ const pt = {
search_field: 'Campo de busca',
field_not_found: 'Campo não encontrado',
clear: 'Limpar',
type: 'Tipo',
list: 'lista',
no_variables: 'Ainda sem variáveis',
no_variables_description: 'Adicione marcações [[variable]] ao documento para criar variáveis de conteúdo dinâmico.',
type_value: 'Digite valor',
add_all_required_fields_to_continue: 'Adicione todos os campos obrigatórios para continuar',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'O PDF carregado contém campos. Manter ou removê-los?',
@@ -703,6 +725,7 @@ const pt = {
select_value_: 'Selecionar valor...',
remove_condition: 'Remover condição',
condition: 'Condição',
make_dynamic: 'Tornar dinâmico',
formula: 'Fórmula',
edit: 'Editar',
settings: 'Configurações',
@@ -712,6 +735,7 @@ const pt = {
are_you_sure_: 'Tem certeza?',
sign_yourself: 'Assine você mesmo',
checked: 'Marcado',
current_date: 'Data atual',
send: 'Enviar',
remove: 'Remover',
save: 'Salvar',
@@ -901,6 +925,10 @@ const fr = {
with_logo: 'Avec logo',
unchecked: 'Décoché',
price: 'Prix',
type: 'Type',
list: 'liste',
no_variables: 'Pas encore de variables',
no_variables_description: 'Ajoutez des balises [[variable]] à votre document pour créer des variables de contenu dynamique.',
type_value: 'Saisir une valeur',
equal: 'Égal',
not_equal: 'Différent',
@@ -925,6 +953,7 @@ const fr = {
up: 'Haut',
down: 'Bas',
checked: 'Coché',
current_date: 'Date du jour',
save: 'Enregistrer',
cancel: 'Annuler',
any: "N'importe lequel",
@@ -956,6 +985,7 @@ const fr = {
option: 'Option',
options: 'Options',
condition: 'Condition',
make_dynamic: 'Rendre dynamique',
first_party: 'Première partie',
second_party: 'Deuxième partie',
third_party: 'Troisième partie',
@@ -1114,6 +1144,10 @@ const de = {
with_logo: 'Mit Logo',
unchecked: 'Nicht markiert',
price: 'Preis',
type: 'Typ',
list: 'Liste',
no_variables: 'Noch keine Variablen',
no_variables_description: 'Fügen Sie [[variable]]-Markierungen zu Ihrem Dokument hinzu, um dynamische Inhaltsvariablen zu erstellen.',
type_value: 'Wert eingeben',
equal: 'Gleich',
not_equal: 'Ungleich',
@@ -1138,6 +1172,7 @@ const de = {
up: 'Nach oben',
down: 'Nach unten',
checked: 'Markiert',
current_date: 'Aktuelles Datum',
save: 'Speichern',
cancel: 'Abbrechen',
any: 'Beliebig',
@@ -1169,6 +1204,7 @@ const de = {
option: 'Option',
options: 'Optionen',
condition: 'Bedingung',
make_dynamic: 'Dynamisch machen',
first_party: 'Erste Partei',
second_party: 'Zweite Partei',
third_party: 'Dritte Partei',
@@ -1327,6 +1363,10 @@ const nl = {
with_logo: 'Met logo',
unchecked: 'Niet aangevinkt',
price: 'Prijs',
type: 'Type',
list: 'lijst',
no_variables: 'Nog geen variabelen',
no_variables_description: 'Voeg [[variable]]-markeringen toe aan uw document om dynamische inhoudsvariabelen te maken.',
type_value: 'Typ waarde',
equal: 'Gelijk aan',
not_equal: 'Niet gelijk aan',
@@ -1351,6 +1391,7 @@ const nl = {
up: 'Omhoog',
down: 'Omlaag',
checked: 'Aangevinkt',
current_date: 'Huidige datum',
save: 'Opslaan',
cancel: 'Annuleren',
any: 'Elke',
@@ -1382,6 +1423,7 @@ const nl = {
option: 'Optie',
options: 'Opties',
condition: 'Voorwaarde',
make_dynamic: 'Dynamisch maken',
first_party: 'Eerste partij',
second_party: 'Tweede partij',
third_party: 'Derde partij',
+1 -1
View File
@@ -2,7 +2,7 @@
<svg
height="40"
width="40"
style="color: #e0753f"
style="color: #e97a42"
viewBox="0 0 180 180"
xmlns="http://www.w3.org/2000/svg"
>
+1 -1
View File
@@ -261,7 +261,7 @@ export default {
},
computed: {
isSelectMode () {
return this.isSelectModeRef.value && !this.drawFieldType && this.editable && !this.drawField
return this.isSelectModeRef.value && !this.drawFieldType && this.editable && !this.drawField && !this.drawCustomField
},
pageSelectedAreas () {
if (!this.selectedAreasRef.value) return []
@@ -22,7 +22,7 @@
>
<div
v-if="!('price_id' in field.preferences) && !('payment_link_id' in field.preferences)"
class="py-1.5 px-1 relative"
class="field-settings-currency py-1.5 px-1 relative"
@click.stop
>
<select
@@ -48,7 +48,7 @@
</label>
</div>
<div
class="py-1.5 px-1 relative"
class="field-settings-price py-1.5 px-1 relative"
@click.stop
>
<input
@@ -121,7 +121,7 @@
</div>
<div
v-if="!isConnected || isOauthSuccess"
class="py-1.5 px-1 relative"
class="field-settings-stripe-connect py-1.5 px-1 relative"
@click.stop
>
<div
@@ -203,7 +203,7 @@
>{{ t('learn_more') }}</a>
</div>
<li
class="mb-1"
class="field-settings-formula mb-1"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@@ -218,7 +218,7 @@
</label>
</li>
<hr>
<li>
<li class="field-settings-description">
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-description')"
@@ -233,7 +233,7 @@
</li>
<li
v-if="withCondition"
class="mt-1"
class="field-settings-condition mt-1"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@@ -251,7 +251,10 @@
v-if="withCustomFields"
class="pb-0.5 mt-0.5"
>
<li v-if="withCustomFields">
<li
v-if="withCustomFields"
class="field-settings-save-as-custom-field"
>
<a
href="#"
class="text-sm py-1 px-2"
+214 -56
View File
@@ -18,20 +18,18 @@
>
<div
style="width: 26px"
class="flex flex-col justify-between group-hover:opacity-100"
:class="{'opacity-0': !item.conditions?.length }"
class="flex flex-col"
>
<div>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
@click.stop="isShowConditionsModal = true"
>
<IconRouteAltLeft
:width="14"
:stroke-width="1.6"
/>
</button>
</div>
<button
v-if="item.conditions?.length"
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
@click.stop="isShowConditionsModal = true"
>
<IconRouteAltLeft
:width="14"
:stroke-width="1.6"
/>
</button>
</div>
<div class="">
<ReplaceButton
@@ -44,51 +42,105 @@
/>
</div>
<div
class="flex flex-col justify-between opacity-0 group-hover:opacity-100"
class="flex flex-col justify-between"
>
<div>
<span
class="dropdown dropdown-end group-hover:opacity-100 has-[label:focus]:opacity-100"
:class="{ 'dropdown-open': isMakeDynamicLoading, 'opacity-0': !isMakeDynamicLoading }"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
tabindex="0"
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button px-0"
style="width: 24px; height: 24px"
@click.stop
>
<IconDotsVertical
:width="16"
:height="16"
:stroke-width="1.6"
/>
</label>
<ul
v-if="renderDropdown"
tabindex="0"
class="mt-1.5 dropdown-content p-1 shadow-lg rounded-lg border border-neutral-200 z-50 bg-white"
style="min-width: 170px"
@click="closeDropdown"
>
<li>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="isShowConditionsModal = true; closeDropdown()"
>
<span class="flex items-center space-x-2">
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</span>
<span
v-if="item.conditions?.length"
class="bg-neutral-200 rounded px-1 leading-3"
style="font-size: 9px;"
>{{ item.conditions.length }}</span>
</button>
</li>
<li v-if="!item.dynamic">
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm whitespace-nowrap"
@click.stop="$emit('reorder', item); closeDropdown()"
>
<IconSortDescending2 class="w-4 h-4" />
<span>{{ t('reorder_fields') }}</span>
</button>
</li>
<li v-if="withDynamicDocuments && !item.dynamic && document.metadata?.original_uuid">
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm whitespace-nowrap"
:disabled="isMakeDynamicLoading"
@click.stop="makeDynamic"
>
<IconInnerShadowTop
v-if="isMakeDynamicLoading"
class="w-4 h-4 animate-spin"
/>
<IconBolt
v-else
class="w-4 h-4"
/>
<span>{{ t('make_dynamic') }}</span>
</button>
</li>
<hr class="my-1 border-neutral-200">
<li>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm text-red-600"
@click.stop="$emit('remove', item); closeDropdown()"
>
<IconTrashX class="w-4 h-4" />
<span>{{ t('remove') }}</span>
</button>
</li>
</ul>
</span>
<div
v-if="withArrows"
class="flex flex-col space-y-1 opacity-0 group-hover:opacity-100"
>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
style="width: 24px; height: 24px"
@click.stop="$emit('remove', item)"
@click.stop="$emit('up', item)"
>
&times;
&uarr;
</button>
</div>
<div
class="flex flex-col space-y-1"
>
<span
:data-tip="t('reorder_fields')"
class="tooltip tooltip-left before:text-xs"
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
style="width: 24px; height: 24px"
@click.stop="$emit('down', item)"
>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
@click.stop="$emit('reorder', item)"
>
<IconSortDescending2
:width="18"
:height="18"
:stroke-width="1.6"
/>
</button>
</span>
<template v-if="withArrows">
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
style="width: 24px; height: 24px"
@click.stop="$emit('up', item)"
>
&uarr;
</button>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
style="width: 24px; height: 24px"
@click.stop="$emit('down', item)"
>
&darr;
</button>
</template>
&darr;
</button>
</div>
</div>
</div>
@@ -129,24 +181,29 @@
<script>
import Contenteditable from './contenteditable'
import Upload from './upload'
import { IconRouteAltLeft, IconSortDescending2 } from '@tabler/icons-vue'
import { IconRouteAltLeft, IconSortDescending2, IconDotsVertical, IconTrashX, IconBolt, IconInnerShadowTop } from '@tabler/icons-vue'
import ConditionsModal from './conditions_modal'
import ReplaceButton from './replace'
import GoogleDriveDocumentSettings from './google_drive_document_settings'
import Field from './field'
import FieldType from './field_type'
import { v4 } from 'uuid'
export default {
name: 'DocumentPreview',
components: {
Contenteditable,
IconInnerShadowTop,
IconRouteAltLeft,
ConditionsModal,
ReplaceButton,
GoogleDriveDocumentSettings,
IconSortDescending2
IconSortDescending2,
IconDotsVertical,
IconTrashX,
IconBolt
},
inject: ['t', 'getFieldTypeIndex'],
inject: ['t', 'getFieldTypeIndex', 'baseFetch'],
props: {
item: {
type: Object,
@@ -168,13 +225,22 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf, application/zip'
default: 'image/*, application/pdf, application/zip, application/json'
},
withReplaceButton: {
type: Boolean,
required: true,
default: true
},
dynamicDocuments: {
type: Array,
required: true
},
withDynamicDocuments: {
type: Boolean,
required: false,
default: false
},
withArrows: {
type: Boolean,
required: false,
@@ -184,7 +250,9 @@ export default {
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'reorder'],
data () {
return {
isShowConditionsModal: false
isShowConditionsModal: false,
isMakeDynamicLoading: false,
renderDropdown: false
}
},
computed: {
@@ -200,6 +268,96 @@ export default {
methods: {
upload: Upload.methods.upload,
buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
makeDynamic () {
this.isMakeDynamicLoading = true
Promise.all([
this.baseFetch(`/templates/${this.template.id}/dynamic_documents`, {
method: 'POST',
body: JSON.stringify({ uuid: this.document.uuid }),
headers: {
'Content-Type': 'application/json'
}
}),
import(/* webpackChunkName: "dynamic-editor" */ './dynamic_document')
]).then(async ([resp, _]) => {
const dynamicDocument = await resp.json()
this.template.schema.find((item) => item.attachment_uuid === dynamicDocument.uuid).dynamic = true
this.removeFieldAreas()
if (dynamicDocument.fields?.length) {
this.addDynamicFields(dynamicDocument.fields)
}
if (dynamicDocument.uuid) {
delete dynamicDocument.fields
this.dynamicDocuments.push(dynamicDocument)
}
this.$emit('change')
}).finally(() => {
this.isMakeDynamicLoading = false
})
},
removeFieldAreas () {
this.template.fields.forEach((field) => {
if (field.areas?.length) {
field.areas = field.areas.filter((a) => a.attachment_uuid !== this.document.uuid)
}
})
this.template.fields = this.template.fields.filter((field) => field.areas?.length)
},
addDynamicFields (fields) {
const submittersNameIndex = this.template.submitters.reduce((acc, submitter) => {
acc[submitter.name] = submitter
return acc
}, {})
fields.forEach((field) => {
const roleName = field.role || this.template.submitters[0]?.name || this.t('first_party')
let submitter = submittersNameIndex[roleName]
if (!submitter) {
submitter = { name: roleName, uuid: v4() }
this.template.submitters.push(submitter)
submittersNameIndex[roleName] = submitter
}
const existingField = this.template.fields.find((f) => {
return f.name && f.name === field.name && f.type === (field.type || 'text') && f.submitter_uuid === submitter.uuid
})
if (existingField) {
field.areas.forEach((area) => {
area.attachment_uuid = this.document.uuid
existingField.areas = existingField.areas || []
existingField.areas.push(area)
})
} else {
field.submitter_uuid = submitter.uuid
delete field.role
field.areas.forEach((area) => {
area.attachment_uuid = this.document.uuid
})
this.template.fields.push(field)
}
})
},
onUpdateName (value) {
this.item.name = value
+1 -1
View File
@@ -35,7 +35,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf, application/zip'
default: 'image/*, application/pdf, application/zip, application/json'
}
},
emits: ['success'],
+1 -1
View File
@@ -195,7 +195,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf, application/zip'
default: 'image/*, application/pdf, application/zip, application/json'
}
},
emits: ['success', 'error'],
@@ -1,21 +0,0 @@
# frozen_string_literal: true
class GenerateAttachmentPreviewJob
include Sidekiq::Job
InvalidFormat = Class.new(StandardError)
sidekiq_options queue: :images
def perform(params = {})
attachment = ActiveStorage::Attachment.find(params['attachment_id'])
if attachment.content_type == Templates::ProcessDocument::PDF_CONTENT_TYPE
Templates::ProcessDocument.generate_pdf_preview_images(attachment, attachment.download)
elsif attachment.image?
Templates::ProcessDocument.generate_preview_image(attachment, attachment.download)
else
raise InvalidFormat, attachment.id
end
end
end
@@ -0,0 +1,38 @@
# frozen_string_literal: true
class SendTemplateArchivedWebhookRequestJob
include Sidekiq::Job
sidekiq_options queue: :webhooks
MAX_ATTEMPTS = 10
def perform(params = {})
template = Template.find_by(id: params['template_id'])
return unless template
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
return unless webhook_url
attempt = params['attempt'].to_i
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.archived')
resp = SendWebhookRequest.call(webhook_url, event_type: 'template.archived',
event_uuid: params['event_uuid'],
record: template,
attempt:,
data: template.as_json(only: %i[id archived_at]))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
end
+1 -1
View File
@@ -26,7 +26,7 @@ class SendTestWebhookRequestJob
Addressable::URI.parse(webhook_url.url).normalize
end
raise HttpsError, 'Only HTTPS is allowed.' if uri.scheme != 'https'
raise HttpsError, 'Only HTTPS is allowed.' if uri.scheme != 'https' || [443, nil].exclude?(uri.port)
raise LocalhostError, "Can't send to localhost." if uri.host.in?(SendWebhookRequest::LOCALHOSTS)
end
+2
View File
@@ -5,6 +5,8 @@ class ApplicationMailer < ActionMailer::Base
layout 'mailer'
register_interceptor ActionMailerConfigsInterceptor
register_interceptor HtmlToPlainTextInterceptor
register_preview_interceptor HtmlToPlainTextInterceptor
register_observer ActionMailerEventsObserver
+12 -7
View File
@@ -14,12 +14,7 @@ class SubmitterMailer < ApplicationMailer
@email_message = submitter.account.email_messages.find_by(uuid: submitter.preferences['email_message_uuid'])
end
template_submitters_index =
if @email_message.blank?
build_submitter_preferences_index(@submitter)
else
{}
end
template_submitters_index = @email_message.blank? ? build_submitter_preferences_index(@submitter) : {}
@body = @email_message&.body.presence ||
template_submitters_index.dig(@submitter.uuid, 'request_email_body').presence ||
@@ -34,7 +29,9 @@ class SubmitterMailer < ApplicationMailer
assign_message_metadata('submitter_invitation', @submitter)
reply_to = build_submitter_reply_to(@submitter)
reply_to = build_submitter_reply_to(@submitter, email_config: @email_config)
maybe_set_custom_domain(@submitter)
I18n.with_locale(@current_account.locale) do
subject = build_invite_subject(@subject, @email_config, submitter)
@@ -133,6 +130,8 @@ class SubmitterMailer < ApplicationMailer
assign_message_metadata('submitter_documents_copy', @submitter)
reply_to = build_submitter_reply_to(submitter, email_config: @email_config, documents_copy_email: true)
maybe_set_custom_domain(@submitter)
I18n.with_locale(@current_account.locale) do
subject =
@subject.present? ? ReplaceEmailVariables.call(@subject, submitter:) : I18n.t(:your_document_copy)
@@ -262,4 +261,10 @@ class SubmitterMailer < ApplicationMailer
def fetch_config_email_body(email_config, _submitter = nil)
email_config ? email_config.value['body'].presence : nil
end
def maybe_set_custom_domain(submitter)
if Docuseal.multitenant? && (config = AccountConfig.find_by(account_id: submitter.account_id, key: :custom_domain))
@custom_domain = config.value
end
end
end

Some files were not shown because too many files have changed in this diff Show More