mirror of
https://github.com/docusealco/docuseal.git
synced 2026-08-07 15:25:16 +00:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 911e55ccc3 | |||
| 41fedfcc40 | |||
| 2f9fc95af3 | |||
| 11b0b16ca7 | |||
| f8cb7ffdab | |||
| 8b59c0aaa0 | |||
| bb2fb7a0c2 | |||
| 8f8b36617a | |||
| ff53436fd4 | |||
| cf09a4f733 | |||
| 2303c21cea | |||
| 89e797b95f | |||
| 20375c3a42 | |||
| 339ceda18d | |||
| 97ce32fd52 | |||
| 8c4cbd86f7 | |||
| eaedaa96bb | |||
| bd7ad5fce8 | |||
| 759dac1b70 | |||
| 38577c6235 | |||
| b24fff0f55 | |||
| 9e0efeb092 | |||
| 41152f90fc | |||
| 4486f8040e | |||
| a98caad968 | |||
| ffb75238b4 | |||
| cba3e74f3a | |||
| 8d3f0b5b98 | |||
| a2598b30f7 | |||
| 8d11f5ddae | |||
| 6ffc88b4ed | |||
| 640b451727 | |||
| db6e2eb7ca | |||
| 407816b576 | |||
| 486cc493e6 | |||
| 3eb0b8a89f | |||
| c23eca0ade | |||
| 67de153016 | |||
| ba2da69a67 | |||
| 111bef14e1 | |||
| 22d4c1bd10 | |||
| e3dfb0ae83 | |||
| 8d7c3f0e26 | |||
| b11775ebda | |||
| 608191183a | |||
| 2ba903e170 | |||
| 342b64d502 | |||
| 1a8f048aaa | |||
| 7309581b75 | |||
| 36d59c40a7 | |||
| ed3c7c324b | |||
| fd868360a3 | |||
| 8dc2ebfbe0 | |||
| 997711b34a | |||
| ac01b7129d | |||
| f4b7528c0d | |||
| d7b9ae5df5 | |||
| 4db29126e9 | |||
| 4cfee2fe45 | |||
| db5dbd6ac2 | |||
| 44db775b89 | |||
| 8e2e780a1a | |||
| 7aba2eb4da | |||
| 3e250bfb28 | |||
| 9c02ed66f5 | |||
| f5ae8c60d3 | |||
| e90ac391b2 | |||
| 0e5f949bcf | |||
| b7c270bef8 | |||
| 5f16b506f5 | |||
| 176148fc71 | |||
| 861daaedf4 | |||
| d8430f4e63 | |||
| dd44864485 | |||
| b3bb29f383 | |||
| b4d137c0de | |||
| 62a969d8fe | |||
| 961f09e092 | |||
| b2dfa83f18 | |||
| 37196ff89f | |||
| b50d982497 | |||
| 4a484aca69 | |||
| 0bf37d571a | |||
| d4e94ac2bc | |||
| 75bc789bb1 | |||
| bc231d5bf1 | |||
| ab9008b3be | |||
| 65491936c3 | |||
| e589998840 | |||
| 0afba8d11e | |||
| f622ed168f | |||
| 5a31df40d8 | |||
| db3b80c96c | |||
| bd28a761d0 | |||
| 4f2830a34d | |||
| a5716cd518 | |||
| d035525c7a |
@@ -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
|
||||
|
||||
|
||||
+12
-15
@@ -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,13 +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
|
||||
ENV VIPS_MAX_COORD=10000
|
||||
|
||||
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
|
||||
|
||||
@@ -70,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
|
||||
@@ -80,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"]
|
||||
|
||||
@@ -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,7 +29,6 @@ gem 'oj'
|
||||
gem 'onnxruntime', require: false
|
||||
gem 'pagy'
|
||||
gem 'pg', require: false
|
||||
gem 'premailer-rails'
|
||||
gem 'pretender'
|
||||
gem 'puma', require: false
|
||||
gem 'rack'
|
||||
@@ -43,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
-163
@@ -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,17 +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-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)
|
||||
@@ -239,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)
|
||||
@@ -247,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)
|
||||
@@ -258,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)
|
||||
@@ -267,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)
|
||||
@@ -306,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)
|
||||
@@ -321,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)
|
||||
@@ -337,18 +328,18 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.1-aarch64-linux-gnu)
|
||||
nokogiri (1.19.2-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-aarch64-linux-musl)
|
||||
nokogiri (1.19.2-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-arm64-darwin)
|
||||
nokogiri (1.19.2-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-x86_64-linux-gnu)
|
||||
nokogiri (1.19.2-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-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-aarch64-linux)
|
||||
@@ -357,16 +348,17 @@ GEM
|
||||
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-aarch64-linux)
|
||||
@@ -376,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)
|
||||
@@ -397,47 +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.5)
|
||||
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)
|
||||
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)
|
||||
@@ -446,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)
|
||||
@@ -464,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)
|
||||
@@ -477,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)
|
||||
@@ -497,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)
|
||||
@@ -525,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)
|
||||
@@ -550,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)
|
||||
@@ -573,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)
|
||||
@@ -584,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)
|
||||
@@ -601,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
|
||||
@@ -611,6 +596,7 @@ PLATFORMS
|
||||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
addressable
|
||||
annotaterb
|
||||
arabic-letter-connector
|
||||
aws-sdk-s3
|
||||
@@ -647,7 +633,6 @@ DEPENDENCIES
|
||||
onnxruntime
|
||||
pagy
|
||||
pg
|
||||
premailer-rails
|
||||
pretender
|
||||
pry-rails
|
||||
puma
|
||||
@@ -669,7 +654,7 @@ DEPENDENCIES
|
||||
simplecov
|
||||
sqlite3
|
||||
strip_attributes
|
||||
trilogy!
|
||||
trilogy
|
||||
turbo-rails
|
||||
twitter_cldr
|
||||
tzinfo-data
|
||||
|
||||
@@ -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.
|
||||
@@ -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])
|
||||
|
||||
@@ -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:,
|
||||
|
||||
@@ -40,13 +40,18 @@ module Api
|
||||
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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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])
|
||||
@@ -117,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]] }]]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,92 +1,25 @@
|
||||
# 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 !signature_valid && !current_user_submitter?(last_submitter)
|
||||
return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
|
||||
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)
|
||||
url = Submitters.build_combined_url(last_submitter)
|
||||
|
||||
if url
|
||||
render json: [url]
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
else
|
||||
render json: build_urls(last_submitter)
|
||||
render json: Submitters.build_document_urls(last_submitter)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def respond_with_combined(submitter)
|
||||
url = 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
|
||||
|
||||
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_path(
|
||||
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_path(
|
||||
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
|
||||
@@ -3,11 +3,12 @@
|
||||
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]
|
||||
|
||||
@@ -91,6 +92,12 @@ class SubmitFormController < ApplicationController
|
||||
|
||||
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
|
||||
@@ -108,8 +115,18 @@ 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)
|
||||
|
||||
@@ -4,31 +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? ||
|
||||
!Submitters::AuthorizedForForm.call(submitter, current_user,
|
||||
request)
|
||||
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? ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,10 +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_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,
|
||||
@@ -107,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -155,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',
|
||||
@@ -174,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,
|
||||
|
||||
@@ -36,15 +36,11 @@ function loadTiptap () {
|
||||
}
|
||||
|
||||
class LinkTooltip {
|
||||
constructor (container, editor) {
|
||||
constructor (container, editor, templateEl) {
|
||||
this.container = container
|
||||
this.editor = editor
|
||||
|
||||
const template = document.createElement('template')
|
||||
|
||||
template.innerHTML = container.dataset.linkTooltipHtml
|
||||
|
||||
this.tooltip = template.content.firstElementChild
|
||||
this.tooltip = templateEl.content.firstElementChild.cloneNode(true)
|
||||
|
||||
this.input = this.tooltip.querySelector('input')
|
||||
this.saveButton = this.tooltip.querySelector('[data-role="link-save"]')
|
||||
@@ -140,7 +136,8 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
'boldButton',
|
||||
'italicButton',
|
||||
'underlineButton',
|
||||
'linkButton'
|
||||
'linkButton',
|
||||
'linkTooltipTemplate'
|
||||
]
|
||||
|
||||
async connectedCallback () {
|
||||
@@ -256,7 +253,7 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
}
|
||||
})
|
||||
|
||||
this.linkTooltip = new LinkTooltip(this, this.editor)
|
||||
this.linkTooltip = new LinkTooltip(this, this.editor, this.linkTooltipTemplate)
|
||||
}
|
||||
|
||||
adjustShortcutsForPlatform () {
|
||||
|
||||
@@ -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) }
|
||||
@@ -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"
|
||||
@@ -832,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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -359,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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -81,7 +81,7 @@
|
||||
/>
|
||||
<template v-else>
|
||||
<form
|
||||
v-if="withSignYourselfButton && undefinedSubmitters.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,
|
||||
@@ -948,13 +986,6 @@ export default {
|
||||
!submitter.email
|
||||
})
|
||||
},
|
||||
withPrefillable () {
|
||||
if (this.template.fields) {
|
||||
return this.template.fields.some((f) => f.prefillable)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
isInlineSize () {
|
||||
return CSS.supports('container-type: size')
|
||||
},
|
||||
@@ -1133,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
|
||||
|
||||
@@ -2490,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) {
|
||||
@@ -2554,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)
|
||||
@@ -2656,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
|
||||
@@ -2667,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, {
|
||||
@@ -2895,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' }
|
||||
@@ -2904,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 = {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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()]"
|
||||
>
|
||||
×
|
||||
</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]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
×
|
||||
↑
|
||||
</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)"
|
||||
>
|
||||
↑
|
||||
</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)"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</template>
|
||||
↓
|
||||
</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
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
@@ -5,6 +5,8 @@ class ApplicationMailer < ActionMailer::Base
|
||||
layout 'mailer'
|
||||
|
||||
register_interceptor ActionMailerConfigsInterceptor
|
||||
register_interceptor HtmlToPlainTextInterceptor
|
||||
register_preview_interceptor HtmlToPlainTextInterceptor
|
||||
|
||||
register_observer ActionMailerEventsObserver
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ 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)
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class AccountConfig < ApplicationRecord
|
||||
ALLOW_TYPED_SIGNATURE = 'allow_typed_signature'
|
||||
ALLOW_TO_RESUBMIT = 'allow_to_resubmit'
|
||||
ALLOW_TO_DECLINE_KEY = 'allow_to_decline'
|
||||
ALLOW_TO_DELEGATE_KEY = 'allow_to_delegate'
|
||||
ALLOW_TO_PARTIAL_DOWNLOAD_KEY = 'allow_to_partial_download'
|
||||
SUBMITTER_REMINDERS = 'submitter_reminders'
|
||||
ENFORCE_SIGNING_ORDER_KEY = 'enforce_signing_order'
|
||||
@@ -57,6 +58,7 @@ class AccountConfig < ApplicationRecord
|
||||
DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format'
|
||||
TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields'
|
||||
POLICY_LINKS_KEY = 'policy_links'
|
||||
ENABLE_MCP_KEY = 'enable_mcp'
|
||||
|
||||
EMAIL_VARIABLES = {
|
||||
SUBMITTER_INVITATION_EMAIL_KEY => %w[template.name submitter.link account.name].freeze,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
# Indexes
|
||||
#
|
||||
# index_document_generation_events_on_submitter_id (submitter_id)
|
||||
# index_document_generation_events_on_submitter_id_and_event_name (submitter_id,event_name) UNIQUE WHERE ((event_name)::text = ANY ((ARRAY['start'::character varying, 'complete'::character varying])::text[]))
|
||||
# index_document_generation_events_on_submitter_id_and_event_name (submitter_id,event_name) UNIQUE WHERE ((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text]))
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: dynamic_documents
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# body :text not null
|
||||
# head :text
|
||||
# sha1 :string not null
|
||||
# uuid :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# template_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_dynamic_documents_on_template_id (template_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (template_id => templates.id)
|
||||
#
|
||||
class DynamicDocument < ApplicationRecord
|
||||
belongs_to :template
|
||||
|
||||
has_many_attached :attachments
|
||||
|
||||
has_many :versions, class_name: 'DynamicDocumentVersion', dependent: :destroy
|
||||
|
||||
attribute :fields, :json
|
||||
|
||||
before_validation :set_sha1
|
||||
|
||||
def set_sha1
|
||||
self.sha1 = Digest::SHA1.hexdigest(body)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: dynamic_document_versions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# areas :text not null
|
||||
# sha1 :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# dynamic_document_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_on_dynamic_document_id_sha1_3503adf557 (dynamic_document_id,sha1) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (dynamic_document_id => dynamic_documents.id)
|
||||
#
|
||||
class DynamicDocumentVersion < ApplicationRecord
|
||||
belongs_to :dynamic_document
|
||||
|
||||
has_one_attached :document
|
||||
|
||||
attribute :areas, :string, default: -> { [] }
|
||||
|
||||
serialize :areas, coder: JSON
|
||||
end
|
||||
@@ -20,7 +20,7 @@
|
||||
#
|
||||
# index_email_events_on_account_id_and_event_datetime (account_id,event_datetime)
|
||||
# index_email_events_on_email (email)
|
||||
# index_email_events_on_email_event_types (email) WHERE ((event_type)::text = ANY ((ARRAY['bounce'::character varying, 'soft_bounce'::character varying, 'permanent_bounce'::character varying, 'complaint'::character varying, 'soft_complaint'::character varying])::text[]))
|
||||
# index_email_events_on_email_event_types (email) WHERE ((event_type)::text = ANY (ARRAY[('bounce'::character varying)::text, ('soft_bounce'::character varying)::text, ('permanent_bounce'::character varying)::text, ('complaint'::character varying)::text, ('soft_complaint'::character varying)::text]))
|
||||
# index_email_events_on_emailable (emailable_type,emailable_id)
|
||||
# index_email_events_on_message_id (message_id)
|
||||
#
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_lock_events_on_event_name_and_key (event_name,key) UNIQUE WHERE ((event_name)::text = ANY ((ARRAY['start'::character varying, 'complete'::character varying])::text[]))
|
||||
# index_lock_events_on_event_name_and_key (event_name,key) UNIQUE WHERE ((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text]))
|
||||
# index_lock_events_on_key (key)
|
||||
#
|
||||
class LockEvent < ApplicationRecord
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: mcp_tokens
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# archived_at :datetime
|
||||
# name :string not null
|
||||
# sha256 :string not null
|
||||
# token_prefix :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_mcp_tokens_on_sha256 (sha256) UNIQUE
|
||||
# index_mcp_tokens_on_user_id (user_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (user_id => users.id)
|
||||
#
|
||||
class McpToken < ApplicationRecord
|
||||
TOKEN_LENGTH = 43
|
||||
|
||||
belongs_to :user
|
||||
|
||||
before_validation :set_sha256_and_token_prefix, on: :create
|
||||
|
||||
attribute :token, :string, default: -> { SecureRandom.base58(TOKEN_LENGTH) }
|
||||
|
||||
scope :active, -> { where(archived_at: nil) }
|
||||
|
||||
private
|
||||
|
||||
def set_sha256_and_token_prefix
|
||||
self.sha256 = Digest::SHA256.hexdigest(token)
|
||||
self.token_prefix = token[0, 5]
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@
|
||||
# name :text
|
||||
# preferences :text not null
|
||||
# slug :string not null
|
||||
# source :text not null
|
||||
# source :string not null
|
||||
# submitters_order :string not null
|
||||
# template_fields :text
|
||||
# template_schema :text
|
||||
@@ -75,6 +75,17 @@ class Submission < ApplicationRecord
|
||||
->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) },
|
||||
through: :template, source: :documents_attachments
|
||||
|
||||
has_many :template_schema_static_documents,
|
||||
->(e) { where(uuid: e.template_schema.reject { |s| s['dynamic'] }.pluck('attachment_uuid')) },
|
||||
through: :template, source: :documents_attachments
|
||||
|
||||
has_many :template_schema_dynamic_document_versions,
|
||||
->(e) { where(sha1: e.template_schema.select { |s| s['dynamic'] }.pluck('dynamic_document_sha1')) },
|
||||
through: :template, source: :dynamic_document_versions
|
||||
|
||||
has_many :template_schema_dynamic_document_attachments,
|
||||
through: :template_schema_dynamic_document_versions, source: :document_attachment
|
||||
|
||||
scope :active, -> { where(archived_at: nil) }
|
||||
scope :archived, -> { where.not(archived_at: nil) }
|
||||
scope :pending, lambda {
|
||||
@@ -110,13 +121,51 @@ class Submission < ApplicationRecord
|
||||
end
|
||||
|
||||
def schema_documents
|
||||
if template_id?
|
||||
template_schema_documents
|
||||
return documents_attachments unless template_id?
|
||||
|
||||
dynamic_count = template_schema&.count { |e| e['dynamic'] }.to_i
|
||||
|
||||
if variables_schema.blank?
|
||||
if dynamic_count > 0
|
||||
if dynamic_count == template_schema.size
|
||||
template_schema_dynamic_document_attachments
|
||||
else
|
||||
template_schema_dynamic_and_static_document_attachments
|
||||
end
|
||||
else
|
||||
template_schema_documents
|
||||
end
|
||||
elsif dynamic_count > 0 && dynamic_count != template_schema.size
|
||||
template_schema_submission_dynamic_and_static_document_attachments
|
||||
else
|
||||
documents_attachments
|
||||
end
|
||||
end
|
||||
|
||||
def template_schema_submission_dynamic_and_static_document_attachments
|
||||
@template_schema_submission_dynamic_and_static_document_attachments ||=
|
||||
ActiveStorage::Attachment.where(
|
||||
ActiveStorage::Attachment.arel_table[:id].in(
|
||||
template_schema_static_documents.select(:id).arel.union(
|
||||
:all,
|
||||
documents_attachments.select(:id).arel
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def template_schema_dynamic_and_static_document_attachments
|
||||
@template_schema_dynamic_and_static_document_attachments ||=
|
||||
ActiveStorage::Attachment.where(
|
||||
ActiveStorage::Attachment.arel_table[:id].in(
|
||||
template_schema_static_documents.select(:id).arel.union(
|
||||
:all,
|
||||
template_schema_dynamic_document_attachments.select(:id).arel
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def fields_uuid_index
|
||||
@fields_uuid_index ||= (template_fields || template.fields).index_by { |f| f['uuid'] }
|
||||
end
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
# index_submission_events_on_created_at (created_at)
|
||||
# index_submission_events_on_submission_id (submission_id)
|
||||
# index_submission_events_on_submitter_id (submitter_id)
|
||||
# index_submissions_events_on_sms_event_types (account_id,created_at) WHERE ((event_type)::text = ANY ((ARRAY['send_sms'::character varying, 'send_2fa_sms'::character varying])::text[]))
|
||||
# index_submissions_events_on_sms_event_types (account_id,created_at) WHERE ((event_type)::text = ANY (ARRAY[('send_sms'::character varying)::text, ('send_2fa_sms'::character varying)::text]))
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
@@ -64,6 +64,7 @@ class SubmissionEvent < ApplicationRecord
|
||||
invite_party: 'invite_party',
|
||||
complete_form: 'complete_form',
|
||||
decline_form: 'decline_form',
|
||||
delegate_form: 'delegate_form',
|
||||
api_complete_form: 'api_complete_form'
|
||||
}, scope: false
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ class Submitter < ApplicationRecord
|
||||
belongs_to :account
|
||||
has_one :template, through: :submission
|
||||
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy if SearchEntry.table_exists?
|
||||
has_many :submitter_versions, dependent: :destroy
|
||||
|
||||
attribute :values, :string, default: -> { {} }
|
||||
attribute :preferences, :string, default: -> { {} }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: submitter_versions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# email :string
|
||||
# name :string
|
||||
# phone :string
|
||||
# slug :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# submitter_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_submitter_versions_on_slug (slug)
|
||||
# index_submitter_versions_on_submitter_id (submitter_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (submitter_id => submitters.id)
|
||||
#
|
||||
class SubmitterVersion < ApplicationRecord
|
||||
belongs_to :submitter
|
||||
end
|
||||
@@ -70,6 +70,12 @@ class Template < ApplicationRecord
|
||||
has_many :submissions, dependent: :destroy
|
||||
has_many :template_sharings, dependent: :destroy
|
||||
has_many :template_accesses, dependent: :destroy
|
||||
has_many :dynamic_documents, dependent: :destroy
|
||||
has_many :dynamic_document_versions, through: :dynamic_documents, source: :versions
|
||||
|
||||
has_many :schema_dynamic_documents, lambda { |e|
|
||||
where(uuid: e.schema.select { |e| e['dynamic'] }.pluck('attachment_uuid'))
|
||||
}, class_name: 'DynamicDocument', dependent: :destroy, inverse_of: :template
|
||||
|
||||
scope :active, -> { where(archived_at: nil) }
|
||||
scope :archived, -> { where.not(archived_at: nil) }
|
||||
|
||||
@@ -62,6 +62,7 @@ class User < ApplicationRecord
|
||||
belongs_to :account
|
||||
has_one :access_token, dependent: :destroy
|
||||
has_many :access_tokens, dependent: :destroy
|
||||
has_many :mcp_tokens, dependent: :destroy
|
||||
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
|
||||
has_many :template_folders, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
|
||||
has_many :user_configs, dependent: :destroy
|
||||
|
||||
@@ -34,6 +34,7 @@ class WebhookUrl < ApplicationRecord
|
||||
submission.archived
|
||||
template.created
|
||||
template.updated
|
||||
template.archived
|
||||
].freeze
|
||||
|
||||
belongs_to :account
|
||||
|
||||
@@ -142,9 +142,38 @@
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false %>
|
||||
</submit-form>
|
||||
<% if !Docuseal.multitenant? || can?(:manage, :disable_decline) %>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false %>
|
||||
</submit-form>
|
||||
<% else %>
|
||||
<a href="<%= console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") %>" data-turbo="false" data-tip="<%= I18n.t('unlock_with_docuseal_pro') %>" data-on="change" class="flex tooltip">
|
||||
<%= f.check_box :value, class: 'toggle pointer-events-none', checked: account_config.value != false, disabled: true %>
|
||||
</a>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TO_DELEGATE_KEY) %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('allow_to_delegate_documents') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('allow_recipients_to_delegate_documents') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<% if !Docuseal.multitenant? || can?(:manage, :delegate_form) %>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= f.check_box :value, class: 'toggle', checked: account_config.value == true %>
|
||||
</submit-form>
|
||||
<% else %>
|
||||
<a href="<%= console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") %>" data-turbo="false" data-tip="<%= I18n.t('unlock_with_docuseal_pro') %>" class="flex tooltip">
|
||||
<%= f.check_box :value, class: 'toggle pointer-events-none', checked: account_config.value == true, disabled: true %>
|
||||
</a>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user