mirror of
https://github.com/docusealco/docuseal.git
synced 2026-08-07 07:14:43 +00:00
Compare commits
217 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 | |||
| 13fa87c449 | |||
| 246ccfb049 | |||
| cbffb1e5b8 | |||
| c4b007411d | |||
| 04264fe937 | |||
| 0c37ad12b7 | |||
| 8c5298ed2e | |||
| 262118e047 | |||
| e5b63ea2b0 | |||
| 746757d402 | |||
| e6e640328b | |||
| 845782a69c | |||
| fc6baa1b3b | |||
| 47822ecc15 | |||
| 3d0c7f1118 | |||
| e9c8e4d325 | |||
| 39cc82ce0c | |||
| 451f83421a | |||
| 5073f2eefb | |||
| 231fff5508 | |||
| f1d146eca3 | |||
| b6635fcc4f | |||
| fb5e13ee4c | |||
| ba84741a64 | |||
| 65c275ac17 | |||
| 825322d489 | |||
| 739e2abdf8 | |||
| 1d2394e31e | |||
| 1b41af798d | |||
| 118f4a231b | |||
| e48652f425 | |||
| 848f01edf8 | |||
| 43fbc42770 | |||
| 2c736a0eed | |||
| 33ca930055 | |||
| 3b396f8421 | |||
| aa77e3a8d3 | |||
| 75316d8d87 | |||
| a999109a5c | |||
| bdd33c7d6b | |||
| 61c5ee22a0 | |||
| 12c5b909e0 | |||
| 871ef6dda6 | |||
| 40052a2d7c | |||
| 347be0137d | |||
| fe6baba8bf | |||
| ed8c313bd4 | |||
| ca0acb34d6 | |||
| 34ea639c25 | |||
| 680ab9dbed | |||
| 9377766e52 | |||
| 7a2c37454e | |||
| 8f6f418f54 | |||
| 9b7745c565 | |||
| a389e8cc38 | |||
| 2056301f70 | |||
| 93babe3660 | |||
| 4aef2d8eb0 | |||
| c5ec2ed230 | |||
| a156d5f6cc | |||
| f84674cddd | |||
| b7b62c197a | |||
| f5350eac36 | |||
| ee6228db15 | |||
| 3e434c0878 | |||
| 7c5372a8e2 | |||
| 6ab68cc36b | |||
| ee2400a5c5 | |||
| 80be5d2cf5 | |||
| 76cf44a31b | |||
| eb63113227 | |||
| b7045fb3d9 | |||
| 11a5b5842d | |||
| 362994afd5 | |||
| d7682f4ebd | |||
| 56d4fe7fa9 | |||
| 93a852ac14 | |||
| 964db0ab08 | |||
| 8547295da7 | |||
| c75050fa46 | |||
| 65372c52aa | |||
| 12c767b7d3 | |||
| 2fc5e9cfb3 | |||
| bccc78ee65 | |||
| c0793f3ce7 | |||
| a8dd8699a3 | |||
| 91bb25e8b6 | |||
| c3f4480cdc | |||
| 33e226861e | |||
| 4f5acbec9d | |||
| d2c9bbf6ab | |||
| b5e57e1f2f | |||
| c82c8b2272 | |||
| 43f1b598a7 | |||
| 5081e387d0 | |||
| 1e5f55c1d6 | |||
| aa38773be3 | |||
| 96530bd809 | |||
| 127f95a9d0 | |||
| cf31e12348 | |||
| 4adaa531b7 | |||
| c7659563b8 | |||
| 7b5494468f | |||
| d12c67fd54 | |||
| e20eacb5ee | |||
| 98055f0762 | |||
| a46c42c3a0 | |||
| c3d1503361 | |||
| 65b47d27cd | |||
| 968c07f8be | |||
| 91814ca105 | |||
| 9128f24270 | |||
| 264897bbd5 | |||
| 67d59ff1ba | |||
| 81eee56e1e | |||
| 92ea8af786 | |||
| df0616f77e | |||
| a337dff85c | |||
| 48e349c1d2 | |||
| df9fa0fec7 |
@@ -12,7 +12,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 3.4.2
|
||||
ruby-version: 4.0.1
|
||||
- name: Cache gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 3.4.2
|
||||
ruby-version: 4.0.1
|
||||
- name: Cache gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 20.9.0
|
||||
node-version: 20.19.0
|
||||
- name: Cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 3.4.2
|
||||
ruby-version: 4.0.1
|
||||
- name: Cache gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -132,11 +132,11 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 3.4.2
|
||||
ruby-version: 4.0.1
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 20.9.0
|
||||
node-version: 20.19.0
|
||||
- name: Install Chrome
|
||||
uses: browser-actions/setup-chrome@latest
|
||||
with:
|
||||
|
||||
+25
-3
@@ -1,4 +1,4 @@
|
||||
require:
|
||||
plugins:
|
||||
- rubocop-performance
|
||||
- rubocop-rails
|
||||
- rubocop-rspec
|
||||
@@ -10,7 +10,7 @@ AllCops:
|
||||
- node_modules/**/*
|
||||
- bin/*
|
||||
- vendor/**/*
|
||||
TargetRubyVersion: '3.3'
|
||||
TargetRubyVersion: '4.0'
|
||||
SuggestExtensions: false
|
||||
|
||||
Metrics/BlockLength:
|
||||
@@ -51,6 +51,15 @@ Style/NumericPredicate:
|
||||
Style/MinMaxComparison:
|
||||
Enabled: false
|
||||
|
||||
Style/EmptyClassDefinition:
|
||||
Enabled: false
|
||||
|
||||
Style/OneClassPerFile:
|
||||
Enabled: false
|
||||
|
||||
Layout/MultilineMethodCallIndentation:
|
||||
Enabled: false
|
||||
|
||||
Naming/PredicateMethod:
|
||||
Enabled: false
|
||||
|
||||
@@ -84,7 +93,7 @@ RSpec/AnyInstance:
|
||||
Enabled: false
|
||||
|
||||
Metrics/BlockNesting:
|
||||
Max: 5
|
||||
Max: 6
|
||||
|
||||
Rails/I18nLocaleTexts:
|
||||
Enabled: false
|
||||
@@ -100,3 +109,16 @@ Rails/ApplicationController:
|
||||
|
||||
Rails/Output:
|
||||
Enabled: false
|
||||
|
||||
Rails/StrongParametersExpect:
|
||||
Enabled: false
|
||||
|
||||
Rails/RedirectBackOrTo:
|
||||
Enabled: false
|
||||
|
||||
Rails/UnknownEnv:
|
||||
Environments:
|
||||
- development
|
||||
- test
|
||||
- production
|
||||
- local
|
||||
|
||||
+16
-20
@@ -1,22 +1,19 @@
|
||||
FROM ruby:3.4.2-alpine AS download
|
||||
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 -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.1.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 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/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:3.4.2-alpine AS webpack
|
||||
FROM ruby:4.0.1-alpine AS webpack
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV NODE_ENV=production
|
||||
@@ -36,22 +33,22 @@ 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
|
||||
|
||||
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
|
||||
|
||||
FROM ruby:3.4.2-alpine AS app
|
||||
FROM ruby:4.0.1-alpine AS app
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV BUNDLE_WITHOUT="development:test"
|
||||
ENV LD_PRELOAD=/lib/libgcompat.so.0
|
||||
ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
|
||||
RUN apk add --no-cache libpq vips redis vips-heif fontconfig onnxruntime
|
||||
|
||||
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
|
||||
|
||||
@@ -69,9 +66,7 @@ activate = 1' >> /etc/openssl_legacy.cnf
|
||||
|
||||
COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./
|
||||
|
||||
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && 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 echo 'https://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories && echo 'https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache onnxruntime
|
||||
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
|
||||
@@ -81,23 +76,24 @@ COPY --chown=docuseal:docuseal ./log ./log
|
||||
COPY --chown=docuseal:docuseal ./lib ./lib
|
||||
COPY --chown=docuseal:docuseal ./public ./public
|
||||
COPY --chown=docuseal:docuseal ./tmp ./tmp
|
||||
COPY --chown=docuseal:docuseal LICENSE README.md Rakefile config.ru .version ./
|
||||
COPY --chown=docuseal:docuseal LICENSE LICENSE_ADDITIONAL_TERMS README.md Rakefile config.ru .version ./
|
||||
COPY --chown=docuseal:docuseal .version ./public/version
|
||||
|
||||
COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
|
||||
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
|
||||
COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts/LICENSE /fonts/
|
||||
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
|
||||
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
|
||||
COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
|
||||
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
|
||||
|
||||
RUN ln -s /fonts /app/public/fonts && \
|
||||
RUN mkdir -p /app/public/fonts && ln -s /fonts/DancingScript-Regular.otf /app/public/fonts/ && \
|
||||
mkdir -p /usr/share/fonts/noto && ln -s /fonts/GoNotoKurrent-Regular.ttf /usr/share/fonts/noto/ && ln -s /fonts/GoNotoKurrent-Bold.ttf /usr/share/fonts/noto/ && fc-cache -f && \
|
||||
bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \
|
||||
chown -R docuseal:docuseal /app/tmp/cache
|
||||
|
||||
WORKDIR /data/docuseal
|
||||
ENV HOME=/home/docuseal
|
||||
ENV WORKDIR=/data/docuseal
|
||||
ENV VIPS_MAX_COORD=17000
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby '3.4.2'
|
||||
ruby '4.0.1'
|
||||
|
||||
gem 'arabic-letter-connector', require: 'arabic-letter-connector/logic'
|
||||
gem 'addressable'
|
||||
gem 'arabic-letter-connector', require: false
|
||||
gem 'aws-sdk-s3', require: false
|
||||
gem 'aws-sdk-secretsmanager', require: false
|
||||
gem 'azure-storage-blob', require: false
|
||||
gem 'azure-blob', require: false
|
||||
gem 'bootsnap', require: false
|
||||
gem 'cancancan'
|
||||
gem 'csv'
|
||||
gem 'csv-safe'
|
||||
gem 'csv', require: false
|
||||
gem 'csv-safe', require: false
|
||||
gem 'devise'
|
||||
gem 'devise-two-factor'
|
||||
gem 'dotenv', require: false
|
||||
@@ -21,30 +22,28 @@ gem 'faraday-follow_redirects'
|
||||
gem 'google-cloud-storage', require: false
|
||||
gem 'hexapdf'
|
||||
gem 'image_processing'
|
||||
gem 'jwt'
|
||||
gem 'jwt', require: false
|
||||
gem 'lograge'
|
||||
gem 'mysql2', require: false
|
||||
gem 'numo-narray'
|
||||
gem 'numo-narray-alt', require: false
|
||||
gem 'oj'
|
||||
gem 'onnxruntime'
|
||||
gem 'onnxruntime', require: false
|
||||
gem 'pagy'
|
||||
gem 'pg', require: false
|
||||
gem 'premailer-rails'
|
||||
gem 'pretender'
|
||||
gem 'puma', require: false
|
||||
gem 'rack'
|
||||
gem 'rails'
|
||||
gem 'rails_autolink'
|
||||
gem 'rails-i18n'
|
||||
gem 'rotp'
|
||||
gem 'rouge', require: false
|
||||
gem 'rqrcode'
|
||||
gem 'rqrcode', require: false
|
||||
gem 'ruby-vips'
|
||||
gem 'rubyXL'
|
||||
gem 'rubyXL', require: false
|
||||
gem 'shakapacker'
|
||||
gem 'sidekiq'
|
||||
gem 'sqlite3', require: false
|
||||
gem 'strip_attributes'
|
||||
gem 'trilogy', require: false
|
||||
gem 'turbo-rails'
|
||||
gem 'twitter_cldr', require: false
|
||||
gem 'tzinfo-data'
|
||||
|
||||
+295
-318
@@ -1,29 +1,31 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
action_text-trix (2.1.18)
|
||||
railties
|
||||
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.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
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.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
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.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
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)
|
||||
@@ -31,55 +33,58 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
actiontext (8.1.3)
|
||||
action_text-trix (~> 2.1.15)
|
||||
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.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
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.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
activejob (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
activerecord (8.0.2.1)
|
||||
activemodel (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
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.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
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.0.2.1)
|
||||
activesupport (8.1.3)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
json
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
annotaterb (4.14.0)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.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.1197.0)
|
||||
aws-sdk-core (3.240.0)
|
||||
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)
|
||||
@@ -87,44 +92,38 @@ GEM
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.118.0)
|
||||
aws-sdk-core (~> 3, >= 3.239.1)
|
||||
aws-sdk-kms (1.123.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.208.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
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.110.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
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)
|
||||
azure-storage-blob (2.0.3)
|
||||
azure-storage-common (~> 2.0)
|
||||
nokogiri (~> 1, >= 1.10.8)
|
||||
azure-storage-common (2.0.4)
|
||||
faraday (~> 1.0)
|
||||
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
|
||||
net-http-persistent (~> 4.0)
|
||||
nokogiri (~> 1, >= 1.10.8)
|
||||
azure-blob (0.8.0)
|
||||
cgi
|
||||
rexml
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.1)
|
||||
better_html (2.1.1)
|
||||
actionview (>= 6.0)
|
||||
activesupport (>= 6.0)
|
||||
bcrypt (3.1.22)
|
||||
better_html (2.2.0)
|
||||
actionview (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
ast (~> 2.0)
|
||||
erubi (~> 1.4)
|
||||
parser (>= 2.4)
|
||||
smart_properties
|
||||
bigdecimal (3.2.2)
|
||||
bigdecimal (4.1.0)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.4)
|
||||
bootsnap (1.23.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.0)
|
||||
brakeman (8.0.4)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bullet (8.0.0)
|
||||
bullet (8.1.0)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
camertron-eprun (1.1.1)
|
||||
@@ -138,51 +137,50 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
cgi (0.5.1)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
cldr-plurals-runtime-rb (1.1.0)
|
||||
cmdparse (3.0.7)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
crack (1.0.0)
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (3.0.2)
|
||||
crack (1.0.1)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
css_parser (1.21.0)
|
||||
addressable
|
||||
csv (3.3.2)
|
||||
csv (3.3.5)
|
||||
csv-safe (3.3.1)
|
||||
csv (~> 3.0)
|
||||
cuprite (0.15.1)
|
||||
cuprite (0.17)
|
||||
capybara (~> 3.0)
|
||||
ferrum (~> 0.15.0)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
ferrum (~> 0.17.0)
|
||||
date (3.5.1)
|
||||
debug (1.11.1)
|
||||
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.1.0)
|
||||
activesupport (>= 7.0, < 8.1)
|
||||
devise (~> 4.0)
|
||||
railties (>= 7.0, < 8.1)
|
||||
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.5.1)
|
||||
digest-crc (0.6.5)
|
||||
diff-lcs (1.6.2)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
docile (1.4.1)
|
||||
dotenv (3.1.7)
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
email_typo (0.2.3)
|
||||
erb (5.0.2)
|
||||
erb_lint (0.7.0)
|
||||
erb (6.0.2)
|
||||
erb_lint (0.9.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
parser (>= 2.7.1.4)
|
||||
@@ -190,117 +188,100 @@ GEM
|
||||
rubocop (>= 1)
|
||||
smart_properties
|
||||
erubi (1.13.1)
|
||||
factory_bot (6.5.0)
|
||||
activesupport (>= 5.0.0)
|
||||
factory_bot_rails (6.4.4)
|
||||
factory_bot (6.5.6)
|
||||
activesupport (>= 6.1.0)
|
||||
factory_bot_rails (6.5.1)
|
||||
factory_bot (~> 6.5)
|
||||
railties (>= 5.0.0)
|
||||
faker (3.5.1)
|
||||
railties (>= 6.1.0)
|
||||
faker (3.6.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (1.10.4)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.0)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (2.14.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.5.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.1.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
ferrum (0.15)
|
||||
faraday-net_http (3.4.2)
|
||||
net-http (~> 0.5)
|
||||
ferrum (0.17.2)
|
||||
addressable (~> 2.5)
|
||||
base64 (~> 0.2)
|
||||
concurrent-ruby (~> 1.1)
|
||||
webrick (~> 1.7)
|
||||
websocket-driver (~> 0.7)
|
||||
ffi (1.17.1)
|
||||
ffi (1.17.1-aarch64-linux-gnu)
|
||||
ffi (1.17.1-aarch64-linux-musl)
|
||||
ffi (1.17.1-arm64-darwin)
|
||||
ffi (1.17.1-x86_64-linux-gnu)
|
||||
ffi (1.17.1-x86_64-linux-musl)
|
||||
foreman (0.88.1)
|
||||
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)
|
||||
globalid (1.2.1)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
google-apis-core (0.15.1)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
google-apis-core (1.0.2)
|
||||
addressable (~> 2.8, >= 2.8.7)
|
||||
faraday (~> 2.13)
|
||||
faraday-follow_redirects (~> 0.3)
|
||||
googleauth (~> 1.14)
|
||||
mini_mime (~> 1.1)
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
google-apis-iamcredentials_v1 (0.22.0)
|
||||
retriable (~> 3.1)
|
||||
google-apis-iamcredentials_v1 (0.26.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.49.0)
|
||||
google-apis-storage_v1 (0.61.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.7.1)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.2.1)
|
||||
google-cloud-env (2.3.1)
|
||||
base64 (~> 0.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.4.0)
|
||||
google-cloud-storage (1.54.0)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.59.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (~> 0.13)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (~> 0.38)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
google-logging-utils (0.1.0)
|
||||
googleauth (1.12.2)
|
||||
google-logging-utils (0.2.0)
|
||||
googleauth (1.16.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
jwt (>= 1.4, < 4.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
hashdiff (1.1.2)
|
||||
hexapdf (1.4.0)
|
||||
hashdiff (1.2.1)
|
||||
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.3.4)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.14.7)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.13.0)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
image_processing (1.14.0)
|
||||
mini_magick (>= 4.9.5, < 6)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
io-console (0.8.1)
|
||||
irb (1.15.2)
|
||||
io-console (0.8.2)
|
||||
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.15.0)
|
||||
jwt (2.9.3)
|
||||
json (2.19.3)
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (3.0.1)
|
||||
launchy (3.1.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
letter_opener_web (3.0.0)
|
||||
@@ -315,29 +296,29 @@ GEM
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.24.1)
|
||||
loofah (2.25.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
mail (2.9.0)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
matrix (0.4.2)
|
||||
marcel (1.1.0)
|
||||
matrix (0.4.3)
|
||||
method_source (1.1.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_magick (5.3.1)
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (5.25.5)
|
||||
msgpack (1.7.5)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
mysql2 (0.5.6)
|
||||
net-http-persistent (4.0.5)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.5.9)
|
||||
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.3)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -346,124 +327,117 @@ GEM
|
||||
timeout
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.9)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.2-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-aarch64-linux-gnu)
|
||||
nokogiri (1.19.2-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-aarch64-linux-musl)
|
||||
nokogiri (1.19.2-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-arm64-darwin)
|
||||
nokogiri (1.19.2-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-x86_64-linux-gnu)
|
||||
nokogiri (1.19.2-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
numo-narray (0.9.2.1)
|
||||
oj (3.16.11)
|
||||
numo-narray-alt (0.10.3)
|
||||
oj (3.16.16)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
onnxruntime (0.10.1)
|
||||
ffi
|
||||
onnxruntime (0.10.1-aarch64-linux)
|
||||
ffi
|
||||
onnxruntime (0.10.1-arm64-darwin)
|
||||
ffi
|
||||
onnxruntime (0.10.1-x86_64-linux)
|
||||
ffi
|
||||
openssl (3.3.0)
|
||||
openssl (4.0.1)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
package_json (0.1.0)
|
||||
pagy (9.3.3)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.9.0)
|
||||
package_json (0.2.0)
|
||||
pagy (43.4.4)
|
||||
json
|
||||
uri
|
||||
yaml
|
||||
parallel (1.28.0)
|
||||
parser (3.3.11.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
pp (0.6.2)
|
||||
pg (1.6.3-aarch64-linux)
|
||||
pg (1.6.3-aarch64-linux-musl)
|
||||
pg (1.6.3-arm64-darwin)
|
||||
pg (1.6.3-x86_64-linux)
|
||||
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.5.0)
|
||||
actionpack (>= 6.1)
|
||||
pretender (1.0.0)
|
||||
actionpack (>= 7.2)
|
||||
prettyprint (0.2.0)
|
||||
prism (1.5.1)
|
||||
pry (0.15.0)
|
||||
prism (1.9.0)
|
||||
pry (0.16.0)
|
||||
coderay (~> 1.1)
|
||||
method_source (~> 1.0)
|
||||
reline (>= 0.6.0)
|
||||
pry-rails (0.3.11)
|
||||
pry (>= 0.13.0)
|
||||
psych (5.2.6)
|
||||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
puma (6.5.0)
|
||||
public_suffix (7.0.5)
|
||||
puma (7.2.0)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.3)
|
||||
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.2.1)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (8.0.2.1)
|
||||
actioncable (= 8.0.2.1)
|
||||
actionmailbox (= 8.0.2.1)
|
||||
actionmailer (= 8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
actiontext (= 8.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activemodel (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
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.0.2.1)
|
||||
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.0.1)
|
||||
rails-i18n (8.1.0)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 8.0.0, < 9)
|
||||
rails_autolink (1.1.8)
|
||||
actionview (> 3.1)
|
||||
activesupport (> 3.1)
|
||||
railties (> 3.1)
|
||||
railties (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
railties (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.0)
|
||||
rdoc (6.14.2)
|
||||
rake (13.3.1)
|
||||
rdoc (7.2.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
redis-client (0.23.0)
|
||||
tsort
|
||||
redis-client (0.28.0)
|
||||
connection_pool
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.2)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
@@ -471,35 +445,35 @@ GEM
|
||||
uber (< 0.2.0)
|
||||
request_store (1.7.0)
|
||||
rack (>= 1.4)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
retriable (3.1.2)
|
||||
responders (3.2.0)
|
||||
actionpack (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
retriable (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rotp (6.3.0)
|
||||
rouge (4.5.2)
|
||||
rqrcode (2.2.0)
|
||||
rouge (4.7.0)
|
||||
rqrcode (3.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 1.0)
|
||||
rqrcode_core (1.2.0)
|
||||
rspec-core (3.13.2)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.1.0)
|
||||
rspec-core (3.13.6)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.3)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.2)
|
||||
rspec-mocks (3.13.8)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (7.1.0)
|
||||
actionpack (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.2)
|
||||
rubocop (1.81.1)
|
||||
rspec-rails (8.0.4)
|
||||
actionpack (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
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)
|
||||
@@ -507,122 +481,127 @@ GEM
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.47.1)
|
||||
rubocop-ast (1.49.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-performance (1.23.0)
|
||||
rubocop (>= 1.48.1, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails (2.27.0)
|
||||
prism (~> 1.7)
|
||||
rubocop-performance (1.26.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-rails (2.34.3)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.52.0, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rspec (3.3.0)
|
||||
rubocop (~> 1.61)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-rspec (3.9.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.81)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.2)
|
||||
ruby-vips (2.3.0)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyXL (3.4.33)
|
||||
rubyXL (3.4.35)
|
||||
nokogiri (>= 1.10.8)
|
||||
rubyzip (>= 1.3.0)
|
||||
rubyzip (2.3.2)
|
||||
rubyzip (>= 3.2.2)
|
||||
rubyzip (3.2.2)
|
||||
securerandom (0.4.1)
|
||||
semantic_range (3.1.0)
|
||||
shakapacker (8.0.2)
|
||||
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 (7.3.7)
|
||||
connection_pool (>= 2.3.0)
|
||||
logger
|
||||
rack (>= 2.2.4)
|
||||
redis-client (>= 0.22.2)
|
||||
signet (0.19.0)
|
||||
sidekiq (8.1.2)
|
||||
connection_pool (>= 3.0.0)
|
||||
json (>= 2.16.0)
|
||||
logger (>= 1.7.0)
|
||||
rack (>= 3.2.0)
|
||||
redis-client (>= 0.26.0)
|
||||
signet (0.21.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
multi_json (~> 1.10)
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sqlite3 (2.5.0)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
sqlite3 (2.5.0-aarch64-linux-gnu)
|
||||
sqlite3 (2.5.0-aarch64-linux-musl)
|
||||
sqlite3 (2.5.0-arm64-darwin)
|
||||
sqlite3 (2.5.0-x86_64-linux-gnu)
|
||||
sqlite3 (2.5.0-x86_64-linux-musl)
|
||||
stringio (3.1.7)
|
||||
strip_attributes (1.14.1)
|
||||
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.5)
|
||||
thor (1.4.0)
|
||||
timeout (0.4.3)
|
||||
strscan (3.1.8)
|
||||
thor (1.5.0)
|
||||
timeout (0.6.1)
|
||||
trailblazer-option (0.1.2)
|
||||
turbo-rails (2.0.11)
|
||||
actionpack (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
twitter_cldr (6.12.1)
|
||||
trilogy (2.12.2)
|
||||
bigdecimal
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.23)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
twitter_cldr (6.14.0)
|
||||
base64
|
||||
camertron-eprun
|
||||
cldr-plurals-runtime-rb (~> 1.1)
|
||||
tzinfo
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
tzinfo-data (1.2024.2)
|
||||
tzinfo-data (1.2026.1)
|
||||
tzinfo (>= 1.0.0)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uniform_notifier (1.16.0)
|
||||
unicode-emoji (4.2.0)
|
||||
uniform_notifier (1.18.0)
|
||||
uri (1.1.1)
|
||||
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.24.0)
|
||||
railties (>= 8.0.0)
|
||||
webmock (3.26.2)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.1)
|
||||
webrick (1.9.2)
|
||||
websocket-driver (0.8.0)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.3)
|
||||
yaml (0.4.0)
|
||||
zeitwerk (2.7.5)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
aarch64-linux-musl
|
||||
arm64-darwin
|
||||
ruby
|
||||
x86_64-linux
|
||||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
addressable
|
||||
annotaterb
|
||||
arabic-letter-connector
|
||||
aws-sdk-s3
|
||||
aws-sdk-secretsmanager
|
||||
azure-storage-blob
|
||||
azure-blob
|
||||
better_html
|
||||
bootsnap
|
||||
brakeman
|
||||
@@ -649,20 +628,17 @@ DEPENDENCIES
|
||||
jwt
|
||||
letter_opener_web
|
||||
lograge
|
||||
mysql2
|
||||
numo-narray
|
||||
numo-narray-alt
|
||||
oj
|
||||
onnxruntime
|
||||
pagy
|
||||
pg
|
||||
premailer-rails
|
||||
pretender
|
||||
pry-rails
|
||||
puma
|
||||
rack
|
||||
rails
|
||||
rails-i18n
|
||||
rails_autolink
|
||||
rotp
|
||||
rouge
|
||||
rqrcode
|
||||
@@ -678,6 +654,7 @@ DEPENDENCIES
|
||||
simplecov
|
||||
sqlite3
|
||||
strip_attributes
|
||||
trilogy
|
||||
turbo-rails
|
||||
twitter_cldr
|
||||
tzinfo-data
|
||||
@@ -685,7 +662,7 @@ DEPENDENCIES
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.4.2p28
|
||||
ruby 4.0.1
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.3
|
||||
4.0.3
|
||||
|
||||
@@ -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.
|
||||
@@ -1,7 +1,7 @@
|
||||
<h1 align="center" style="border-bottom: none">
|
||||
<div>
|
||||
<a href="https://www.docuseal.com">
|
||||
<img alt="DocuSeal" src="https://github.com/docusealco/docuseal/assets/5418788/c12cd051-81cd-4402-bc3a-92f2cfdc1b06" width="80" />
|
||||
<img alt="DocuSeal" src="https://github.com/user-attachments/assets/38b45682-ffa4-4919-abde-d2d422325c44" width="80" />
|
||||
<br>
|
||||
</a>
|
||||
DocuSeal
|
||||
@@ -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])
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AccountCustomFieldsController < ApplicationController
|
||||
before_action :load_account_config, only: :create
|
||||
|
||||
def create
|
||||
authorize!(:create, Template)
|
||||
|
||||
@account_config.update!(account_config_params)
|
||||
|
||||
render json: @account_config.value
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_account_config
|
||||
@account_config =
|
||||
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY)
|
||||
end
|
||||
|
||||
def account_config_params
|
||||
params.permit(
|
||||
value: [[:uuid, :name, :type,
|
||||
:required, :readonly, :default_value,
|
||||
:title, :description,
|
||||
{ preferences: {},
|
||||
default_value: [],
|
||||
options: [%i[value uuid]],
|
||||
validation: %i[message pattern min max step],
|
||||
areas: [%i[x y w h cell_w option_uuid]] }]]
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
module Api
|
||||
class ApiBaseController < ActionController::API
|
||||
include ActiveStorage::SetCurrent
|
||||
include Pagy::Backend
|
||||
include Pagy::Method
|
||||
|
||||
DEFAULT_LIMIT = 10
|
||||
MAX_LIMIT = 100
|
||||
|
||||
@@ -10,6 +10,12 @@ module Api
|
||||
def create
|
||||
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
unless can_upload?(submitter)
|
||||
Rollbar.error("Can't upload: #{submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
if params[:type].in?(%w[initials signature])
|
||||
image = Vips::Image.new_from_file(params[:file].path)
|
||||
|
||||
@@ -40,6 +46,14 @@ module Api
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def can_upload?(submitter)
|
||||
!submitter.declined_at? &&
|
||||
!submitter.completed_at? &&
|
||||
!submitter.submission.archived_at? &&
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at?
|
||||
end
|
||||
|
||||
def build_new_cookie_signatures_json(submitter, attachment)
|
||||
values =
|
||||
begin
|
||||
|
||||
@@ -156,7 +156,7 @@ module Api
|
||||
params:)
|
||||
else
|
||||
submissions_attrs, attachments =
|
||||
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template)
|
||||
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template, purpose: :api)
|
||||
|
||||
submissions = Submissions.create_from_submitters(
|
||||
template:,
|
||||
@@ -172,7 +172,10 @@ module Api
|
||||
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments, submitters)
|
||||
|
||||
submitters.each do |submitter|
|
||||
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request) if submitter.completed_at?
|
||||
if submitter.completed_at?
|
||||
Submitters::SubmitValues.maybe_invite_via_field(submitter, request)
|
||||
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request)
|
||||
end
|
||||
end
|
||||
|
||||
submissions
|
||||
@@ -188,7 +191,7 @@ module Api
|
||||
message: %i[subject body],
|
||||
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
|
||||
:completed, :phone, :application_key, :external_id, :reply_to, :go_to_last,
|
||||
:require_phone_2fa, :require_email_2fa, :order, :invite_by,
|
||||
:require_phone_2fa, :require_email_2fa, :order, :index, :invite_by,
|
||||
{ metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body],
|
||||
fields: [:name, :uuid, :default_value, :value, :title, :description,
|
||||
:readonly, :required, :validation_pattern, :invalid_message,
|
||||
|
||||
@@ -34,18 +34,24 @@ module Api
|
||||
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true, params:)
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def update
|
||||
if @submitter.completed_at?
|
||||
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
if @submitter.declined_at?
|
||||
return render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
submission = @submitter.submission
|
||||
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
|
||||
|
||||
normalized_params, new_attachments = Submissions::NormalizeParamUtils.normalize_submitter_params!(
|
||||
submitter_params.merge(role:),
|
||||
@submitter.template || Template.new(submitters: submission.template_submitters, account: @submitter.account),
|
||||
for_submitter: @submitter
|
||||
for_submitter: @submitter,
|
||||
purpose: :api
|
||||
)
|
||||
|
||||
Submissions::CreateFromSubmitters.maybe_set_template_fields(submission, [normalized_params],
|
||||
@@ -60,7 +66,10 @@ module Api
|
||||
|
||||
@submitter.submission.save!
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request) if @submitter.completed_at?
|
||||
if @submitter.completed_at?
|
||||
Submitters::SubmitValues.maybe_invite_via_field(@submitter, request)
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request)
|
||||
end
|
||||
end
|
||||
|
||||
if @submitter.completed_at?
|
||||
@@ -78,6 +87,7 @@ module Api
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
def submitter_params
|
||||
submitter_params = params.key?(:submitter) ? params.require(:submitter) : params
|
||||
|
||||
@@ -9,7 +9,7 @@ module Api
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [schema_documents: :preview_images_attachments]
|
||||
associations: [{ schema_documents: :preview_images_attachments }]
|
||||
).call
|
||||
|
||||
cloned_template = Templates::Clone.call(
|
||||
|
||||
@@ -69,6 +69,7 @@ module Api
|
||||
SearchEntries.enqueue_reindex(@template)
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.updated')
|
||||
WebhookUrls.enqueue_events(@template, 'template.archived') if archived == true
|
||||
|
||||
render json: @template.as_json(only: %i[id updated_at])
|
||||
end
|
||||
@@ -78,6 +79,8 @@ module Api
|
||||
@template.destroy!
|
||||
else
|
||||
@template.update!(archived_at: Time.current)
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.archived')
|
||||
end
|
||||
|
||||
render json: @template.as_json(only: %i[id archived_at])
|
||||
@@ -107,7 +110,8 @@ module Api
|
||||
:external_id,
|
||||
:shared_link,
|
||||
{
|
||||
submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email order]],
|
||||
submitters: [%i[name uuid is_requester invite_by_uuid invite_via_field_uuid
|
||||
optional_invite_by_uuid linked_to_uuid email order]],
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type,
|
||||
:required, :readonly, :default_value,
|
||||
:title, :description, :prefillable,
|
||||
@@ -116,7 +120,7 @@ module Api
|
||||
conditions: [%i[field_uuid value action operation]],
|
||||
options: [%i[value uuid]],
|
||||
validation: %i[message pattern min max step],
|
||||
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
|
||||
areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ class ApplicationController < ActionController::Base
|
||||
BROWSER_LOCALE_REGEXP = /\A\w{2}(?:-\w{2})?/
|
||||
|
||||
include ActiveStorage::SetCurrent
|
||||
include Pagy::Backend
|
||||
include Pagy::Method
|
||||
|
||||
check_authorization unless: :devise_controller?
|
||||
|
||||
@@ -23,7 +23,7 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
|
||||
|
||||
rescue_from Pagy::OverflowError do
|
||||
rescue_from Pagy::RangeError do
|
||||
redirect_to request.path
|
||||
end
|
||||
|
||||
@@ -42,10 +42,6 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
|
||||
def default_url_options
|
||||
if request.domain == 'docuseal.com'
|
||||
return { host: 'docuseal.com', protocol: ENV['FORCE_SSL'].present? ? 'https' : 'http' }
|
||||
end
|
||||
|
||||
Docuseal.default_url_options
|
||||
end
|
||||
|
||||
@@ -60,7 +56,7 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
def pagy_auto(collection, **keyword_args)
|
||||
if current_ability.can?(:manage, :countless)
|
||||
pagy_countless(collection, **keyword_args)
|
||||
pagy(:countless, collection, **keyword_args)
|
||||
else
|
||||
pagy(collection, **keyword_args)
|
||||
end
|
||||
|
||||
@@ -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,40 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmissionsDownloadController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
TTL = 40.minutes
|
||||
FILES_TTL = 5.minutes
|
||||
load_and_authorize_resource :submission
|
||||
|
||||
def index
|
||||
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
|
||||
signature_valid =
|
||||
if @submitter&.slug == params[:submitter_slug]
|
||||
true
|
||||
else
|
||||
@submitter = nil
|
||||
end
|
||||
|
||||
@submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
|
||||
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
last_submitter = @submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
|
||||
return head :not_found unless last_submitter
|
||||
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter)
|
||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
if params[:combined] == 'true'
|
||||
url = build_combined_url(@submitter)
|
||||
url = Submitters.build_combined_url(last_submitter)
|
||||
|
||||
if url
|
||||
render json: [url]
|
||||
@@ -42,43 +19,7 @@ class SubmissionsDownloadController < ApplicationController
|
||||
head :not_found
|
||||
end
|
||||
else
|
||||
render json: build_urls(last_submitter)
|
||||
render json: Submitters.build_document_urls(last_submitter)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def current_user_submitter?(submitter)
|
||||
current_user && current_user.account.submitters.exists?(id: submitter.id)
|
||||
end
|
||||
|
||||
def build_urls(submitter)
|
||||
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
|
||||
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
|
||||
|
||||
Submitters.select_attachments_for_download(submitter).map do |attachment|
|
||||
ActiveStorage::Blob.proxy_url(
|
||||
attachment.blob,
|
||||
expires_at: FILES_TTL.from_now.to_i,
|
||||
filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def build_combined_url(submitter)
|
||||
return if submitter.submission.submitters.exists?(completed_at: nil)
|
||||
return if submitter.submission.submitters.order(:completed_at).last != submitter
|
||||
|
||||
attachment = submitter.submission.combined_document_attachment
|
||||
attachment ||= Submissions::EnsureCombinedGenerated.call(submitter)
|
||||
|
||||
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
|
||||
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
|
||||
|
||||
ActiveStorage::Blob.proxy_url(
|
||||
attachment.blob,
|
||||
expires_at: FILES_TTL.from_now.to_i,
|
||||
filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,10 +5,11 @@ class SubmissionsExportController < ApplicationController
|
||||
load_and_authorize_resource :submission, through: :template, parent: false, only: :index
|
||||
|
||||
def index
|
||||
submissions = @submissions.active
|
||||
.preload(submitters: { documents_attachments: :blob,
|
||||
attachments_attachments: :blob })
|
||||
.order(id: :asc)
|
||||
submissions = params[:archived] == 'true' ? @submissions.archived : @submissions.active
|
||||
|
||||
submissions = submissions.preload(submitters: { documents_attachments: :blob,
|
||||
attachments_attachments: :blob })
|
||||
.order(id: :asc)
|
||||
|
||||
submissions = Submissions.search(current_user, submissions, params[:q], search_values: true)
|
||||
submissions = Submissions::Filter.call(submissions, current_user, params)
|
||||
|
||||
@@ -10,13 +10,15 @@ class SubmissionsPreviewController < ApplicationController
|
||||
TTL = 40.minutes
|
||||
|
||||
def show
|
||||
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
@sig_submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
|
||||
signature_valid =
|
||||
if submitter && submitter.submission.slug == params[:slug]
|
||||
@submission = submitter.submission
|
||||
if @sig_submitter && @sig_submitter.submission.slug == params[:slug]
|
||||
@submission = @sig_submitter.submission
|
||||
|
||||
true
|
||||
else
|
||||
@sig_submitter = nil
|
||||
end
|
||||
|
||||
@submission ||= Submission.find_by!(slug: params[:slug])
|
||||
@@ -36,7 +38,7 @@ class SubmissionsPreviewController < ApplicationController
|
||||
|
||||
@submission = Submissions.preload_with_pages(@submission)
|
||||
|
||||
render 'submissions/show', layout: 'plain'
|
||||
render 'submissions/show', layout: 'plain', locals: { is_preview: true }
|
||||
end
|
||||
|
||||
def completed
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmissionsPreviewDownloadController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
TTL = 40.minutes
|
||||
|
||||
def index
|
||||
@submission = Submission.find_by!(slug: params[:submission_slug] || params[:submissions_preview_slug])
|
||||
|
||||
last_submitter = @submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
|
||||
return head :not_found unless last_submitter
|
||||
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
unless current_user_submission?(@submission)
|
||||
if use_2fa?(@submission)
|
||||
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
if last_submitter.completed_at < TTL.ago
|
||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
if params[:combined] == 'true'
|
||||
respond_with_combined(last_submitter)
|
||||
else
|
||||
render json: Submitters.build_document_urls(last_submitter)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def respond_with_combined(submitter)
|
||||
url = Submitters.build_combined_url(submitter)
|
||||
|
||||
if url
|
||||
render json: [url]
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
def current_user_submission?(submission)
|
||||
current_user && current_ability.can?(:read, submission)
|
||||
end
|
||||
|
||||
def use_2fa?(submission)
|
||||
return true if submission.submitters.any? do |e|
|
||||
e.preferences['require_phone_2fa'] || e.preferences['require_email_2fa']
|
||||
end
|
||||
return true if submission.template&.preferences&.dig('require_phone_2fa')
|
||||
return true if submission.template&.preferences&.dig('require_email_2fa')
|
||||
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,70 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitFormCompletedDownloadController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
TTL = 40.minutes
|
||||
FILES_TTL = 5.minutes
|
||||
|
||||
def index
|
||||
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
|
||||
signature_valid =
|
||||
if @submitter&.slug == submitter_slug
|
||||
true
|
||||
else
|
||||
@submitter = nil
|
||||
end
|
||||
|
||||
@submitter ||= Submitter.find_by!(slug: submitter_slug)
|
||||
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
|
||||
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
|
||||
return head :not_found unless last_submitter
|
||||
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
if !signature_valid && !current_user_submitter?(last_submitter)
|
||||
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
if last_submitter.completed_at < TTL.ago
|
||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
if params[:combined] == 'true'
|
||||
respond_with_combined(last_submitter)
|
||||
else
|
||||
render json: Submitters.build_document_urls(last_submitter)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def submitter_slug
|
||||
params[:submit_form_slug] || params[:submitter_slug] || params[:submitter_id]
|
||||
end
|
||||
|
||||
def respond_with_combined(submitter)
|
||||
url = Submitters.build_combined_url(submitter)
|
||||
|
||||
if url
|
||||
render json: [url]
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
def current_user_submitter?(submitter)
|
||||
current_user && current_ability.can?(:read, submitter)
|
||||
end
|
||||
end
|
||||
@@ -3,21 +3,22 @@
|
||||
class SubmitFormController < ApplicationController
|
||||
layout 'form'
|
||||
|
||||
around_action :with_browser_locale, only: %i[show completed success]
|
||||
around_action :with_browser_locale, only: %i[show completed success delegated]
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
before_action :load_submitter, only: %i[show update completed]
|
||||
before_action :maybe_redirect_delegated, only: %i[show completed]
|
||||
before_action :maybe_render_locked_page, only: :show
|
||||
before_action :maybe_require_link_2fa, only: %i[show update]
|
||||
before_action :maybe_require_link_2fa, only: %i[show]
|
||||
|
||||
CONFIG_KEYS = [].freeze
|
||||
|
||||
def show
|
||||
submission = @submitter.submission
|
||||
|
||||
return render :email_2fa unless Submitters::AuthorizedForForm.pass_email_2fa?(@submitter, request)
|
||||
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
|
||||
return render :email_2fa if require_email_2fa?(@submitter)
|
||||
|
||||
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
|
||||
|
||||
@@ -48,7 +49,7 @@ class SubmitFormController < ApplicationController
|
||||
end
|
||||
|
||||
def update
|
||||
if require_email_2fa?(@submitter)
|
||||
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
return render json: { error: I18n.t('verification_required_refresh_the_page_and_pass_2fa') },
|
||||
status: :unprocessable_content
|
||||
end
|
||||
@@ -84,18 +85,23 @@ class SubmitFormController < ApplicationController
|
||||
def completed
|
||||
raise ActionController::RoutingError, I18n.t('not_found') if @submitter.account.archived_at?
|
||||
|
||||
redirect_to submit_form_path(params[:submit_form_slug]) if require_email_2fa?(@submitter)
|
||||
return if Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
|
||||
redirect_to submit_form_path(params[:submit_form_slug])
|
||||
end
|
||||
|
||||
def success; end
|
||||
|
||||
def delegated
|
||||
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
|
||||
|
||||
@submitter = submitter_version.submitter
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def maybe_require_link_2fa
|
||||
return if @submitter.submission.source != 'link'
|
||||
return unless @submitter.submission.template&.preferences&.dig('shared_link_2fa') == true
|
||||
return if cookies.encrypted[:email_2fa_slug] == @submitter.slug
|
||||
return if @submitter.email == current_user&.email && current_user&.account_id == @submitter.account_id
|
||||
return if Submitters::AuthorizedForForm.pass_link_2fa?(@submitter, current_user, request)
|
||||
|
||||
redirect_to start_form_path(@submitter.submission.template.slug)
|
||||
end
|
||||
@@ -109,20 +115,22 @@ class SubmitFormController < ApplicationController
|
||||
render :declined if @submitter.declined_at?
|
||||
end
|
||||
|
||||
def maybe_redirect_delegated
|
||||
return if @submitter
|
||||
|
||||
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
|
||||
|
||||
submitter_version.submitter.submission_events.find_by!(event_type: :delegate_form)
|
||||
|
||||
redirect_to submit_form_delegated_path(submitter_version.slug)
|
||||
end
|
||||
|
||||
def load_submitter
|
||||
@submitter = Submitter.find_by!(slug: params[:slug] || params[:submit_form_slug])
|
||||
@submitter = Submitter.find_by(slug: params[:slug] || params[:submit_form_slug])
|
||||
end
|
||||
|
||||
def build_attachments_index(submission)
|
||||
ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments)
|
||||
.preload(:blob).index_by(&:uuid)
|
||||
end
|
||||
|
||||
def require_email_2fa?(submitter)
|
||||
return false if submitter.submission.template&.preferences&.dig('require_email_2fa') != true &&
|
||||
submitter.preferences['require_email_2fa'] != true
|
||||
return false if cookies.encrypted[:email_2fa_slug] == submitter.slug
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,29 +4,38 @@ class SubmitFormDeclineController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def create
|
||||
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
before_action :load_submitter
|
||||
|
||||
return redirect_to submit_form_path(submitter.slug) if submitter.declined_at? ||
|
||||
submitter.completed_at? ||
|
||||
submitter.submission.archived_at? ||
|
||||
submitter.submission.expired? ||
|
||||
submitter.submission.template&.archived_at?
|
||||
def create
|
||||
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
|
||||
@submitter.completed_at? ||
|
||||
@submitter.submission.archived_at? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter,
|
||||
current_user,
|
||||
request)
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
submitter.update!(declined_at: Time.current)
|
||||
@submitter.update!(declined_at: Time.current)
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(submitter, 'decline_form', request, { reason: params[:reason] })
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'decline_form', request, { reason: params[:reason] })
|
||||
end
|
||||
|
||||
user = submitter.submission.created_by_user || submitter.template.author
|
||||
user = @submitter.submission.created_by_user || @submitter.template.author
|
||||
|
||||
if user.user_configs.find_by(key: UserConfig::RECEIVE_DECLINED_EMAIL)&.value != false
|
||||
SubmitterMailer.declined_email(submitter, user).deliver_later!
|
||||
SubmitterMailer.declined_email(@submitter, user).deliver_later!
|
||||
end
|
||||
|
||||
WebhookUrls.enqueue_events(submitter, 'form.declined')
|
||||
WebhookUrls.enqueue_events(@submitter, 'form.declined')
|
||||
|
||||
redirect_to submit_form_path(submitter.slug)
|
||||
redirect_to submit_form_path(@submitter.slug)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_submitter
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitFormDelegateController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
before_action :load_submitter
|
||||
|
||||
def create
|
||||
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
|
||||
@submitter.completed_at? ||
|
||||
@submitter.submission.archived_at? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter,
|
||||
current_user,
|
||||
request)
|
||||
|
||||
@submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true)
|
||||
|
||||
email = Submissions.normalize_email(params[:email])
|
||||
|
||||
return redirect_to submit_form_path(@submitter.slug) if email.blank?
|
||||
|
||||
old_slug = @submitter.slug
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
@submitter.submitter_versions.create!(slug: old_slug, email: @submitter.email,
|
||||
name: @submitter.name, phone: @submitter.phone)
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'delegate_form', request,
|
||||
{ old_email: @submitter.email, email: })
|
||||
|
||||
@submitter.update!(email:, phone: nil, name: nil, slug: SecureRandom.base58(14))
|
||||
end
|
||||
|
||||
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => @submitter.id)
|
||||
|
||||
redirect_to submit_form_delegated_path(old_slug)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_submitter
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,7 @@ class SubmitFormDownloadController < ApplicationController
|
||||
def index
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
return redirect_to submitter_download_index_path(@submitter.slug) if @submitter.completed_at?
|
||||
return redirect_to submit_form_documents_path(@submitter.slug) if @submitter.completed_at?
|
||||
|
||||
return head :unprocessable_content if @submitter.declined_at? ||
|
||||
@submitter.submission.archived_at? ||
|
||||
@@ -17,7 +17,8 @@ class SubmitFormDownloadController < ApplicationController
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
AccountConfig.exists?(account_id: @submitter.account_id,
|
||||
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
|
||||
value: false)
|
||||
value: false) ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
|
||||
last_completed_submitter = @submitter.submission.submitters
|
||||
.where.not(id: @submitter.id)
|
||||
@@ -32,7 +33,7 @@ class SubmitFormDownloadController < ApplicationController
|
||||
end
|
||||
|
||||
urls = attachments.map do |attachment|
|
||||
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.from_now.to_i)
|
||||
ActiveStorage::Blob.proxy_path(attachment.blob, expires_at: FILES_TTL.from_now.to_i)
|
||||
end
|
||||
|
||||
render json: urls
|
||||
|
||||
@@ -12,7 +12,8 @@ class SubmitFormDrawSignatureController < ApplicationController
|
||||
|
||||
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
|
||||
|
||||
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at?
|
||||
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
return redirect_to submit_form_path(@submitter.slug)
|
||||
end
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ class SubmitFormInviteController < ApplicationController
|
||||
next unless attrs
|
||||
next if attrs[:email].blank?
|
||||
|
||||
submitter.submission.submitters.create!(**attrs, account_id: submitter.account_id)
|
||||
email = Submissions.normalize_email(attrs[:email])
|
||||
|
||||
submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: submitter.account_id)
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(submitter, 'invite_party', request, { uuid: submitter.uuid })
|
||||
end
|
||||
@@ -45,7 +47,8 @@ class SubmitFormInviteController < ApplicationController
|
||||
!submitter.completed_at? &&
|
||||
!submitter.submission.archived_at? &&
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at?
|
||||
!submitter.submission.template&.archived_at? &&
|
||||
Submitters::AuthorizedForForm.call(submitter, current_user, request)
|
||||
end
|
||||
|
||||
def filter_invite_submitters(submitter, key = 'invite_by_uuid')
|
||||
|
||||
@@ -7,10 +7,12 @@ class SubmitFormValuesController < ApplicationController
|
||||
def index
|
||||
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
return render json: {} if submitter.completed_at? || submitter.declined_at?
|
||||
return render json: {} if submitter.submission.template&.archived_at? ||
|
||||
return render json: {} if submitter.completed_at? ||
|
||||
submitter.declined_at? ||
|
||||
submitter.submission.template&.archived_at? ||
|
||||
submitter.submission.archived_at? ||
|
||||
submitter.submission.expired?
|
||||
submitter.submission.expired? ||
|
||||
!Submitters::AuthorizedForForm.call(submitter, current_user, request)
|
||||
|
||||
value = submitter.values[params['field_uuid']]
|
||||
attachment = submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
|
||||
|
||||
@@ -7,25 +7,34 @@ class SubmittersAutocompleteController < ApplicationController
|
||||
LIMIT = 100
|
||||
|
||||
def index
|
||||
submitters = search_submitters(@submitters)
|
||||
field = SELECT_COLUMNS.find { |c| c == params[:field] }
|
||||
|
||||
submitters = search_submitters(@submitters, field)
|
||||
|
||||
arel_columns = SELECT_COLUMNS.map { |col| Submitter.arel_table[col] }
|
||||
values = submitters.limit(LIMIT).group(arel_columns).pluck(arel_columns)
|
||||
|
||||
values =
|
||||
if field
|
||||
max_ids = submitters.group(field).limit(LIMIT).select(Submitter.arel_table[:id].maximum)
|
||||
|
||||
submitters.where(id: max_ids).order(id: :desc).pluck(arel_columns)
|
||||
else
|
||||
submitters.limit(LIMIT).group(arel_columns).pluck(arel_columns)
|
||||
end
|
||||
|
||||
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
|
||||
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
|
||||
|
||||
render json: attrs
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_submitters(submitters)
|
||||
if SELECT_COLUMNS.include?(params[:field])
|
||||
def search_submitters(submitters, field)
|
||||
if field
|
||||
if Docuseal.fulltext_search?
|
||||
Submitters.fulltext_search_field(current_user, submitters, params[:q], params[:field])
|
||||
Submitters.fulltext_search_field(current_user, submitters, params[:q], field)
|
||||
else
|
||||
column = Submitter.arel_table[params[:field].to_sym]
|
||||
column = Submitter.arel_table[field.to_sym]
|
||||
|
||||
term = "#{params[:q].downcase}%"
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmittersDownloadController < ApplicationController
|
||||
load_and_authorize_resource :submitter
|
||||
|
||||
def index
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
|
||||
render json: Submitters.build_document_urls(@submitter)
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmittersSendEmailController < ApplicationController
|
||||
load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug
|
||||
load_and_authorize_resource :submitter
|
||||
|
||||
def create
|
||||
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
|
||||
|
||||
@@ -6,7 +6,7 @@ class TemplateDocumentsController < ApplicationController
|
||||
FILES_TTL = 5.minutes
|
||||
|
||||
def index
|
||||
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_url(d.blob, expires_at: FILES_TTL.from_now.to_i) }
|
||||
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) }
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -16,7 +16,7 @@ class TemplateDocumentsController < ApplicationController
|
||||
|
||||
old_fields_hash = @template.fields.hash
|
||||
|
||||
documents = Templates::CreateAttachments.call(@template, params, extract_fields: true)
|
||||
documents, = Templates::CreateAttachments.call(@template, params, extract_fields: true)
|
||||
|
||||
schema = documents.map do |doc|
|
||||
{ attachment_uuid: doc.uuid, name: doc.filename.base }
|
||||
@@ -27,7 +27,7 @@ class TemplateDocumentsController < ApplicationController
|
||||
fields: old_fields_hash == @template.fields.hash ? nil : @template.fields,
|
||||
submitters: old_fields_hash == @template.fields.hash ? nil : @template.submitters,
|
||||
documents: documents.as_json(
|
||||
methods: %i[metadata signed_uuid],
|
||||
methods: %i[metadata signed_key],
|
||||
include: {
|
||||
preview_images: { methods: %i[url metadata filename] }
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ class TemplatesCloneAndReplaceController < ApplicationController
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [schema_documents: :preview_images_attachments]
|
||||
associations: [{ schema_documents: :preview_images_attachments }]
|
||||
).call
|
||||
|
||||
cloned_template = Templates::Clone.call(@template, author: current_user)
|
||||
|
||||
@@ -12,7 +12,7 @@ class TemplatesCloneController < ApplicationController
|
||||
def create
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@base_template],
|
||||
associations: [schema_documents: :preview_images_attachments]
|
||||
associations: [{ schema_documents: :preview_images_attachments }]
|
||||
).call
|
||||
|
||||
@template = Templates::Clone.call(@base_template, author: current_user,
|
||||
@@ -21,7 +21,7 @@ class TemplatesCloneController < ApplicationController
|
||||
|
||||
authorize!(:create, @template)
|
||||
|
||||
if params[:account_id].present? && true_ability.authorize!(:manage, Account.find(params[:account_id]))
|
||||
if params[:account_id].present? && true_ability.can?(:manage, Account.find(params[:account_id]))
|
||||
@template.account_id = params[:account_id]
|
||||
@template.author = true_user if true_user.account_id == @template.account_id
|
||||
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
|
||||
|
||||
@@ -29,13 +29,13 @@ class TemplatesController < ApplicationController
|
||||
def edit
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [schema_documents: [:blob, { preview_images_attachments: :blob }]]
|
||||
associations: [{ schema_documents: [:blob, { preview_images_attachments: :blob }] }]
|
||||
).call
|
||||
|
||||
@template_data =
|
||||
@template.as_json.merge(
|
||||
documents: @template.schema_documents.as_json(
|
||||
methods: %i[metadata signed_uuid],
|
||||
methods: %i[metadata signed_key],
|
||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||
)
|
||||
).to_json
|
||||
@@ -84,6 +84,8 @@ class TemplatesController < ApplicationController
|
||||
else
|
||||
@template.update!(archived_at: Time.current)
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.archived')
|
||||
|
||||
I18n.t('template_has_been_archived')
|
||||
end
|
||||
|
||||
@@ -95,9 +97,11 @@ class TemplatesController < ApplicationController
|
||||
def template_params
|
||||
params.require(:template).permit(
|
||||
:name,
|
||||
{ schema: [[:attachment_uuid, :google_drive_file_id, :name,
|
||||
{ schema: [[:attachment_uuid, :google_drive_file_id, :name, :dynamic,
|
||||
{ conditions: [%i[field_uuid value action operation]] }]],
|
||||
submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid optional_invite_by_uuid email order]],
|
||||
submitters: [%i[name uuid is_requester linked_to_uuid invite_via_field_uuid
|
||||
invite_by_uuid optional_invite_by_uuid email order]],
|
||||
variables_schema: {},
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type,
|
||||
:required, :readonly, :default_value,
|
||||
:title, :description, :prefillable,
|
||||
@@ -106,7 +110,7 @@ class TemplatesController < ApplicationController
|
||||
conditions: [%i[field_uuid value action operation]],
|
||||
options: [%i[value uuid]],
|
||||
validation: %i[message pattern min max step],
|
||||
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
|
||||
areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]] }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,7 @@ class TemplatesDetectFieldsController < ApplicationController
|
||||
documents = @template.schema_documents.preload(:blob)
|
||||
documents = documents.where(uuid: params[:attachment_uuid]) if params[:attachment_uuid].present?
|
||||
|
||||
page_number = params[:page].present? ? params[:page].to_i : nil
|
||||
page_number = params[:page].presence&.to_i
|
||||
|
||||
documents.each do |document|
|
||||
io = StringIO.new(document.download)
|
||||
|
||||
@@ -58,8 +58,8 @@ class TemplatesPreferencesController < ApplicationController
|
||||
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
|
||||
completed_notification_email_subject completed_notification_email_body
|
||||
completed_notification_email_enabled completed_notification_email_attach_audit] +
|
||||
[completed_message: %i[title body],
|
||||
submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: []]
|
||||
[{ completed_message: %i[title body],
|
||||
submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: [] }]
|
||||
).tap do |attrs|
|
||||
attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1'
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ class TemplatesPreviewController < ApplicationController
|
||||
def show
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [schema_documents: { preview_images_attachments: :blob }]
|
||||
associations: [{ schema_documents: { preview_images_attachments: :blob } }]
|
||||
).call
|
||||
|
||||
@template_data =
|
||||
@template.as_json.merge(
|
||||
documents: @template.schema_documents.as_json(
|
||||
methods: %i[metadata signed_uuid],
|
||||
methods: %i[metadata signed_key],
|
||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||
)
|
||||
).to_json
|
||||
|
||||
@@ -22,7 +22,7 @@ class TemplatesRecipientsController < ApplicationController
|
||||
|
||||
def submitters_params
|
||||
permit_params = { submitters: [%i[name uuid is_requester optional_invite_by_uuid
|
||||
invite_by_uuid linked_to_uuid email option order]] }
|
||||
invite_by_uuid invite_via_field_uuid linked_to_uuid email option order]] }
|
||||
|
||||
params.require(:template).permit(permit_params).fetch(:submitters, {}).values.filter_map do |s|
|
||||
next if s[:uuid].blank?
|
||||
@@ -36,6 +36,7 @@ class TemplatesRecipientsController < ApplicationController
|
||||
s[:order] = s[:order].to_i if s[:order].present?
|
||||
s.delete(:invite_by_uuid) if s[:invite_by_uuid].blank?
|
||||
s.delete(:optional_invite_by_uuid) if s[:optional_invite_by_uuid].blank?
|
||||
s.delete(:invite_via_field_uuid) if s[:invite_via_field_uuid].blank?
|
||||
|
||||
normalize_option_value(s)
|
||||
end
|
||||
@@ -53,6 +54,7 @@ class TemplatesRecipientsController < ApplicationController
|
||||
attrs.delete(:email)
|
||||
attrs.delete(:linked_to_uuid)
|
||||
attrs.delete(:invite_by_uuid)
|
||||
attrs.delete(:invite_via_field_uuid)
|
||||
attrs.delete(:optional_invite_by_uuid)
|
||||
when /\Alinked_to_(.*)\z/
|
||||
attrs[:linked_to_uuid] = ::Regexp.last_match(-1)
|
||||
|
||||
@@ -6,6 +6,8 @@ class TemplatesRestoreController < ApplicationController
|
||||
def create
|
||||
@template.update!(archived_at: nil)
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.updated')
|
||||
|
||||
redirect_to template_path(@template), notice: I18n.t('template_has_been_unarchived')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ class TemplatesUploadsController < ApplicationController
|
||||
|
||||
save_template!(@template, url_params)
|
||||
|
||||
documents = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true)
|
||||
documents, = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true)
|
||||
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
|
||||
|
||||
if @template.fields.blank?
|
||||
@@ -56,7 +56,7 @@ class TemplatesUploadsController < ApplicationController
|
||||
def create_file_params_from_url
|
||||
tempfile = Tempfile.new
|
||||
tempfile.binmode
|
||||
tempfile.write(DownloadUtils.call(params[:url]).body)
|
||||
tempfile.write(DownloadUtils.call(params[:url], validate: true).body)
|
||||
tempfile.rewind
|
||||
|
||||
filename = URI.decode_www_form_component(params[:filename]) if params[:filename].present?
|
||||
|
||||
@@ -32,7 +32,7 @@ class TimestampServerController < ApplicationController
|
||||
uri = Addressable::URI.parse(url)
|
||||
|
||||
conn = Faraday.new(uri.origin) do |c|
|
||||
c.basic_auth(uri.user, uri.password) if uri.password.present?
|
||||
c.request :authorization, :basic, uri.user, uri.password if uri.password.present?
|
||||
end
|
||||
|
||||
response = conn.post(uri.path, req.to_der,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,7 +15,7 @@ class WebhookSettingsController < ApplicationController
|
||||
|
||||
@webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status])
|
||||
|
||||
@pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc))
|
||||
@pagy, @webhook_events = pagy(:countless, @webhook_events.order(id: :desc))
|
||||
|
||||
render :show
|
||||
end
|
||||
@@ -26,7 +26,7 @@ class WebhookSettingsController < ApplicationController
|
||||
|
||||
@webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status])
|
||||
|
||||
@pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc))
|
||||
@pagy, @webhook_events = pagy(:countless, @webhook_events.order(id: :desc))
|
||||
end
|
||||
|
||||
def new; end
|
||||
|
||||
@@ -40,6 +40,7 @@ import DashboardDropzone from './elements/dashboard_dropzone'
|
||||
import RequiredCheckboxGroup from './elements/required_checkbox_group'
|
||||
import PageContainer from './elements/page_container'
|
||||
import EmailEditor from './elements/email_editor'
|
||||
import MarkdownEditor from './elements/markdown_editor'
|
||||
import MountOnClick from './elements/mount_on_click'
|
||||
import RemoveOnEvent from './elements/remove_on_event'
|
||||
import ScrollTo from './elements/scroll_to'
|
||||
@@ -131,6 +132,7 @@ safeRegisterElement('check-on-click', CheckOnClick)
|
||||
safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
|
||||
safeRegisterElement('page-container', PageContainer)
|
||||
safeRegisterElement('email-editor', EmailEditor)
|
||||
safeRegisterElement('markdown-editor', MarkdownEditor)
|
||||
safeRegisterElement('mount-on-click', MountOnClick)
|
||||
safeRegisterElement('remove-on-event', RemoveOnEvent)
|
||||
safeRegisterElement('scroll-to', ScrollTo)
|
||||
@@ -153,23 +155,30 @@ 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',
|
||||
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
|
||||
editable: this.dataset.editable !== 'false',
|
||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||
withCustomFields: true,
|
||||
withPayment: this.dataset.withPayment === 'true',
|
||||
isPaymentConnected: this.dataset.isPaymentConnected === 'true',
|
||||
withFormula: this.dataset.withFormula === 'true',
|
||||
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,
|
||||
|
||||
@@ -155,3 +155,7 @@ button[disabled] .enabled, button.btn-disabled .enabled {
|
||||
.font-courier {
|
||||
font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco;
|
||||
}
|
||||
|
||||
markdown-editor [contenteditable] p {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
+51
-6
@@ -4,15 +4,26 @@ import { isValidSignatureCanvas } from './submission_form/validate_signature'
|
||||
|
||||
window.customElements.define('draw-signature', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const scale = 3
|
||||
|
||||
this.canvas.width = this.canvas.parentNode.clientWidth * scale
|
||||
this.canvas.height = this.canvas.parentNode.clientHeight * scale
|
||||
|
||||
this.canvas.getContext('2d').scale(scale, scale)
|
||||
this.setCanvasSize()
|
||||
|
||||
this.pad = new SignaturePad(this.canvas)
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.canvas) return
|
||||
|
||||
const { width, height } = this.canvas
|
||||
|
||||
this.setCanvasSize()
|
||||
|
||||
if (this.canvas.width !== width || this.canvas.height !== height) {
|
||||
this.redrawCanvas(width, height)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.resizeObserver.observe(this.canvas.parentNode)
|
||||
|
||||
if (this.dataset.color) {
|
||||
this.pad.penColor = this.dataset.color
|
||||
}
|
||||
@@ -57,6 +68,40 @@ window.customElements.define('draw-signature', class extends HTMLElement {
|
||||
this.updateSubmitButtonVisibility()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
setCanvasSize () {
|
||||
const scale = 3
|
||||
|
||||
const width = this.canvas.parentNode.clientWidth
|
||||
const height = this.canvas.parentNode.clientHeight
|
||||
|
||||
if (this.canvas.width !== width * scale || this.canvas.height !== height * scale) {
|
||||
this.canvas.width = width * scale
|
||||
this.canvas.height = height * scale
|
||||
|
||||
this.canvas.getContext('2d').scale(scale, scale)
|
||||
}
|
||||
}
|
||||
|
||||
redrawCanvas (oldWidth, oldHeight) {
|
||||
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
|
||||
const sx = this.canvas.width / oldWidth
|
||||
const sy = this.canvas.height / oldHeight
|
||||
|
||||
const scaledData = this.pad.toData().map((stroke) => ({
|
||||
...stroke,
|
||||
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
|
||||
}))
|
||||
|
||||
this.pad.fromData(scaledData)
|
||||
}
|
||||
}
|
||||
|
||||
updateSubmitButtonVisibility () {
|
||||
if (this.pad.isEmpty()) {
|
||||
this.submitButton.style.display = 'none'
|
||||
|
||||
@@ -64,6 +64,22 @@ export default class extends HTMLElement {
|
||||
if (action === 'empty' || action === 'unchecked') return this.isEmpty(actual)
|
||||
if (action === 'not_empty' || action === 'checked') return !this.isEmpty(actual)
|
||||
|
||||
if (['equal', 'not_equal', 'greater_than', 'less_than'].includes(action) && this.sourceEl?.getAttribute('type') === 'number') {
|
||||
if (this.isEmpty(actual) || this.isEmpty(expected)) return false
|
||||
|
||||
const actualNumber = parseFloat(actual)
|
||||
const expectedNumber = parseFloat(expected)
|
||||
|
||||
if (Number.isNaN(actualNumber) || Number.isNaN(expectedNumber)) return false
|
||||
|
||||
if (action === 'equal') return Math.abs(actualNumber - expectedNumber) < Number.EPSILON
|
||||
if (action === 'not_equal') return Math.abs(actualNumber - expectedNumber) > Number.EPSILON
|
||||
if (action === 'greater_than') return actualNumber > expectedNumber
|
||||
if (action === 'less_than') return actualNumber < expectedNumber
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (action === 'equal') {
|
||||
const list = Array.isArray(actual) ? actual : [actual]
|
||||
return list.filter((v) => v !== null && v !== undefined).map(String).includes(String(expected))
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||
import { actionable } from '@github/catalyst/lib/actionable'
|
||||
|
||||
function loadTiptap () {
|
||||
return Promise.all([
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/core'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-bold'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-italic'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-paragraph'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-text'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-hard-break'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-document'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-link'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-underline'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extensions'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/markdown'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/state'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/view')
|
||||
]).then(([core, bold, italic, paragraph, text, hardBreak, document, link, underline, extensions, markdown, pmState, pmView]) => ({
|
||||
Editor: core.Editor,
|
||||
Extension: core.Extension,
|
||||
Bold: bold.default || bold,
|
||||
Italic: italic.default || italic,
|
||||
Paragraph: paragraph.default || paragraph,
|
||||
Text: text.default || text,
|
||||
HardBreak: hardBreak.default || hardBreak,
|
||||
Document: document.default || document,
|
||||
Link: link.default || link,
|
||||
Underline: underline.default || underline,
|
||||
UndoRedo: extensions.UndoRedo,
|
||||
Markdown: markdown.Markdown,
|
||||
Plugin: pmState.Plugin,
|
||||
Decoration: pmView.Decoration,
|
||||
DecorationSet: pmView.DecorationSet
|
||||
}))
|
||||
}
|
||||
|
||||
class LinkTooltip {
|
||||
constructor (container, editor, templateEl) {
|
||||
this.container = container
|
||||
this.editor = editor
|
||||
|
||||
this.tooltip = templateEl.content.firstElementChild.cloneNode(true)
|
||||
|
||||
this.input = this.tooltip.querySelector('input')
|
||||
this.saveButton = this.tooltip.querySelector('[data-role="link-save"]')
|
||||
this.removeButton = this.tooltip.querySelector('[data-role="link-remove"]')
|
||||
|
||||
container.style.position = 'relative'
|
||||
container.appendChild(this.tooltip)
|
||||
}
|
||||
|
||||
isVisible () {
|
||||
return !this.tooltip.classList.contains('hidden')
|
||||
}
|
||||
|
||||
normalizeUrl (url) {
|
||||
if (!url) return url
|
||||
if (/^{/i.test(url)) return url
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
if (/^mailto:/i.test(url)) return url
|
||||
|
||||
return `https://${url}`
|
||||
}
|
||||
|
||||
show (url, pos, { focus = false } = {}) {
|
||||
this.input.value = url || ''
|
||||
this.removeButton.classList.toggle('hidden', !url)
|
||||
|
||||
this.tooltip.classList.remove('hidden')
|
||||
|
||||
const coords = this.editor.view.coordsAtPos(pos)
|
||||
const containerRect = this.container.getBoundingClientRect()
|
||||
|
||||
this.tooltip.style.left = `${coords.left - containerRect.left}px`
|
||||
this.tooltip.style.top = `${coords.bottom - containerRect.top + 4}px`
|
||||
|
||||
if (focus) this.input.focus()
|
||||
|
||||
this.saveHandler = () => {
|
||||
const inputUrl = this.input.value.trim()
|
||||
|
||||
if (inputUrl) {
|
||||
this.editor.chain().focus().extendMarkRange('link').setLink({ href: this.normalizeUrl(inputUrl) }).run()
|
||||
}
|
||||
|
||||
this.hide()
|
||||
}
|
||||
|
||||
this.removeHandler = () => {
|
||||
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
|
||||
this.hide()
|
||||
}
|
||||
|
||||
this.keyHandler = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
this.saveHandler()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
this.hide()
|
||||
}
|
||||
}
|
||||
|
||||
this.saveButton.addEventListener('click', this.saveHandler, { once: true })
|
||||
this.removeButton.addEventListener('click', this.removeHandler, { once: true })
|
||||
this.input.addEventListener('keydown', this.keyHandler)
|
||||
}
|
||||
|
||||
hide () {
|
||||
if (this.saveHandler) {
|
||||
this.saveButton.removeEventListener('click', this.saveHandler)
|
||||
this.saveHandler = null
|
||||
}
|
||||
|
||||
if (this.removeHandler) {
|
||||
this.removeButton.removeEventListener('click', this.removeHandler)
|
||||
this.removeHandler = null
|
||||
}
|
||||
|
||||
if (this.keyHandler) {
|
||||
this.input.removeEventListener('keydown', this.keyHandler)
|
||||
this.keyHandler = null
|
||||
}
|
||||
|
||||
this.tooltip.classList.add('hidden')
|
||||
this.currentMark = null
|
||||
}
|
||||
}
|
||||
|
||||
export default actionable(targetable(class extends HTMLElement {
|
||||
static [target.static] = [
|
||||
'textarea',
|
||||
'editorElement',
|
||||
'boldButton',
|
||||
'italicButton',
|
||||
'underlineButton',
|
||||
'linkButton',
|
||||
'linkTooltipTemplate'
|
||||
]
|
||||
|
||||
async connectedCallback () {
|
||||
if (!this.textarea || !this.editorElement) return
|
||||
|
||||
this.textarea.style.display = 'none'
|
||||
this.adjustShortcutsForPlatform()
|
||||
|
||||
const { Editor, Extension, Bold, Italic, Paragraph, Text, HardBreak, UndoRedo, Document, Link, Underline, Markdown, Plugin, Decoration, DecorationSet } = await loadTiptap()
|
||||
|
||||
const buildDecorations = (doc) => {
|
||||
const decorations = []
|
||||
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText) return
|
||||
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(node.text)) !== null) {
|
||||
decorations.push(
|
||||
Decoration.inline(pos + match.index, pos + match.index + match[0].length, {
|
||||
class: 'bg-amber-100 py-0.5 px-1 rounded'
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
const VariableHighlight = Extension.create({
|
||||
name: 'variableHighlight',
|
||||
addProseMirrorPlugins () {
|
||||
return [new Plugin({
|
||||
state: {
|
||||
init (_, { doc }) {
|
||||
return buildDecorations(doc)
|
||||
},
|
||||
apply (tr, oldSet) {
|
||||
return tr.docChanged ? buildDecorations(tr.doc) : oldSet
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations (state) {
|
||||
return this.getState(state)
|
||||
}
|
||||
}
|
||||
})]
|
||||
}
|
||||
})
|
||||
|
||||
this.editor = new Editor({
|
||||
element: this.editorElement,
|
||||
extensions: [
|
||||
Markdown,
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Bold,
|
||||
Italic,
|
||||
HardBreak.extend({
|
||||
addKeyboardShortcuts () {
|
||||
return {
|
||||
Enter: () => this.editor.commands.setHardBreak()
|
||||
}
|
||||
}
|
||||
}),
|
||||
UndoRedo,
|
||||
Link.extend({
|
||||
inclusive: true,
|
||||
addKeyboardShortcuts: () => ({
|
||||
'Mod-k': () => {
|
||||
this.toggleLink()
|
||||
|
||||
return true
|
||||
}
|
||||
})
|
||||
}).configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: 'link',
|
||||
'data-turbo': 'false',
|
||||
style: 'color: #2563eb; text-decoration: underline; cursor: text;'
|
||||
}
|
||||
}),
|
||||
Underline,
|
||||
VariableHighlight
|
||||
],
|
||||
content: (this.textarea.value || '').trim().replace(/ *\n/g, '<br>'),
|
||||
contentType: 'markdown',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
style: 'min-height: 220px',
|
||||
dir: 'auto',
|
||||
class: 'p-3 outline-none focus:outline-none'
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
this.textarea.value = editor.getMarkdown()
|
||||
this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
},
|
||||
onSelectionUpdate: ({ editor }) => {
|
||||
this.updateToolbarState()
|
||||
this.handleLinkTooltip(editor)
|
||||
},
|
||||
onBlur: () => {
|
||||
setTimeout(() => {
|
||||
if (!this.linkTooltip.tooltip.contains(document.activeElement)) {
|
||||
this.linkTooltip.hide()
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
|
||||
this.linkTooltip = new LinkTooltip(this, this.editor, this.linkTooltipTemplate)
|
||||
}
|
||||
|
||||
adjustShortcutsForPlatform () {
|
||||
if ((navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')) {
|
||||
this.querySelectorAll('.tooltip[data-tip]').forEach(tooltip => {
|
||||
const tip = tooltip.getAttribute('data-tip')
|
||||
|
||||
if (tip && tip.includes('Ctrl')) {
|
||||
tooltip.setAttribute('data-tip', tip.replace(/Ctrl/g, '⌘'))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
bold (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.editor.chain().focus().toggleBold().run()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
italic (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.editor.chain().focus().toggleItalic().run()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
underline (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.editor.chain().focus().toggleUnderline().run()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
linkSelection (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.toggleLink()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
undo (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.editor.chain().focus().undo().run()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
redo (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.editor.chain().focus().redo().run()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
updateToolbarState () {
|
||||
this.boldButton.classList.toggle('bg-base-200', this.editor.isActive('bold'))
|
||||
this.italicButton.classList.toggle('bg-base-200', this.editor.isActive('italic'))
|
||||
this.underlineButton.classList.toggle('bg-base-200', this.editor.isActive('underline'))
|
||||
this.linkButton.classList.toggle('bg-base-200', this.editor.isActive('link'))
|
||||
}
|
||||
|
||||
handleLinkTooltip (editor) {
|
||||
const { from } = editor.state.selection
|
||||
const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link')
|
||||
|
||||
if (!mark) {
|
||||
if (this.linkTooltip.isVisible()) this.linkTooltip.hide()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (this.linkTooltip.isVisible() && this.linkTooltip.currentMark === mark) return
|
||||
|
||||
let linkStart = from
|
||||
const start = editor.state.doc.resolve(from).start()
|
||||
|
||||
for (let i = from - 1; i >= start; i--) {
|
||||
if (editor.state.doc.resolve(i).marks().some(m => m.eq(mark))) {
|
||||
linkStart = i
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
this.linkTooltip.hide()
|
||||
this.linkTooltip.show(mark.attrs.href, linkStart > start ? linkStart - 1 : linkStart)
|
||||
this.linkTooltip.currentMark = mark
|
||||
}
|
||||
|
||||
toggleLink () {
|
||||
if (this.editor.isActive('link')) {
|
||||
this.linkTooltip.hide()
|
||||
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
this.updateToolbarState()
|
||||
} else {
|
||||
const { from } = this.editor.state.selection
|
||||
|
||||
this.linkTooltip.hide()
|
||||
this.linkTooltip.show(this.editor.getAttributes('link').href, from, { focus: true })
|
||||
}
|
||||
}
|
||||
|
||||
insertVariable (e) {
|
||||
const variable = e.target.closest('[data-variable]')?.dataset.variable
|
||||
|
||||
if (variable) {
|
||||
const { from, to } = this.editor.state.selection
|
||||
|
||||
if (variable.includes('link') && from !== to) {
|
||||
this.editor.chain().focus().setLink({ href: `{${variable}}` }).run()
|
||||
} else {
|
||||
this.editor.chain().focus().insertContent(`{${variable}}`).run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.linkTooltip.hide()
|
||||
|
||||
if (this.editor) {
|
||||
this.editor.destroy()
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -5,17 +5,28 @@ export default targetable(class extends HTMLElement {
|
||||
static [target.static] = ['canvas', 'input', 'clear', 'button']
|
||||
|
||||
async connectedCallback () {
|
||||
const scale = 3
|
||||
|
||||
this.canvas.width = this.canvas.parentNode.clientWidth * scale
|
||||
this.canvas.height = this.canvas.parentNode.clientHeight * scale
|
||||
|
||||
this.canvas.getContext('2d').scale(scale, scale)
|
||||
|
||||
const { default: SignaturePad } = await import('signature_pad')
|
||||
|
||||
this.setCanvasSize()
|
||||
|
||||
this.pad = new SignaturePad(this.canvas)
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.canvas) return
|
||||
|
||||
const { width, height } = this.canvas
|
||||
|
||||
this.setCanvasSize()
|
||||
|
||||
if (this.canvas.width !== width || this.canvas.height !== height) {
|
||||
this.redrawCanvas(width, height)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.resizeObserver.observe(this.canvas.parentNode)
|
||||
|
||||
this.clear.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -47,4 +58,38 @@ export default targetable(class extends HTMLElement {
|
||||
|
||||
this.closest('form').requestSubmit()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
setCanvasSize () {
|
||||
const scale = 3
|
||||
|
||||
const width = this.canvas.parentNode.clientWidth
|
||||
const height = this.canvas.parentNode.clientWidth / 2.5
|
||||
|
||||
if (this.canvas.width !== width * scale || this.canvas.height !== height * scale) {
|
||||
this.canvas.width = width * scale
|
||||
this.canvas.height = height * scale
|
||||
|
||||
this.canvas.getContext('2d').scale(scale, scale)
|
||||
}
|
||||
}
|
||||
|
||||
redrawCanvas (oldWidth, oldHeight) {
|
||||
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
|
||||
const sx = this.canvas.width / oldWidth
|
||||
const sy = this.canvas.height / oldHeight
|
||||
|
||||
const scaledData = this.pad.toData().map((stroke) => ({
|
||||
...stroke,
|
||||
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
|
||||
}))
|
||||
|
||||
this.pad.fromData(scaledData)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.input.addEventListener('change', (event) => {
|
||||
if (!this.target) return
|
||||
|
||||
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
|
||||
const dataValue = this.dataset.value === 'false' ? false : this.dataset.value || true
|
||||
|
||||
if (this.dataset.attribute) {
|
||||
this.target[this.dataset.attribute] = event.target.checked
|
||||
this.target[this.dataset.attribute] = value === dataValue
|
||||
}
|
||||
|
||||
if (this.dataset.className) {
|
||||
this.target.classList.toggle(this.dataset.className, event.target.value !== this.dataset.value)
|
||||
this.target.classList.toggle(this.dataset.className, value !== dataValue)
|
||||
|
||||
if (this.dataset.className === 'hidden' && this.target.tagName === 'INPUT') {
|
||||
this.target.disabled = event.target.value !== this.dataset.value
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const button = this.querySelector('a, button')
|
||||
const button = this.querySelector('a, button, label')
|
||||
|
||||
const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
this.dataset.classes.split(' ').forEach((cls) => {
|
||||
button.classList.toggle(cls)
|
||||
if (this.dataset.action === 'remove') {
|
||||
target.classList.remove(cls)
|
||||
} else if (this.dataset.action === 'add') {
|
||||
target.classList.add(cls)
|
||||
} else {
|
||||
target.classList.toggle(cls)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@ export default class extends HTMLElement {
|
||||
}
|
||||
|
||||
get button () {
|
||||
return this.querySelector('button')
|
||||
return this.querySelector('button, label')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -182,8 +182,8 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="field.type === 'cells'"
|
||||
class="w-full flex items-center"
|
||||
:class="{ 'justify-end': field.preferences?.align === 'right', ...fontClasses }"
|
||||
class="w-full flex"
|
||||
:class="{ 'justify-end': field.preferences?.align === 'right', ...alignClasses, ...fontClasses }"
|
||||
>
|
||||
<div
|
||||
v-for="(char, index) in modelValue"
|
||||
|
||||
@@ -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) }
|
||||
@@ -161,6 +161,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
fetchOptions: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
completedButton: {
|
||||
type: Object,
|
||||
required: false,
|
||||
@@ -214,7 +219,10 @@ export default {
|
||||
download () {
|
||||
this.isDownloading = true
|
||||
|
||||
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`).then(async (response) => {
|
||||
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`, {
|
||||
method: 'GET',
|
||||
...this.fetchOptions
|
||||
}).then(async (response) => {
|
||||
if (response.ok) {
|
||||
const urls = await response.json()
|
||||
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<AppearsOn :field="field" />
|
||||
<div class="text-center">
|
||||
<div class="text-center flex">
|
||||
<input
|
||||
:id="field.uuid"
|
||||
ref="input"
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
id="expand_form_button"
|
||||
class="btn btn-neutral flex text-white absolute bottom-0 w-full mb-3 expand-form-button text-base"
|
||||
style="width: 96%; margin-left: 2%"
|
||||
@click.prevent="[isFormVisible = true, scrollIntoField(currentField)]"
|
||||
@click.prevent="[isFormVisible = true, $nextTick(() => scrollIntoField(currentField))]"
|
||||
>
|
||||
<template v-if="['initials', 'signature'].includes(currentField.type)">
|
||||
<IconWritingSign stroke-width="1.5" />
|
||||
@@ -395,11 +395,15 @@
|
||||
v-model="values[currentField.uuid]"
|
||||
:reason="values[currentField.preferences?.reason_field_uuid]"
|
||||
:field="currentField"
|
||||
:values="values"
|
||||
:previous-value="previousSignatureValueFor(currentField) || previousSignatureValue"
|
||||
:touch-attachment-uuid="previousSignatureValue"
|
||||
:with-typed-signature="withTypedSignature"
|
||||
: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"
|
||||
@@ -407,6 +411,7 @@
|
||||
:submitter="submitter"
|
||||
:show-field-names="showFieldNames"
|
||||
@update:reason="values[currentField.preferences?.reason_field_uuid] = $event"
|
||||
@touch-attachment="attachmentsIndex[previousSignatureValue] ? attachmentsIndex[previousSignatureValue].created_at = new Date() : null"
|
||||
@attached="attachments.push($event)"
|
||||
@start="scrollIntoField(currentField)"
|
||||
@minimize="minimizeForm"
|
||||
@@ -418,6 +423,7 @@
|
||||
v-model="values[currentField.uuid]"
|
||||
:field="currentField"
|
||||
:dry-run="dryRun"
|
||||
:submitter="submitter"
|
||||
:previous-value="previousInitialsValue"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:show-field-names="showFieldNames"
|
||||
@@ -526,6 +532,7 @@
|
||||
v-else-if="isInvite"
|
||||
:submitters="inviteSubmitters"
|
||||
:optional-submitters="optionalInviteSubmitters"
|
||||
:fetch-options="fetchOptions"
|
||||
:submitter-slug="submitterSlug"
|
||||
:authenticity-token="authenticityToken"
|
||||
:url="baseUrl + submitPath + '/invite'"
|
||||
@@ -539,6 +546,7 @@
|
||||
:has-signature-fields="stepFields.some((fields) => fields.some((f) => ['signature', 'initials'].includes(f.type)))"
|
||||
:has-multiple-documents="hasMultipleDocuments"
|
||||
:completed-button="completedRedirectUrl ? {} : completedButton"
|
||||
:fetch-options="fetchOptions"
|
||||
:completed-message="completedRedirectUrl ? {} : completedMessage"
|
||||
:with-send-copy-button="withSendCopyButton && !completedRedirectUrl"
|
||||
:with-download-button="withDownloadButton && !completedRedirectUrl && !dryRun"
|
||||
@@ -551,14 +559,18 @@
|
||||
class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1 select-none"
|
||||
>
|
||||
<div class="flex items-center flex-wrap steps-progress">
|
||||
<a
|
||||
<template
|
||||
v-for="(step, index) in stepFields"
|
||||
:key="step[0].uuid"
|
||||
href="#"
|
||||
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1"
|
||||
:class="{ 'bg-base-300 steps-progress-current': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }"
|
||||
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(index, true)]"
|
||||
/>
|
||||
>
|
||||
<a
|
||||
v-if="!onlyRequiredFields || step.some((f) => f.required)"
|
||||
href="#"
|
||||
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1"
|
||||
:class="{ 'bg-base-300 steps-progress-current': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }"
|
||||
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(index, true)]"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -592,6 +604,18 @@ import AppearsOn from './appears_on'
|
||||
import i18n from './i18n'
|
||||
import { sanitizeUrl } from '@braintree/sanitize-url'
|
||||
|
||||
if (typeof URL.canParse !== 'function') {
|
||||
URL.canParse = function (url, base) {
|
||||
try {
|
||||
const parsed = new URL(url, base)
|
||||
|
||||
return !!parsed
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = (obj) => {
|
||||
if (obj == null) return true
|
||||
|
||||
@@ -658,6 +682,11 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
fetchOptions: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
optionalInviteSubmitters: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -678,6 +707,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
onlyRequiredFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
requireSigningReason: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -800,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,
|
||||
@@ -914,10 +958,12 @@ export default {
|
||||
}, {})
|
||||
},
|
||||
attachmentConditionsIndex () {
|
||||
const cache = {}
|
||||
|
||||
return this.schema.reduce((acc, item) => {
|
||||
if (item.conditions?.length) {
|
||||
if (item.conditions.every((c) => this.fieldsUuidIndex[c.field_uuid])) {
|
||||
acc[item.attachment_uuid] = this.checkFieldConditions(item)
|
||||
acc[item.attachment_uuid] = this.checkFieldConditions(item, cache)
|
||||
} else {
|
||||
acc[item.attachment_uuid] = true
|
||||
}
|
||||
@@ -938,7 +984,13 @@ export default {
|
||||
submitButtonText () {
|
||||
if (this.alwaysMinimize) {
|
||||
return this.t('submit')
|
||||
} else if (this.stepFields.length === this.currentStep + 1) {
|
||||
} else if (!this.onlyRequiredFields && this.stepFields.length === this.currentStep + 1) {
|
||||
if (this.currentField.type === 'signature') {
|
||||
return this.t('sign_and_complete')
|
||||
} else {
|
||||
return this.t('complete')
|
||||
}
|
||||
} else if (this.onlyRequiredFields && !this.findNextStep(this.currentStep)) {
|
||||
if (this.currentField.type === 'signature') {
|
||||
return this.t('sign_and_complete')
|
||||
} else {
|
||||
@@ -984,7 +1036,9 @@ export default {
|
||||
},
|
||||
previousInitialsValue () {
|
||||
if (this.reuseSignature !== false) {
|
||||
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
|
||||
const initialsField = this.fields.findLast
|
||||
? this.fields.findLast((field) => field.type === 'initials' && !!this.values[field.uuid])
|
||||
: [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
|
||||
|
||||
return this.values[initialsField?.uuid]
|
||||
} else {
|
||||
@@ -1011,7 +1065,9 @@ export default {
|
||||
return this.readonlyFields.filter((f) => f.conditions?.length)
|
||||
},
|
||||
readonlyFields () {
|
||||
return this.fields.filter((f) => f.readonly && this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f))
|
||||
const cache = {}
|
||||
|
||||
return this.fields.filter((f) => f.readonly && this.checkFieldConditions(f, cache) && this.checkFieldDocumentsConditions(f))
|
||||
},
|
||||
stepFields () {
|
||||
const verificationFields = []
|
||||
@@ -1066,10 +1122,12 @@ export default {
|
||||
sortedFields.push(verificationFields.pop())
|
||||
}
|
||||
|
||||
const cache = {}
|
||||
|
||||
return sortedFields.reduce((acc, f) => {
|
||||
const prevStep = acc[acc.length - 1]
|
||||
|
||||
if (this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f)) {
|
||||
if (this.checkFieldConditions(f, cache) && this.checkFieldDocumentsConditions(f)) {
|
||||
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox' && !f.description) {
|
||||
prevStep.push(f)
|
||||
} else {
|
||||
@@ -1081,7 +1139,9 @@ export default {
|
||||
}, [])
|
||||
},
|
||||
formulaFields () {
|
||||
return this.fields.filter((f) => f.preferences?.formula && f.type !== 'payment' && this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f))
|
||||
const cache = {}
|
||||
|
||||
return this.fields.filter((f) => f.preferences?.formula && f.type !== 'payment' && this.checkFieldConditions(f, cache) && this.checkFieldDocumentsConditions(f))
|
||||
},
|
||||
attachmentsIndex () {
|
||||
return this.attachments.reduce((acc, a) => {
|
||||
@@ -1143,15 +1203,17 @@ export default {
|
||||
this.currentStep = Math.max(stepIndex, 0)
|
||||
} else if (this.goToLast) {
|
||||
const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.submittedValues[f.uuid])))
|
||||
const lastFilledStepIndex = this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1
|
||||
const lastFilledStepIndex = this.stepFields.indexOf(this.stepFields.findLast
|
||||
? this.stepFields.findLast((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))
|
||||
: [...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1
|
||||
|
||||
const indexesList = [this.stepFields.length - 1]
|
||||
|
||||
if (requiredEmptyStepIndex !== -1) {
|
||||
if (requiredEmptyStepIndex !== -1 && (!this.onlyRequiredFields || this.stepFields[requiredEmptyStepIndex].some((f) => f.required))) {
|
||||
indexesList.push(requiredEmptyStepIndex)
|
||||
}
|
||||
|
||||
if (lastFilledStepIndex !== -1) {
|
||||
if (lastFilledStepIndex !== -1 && (!this.onlyRequiredFields || this.stepFields[lastFilledStepIndex].some((f) => f.required))) {
|
||||
indexesList.push(lastFilledStepIndex)
|
||||
}
|
||||
|
||||
@@ -1211,27 +1273,35 @@ export default {
|
||||
return true
|
||||
}
|
||||
},
|
||||
checkFieldConditions (field) {
|
||||
checkFieldConditions (field, cache = {}) {
|
||||
const cacheKey = field.uuid || field.attachment_uuid
|
||||
|
||||
if (cache[cacheKey] !== undefined) {
|
||||
return cache[cacheKey]
|
||||
}
|
||||
|
||||
if (field.conditions?.length) {
|
||||
const result = field.conditions.reduce((acc, cond) => {
|
||||
if (cond.operation === 'or') {
|
||||
acc.push(acc.pop() || this.checkFieldCondition(cond))
|
||||
acc.push(acc.pop() || this.checkFieldCondition(cond, cache))
|
||||
} else {
|
||||
acc.push(this.checkFieldCondition(cond))
|
||||
acc.push(this.checkFieldCondition(cond, cache))
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return !result.includes(false)
|
||||
cache[cacheKey] = !result.includes(false)
|
||||
} else {
|
||||
return true
|
||||
cache[cacheKey] = true
|
||||
}
|
||||
|
||||
return cache[cacheKey]
|
||||
},
|
||||
checkFieldCondition (condition) {
|
||||
checkFieldCondition (condition, cache = {}) {
|
||||
const field = this.fieldsUuidIndex[condition.field_uuid]
|
||||
|
||||
if (['not_empty', 'checked', 'equal', 'contains'].includes(condition.action) && field && !this.checkFieldConditions(field)) {
|
||||
if (['not_empty', 'checked', 'equal', 'contains', 'greater_than', 'less_than'].includes(condition.action) && field && !this.checkFieldConditions(field, cache)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1241,6 +1311,22 @@ export default {
|
||||
return isEmpty(this.values[condition.field_uuid] ?? defaultValue)
|
||||
} else if (['not_empty', 'checked'].includes(condition.action)) {
|
||||
return !isEmpty(this.values[condition.field_uuid] ?? defaultValue)
|
||||
} else if (field?.type === 'number' && ['equal', 'not_equal', 'greater_than', 'less_than'].includes(condition.action)) {
|
||||
const value = this.values[condition.field_uuid] ?? defaultValue
|
||||
|
||||
if (isEmpty(value) || isEmpty(condition.value)) return false
|
||||
|
||||
const actual = parseFloat(value)
|
||||
const expected = parseFloat(condition.value)
|
||||
|
||||
if (Number.isNaN(actual) || Number.isNaN(expected)) return false
|
||||
|
||||
if (condition.action === 'equal') return Math.abs(actual - expected) < Number.EPSILON
|
||||
if (condition.action === 'not_equal') return Math.abs(actual - expected) > Number.EPSILON
|
||||
if (condition.action === 'greater_than') return actual > expected
|
||||
if (condition.action === 'less_than') return actual < expected
|
||||
|
||||
return false
|
||||
} else if (['equal', 'contains'].includes(condition.action) && field) {
|
||||
if (field.options) {
|
||||
const option = field.options.find((o) => o.uuid === condition.value)
|
||||
@@ -1280,6 +1366,13 @@ export default {
|
||||
return `${this.t('option')} ${index + 1}`
|
||||
}
|
||||
},
|
||||
findNextStep (currentStepIndex) {
|
||||
if (this.onlyRequiredFields) {
|
||||
return this.stepFields.find((step, index) => index > currentStepIndex && step.some((f) => f.required))
|
||||
} else {
|
||||
return this.stepFields[currentStepIndex + 1]
|
||||
}
|
||||
},
|
||||
maybeTrackEmailClick () {
|
||||
const { queryParams } = this
|
||||
|
||||
@@ -1341,9 +1434,9 @@ export default {
|
||||
},
|
||||
previousSignatureValueFor (field) {
|
||||
if (this.reuseSignature !== false) {
|
||||
const signatureField = [...this.fields].reverse().find((f) =>
|
||||
f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid]
|
||||
)
|
||||
const signatureField = this.fields.findLast
|
||||
? this.fields.findLast((f) => f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid])
|
||||
: [...this.fields].reverse().find((f) => f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid])
|
||||
|
||||
return this.values[signatureField?.uuid]
|
||||
} else {
|
||||
@@ -1393,7 +1486,8 @@ export default {
|
||||
} else {
|
||||
return fetch(this.baseUrl + this.submitPath, {
|
||||
method: 'POST',
|
||||
body: formData || new FormData(this.$refs.form)
|
||||
body: formData || new FormData(this.$refs.form),
|
||||
...this.fetchOptions
|
||||
}).then((response) => {
|
||||
if (response.status === 200) {
|
||||
currentFieldUuids.forEach((fieldUuid) => {
|
||||
@@ -1426,7 +1520,7 @@ export default {
|
||||
this.isSubmittingComplete = true
|
||||
}
|
||||
|
||||
const submitStep = this.currentStep
|
||||
const submitStepIndex = this.currentStep
|
||||
|
||||
const stepPromise = ['signature', 'phone', 'initials', 'payment', 'verification', 'kba'].includes(this.currentField.type)
|
||||
? this.$refs.currentStep.submit
|
||||
@@ -1434,7 +1528,7 @@ export default {
|
||||
|
||||
stepPromise().then(async () => {
|
||||
const emptyRequiredField = this.stepFields.find((fields, index) => {
|
||||
if (forceComplete ? index === submitStep : index >= submitStep) {
|
||||
if (forceComplete ? index === submitStepIndex : index >= submitStepIndex) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1444,7 +1538,7 @@ export default {
|
||||
})
|
||||
|
||||
const formData = new FormData(this.$refs.form)
|
||||
const isLastStep = (submitStep === this.stepFields.length - 1) || forceComplete
|
||||
const isLastStep = (this.onlyRequiredFields ? !this.findNextStep(submitStepIndex) : (submitStepIndex === this.stepFields.length - 1)) || forceComplete
|
||||
|
||||
if (isLastStep && !emptyRequiredField && !this.inviteSubmitters.length && !this.optionalInviteSubmitters.length) {
|
||||
formData.append('completed', 'true')
|
||||
@@ -1488,7 +1582,7 @@ export default {
|
||||
return Promise.reject(new Error(data.error))
|
||||
}
|
||||
|
||||
const nextStep = (isLastStep && emptyRequiredField) || (forceComplete ? null : this.stepFields[submitStep + 1])
|
||||
const nextStep = (isLastStep && emptyRequiredField) || (forceComplete ? null : this.findNextStep(submitStepIndex))
|
||||
|
||||
if (nextStep) {
|
||||
if (this.alwaysMinimize) {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const en = {
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Please upload an image file',
|
||||
must_be_characters_length: 'Must be {number} characters length',
|
||||
must_be_characters_length: 'Must be {number} characters long',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
|
||||
verify_id: 'Verify ID',
|
||||
identity_verification: 'Identity verification',
|
||||
@@ -97,6 +97,7 @@ const en = {
|
||||
upload: 'Upload',
|
||||
files: 'Files',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.',
|
||||
browser_privacy_settings_block_canvas: 'Your browser privacy settings restrict use of the drawing canvas. Please use a different browser or device, or disable privacy settings that block canvas in order to sign.',
|
||||
wait_countdown_seconds: 'Wait {countdown} seconds'
|
||||
}
|
||||
|
||||
@@ -199,6 +200,7 @@ const es = {
|
||||
upload: 'Subir',
|
||||
files: 'Archivos',
|
||||
signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.',
|
||||
browser_privacy_settings_block_canvas: 'La configuración de privacidad de su navegador restringe el uso del lienzo de dibujo. Utilice un navegador o dispositivo diferente, o desactive la configuración de privacidad que bloquea el lienzo para firmar.',
|
||||
wait_countdown_seconds: 'Espera {countdown} segundos'
|
||||
}
|
||||
|
||||
@@ -301,6 +303,7 @@ const it = {
|
||||
upload: 'Carica',
|
||||
files: 'File',
|
||||
signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.',
|
||||
browser_privacy_settings_block_canvas: 'Le impostazioni sulla privacy del browser limitano l\'uso dell\'area di disegno. Utilizza un browser o dispositivo diverso oppure disattiva le impostazioni sulla privacy che bloccano il canvas per firmare.',
|
||||
wait_countdown_seconds: 'Attendi {countdown} secondi'
|
||||
}
|
||||
|
||||
@@ -403,6 +406,7 @@ const de = {
|
||||
upload: 'Hochladen',
|
||||
files: 'Dateien',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte neu zeichnen.',
|
||||
browser_privacy_settings_block_canvas: 'Die Datenschutzeinstellungen Ihres Browsers schränken die Nutzung der Zeichenfläche ein. Bitte verwenden Sie einen anderen Browser oder ein anderes Gerät oder deaktivieren Sie die Datenschutzeinstellungen, die Canvas blockieren, um zu unterschreiben.',
|
||||
wait_countdown_seconds: 'Bitte {countdown} Sekunden warten'
|
||||
}
|
||||
|
||||
@@ -505,6 +509,7 @@ const fr = {
|
||||
upload: 'Téléverser',
|
||||
files: 'Fichiers',
|
||||
signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.',
|
||||
browser_privacy_settings_block_canvas: 'Les paramètres de confidentialité de votre navigateur empêchent l\'utilisation du canevas de dessin. Veuillez utiliser un autre navigateur ou appareil, ou désactiver les paramètres de confidentialité qui bloquent le canevas pour signer.',
|
||||
wait_countdown_seconds: 'Veuillez patienter {countdown} secondes'
|
||||
}
|
||||
|
||||
@@ -607,6 +612,7 @@ const pl = {
|
||||
upload: 'Przesyłanie',
|
||||
files: 'Pliki',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.',
|
||||
browser_privacy_settings_block_canvas: 'Ustawienia prywatności przeglądarki blokują użycie obszaru rysowania. Użyj innej przeglądarki lub urządzenia albo wyłącz ustawienia prywatności blokujące canvas, aby podpisać.',
|
||||
wait_countdown_seconds: 'Poczekaj {countdown} sekund'
|
||||
}
|
||||
|
||||
@@ -709,6 +715,7 @@ const uk = {
|
||||
upload: 'Завантажити',
|
||||
files: 'Файли',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.',
|
||||
browser_privacy_settings_block_canvas: 'Налаштування конфіденційності вашого браузера блокують використання полотна для малювання. Будь ласка, скористайтеся іншим браузером або пристроєм, або вимкніть налаштування конфіденційності, що блокують canvas, щоб підписати.',
|
||||
wait_countdown_seconds: 'Зачекайте {countdown} секунд'
|
||||
}
|
||||
|
||||
@@ -811,6 +818,7 @@ const cs = {
|
||||
upload: 'Nahrát',
|
||||
files: 'Soubory',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.',
|
||||
browser_privacy_settings_block_canvas: 'Nastavení soukromí vašeho prohlížeče omezuje použití kreslicího plátna. Použijte prosím jiný prohlížeč nebo zařízení, nebo vypněte nastavení soukromí blokující canvas pro podepsání.',
|
||||
wait_countdown_seconds: 'Počkejte {countdown} sekund'
|
||||
}
|
||||
|
||||
@@ -913,6 +921,7 @@ const pt = {
|
||||
upload: 'Carregar',
|
||||
files: 'Arquivos',
|
||||
signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.',
|
||||
browser_privacy_settings_block_canvas: 'As configurações de privacidade do seu navegador restringem o uso da área de desenho. Use um navegador ou dispositivo diferente, ou desative as configurações de privacidade que bloqueiam o canvas para assinar.',
|
||||
wait_countdown_seconds: 'Aguarde {countdown} segundos'
|
||||
}
|
||||
|
||||
@@ -1015,6 +1024,7 @@ const he = {
|
||||
upload: 'העלאה',
|
||||
files: 'קבצים',
|
||||
signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.',
|
||||
browser_privacy_settings_block_canvas: 'הגדרות הפרטיות של הדפדפן שלך מגבילות את השימוש באזור הציור. אנא השתמש בדפדפן או מכשיר אחר, או בטל את הגדרות הפרטיות החוסמות canvas כדי לחתום.',
|
||||
wait_countdown_seconds: 'המתן {countdown} שניות'
|
||||
}
|
||||
|
||||
@@ -1117,6 +1127,7 @@ const nl = {
|
||||
upload: 'Uploaden',
|
||||
files: 'Bestanden',
|
||||
signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.',
|
||||
browser_privacy_settings_block_canvas: 'De privacyinstellingen van uw browser beperken het gebruik van het tekenveld. Gebruik een andere browser of ander apparaat, of schakel de privacyinstellingen uit die canvas blokkeren om te ondertekenen.',
|
||||
wait_countdown_seconds: 'Wacht {countdown} seconden'
|
||||
}
|
||||
|
||||
@@ -1219,6 +1230,7 @@ const ar = {
|
||||
upload: 'تحميل',
|
||||
files: 'الملفات',
|
||||
signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.',
|
||||
browser_privacy_settings_block_canvas: 'إعدادات الخصوصية في متصفحك تمنع استخدام لوحة الرسم. يرجى استخدام متصفح أو جهاز مختلف، أو تعطيل إعدادات الخصوصية التي تحظر canvas للتوقيع.',
|
||||
wait_countdown_seconds: 'انتظر {countdown} ثانية'
|
||||
}
|
||||
|
||||
@@ -1321,6 +1333,7 @@ const ko = {
|
||||
upload: '업로드',
|
||||
files: '파일',
|
||||
signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.',
|
||||
browser_privacy_settings_block_canvas: '브라우저 개인정보 보호 설정으로 인해 그리기 캔버스를 사용할 수 없습니다. 다른 브라우저나 기기를 사용하거나, 서명을 위해 캔버스를 차단하는 개인정보 보호 설정을 비활성화해 주세요.',
|
||||
wait_countdown_seconds: '{countdown}초 기다리세요'
|
||||
}
|
||||
|
||||
@@ -1423,6 +1436,7 @@ const ja = {
|
||||
upload: 'アップロード',
|
||||
files: 'ファイル',
|
||||
signature_is_too_small_or_simple_please_redraw: '署名が小さすぎるか単純すぎます。もう一度描いてください。',
|
||||
browser_privacy_settings_block_canvas: 'ブラウザのプライバシー設定により、描画キャンバスの使用が制限されています。別のブラウザまたはデバイスを使用するか、署名するためにキャンバスをブロックするプライバシー設定を無効にしてください。',
|
||||
wait_countdown_seconds: '{countdown} 秒お待ちください'
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
|
||||
<script>
|
||||
import { cropCanvasAndExportToPNG } from './crop_canvas'
|
||||
import { isCanvasBlocked } from './validate_signature'
|
||||
import { IconReload, IconTextSize, IconUpload, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||
import SignaturePad from 'signature_pad'
|
||||
import AppearsOn from './appears_on'
|
||||
@@ -175,6 +176,10 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
submitter: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
dryRun: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -257,6 +262,14 @@ export default {
|
||||
|
||||
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||
|
||||
if (!this.isDrawInitials) {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.textInput) {
|
||||
this.initTextInitial()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.intersectionObserver?.disconnect()
|
||||
}
|
||||
})
|
||||
@@ -332,12 +345,29 @@ export default {
|
||||
|
||||
if (!this.isDrawInitials) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.textInput.focus()
|
||||
if (this.$refs.textInput) {
|
||||
if (!this.submitter.name) {
|
||||
this.$refs.textInput.focus()
|
||||
}
|
||||
|
||||
this.$emit('start')
|
||||
this.initTextInitial()
|
||||
|
||||
this.$emit('start')
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
initTextInitial () {
|
||||
if (this.submitter.name) {
|
||||
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) {
|
||||
this.updateWrittenInitials({ target: this.$refs.textInput })
|
||||
}
|
||||
},
|
||||
async submit () {
|
||||
if (this.modelValue || this.computedPreviousValue) {
|
||||
if (this.computedPreviousValue) {
|
||||
@@ -392,7 +422,15 @@ export default {
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (this.field.required === true) {
|
||||
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
|
||||
if (isCanvasBlocked()) {
|
||||
alert(this.t('browser_privacy_settings_block_canvas'))
|
||||
|
||||
if (window.Rollbar) {
|
||||
window.Rollbar.info('Canvas blocked')
|
||||
}
|
||||
} else {
|
||||
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
|
||||
}
|
||||
|
||||
return reject(error)
|
||||
} else {
|
||||
|
||||
@@ -78,6 +78,11 @@ export default {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
fetchOptions: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
optionalSubmitters: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -108,7 +113,8 @@ export default {
|
||||
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: new FormData(this.$refs.form)
|
||||
body: new FormData(this.$refs.form),
|
||||
...this.fetchOptions
|
||||
}).then((response) => {
|
||||
if (response.status === 200) {
|
||||
this.$emit('success')
|
||||
|
||||
@@ -497,10 +497,10 @@ export default {
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!resp.ok) throw new Error('Failed to start KBA')
|
||||
|
||||
const data = await resp.json()
|
||||
|
||||
if (!resp.ok) throw new Error(data.error || 'Failed to start KBA')
|
||||
|
||||
if (data.result && data.result.action === 'FAIL') {
|
||||
if (data.result.detail === 'NO MATCH') {
|
||||
throw new Error('Unfortunately, we were unable to start Knowledge Based Authentication with the details provided. Please review and confirm that all your personal details are correct.')
|
||||
@@ -555,7 +555,11 @@ export default {
|
||||
const data = await resp.json()
|
||||
|
||||
if (data.result?.action !== 'PASS') {
|
||||
this.error = 'Knowledge Based Authentication Failed - make sure you provide correct answers for the Knowledge Based authentication.'
|
||||
if (data.result?.issues?.length) {
|
||||
this.error = `Knowledge Based Authentication Failed - make sure you provide correct details for the Knowledge Based authentication: ${data.result.issues.join(', ')}`
|
||||
} else {
|
||||
this.error = 'Knowledge Based Authentication Failed - make sure you provide correct answers for the Knowledge Based authentication.'
|
||||
}
|
||||
|
||||
throw new Error('Knowledge Based Authentication Failed')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -127,6 +127,12 @@
|
||||
type="hidden"
|
||||
:name="`values[${field.uuid}]`"
|
||||
>
|
||||
<input
|
||||
v-if="isTouchAttachment"
|
||||
:value="touchAttachmentUuid"
|
||||
type="hidden"
|
||||
name="touch_attachment_uuid"
|
||||
>
|
||||
<img
|
||||
v-if="modelValue || computedPreviousValue"
|
||||
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
|
||||
@@ -175,9 +181,7 @@
|
||||
v-if="isShowQr"
|
||||
class="top-0 bottom-0 right-0 left-0 absolute bg-base-content/10 rounded-2xl"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1.5 right-1.5 md:tooltip"
|
||||
>
|
||||
<div class="absolute top-1.5 right-1.5">
|
||||
<a
|
||||
href="#"
|
||||
class="btn btn-sm btn-circle btn-normal btn-outline"
|
||||
@@ -305,14 +309,14 @@
|
||||
<script>
|
||||
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue'
|
||||
import { cropCanvasAndExportToPNG } from './crop_canvas'
|
||||
import { isValidSignatureCanvas } from './validate_signature'
|
||||
import { isValidSignatureCanvas, isCanvasBlocked } from './validate_signature'
|
||||
import SignaturePad from 'signature_pad'
|
||||
import AppearsOn from './appears_on'
|
||||
import FileDropzone from './dropzone'
|
||||
import MarkdownContent from './markdown_content'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
let isFontLoaded = false
|
||||
let fontLoadPromise = null
|
||||
|
||||
const scale = 3
|
||||
|
||||
@@ -336,6 +340,10 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
requireSigningReason: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -390,25 +398,41 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
touchAttachmentUuid: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
reason: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
signatureText: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
signatureSrc: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason'],
|
||||
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason', 'touch-attachment'],
|
||||
data () {
|
||||
return {
|
||||
isSignatureStarted: false,
|
||||
isShowQr: false,
|
||||
isOtherReason: false,
|
||||
isUsePreviousValue: true,
|
||||
isTextSignature: this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload',
|
||||
isTouchAttachment: false,
|
||||
isTextSignature: !this.signatureSrc && (!!this.signatureText || this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload'),
|
||||
uploadImageInputKey: Math.random().toString()
|
||||
}
|
||||
},
|
||||
@@ -448,14 +472,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.canvas) {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
|
||||
|
||||
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||
}
|
||||
})
|
||||
this.$nextTick(() => this.setCanvasSize())
|
||||
|
||||
if (this.$refs.canvas) {
|
||||
this.pad = new SignaturePad(this.$refs.canvas)
|
||||
@@ -470,13 +487,20 @@ export default {
|
||||
this.$emit('start')
|
||||
})
|
||||
|
||||
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
|
||||
this.intersectionObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
|
||||
this.setCanvasSize()
|
||||
|
||||
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||
if (this.signatureSrc) {
|
||||
this.$nextTick(() => this.drawSignatureSrc())
|
||||
} else if (this.isTextSignature) {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.textInput) {
|
||||
this.initTypedSignature()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.intersectionObserver?.disconnect()
|
||||
}
|
||||
@@ -484,13 +508,66 @@ export default {
|
||||
})
|
||||
|
||||
this.intersectionObserver.observe(this.$refs.canvas)
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.$refs.canvas) return
|
||||
|
||||
const { width, height } = this.$refs.canvas
|
||||
|
||||
this.setCanvasSize()
|
||||
|
||||
if (this.$refs.canvas.width !== width || this.$refs.canvas.height !== height) {
|
||||
this.redrawCanvas(width, height)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.resizeObserver.observe(this.$refs.canvas.parentNode)
|
||||
|
||||
if (this.isTextSignature) {
|
||||
this.loadFont()
|
||||
}
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.intersectionObserver?.disconnect()
|
||||
this.resizeObserver?.disconnect()
|
||||
this.stopCheckSignature()
|
||||
},
|
||||
methods: {
|
||||
setCanvasSize () {
|
||||
const canvas = this.$refs.canvas
|
||||
|
||||
if (canvas) {
|
||||
const width = canvas.parentNode.clientWidth
|
||||
const height = width / 3
|
||||
|
||||
if (canvas.width !== width * scale || canvas.height !== height * scale) {
|
||||
canvas.width = width * scale
|
||||
canvas.height = height * scale
|
||||
|
||||
canvas.getContext('2d').scale(scale, scale)
|
||||
}
|
||||
}
|
||||
},
|
||||
redrawCanvas (oldWidth, oldHeight) {
|
||||
const canvas = this.$refs.canvas
|
||||
|
||||
if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
|
||||
const sx = canvas.width / oldWidth
|
||||
const sy = canvas.height / oldHeight
|
||||
|
||||
const scaledData = this.pad.toData().map((stroke) => ({
|
||||
...stroke,
|
||||
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
|
||||
}))
|
||||
|
||||
this.pad.fromData(scaledData)
|
||||
} else if (this.isTextSignature && this.$refs.textInput) {
|
||||
this.updateWrittenSignature({ target: this.$refs.textInput })
|
||||
}
|
||||
},
|
||||
remove () {
|
||||
this.$emit('update:model-value', '')
|
||||
|
||||
@@ -498,17 +575,17 @@ export default {
|
||||
this.isSignatureStarted = false
|
||||
},
|
||||
loadFont () {
|
||||
if (!isFontLoaded) {
|
||||
if (!fontLoadPromise) {
|
||||
const font = new FontFace('Dancing Script', `url(${this.baseUrl}/fonts/DancingScript-Regular.otf) format("opentype")`)
|
||||
|
||||
font.load().then((loadedFont) => {
|
||||
fontLoadPromise = font.load().then((loadedFont) => {
|
||||
document.fonts.add(loadedFont)
|
||||
|
||||
isFontLoaded = true
|
||||
}).catch((error) => {
|
||||
console.error('Font loading failed:', error)
|
||||
})
|
||||
}
|
||||
|
||||
return fontLoadPromise
|
||||
},
|
||||
showQr () {
|
||||
this.isShowQr = true
|
||||
@@ -608,14 +685,73 @@ export default {
|
||||
|
||||
if (this.isTextSignature) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.textInput.focus()
|
||||
if (this.$refs.textInput) {
|
||||
if (!this.submitter.name) {
|
||||
this.$refs.textInput.focus()
|
||||
}
|
||||
|
||||
this.loadFont()
|
||||
this.initTypedSignature()
|
||||
|
||||
this.$emit('start')
|
||||
this.$emit('start')
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
async initTypedSignature () {
|
||||
if (this.signatureText) {
|
||||
this.$refs.textInput.value = this.signatureText
|
||||
} else if (this.submitter.name) {
|
||||
this.$refs.textInput.value = this.submitter.name
|
||||
}
|
||||
|
||||
await this.loadFont()
|
||||
|
||||
if (this.$refs.textInput.value) {
|
||||
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()
|
||||
@@ -694,6 +830,13 @@ export default {
|
||||
},
|
||||
async submit () {
|
||||
if (this.modelValue || this.computedPreviousValue) {
|
||||
if (this.touchAttachmentUuid && this.computedPreviousValue === this.touchAttachmentUuid && !Object.values(this.values).includes(this.touchAttachmentUuid)) {
|
||||
this.isTouchAttachment = true
|
||||
this.$emit('touch-attachment', this.touchAttachmentUuid)
|
||||
} else {
|
||||
this.isTouchAttachment = false
|
||||
}
|
||||
|
||||
if (this.computedPreviousValue) {
|
||||
this.$emit('update:model-value', this.computedPreviousValue)
|
||||
}
|
||||
@@ -703,7 +846,15 @@ export default {
|
||||
|
||||
if (this.isSignatureStarted && this.pad.toData().length > 0 && !isValidSignatureCanvas(this.pad.toData())) {
|
||||
if (this.field.required === true || this.pad.toData().length > 0) {
|
||||
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
|
||||
if (isCanvasBlocked()) {
|
||||
alert(this.t('browser_privacy_settings_block_canvas'))
|
||||
|
||||
if (window.Rollbar) {
|
||||
window.Rollbar.info('Canvas blocked')
|
||||
}
|
||||
} else {
|
||||
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
|
||||
}
|
||||
|
||||
return Promise.reject(new Error('Image too small or simple'))
|
||||
} else {
|
||||
@@ -759,7 +910,15 @@ export default {
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (this.field.required === true) {
|
||||
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
|
||||
if (isCanvasBlocked()) {
|
||||
alert(this.t('browser_privacy_settings_block_canvas'))
|
||||
|
||||
if (window.Rollbar) {
|
||||
window.Rollbar.info('Canvas blocked')
|
||||
}
|
||||
} else {
|
||||
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
|
||||
}
|
||||
|
||||
return reject(error)
|
||||
} else {
|
||||
|
||||
@@ -35,4 +35,24 @@ function isValidSignatureCanvas (data) {
|
||||
return validStrokes.length > 0
|
||||
}
|
||||
|
||||
export { isValidSignatureCanvas }
|
||||
function isCanvasBlocked () {
|
||||
try {
|
||||
const testCanvas = document.createElement('canvas')
|
||||
|
||||
testCanvas.width = 2
|
||||
testCanvas.height = 2
|
||||
|
||||
const ctx = testCanvas.getContext('2d')
|
||||
|
||||
ctx.fillStyle = 'rgb(255, 0, 0)'
|
||||
ctx.fillRect(0, 0, 2, 2)
|
||||
|
||||
const pixel = ctx.getImageData(0, 0, 1, 1).data
|
||||
|
||||
return pixel[0] !== 255 || pixel[1] !== 0 || pixel[2] !== 0 || pixel[3] !== 255
|
||||
} catch (e) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export { isValidSignatureCanvas, isCanvasBlocked }
|
||||
|
||||
@@ -20,8 +20,11 @@
|
||||
{{ t('complete_all_required_fields_to_proceed_with_identity_verification') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="errorMessage">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
v-else-if="isLoading"
|
||||
class="w-full flex space-x-2 justify-center mb-2"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
@@ -96,6 +99,7 @@ export default {
|
||||
isMathLoaded: false,
|
||||
redirectUrl: '',
|
||||
isLoading: false,
|
||||
errorMessage: '',
|
||||
eidEasyData: {}
|
||||
}
|
||||
},
|
||||
@@ -119,6 +123,7 @@ export default {
|
||||
docId: this.eidEasyData.doc_id,
|
||||
language: this.locale,
|
||||
countryCode: this.countryCode,
|
||||
sandbox: ['demo.docuseal.tech'].includes(location.host),
|
||||
enabledMethods: {
|
||||
signature: this.eidEasyData.available_methods
|
||||
},
|
||||
@@ -161,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,135 +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
|
||||
: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"
|
||||
@click-condition="isShowConditionsModal = true"
|
||||
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
|
||||
/>
|
||||
</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"
|
||||
@@ -250,7 +141,8 @@
|
||||
</span>
|
||||
<div
|
||||
v-else-if="field.type === 'cells' && field.default_value"
|
||||
class="w-full flex items-center"
|
||||
class="w-full flex"
|
||||
:class="fontClasses"
|
||||
>
|
||||
<div
|
||||
v-for="(char, index) in field.default_value"
|
||||
@@ -323,83 +215,22 @@
|
||||
@mousedown.stop="startResize"
|
||||
@touchstart="startTouchResize"
|
||||
/>
|
||||
<Teleport
|
||||
v-if="isShowFormulaModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<FormulaModal
|
||||
:field="field"
|
||||
:editable="editable && !defaultField"
|
||||
:default-field="defaultField"
|
||||
:build-default-name="buildDefaultName"
|
||||
@close="isShowFormulaModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowFontModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<FontModal
|
||||
:field="field"
|
||||
:editable="editable && !defaultField"
|
||||
:default-field="defaultField"
|
||||
:build-default-name="buildDefaultName"
|
||||
@close="isShowFontModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowConditionsModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<ConditionsModal
|
||||
:item="field"
|
||||
:build-default-name="buildDefaultName"
|
||||
:default-field="defaultField"
|
||||
@close="isShowConditionsModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowDescriptionModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<DescriptionModal
|
||||
:field="field"
|
||||
:editable="editable && !defaultField"
|
||||
:default-field="defaultField"
|
||||
:build-default-name="buildDefaultName"
|
||||
@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'],
|
||||
inject: ['template', 'save', 't', 'isInlineSize', 'selectedAreasRef', 'isCmdKeyRef', 'getFieldTypeIndex'],
|
||||
props: {
|
||||
area: {
|
||||
type: Object,
|
||||
@@ -465,26 +296,24 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
isMobile: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
isSelectMode: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to'],
|
||||
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 }
|
||||
@@ -573,11 +402,8 @@ 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, this.template.fields)
|
||||
return this.buildDefaultName(this.field)
|
||||
},
|
||||
fontClasses () {
|
||||
if (!this.field.preferences) {
|
||||
@@ -597,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 = []
|
||||
|
||||
@@ -686,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]
|
||||
|
||||
@@ -773,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)
|
||||
@@ -824,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()
|
||||
|
||||
@@ -908,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
|
||||
@@ -1105,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>
|
||||
@@ -20,8 +20,9 @@
|
||||
/>
|
||||
<DragPlaceholder
|
||||
ref="dragPlaceholder"
|
||||
:field="fieldsDragFieldRef.value || toRaw(dragField)"
|
||||
:field="customDragFieldRef.value || fieldsDragFieldRef.value || toRaw(dragField)"
|
||||
:is-field="template.fields.includes(fieldsDragFieldRef.value)"
|
||||
:is-custom="!!customDragFieldRef.value"
|
||||
:is-default="defaultFields.includes(toRaw(dragField))"
|
||||
:is-required="defaultRequiredFields.includes(toRaw(dragField))"
|
||||
/>
|
||||
@@ -80,7 +81,7 @@
|
||||
/>
|
||||
<template v-else>
|
||||
<form
|
||||
v-if="withSignYourselfButton && template.submitters.length < 2"
|
||||
v-if="withSignYourselfButton && undefinedSubmitters.length < 2 && (!template.variables_schema || Object.keys(template.variables_schema).length === 0)"
|
||||
target="_blank"
|
||||
data-turbo="false"
|
||||
class="inline"
|
||||
@@ -273,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"
|
||||
@@ -351,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"
|
||||
@@ -362,7 +382,7 @@
|
||||
:is-drag="!!dragField"
|
||||
:input-mode="inputMode"
|
||||
:default-fields="[...defaultRequiredFields, ...defaultFields]"
|
||||
:allow-draw="!onlyDefinedFields || drawField"
|
||||
:allow-draw="!onlyDefinedFields || drawField || drawCustomField"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
:data-document-uuid="document.uuid"
|
||||
@@ -371,17 +391,20 @@
|
||||
:with-field-placeholder="withFieldPlaceholder"
|
||||
:draw-field="drawField"
|
||||
:draw-field-type="drawFieldType"
|
||||
:draw-custom-field="drawCustomField"
|
||||
:editable="editable"
|
||||
:is-mobile="isMobile"
|
||||
:base-url="baseUrl"
|
||||
:with-fields-detection="withFieldsDetection"
|
||||
@draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
|
||||
@draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', drawCustomField = null, showDrawField = false]"
|
||||
@drop-field="onDropfield"
|
||||
@remove-area="removeArea"
|
||||
@paste-field="pasteField"
|
||||
@copy-field="copyField"
|
||||
@add-custom-field="addCustomField"
|
||||
@set-draw="[drawField = $event.field, drawOption = $event.option]"
|
||||
@copy-selected-areas="copySelectedAreas"
|
||||
@delete-selected-areas="deleteSelectedAreas"
|
||||
@align-selected-areas="alignSelectedAreas"
|
||||
@autodetect-fields="detectFieldsForPage"
|
||||
/>
|
||||
<DocumentControls
|
||||
@@ -436,15 +459,15 @@
|
||||
v-if="withFieldsList && !isMobile"
|
||||
id="fields_list_container"
|
||||
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block fields-list-container"
|
||||
:class="drawField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'"
|
||||
:class="drawField || drawCustomField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'"
|
||||
>
|
||||
<div
|
||||
v-if="showDrawField || drawField"
|
||||
v-if="showDrawField || drawField || drawCustomField"
|
||||
class="sticky inset-0 h-full z-20"
|
||||
:style="{ backgroundColor }"
|
||||
>
|
||||
<div class="bg-base-200 rounded-lg p-5 text-center space-y-4 draw-field-container">
|
||||
<p v-if="(drawField?.type || drawFieldType) === 'strikethrough'">
|
||||
<p v-if="(drawField?.type || drawFieldType || drawCustomField?.type) === 'strikethrough'">
|
||||
{{ t('draw_strikethrough_the_document') }}
|
||||
</p>
|
||||
<p v-else>
|
||||
@@ -458,10 +481,10 @@
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<a
|
||||
v-if="!drawField && !drawOption && !['stamp', 'signature', 'initials', 'heading', 'strikethrough'].includes(drawField?.type || drawFieldType)"
|
||||
v-if="!drawField && !drawOption && !['stamp', 'signature', 'initials', 'heading', 'strikethrough'].includes(drawField?.type || drawFieldType || drawCustomField?.type)"
|
||||
href="#"
|
||||
class="link block mt-3 text-sm"
|
||||
@click.prevent="[addField(drawFieldType), drawField = null, drawOption = null, withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
|
||||
@click.prevent="drawCustomField ? addCustomFieldWithoutDraw() : [addField(drawFieldType), drawField = null, drawOption = null, withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
|
||||
>
|
||||
{{ t('or_add_field_without_drawing') }}
|
||||
</a>
|
||||
@@ -477,6 +500,8 @@
|
||||
:with-help="withHelp"
|
||||
:default-submitters="defaultSubmitters"
|
||||
:draw-field-type="drawFieldType"
|
||||
:custom-fields="customFields"
|
||||
:with-custom-fields="withCustomFields"
|
||||
:with-fields-search="withFieldsSearch"
|
||||
:default-fields="[...defaultRequiredFields, ...defaultFields]"
|
||||
:template="template"
|
||||
@@ -491,13 +516,17 @@
|
||||
: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]"
|
||||
@set-drag="dragField = $event"
|
||||
@set-drag-placeholder="$refs.dragPlaceholder.dragPlaceholder = $event"
|
||||
@change-submitter="selectedSubmitter = $event"
|
||||
@drag-end="[dragField = null, $refs.dragPlaceholder.dragPlaceholder = null]"
|
||||
@scroll-to-area="scrollToArea"
|
||||
@rebuild-variables-schema="rebuildVariablesSchema"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -585,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,
|
||||
@@ -632,12 +662,15 @@ export default {
|
||||
isPaymentConnected: this.isPaymentConnected,
|
||||
withFormula: this.withFormula,
|
||||
withConditions: this.withConditions,
|
||||
withCustomFields: this.withCustomFields,
|
||||
isInlineSize: this.isInlineSize,
|
||||
defaultDrawFieldType: this.defaultDrawFieldType,
|
||||
selectedAreasRef: computed(() => this.selectedAreasRef),
|
||||
fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef),
|
||||
customDragFieldRef: computed(() => this.customDragFieldRef),
|
||||
isSelectModeRef: computed(() => this.isSelectModeRef),
|
||||
isCmdKeyRef: computed(() => this.isCmdKeyRef)
|
||||
isCmdKeyRef: computed(() => this.isCmdKeyRef),
|
||||
getFieldTypeIndex: this.getFieldTypeIndex
|
||||
}
|
||||
},
|
||||
props: {
|
||||
@@ -705,6 +738,26 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withCustomFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withPrefillable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
dynamicDocuments: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
customFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
withAddPageButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -758,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,
|
||||
@@ -821,6 +874,11 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
withDynamicDocuments: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withDocumentsList: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -902,6 +960,7 @@ export default {
|
||||
pendingFieldAttachmentUuids: [],
|
||||
drawField: null,
|
||||
drawFieldType: null,
|
||||
drawCustomField: null,
|
||||
drawOption: null,
|
||||
dragField: null,
|
||||
isDragFile: false
|
||||
@@ -912,16 +971,20 @@ export default {
|
||||
isSelectModeRef: () => ref(false),
|
||||
isCmdKeyRef: () => ref(false),
|
||||
fieldsDragFieldRef: () => ref(),
|
||||
customDragFieldRef: () => ref(),
|
||||
selectedAreasRef: () => ref([]),
|
||||
language () {
|
||||
return this.locale.split('-')[0].toLowerCase()
|
||||
},
|
||||
withPrefillable () {
|
||||
if (this.template.fields) {
|
||||
return this.template.fields.some((f) => f.prefillable)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
undefinedSubmitters () {
|
||||
return this.template.submitters.filter((submitter) => {
|
||||
return !submitter.invite_by_uuid &&
|
||||
!submitter.optional_invite_by_uuid &&
|
||||
!submitter.invite_via_field_uuid &&
|
||||
!submitter.linked_to_uuid &&
|
||||
!submitter.is_requester &&
|
||||
!submitter.email
|
||||
})
|
||||
},
|
||||
isInlineSize () {
|
||||
return CSS.supports('container-type: size')
|
||||
@@ -969,6 +1032,18 @@ export default {
|
||||
|
||||
return areas
|
||||
},
|
||||
fieldTypeIndexMap () {
|
||||
const map = {}
|
||||
const typeCounters = {}
|
||||
|
||||
this.template.fields.forEach((f) => {
|
||||
typeCounters[f.type] ||= 0
|
||||
map[f.uuid] = typeCounters[f.type]
|
||||
typeCounters[f.type]++
|
||||
})
|
||||
|
||||
return map
|
||||
},
|
||||
isAllRequiredFieldsAdded () {
|
||||
return !this.defaultRequiredFields?.some((f) => {
|
||||
return !this.template.fields?.some((field) => field.name === f.name)
|
||||
@@ -1062,6 +1137,59 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
toRaw,
|
||||
addCustomField (field) {
|
||||
return this.$refs.fields.addCustomField(field)
|
||||
},
|
||||
getFieldTypeIndex (field) {
|
||||
return this.fieldTypeIndexMap[field.uuid]
|
||||
},
|
||||
addCustomFieldWithoutDraw () {
|
||||
const customField = this.drawCustomField
|
||||
|
||||
const field = JSON.parse(JSON.stringify(customField))
|
||||
|
||||
field.uuid = v4()
|
||||
field.submitter_uuid = this.selectedSubmitter.uuid
|
||||
field.areas = []
|
||||
|
||||
if (field.options?.length) {
|
||||
field.options = field.options.map(opt => ({ ...opt, uuid: v4() }))
|
||||
}
|
||||
|
||||
delete field.conditions
|
||||
|
||||
this.insertField(field)
|
||||
this.save()
|
||||
|
||||
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
|
||||
|
||||
@@ -1116,27 +1244,6 @@ export default {
|
||||
|
||||
this.debouncedSave()
|
||||
},
|
||||
alignSelectedAreas (direction) {
|
||||
const areas = this.selectedAreasRef.value
|
||||
|
||||
let targetValue
|
||||
|
||||
if (direction === 'left') {
|
||||
targetValue = Math.min(...areas.map(a => a.x))
|
||||
areas.forEach((area) => { area.x = targetValue })
|
||||
} else if (direction === 'right') {
|
||||
targetValue = Math.max(...areas.map(a => a.x + a.w))
|
||||
areas.forEach((area) => { area.x = targetValue - area.w })
|
||||
} else if (direction === 'top') {
|
||||
targetValue = Math.min(...areas.map(a => a.y))
|
||||
areas.forEach((area) => { area.y = targetValue })
|
||||
} else if (direction === 'bottom') {
|
||||
targetValue = Math.max(...areas.map(a => a.y + a.h))
|
||||
areas.forEach((area) => { area.y = targetValue - area.h })
|
||||
}
|
||||
|
||||
this.save()
|
||||
},
|
||||
download () {
|
||||
this.isDownloading = true
|
||||
|
||||
@@ -1514,6 +1621,7 @@ export default {
|
||||
clearDrawField () {
|
||||
this.drawField = null
|
||||
this.drawOption = null
|
||||
this.drawCustomField = null
|
||||
this.showDrawField = false
|
||||
|
||||
if (!this.withSelectedFieldType) {
|
||||
@@ -1951,6 +2059,10 @@ export default {
|
||||
}
|
||||
},
|
||||
onDraw ({ area, isTooSmall }) {
|
||||
if (this.drawCustomField) {
|
||||
return this.onDrawCustomField(area)
|
||||
}
|
||||
|
||||
if (this.drawField) {
|
||||
if (this.drawOption) {
|
||||
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
|
||||
@@ -2016,7 +2128,9 @@ export default {
|
||||
}
|
||||
|
||||
if (type === 'checkbox' && !this.drawFieldType && (this.template.fields[this.template.fields.length - 1]?.type === 'checkbox' || area.w)) {
|
||||
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
|
||||
const previousField = this.template.fields.findLast
|
||||
? this.template.fields.findLast((f) => f.type === type)
|
||||
: [...this.template.fields].reverse().find((f) => f.type === type)
|
||||
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
|
||||
|
||||
if (previousArea || area.w) {
|
||||
@@ -2061,6 +2175,10 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.customDragFieldRef.value) {
|
||||
return this.dropCustomField(area)
|
||||
}
|
||||
|
||||
const field = this.fieldsDragFieldRef.value || {
|
||||
name: '',
|
||||
uuid: v4(),
|
||||
@@ -2153,10 +2271,132 @@ export default {
|
||||
})
|
||||
}
|
||||
},
|
||||
dropCustomField (area) {
|
||||
const customField = this.customDragFieldRef.value
|
||||
const customAreas = customField.areas || []
|
||||
|
||||
const field = JSON.parse(JSON.stringify(customField))
|
||||
|
||||
field.uuid = v4()
|
||||
field.submitter_uuid = this.selectedSubmitter.uuid
|
||||
field.areas = []
|
||||
|
||||
if (field.options?.length) {
|
||||
field.options = field.options.map(opt => ({ ...opt, uuid: v4() }))
|
||||
}
|
||||
|
||||
delete field.conditions
|
||||
|
||||
const dropX = (area.x - 6) / area.maskW
|
||||
const dropY = area.y / area.maskH
|
||||
|
||||
if (customAreas.length > 0) {
|
||||
const refArea = customAreas[0]
|
||||
|
||||
customAreas.forEach((customArea) => {
|
||||
const fieldArea = {
|
||||
x: dropX + (customArea.x - refArea.x),
|
||||
y: dropY + (customArea.y - refArea.y) - (customArea.h / 2),
|
||||
w: customArea.w,
|
||||
h: customArea.h,
|
||||
page: area.page,
|
||||
attachment_uuid: area.attachment_uuid
|
||||
}
|
||||
|
||||
if (customArea.cell_w) {
|
||||
fieldArea.cell_w = customArea.cell_w
|
||||
}
|
||||
|
||||
if (customArea.option_uuid && field.options?.length) {
|
||||
const optionIndex = customField.options.findIndex(o => o.uuid === customArea.option_uuid)
|
||||
if (optionIndex !== -1) {
|
||||
fieldArea.option_uuid = field.options[optionIndex].uuid
|
||||
}
|
||||
}
|
||||
|
||||
field.areas.push(fieldArea)
|
||||
})
|
||||
} else {
|
||||
const fieldArea = {
|
||||
x: dropX,
|
||||
y: dropY,
|
||||
page: area.page,
|
||||
attachment_uuid: area.attachment_uuid
|
||||
}
|
||||
|
||||
this.assignDropAreaSize(fieldArea, field, area)
|
||||
|
||||
field.areas.push(fieldArea)
|
||||
}
|
||||
|
||||
this.selectedAreasRef.value = [field.areas[0]]
|
||||
|
||||
this.insertField(field)
|
||||
this.save()
|
||||
|
||||
document.activeElement?.blur()
|
||||
},
|
||||
onDrawCustomField (area) {
|
||||
const customField = this.drawCustomField
|
||||
const customAreas = customField.areas || []
|
||||
|
||||
const field = JSON.parse(JSON.stringify(customField))
|
||||
|
||||
field.uuid = v4()
|
||||
field.submitter_uuid = this.selectedSubmitter.uuid
|
||||
field.areas = []
|
||||
|
||||
if (field.options?.length) {
|
||||
field.options = field.options.map(opt => ({ ...opt, uuid: v4() }))
|
||||
}
|
||||
|
||||
delete field.conditions
|
||||
|
||||
const isClick = area.w === 0 || area.h === 0
|
||||
|
||||
const firstArea = {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
w: area.w || customAreas[0]?.w,
|
||||
h: area.h || customAreas[0]?.h,
|
||||
page: area.page,
|
||||
attachment_uuid: area.attachment_uuid
|
||||
}
|
||||
|
||||
if (!firstArea.w || !firstArea.h) {
|
||||
if (customAreas[0]) {
|
||||
firstArea.w = customAreas[0].w
|
||||
firstArea.h = customAreas[0].h
|
||||
} else {
|
||||
this.setDefaultAreaSize(firstArea, field.type)
|
||||
}
|
||||
}
|
||||
|
||||
if (isClick) {
|
||||
firstArea.x -= firstArea.w / 2
|
||||
firstArea.y -= firstArea.h / 2
|
||||
}
|
||||
|
||||
if (field.options?.length) {
|
||||
firstArea.option_uuid = field.options[0].uuid
|
||||
}
|
||||
|
||||
field.areas.push(firstArea)
|
||||
|
||||
this.selectedAreasRef.value = [field.areas[0]]
|
||||
|
||||
this.insertField(field)
|
||||
this.save()
|
||||
|
||||
this.drawCustomField = null
|
||||
this.showDrawField = false
|
||||
},
|
||||
assignDropAreaSize (fieldArea, field, area) {
|
||||
const fieldType = field.type || 'text'
|
||||
|
||||
const previousField = [...this.template.fields].reverse().find((f) => f.type === fieldType)
|
||||
const previousField = this.template.fields.findLast
|
||||
? this.template.fields.findLast((f) => f.type === fieldType)
|
||||
: [...this.template.fields].reverse().find((f) => f.type === fieldType)
|
||||
|
||||
let baseArea
|
||||
|
||||
@@ -2307,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) {
|
||||
@@ -2371,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)
|
||||
@@ -2473,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
|
||||
@@ -2484,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, {
|
||||
@@ -2712,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' }
|
||||
@@ -2721,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 = {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<span class="modal-title">
|
||||
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item, template.fields) }}
|
||||
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item) }}
|
||||
</span>
|
||||
<a
|
||||
href="#"
|
||||
@@ -83,7 +83,7 @@
|
||||
class="text-base-content"
|
||||
:selected="condition.field_uuid === f.uuid"
|
||||
>
|
||||
{{ f.name || buildDefaultName(f, template.fields) }}
|
||||
{{ f.name || buildDefaultName(f) }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
@@ -124,6 +124,16 @@
|
||||
{{ option.value || `${t('option')} ${index + 1}` }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-else-if="conditionField(condition)?.type === 'number' && ['equal', 'not_equal', 'greater_than', 'less_than'].includes(condition.action)"
|
||||
v-model="condition.value"
|
||||
type="number"
|
||||
step="any"
|
||||
class="input input-bordered input-sm w-full bg-white h-11 pl-4 text-base font-normal"
|
||||
:class="{ 'text-gray-300': !condition.value }"
|
||||
:placeholder="t('type_value')"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
@@ -154,7 +164,7 @@
|
||||
<script>
|
||||
export default {
|
||||
name: 'ConditionModal',
|
||||
inject: ['t', 'save', 'template', 'withConditions'],
|
||||
inject: ['t', 'template', 'withConditions'],
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
@@ -169,18 +179,13 @@ export default {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
withClickSaveEvent: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
excludeFieldUuids: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
emits: ['close', 'click-save'],
|
||||
emits: ['close', 'save'],
|
||||
data () {
|
||||
return {
|
||||
conditions: this.item.conditions?.[0] ? JSON.parse(JSON.stringify(this.item.conditions)) : [{}]
|
||||
@@ -227,6 +232,8 @@ export default {
|
||||
actions.push('equal', 'not_equal')
|
||||
} else if (['multiple'].includes(field.type)) {
|
||||
actions.push('contains', 'does_not_contain')
|
||||
} else if (field.type === 'number') {
|
||||
actions.push('not_empty', 'empty', 'equal', 'not_equal', 'greater_than', 'less_than')
|
||||
} else {
|
||||
actions.push('not_empty', 'empty')
|
||||
}
|
||||
@@ -244,12 +251,7 @@ export default {
|
||||
delete this.item.conditions
|
||||
}
|
||||
|
||||
if (this.withClickSaveEvent) {
|
||||
this.$emit('click-save')
|
||||
} else {
|
||||
this.save()
|
||||
}
|
||||
|
||||
this.$emit('save')
|
||||
this.$emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,24 @@
|
||||
<span
|
||||
ref="contenteditable"
|
||||
dir="auto"
|
||||
:contenteditable="editable"
|
||||
style="min-width: 2px"
|
||||
:class="[iconInline ? 'inline' : 'block', hideIcon ? 'focus:block' : '']"
|
||||
class="peer outline-none"
|
||||
:contenteditable="editable && (!editableOnButton || isEditable)"
|
||||
:data-placeholder="placeholder"
|
||||
:data-empty="isEmpty"
|
||||
:style="{ minWidth }"
|
||||
:class="[iconInline ? (isEmpty ? 'inline-block' : 'inline') : 'block', hideIcon ? 'focus:block' : '']"
|
||||
class="peer relative inline-block outline-none before:pointer-events-none before:absolute before:left-0 before:top-0 before:select-none before:whitespace-pre before:text-neutral-400 before:content-[attr(data-placeholder)] before:opacity-0 data-[empty=true]:before:opacity-100"
|
||||
@paste.prevent="onPaste"
|
||||
@keydown.enter.prevent="blurContenteditable"
|
||||
@input="updateInputValue"
|
||||
@cut="updateInputValue"
|
||||
@focus="$emit('focus', $event)"
|
||||
@blur="onBlur"
|
||||
@click="editable && (!editableOnButton || isEditable) ? '' : $emit('click-contenteditable')"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
<span
|
||||
v-if="withButton"
|
||||
class="relative inline"
|
||||
:class="{ 'peer-focus:hidden': hideIcon, 'peer-focus:invisible': !hideIcon }"
|
||||
>
|
||||
@@ -28,7 +34,7 @@
|
||||
:class="{ invisible: !editable, 'absolute top-1/2 -translate-y-1/2': !iconInline || floatIcon, 'inline align-bottom': iconInline, 'left-0': floatIcon }"
|
||||
:width="iconWidth + 4"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
@click="[focusContenteditable(), selectOnEditClick && selectContent()]"
|
||||
@click="clickEdit"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -49,6 +55,16 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
withButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
iconInline: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -74,6 +90,16 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
editableOnButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
minWidth: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '2px'
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -85,21 +111,34 @@ export default {
|
||||
default: 2
|
||||
}
|
||||
},
|
||||
emits: ['update:model-value', 'focus', 'blur'],
|
||||
emits: ['update:model-value', 'focus', 'blur', 'click-contenteditable'],
|
||||
data () {
|
||||
return {
|
||||
isEditable: false,
|
||||
inputValue: '',
|
||||
value: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isEmpty () {
|
||||
return !this.inputValue.replace(/\u200B/g, '').replace(/\u00A0/g, ' ').trim()
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
modelValue: {
|
||||
handler (value) {
|
||||
this.value = value
|
||||
this.value = value || ''
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.updateInputValue()
|
||||
},
|
||||
methods: {
|
||||
updateInputValue () {
|
||||
this.inputValue = this.$refs.contenteditable?.textContent || ''
|
||||
},
|
||||
onPaste (e) {
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain')
|
||||
|
||||
@@ -110,6 +149,20 @@ export default {
|
||||
selection.getRangeAt(0).insertNode(document.createTextNode(text))
|
||||
selection.collapseToEnd()
|
||||
}
|
||||
|
||||
this.updateInputValue()
|
||||
},
|
||||
clickEdit (e) {
|
||||
this.focusContenteditable()
|
||||
|
||||
if (this.selectOnEditClick) {
|
||||
this.selectContent()
|
||||
}
|
||||
},
|
||||
setText (text) {
|
||||
this.$refs.contenteditable.innerText = text
|
||||
|
||||
this.updateInputValue()
|
||||
},
|
||||
selectContent () {
|
||||
const el = this.$refs.contenteditable
|
||||
@@ -126,13 +179,24 @@ export default {
|
||||
},
|
||||
onBlur (e) {
|
||||
setTimeout(() => {
|
||||
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
|
||||
this.$emit('update:model-value', this.value)
|
||||
if (this.$refs.contenteditable) {
|
||||
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
|
||||
this.$emit('update:model-value', this.value)
|
||||
}
|
||||
|
||||
this.$emit('blur', e)
|
||||
|
||||
this.isEditable = false
|
||||
}, 1)
|
||||
},
|
||||
focusContenteditable () {
|
||||
this.$refs.contenteditable.focus()
|
||||
this.isEditable = true
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.contenteditable.focus()
|
||||
|
||||
this.updateInputValue()
|
||||
})
|
||||
},
|
||||
blurContenteditable () {
|
||||
this.$refs.contenteditable.blur()
|
||||
|
||||
@@ -1,546 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="!isShowFormulaModal && !isShowFontModal && !isShowConditionsModal && !isShowDescriptionModal"
|
||||
ref="menu"
|
||||
class="fixed z-50 p-1 bg-white shadow-lg rounded-lg border border-base-300 min-w-[170px] cursor-default"
|
||||
:style="menuStyle"
|
||||
@mousedown.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<label
|
||||
v-if="showRequired"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm cursor-pointer"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
:checked="isRequired"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
@change="handleToggleRequired($event.target.checked)"
|
||||
@click.stop
|
||||
>
|
||||
<span>{{ t('required') }}</span>
|
||||
</label>
|
||||
<label
|
||||
v-if="showReadOnly"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm cursor-pointer"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
:checked="isReadOnly"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
@change="handleToggleReadOnly($event.target.checked)"
|
||||
@click.stop
|
||||
>
|
||||
<span>{{ t('read_only') }}</span>
|
||||
</label>
|
||||
<hr
|
||||
v-if="(showRequired || showReadOnly) && (showFont || showDescription || showCondition || showFormula)"
|
||||
class="my-1 border-base-300"
|
||||
>
|
||||
<button
|
||||
v-if="showFont && !isMultiSelection"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="openFontModal"
|
||||
>
|
||||
<IconTypography class="w-4 h-4" />
|
||||
<span>{{ t('font') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showDescription"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="openDescriptionModal"
|
||||
>
|
||||
<IconInfoCircle class="w-4 h-4" />
|
||||
<span>{{ t('description') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showCondition && !isMultiSelection"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="openConditionModal"
|
||||
>
|
||||
<IconRouteAltLeft class="w-4 h-4" />
|
||||
<span>{{ t('condition') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showFormula"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="openFormulaModal"
|
||||
>
|
||||
<IconMathFunction class="w-4 h-4" />
|
||||
<span>{{ t('formula') }}</span>
|
||||
</button>
|
||||
<hr
|
||||
v-if="((showFont && !isMultiSelection) || showDescription || (showCondition && !isMultiSelection) || showFormula) && (showCopy || showDelete || showPaste)"
|
||||
class="my-1 border-base-300"
|
||||
>
|
||||
<button
|
||||
v-if="isMultiSelection"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="$emit('align', 'left')"
|
||||
>
|
||||
<IconLayoutAlignLeft class="w-4 h-4" />
|
||||
<span>{{ t('align_left') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="isMultiSelection"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="$emit('align', 'right')"
|
||||
>
|
||||
<IconLayoutAlignRight class="w-4 h-4" />
|
||||
<span>{{ t('align_right') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="isMultiSelection"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="$emit('align', 'top')"
|
||||
>
|
||||
<IconLayoutAlignTop class="w-4 h-4" />
|
||||
<span>{{ t('align_top') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="isMultiSelection"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="$emit('align', 'bottom')"
|
||||
>
|
||||
<IconLayoutAlignBottom class="w-4 h-4" />
|
||||
<span>{{ t('align_bottom') }}</span>
|
||||
</button>
|
||||
<hr
|
||||
v-if="isMultiSelection && (showFont || showCondition)"
|
||||
class="my-1 border-base-300"
|
||||
>
|
||||
<button
|
||||
v-if="showFont && isMultiSelection"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="openFontModal"
|
||||
>
|
||||
<IconTypography class="w-4 h-4" />
|
||||
<span>{{ t('font') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showCondition && isMultiSelection"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="openConditionModal"
|
||||
>
|
||||
<IconRouteAltLeft class="w-4 h-4" />
|
||||
<span>{{ t('condition') }}</span>
|
||||
</button>
|
||||
<hr
|
||||
v-if="isMultiSelection"
|
||||
class="my-1 border-base-300"
|
||||
>
|
||||
<button
|
||||
v-if="showCopy"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
|
||||
@click.stop="$emit('copy')"
|
||||
>
|
||||
<span class="flex items-center space-x-2">
|
||||
<IconCopy class="w-4 h-4" />
|
||||
<span>{{ t('copy') }}</span>
|
||||
</span>
|
||||
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘C' : 'Ctrl+C' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showDelete"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm text-red-600"
|
||||
@click.stop="$emit('delete')"
|
||||
>
|
||||
<span class="flex items-center space-x-2">
|
||||
<IconTrashX class="w-4 h-4" />
|
||||
<span>{{ t('remove') }}</span>
|
||||
</span>
|
||||
<span class="text-xs text-base-content/60 ml-4">Del</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showPaste"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
|
||||
:class="!hasClipboardData ? 'opacity-50 cursor-not-allowed' : 'hover:bg-base-100'"
|
||||
:disabled="!hasClipboardData"
|
||||
@click.stop="!hasClipboardData ? null : $emit('paste')"
|
||||
>
|
||||
<span class="flex items-center space-x-2">
|
||||
<IconClipboard class="w-4 h-4" />
|
||||
<span>{{ t('paste') }}</span>
|
||||
</span>
|
||||
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘V' : 'Ctrl+V' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showSelectFields"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
|
||||
@click.stop="handleToggleSelectMode"
|
||||
>
|
||||
<span class="flex items-center space-x-2">
|
||||
<IconClick
|
||||
v-if="!isSelectModeRef.value"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<IconNewSection
|
||||
v-else
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span>{{ isSelectModeRef.value ? t('draw_fields') : t('select_fields') }}</span>
|
||||
</span>
|
||||
<span class="text-xs text-base-content/60 ml-4">Tab</span>
|
||||
</button>
|
||||
<hr
|
||||
v-if="showAutodetectFields"
|
||||
class="my-1 border-base-300"
|
||||
>
|
||||
<button
|
||||
v-if="showAutodetectFields"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="$emit('autodetect-fields')"
|
||||
>
|
||||
<IconSparkles class="w-4 h-4" />
|
||||
<span>{{ t('autodetect_fields') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<Teleport
|
||||
v-if="isShowFormulaModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<FormulaModal
|
||||
:field="field"
|
||||
:editable="editable"
|
||||
:build-default-name="buildDefaultName"
|
||||
@close="closeModal"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowFontModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<FontModal
|
||||
:field="multiSelectField || field"
|
||||
:area="contextMenu.area"
|
||||
:editable="editable"
|
||||
:build-default-name="buildDefaultName"
|
||||
:with-click-save-event="isMultiSelection"
|
||||
@click-save="handleSaveMultiSelectFontModal"
|
||||
@close="closeModal"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowConditionsModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<ConditionsModal
|
||||
:item="multiSelectField || field"
|
||||
:build-default-name="buildDefaultName"
|
||||
:exclude-field-uuids="isMultiSelection ? selectedFields.map(f => f.uuid) : []"
|
||||
:with-click-save-event="isMultiSelection"
|
||||
@click-save="handleSaveMultiSelectConditionsModal"
|
||||
@close="closeModal"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowDescriptionModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<DescriptionModal
|
||||
:field="field"
|
||||
:editable="editable"
|
||||
:build-default-name="buildDefaultName"
|
||||
@close="closeModal"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconCopy, IconClipboard, IconTrashX, IconTypography, IconInfoCircle, IconRouteAltLeft, IconMathFunction, IconClick, IconNewSection, IconLayoutAlignLeft, IconLayoutAlignRight, IconLayoutAlignTop, IconLayoutAlignBottom, IconSparkles } from '@tabler/icons-vue'
|
||||
import FormulaModal from './formula_modal'
|
||||
import FontModal from './font_modal'
|
||||
import ConditionsModal from './conditions_modal'
|
||||
import DescriptionModal from './description_modal'
|
||||
import Field from './field'
|
||||
import FieldType from './field_type.vue'
|
||||
|
||||
export default {
|
||||
name: 'ContextMenu',
|
||||
components: {
|
||||
IconCopy,
|
||||
IconClipboard,
|
||||
IconTrashX,
|
||||
IconTypography,
|
||||
IconInfoCircle,
|
||||
IconRouteAltLeft,
|
||||
IconMathFunction,
|
||||
IconClick,
|
||||
IconNewSection,
|
||||
IconLayoutAlignLeft,
|
||||
IconLayoutAlignRight,
|
||||
IconLayoutAlignTop,
|
||||
IconLayoutAlignBottom,
|
||||
IconSparkles,
|
||||
FormulaModal,
|
||||
FontModal,
|
||||
ConditionsModal,
|
||||
DescriptionModal
|
||||
},
|
||||
inject: ['t', 'save', 'selectedAreasRef', 'isSelectModeRef'],
|
||||
props: {
|
||||
contextMenu: {
|
||||
type: Object,
|
||||
default: null,
|
||||
required: true
|
||||
},
|
||||
field: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isMultiSelection: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
selectedAreas: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
template: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
withFieldsDetection: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['copy', 'paste', 'delete', 'close', 'align', 'autodetect-fields'],
|
||||
data () {
|
||||
return {
|
||||
isShowFormulaModal: false,
|
||||
isShowFontModal: false,
|
||||
isShowConditionsModal: false,
|
||||
isShowDescriptionModal: false,
|
||||
multiSelectField: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldLabels: FieldType.computed.fieldLabels,
|
||||
modalContainerEl () {
|
||||
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
|
||||
},
|
||||
selectedFields () {
|
||||
if (!this.isMultiSelection) return []
|
||||
|
||||
return this.selectedAreasRef.value.map((area) => {
|
||||
return this.template.fields.find((f) => f.areas?.includes(area))
|
||||
}).filter(Boolean)
|
||||
},
|
||||
isMac () {
|
||||
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
|
||||
},
|
||||
hasClipboardData () {
|
||||
try {
|
||||
const clipboard = localStorage.getItem('docuseal_clipboard')
|
||||
|
||||
if (clipboard) {
|
||||
const data = JSON.parse(clipboard)
|
||||
|
||||
return Date.now() - data.timestamp < 3600000
|
||||
}
|
||||
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
menuStyle () {
|
||||
return {
|
||||
left: this.contextMenu.x + 'px',
|
||||
top: this.contextMenu.y + 'px'
|
||||
}
|
||||
},
|
||||
showCopy () {
|
||||
return !!this.contextMenu.area || this.isMultiSelection
|
||||
},
|
||||
showPaste () {
|
||||
return !this.contextMenu.area && !this.isMultiSelection
|
||||
},
|
||||
showDelete () {
|
||||
return !!this.contextMenu.area || this.isMultiSelection
|
||||
},
|
||||
showFont () {
|
||||
if (this.isMultiSelection) return true
|
||||
if (!this.field) return false
|
||||
|
||||
return ['text', 'number', 'date', 'select', 'heading'].includes(this.field.type)
|
||||
},
|
||||
showDescription () {
|
||||
if (!this.field) return false
|
||||
|
||||
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)
|
||||
},
|
||||
showCondition () {
|
||||
if (this.isMultiSelection) return true
|
||||
if (!this.field) return false
|
||||
|
||||
return !['stamp', 'heading'].includes(this.field.type)
|
||||
},
|
||||
showFormula () {
|
||||
if (!this.field) return false
|
||||
|
||||
return this.field.type === 'number'
|
||||
},
|
||||
showRequired () {
|
||||
if (!this.field) return false
|
||||
|
||||
return !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type)
|
||||
},
|
||||
showReadOnly () {
|
||||
if (!this.field) return false
|
||||
|
||||
return ['text', 'number'].includes(this.field.type)
|
||||
},
|
||||
isRequired () {
|
||||
return this.field?.required || false
|
||||
},
|
||||
isReadOnly () {
|
||||
return this.field?.readonly || false
|
||||
},
|
||||
showSelectFields () {
|
||||
return !this.contextMenu.area && !this.isMultiSelection
|
||||
},
|
||||
showAutodetectFields () {
|
||||
return this.withFieldsDetection && this.editable && !this.contextMenu.area && !this.isMultiSelection
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
document.addEventListener('keydown', this.onKeyDown)
|
||||
document.addEventListener('mousedown', this.handleClickOutside)
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.checkMenuPosition()
|
||||
})
|
||||
},
|
||||
beforeUnmount () {
|
||||
document.removeEventListener('keydown', this.onKeyDown)
|
||||
document.removeEventListener('mousedown', this.handleClickOutside)
|
||||
},
|
||||
methods: {
|
||||
buildDefaultName: Field.methods.buildDefaultName,
|
||||
checkMenuPosition () {
|
||||
if (this.$refs.menu) {
|
||||
const rect = this.$refs.menu.getBoundingClientRect()
|
||||
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
this.contextMenu.y = this.contextMenu.y - rect.height
|
||||
}
|
||||
|
||||
if (rect.right > window.innerWidth) {
|
||||
this.contextMenu.x = this.contextMenu.x - rect.width
|
||||
}
|
||||
}
|
||||
},
|
||||
handleToggleRequired (value) {
|
||||
if (this.field) {
|
||||
this.field.required = value
|
||||
this.save()
|
||||
}
|
||||
},
|
||||
handleToggleReadOnly (value) {
|
||||
if (this.field) {
|
||||
this.field.readonly = value
|
||||
this.save()
|
||||
}
|
||||
},
|
||||
onKeyDown (event) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
this.$emit('close')
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v' && this.hasClipboardData) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
this.$emit('paste')
|
||||
}
|
||||
},
|
||||
handleClickOutside (event) {
|
||||
if (this.$refs.menu && !this.$refs.menu.contains(event.target)) {
|
||||
this.$emit('close')
|
||||
}
|
||||
},
|
||||
openFontModal () {
|
||||
if (this.isMultiSelection) {
|
||||
this.multiSelectField = {
|
||||
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
|
||||
preferences: {}
|
||||
}
|
||||
|
||||
const preferencesStrings = this.selectedFields.map((f) => JSON.stringify(f.preferences || {}))
|
||||
|
||||
if (preferencesStrings.every((s) => s === preferencesStrings[0])) {
|
||||
this.multiSelectField.preferences = JSON.parse(preferencesStrings[0])
|
||||
}
|
||||
}
|
||||
|
||||
this.isShowFontModal = true
|
||||
},
|
||||
openDescriptionModal () {
|
||||
this.isShowDescriptionModal = true
|
||||
},
|
||||
openConditionModal () {
|
||||
if (this.isMultiSelection) {
|
||||
this.multiSelectField = {
|
||||
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
|
||||
conditions: []
|
||||
}
|
||||
|
||||
const conditionStrings = this.selectedFields.map((f) => JSON.stringify(f.conditions || []))
|
||||
|
||||
if (conditionStrings.every((s) => s === conditionStrings[0])) {
|
||||
this.multiSelectField.conditions = JSON.parse(conditionStrings[0])
|
||||
}
|
||||
}
|
||||
|
||||
this.isShowConditionsModal = true
|
||||
},
|
||||
openFormulaModal () {
|
||||
this.isShowFormulaModal = true
|
||||
},
|
||||
closeModal () {
|
||||
this.isShowFormulaModal = false
|
||||
this.isShowFontModal = false
|
||||
this.isShowConditionsModal = false
|
||||
this.isShowDescriptionModal = false
|
||||
this.multiSelectField = null
|
||||
|
||||
this.$emit('close')
|
||||
},
|
||||
handleSaveMultiSelectFontModal () {
|
||||
this.selectedFields.forEach((field) => {
|
||||
field.preferences = { ...field.preferences, ...this.multiSelectField.preferences }
|
||||
})
|
||||
|
||||
this.save()
|
||||
|
||||
this.closeModal()
|
||||
},
|
||||
handleSaveMultiSelectConditionsModal () {
|
||||
this.selectedFields.forEach((field) => {
|
||||
field.conditions = JSON.parse(JSON.stringify(this.multiSelectField.conditions))
|
||||
})
|
||||
|
||||
this.save()
|
||||
|
||||
this.closeModal()
|
||||
},
|
||||
handleToggleSelectMode () {
|
||||
this.isSelectModeRef.value = !this.isSelectModeRef.value
|
||||
|
||||
this.$emit('close')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div
|
||||
class="list-field group"
|
||||
>
|
||||
<div
|
||||
class="border border-dashed border-base-300 hover:border-base-content/20 rounded relative group fields-list-item transition-colors"
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
>
|
||||
<div class="flex items-center justify-between relative group/contenteditable-container">
|
||||
<div
|
||||
v-if="!isNew"
|
||||
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
|
||||
@click="$emit('click', field)"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 bottom-0 left-0 flex items-center transition-all cursor-grab group-hover:bg-base-200/50"
|
||||
@click="$emit('click', field)"
|
||||
>
|
||||
<IconDrag style="margin-left: 1px" />
|
||||
</div>
|
||||
<div class="flex items-center p-1 pl-6 space-x-1">
|
||||
<FieldType
|
||||
v-model="field.type"
|
||||
:editable="false"
|
||||
:button-width="20"
|
||||
@click="$emit('click', field)"
|
||||
/>
|
||||
<Contenteditable
|
||||
ref="name"
|
||||
:model-value="field.name"
|
||||
:placeholder="'Field Name'"
|
||||
:icon-inline="true"
|
||||
:icon-width="18"
|
||||
:min-width="isNew ? '100px' : '2px'"
|
||||
:icon-stroke-width="1.6"
|
||||
:editable-on-button="!isNew"
|
||||
:with-button="!isNew"
|
||||
:class="{ 'cursor-pointer': !isNew }"
|
||||
@click-contenteditable="$emit('click', field)"
|
||||
@focus="onNameFocus"
|
||||
@blur="onNameBlur"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center space-x-1"
|
||||
>
|
||||
<PaymentSettings
|
||||
v-if="field.type === 'payment' && !isNew"
|
||||
:field="field"
|
||||
:with-condition="false"
|
||||
:with-force-open="false"
|
||||
@click-description="isShowDescriptionModal = true"
|
||||
@click-formula="isShowFormulaModal = true"
|
||||
/>
|
||||
<span
|
||||
v-else-if="!isNew"
|
||||
class="dropdown dropdown-end field-settings-dropdown"
|
||||
@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 }"
|
||||
draggable="true"
|
||||
@dragstart.prevent.stop
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<FieldSettings
|
||||
:field="field"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
:background-color="dropdownBgColor"
|
||||
:with-areas="false"
|
||||
:with-copy-to-all-pages="false"
|
||||
:with-condition="false"
|
||||
@click-formula="isShowFormulaModal = true"
|
||||
@click-font="isShowFontModal = true"
|
||||
@click-description="isShowDescriptionModal = true"
|
||||
@save="$emit('save')"
|
||||
/>
|
||||
</ul>
|
||||
</span>
|
||||
<button
|
||||
v-if="isNew && !$refs.name"
|
||||
class="relative text-base-content pr-1 field-save-button"
|
||||
:title="t('save')"
|
||||
@click="field.name ? $emit('save', field) : focusName()"
|
||||
>
|
||||
<IconCheck
|
||||
:width="18"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="relative group-hover:text-base-content pr-1 field-remove-button"
|
||||
:class="isNew ? 'text-base-content' : 'text-transparent group-hover:text-base-content'"
|
||||
:title="t('remove')"
|
||||
@click="onRemoveClick"
|
||||
>
|
||||
<IconTrashX
|
||||
:width="18"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport
|
||||
v-if="isShowFormulaModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<FormulaModal
|
||||
:field="field"
|
||||
:default-field="defaultField"
|
||||
:build-default-name="buildDefaultName"
|
||||
@save="$emit('save')"
|
||||
@close="isShowFormulaModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowFontModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<FontModal
|
||||
:field="field"
|
||||
:default-field="defaultField"
|
||||
:build-default-name="buildDefaultName"
|
||||
@save="$emit('save')"
|
||||
@close="isShowFontModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowDescriptionModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<DescriptionModal
|
||||
:field="field"
|
||||
:default-field="defaultField"
|
||||
:build-default-name="buildDefaultName"
|
||||
@save="$emit('save')"
|
||||
@close="isShowDescriptionModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Contenteditable from './contenteditable'
|
||||
import FieldType from './field_type'
|
||||
import PaymentSettings from './payment_settings'
|
||||
import FieldSettings from './field_settings'
|
||||
import FormulaModal from './formula_modal'
|
||||
import FontModal from './font_modal'
|
||||
import DescriptionModal from './description_modal'
|
||||
import { IconTrashX, IconSettings, IconCheck } from '@tabler/icons-vue'
|
||||
import IconDrag from './icon_drag'
|
||||
|
||||
export default {
|
||||
name: 'CustomField',
|
||||
components: {
|
||||
Contenteditable,
|
||||
IconSettings,
|
||||
IconCheck,
|
||||
FieldSettings,
|
||||
PaymentSettings,
|
||||
IconDrag,
|
||||
FormulaModal,
|
||||
FontModal,
|
||||
DescriptionModal,
|
||||
IconTrashX,
|
||||
FieldType
|
||||
},
|
||||
inject: ['backgroundColor', 't'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isNew: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withPrefillable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['remove', 'save', 'click'],
|
||||
data () {
|
||||
return {
|
||||
isShowFormulaModal: false,
|
||||
isShowFontModal: false,
|
||||
isShowDescriptionModal: false,
|
||||
renderDropdown: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dropdownBgColor () {
|
||||
return ['', null, 'transparent'].includes(this.backgroundColor) ? 'white' : this.backgroundColor
|
||||
},
|
||||
modalContainerEl () {
|
||||
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.field.preferences ||= {}
|
||||
},
|
||||
mounted () {
|
||||
if (this.isNew) {
|
||||
this.focusName()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
buildDefaultName () {
|
||||
return this.t('custom')
|
||||
},
|
||||
focusName () {
|
||||
setTimeout(() => {
|
||||
this.$refs.name.clickEdit()
|
||||
}, 1)
|
||||
},
|
||||
onNameFocus (e) {
|
||||
if (!this.field.name) {
|
||||
setTimeout(() => {
|
||||
this.$refs.name.$refs.contenteditable.innerText = ' '
|
||||
}, 1)
|
||||
}
|
||||
},
|
||||
closeDropdown () {
|
||||
this.$el.getRootNode().activeElement.blur()
|
||||
},
|
||||
onRemoveClick () {
|
||||
if (this.isNew || window.confirm(this.t('are_you_sure_'))) {
|
||||
this.$emit('remove', this.field)
|
||||
}
|
||||
},
|
||||
onNameBlur (e) {
|
||||
const text = this.$refs.name.$refs.contenteditable.innerText.trim()
|
||||
|
||||
if (text) {
|
||||
this.field.name = text
|
||||
} else {
|
||||
this.$refs.name.setText(this.field.name)
|
||||
}
|
||||
|
||||
if (this.field.name) {
|
||||
this.$emit('save')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<span class="modal-title">
|
||||
{{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field, template.fields) }}
|
||||
{{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
|
||||
</span>
|
||||
<a
|
||||
href="#"
|
||||
@@ -67,7 +67,7 @@
|
||||
<script>
|
||||
export default {
|
||||
name: 'DescriptionModal',
|
||||
inject: ['t', 'save', 'template'],
|
||||
inject: ['t', 'template'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -88,7 +88,7 @@ export default {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['close'],
|
||||
emits: ['close', 'save'],
|
||||
data () {
|
||||
return {
|
||||
description: this.field.description,
|
||||
@@ -103,7 +103,7 @@ export default {
|
||||
this.field.description = this.description
|
||||
this.field.title = this.title
|
||||
|
||||
this.save()
|
||||
this.$emit('save')
|
||||
this.$emit('close')
|
||||
},
|
||||
resizeTextarea () {
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
:is-drag="isDrag"
|
||||
:is-mobile="isMobile"
|
||||
:with-field-placeholder="withFieldPlaceholder"
|
||||
:default-fields="defaultFields"
|
||||
:drag-field-placeholder="dragFieldPlaceholder"
|
||||
:default-submitters="defaultSubmitters"
|
||||
:draw-field="drawField"
|
||||
:draw-field-type="drawFieldType"
|
||||
:draw-custom-field="drawCustomField"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
:total-pages="sortedPreviewImages.length"
|
||||
:image="image"
|
||||
@@ -28,9 +30,10 @@
|
||||
@remove-area="$emit('remove-area', $event)"
|
||||
@copy-field="$emit('copy-field', $event)"
|
||||
@paste-field="$emit('paste-field', { ...$event, attachment_uuid: document.uuid })"
|
||||
@add-custom-field="$emit('add-custom-field', $event)"
|
||||
@set-draw="$emit('set-draw', $event)"
|
||||
@copy-selected-areas="$emit('copy-selected-areas')"
|
||||
@delete-selected-areas="$emit('delete-selected-areas')"
|
||||
@align-selected-areas="$emit('align-selected-areas', $event)"
|
||||
@autodetect-fields="$emit('autodetect-fields', $event)"
|
||||
@scroll-to="scrollToArea"
|
||||
@draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })"
|
||||
@@ -96,6 +99,11 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
isMobile: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
allowDraw: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -115,6 +123,11 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
drawCustomField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
baseUrl: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -131,7 +144,7 @@ export default {
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas', 'autodetect-fields'],
|
||||
emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field', 'copy-selected-areas', 'delete-selected-areas', 'autodetect-fields', 'add-custom-field', 'set-draw'],
|
||||
data () {
|
||||
return {
|
||||
pageRefs: []
|
||||
@@ -155,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`
|
||||
})
|
||||
})
|
||||
},
|
||||
@@ -173,7 +186,15 @@ export default {
|
||||
methods: {
|
||||
scrollToArea (area) {
|
||||
this.$nextTick(() => {
|
||||
this.pageRefs[area.page].areaRefs.find((e) => e.area === area).$el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
const pageRef = this.pageRefs[area.page]
|
||||
|
||||
if (pageRef && pageRef.areaRefs) {
|
||||
const areaRef = pageRef.areaRefs.find((e) => e.area === area)
|
||||
|
||||
if (areaRef && areaRef.$el) {
|
||||
areaRef.$el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
setPageRefs (el) {
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
class="fixed z-20 pointer-events-none"
|
||||
:editable="false"
|
||||
/>
|
||||
<CustomField
|
||||
v-else-if="dragPlaceholder && isCustom && !isMask && field"
|
||||
ref="dragPlaceholder"
|
||||
:style="dragPlaceholderStyle"
|
||||
:field="field"
|
||||
class="fixed z-20 pointer-events-none opacity-90"
|
||||
/>
|
||||
<div
|
||||
v-else-if="dragPlaceholder && (isDefault || isRequired) && !isMask && field"
|
||||
ref="dragPlaceholder"
|
||||
@@ -57,6 +64,7 @@
|
||||
|
||||
<script>
|
||||
import Field from './field'
|
||||
import CustomField from './custom_field'
|
||||
import IconDrag from './icon_drag'
|
||||
import FieldType from './field_type'
|
||||
|
||||
@@ -64,6 +72,7 @@ export default {
|
||||
name: 'DragPlaceholder',
|
||||
components: {
|
||||
Field,
|
||||
CustomField,
|
||||
IconDrag
|
||||
},
|
||||
inject: ['t', 'backgroundColor'],
|
||||
@@ -87,6 +96,11 @@ export default {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
isCustom: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
|
||||
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user