mirror of
https://github.com/docusealco/docuseal.git
synced 2026-08-07 15:25:16 +00:00
Compare commits
287 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fe75c84ff | |||
| 4549a04fc5 | |||
| 0ea6b912a3 | |||
| 9b86fa3d40 | |||
| 374f66c59f | |||
| da43d881f1 | |||
| 9cbfa3a0ba | |||
| 77a925f33b | |||
| 3f7d7beedd | |||
| 40d91df732 | |||
| c8e783a210 | |||
| 61fc24d35d | |||
| 73734b0de7 | |||
| 09bd831d54 | |||
| 2c9f1b999e | |||
| 6a3a185310 | |||
| 7d381b7aee | |||
| bba5626dea | |||
| bdf5e58929 | |||
| 035168230e | |||
| 28168f082d | |||
| a29e09ed2f | |||
| e88874e757 | |||
| b6fb7c6977 | |||
| 8a60f17d13 | |||
| e1d860a42c | |||
| 56da326709 | |||
| 672008f8af | |||
| d86b16de6b | |||
| d41710f507 | |||
| 2b53166763 | |||
| 4e56a16a58 | |||
| ac7cc77017 | |||
| 9194879c43 | |||
| 1902cdaa55 | |||
| 6314987e69 | |||
| 65bcbd7737 | |||
| 9b25b8538e | |||
| bbf8bb2a94 | |||
| 2085227cd9 | |||
| 93e0730210 | |||
| 25c5c11ab3 | |||
| 6ed5b5d35d | |||
| 673cc1e0df | |||
| 065dcf6f9f | |||
| a66b16ce1c | |||
| e4c0b30284 | |||
| 3cb37748e9 | |||
| e54d76f0e2 | |||
| f19e037011 | |||
| ee3f47d918 | |||
| a49a645022 | |||
| cf30441ac6 | |||
| c9bcf866ab | |||
| 506609f3cd | |||
| 721172c980 | |||
| b183db5b80 | |||
| c7f277f404 | |||
| 8ac03a9f8a | |||
| dc1c386d7e | |||
| 1eaf4e5bab | |||
| 3c0f1c2e73 | |||
| b2790776ef | |||
| 2840359396 | |||
| c18ddb99a8 | |||
| 432435aa23 | |||
| 90c1a6f4ad | |||
| 0e8f478c2e | |||
| 5f23d5a5ab | |||
| 76821a4c2d | |||
| e4a8cca90a | |||
| 6024ed4cd1 | |||
| 3811f8bbb8 | |||
| 306218e5df | |||
| cabfe45608 | |||
| 4ade6a5911 | |||
| 10d45c3e20 | |||
| 6fb247ec06 | |||
| 1546258529 | |||
| 7ec83b8af6 | |||
| 21d0a87c17 | |||
| 528f59c1bf | |||
| d1535c4cd8 | |||
| da44d51a3b | |||
| 73ce10b26a | |||
| 13874b8830 | |||
| ac66809a05 | |||
| f5229d5dec | |||
| 222291d11d | |||
| 28f47e1093 | |||
| 7784346a97 | |||
| ed46af8418 | |||
| 095f25a6e9 | |||
| 3cdab30dc8 | |||
| 8e462047ba | |||
| b7f8b65d9d | |||
| da21c37254 | |||
| a89aef4a89 | |||
| dd6516fd11 | |||
| 344a3ce198 | |||
| 62f8283587 | |||
| 1f89accac3 | |||
| 9fcaef4cf7 | |||
| d5738a0631 | |||
| 8bf7a1f95a | |||
| 04cf36891e | |||
| b2d9948c30 | |||
| a2c9ac1707 | |||
| 46a6bd0108 | |||
| 5f069e7a40 | |||
| ff57e5c6ae | |||
| f65b6e2d76 | |||
| 58fd180ae0 | |||
| cd6503c4c3 | |||
| e8b36c2b6d | |||
| 51743f1359 | |||
| 504c42646b | |||
| c61e84d1b4 | |||
| b8ab01c46c | |||
| 9558060bde | |||
| 0741c879f1 | |||
| 2e6dd2867e | |||
| 9f3fcccb61 | |||
| ae08d7abad | |||
| 16808a7ec6 | |||
| b1dfcd6283 | |||
| 369f4c0f08 | |||
| 827de03579 | |||
| 0c7f4e5b43 | |||
| 9c700a3fb6 | |||
| 89bf83febb | |||
| d44b35cf0a | |||
| 891833e273 | |||
| 29d3a80d3f | |||
| 90fef58684 | |||
| 07841722ac | |||
| 9e4da5948b | |||
| dc6e4313a1 | |||
| a68cc0b689 | |||
| d057fb0f67 | |||
| 7e8f045a29 | |||
| 5249d8d18d | |||
| f0fafbadd4 | |||
| 8b51079dcc | |||
| 354bccd6e8 | |||
| 6806772346 | |||
| 60082655d4 | |||
| 7fe56941fd | |||
| abd498dd33 | |||
| 99ca0136ed | |||
| e52830c9b4 | |||
| 755decca27 | |||
| 41604008d1 | |||
| 5bddce8969 | |||
| b25503f141 | |||
| bdf1850448 | |||
| 230d3ccf69 | |||
| e378025a2d | |||
| 04129ded90 | |||
| a7891f89f8 | |||
| 37d4a8e834 | |||
| 528a1216f8 | |||
| f2479bd259 | |||
| 135f0826d2 | |||
| 5710f0177b | |||
| 1a3a0528ba | |||
| 59793ff374 | |||
| 3c4ed42419 | |||
| 6a17b61550 | |||
| 76659497b7 | |||
| ee50c957cb | |||
| a9dd200919 | |||
| 1fa953104a | |||
| 50eb5b070e | |||
| 10fd624bec | |||
| 04ec2f8260 | |||
| d828d79574 | |||
| 01dd3fefe5 | |||
| 45ae954c0c | |||
| 1304849b55 | |||
| 0875faa079 | |||
| 3aa15d6ea6 | |||
| 6557329e97 | |||
| be27ce4161 | |||
| a3391a970e | |||
| 744d45d2c5 | |||
| 310b16f54a | |||
| 6d3ad4a869 | |||
| 1ce2448820 | |||
| 07b4ada5be | |||
| f70bf530ba | |||
| a26aa0f411 | |||
| 584577c21f | |||
| 1503375eed | |||
| 2dec099f20 | |||
| bf6eed4ea2 | |||
| e0dc33cfb4 | |||
| 9c9dc27537 | |||
| 7305637b8c | |||
| a6e22adf2e | |||
| 809c6b1aa8 | |||
| 1536ded2a6 | |||
| 76d54971ce | |||
| 6dec56198c | |||
| d96d252df3 | |||
| 3fca76a716 | |||
| a184fa11f1 | |||
| e9c0b6a703 | |||
| ec7df08496 | |||
| 6627d7eac5 | |||
| 1f8945d710 | |||
| daaa289a5c | |||
| 81a6a48d70 | |||
| b93c7cd261 | |||
| 570482e12d | |||
| 39407557f2 | |||
| 3b040d558d | |||
| 56ba3c2a52 | |||
| c7afe33c72 | |||
| 82500c2d7d | |||
| fb1c1f117b | |||
| 7c6a7513fd | |||
| 6d13119ee0 | |||
| 1e2c752937 | |||
| e82faf8320 | |||
| 4adb1001b0 | |||
| 992a1b26c0 | |||
| c07bc6687c | |||
| eea44bda34 | |||
| 7cdf263da1 | |||
| ee65a5693c | |||
| 0af6ccf35f | |||
| 888f1ec6df | |||
| c95a8616ac | |||
| 5ea6289b7a | |||
| d4a79ca5db | |||
| 70015ce1c4 | |||
| 6b85c28944 | |||
| 6c289cf273 | |||
| a64bc3c618 | |||
| 46cf1e3067 | |||
| 565e1eb2bc | |||
| e689687805 | |||
| 3c3b61fb47 | |||
| 1355d350c5 | |||
| fda911e178 | |||
| f995e1864c | |||
| 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 |
@@ -12,7 +12,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 4.0.1
|
||||
ruby-version: 4.0.5
|
||||
- name: Cache gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 4.0.1
|
||||
ruby-version: 4.0.5
|
||||
- name: Cache gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 4.0.1
|
||||
ruby-version: 4.0.5
|
||||
- name: Cache gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
bundle install --jobs 4 --retry 4
|
||||
yarn install
|
||||
sudo apt-get update
|
||||
sudo apt-get install libvips
|
||||
sudo apt-get install libvips liblept5
|
||||
- name: Run Brakeman
|
||||
run: bundle exec brakeman -q --exit-on-warn
|
||||
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 4.0.1
|
||||
ruby-version: 4.0.5
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
@@ -162,8 +162,8 @@ jobs:
|
||||
bundle install --jobs 4 --retry 4
|
||||
yarn install
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libvips
|
||||
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"
|
||||
sudo apt-get install -y libvips liblept5
|
||||
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
|
||||
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
|
||||
rm -f pdfium-linux.tgz
|
||||
- name: Run
|
||||
|
||||
+10
-1
@@ -28,7 +28,7 @@ Lint/MissingSuper:
|
||||
Enabled: false
|
||||
|
||||
Metrics/ParameterLists:
|
||||
Max: 10
|
||||
Max: 12
|
||||
|
||||
Metrics/MethodLength:
|
||||
Max: 30
|
||||
@@ -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
|
||||
|
||||
|
||||
+21
-19
@@ -1,22 +1,23 @@
|
||||
FROM ruby:4.0.1-alpine AS download
|
||||
FROM ruby:4.0.5-alpine AS download
|
||||
|
||||
WORKDIR /fonts
|
||||
|
||||
RUN apk --no-cache add fontforge wget && \
|
||||
RUN apk --no-cache add wget unzip && \
|
||||
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \
|
||||
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \
|
||||
wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \
|
||||
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \
|
||||
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
|
||||
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
|
||||
wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \
|
||||
wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \
|
||||
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
|
||||
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
|
||||
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260613/pdfium-musl-$(uname -m).zip" && \
|
||||
case "$(uname -m)" in \
|
||||
x86_64) echo "2c953ff72ee2dda07e7fc577e25841cc3d6464468a7c5adfaea574efcbc3b90b pdfium-linux.zip" ;; \
|
||||
aarch64) echo "23bbe287d2753fdb05741c7660647eb0ef0d2e4da2ce0722bfa9d9d455bd64e2 pdfium-linux.zip" ;; \
|
||||
esac | sha256sum -c - && \
|
||||
mkdir -p /pdfium-linux && \
|
||||
tar -xzf pdfium-linux.tgz -C /pdfium-linux
|
||||
unzip -q pdfium-linux.zip -d /pdfium-linux
|
||||
|
||||
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
|
||||
|
||||
FROM ruby:4.0.1-alpine AS webpack
|
||||
FROM ruby:4.0.5-alpine AS webpack
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV NODE_ENV=production
|
||||
@@ -43,17 +44,16 @@ COPY ./app/views ./app/views
|
||||
|
||||
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
|
||||
|
||||
FROM ruby:4.0.1-alpine AS app
|
||||
FROM ruby:4.0.5-alpine AS app
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV BUNDLE_WITHOUT="development:test"
|
||||
ENV LD_PRELOAD=/lib/libgcompat.so.0
|
||||
ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
|
||||
ENV VIPS_MAX_COORD=15000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache sqlite-dev libpq-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont onnxruntime && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
|
||||
RUN apk add --no-cache libpq vips redis onnxruntime leptonica && \
|
||||
rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test
|
||||
|
||||
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
|
||||
|
||||
@@ -71,7 +71,7 @@ activate = 1' >> /etc/openssl_legacy.cnf
|
||||
|
||||
COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./
|
||||
|
||||
RUN apk add --no-cache build-base git && bundle install && apk del --no-cache build-base git && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
|
||||
RUN apk add --no-cache build-base git libpq-dev yaml-dev && bundle install && apk del --no-cache build-base git libpq-dev yaml-dev && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales,resources/unicode_data/properties}'] + Dir['/usr/local/bundle/gems/*/{test,tests,examples,sample,misc,doc,docs}'] + Dir['/usr/local/bundle/gems/*/ext/**/*.{c,h,o,S}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
|
||||
|
||||
COPY --chown=docuseal:docuseal ./bin ./bin
|
||||
COPY --chown=docuseal:docuseal ./app ./app
|
||||
@@ -84,20 +84,22 @@ COPY --chown=docuseal:docuseal ./tmp ./tmp
|
||||
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 --from=download /pdfium-linux/licenses/ /usr/lib/libpdfium-licenses/
|
||||
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
|
||||
ENV VIPS_BLOCK_UNTRUSTED=1
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby '4.0.1'
|
||||
ruby '4.0.5'
|
||||
|
||||
gem 'addressable'
|
||||
gem 'arabic-letter-connector', require: false
|
||||
@@ -21,11 +21,9 @@ gem 'faraday'
|
||||
gem 'faraday-follow_redirects'
|
||||
gem 'google-cloud-storage', require: false
|
||||
gem 'hexapdf'
|
||||
gem 'image_processing'
|
||||
gem 'jwt', require: false
|
||||
gem 'lograge'
|
||||
gem 'numo-narray-alt', require: false
|
||||
gem 'oj'
|
||||
gem 'onnxruntime', require: false
|
||||
gem 'pagy'
|
||||
gem 'pg', require: false
|
||||
|
||||
+137
-148
@@ -1,31 +1,31 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
action_text-trix (2.1.16)
|
||||
action_text-trix (2.1.18)
|
||||
railties
|
||||
actioncable (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actioncable (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionmailbox (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionmailer (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionpack (8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
@@ -33,36 +33,36 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.1.2)
|
||||
actiontext (8.1.3)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionpack (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionview (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activejob (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activerecord (8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activemodel (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activerecord (8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activestorage (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.2)
|
||||
activesupport (8.1.3)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
@@ -75,16 +75,16 @@ GEM
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.8.8)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
annotaterb (4.20.0)
|
||||
annotaterb (4.22.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
arabic-letter-connector (0.1.1)
|
||||
ast (2.4.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1209.0)
|
||||
aws-sdk-core (3.241.4)
|
||||
aws-partitions (1.1233.0)
|
||||
aws-sdk-core (3.244.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -92,15 +92,15 @@ GEM
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.121.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-kms (1.123.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.212.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-s3 (1.218.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-secretsmanager (1.128.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-secretsmanager (1.129.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
@@ -116,11 +116,11 @@ GEM
|
||||
erubi (~> 1.4)
|
||||
parser (>= 2.4)
|
||||
smart_properties
|
||||
bigdecimal (4.0.1)
|
||||
bigdecimal (4.1.2)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.21.1)
|
||||
bootsnap (1.23.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.1.2)
|
||||
brakeman (8.0.4)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bullet (8.1.0)
|
||||
@@ -144,7 +144,7 @@ GEM
|
||||
cldr-plurals-runtime-rb (1.1.0)
|
||||
cmdparse (3.0.7)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.3.6)
|
||||
concurrent-ruby (1.3.7)
|
||||
connection_pool (3.0.2)
|
||||
crack (1.0.1)
|
||||
bigdecimal
|
||||
@@ -161,7 +161,7 @@ GEM
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
declarative (0.0.20)
|
||||
devise (5.0.3)
|
||||
devise (5.0.4)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 7.0)
|
||||
@@ -179,7 +179,7 @@ GEM
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
email_typo (0.2.3)
|
||||
erb (6.0.2)
|
||||
erb (6.0.4)
|
||||
erb_lint (0.9.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
@@ -193,27 +193,27 @@ GEM
|
||||
factory_bot_rails (6.5.1)
|
||||
factory_bot (~> 6.5)
|
||||
railties (>= 6.1.0)
|
||||
faker (3.6.0)
|
||||
faker (3.6.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.1)
|
||||
faraday (2.14.3)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.5.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-net_http (3.4.2)
|
||||
faraday-net_http (3.4.4)
|
||||
net-http (~> 0.5)
|
||||
ferrum (0.17.1)
|
||||
ferrum (0.17.2)
|
||||
addressable (~> 2.5)
|
||||
base64 (~> 0.2)
|
||||
concurrent-ruby (~> 1.1)
|
||||
webrick (~> 1.7)
|
||||
websocket-driver (~> 0.7)
|
||||
ffi (1.17.3-aarch64-linux-gnu)
|
||||
ffi (1.17.3-aarch64-linux-musl)
|
||||
ffi (1.17.3-arm64-darwin)
|
||||
ffi (1.17.3-x86_64-linux-gnu)
|
||||
ffi (1.17.3-x86_64-linux-musl)
|
||||
ffi (1.17.4-aarch64-linux-gnu)
|
||||
ffi (1.17.4-aarch64-linux-musl)
|
||||
ffi (1.17.4-arm64-darwin)
|
||||
ffi (1.17.4-x86_64-linux-gnu)
|
||||
ffi (1.17.4-x86_64-linux-musl)
|
||||
foreman (0.90.0)
|
||||
thor (~> 1.4)
|
||||
geom2d (0.4.1)
|
||||
@@ -229,7 +229,7 @@ GEM
|
||||
retriable (~> 3.1)
|
||||
google-apis-iamcredentials_v1 (0.26.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.59.0)
|
||||
google-apis-storage_v1 (0.61.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
@@ -237,8 +237,8 @@ GEM
|
||||
google-cloud-env (2.3.1)
|
||||
base64 (~> 0.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.5.0)
|
||||
google-cloud-storage (1.58.0)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.59.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
@@ -248,7 +248,7 @@ GEM
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
google-logging-utils (0.2.0)
|
||||
googleauth (1.16.1)
|
||||
googleauth (1.16.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
@@ -257,25 +257,22 @@ GEM
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
hashdiff (1.2.1)
|
||||
hexapdf (1.5.0)
|
||||
hexapdf (1.7.0)
|
||||
cmdparse (~> 3.0, >= 3.0.3)
|
||||
geom2d (~> 0.4, >= 0.4.1)
|
||||
openssl (>= 2.2.1)
|
||||
strscan (>= 3.1.2)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.14.0)
|
||||
mini_magick (>= 4.9.5, < 6)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
io-console (0.8.2)
|
||||
irb (1.17.0)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.19.2)
|
||||
jwt (3.1.2)
|
||||
json (2.19.9)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (3.1.1)
|
||||
@@ -308,17 +305,15 @@ GEM
|
||||
marcel (1.1.0)
|
||||
matrix (0.4.3)
|
||||
method_source (1.1.0)
|
||||
mini_magick (5.3.1)
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (6.0.2)
|
||||
minitest (6.0.6)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.19.1)
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-imap (0.6.2)
|
||||
net-imap (0.6.4.1)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -328,36 +323,33 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.2-aarch64-linux-gnu)
|
||||
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.2-aarch64-linux-musl)
|
||||
nokogiri (1.19.4-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.2-arm64-darwin)
|
||||
nokogiri (1.19.4-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.2-x86_64-linux-gnu)
|
||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.2-x86_64-linux-musl)
|
||||
nokogiri (1.19.4-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
numo-narray-alt (0.10.3)
|
||||
oj (3.16.13)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
onnxruntime (0.10.1-aarch64-linux)
|
||||
ffi
|
||||
onnxruntime (0.10.1-arm64-darwin)
|
||||
ffi
|
||||
onnxruntime (0.10.1-x86_64-linux)
|
||||
ffi
|
||||
openssl (4.0.0)
|
||||
openssl (4.0.1)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
package_json (0.2.0)
|
||||
pagy (43.2.8)
|
||||
pagy (43.4.4)
|
||||
json
|
||||
uri
|
||||
yaml
|
||||
parallel (1.27.0)
|
||||
parser (3.3.10.1)
|
||||
parallel (1.28.0)
|
||||
parser (3.3.11.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.6.3-aarch64-linux)
|
||||
@@ -367,8 +359,8 @@ GEM
|
||||
pg (1.6.3-x86_64-linux-musl)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
pretender (0.6.0)
|
||||
actionpack (>= 7.1)
|
||||
pretender (1.0.0)
|
||||
actionpack (>= 7.2)
|
||||
prettyprint (0.2.0)
|
||||
prism (1.9.0)
|
||||
pry (0.16.0)
|
||||
@@ -380,34 +372,34 @@ GEM
|
||||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
public_suffix (7.0.2)
|
||||
puma (7.2.0)
|
||||
public_suffix (7.0.5)
|
||||
puma (8.0.2)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.5)
|
||||
rack (3.2.6)
|
||||
rack-proxy (0.7.7)
|
||||
rack
|
||||
rack-session (2.1.1)
|
||||
rack-session (2.1.2)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (8.1.2)
|
||||
actioncable (= 8.1.2)
|
||||
actionmailbox (= 8.1.2)
|
||||
actionmailer (= 8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actiontext (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
rails (8.1.3)
|
||||
actioncable (= 8.1.3)
|
||||
actionmailbox (= 8.1.3)
|
||||
actionmailer (= 8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actiontext (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.2)
|
||||
railties (= 8.1.3)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -418,9 +410,9 @@ GEM
|
||||
rails-i18n (8.1.0)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 8.0.0, < 9)
|
||||
railties (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
railties (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -428,12 +420,12 @@ GEM
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
rake (13.4.2)
|
||||
rdoc (7.2.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
redis-client (0.26.4)
|
||||
redis-client (0.29.0)
|
||||
connection_pool
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
@@ -447,7 +439,7 @@ GEM
|
||||
responders (3.2.0)
|
||||
actionpack (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
retriable (3.1.2)
|
||||
retriable (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rotp (6.3.0)
|
||||
rouge (4.7.0)
|
||||
@@ -460,19 +452,19 @@ GEM
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.7)
|
||||
rspec-mocks (3.13.8)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (8.0.2)
|
||||
rspec-rails (8.0.4)
|
||||
actionpack (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.6)
|
||||
rubocop (1.82.1)
|
||||
rspec-core (>= 3.13.0, < 5.0.0)
|
||||
rspec-expectations (>= 3.13.0, < 5.0.0)
|
||||
rspec-mocks (>= 3.13.0, < 5.0.0)
|
||||
rspec-support (>= 3.13.0, < 5.0.0)
|
||||
rspec-support (3.13.7)
|
||||
rubocop (1.86.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -480,10 +472,10 @@ GEM
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.48.0, < 2.0)
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.0)
|
||||
rubocop-ast (1.49.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.7)
|
||||
rubocop-performance (1.26.1)
|
||||
@@ -508,19 +500,19 @@ GEM
|
||||
rubyzip (>= 3.2.2)
|
||||
rubyzip (3.2.2)
|
||||
securerandom (0.4.1)
|
||||
semantic_range (3.1.0)
|
||||
shakapacker (9.5.0)
|
||||
semantic_range (3.1.1)
|
||||
shakapacker (9.7.0)
|
||||
activesupport (>= 5.2)
|
||||
package_json
|
||||
rack-proxy (>= 0.6.1)
|
||||
railties (>= 5.2)
|
||||
semantic_range (>= 2.3.0)
|
||||
sidekiq (8.1.0)
|
||||
sidekiq (8.1.6)
|
||||
connection_pool (>= 3.0.0)
|
||||
json (>= 2.16.0)
|
||||
logger (>= 1.7.0)
|
||||
rack (>= 3.2.0)
|
||||
redis-client (>= 0.26.0)
|
||||
redis-client (>= 0.29.0)
|
||||
signet (0.21.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
@@ -533,22 +525,22 @@ GEM
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sqlite3 (2.9.0-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.0-aarch64-linux-musl)
|
||||
sqlite3 (2.9.0-arm64-darwin)
|
||||
sqlite3 (2.9.0-x86_64-linux-gnu)
|
||||
sqlite3 (2.9.0-x86_64-linux-musl)
|
||||
sqlite3 (2.9.2-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.2-aarch64-linux-musl)
|
||||
sqlite3 (2.9.2-arm64-darwin)
|
||||
sqlite3 (2.9.2-x86_64-linux-gnu)
|
||||
sqlite3 (2.9.2-x86_64-linux-musl)
|
||||
stringio (3.2.0)
|
||||
strip_attributes (2.0.1)
|
||||
activemodel (>= 3.0, < 9.0)
|
||||
strscan (3.1.7)
|
||||
strscan (3.1.8)
|
||||
thor (1.5.0)
|
||||
timeout (0.6.0)
|
||||
timeout (0.6.1)
|
||||
trailblazer-option (0.1.2)
|
||||
trilogy (2.10.0)
|
||||
trilogy (2.12.2)
|
||||
bigdecimal
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.21)
|
||||
turbo-rails (2.0.23)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
twitter_cldr (6.14.0)
|
||||
@@ -558,7 +550,7 @@ GEM
|
||||
tzinfo
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
tzinfo-data (1.2025.3)
|
||||
tzinfo-data (1.2026.1)
|
||||
tzinfo (>= 1.0.0)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (3.2.0)
|
||||
@@ -569,12 +561,11 @@ GEM
|
||||
useragent (0.16.11)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
web-console (4.3.0)
|
||||
actionview (>= 8.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webmock (3.26.1)
|
||||
railties (>= 8.0.0)
|
||||
webmock (3.26.2)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
@@ -624,12 +615,10 @@ DEPENDENCIES
|
||||
foreman
|
||||
google-cloud-storage
|
||||
hexapdf
|
||||
image_processing
|
||||
jwt
|
||||
letter_opener_web
|
||||
lograge
|
||||
numo-narray-alt
|
||||
oj
|
||||
onnxruntime
|
||||
pagy
|
||||
pg
|
||||
@@ -662,7 +651,7 @@ DEPENDENCIES
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 4.0.1
|
||||
ruby 4.0.5
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.3
|
||||
|
||||
@@ -50,7 +50,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
- Company logo and white-label
|
||||
- User roles
|
||||
- Automated reminders
|
||||
- Invitation and identify verification via SMS
|
||||
- Invitation and identity verification via SMS
|
||||
- Conditional fields and formulas
|
||||
- Bulk send with CSV, XLSX spreadsheet import
|
||||
- SSO / SAML
|
||||
@@ -74,7 +74,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
docker run --name docuseal -p 3000:3000 -v.:/data docuseal/docuseal
|
||||
```
|
||||
|
||||
By default DocuSeal docker container uses an SQLite database to store data and configurations. Alternatively, it is possible use PostgreSQL or MySQL databases by specifying the `DATABASE_URL` env variable.
|
||||
By default DocuSeal docker container uses an SQLite database to store data and configurations. Alternatively, it is possible to use PostgreSQL or MySQL databases by specifying the `DATABASE_URL` env variable.
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -36,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!
|
||||
|
||||
@@ -45,8 +46,12 @@ class AccountConfigsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def allowed_keys
|
||||
ALLOWED_KEYS
|
||||
end
|
||||
|
||||
def load_account_config
|
||||
raise InvalidKey unless ALLOWED_KEYS.include?(account_config_params[:key])
|
||||
raise InvalidKey unless allowed_keys.include?(account_config_params[:key])
|
||||
|
||||
@account_config =
|
||||
AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key])
|
||||
|
||||
@@ -53,7 +53,9 @@ class AccountsController < ApplicationController
|
||||
# rubocop:disable Layout/LineLength
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
:account_delete_button,
|
||||
html: helpers.tag.p(I18n.t('your_account_removal_request_will_be_processed_within_2_months_please_contact_us_if_you_want_to_keep_your_account'))
|
||||
html: helpers.tag.p(I18n.t('your_account_will_be_permanently_deleted_within_2_months_please_contact_us_if_you_want_to_keep_your_account')) +
|
||||
helpers.tag.br +
|
||||
helpers.tag.p(I18n.t('your_email_address_has_been_released_immediately_you_can_now_be_added_to_your_company_team_without_waiting_for_the_deletion_period_to_end'))
|
||||
)
|
||||
# rubocop:enable Layout/LineLength
|
||||
end
|
||||
|
||||
@@ -9,7 +9,9 @@ module Api
|
||||
|
||||
before_action :set_cors_headers
|
||||
before_action :set_noindex_headers
|
||||
before_action :set_security_headers
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def show
|
||||
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
|
||||
|
||||
@@ -21,6 +23,12 @@ module Api
|
||||
|
||||
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
|
||||
|
||||
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
|
||||
Rollbar.error('Dangerous extension') if defined?(Rollbar)
|
||||
|
||||
return head :unprocessable_content
|
||||
end
|
||||
|
||||
attachment = blob.attachments.take
|
||||
|
||||
@record = attachment.record
|
||||
@@ -33,12 +41,19 @@ module Api
|
||||
else
|
||||
http_cache_forever public: true do
|
||||
response.headers['Accept-Ranges'] = 'bytes'
|
||||
response.headers['Content-Length'] = blob.byte_size.to_s
|
||||
|
||||
send_blob_stream blob, disposition: params[:disposition]
|
||||
if request.head?
|
||||
response.headers['Content-Type'] = blob.content_type_for_serving
|
||||
head :ok
|
||||
else
|
||||
send_blob_stream blob, disposition: params[:disposition]
|
||||
end
|
||||
|
||||
response.headers['Content-Length'] = blob.byte_size.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
|
||||
private
|
||||
|
||||
@@ -57,8 +72,6 @@ module Api
|
||||
return if !require_ttl && !require_auth
|
||||
end
|
||||
|
||||
Rollbar.error('Blob unauthorized') if defined?(Rollbar)
|
||||
|
||||
raise CanCan::AccessDenied
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,6 +9,7 @@ module Api
|
||||
|
||||
before_action :set_cors_headers
|
||||
before_action :set_noindex_headers
|
||||
before_action :set_security_headers
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def show
|
||||
@@ -18,6 +19,12 @@ module Api
|
||||
|
||||
return head :not_found unless blob
|
||||
|
||||
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
|
||||
Rollbar.error('Dangerous extension') if defined?(Rollbar)
|
||||
|
||||
return head :unprocessable_content
|
||||
end
|
||||
|
||||
is_permitted = blob.attachments.any? do |a|
|
||||
(current_user && a.record.account.id == current_user.account_id) ||
|
||||
a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } ||
|
||||
|
||||
@@ -102,6 +102,10 @@ module Api
|
||||
headers['X-Robots-Tag'] = 'noindex'
|
||||
end
|
||||
|
||||
def set_security_headers
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
end
|
||||
|
||||
def set_cors_headers
|
||||
headers['Access-Control-Allow-Origin'] = '*'
|
||||
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
|
||||
|
||||
@@ -8,29 +8,37 @@ module Api
|
||||
COOKIE_STORE_LIMIT = 10
|
||||
|
||||
def create
|
||||
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
unless can_upload?(@submitter)
|
||||
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
file = params[:file]
|
||||
|
||||
if params[:type].in?(%w[initials signature])
|
||||
image = Vips::Image.new_from_file(params[:file].path)
|
||||
image = ImageUtils.load_vips(file.read, content_type: file.content_type)
|
||||
|
||||
if ImageUtils.blank?(image)
|
||||
Rollbar.error("Empty signature: #{submitter.id}") if defined?(Rollbar)
|
||||
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
if ImageUtils.error?(image)
|
||||
Rollbar.error("Error signature: #{submitter.id}") if defined?(Rollbar)
|
||||
Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: "#{params[:type]} error, try to sign on another device" },
|
||||
status: :unprocessable_content
|
||||
end
|
||||
|
||||
metadata = { analyzed: true, identified: true, width: image.width, height: image.height }
|
||||
end
|
||||
|
||||
attachment = Submitters.create_attachment!(submitter, params)
|
||||
attachment = Submitters.create_attachment!(@submitter, file, metadata:)
|
||||
|
||||
if params[:remember_signature] == 'true' && submitter.email.present?
|
||||
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(submitter, attachment)
|
||||
if params[:remember_signature] == 'true' && @submitter.email.present?
|
||||
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
|
||||
end
|
||||
|
||||
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
|
||||
@@ -40,6 +48,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
|
||||
|
||||
@@ -9,7 +9,7 @@ module Api
|
||||
(@submission.schema_documents || @submission.template.schema_documents).size > 1
|
||||
|
||||
documents =
|
||||
if @submission.submitters.all?(&:completed_at?)
|
||||
if @submission.completed_at?
|
||||
build_completed_documents(@submission, merge: is_merge)
|
||||
else
|
||||
build_preview_documents(@submission, merge: is_merge)
|
||||
@@ -30,7 +30,7 @@ module Api
|
||||
private
|
||||
|
||||
def build_completed_documents(submission, merge: false)
|
||||
last_submitter = submission.submitters.max_by(&:completed_at)
|
||||
last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at)
|
||||
|
||||
if merge
|
||||
if submission.merged_document_attachment.blank?
|
||||
|
||||
@@ -5,7 +5,7 @@ module Api
|
||||
load_and_authorize_resource :submission, parent: false
|
||||
|
||||
def index
|
||||
submissions = build_completed_query(@submissions)
|
||||
submissions = @submissions.active.where.not(completed_at: nil)
|
||||
|
||||
params[:after] = Time.zone.at(params[:after].to_i) if params[:after].present?
|
||||
params[:before] = Time.zone.at(params[:before].to_i) if params[:before].present?
|
||||
@@ -36,20 +36,5 @@ module Api
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_completed_query(submissions)
|
||||
submissions = submissions.where(
|
||||
Submitter.where(completed_at: nil).where(
|
||||
Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
|
||||
).select(1).arel.exists.not
|
||||
)
|
||||
|
||||
submissions.joins(:submitters)
|
||||
.group(:id)
|
||||
.select(Submission.arel_table[Arel.star],
|
||||
Submitter.arel_table[:completed_at].maximum.as('completed_at'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,21 +2,39 @@
|
||||
|
||||
module Api
|
||||
class SubmissionsController < ApiBaseController
|
||||
SUBMISSION_COLUMNS = %i[id name slug source submitters_order expire_at completed_at created_at updated_at
|
||||
archived_at variables template_id template_submitters created_by_user_id].freeze
|
||||
TEMPLATE_COLUMNS = %i[id name external_id created_at updated_at folder_id submitters].freeze
|
||||
|
||||
load_and_authorize_resource :template, only: :create
|
||||
load_and_authorize_resource :submission, only: %i[show index destroy]
|
||||
load_and_authorize_resource :submission, only: %i[show index update destroy]
|
||||
|
||||
before_action only: :create do
|
||||
authorize!(:create, Submission)
|
||||
end
|
||||
|
||||
before_action :maybe_return_template_error, only: :create
|
||||
|
||||
def index
|
||||
submissions = Submissions.search(current_user, @submissions, params[:q])
|
||||
submissions = filter_submissions(submissions, params)
|
||||
|
||||
submissions = paginate(submissions.preload(:created_by_user, :submitters,
|
||||
template: { folder: :parent_folder },
|
||||
combined_document_attachment: :blob,
|
||||
audit_trail_attachment: :blob))
|
||||
with_fields = params[:include].to_s.include?('fields') || params[:include].to_s.include?('combined_document_url')
|
||||
|
||||
submissions = paginate(
|
||||
submissions.select(with_fields ? nil : SUBMISSION_COLUMNS)
|
||||
.preload(:created_by_user, :submitters, combined_document_attachment: :blob,
|
||||
audit_trail_attachment: :blob)
|
||||
)
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: submissions,
|
||||
associations: :template,
|
||||
scope: with_fields ? nil : Template.select(TEMPLATE_COLUMNS)
|
||||
).call
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(records: submissions.filter_map(&:template),
|
||||
associations: { folder: :parent_folder }).call
|
||||
|
||||
expires_at = Accounts.link_expires_at(current_account)
|
||||
|
||||
@@ -42,7 +60,7 @@ module Api
|
||||
end
|
||||
end
|
||||
|
||||
if @submission.audit_trail_attachment.blank? && submitters.all?(&:completed_at?)
|
||||
if @submission.audit_trail_attachment.blank? && @submission.completed_at?
|
||||
@submission.audit_trail_attachment = Submissions::EnsureAuditGenerated.call(@submission)
|
||||
end
|
||||
|
||||
@@ -52,14 +70,6 @@ module Api
|
||||
def create
|
||||
Params::SubmissionCreateValidator.call(params)
|
||||
|
||||
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
|
||||
|
||||
if @template.fields.blank?
|
||||
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: 'Template does not contain fields' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
params[:send_email] = true unless params.key?(:send_email)
|
||||
params[:send_sms] = false unless params.key?(:send_sms)
|
||||
|
||||
@@ -70,10 +80,17 @@ module Api
|
||||
Submissions.send_signature_requests(submissions)
|
||||
|
||||
submissions.each do |submission|
|
||||
if submission.submitters.all? { |s| s.viewer? || s.completed_at? } &&
|
||||
Submissions.maybe_update_completed_at(submission)
|
||||
last_submitter = submission.submitters.reject(&:viewer?).max_by(&:completed_at)
|
||||
end
|
||||
|
||||
submission.submitters.each do |submitter|
|
||||
next unless submitter.completed_at?
|
||||
|
||||
ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id, 'send_invitation_email' => false)
|
||||
ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id,
|
||||
'is_last' => submitter == last_submitter,
|
||||
'send_invitation_email' => false)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -87,6 +104,25 @@ module Api
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def update
|
||||
@submission = assign_submission_attrs(@submission, submission_params)
|
||||
|
||||
@submission.save!
|
||||
|
||||
if @submission.saved_change_to_archived_at? && @submission.archived_at?
|
||||
WebhookUrls.enqueue_events(@submission, 'submission.archived')
|
||||
end
|
||||
|
||||
if @submission.saved_change_to_expire_at? && @submission.expire_at?
|
||||
ProcessSubmissionExpiredJob.perform_at(@submission.expire_at, 'submission_id' => @submission.id,
|
||||
'expire_at' => @submission.expire_at.to_i)
|
||||
end
|
||||
|
||||
SearchEntries.enqueue_reindex(@submission) if @submission.saved_change_to_name?
|
||||
|
||||
render json: Submissions::SerializeForApi.call(@submission, nil, params, with_events: false)
|
||||
end
|
||||
|
||||
def destroy
|
||||
if params[:permanently].in?(['true', true])
|
||||
@submission.destroy!
|
||||
@@ -101,6 +137,41 @@ module Api
|
||||
|
||||
private
|
||||
|
||||
def assign_submission_attrs(submission, attrs)
|
||||
archived = attrs.key?(:archived) ? attrs[:archived] : attrs[:archived_at]
|
||||
|
||||
if archived.in?([true, false, 'true', 'false']) && current_ability.can?(:destroy, submission)
|
||||
submission.archived_at = archived.in?(Submitters::TRUE_VALUES) ? Time.current : nil
|
||||
end
|
||||
|
||||
submission.name = attrs[:name] if attrs.key?(:name)
|
||||
submission.expire_at = attrs[:expire_at].presence if attrs.key?(:expire_at)
|
||||
|
||||
submission
|
||||
end
|
||||
|
||||
def submission_params
|
||||
submission_params = params.key?(:submission) ? params.require(:submission) : params
|
||||
|
||||
submission_params.permit(:name, :expire_at, :archived, :archived_at)
|
||||
end
|
||||
|
||||
def maybe_return_template_error
|
||||
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
|
||||
|
||||
if @template.archived_at?
|
||||
Rollbar.warning("Archived template submission: #{@template.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: 'Template has been archived' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
return if @template.fields.present?
|
||||
|
||||
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
|
||||
|
||||
render json: { error: 'Template does not contain fields' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def filter_submissions(submissions, params)
|
||||
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
|
||||
submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
|
||||
@@ -156,7 +227,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:,
|
||||
|
||||
@@ -4,6 +4,8 @@ module Api
|
||||
class SubmittersController < ApiBaseController
|
||||
load_and_authorize_resource :submitter
|
||||
|
||||
before_action :maybe_return_submitter_error, only: :update
|
||||
|
||||
def index
|
||||
submitters = Submitters.search(current_user, @submitters, params[:q])
|
||||
|
||||
@@ -36,21 +38,14 @@ module Api
|
||||
|
||||
# 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],
|
||||
@@ -72,7 +67,9 @@ module Api
|
||||
end
|
||||
|
||||
if @submitter.completed_at?
|
||||
ProcessSubmitterCompletionJob.perform_async('submitter_id' => @submitter.id)
|
||||
is_last = Submissions.maybe_update_completed_at(@submitter.submission)
|
||||
|
||||
ProcessSubmitterCompletionJob.perform_async('submitter_id' => @submitter.id, 'is_last' => is_last)
|
||||
elsif normalized_params[:send_email] || normalized_params[:send_sms]
|
||||
Submitters.send_signature_requests([@submitter])
|
||||
end
|
||||
@@ -103,7 +100,17 @@ module Api
|
||||
|
||||
private
|
||||
|
||||
def maybe_filder_by_completed_at(submitters, params)
|
||||
def maybe_return_submitter_error
|
||||
if @submitter.completed_at? || @submitter.submission.completed_at?
|
||||
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
return unless @submitter.declined_at?
|
||||
|
||||
render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def maybe_filter_by_completed_at(submitters, params)
|
||||
if params[:completed_after].present?
|
||||
submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..)
|
||||
end
|
||||
@@ -160,9 +167,7 @@ module Api
|
||||
submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter)
|
||||
end
|
||||
|
||||
submitter.values = submitter.values.transform_values do |v|
|
||||
v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v
|
||||
end
|
||||
submitter.values = Submitters::SubmitValues.replace_current_date_placeholders(submitter)
|
||||
end
|
||||
|
||||
submitter
|
||||
@@ -178,7 +183,7 @@ module Api
|
||||
submitters = submitters.joins(:submission).where(submissions: { template_id: params[:template_id] })
|
||||
end
|
||||
|
||||
maybe_filder_by_completed_at(submitters, params)
|
||||
maybe_filter_by_completed_at(submitters, params)
|
||||
end
|
||||
|
||||
def assign_external_id(submitter, attrs)
|
||||
@@ -204,10 +209,15 @@ module Api
|
||||
|
||||
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
|
||||
submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to')
|
||||
|
||||
if submitter_preferences.key?('require_phone_2fa')
|
||||
submitter.preferences['require_phone_2fa'] = submitter_preferences['require_phone_2fa']
|
||||
end
|
||||
|
||||
if submitter_preferences.key?('require_email_2fa')
|
||||
submitter.preferences['require_email_2fa'] = submitter_preferences['require_email_2fa']
|
||||
end
|
||||
|
||||
if submitter_preferences.key?('go_to_last')
|
||||
submitter.preferences['go_to_last'] = submitter_preferences['go_to_last']
|
||||
end
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,24 +5,13 @@ module Api
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def index
|
||||
@templates = Templates.shared(current_user) if params[:shared].in?(['true', true])
|
||||
|
||||
templates = filter_templates(@templates, params)
|
||||
|
||||
templates = paginate(templates.preload(:author, folder: :parent_folder))
|
||||
|
||||
schema_documents =
|
||||
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
|
||||
record_type: 'Template',
|
||||
name: :documents,
|
||||
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
|
||||
.preload(:blob)
|
||||
|
||||
preview_image_attachments =
|
||||
ActiveStorage::Attachment.joins(:blob)
|
||||
.where(blob: { filename: ['0.png', '0.jpg'] })
|
||||
.where(record_id: schema_documents.map(&:id),
|
||||
record_type: 'ActiveStorage::Attachment',
|
||||
name: :preview_images)
|
||||
.preload(:blob)
|
||||
schema_documents, dynamic_documents, preview_image_attachments = preload_relations(templates)
|
||||
|
||||
expires_at = Accounts.link_expires_at(current_account)
|
||||
|
||||
@@ -30,6 +19,7 @@ module Api
|
||||
data: templates.map do |t|
|
||||
Templates::SerializeForApi.call(t,
|
||||
schema_documents: schema_documents.select { |e| e.record_id == t.id },
|
||||
dynamic_documents:,
|
||||
preview_image_attachments:,
|
||||
expires_at:)
|
||||
end,
|
||||
@@ -60,16 +50,19 @@ module Api
|
||||
|
||||
archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived)
|
||||
|
||||
if archived.in?([true, false])
|
||||
if archived.in?([true, false]) && current_ability.can?(:destroy, @template)
|
||||
@template.archived_at = archived == true ? Time.current : nil
|
||||
end
|
||||
|
||||
@template.update!(template_params)
|
||||
|
||||
SearchEntries.enqueue_reindex(@template)
|
||||
SearchEntries.enqueue_reindex(@template) if @template.saved_change_to_name?
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.updated')
|
||||
WebhookUrls.enqueue_events(@template, 'template.archived') if archived == true
|
||||
|
||||
if @template.saved_change_to_archived_at? && @template.archived_at?
|
||||
WebhookUrls.enqueue_events(@template, 'template.archived')
|
||||
end
|
||||
|
||||
render json: @template.as_json(only: %i[id updated_at])
|
||||
end
|
||||
@@ -88,8 +81,49 @@ module Api
|
||||
|
||||
private
|
||||
|
||||
def preload_relations(templates)
|
||||
schema_documents =
|
||||
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
|
||||
record_type: 'Template',
|
||||
name: :documents,
|
||||
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
|
||||
.preload(:blob)
|
||||
|
||||
dynamic_document_uuids =
|
||||
templates.flat_map { |t| t.schema.select { |item| item['dynamic'] }.pluck('attachment_uuid') }
|
||||
|
||||
dynamic_documents =
|
||||
if dynamic_document_uuids.present?
|
||||
DynamicDocument.where(template: templates.map(&:id))
|
||||
.where(uuid: dynamic_document_uuids)
|
||||
.preload(current_version: { document_attachment: :blob })
|
||||
.select(:id, :uuid, :template_id, :sha1, :created_at, :updated_at)
|
||||
else
|
||||
DynamicDocument.none
|
||||
end
|
||||
|
||||
preview_attachment_ids =
|
||||
schema_documents.map(&:id) + dynamic_documents.filter_map { |d| d.current_version&.document_attachment&.id }
|
||||
|
||||
preview_image_attachments =
|
||||
ActiveStorage::Attachment.joins(:blob)
|
||||
.where(blob: { filename: ['0.png', '0.jpg'] })
|
||||
.where(record_id: preview_attachment_ids,
|
||||
record_type: 'ActiveStorage::Attachment',
|
||||
name: :preview_images)
|
||||
.preload(:blob)
|
||||
|
||||
[schema_documents, dynamic_documents, preview_image_attachments]
|
||||
end
|
||||
|
||||
def filter_templates(templates, params)
|
||||
templates = Templates.search(current_user, templates, params[:q])
|
||||
templates =
|
||||
if params[:shared].in?(['true', true])
|
||||
Templates.search_shared(current_user, templates, params[:q])
|
||||
else
|
||||
Templates.search(current_user, templates, params[:q])
|
||||
end
|
||||
|
||||
templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active
|
||||
templates = templates.where(external_id: params[:application_key]) if params[:application_key].present?
|
||||
templates = templates.where(external_id: params[:external_id]) if params[:external_id].present?
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class EmailSmtpSettingsController < ApplicationController
|
||||
before_action :load_encrypted_config
|
||||
authorize_resource :encrypted_config, only: :index
|
||||
authorize_resource :encrypted_config, parent: false, only: :create
|
||||
authorize_resource :encrypted_config, parent: false, only: %i[create destroy]
|
||||
|
||||
def index; end
|
||||
|
||||
@@ -23,6 +23,12 @@ class EmailSmtpSettingsController < ApplicationController
|
||||
render :index, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def destroy
|
||||
@encrypted_config.destroy!
|
||||
|
||||
redirect_to settings_email_index_path, notice: I18n.t('smtp_settings_have_been_reset')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_encrypted_config
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,7 +25,10 @@ class PreviewDocumentPageController < ActionController::API
|
||||
preview_image = attachment.preview_images.joins(:blob)
|
||||
.find_by(blob: { filename: ["#{params[:id]}.png", "#{params[:id]}.jpg"] })
|
||||
|
||||
return redirect_to preview_image.url, allow_other_host: true if preview_image
|
||||
if preview_image
|
||||
return redirect_to preview_image.url(time: ActiveStorage::Attachment.service_url_time),
|
||||
allow_other_host: true
|
||||
end
|
||||
|
||||
file_path =
|
||||
if attachment.service.name == :disk
|
||||
@@ -37,11 +40,11 @@ class PreviewDocumentPageController < ActionController::API
|
||||
preview_image =
|
||||
Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
|
||||
|
||||
redirect_to preview_image.url, allow_other_host: true
|
||||
redirect_to preview_image.url(time: ActiveStorage::Attachment.service_url_time), allow_other_host: true
|
||||
end
|
||||
|
||||
def find_or_create_document_tempfile_path(attachment)
|
||||
file_path = "#{Dir.tmpdir}/#{attachment.uuid}"
|
||||
file_path = "#{Dir.tmpdir}/attachment-#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}"
|
||||
|
||||
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
|
||||
f.flock(File::LOCK_EX)
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RevealAccessTokenController < ApplicationController
|
||||
rate_limit to: 4, within: 1.minute, only: %i[create], by: -> { current_user.id }, with: lambda {
|
||||
Rollbar.error('Rate limit api key') if defined?(Rollbar)
|
||||
|
||||
render turbo_stream: turbo_stream.replace(:modal, template: 'reveal_access_token/show',
|
||||
locals: { error_message: I18n.t(:too_many_attempts) }),
|
||||
status: :unprocessable_content
|
||||
}
|
||||
|
||||
def show
|
||||
authorize!(:manage, current_user.access_token)
|
||||
end
|
||||
|
||||
@@ -14,22 +14,22 @@ class SendSubmissionEmailController < ApplicationController
|
||||
template = Template.find_by!(slug: params[:template_slug])
|
||||
|
||||
@submitter =
|
||||
Submitter.completed.where(submission: template.submissions).find_by!(email: params[:email].to_s.downcase)
|
||||
Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase)
|
||||
elsif params[:submission_slug]
|
||||
submission = Submission.find_by(slug: params[:submission_slug])
|
||||
submission = Submission.find_by!(slug: params[:submission_slug])
|
||||
|
||||
if submission
|
||||
@submitter = Submitter.completed.find_by(submission: submission, email: params[:email].to_s.downcase)
|
||||
end
|
||||
@submitter = submission.submitters.order(:completed_at).find_by(email: params[:email].to_s.downcase)
|
||||
|
||||
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter
|
||||
else
|
||||
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
|
||||
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
end
|
||||
|
||||
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
|
||||
if @submitter && completed_submitter?(@submitter)
|
||||
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
|
||||
|
||||
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
|
||||
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
|
||||
end
|
||||
|
||||
respond_to do |f|
|
||||
f.html { render :success }
|
||||
@@ -39,6 +39,10 @@ class SendSubmissionEmailController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def completed_submitter?(submitter)
|
||||
submitter.completed_at? || (submitter.viewer? && submitter.submission.completed_at?)
|
||||
end
|
||||
|
||||
def can_send?(submitter)
|
||||
return false if submitter.account.archived_at?
|
||||
return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
|
||||
|
||||
@@ -93,20 +93,34 @@ class StartFormController < ApplicationController
|
||||
|
||||
SearchEntries.enqueue_reindex(submitter)
|
||||
|
||||
return unless submitter.submission.expire_at?
|
||||
expire_at = submitter.submission.expire_at
|
||||
|
||||
ProcessSubmissionExpiredJob.perform_at(submitter.submission.expire_at, 'submission_id' => submitter.submission_id)
|
||||
return unless expire_at
|
||||
|
||||
ProcessSubmissionExpiredJob.perform_at(expire_at, 'submission_id' => submitter.submission_id,
|
||||
'expire_at' => expire_at.to_i)
|
||||
end
|
||||
|
||||
def load_resubmit_submitter
|
||||
@resubmit_submitter =
|
||||
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
|
||||
Submitter.find_by(slug: params[:resubmit])
|
||||
submitter = Submitter.find_by(slug: params[:resubmit])
|
||||
|
||||
submitter if submitter && can_resubmit?(submitter)
|
||||
end
|
||||
end
|
||||
|
||||
def can_resubmit?(submitter)
|
||||
submitter.completed_at? && submitter.completed_at > 14.days.ago &&
|
||||
%w[api embed mcp].exclude?(submitter.submission.source) &&
|
||||
submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false
|
||||
end
|
||||
|
||||
def authorize_start!
|
||||
return redirect_to start_form_path(@template.slug) if @template.archived_at?
|
||||
is_archived = @template.archived_at? || @template.account.archived_at?
|
||||
|
||||
return redirect_to submit_form_path(@resubmit_submitter.slug) if @resubmit_submitter && is_archived
|
||||
return redirect_to start_form_path(@template.slug) if is_archived
|
||||
|
||||
return if @resubmit_submitter
|
||||
return if @template.shared_link? || (current_user && current_ability.can?(:read, @template))
|
||||
@@ -127,8 +141,7 @@ class StartFormController < ApplicationController
|
||||
|
||||
submitter ||=
|
||||
Submitter
|
||||
.where(submission: template.submissions.where(expire_at: Time.current..)
|
||||
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
|
||||
.where(submission: template.submissions.non_expired.active)
|
||||
.order(id: :desc)
|
||||
.where(declined_at: nil)
|
||||
.where(external_id: nil)
|
||||
@@ -136,6 +149,8 @@ class StartFormController < ApplicationController
|
||||
.then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
|
||||
.find_or_initialize_by(find_params)
|
||||
|
||||
submitter = Submitter.new(find_params) if submitter.submission&.completed_at? && submitter.viewer?
|
||||
|
||||
submitter.name = required_params['name'] if submitter.new_record?
|
||||
|
||||
unless @resubmit_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'
|
||||
|
||||
@@ -7,17 +7,27 @@ class SubmissionsArchivedController < ApplicationController
|
||||
@submissions = @submissions.left_joins(:template)
|
||||
@submissions = @submissions.where.not(archived_at: nil)
|
||||
.or(@submissions.where.not(templates: { archived_at: nil }))
|
||||
.preload(:template_accesses, :created_by_user, template: :author)
|
||||
.preload(:template_accesses, :created_by_user)
|
||||
|
||||
@submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true)
|
||||
@submissions = Submissions::Filter.call(@submissions, current_user, params)
|
||||
|
||||
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
|
||||
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
|
||||
else
|
||||
@submissions.order(id: :desc)
|
||||
end
|
||||
@submissions =
|
||||
if params[:status] == 'completed' || params[:completed_at_from].present? || params[:completed_at_to].present?
|
||||
@submissions.order(completed_at: :desc)
|
||||
else
|
||||
@submissions.order(id: :desc)
|
||||
end
|
||||
|
||||
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
|
||||
@pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))
|
||||
|
||||
template_scope = @submissions.all?(&:template_submitters) ? Template.select_for_list : nil
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(records: @submissions,
|
||||
associations: :template,
|
||||
scope: template_scope).call
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
|
||||
associations: :author).call
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmissionsController < ApplicationController
|
||||
before_action :load_template, only: %i[new create]
|
||||
authorize_resource :template, only: %i[new create]
|
||||
|
||||
load_and_authorize_resource :template, only: %i[new create]
|
||||
load_and_authorize_resource :submission, only: %i[show destroy]
|
||||
|
||||
prepend_before_action :maybe_redirect_com, only: %i[show]
|
||||
@@ -23,10 +21,10 @@ class SubmissionsController < ApplicationController
|
||||
def show
|
||||
@submission = Submissions.preload_with_pages(@submission)
|
||||
|
||||
unless @submission.submitters.all?(&:completed_at?)
|
||||
unless @submission.completed_at?
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@submission],
|
||||
associations: [submitters: :start_form_submission_events]
|
||||
associations: [{ submitters: :start_form_submission_events }]
|
||||
).call
|
||||
end
|
||||
|
||||
@@ -38,6 +36,8 @@ class SubmissionsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
return redirect_to template_path(@template), alert: I18n.t('template_has_been_archived') if @template.archived_at?
|
||||
|
||||
save_template_message(@template, params) if params[:save_message] == '1'
|
||||
|
||||
[params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1'
|
||||
@@ -87,6 +87,8 @@ class SubmissionsController < ApplicationController
|
||||
private
|
||||
|
||||
def create_submissions(template, submissions_params, params)
|
||||
normalize_message_submitter_uuids!(params)
|
||||
|
||||
submissions_attrs = submissions_params[:submission].to_h.values
|
||||
|
||||
submissions_attrs, _, new_fields =
|
||||
@@ -112,7 +114,22 @@ class SubmissionsController < ApplicationController
|
||||
params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] })
|
||||
end
|
||||
|
||||
def load_template
|
||||
@template = Template.accessible_by(current_ability).find(params[:template_id])
|
||||
def normalize_message_submitter_uuids!(params)
|
||||
return if params[:request_email_per_submitter] == '1'
|
||||
|
||||
uuids = params[:email_message_submitter_uuids]
|
||||
|
||||
return if uuids.blank?
|
||||
return if params[:subject].blank? && params[:body].blank?
|
||||
|
||||
params[:submitter_preferences] =
|
||||
Array.wrap(uuids).index_with { { 'subject' => params[:subject], 'body' => params[:body] } }
|
||||
|
||||
params[:request_email_per_submitter] = '1'
|
||||
|
||||
params.delete(:subject)
|
||||
params.delete(:body)
|
||||
|
||||
params
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,17 +8,27 @@ class SubmissionsDashboardController < ApplicationController
|
||||
|
||||
@submissions = @submissions.where(archived_at: nil)
|
||||
.where(templates: { archived_at: nil })
|
||||
.preload(:template_accesses, :created_by_user, template: :author)
|
||||
.preload(:template_accesses, :created_by_user)
|
||||
|
||||
@submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true)
|
||||
@submissions = Submissions::Filter.call(@submissions, current_user, params)
|
||||
|
||||
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
|
||||
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
|
||||
else
|
||||
@submissions.order(id: :desc)
|
||||
end
|
||||
@submissions =
|
||||
if params[:status] == 'completed' || params[:completed_at_from].present? || params[:completed_at_to].present?
|
||||
@submissions.order(completed_at: :desc)
|
||||
else
|
||||
@submissions.order(id: :desc)
|
||||
end
|
||||
|
||||
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
|
||||
@pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))
|
||||
|
||||
template_scope = @submissions.all?(&:template_submitters) ? Template.select_for_list : nil
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(records: @submissions,
|
||||
associations: :template,
|
||||
scope: template_scope).call
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
|
||||
associations: :author).call
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,7 +9,7 @@ class SubmissionsDebugController < ApplicationController
|
||||
def index
|
||||
@submitter = Submitter.preload({ attachments_attachments: :blob },
|
||||
submission: { template: { documents_attachments: :blob } })
|
||||
.find_by(slug: params[:submitter_slug])
|
||||
.find_by(slug: params[:submit_form_slug])
|
||||
|
||||
respond_to do |f|
|
||||
f.html do
|
||||
|
||||
@@ -1,92 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmissionsDownloadController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
TTL = 40.minutes
|
||||
FILES_TTL = 5.minutes
|
||||
load_and_authorize_resource :submission
|
||||
|
||||
def index
|
||||
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
|
||||
signature_valid =
|
||||
if @submitter&.slug == params[:submitter_slug]
|
||||
true
|
||||
else
|
||||
@submitter = nil
|
||||
end
|
||||
|
||||
@submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
|
||||
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
last_submitter = @submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
|
||||
return head :not_found unless last_submitter
|
||||
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
if !signature_valid && !current_user_submitter?(last_submitter)
|
||||
return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
|
||||
if last_submitter.completed_at < TTL.ago
|
||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
if params[:combined] == 'true'
|
||||
respond_with_combined(last_submitter)
|
||||
url = Submitters.build_combined_url(last_submitter)
|
||||
|
||||
if url
|
||||
render json: [url]
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
else
|
||||
render json: build_urls(last_submitter)
|
||||
render json: Submitters.build_document_urls(last_submitter)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def respond_with_combined(submitter)
|
||||
url = build_combined_url(submitter)
|
||||
|
||||
if url
|
||||
render json: [url]
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
def current_user_submitter?(submitter)
|
||||
current_user && current_ability.can?(:read, submitter)
|
||||
end
|
||||
|
||||
def build_urls(submitter)
|
||||
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
|
||||
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
|
||||
|
||||
Submitters.select_attachments_for_download(submitter).map do |attachment|
|
||||
ActiveStorage::Blob.proxy_path(
|
||||
attachment.blob,
|
||||
expires_at: FILES_TTL.from_now.to_i,
|
||||
filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def build_combined_url(submitter)
|
||||
return if submitter.submission.submitters.exists?(completed_at: nil)
|
||||
return if submitter.submission.submitters.order(:completed_at).last != submitter
|
||||
|
||||
attachment = submitter.submission.combined_document_attachment
|
||||
attachment ||= Submissions::EnsureCombinedGenerated.call(submitter)
|
||||
|
||||
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
|
||||
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
|
||||
|
||||
ActiveStorage::Blob.proxy_path(
|
||||
attachment.blob,
|
||||
expires_at: FILES_TTL.from_now.to_i,
|
||||
filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,20 +10,22 @@ 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])
|
||||
|
||||
raise ActionController::RoutingError, I18n.t('not_found') if @submission.account.archived_at?
|
||||
|
||||
if !@submission.submitters.all?(&:completed_at?) && !signature_valid &&
|
||||
if !@submission.completed_at? && !signature_valid &&
|
||||
(!current_user || !current_ability.can?(:read, @submission))
|
||||
raise ActionController::RoutingError, I18n.t('not_found')
|
||||
end
|
||||
@@ -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,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmissionsResendEmailController < ApplicationController
|
||||
load_and_authorize_resource :submission
|
||||
|
||||
before_action do
|
||||
authorize!(:manage, :resend_all)
|
||||
authorize!(:update, @submission)
|
||||
end
|
||||
|
||||
def create
|
||||
submitters = @submission.submitters.reject(&:completed_at?).select { |s| s.email.present? && !s.declined_at? }
|
||||
|
||||
if Docuseal.multitenant?
|
||||
recent_submitter_ids =
|
||||
SubmissionEvent.where(submitter_id: submitters.map(&:id),
|
||||
event_type: 'send_email',
|
||||
created_at: 10.hours.ago..Time.current).pluck(:submitter_id).to_set
|
||||
|
||||
submitters = submitters.reject { |s| recent_submitter_ids.include?(s.id) }
|
||||
end
|
||||
|
||||
submitters.each do |submitter|
|
||||
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id)
|
||||
|
||||
submitter.sent_at ||= Time.current
|
||||
submitter.save!
|
||||
end
|
||||
|
||||
notice =
|
||||
if submitters.empty?
|
||||
I18n.t('email_has_been_sent_already')
|
||||
else
|
||||
I18n.t('emails_have_been_sent_to_n_recipients', count: submitters.size)
|
||||
end
|
||||
|
||||
redirect_back(fallback_location: submission_path(@submission), notice:)
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,8 @@ class SubmissionsUnarchiveController < ApplicationController
|
||||
load_and_authorize_resource :submission
|
||||
|
||||
def create
|
||||
authorize!(:destroy, @submission)
|
||||
|
||||
@submission.update!(archived_at: nil)
|
||||
|
||||
redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_unarchived')
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# 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) if @submitter.completed_at?
|
||||
|
||||
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)
|
||||
return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
|
||||
if last_submitter.completed_at < TTL.ago
|
||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
if params[:combined] == 'true'
|
||||
respond_with_combined(last_submitter)
|
||||
else
|
||||
render json: Submitters.build_document_urls(last_submitter)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def submitter_slug
|
||||
params[:submit_form_slug] || params[:submitter_slug] || params[:submitter_id]
|
||||
end
|
||||
|
||||
def respond_with_combined(submitter)
|
||||
url = Submitters.build_combined_url(submitter)
|
||||
|
||||
if url
|
||||
render json: [url]
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
def current_user_submitter?(submitter)
|
||||
current_user && current_ability.can?(:read, submitter)
|
||||
end
|
||||
end
|
||||
@@ -3,11 +3,12 @@
|
||||
class SubmitFormController < ApplicationController
|
||||
layout 'form'
|
||||
|
||||
around_action :with_browser_locale, only: %i[show completed success]
|
||||
around_action :with_browser_locale, only: %i[show completed success delegated]
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
before_action :load_submitter, only: %i[show update completed]
|
||||
before_action :maybe_redirect_delegated, only: %i[show completed]
|
||||
before_action :maybe_render_locked_page, only: :show
|
||||
before_action :maybe_require_link_2fa, only: %i[show]
|
||||
|
||||
@@ -17,7 +18,10 @@ class SubmitFormController < ApplicationController
|
||||
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?
|
||||
|
||||
if @submitter.completed_at? || submission.completed_at?
|
||||
return redirect_to submit_form_completed_path(@submitter.slug)
|
||||
end
|
||||
|
||||
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
|
||||
|
||||
@@ -70,6 +74,12 @@ class SubmitFormController < ApplicationController
|
||||
status: :unprocessable_content
|
||||
end
|
||||
|
||||
if @submitter.viewer?
|
||||
Rollbar.warning("Submit viewer: #{@submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: I18n.t('form_is_view_only') }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
Submitters::SubmitValues.call(@submitter, params, request)
|
||||
|
||||
head :ok
|
||||
@@ -78,6 +88,8 @@ class SubmitFormController < ApplicationController
|
||||
|
||||
render json: { field_uuid: e.message }, status: :unprocessable_content
|
||||
rescue Submitters::SubmitValues::ValidationError => e
|
||||
Rollbar.warning("Validation error #{@submitter.id}: #{e.message}") if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
@@ -91,6 +103,12 @@ class SubmitFormController < ApplicationController
|
||||
|
||||
def success; end
|
||||
|
||||
def delegated
|
||||
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
|
||||
|
||||
@submitter = submitter_version.submitter
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def maybe_require_link_2fa
|
||||
@@ -108,8 +126,18 @@ class SubmitFormController < ApplicationController
|
||||
render :declined if @submitter.declined_at?
|
||||
end
|
||||
|
||||
def maybe_redirect_delegated
|
||||
return if @submitter
|
||||
|
||||
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
|
||||
|
||||
submitter_version.submitter.submission_events.find_by!(event_type: :delegate_form)
|
||||
|
||||
redirect_to submit_form_delegated_path(submitter_version.slug)
|
||||
end
|
||||
|
||||
def load_submitter
|
||||
@submitter = Submitter.find_by!(slug: params[:slug] || params[:submit_form_slug])
|
||||
@submitter = Submitter.find_by(slug: params[:slug] || params[:submit_form_slug])
|
||||
end
|
||||
|
||||
def build_attachments_index(submission)
|
||||
|
||||
@@ -4,31 +4,44 @@ class SubmitFormDeclineController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def create
|
||||
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
before_action :load_submitter
|
||||
|
||||
return redirect_to submit_form_path(submitter.slug) if submitter.declined_at? ||
|
||||
submitter.completed_at? ||
|
||||
submitter.submission.archived_at? ||
|
||||
submitter.submission.expired? ||
|
||||
submitter.submission.template&.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(submitter, current_user,
|
||||
request)
|
||||
def create
|
||||
return redirect_to submit_form_path(@submitter.slug) if declining_disabled? ||
|
||||
@submitter.declined_at? ||
|
||||
@submitter.completed_at? ||
|
||||
@submitter.submission.archived_at? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
@submitter.viewer? ||
|
||||
!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 declining_disabled?
|
||||
@submitter.account.account_configs.find_by(key: AccountConfig::ALLOW_TO_DECLINE_KEY)&.value == false
|
||||
end
|
||||
|
||||
def load_submitter
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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? ||
|
||||
@submitter.viewer? ||
|
||||
!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,11 +9,12 @@ 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? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.completed_at? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
AccountConfig.exists?(account_id: @submitter.account_id,
|
||||
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
|
||||
|
||||
@@ -12,6 +12,8 @@ class SubmitFormDrawSignatureController < ApplicationController
|
||||
|
||||
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
|
||||
|
||||
return redirect_to submit_form_path(@submitter.slug) if @submitter.viewer?
|
||||
|
||||
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
return redirect_to submit_form_path(@submitter.slug)
|
||||
|
||||
@@ -5,12 +5,12 @@ class SubmitFormInviteController < ApplicationController
|
||||
skip_authorization_check
|
||||
|
||||
def create
|
||||
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
return head :unprocessable_content unless can_invite?(submitter)
|
||||
return head :unprocessable_content unless can_invite?(@submitter)
|
||||
|
||||
invite_submitters = filter_invite_submitters(submitter, 'invite_by_uuid')
|
||||
optional_invite_submitters = filter_invite_submitters(submitter, 'optional_invite_by_uuid')
|
||||
invite_submitters = filter_invite_submitters(@submitter, 'invite_by_uuid')
|
||||
optional_invite_submitters = filter_invite_submitters(@submitter, 'optional_invite_by_uuid')
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
(invite_submitters + optional_invite_submitters).each do |item|
|
||||
@@ -21,18 +21,18 @@ class SubmitFormInviteController < ApplicationController
|
||||
|
||||
email = Submissions.normalize_email(attrs[:email])
|
||||
|
||||
submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: submitter.account_id)
|
||||
@submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: @submitter.account_id)
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(submitter, 'invite_party', request, { uuid: submitter.uuid })
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'invite_party', request, { uuid: @submitter.uuid })
|
||||
end
|
||||
|
||||
submitter.submission.update!(submitters_order: :preserved)
|
||||
@submitter.submission.update!(submitters_order: :preserved)
|
||||
end
|
||||
|
||||
submitter.submission.submitters.reload
|
||||
@submitter.submission.submitters.reload
|
||||
|
||||
if invite_submitters.all? { |s| submitter.submission.submitters.any? { |e| e.uuid == s['uuid'] } }
|
||||
Submitters::SubmitValues.call(submitter, ActionController::Parameters.new(completed: 'true'), request)
|
||||
if invite_submitters.all? { |s| @submitter.submission.submitters.any? { |e| e.uuid == s['uuid'] } }
|
||||
Submitters::SubmitValues.call(@submitter, ActionController::Parameters.new(completed: 'true'), request)
|
||||
|
||||
head :ok
|
||||
else
|
||||
@@ -48,6 +48,7 @@ class SubmitFormInviteController < ApplicationController
|
||||
!submitter.submission.archived_at? &&
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at? &&
|
||||
!submitter.viewer? &&
|
||||
Submitters::AuthorizedForForm.call(submitter, current_user, request)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitFormMetadataController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def index
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
return head :not_found unless authorized_submitter?(@submitter)
|
||||
|
||||
submission = @submitter.submission
|
||||
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
|
||||
schema = Submissions.filtered_conditions_schema(submission, values:, include_submitter_uuid: @submitter.uuid)
|
||||
|
||||
documents = schema.filter_map do |item|
|
||||
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
|
||||
end
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(records: documents, associations: %i[blob record]).call
|
||||
|
||||
text_runs = documents.to_h do |document|
|
||||
[
|
||||
document.uuid,
|
||||
DocumentMetadatas.find_or_create_for_document(document, account_id: document.record.account_id).text_runs
|
||||
]
|
||||
end
|
||||
|
||||
render json: { text_runs: }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authorized_submitter?(submitter)
|
||||
!submitter.declined_at? &&
|
||||
!submitter.completed_at? &&
|
||||
!submitter.submission.archived_at? &&
|
||||
!submitter.submission.completed_at? &&
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at? &&
|
||||
!submitter.account.archived_at? &&
|
||||
Submitters::AuthorizedForForm.call(submitter, current_user, request)
|
||||
end
|
||||
end
|
||||
@@ -5,21 +5,21 @@ class SubmitFormValuesController < ApplicationController
|
||||
skip_authorization_check
|
||||
|
||||
def index
|
||||
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
return render json: {} if submitter.completed_at? ||
|
||||
submitter.declined_at? ||
|
||||
submitter.submission.template&.archived_at? ||
|
||||
submitter.submission.archived_at? ||
|
||||
submitter.submission.expired? ||
|
||||
!Submitters::AuthorizedForForm.call(submitter, current_user, request)
|
||||
return render json: {} if @submitter.completed_at? ||
|
||||
@submitter.declined_at? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
@submitter.submission.archived_at? ||
|
||||
@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?
|
||||
value = @submitter.values[params['field_uuid']]
|
||||
attachment = @submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
|
||||
|
||||
render json: {
|
||||
value:,
|
||||
attachment: attachment&.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
|
||||
}, head: :ok
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,7 +13,7 @@ class SubmittersController < ApplicationController
|
||||
def update
|
||||
submission = @submitter.submission
|
||||
|
||||
if @submitter.submission_events.exists?(event_type: 'start_form') || submission.archived_at? || submission.expired?
|
||||
unless submitter_editable?(submission)
|
||||
return redirect_back fallback_location: submission_path(submission), alert: I18n.t('submitter_cannot_be_updated')
|
||||
end
|
||||
|
||||
@@ -48,11 +48,17 @@ class SubmittersController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def submitter_editable?(submission)
|
||||
!@submitter.submission_events.exists?(event_type: 'start_form') &&
|
||||
!@submitter.completed_at? && !@submitter.declined_at? && !submission.completed_at? &&
|
||||
!submission.archived_at? && !submission.expired? && !submission.template&.archived_at?
|
||||
end
|
||||
|
||||
def maybe_resend_email_sms(submitter, params)
|
||||
if params[:send_email] == '1' && submitter.email.present?
|
||||
is_sent_recently = Docuseal.multitenant? &&
|
||||
EmailEvent.exists?(email: submitter.email,
|
||||
tag: 'submitter_invitation',
|
||||
tag: %w[submitter_invitation submitter_view_invitation],
|
||||
emailable: submitter,
|
||||
event_type: 'send',
|
||||
created_at: 4.hours.ago..Time.current)
|
||||
|
||||
@@ -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
|
||||
@@ -5,6 +5,8 @@ class SubmittersResubmitController < ApplicationController
|
||||
|
||||
def update
|
||||
return redirect_to submit_form_path(slug: @submitter.slug) if @submitter.email != current_user.email
|
||||
return redirect_to submit_form_path(slug: @submitter.slug) if @submitter.completed_at.blank? ||
|
||||
@submitter.completed_at < 1.month.ago
|
||||
|
||||
submission = @submitter.account.submissions.new(created_by_user: current_user,
|
||||
submitters_order: :preserved,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# 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
|
||||
authorize!(:update, @submitter)
|
||||
|
||||
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
|
||||
event_type: 'send_email',
|
||||
created_at: 10.hours.ago..Time.current)
|
||||
|
||||
@@ -10,6 +10,8 @@ class TemplateDocumentsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
authorize!(:update, @template)
|
||||
|
||||
if params[:blobs].blank? && params[:files].blank?
|
||||
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplateDocumentsCropController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
before_action :load_attachment
|
||||
|
||||
rescue_from Leptonica::LeptonicaError do
|
||||
render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def index
|
||||
render json: { corners: Leptonica.detect_document_corners(@attachment.download) }
|
||||
end
|
||||
|
||||
def create
|
||||
authorize!(:update, @template)
|
||||
|
||||
document = Templates::CreateDocumentCrop.call(@template, @attachment, crop_params)
|
||||
|
||||
render json: {
|
||||
document: document.as_json(
|
||||
methods: %i[metadata signed_key],
|
||||
include: {
|
||||
preview_images: { methods: %i[url metadata filename] }
|
||||
}
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_attachment
|
||||
@attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid])
|
||||
end
|
||||
|
||||
def crop_params
|
||||
params.permit(:scan, :rotate, :flip_h, :flip_v, corners: [%i[x y]])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplateDocumentsModifyController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
authorize!(:update, @template)
|
||||
|
||||
documents_layout =
|
||||
params.require(:documents).map do |item|
|
||||
item.permit(:attachment_uuid,
|
||||
pages: [:attachment_uuid, :page, :rotate,
|
||||
{ redact: [%i[x y w h color]], replaced_page: %i[attachment_uuid page] }]).to_h
|
||||
end
|
||||
|
||||
Templates::ModifyDocuments.call(@template, documents_layout)
|
||||
|
||||
render json: {
|
||||
schema: @template.schema,
|
||||
fields: @template.fields,
|
||||
submitters: @template.submitters,
|
||||
documents: @template.schema_documents.reload.preload(:blob, preview_images_attachments: :blob).as_json(
|
||||
methods: %i[metadata signed_key],
|
||||
include: {
|
||||
preview_images: { methods: %i[url metadata filename] }
|
||||
}
|
||||
)
|
||||
}
|
||||
rescue Templates::ModifyDocuments::InvalidLayout
|
||||
render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplateDocumentsPageObjectsController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def index
|
||||
attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid])
|
||||
|
||||
render json: Templates::ModifyDocuments.page_objects(attachment, params[:page].to_i)
|
||||
end
|
||||
end
|
||||
@@ -30,9 +30,11 @@ class TemplateFoldersController < ApplicationController
|
||||
(@template_folders.size < 7 ? 9 : 6)
|
||||
end
|
||||
|
||||
@pagy, @templates = pagy_auto(@templates, limit:)
|
||||
@pagy, @templates = pagy_auto(@templates.select_for_list, limit:)
|
||||
|
||||
load_related_submissions if params[:q].present? && @templates.blank?
|
||||
if params[:q].present? && @templates.blank?
|
||||
@related_submissions_pagy, @related_submissions = load_related_submissions(@template_folder)
|
||||
end
|
||||
else
|
||||
@pagy, @template_folders = pagy(@template_folders, limit: FOLDERS_PER_PAGE)
|
||||
|
||||
@@ -55,11 +57,10 @@ class TemplateFoldersController < ApplicationController
|
||||
|
||||
def selected_order
|
||||
@selected_order ||=
|
||||
if cookies.permanent[:dashboard_templates_order].blank? ||
|
||||
(cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
|
||||
if can?(:manage, :countless)
|
||||
'created_at'
|
||||
else
|
||||
cookies.permanent[:dashboard_templates_order]
|
||||
cookies.permanent[:dashboard_templates_order].presence || 'created_at'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -67,20 +68,20 @@ class TemplateFoldersController < ApplicationController
|
||||
params.require(:template_folder).permit(:name)
|
||||
end
|
||||
|
||||
def load_related_submissions
|
||||
@related_submissions =
|
||||
def load_related_submissions(template_folder)
|
||||
related_submissions =
|
||||
Submission.accessible_by(current_ability)
|
||||
.where(archived_at: nil)
|
||||
.where(template_id: current_account.templates.active
|
||||
.where(folder: [@template_folder, *@template_folder.subfolders])
|
||||
.where(folder: [template_folder, *template_folder.subfolders])
|
||||
.select(:id))
|
||||
.preload(:template_accesses, :created_by_user,
|
||||
template: :author,
|
||||
submitters: :start_form_submission_events)
|
||||
|
||||
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q])
|
||||
.order(id: :desc)
|
||||
related_submissions = Submissions.search(current_user, related_submissions, params[:q])
|
||||
.order(id: :desc)
|
||||
|
||||
@related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5)
|
||||
pagy_auto(related_submissions.select_for_list, limit: 5)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,11 +10,17 @@ class TemplatesArchivedController < ApplicationController
|
||||
|
||||
@templates = Templates.search(current_user, @templates, params[:q])
|
||||
|
||||
@pagy, @templates = pagy_auto(@templates, limit: 12)
|
||||
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
|
||||
|
||||
return unless params[:q].present? && @templates.blank?
|
||||
|
||||
@related_submissions =
|
||||
@related_submissions_pagy, @related_submissions = load_related_submissions
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_related_submissions
|
||||
related_submissions =
|
||||
Submission.accessible_by(current_ability)
|
||||
.joins(:template)
|
||||
.where.not(templates: { archived_at: nil })
|
||||
@@ -22,9 +28,9 @@ class TemplatesArchivedController < ApplicationController
|
||||
template: :author,
|
||||
submitters: :start_form_submission_events)
|
||||
|
||||
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q])
|
||||
.order(id: :desc)
|
||||
related_submissions = Submissions.search(current_user, related_submissions, params[:q])
|
||||
.order(id: :desc)
|
||||
|
||||
@related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5)
|
||||
pagy_auto(related_submissions.select_for_list, limit: 5)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,12 +10,12 @@ class TemplatesArchivedSubmissionsController < ApplicationController
|
||||
@submissions = Submissions::Filter.call(@submissions, current_user, params)
|
||||
|
||||
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
|
||||
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
|
||||
@submissions.order(completed_at: :desc)
|
||||
else
|
||||
@submissions.order(id: :desc)
|
||||
end
|
||||
|
||||
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
|
||||
@pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to root_path
|
||||
end
|
||||
|
||||
@@ -8,11 +8,14 @@ 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)
|
||||
cloned_template.name = File.basename(params[:files].first.original_filename, '.*')
|
||||
|
||||
authorize!(:create, cloned_template)
|
||||
|
||||
cloned_template.save!
|
||||
|
||||
documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,12 +14,13 @@ class TemplatesController < ApplicationController
|
||||
submissions = Submissions::Filter.filter_by_status(submissions, params)
|
||||
|
||||
submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
|
||||
submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
|
||||
submissions.order(completed_at: :desc)
|
||||
else
|
||||
submissions.order(id: :desc)
|
||||
end
|
||||
|
||||
@pagy, @submissions = pagy_auto(submissions.preload(:template_accesses, submitters: :start_form_submission_events))
|
||||
@pagy, @submissions =
|
||||
pagy_auto(submissions.select_for_list.preload(:template_accesses, submitters: :start_form_submission_events))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to root_path
|
||||
end
|
||||
@@ -27,18 +28,7 @@ class TemplatesController < ApplicationController
|
||||
def new; end
|
||||
|
||||
def edit
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
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_key],
|
||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||
)
|
||||
).to_json
|
||||
@template_data = Templates.serialize_for_builder(@template)
|
||||
|
||||
render :edit, layout: 'plain'
|
||||
end
|
||||
|
||||
@@ -11,86 +11,126 @@ class TemplatesDashboardController < ApplicationController
|
||||
helper_method :selected_order
|
||||
|
||||
def index
|
||||
@default_folder = current_account.default_template_folder
|
||||
|
||||
@template_folders =
|
||||
TemplateFolders.filter_active_folders(@template_folders.where(parent_folder_id: nil), @templates)
|
||||
|
||||
@template_folders = @template_folders.where.not(id: @default_folder.id) if params[:q].blank?
|
||||
|
||||
@template_folders = TemplateFolders.search(@template_folders, params[:q])
|
||||
@template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
|
||||
|
||||
@pagy, @template_folders = pagy(
|
||||
@template_folders,
|
||||
limit: FOLDERS_PER_PAGE,
|
||||
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
|
||||
)
|
||||
@shared_templates = Templates.shared(current_user).active
|
||||
|
||||
@pagy, @template_folders, @show_default_folder, @show_shared_folder, @show_shared_inline =
|
||||
load_folders(@template_folders, @templates, @shared_templates)
|
||||
|
||||
if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD
|
||||
@templates = @templates.none
|
||||
else
|
||||
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
|
||||
@templates = filter_templates(@templates).preload(:author, :template_accesses)
|
||||
@templates = Templates::Order.call(@templates, current_user, selected_order)
|
||||
if @show_shared_inline
|
||||
@templates = @shared_templates.preload(:template_sharings)
|
||||
else
|
||||
@templates = @templates.active
|
||||
@templates = @templates.where(folder_id: @default_folder.id) if params[:q].blank?
|
||||
end
|
||||
|
||||
limit =
|
||||
if @template_folders.size < 4
|
||||
TEMPLATES_PER_PAGE
|
||||
else
|
||||
(@template_folders.size < 7 ? 9 : 6)
|
||||
end
|
||||
@pagy, @templates = load_templates(@templates.select_for_list, @pagy.count,
|
||||
show_shared_inline: @show_shared_inline)
|
||||
|
||||
@pagy, @templates = pagy_auto(@templates, limit:)
|
||||
|
||||
load_related_submissions if params[:q].present? && @templates.blank?
|
||||
if params[:q].present? && @templates.blank?
|
||||
@related_submissions_pagy, @related_submissions = load_related_submissions
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filter_templates(templates)
|
||||
rel = templates.active
|
||||
def load_templates(templates, folders_count, show_shared_inline: false)
|
||||
templates = templates.preload(:author, :template_accesses)
|
||||
|
||||
if params[:q].blank?
|
||||
if Docuseal.multitenant? ? current_account.testing? : current_account.linked_account_account
|
||||
shared_account_ids = [current_user.account_id]
|
||||
shared_account_ids << TemplateSharing::ALL_ID if !Docuseal.multitenant? && !current_account.testing?
|
||||
|
||||
shared_template_ids = TemplateSharing.where(account_id: shared_account_ids).select(:template_id)
|
||||
|
||||
rel = Template.where(
|
||||
Template.arel_table[:id].in(
|
||||
rel.where(folder_id: current_account.default_template_folder.id).select(:id).arel
|
||||
.union(:all, shared_template_ids.arel)
|
||||
)
|
||||
)
|
||||
templates =
|
||||
if show_shared_inline
|
||||
Templates.search_shared(current_user, templates, params[:q])
|
||||
else
|
||||
rel = rel.where(folder_id: current_account.default_template_folder.id)
|
||||
Templates.search(current_user, templates, params[:q])
|
||||
end
|
||||
end
|
||||
|
||||
Templates.search(current_user, rel, params[:q])
|
||||
templates = Templates::Order.call(templates, current_user, selected_order)
|
||||
|
||||
limit =
|
||||
if folders_count < 4
|
||||
TEMPLATES_PER_PAGE
|
||||
else
|
||||
(folders_count < 7 ? 9 : 6)
|
||||
end
|
||||
|
||||
pagy_auto(templates, limit:)
|
||||
end
|
||||
|
||||
def load_folders(template_folders, templates, shared_templates)
|
||||
if params[:q].present?
|
||||
pagy(template_folders, limit: FOLDERS_PER_PAGE,
|
||||
page: template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1)
|
||||
else
|
||||
load_folders_with_pinned(template_folders, templates, shared_templates)
|
||||
end
|
||||
end
|
||||
|
||||
def load_folders_with_pinned(template_folders, templates, shared_templates)
|
||||
folders_count = template_folders.count
|
||||
|
||||
shared_exists = shared_templates.exists?
|
||||
default_has_templates = templates.active.exists?(folder_id: current_account.default_template_folder.id)
|
||||
|
||||
show_inline_folders =
|
||||
folders_count + (shared_exists && default_has_templates ? 1 : 0) <= SHOW_TEMPLATES_FOLDERS_THRESHOLD
|
||||
|
||||
show_shared_inline = shared_exists && !default_has_templates && show_inline_folders
|
||||
|
||||
show_shared_in_grid = shared_exists && !show_shared_inline
|
||||
show_default_in_grid = !show_inline_folders && default_has_templates
|
||||
|
||||
pinned_count = (show_default_in_grid ? 1 : 0) + (show_shared_in_grid ? 1 : 0)
|
||||
|
||||
pagy = Pagy::Offset.new(count: folders_count + pinned_count,
|
||||
page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max,
|
||||
limit: FOLDERS_PER_PAGE,
|
||||
raise_range_error: true)
|
||||
|
||||
show_default_folder = show_default_in_grid && pagy.page == 1
|
||||
show_shared_folder = show_shared_in_grid && pagy.page == 1
|
||||
|
||||
folder_offset = pagy.page == 1 ? 0 : pagy.offset - pinned_count
|
||||
folder_limit = pagy.page == 1 ? FOLDERS_PER_PAGE - pinned_count : FOLDERS_PER_PAGE
|
||||
|
||||
template_folders = template_folders.offset(folder_offset).limit(folder_limit)
|
||||
|
||||
[pagy, template_folders, show_default_folder, show_shared_folder, show_shared_inline]
|
||||
end
|
||||
|
||||
def selected_order
|
||||
@selected_order ||=
|
||||
if cookies.permanent[:dashboard_templates_order].blank? ||
|
||||
(cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
|
||||
if can?(:manage, :countless)
|
||||
'created_at'
|
||||
else
|
||||
cookies.permanent[:dashboard_templates_order]
|
||||
cookies.permanent[:dashboard_templates_order].presence || 'created_at'
|
||||
end
|
||||
end
|
||||
|
||||
def load_related_submissions
|
||||
@related_submissions = Submission.accessible_by(current_ability)
|
||||
.left_joins(:template)
|
||||
.where(archived_at: nil)
|
||||
.where(templates: { archived_at: nil })
|
||||
.preload(:template_accesses, :created_by_user,
|
||||
template: :author,
|
||||
submitters: :start_form_submission_events)
|
||||
related_submissions = Submission.accessible_by(current_ability)
|
||||
.left_joins(:template)
|
||||
.where(archived_at: nil)
|
||||
.where(templates: { archived_at: nil })
|
||||
.preload(:template_accesses, :created_by_user,
|
||||
template: :author,
|
||||
submitters: :start_form_submission_events)
|
||||
|
||||
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q])
|
||||
.order(id: :desc)
|
||||
related_submissions = Submissions.search(current_user, related_submissions, params[:q])
|
||||
.order(id: :desc)
|
||||
|
||||
@related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5)
|
||||
pagy_auto(related_submissions.select_for_list, limit: 5)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,7 +30,7 @@ class TemplatesDebugController < ApplicationController
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [schema_documents: { preview_images_attachments: :blob }]
|
||||
associations: [{ schema_documents: { preview_images_attachments: :blob } }]
|
||||
).call
|
||||
|
||||
@template_data =
|
||||
|
||||
@@ -16,7 +16,12 @@ class TemplatesDetectFieldsController < ApplicationController
|
||||
page_number = params[:page].presence&.to_i
|
||||
|
||||
documents.each do |document|
|
||||
io = StringIO.new(document.download)
|
||||
io =
|
||||
if document.image?
|
||||
StringIO.new(document.preview_images.joins(:blob).find_by(blob: { filename: ['0.png', '0.jpg'] }).download)
|
||||
else
|
||||
StringIO.new(document.download)
|
||||
end
|
||||
|
||||
Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)|
|
||||
sse.write({ attachment_uuid:, page:, fields: })
|
||||
|
||||
@@ -6,6 +6,8 @@ class TemplatesFoldersController < ApplicationController
|
||||
def edit; end
|
||||
|
||||
def update
|
||||
authorize!(:update, @template)
|
||||
|
||||
name = [params[:parent_name], params[:name]].compact_blank.join(' / ')
|
||||
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, name)
|
||||
|
||||
@@ -5,6 +5,8 @@ class TemplatesPreferencesController < ApplicationController
|
||||
|
||||
RESETTABLE_PREFERENCE_KEYS = {
|
||||
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY => %w[request_email_subject request_email_body submitters],
|
||||
AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY => %w[invitation_view_email_subject
|
||||
invitation_view_email_body],
|
||||
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[invitation_reminder_email_subject
|
||||
invitation_reminder_email_body],
|
||||
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[documents_copy_email_subject documents_copy_email_body],
|
||||
@@ -48,18 +50,18 @@ class TemplatesPreferencesController < ApplicationController
|
||||
def template_params
|
||||
params.require(:template).permit(
|
||||
preferences: %i[bcc_completed request_email_subject request_email_body
|
||||
invitation_view_email_subject invitation_view_email_body
|
||||
invitation_reminder_email_subject invitation_reminder_email_body
|
||||
documents_copy_email_subject documents_copy_email_body
|
||||
documents_copy_email_enabled documents_copy_email_attach_audit
|
||||
documents_copy_email_attach_documents documents_copy_email_reply_to
|
||||
completed_notification_email_attach_documents
|
||||
completed_redirect_url validate_unique_submitters
|
||||
completed_notification_email_attach_documents completed_redirect_url validate_unique_submitters
|
||||
require_all_submitters submitters_order require_phone_2fa require_email_2fa
|
||||
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'
|
||||
|
||||
|
||||
@@ -4,18 +4,7 @@ class TemplatesPreviewController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def show
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [schema_documents: { preview_images_attachments: :blob }]
|
||||
).call
|
||||
|
||||
@template_data =
|
||||
@template.as_json.merge(
|
||||
documents: @template.schema_documents.as_json(
|
||||
methods: %i[metadata signed_key],
|
||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||
)
|
||||
).to_json
|
||||
@template_data = Templates.serialize_for_builder(@template)
|
||||
|
||||
render :show, layout: 'plain'
|
||||
end
|
||||
|
||||
@@ -4,6 +4,8 @@ class TemplatesRestoreController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
authorize!(:destroy, @template)
|
||||
|
||||
@template.update!(archived_at: nil)
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.updated')
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesShareLinkQrController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def show
|
||||
return render :disabled, layout: 'plain' unless @template.shared_link?
|
||||
|
||||
shared_link_url = start_form_url(slug: @template.slug, host: form_link_host)
|
||||
|
||||
@qr_svg_code = RQRCode::QRCode.new(shared_link_url, level: :m).as_svg(viewbox: true)
|
||||
|
||||
@page_size =
|
||||
if TimeUtils.timezone_abbr(current_account.timezone, Time.current.beginning_of_year).in?(TimeUtils::US_TIMEZONES)
|
||||
'Letter'
|
||||
else
|
||||
'A4'
|
||||
end
|
||||
|
||||
render :show, layout: false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesSharedController < ApplicationController
|
||||
def index
|
||||
authorize!(:read, Template)
|
||||
|
||||
@is_archived = params[:archived] == 'true'
|
||||
|
||||
@templates = Templates.shared(current_user)
|
||||
|
||||
@has_archived = !@is_archived && @templates.archived.exists?
|
||||
|
||||
@templates = @is_archived ? @templates.archived : @templates.active
|
||||
|
||||
@templates = @templates.preload(:author, :template_accesses, :template_sharings)
|
||||
.order(id: :desc)
|
||||
|
||||
@templates = Templates.search_shared(current_user, @templates, params[:q])
|
||||
|
||||
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
|
||||
|
||||
return unless params[:q].present? && @templates.blank?
|
||||
|
||||
@related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_related_submissions(is_archived:)
|
||||
shared_templates = Templates.shared(current_user)
|
||||
shared_templates = is_archived ? shared_templates.archived : shared_templates.active
|
||||
|
||||
related_submissions =
|
||||
Submission.accessible_by(current_ability)
|
||||
.where(template_id: shared_templates.select(:id))
|
||||
.preload(:template_accesses, :created_by_user,
|
||||
template: :author,
|
||||
submitters: :start_form_submission_events)
|
||||
|
||||
related_submissions = related_submissions.where(archived_at: nil) unless is_archived
|
||||
|
||||
related_submissions = Submissions.search(current_user, related_submissions, params[:q])
|
||||
.order(id: :desc)
|
||||
|
||||
pagy_auto(related_submissions.select_for_list, limit: 5)
|
||||
end
|
||||
end
|
||||
@@ -5,7 +5,9 @@ class TemplatesUploadsController < ApplicationController
|
||||
|
||||
layout 'plain'
|
||||
|
||||
def show; end
|
||||
def show
|
||||
redirect_to root_path if params[:url].blank?
|
||||
end
|
||||
|
||||
def create
|
||||
url_params = create_file_params_from_url if params[:url].present?
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesVersionsController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def index
|
||||
versions = @template.template_versions.order(id: :desc).preload(:author)
|
||||
|
||||
render json: versions.as_json(TemplateVersions::SERIALIZE_PARAMS)
|
||||
end
|
||||
|
||||
def show
|
||||
version = @template.template_versions.find(params[:id])
|
||||
|
||||
render json: TemplateVersions.serialize(version)
|
||||
end
|
||||
|
||||
def create
|
||||
authorize!(:update, @template)
|
||||
|
||||
TemplateVersions.find_or_create_for(@template, author: current_user)
|
||||
|
||||
head :ok
|
||||
end
|
||||
end
|
||||
@@ -3,7 +3,7 @@
|
||||
class TestingAccountsController < ApplicationController
|
||||
skip_authorization_check only: :destroy
|
||||
|
||||
def show
|
||||
def create
|
||||
authorize!(:manage, current_account)
|
||||
authorize!(:manage, current_user)
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ class UserInitialsController < ApplicationController
|
||||
|
||||
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank?
|
||||
|
||||
extension = File.extname(file.original_filename).delete_prefix('.').downcase
|
||||
|
||||
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
|
||||
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
|
||||
end
|
||||
|
||||
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
|
||||
filename: file.original_filename,
|
||||
content_type: file.content_type)
|
||||
|
||||
@@ -11,6 +11,12 @@ class UserSignaturesController < ApplicationController
|
||||
|
||||
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank?
|
||||
|
||||
extension = File.extname(file.original_filename).delete_prefix('.').downcase
|
||||
|
||||
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
|
||||
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
|
||||
end
|
||||
|
||||
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
|
||||
filename: file.original_filename,
|
||||
content_type: file.content_type)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class WebhookHmacController < ApplicationController
|
||||
load_and_authorize_resource :webhook_url, parent: false
|
||||
|
||||
def show; end
|
||||
end
|
||||
@@ -32,9 +32,13 @@ class WebhookSettingsController < ApplicationController
|
||||
def new; end
|
||||
|
||||
def create
|
||||
@webhook_url.save!
|
||||
if @webhook_url.url.present?
|
||||
@webhook_url.save!
|
||||
|
||||
redirect_to settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved')
|
||||
redirect_to settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved')
|
||||
else
|
||||
redirect_back fallback_location: settings_webhooks_path
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -21,6 +21,7 @@ import SubmittersAutocomplete from './elements/submitter_autocomplete'
|
||||
import FolderAutocomplete from './elements/folder_autocomplete'
|
||||
import SignatureForm from './elements/signature_form'
|
||||
import SubmitForm from './elements/submit_form'
|
||||
import ConvertUpload from './elements/convert_upload'
|
||||
import PromptPassword from './elements/prompt_password'
|
||||
import EmailsTextarea from './elements/emails_textarea'
|
||||
import ToggleSubmit from './elements/toggle_submit'
|
||||
@@ -41,19 +42,20 @@ 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 HtmlEditor from './elements/html_editor'
|
||||
import MountOnClick from './elements/mount_on_click'
|
||||
import RemoveOnEvent from './elements/remove_on_event'
|
||||
import ScrollTo from './elements/scroll_to'
|
||||
import SetValue from './elements/set_value'
|
||||
import ReviewForm from './elements/review_form'
|
||||
import ShowOnValue from './elements/show_on_value'
|
||||
import CustomValidation from './elements/custom_validation'
|
||||
import ToggleClasses from './elements/toggle_classes'
|
||||
import AutosizeField from './elements/autosize_field'
|
||||
import GoogleDriveFilePicker from './elements/google_drive_file_picker'
|
||||
import OpenModal from './elements/open_modal'
|
||||
import BarChart from './elements/bar_chart'
|
||||
import FieldCondition from './elements/field_condition'
|
||||
import ConfirmUpload from './elements/confirm_upload'
|
||||
|
||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||
|
||||
@@ -112,6 +114,7 @@ safeRegisterElement('submitters-autocomplete', SubmittersAutocomplete)
|
||||
safeRegisterElement('folder-autocomplete', FolderAutocomplete)
|
||||
safeRegisterElement('signature-form', SignatureForm)
|
||||
safeRegisterElement('submit-form', SubmitForm)
|
||||
safeRegisterElement('convert-upload', ConvertUpload)
|
||||
safeRegisterElement('prompt-password', PromptPassword)
|
||||
safeRegisterElement('emails-textarea', EmailsTextarea)
|
||||
safeRegisterElement('toggle-cookies', ToggleCookies)
|
||||
@@ -133,19 +136,20 @@ safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
|
||||
safeRegisterElement('page-container', PageContainer)
|
||||
safeRegisterElement('email-editor', EmailEditor)
|
||||
safeRegisterElement('markdown-editor', MarkdownEditor)
|
||||
safeRegisterElement('html-editor', HtmlEditor)
|
||||
safeRegisterElement('mount-on-click', MountOnClick)
|
||||
safeRegisterElement('remove-on-event', RemoveOnEvent)
|
||||
safeRegisterElement('scroll-to', ScrollTo)
|
||||
safeRegisterElement('set-value', SetValue)
|
||||
safeRegisterElement('review-form', ReviewForm)
|
||||
safeRegisterElement('show-on-value', ShowOnValue)
|
||||
safeRegisterElement('custom-validation', CustomValidation)
|
||||
safeRegisterElement('toggle-classes', ToggleClasses)
|
||||
safeRegisterElement('autosize-field', AutosizeField)
|
||||
safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
|
||||
safeRegisterElement('open-modal', OpenModal)
|
||||
safeRegisterElement('bar-chart', BarChart)
|
||||
safeRegisterElement('field-condition', FieldCondition)
|
||||
safeRegisterElement('confirm-upload', ConfirmUpload)
|
||||
|
||||
safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
@@ -160,6 +164,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
this.app = createApp(TemplateBuilder, {
|
||||
template,
|
||||
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
|
||||
dateFormats: JSON.parse(this.dataset.dateFormats || '[]'),
|
||||
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
|
||||
backgroundColor: '#faf7f5',
|
||||
locale: this.dataset.locale,
|
||||
@@ -169,6 +174,9 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null,
|
||||
withLogo: this.dataset.withLogo !== 'false',
|
||||
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
|
||||
withDetectExistingFields: this.dataset.withDetectExistingFields === 'true',
|
||||
withRevisions: true,
|
||||
withRevisionsMenu: this.dataset.withRevisionsMenu === 'true',
|
||||
editable: this.dataset.editable !== 'false',
|
||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||
withCustomFields: true,
|
||||
@@ -180,6 +188,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
withConditions: this.dataset.withConditions === 'true',
|
||||
withDynamicDocuments: this.dataset.withDynamicDocuments === 'true',
|
||||
withGoogleDrive: this.dataset.withGoogleDrive === 'true',
|
||||
pagePreviewFormat: this.dataset.pagePreviewFormat || '.jpg',
|
||||
withReplaceAndCloneUpload: true,
|
||||
withDownload: true,
|
||||
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
|
||||
@@ -193,10 +202,35 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
}
|
||||
|
||||
onSubmit = (e) => {
|
||||
if (e.detail.success && e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
|
||||
e.detail.fetchResponse.response.json().then((data) => {
|
||||
this.component.template.submitters = data.submitters
|
||||
})
|
||||
if (e.detail.success) {
|
||||
if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
|
||||
e.detail.fetchResponse.response.json().then((data) => {
|
||||
this.component.template.submitters = data.submitters
|
||||
})
|
||||
}
|
||||
|
||||
if (e.detail?.formSubmission?.formElement?.action?.endsWith('/prefillable_fields')) {
|
||||
e.detail.fetchResponse.response.text().then((data) => {
|
||||
const doc = new DOMParser().parseFromString(data, 'text/html')
|
||||
const fragment = doc.querySelector('turbo-stream template').content
|
||||
|
||||
const prefillableUuidsIndex = {}
|
||||
|
||||
fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => {
|
||||
prefillableUuidsIndex[field.value] = true
|
||||
})
|
||||
|
||||
this.component.template.fields.forEach((field) => {
|
||||
if (prefillableUuidsIndex[field.uuid]) {
|
||||
field.prefillable = true
|
||||
field.readonly = true
|
||||
} else if (field.prefillable) {
|
||||
delete field.prefillable
|
||||
delete field.readonly
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ window.customElements.define('draw-signature', class extends HTMLElement {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.canvas) return
|
||||
if (!this.canvas.parentNode?.clientWidth) return
|
||||
|
||||
const { width, height } = this.canvas
|
||||
|
||||
@@ -89,7 +90,7 @@ window.customElements.define('draw-signature', class extends HTMLElement {
|
||||
}
|
||||
|
||||
redrawCanvas (oldWidth, oldHeight) {
|
||||
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
|
||||
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 0) {
|
||||
const sx = this.canvas.width / oldWidth
|
||||
const sy = this.canvas.height / oldHeight
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||
|
||||
export default targetable(class extends HTMLElement {
|
||||
static [target.static] = [
|
||||
'prompt',
|
||||
'processing',
|
||||
'logo'
|
||||
]
|
||||
|
||||
connectedCallback () {
|
||||
this.form.addEventListener('submit', this.onSubmit)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.form.removeEventListener('submit', this.onSubmit)
|
||||
}
|
||||
|
||||
onSubmit = () => {
|
||||
this.prompt.classList.add('hidden')
|
||||
this.processing.classList.remove('hidden')
|
||||
this.logo.classList.add('animate-bounce')
|
||||
}
|
||||
|
||||
get form () {
|
||||
return this.querySelector('form')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
export function convertImage (sourceFile, targetType, quality) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = function (event) {
|
||||
const img = new Image()
|
||||
|
||||
img.onload = function () {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
canvas.width = img.width
|
||||
canvas.height = img.height
|
||||
ctx.drawImage(img, 0, 0)
|
||||
canvas.toBlob(function (blob) {
|
||||
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
|
||||
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
|
||||
resolve(newFile)
|
||||
}, targetType, quality)
|
||||
}
|
||||
|
||||
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
|
||||
|
||||
img.src = event.target.result
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(sourceFile)
|
||||
})
|
||||
}
|
||||
|
||||
export async function convertImagesInInput (input) {
|
||||
if (!input.files || input.files.length === 0) return
|
||||
|
||||
const dt = new DataTransfer()
|
||||
let didConvert = false
|
||||
|
||||
for (const file of Array.from(input.files)) {
|
||||
let converted = file
|
||||
|
||||
try {
|
||||
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
|
||||
converted = await convertImage(file, 'image/png')
|
||||
didConvert = true
|
||||
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
|
||||
converted = await convertImage(file, 'image/jpeg', 0.9)
|
||||
didConvert = true
|
||||
}
|
||||
} catch (e) {
|
||||
alert(e.message)
|
||||
}
|
||||
|
||||
dt.items.add(converted)
|
||||
}
|
||||
|
||||
if (didConvert) {
|
||||
input.files = dt.files
|
||||
}
|
||||
}
|
||||
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const input = this.querySelector('input[type="file"]')
|
||||
const form = input.form
|
||||
|
||||
input.addEventListener('change', async () => {
|
||||
await convertImagesInInput(input)
|
||||
|
||||
form.querySelector('[type="submit"]')?.setAttribute('disabled', true)
|
||||
|
||||
form.requestSubmit()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const input = this.querySelector('input')
|
||||
const invalidMessage = this.dataset.invalidMessage || ''
|
||||
|
||||
input.addEventListener('invalid', () => {
|
||||
input.setCustomValidity(input.value ? invalidMessage : '')
|
||||
})
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
input.setCustomValidity('')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { target, targets, targetable } from '@github/catalyst/lib/targetable'
|
||||
import { convertImagesInInput } from './convert_upload'
|
||||
|
||||
const loadingIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
@@ -150,12 +151,16 @@ export default targetable(class extends HTMLElement {
|
||||
if (!this.isLoading) this.hideDraghover()
|
||||
}
|
||||
|
||||
uploadFiles (files, url) {
|
||||
async uploadFiles (files, url) {
|
||||
this.isLoading = true
|
||||
|
||||
this.form.action = url
|
||||
|
||||
this.form.querySelector('[type="file"]').files = files
|
||||
const input = this.form.querySelector('[type="file"]')
|
||||
|
||||
input.files = files
|
||||
|
||||
await convertImagesInInput(input)
|
||||
|
||||
this.form.querySelector('[type="submit"]').click()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ export default targetable(class extends HTMLElement {
|
||||
|
||||
connectedCallback () {
|
||||
this.addEventListener('click', () => this.downloadFiles())
|
||||
this.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
this.downloadFiles()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
toggleState () {
|
||||
|
||||
@@ -9,8 +9,9 @@ function loadCodeMirror () {
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/language'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/lint'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow')
|
||||
]).then(([view, commands, language, html, htmlflow]) => {
|
||||
]).then(([view, commands, language, html, lint, htmlflow]) => {
|
||||
return {
|
||||
minimalSetup: [
|
||||
commands.history(),
|
||||
@@ -19,6 +20,8 @@ function loadCodeMirror () {
|
||||
],
|
||||
EditorView: view.EditorView,
|
||||
html: html.html,
|
||||
htmlLanguage: html.htmlLanguage,
|
||||
linter: lint.linter,
|
||||
htmlflow: htmlflow.default || htmlflow
|
||||
}
|
||||
})
|
||||
@@ -46,6 +49,70 @@ export default targetable(class extends HTMLElement {
|
||||
|
||||
this.previewViewTab.addEventListener('click', this.showPreviewView)
|
||||
this.codeViewTab.addEventListener('click', this.showCodeView)
|
||||
|
||||
this.form = this.closest('form')
|
||||
this.form?.addEventListener('submit', this.validateOnSubmit)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.form?.removeEventListener('submit', this.validateOnSubmit)
|
||||
}
|
||||
|
||||
validateOnSubmit = (e) => {
|
||||
if (!this.htmlLanguage) return
|
||||
|
||||
const bodyType = this.form.querySelector('input[name$="[body_type]"]:checked')?.value
|
||||
|
||||
if (bodyType && bodyType !== 'html') return
|
||||
|
||||
const diagnostics = this.buildDiagnostics(this.input.value)
|
||||
|
||||
if (diagnostics.length === 0) return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
this.showCodeView()
|
||||
|
||||
const pos = Math.min(diagnostics[0].from, this.editorView.state.doc.length)
|
||||
|
||||
this.editorView.dispatch({ selection: { anchor: pos }, scrollIntoView: true })
|
||||
this.editorView.focus()
|
||||
|
||||
alert(diagnostics[0].message)
|
||||
}
|
||||
|
||||
buildDiagnostics (value) {
|
||||
const diagnostics = []
|
||||
|
||||
if (!value.trim()) return diagnostics
|
||||
|
||||
if (!/^\s*(<!doctype[^>]*>\s*)?<html/i.test(value)) {
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: Math.min(5, value.length),
|
||||
severity: 'error',
|
||||
message: 'The email template must start with the <html> tag'
|
||||
})
|
||||
}
|
||||
|
||||
const seen = new Set()
|
||||
|
||||
this.htmlLanguage.parser.parse(value).iterate({
|
||||
enter: (node) => {
|
||||
if (!node.type.isError || seen.has(node.from) || seen.size >= 20) return
|
||||
|
||||
seen.add(node.from)
|
||||
|
||||
diagnostics.push({
|
||||
from: node.from,
|
||||
to: Math.min(node.to + 1, value.length),
|
||||
severity: 'error',
|
||||
message: 'The email template contains invalid HTML'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
showCodeView = () => {
|
||||
@@ -76,7 +143,9 @@ export default targetable(class extends HTMLElement {
|
||||
this.input = this.querySelector('input[type="hidden"]')
|
||||
this.input.style.display = 'none'
|
||||
|
||||
const { EditorView, minimalSetup, html, htmlflow } = await loadCodeMirror()
|
||||
const { EditorView, minimalSetup, html, htmlLanguage, linter, htmlflow } = await loadCodeMirror()
|
||||
|
||||
this.htmlLanguage = htmlLanguage
|
||||
|
||||
this.editorView = new EditorView({
|
||||
doc: this.input.value,
|
||||
@@ -85,8 +154,11 @@ export default targetable(class extends HTMLElement {
|
||||
html(),
|
||||
minimalSetup,
|
||||
EditorView.lineWrapping,
|
||||
linter((view) => this.buildDiagnostics(view.state.doc.toString()), { delay: 600 }),
|
||||
EditorView.updateListener.of(update => {
|
||||
if (update.docChanged) this.input.value = update.state.doc.toString()
|
||||
if (update.docChanged) {
|
||||
this.input.value = update.state.doc.toString()
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { actionable } from '@github/catalyst/lib/actionable'
|
||||
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||
import { convertImagesInInput } from './convert_upload'
|
||||
|
||||
export default actionable(targetable(class extends HTMLElement {
|
||||
static [target.static] = [
|
||||
@@ -38,17 +39,21 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
this.classList.add('border-base-300', 'hover:bg-base-200/30')
|
||||
}
|
||||
|
||||
onDrop (e) {
|
||||
async onDrop (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.input.files = e.dataTransfer.files
|
||||
|
||||
this.uploadFiles(e.dataTransfer.files)
|
||||
await convertImagesInInput(this.input)
|
||||
|
||||
this.uploadFiles(this.input.files)
|
||||
}
|
||||
|
||||
onSelectFiles (e) {
|
||||
async onSelectFiles (e) {
|
||||
e.preventDefault()
|
||||
|
||||
await convertImagesInInput(this.input)
|
||||
|
||||
this.uploadFiles(this.input.files)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||
import { actionable } from '@github/catalyst/lib/actionable'
|
||||
import { LinkTooltip } from './markdown_editor'
|
||||
|
||||
async function loadTiptap () {
|
||||
const [core, document, text, hardBreak, gapcursor, dropcursor, extensions, pmState, pmView] = await Promise.all([
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/core'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-document'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-text'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-hard-break'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-gapcursor'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-dropcursor'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extensions'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/state'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/view')
|
||||
])
|
||||
|
||||
return {
|
||||
Editor: core.Editor,
|
||||
Extension: core.Extension,
|
||||
Node: core.Node,
|
||||
Mark: core.Mark,
|
||||
Document: document.default || document,
|
||||
Text: text.default || text,
|
||||
HardBreak: hardBreak.default || hardBreak,
|
||||
Gapcursor: gapcursor.default || gapcursor,
|
||||
Dropcursor: dropcursor.default || dropcursor,
|
||||
UndoRedo: extensions.UndoRedo,
|
||||
Plugin: pmState.Plugin,
|
||||
Decoration: pmView.Decoration,
|
||||
DecorationSet: pmView.DecorationSet
|
||||
}
|
||||
}
|
||||
|
||||
const editorStylesheet = new CSSStyleSheet()
|
||||
|
||||
editorStylesheet.replaceSync(`
|
||||
:host {
|
||||
display: block;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
border-radius: 0 0 1rem 1rem;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
word-wrap: break-word;
|
||||
-webkit-font-variant-ligatures: none;
|
||||
font-variant-ligatures: none;
|
||||
font-feature-settings: "liga" 0;
|
||||
outline: none;
|
||||
min-height: 220px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.variable-highlight {
|
||||
background-color: #fef3c7;
|
||||
padding: 1px 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`)
|
||||
|
||||
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 buildExtensions ({ Node, Mark, Extension, Plugin, Decoration, DecorationSet }) {
|
||||
const blockNode = (name, tag, content, extra = {}) => Node.create({
|
||||
name,
|
||||
group: 'block',
|
||||
content: content || 'block+',
|
||||
...extra,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag, getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return [tag, node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const attrsMark = (name, tag) => Mark.create({
|
||||
name,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag, getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ mark }) {
|
||||
return [tag, mark.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
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 toggleMark = (name, renderTag, parseRules, shortcuts) => Mark.create({
|
||||
name,
|
||||
parseHTML () {
|
||||
return parseRules
|
||||
},
|
||||
renderHTML () {
|
||||
return [renderTag, 0]
|
||||
},
|
||||
addCommands () {
|
||||
const commandName = `toggle${name[0].toUpperCase()}${name.slice(1)}`
|
||||
|
||||
return {
|
||||
[commandName]: () => ({ commands }) => commands.toggleMark(name)
|
||||
}
|
||||
},
|
||||
addKeyboardShortcuts () {
|
||||
return {
|
||||
[shortcuts]: () => this.editor.commands.toggleMark(name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const Heading = 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 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 HrNode = Node.create({
|
||||
name: 'horizontalRule',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'hr', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['hr', node.attrs.htmlAttrs]
|
||||
}
|
||||
})
|
||||
|
||||
const StyleNode = Node.create({
|
||||
name: 'style',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: false,
|
||||
addAttributes () {
|
||||
return {
|
||||
htmlAttrs: { default: {} },
|
||||
css: { default: '' }
|
||||
}
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'style', getAttrs: (dom) => ({ ...collectDomAttrs(dom), css: dom.textContent }) }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['style', node.attrs.htmlAttrs, node.attrs.css]
|
||||
}
|
||||
})
|
||||
|
||||
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 LinkMark = Mark.create({
|
||||
name: 'link',
|
||||
inclusive: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'a', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ mark }) {
|
||||
return ['a', mark.attrs.htmlAttrs, 0]
|
||||
},
|
||||
addCommands () {
|
||||
return {
|
||||
setLink: ({ href }) => ({ editor, commands }) => {
|
||||
const htmlAttrs = { ...(editor.getAttributes('link').htmlAttrs || {}), href }
|
||||
|
||||
return commands.setMark('link', { htmlAttrs })
|
||||
},
|
||||
unsetLink: () => ({ commands }) => commands.unsetMark('link', { extendEmptyMarkRange: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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: 'variable-highlight'
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})]
|
||||
}
|
||||
})
|
||||
|
||||
return [
|
||||
blockNode('paragraph', 'p', 'inline*'),
|
||||
Heading,
|
||||
blockNode('section', 'section'),
|
||||
blockNode('article', 'article', null, { isolating: true }),
|
||||
blockNode('header', 'header', null, { isolating: true }),
|
||||
blockNode('footer', 'footer', null, { isolating: true }),
|
||||
blockNode('div', 'div'),
|
||||
blockNode('center', 'center'),
|
||||
blockNode('blockquote', 'blockquote'),
|
||||
blockNode('pre', 'pre'),
|
||||
blockNode('orderedList', 'ol', '(listItem | block)+'),
|
||||
blockNode('bulletList', 'ul', '(listItem | block)+'),
|
||||
blockNode('listItem', 'li', 'block+', { group: null }),
|
||||
blockNode('table', 'table', '(colgroup | tableHead | tableBody | tableFoot | tableRow)+'),
|
||||
blockNode('tableHead', 'thead', 'tableRow+', { group: null }),
|
||||
blockNode('tableBody', 'tbody', 'tableRow+', { group: null }),
|
||||
blockNode('tableFoot', 'tfoot', 'tableRow+', { group: null }),
|
||||
blockNode('tableRow', 'tr', '(tableCell | tableHeader)+', { group: null }),
|
||||
blockNode('tableCell', 'td', 'block*', { group: null }),
|
||||
blockNode('tableHeader', 'th', 'block*', { group: null }),
|
||||
blockNode('colgroup', 'colgroup', 'col*', { group: null }),
|
||||
Node.create({
|
||||
name: 'col',
|
||||
atom: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'col', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['col', node.attrs.htmlAttrs]
|
||||
}
|
||||
}),
|
||||
ImageNode,
|
||||
HrNode,
|
||||
StyleNode,
|
||||
EmptySpanNode,
|
||||
SpanMark,
|
||||
LinkMark,
|
||||
toggleMark('bold', 'strong', [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }, { style: 'font-weight=700' }], 'Mod-b'),
|
||||
toggleMark('italic', 'em', [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }], 'Mod-i'),
|
||||
toggleMark('underline', 'u', [{ tag: 'u' }, { style: 'text-decoration=underline' }], 'Mod-u'),
|
||||
toggleMark('strike', 's', [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }], 'Mod-Shift-s'),
|
||||
attrsMark('subscript', 'sub'),
|
||||
attrsMark('superscript', 'sup'),
|
||||
VariableHighlight
|
||||
]
|
||||
}
|
||||
|
||||
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 tiptap = await loadTiptap()
|
||||
|
||||
const { Editor, Extension, Document, Text, HardBreak, UndoRedo, Gapcursor, Dropcursor } = tiptap
|
||||
|
||||
this.emailDocument = new DOMParser().parseFromString(this.textarea.value, 'text/html')
|
||||
|
||||
const shadow = this.editorElement.attachShadow({ mode: 'open' })
|
||||
|
||||
shadow.adoptedStyleSheets = [editorStylesheet]
|
||||
|
||||
this.emailDocument.head.querySelectorAll('style').forEach((style) => {
|
||||
shadow.appendChild(style.cloneNode(true))
|
||||
})
|
||||
|
||||
const container = document.createElement('div')
|
||||
const bodyStyle = this.emailDocument.body.getAttribute('style')
|
||||
|
||||
if (bodyStyle) container.setAttribute('style', bodyStyle)
|
||||
|
||||
shadow.appendChild(container)
|
||||
|
||||
const LinkShortcut = Extension.create({
|
||||
name: 'linkShortcut',
|
||||
addKeyboardShortcuts: () => ({
|
||||
'Mod-k': () => {
|
||||
this.toggleLink()
|
||||
|
||||
return true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.editor = new Editor({
|
||||
element: container,
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
HardBreak,
|
||||
UndoRedo,
|
||||
Gapcursor,
|
||||
Dropcursor,
|
||||
...buildExtensions(tiptap),
|
||||
LinkShortcut
|
||||
],
|
||||
content: this.emailDocument.body.innerHTML,
|
||||
injectCSS: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
dir: 'auto'
|
||||
},
|
||||
handleDOMEvents: {
|
||||
click: (_, event) => {
|
||||
if (event.target.closest('a')) event.preventDefault()
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
this.emailDocument.body.innerHTML = editor.getHTML()
|
||||
|
||||
this.textarea.value = this.emailDocument.documentElement.outerHTML
|
||||
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)?.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.htmlAttrs?.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').htmlAttrs?.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()
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -35,7 +35,7 @@ function loadTiptap () {
|
||||
}))
|
||||
}
|
||||
|
||||
class LinkTooltip {
|
||||
export class LinkTooltip {
|
||||
constructor (container, editor, templateEl) {
|
||||
this.container = container
|
||||
this.editor = editor
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const dialog = document.getElementById(this.dataset.target)
|
||||
|
||||
this.querySelector('button').addEventListener('click', () => {
|
||||
if (dialog) {
|
||||
dialog.inert = false
|
||||
dialog.showModal()
|
||||
}
|
||||
})
|
||||
|
||||
if (dialog) {
|
||||
dialog.addEventListener('close', () => {
|
||||
dialog.inert = true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,11 +47,13 @@ export default class extends HTMLElement {
|
||||
|
||||
this.classList.remove('hidden', '-translate-y-10', 'opacity-0')
|
||||
this.classList.add('translate-y-0', 'opacity-100')
|
||||
this.inert = false
|
||||
}
|
||||
|
||||
hideButtons () {
|
||||
this.classList.remove('translate-y-0', 'opacity-100')
|
||||
this.classList.add('-translate-y-10', 'opacity-0')
|
||||
this.inert = true
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.classList.contains('-translate-y-10')) {
|
||||
|
||||
@@ -14,6 +14,7 @@ export default targetable(class extends HTMLElement {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.canvas) return
|
||||
if (!this.canvas.parentNode?.clientWidth) return
|
||||
|
||||
const { width, height } = this.canvas
|
||||
|
||||
@@ -80,7 +81,7 @@ export default targetable(class extends HTMLElement {
|
||||
}
|
||||
|
||||
redrawCanvas (oldWidth, oldHeight) {
|
||||
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
|
||||
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 0) {
|
||||
const sx = this.canvas.width / oldWidth
|
||||
const sy = this.canvas.height / oldHeight
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import FetchForm from './elements/fetch_form'
|
||||
import ScrollButtons from './elements/scroll_buttons'
|
||||
import PageContainer from './elements/page_container'
|
||||
import SubmitForm from './elements/submit_form'
|
||||
import ModalButton from './elements/modal_button'
|
||||
|
||||
const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
|
||||
|
||||
@@ -16,6 +17,7 @@ safeRegisterElement('fetch-form', FetchForm)
|
||||
safeRegisterElement('scroll-buttons', ScrollButtons)
|
||||
safeRegisterElement('page-container', PageContainer)
|
||||
safeRegisterElement('submit-form', SubmitForm)
|
||||
safeRegisterElement('modal-button', ModalButton)
|
||||
safeRegisterElement('submission-form', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.appElem = document.createElement('div')
|
||||
@@ -31,12 +33,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',
|
||||
@@ -49,7 +52,9 @@ safeRegisterElement('submission-form', class extends HTMLElement {
|
||||
completedMessage: JSON.parse(this.dataset.completedMessage || '{}'),
|
||||
completedRedirectUrl: this.dataset.completedRedirectUrl,
|
||||
attachments: reactive(JSON.parse(this.dataset.attachments)),
|
||||
fields: JSON.parse(this.dataset.fields)
|
||||
fields: JSON.parse(this.dataset.fields),
|
||||
completeButtonContainer: document.getElementById('complete_button_container'),
|
||||
completeButtonScrollContainer: document.getElementById('complete_button_container_scroll')
|
||||
})
|
||||
|
||||
this.app.mount(this.appElem)
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
<template>
|
||||
<template
|
||||
v-for="(pages, docUuid) in textRuns"
|
||||
:key="docUuid"
|
||||
>
|
||||
<template
|
||||
v-for="(_, pageIndex) in pages"
|
||||
:key="pageIndex"
|
||||
>
|
||||
<template
|
||||
v-for="(pageElem, i) in [findPageElement(docUuid, pageIndex)]"
|
||||
:key="i"
|
||||
>
|
||||
<Teleport
|
||||
v-if="pageElem"
|
||||
:to="pageElem"
|
||||
>
|
||||
<template
|
||||
v-for="(item, index) in sortedItemsForPage(docUuid, pageIndex)"
|
||||
:key="index"
|
||||
>
|
||||
<template v-if="item.type === 'text_group'">
|
||||
<span
|
||||
class="absolute overflow-hidden text-transparent select-none pointer-events-none"
|
||||
:style="{ left: item.x * 100 + '%', top: item.y * 100 + '%', width: item.w * 100 + '%', height: item.h * 100 + '%' }"
|
||||
>{{ item.text }}</span>
|
||||
<span
|
||||
v-for="(run, runIndex) in item.items"
|
||||
:key="runIndex"
|
||||
aria-hidden="true"
|
||||
class="absolute overflow-hidden text-transparent"
|
||||
:style="{ left: run.x * 100 + '%', top: run.y * 100 + '%', width: run.w * 100 + '%', height: run.h * 100 + '%', fontSize: run.font_size ? (run.font_size / 10) + 'cqmin' : undefined, textAlign: 'justify', textAlignLast: 'justify', textJustify: 'inter-character' }"
|
||||
>{{ run.text }}</span>
|
||||
</template>
|
||||
<FieldArea
|
||||
v-else-if="item.type === 'field_area'"
|
||||
:ref="setAreaRef"
|
||||
v-model="values[item.field.uuid]"
|
||||
:values="values"
|
||||
:field="item.field"
|
||||
:area="item.area"
|
||||
:submittable="true"
|
||||
:page-width="1400"
|
||||
:page-height="(1400.0 / pageElem.offsetWidth) * pageElem.offsetHeight"
|
||||
:field-index="item.fieldIndex"
|
||||
:is-inline-size="isInlineSize"
|
||||
:scroll-padding="scrollPadding"
|
||||
:submitter="submitter"
|
||||
:with-field-placeholder="withFieldPlaceholder"
|
||||
:with-signature-id="withSignatureId"
|
||||
:is-active="currentStep === item.step"
|
||||
:with-label="withLabel && !withFieldPlaceholder && item.step.length < 2"
|
||||
:is-value-set="item.step.some((f) => f.uuid in values)"
|
||||
:attachments-index="attachmentsIndex"
|
||||
@click="[$emit('focus-step', item.stepIndex), maybeScrollOnClick(item.field, item.area)]"
|
||||
/>
|
||||
<FieldArea
|
||||
v-else-if="item.type === 'readonly_field_area'"
|
||||
:model-value="readonlyConditionalFieldValues[item.field.uuid]"
|
||||
:values="readonlyConditionalFieldValues"
|
||||
:field="item.field"
|
||||
:area="item.area"
|
||||
:submittable="false"
|
||||
:page-width="1400"
|
||||
:page-height="(1400.0 / pageElem.offsetWidth) * pageElem.offsetHeight"
|
||||
:field-index="item.fieldIndex"
|
||||
:is-inline-size="isInlineSize"
|
||||
:submitter="submitter"
|
||||
:attachments-index="attachmentsIndex"
|
||||
/>
|
||||
<FieldArea
|
||||
v-else-if="item.type === 'formula_area' && isMathLoaded"
|
||||
:model-value="calculateFormula(item.field)"
|
||||
:is-inline-size="isInlineSize"
|
||||
:field="item.field"
|
||||
:area="item.area"
|
||||
:submittable="false"
|
||||
:field-index="item.fieldIndex"
|
||||
/>
|
||||
</template>
|
||||
</Teleport>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FieldArea from './area'
|
||||
import FormulaAreas from './formula_areas'
|
||||
import FieldAreas from './areas'
|
||||
|
||||
export default {
|
||||
name: 'AccessibilityAreas',
|
||||
components: {
|
||||
FieldArea
|
||||
},
|
||||
inject: ['baseUrl', 't'],
|
||||
props: {
|
||||
submitterSlug: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
filledFieldsIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
steps: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
readonlyConditionalFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
readonlyConditionalFieldValues: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
formulaFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
readonlyValues: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
submitter: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
currentStep: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
withFieldPlaceholder: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withLabel: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
scrollPadding: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '-80px'
|
||||
},
|
||||
scrollEl: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
attachmentsIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
fetchOptions: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
emits: ['focus-step'],
|
||||
data () {
|
||||
return {
|
||||
isMathLoaded: false,
|
||||
math: null,
|
||||
textRuns: {},
|
||||
areaRefs: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldValuesIndex () {
|
||||
return this.filledFieldsIndex || this.extractStaticValues()
|
||||
},
|
||||
isMobileContainer: FieldAreas.computed.isMobileContainer,
|
||||
isInlineSize: FieldAreas.computed.isInlineSize,
|
||||
fieldsUuidIndex () {
|
||||
return this.formulaFields.reduce((acc, field) => {
|
||||
acc[field.uuid] = field
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
fieldAreasIndex () {
|
||||
const index = Object.create(null)
|
||||
|
||||
this.steps.forEach((step, stepIndex) => {
|
||||
step.forEach((field, fieldIndex) => {
|
||||
(field.areas || []).forEach((area) => {
|
||||
index[area.attachment_uuid] ||= Object.create(null)
|
||||
index[area.attachment_uuid][area.page] ||= []
|
||||
index[area.attachment_uuid][area.page].push({
|
||||
type: 'field_area',
|
||||
field,
|
||||
area,
|
||||
step,
|
||||
stepIndex,
|
||||
fieldIndex,
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
w: area.w,
|
||||
h: area.h
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return index
|
||||
},
|
||||
formulaAreasIndex () {
|
||||
const index = Object.create(null)
|
||||
|
||||
this.formulaFields.forEach((field, fieldIndex) => {
|
||||
(field.areas || []).forEach((area) => {
|
||||
index[area.attachment_uuid] ||= Object.create(null)
|
||||
index[area.attachment_uuid][area.page] ||= []
|
||||
index[area.attachment_uuid][area.page].push({
|
||||
type: 'formula_area',
|
||||
field,
|
||||
area,
|
||||
fieldIndex,
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
w: area.w,
|
||||
h: area.h
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return index
|
||||
},
|
||||
readonlyFieldAreasIndex () {
|
||||
const index = Object.create(null)
|
||||
|
||||
this.readonlyConditionalFields.forEach((field, fieldIndex) => {
|
||||
(field.areas || []).forEach((area) => {
|
||||
index[area.attachment_uuid] ||= Object.create(null)
|
||||
index[area.attachment_uuid][area.page] ||= []
|
||||
index[area.attachment_uuid][area.page].push({
|
||||
type: 'readonly_field_area',
|
||||
field,
|
||||
area,
|
||||
fieldIndex,
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
w: area.w,
|
||||
h: area.h
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return index
|
||||
}
|
||||
},
|
||||
beforeUpdate () {
|
||||
this.areaRefs = []
|
||||
},
|
||||
async mounted () {
|
||||
const [metadataResult] = await Promise.all([
|
||||
fetch(this.baseUrl + `/s/${this.submitterSlug}/metadata`, {
|
||||
...this.fetchOptions
|
||||
}).then((r) => r.json()).catch(() => ({})),
|
||||
this.loadMath()
|
||||
])
|
||||
|
||||
this.textRuns = metadataResult.text_runs || {}
|
||||
},
|
||||
methods: {
|
||||
normalizeFormula: FormulaAreas.methods.normalizeFormula,
|
||||
calculateFormula: FormulaAreas.methods.calculateFormula,
|
||||
scrollInContainer: FieldAreas.methods.scrollInContainer,
|
||||
scrollIntoArea: FieldAreas.methods.scrollIntoArea,
|
||||
scrollIntoField: FieldAreas.methods.scrollIntoField,
|
||||
maybeScrollOnClick: FieldAreas.methods.maybeScrollOnClick,
|
||||
setAreaRef (el) {
|
||||
if (el) {
|
||||
this.areaRefs.push(el)
|
||||
}
|
||||
},
|
||||
async loadMath () {
|
||||
if (this.formulaFields.length && !this.isMathLoaded) {
|
||||
const { Calculator } = await import('./calculator')
|
||||
|
||||
this.math = new Calculator()
|
||||
this.isMathLoaded = true
|
||||
}
|
||||
},
|
||||
extractStaticValues () {
|
||||
const result = Object.create(null)
|
||||
const root = this.$root.$el?.parentNode?.getRootNode() || document
|
||||
const pageContainers = root.querySelectorAll('page-container')
|
||||
|
||||
pageContainers.forEach((container) => {
|
||||
const overlay = container.querySelector('[id^="page-"]')
|
||||
|
||||
if (!overlay) return
|
||||
|
||||
const parts = overlay.id.split('-')
|
||||
const pageIndex = parseInt(parts[parts.length - 1])
|
||||
const docUuid = parts.slice(1, -1).join('-')
|
||||
|
||||
const fieldValues = overlay.querySelectorAll('field-value')
|
||||
|
||||
if (!fieldValues.length) return
|
||||
|
||||
result[docUuid] ||= Object.create(null)
|
||||
result[docUuid][pageIndex] = []
|
||||
|
||||
fieldValues.forEach((el) => {
|
||||
const style = el.style
|
||||
const x = parseFloat(style.left) / 100
|
||||
const y = parseFloat(style.top) / 100
|
||||
const w = parseFloat(style.width) / 100
|
||||
const h = parseFloat(style.height) / 100
|
||||
const text = el.textContent.trim()
|
||||
|
||||
if (text) {
|
||||
result[docUuid][pageIndex].push({ type: 'static_value', text, x, y, w, h })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
},
|
||||
findPageElement (docUuid, pageIndex) {
|
||||
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${docUuid}-${pageIndex}`)
|
||||
},
|
||||
sortedItemsForPage (docUuid, pageIndex) {
|
||||
const items = []
|
||||
|
||||
const pageTextRuns = this.textRuns[docUuid]?.[pageIndex] || []
|
||||
|
||||
pageTextRuns.forEach((run) => {
|
||||
items.push({
|
||||
type: 'text_run',
|
||||
text: run.text,
|
||||
x: run.x,
|
||||
y: run.y,
|
||||
w: run.w,
|
||||
h: run.h,
|
||||
font_size: run.font_size
|
||||
})
|
||||
})
|
||||
|
||||
const fieldAreas = this.fieldAreasIndex[docUuid]?.[pageIndex] || []
|
||||
items.push(...fieldAreas)
|
||||
|
||||
const readonlyFieldAreas = this.readonlyFieldAreasIndex[docUuid]?.[pageIndex] || []
|
||||
items.push(...readonlyFieldAreas)
|
||||
|
||||
const formulaAreas = this.formulaAreasIndex[docUuid]?.[pageIndex] || []
|
||||
items.push(...formulaAreas)
|
||||
|
||||
const pageFieldValues = this.fieldValuesIndex[docUuid]?.[pageIndex] || []
|
||||
items.push(...pageFieldValues)
|
||||
|
||||
items.sort((a, b) => {
|
||||
const aCenterY = a.y + a.h / 2
|
||||
const bCenterY = b.y + b.h / 2
|
||||
const lineThreshold = Math.min(a.h, b.h) / 2
|
||||
|
||||
if (Math.abs(aCenterY - bCenterY) < lineThreshold) {
|
||||
return a.x - b.x
|
||||
}
|
||||
|
||||
return aCenterY - bCenterY
|
||||
})
|
||||
|
||||
const grouped = []
|
||||
let currentGroup = null
|
||||
|
||||
const closeGroup = () => {
|
||||
if (!currentGroup) return
|
||||
|
||||
const groupItems = currentGroup.items
|
||||
const minX = Math.min(...groupItems.map((i) => i.x))
|
||||
const minY = Math.min(...groupItems.map((i) => i.y))
|
||||
const maxEndX = Math.max(...groupItems.map((i) => i.x + i.w))
|
||||
const maxEndY = Math.max(...groupItems.map((i) => i.y + i.h))
|
||||
|
||||
currentGroup.x = minX
|
||||
currentGroup.y = minY
|
||||
currentGroup.w = maxEndX - minX
|
||||
currentGroup.h = maxEndY - minY
|
||||
currentGroup.text = groupItems.map((i) => i.text).join(' ').replace(/\s+/g, ' ').trim()
|
||||
|
||||
grouped.push(currentGroup)
|
||||
currentGroup = null
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const isTextLike = item.type === 'text_run' || item.type === 'static_value'
|
||||
|
||||
if (!isTextLike) {
|
||||
closeGroup()
|
||||
grouped.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!currentGroup) {
|
||||
currentGroup = { type: 'text_group', items: [] }
|
||||
}
|
||||
|
||||
currentGroup.items.push(item)
|
||||
}
|
||||
|
||||
closeGroup()
|
||||
|
||||
return grouped
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -42,10 +42,25 @@ export default {
|
||||
const areas = {}
|
||||
|
||||
this.field.areas?.forEach((area) => {
|
||||
areas[area.attachment_uuid + area.page] ||= area
|
||||
areas[area.attachment_uuid] ||= []
|
||||
areas[area.attachment_uuid].push(area)
|
||||
})
|
||||
|
||||
return Object.values(areas).slice(0, 6)
|
||||
const sortedAreas = Object.values(areas).reduce((acc, group) => {
|
||||
const seen = {}
|
||||
const sortedGroup = [...group].sort((a, b) => a.page - b.page)
|
||||
|
||||
sortedGroup.forEach((area) => {
|
||||
if (!seen[area.page]) {
|
||||
seen[area.page] = true
|
||||
acc.push(area)
|
||||
}
|
||||
})
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return sortedAreas.slice(0, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex absolute lg:text-base -outline-offset-1 field-area"
|
||||
class="flex absolute lg:text-base -outline-offset-1 focus-visible:outline-blue-500 focus-visible:outline-2 focus-visible:outline field-area"
|
||||
dir="auto"
|
||||
:style="[computedStyle, fontStyle]"
|
||||
:class="{ 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10 field-area-active': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
|
||||
:role="submittable && !isNativeInputField ? 'button' : undefined"
|
||||
:tabindex="submittable && !isNativeInputField ? 0 : undefined"
|
||||
:aria-label="submittable && !isNativeInputField ? fieldAreaLabel : undefined"
|
||||
@keydown.enter.prevent="submittable && !isNativeInputField ? $el.click() : undefined"
|
||||
@keydown.space.prevent="submittable && !isNativeInputField ? $el.click() : undefined"
|
||||
>
|
||||
<div
|
||||
v-if="(!withFieldPlaceholder || !field.name || field.type === 'cells') && !isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
|
||||
@@ -18,6 +23,7 @@
|
||||
width="100%"
|
||||
height="100%"
|
||||
class="max-h-10 text-base-content"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -129,6 +135,7 @@
|
||||
v-if="submittable"
|
||||
type="checkbox"
|
||||
:value="false"
|
||||
:aria-label="field.name || fieldNames[field.type]"
|
||||
class="aspect-square base-checkbox"
|
||||
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
:checked="!!modelValue"
|
||||
@@ -149,6 +156,8 @@
|
||||
v-if="submittable"
|
||||
type="radio"
|
||||
:value="false"
|
||||
:name="`radio-area-${field.uuid}`"
|
||||
:aria-label="optionValue(option)"
|
||||
class="aspect-square checked:checkbox checked:checkbox-xs"
|
||||
:class="{ 'base-radio': !modelValue || modelValue !== optionValue(option), '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
:checked="!!modelValue && modelValue === optionValue(option)"
|
||||
@@ -169,6 +178,7 @@
|
||||
v-if="submittable"
|
||||
type="checkbox"
|
||||
:value="false"
|
||||
:aria-label="optionValue(option)"
|
||||
class="aspect-square base-checkbox"
|
||||
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
:checked="!!modelValue && modelValue.includes(optionValue(option))"
|
||||
@@ -400,6 +410,16 @@ export default {
|
||||
kba: this.t('kba')
|
||||
}
|
||||
},
|
||||
isNativeInputField () {
|
||||
return ['checkbox', 'radio', 'multiple'].includes(this.field.type)
|
||||
},
|
||||
fieldAreaLabel () {
|
||||
const name = this.field.name || this.fieldNames[this.field.type] || this.field.type
|
||||
if (this.area.option_uuid && this.option) {
|
||||
return `${name} - ${this.optionValue(this.option)}`
|
||||
}
|
||||
return name
|
||||
},
|
||||
strikethroughWidth () {
|
||||
if (this.isInlineSize) {
|
||||
return '0.6cqmin'
|
||||
@@ -507,7 +527,8 @@ export default {
|
||||
try {
|
||||
return this.formatDate(
|
||||
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
|
||||
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
|
||||
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'),
|
||||
{ withTimePlaceholders: this.modelValue === '{{date}}', utc: this.modelValue !== '{{date}}' }
|
||||
)
|
||||
} catch {
|
||||
return this.modelValue
|
||||
@@ -622,40 +643,63 @@ export default {
|
||||
return new Intl.NumberFormat('de-DE').format(number)
|
||||
} else if (format === 'space') {
|
||||
return new Intl.NumberFormat('fr-FR').format(number)
|
||||
} else if (format === 'percent') {
|
||||
return `${number}%`
|
||||
} else if (format === 'percent_space') {
|
||||
return `${String(number).replace('.', ',')} %`
|
||||
} else {
|
||||
return number
|
||||
}
|
||||
},
|
||||
formatDate (date, format) {
|
||||
const monthFormats = {
|
||||
M: 'numeric',
|
||||
MM: '2-digit',
|
||||
MMM: 'short',
|
||||
MMMM: 'long'
|
||||
}
|
||||
formatDate (date, format, { withTimePlaceholders = false, utc = true } = {}) {
|
||||
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
|
||||
const dayFormats = { D: 'numeric', DD: '2-digit' }
|
||||
const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
|
||||
const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' }
|
||||
const minuteFormats = { m: 'numeric', mm: '2-digit' }
|
||||
const secondFormats = { s: 'numeric', ss: '2-digit' }
|
||||
|
||||
const dayFormats = {
|
||||
D: 'numeric',
|
||||
DD: '2-digit'
|
||||
}
|
||||
const hasTime = /[HhAasz]/.test(format)
|
||||
|
||||
const yearFormats = {
|
||||
YYYY: 'numeric',
|
||||
YYY: 'numeric',
|
||||
YY: '2-digit'
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat([], {
|
||||
const opts = {
|
||||
day: dayFormats[format.match(/D+/)],
|
||||
month: monthFormats[format.match(/M+/)],
|
||||
year: yearFormats[format.match(/Y+/)],
|
||||
timeZone: 'UTC'
|
||||
}).formatToParts(date)
|
||||
year: yearFormats[format.match(/Y+/)]
|
||||
}
|
||||
|
||||
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)
|
||||
if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false }
|
||||
if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true }
|
||||
if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true
|
||||
if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
|
||||
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
|
||||
if (/z/.test(format)) opts.timeZoneName = 'short'
|
||||
if (!hasTime && utc) opts.timeZone = 'UTC'
|
||||
|
||||
const partTypes = {
|
||||
M: 'month',
|
||||
D: 'day',
|
||||
Y: 'year',
|
||||
H: 'hour',
|
||||
h: 'hour',
|
||||
m: 'minute',
|
||||
s: 'second',
|
||||
z: 'timeZoneName',
|
||||
A: 'dayPeriod',
|
||||
a: 'dayPeriod'
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
|
||||
|
||||
return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => {
|
||||
if (withTimePlaceholders && /^(HH|hh|H|h|mm|m|ss|s|A|a)$/.test(token)) return '--'
|
||||
|
||||
const value = parts.find((p) => p.type === partTypes[token[0]])?.value
|
||||
|
||||
if (token === 'A') return (value || '').toUpperCase()
|
||||
if (token === 'a') return (value || '').toLowerCase()
|
||||
|
||||
return value
|
||||
})
|
||||
},
|
||||
updateMultipleSelectValue (value) {
|
||||
if (this.modelValue?.includes(value)) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="value.length">
|
||||
<div
|
||||
<ul
|
||||
v-if="value.length"
|
||||
:aria-label="t('uploaded_files')"
|
||||
class="list-none p-0 m-0"
|
||||
>
|
||||
<li
|
||||
v-for="(val, index) in value"
|
||||
:key="index"
|
||||
class="flex mb-2"
|
||||
@@ -20,23 +24,27 @@
|
||||
<IconPaperclip
|
||||
:width="16"
|
||||
class="flex-none"
|
||||
:heigh="16"
|
||||
:height="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{{ attachmentsIndex[val].filename }}
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-attachment-button"
|
||||
:aria-label="`${t('clear')} ${attachmentsIndex[val].filename}`"
|
||||
@click.prevent="removeAttachment(val)"
|
||||
>
|
||||
<IconTrashX
|
||||
:width="18"
|
||||
:heigh="19"
|
||||
:height="19"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<template v-else>
|
||||
<input
|
||||
value=""
|
||||
|
||||
@@ -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) }
|
||||
@@ -3,10 +3,13 @@
|
||||
id="form_completed"
|
||||
class="mx-auto max-w-md flex flex-col completed-form"
|
||||
dir="auto"
|
||||
role="status"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="font-medium text-2xl flex items-center space-x-1.5 mx-auto">
|
||||
<IconCircleCheck
|
||||
class="inline text-green-600"
|
||||
aria-hidden="true"
|
||||
:width="30"
|
||||
:height="30"
|
||||
/>
|
||||
@@ -42,8 +45,12 @@
|
||||
<IconInnerShadowTop
|
||||
v-if="isSendingCopy"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconMail
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconMail v-else />
|
||||
<span>
|
||||
{{ t('send_copy_via_email') }}
|
||||
</span>
|
||||
@@ -57,8 +64,12 @@
|
||||
<IconInnerShadowTop
|
||||
v-if="isDownloading"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconDownload
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconDownload v-else />
|
||||
<span>
|
||||
{{ t('download') }}
|
||||
</span>
|
||||
@@ -199,7 +210,7 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
document.querySelectorAll('#decline_button').forEach((button) => {
|
||||
document.querySelectorAll('#decline_button, #decline_button_mobile, #delegate_button, #delegate_button_mobile').forEach((button) => {
|
||||
button.setAttribute('disabled', 'true')
|
||||
})
|
||||
},
|
||||
|
||||
@@ -27,15 +27,20 @@
|
||||
</label>
|
||||
<button
|
||||
v-if="withToday"
|
||||
type="button"
|
||||
class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button"
|
||||
@click.prevent="[setCurrentDate(), $emit('focus')]"
|
||||
>
|
||||
<IconCalendarCheck :width="16" />
|
||||
<IconCalendarCheck
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('set_today') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="field.description"
|
||||
:id="field.uuid + '-desc'"
|
||||
class="mb-3 px-1 field-description-text"
|
||||
dir="auto"
|
||||
>
|
||||
@@ -51,12 +56,19 @@
|
||||
:max="validationMax"
|
||||
class="base-input !text-2xl text-center w-full"
|
||||
:required="field.required"
|
||||
type="date"
|
||||
:name="`values[${field.uuid}]`"
|
||||
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
|
||||
:type="inputType"
|
||||
:name="formatType === 'datetime' ? undefined : `values[${field.uuid}]`"
|
||||
@keydown.enter="onEnter"
|
||||
@focus="$emit('focus')"
|
||||
@paste="onPaste"
|
||||
>
|
||||
<input
|
||||
v-if="formatType === 'datetime'"
|
||||
type="hidden"
|
||||
:name="`values[${field.uuid}]`"
|
||||
:value="modelValue"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -92,14 +104,19 @@ export default {
|
||||
},
|
||||
emits: ['update:model-value', 'focus', 'submit'],
|
||||
computed: {
|
||||
formatType () {
|
||||
const format = this.field.preferences?.format || ''
|
||||
|
||||
if (/[HhAasz]/.test(format)) return 'datetime'
|
||||
if (format && !/[Dd]/.test(format)) return 'month'
|
||||
|
||||
return 'date'
|
||||
},
|
||||
inputType () {
|
||||
return { datetime: 'datetime-local', month: 'month', date: 'date' }[this.formatType]
|
||||
},
|
||||
dateNowString () {
|
||||
const today = new Date()
|
||||
|
||||
const yyyy = today.getFullYear()
|
||||
const mm = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(today.getDate()).padStart(2, '0')
|
||||
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
return this.formatDateValue(new Date())
|
||||
},
|
||||
validationMin () {
|
||||
if (this.field.validation?.min) {
|
||||
@@ -116,6 +133,8 @@ export default {
|
||||
}
|
||||
},
|
||||
withToday () {
|
||||
if (this.formatType === 'datetime') return false
|
||||
|
||||
const todayDate = new Date().setHours(0, 0, 0, 0)
|
||||
|
||||
if (this.validationMin) {
|
||||
@@ -132,9 +151,25 @@ export default {
|
||||
},
|
||||
value: {
|
||||
set (value) {
|
||||
if (this.formatType === 'datetime' && value) {
|
||||
const d = new Date(value)
|
||||
|
||||
if (!isNaN(d)) {
|
||||
this.$emit('update:model-value', d.toISOString())
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.$emit('update:model-value', value)
|
||||
},
|
||||
get () {
|
||||
if (this.formatType === 'datetime') {
|
||||
const d = new Date(this.modelValue)
|
||||
|
||||
return isNaN(d) ? '' : this.formatDateValue(d)
|
||||
}
|
||||
|
||||
return this.modelValue
|
||||
}
|
||||
}
|
||||
@@ -158,20 +193,32 @@ export default {
|
||||
|
||||
const parsedDate = new Date(pasteData)
|
||||
|
||||
if (!isNaN(parsedDate)) {
|
||||
const inputEl = this.$refs.input
|
||||
if (isNaN(parsedDate)) return
|
||||
|
||||
inputEl.valueAsDate = new Date(parsedDate.getTime() - parsedDate.getTimezoneOffset() * 60000)
|
||||
|
||||
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
this.setInputValue(parsedDate)
|
||||
},
|
||||
setCurrentDate () {
|
||||
this.setInputValue(new Date())
|
||||
},
|
||||
setInputValue (date) {
|
||||
const inputEl = this.$refs.input
|
||||
|
||||
inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000)
|
||||
if (this.formatType === 'date') {
|
||||
inputEl.valueAsDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
|
||||
} else {
|
||||
inputEl.value = this.formatDateValue(date)
|
||||
}
|
||||
|
||||
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
},
|
||||
formatDateValue (date) {
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
|
||||
if (this.formatType === 'month') return ymd.slice(0, 7)
|
||||
if (this.formatType === 'datetime') return `${ymd}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
|
||||
return ymd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,9 @@
|
||||
ref="input"
|
||||
:multiple="multiple"
|
||||
:accept="accept"
|
||||
:aria-label="message"
|
||||
type="file"
|
||||
class="hidden"
|
||||
class="sr-only"
|
||||
@change="onSelectFiles"
|
||||
>
|
||||
</label>
|
||||
@@ -50,6 +51,36 @@
|
||||
<script>
|
||||
import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
|
||||
function convertImage (sourceFile, targetType, quality) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = function (event) {
|
||||
const img = new Image()
|
||||
|
||||
img.onload = function () {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
canvas.width = img.width
|
||||
canvas.height = img.height
|
||||
ctx.drawImage(img, 0, 0)
|
||||
canvas.toBlob(function (blob) {
|
||||
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
|
||||
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
|
||||
resolve(newFile)
|
||||
}, targetType, quality)
|
||||
}
|
||||
|
||||
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
|
||||
|
||||
img.src = event.target.result
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(sourceFile)
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'FileDropzone',
|
||||
components: {
|
||||
@@ -152,8 +183,14 @@ export default {
|
||||
}
|
||||
})
|
||||
} else {
|
||||
if (file.type === 'image/bmp' || file.type === 'image/vnd.microsoft.icon') {
|
||||
file = await this.convertBmpToPng(file)
|
||||
try {
|
||||
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
|
||||
file = await convertImage(file, 'image/png')
|
||||
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
|
||||
file = await convertImage(file, 'image/jpeg', 0.9)
|
||||
}
|
||||
} catch (e) {
|
||||
alert(e.message)
|
||||
}
|
||||
|
||||
formData.append('file', file)
|
||||
@@ -180,32 +217,6 @@ export default {
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
},
|
||||
convertBmpToPng (bmpFile) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = function (event) {
|
||||
const img = new Image()
|
||||
|
||||
img.onload = function () {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
canvas.width = img.width
|
||||
canvas.height = img.height
|
||||
ctx.drawImage(img, 0, 0)
|
||||
canvas.toBlob(function (blob) {
|
||||
const newFile = new File([blob], bmpFile.name.replace(/\.\w+$/, '.png'), { type: 'image/png' })
|
||||
resolve(newFile)
|
||||
}, 'image/png')
|
||||
}
|
||||
|
||||
img.src = event.target.result
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(bmpFile)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,52 @@
|
||||
<template>
|
||||
<Teleport
|
||||
v-if="withAccessibilityAreas === null && !isAccessibilityMode"
|
||||
to="#sr_only_content"
|
||||
>
|
||||
<button
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-base-100 focus:text-base-content focus:rounded focus:shadow-lg"
|
||||
@click="isAccessibilityMode = true"
|
||||
>
|
||||
{{ t('enter_screen_reader_mode') }}
|
||||
</button>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-for="item in (withAccessibilityAreas === null ? schema : [])"
|
||||
:key="item.attachment_uuid"
|
||||
:to="`#document-${item.attachment_uuid} .sr_only_content`"
|
||||
>
|
||||
<button
|
||||
v-if="!isAccessibilityMode"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-base-100 focus:text-base-content focus:rounded focus:shadow-lg"
|
||||
@click="isAccessibilityMode = true"
|
||||
>
|
||||
{{ t('enter_screen_reader_mode') }}
|
||||
</button>
|
||||
</Teleport>
|
||||
<AccessibilityAreas
|
||||
v-if="withAccessibilityAreas || isAccessibilityMode"
|
||||
ref="areas"
|
||||
:submitter-slug="submitterSlug"
|
||||
:steps="stepFields"
|
||||
:readonly-conditional-fields="readonlyConditionalFields"
|
||||
:readonly-conditional-field-values="readonlyConditionalFieldValues"
|
||||
:formula-fields="formulaFields"
|
||||
:values="values"
|
||||
:readonly-values="readonlyFieldValues"
|
||||
:submitter="submitter"
|
||||
:scroll-el="scrollEl"
|
||||
:current-step="currentStepFields"
|
||||
:with-field-placeholder="withFieldPlaceholder"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-label="withFieldLabels && !isAnonymousChecboxes && showFieldNames"
|
||||
:scroll-padding="scrollPadding"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:fetch-options="fetchOptions"
|
||||
:filled-fields-index="filledFieldsIndex"
|
||||
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
|
||||
/>
|
||||
<FieldAreas
|
||||
v-if="!withAccessibilityAreas && !isAccessibilityMode"
|
||||
ref="areas"
|
||||
:steps="stepFields"
|
||||
:values="values"
|
||||
@@ -14,6 +61,7 @@
|
||||
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
|
||||
/>
|
||||
<FieldAreas
|
||||
v-if="!withAccessibilityAreas && !isAccessibilityMode"
|
||||
:steps="readonlyConditionalFields.map((e) => [e])"
|
||||
:values="readonlyConditionalFieldValues"
|
||||
:submitter="submitter"
|
||||
@@ -21,7 +69,7 @@
|
||||
:submittable="false"
|
||||
/>
|
||||
<FormulaFieldAreas
|
||||
v-if="formulaFields.length"
|
||||
v-if="!withAccessibilityAreas && !isAccessibilityMode && formulaFields.length"
|
||||
:fields="formulaFields"
|
||||
:readonly-values="readonlyFieldValues"
|
||||
:values="values"
|
||||
@@ -47,7 +95,7 @@
|
||||
v-else
|
||||
id="complete_form_button"
|
||||
class="btn btn-sm btn-neutral text-white px-4 w-full flex justify-center"
|
||||
form="steps_form"
|
||||
form="complete_form"
|
||||
type="submit"
|
||||
name="completed"
|
||||
value="true"
|
||||
@@ -57,6 +105,7 @@
|
||||
<IconInnerShadowTop
|
||||
v-if="isSubmittingComplete"
|
||||
class="mr-1 animate-spin w-5 h-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{{ t('complete') }}
|
||||
@@ -64,8 +113,41 @@
|
||||
</span>
|
||||
</button>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-for="ref in (showCompleteButton ? [completeButtonContainer, completeButtonScrollContainer].filter(Boolean) : [])"
|
||||
:key="ref"
|
||||
:to="ref"
|
||||
>
|
||||
<button
|
||||
class="complete-button btn btn-sm btn-neutral text-white px-4"
|
||||
form="complete_form"
|
||||
type="submit"
|
||||
name="completed"
|
||||
value="true"
|
||||
:disabled="isSubmittingComplete"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<IconInnerShadowTop
|
||||
v-if="isSubmittingComplete"
|
||||
class="mr-1 animate-spin w-5 h-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{{ t('complete') }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</Teleport>
|
||||
<form
|
||||
v-if="!isCompleted && !isInvite"
|
||||
id="complete_form"
|
||||
class="hidden"
|
||||
:action="submitPath"
|
||||
method="post"
|
||||
@submit.prevent="submitStep"
|
||||
/>
|
||||
<button
|
||||
v-if="!isFormVisible"
|
||||
v-if="!isFormVisible && currentField"
|
||||
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%"
|
||||
@@ -88,9 +170,11 @@
|
||||
class="absolute right-0 mr-4"
|
||||
:width="20"
|
||||
:height="20"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="currentField"
|
||||
v-show="isFormVisible"
|
||||
id="form_container"
|
||||
class="shadow-md bg-base-100 absolute bottom-0 w-full border-base-200 border p-4 rounded form-container overflow-hidden"
|
||||
@@ -103,11 +187,13 @@
|
||||
class="absolute right-0 top-0 minimize-form-button"
|
||||
:class="currentField?.description?.length > 100 ? 'mr-1 mt-1 md:mr-2 md:mt-2': 'mr-2 mt-2 hidden md:block'"
|
||||
:title="t('minimize')"
|
||||
:aria-label="t('minimize')"
|
||||
@click.prevent="minimizeForm"
|
||||
>
|
||||
<IconArrowsDiagonalMinimize2
|
||||
:width="20"
|
||||
:height="20"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
@@ -193,6 +279,7 @@
|
||||
/>
|
||||
<div
|
||||
v-if="currentField.description"
|
||||
:id="currentField.uuid + '-desc'"
|
||||
dir="auto"
|
||||
class="mb-3 px-1 field-description-text"
|
||||
>
|
||||
@@ -203,6 +290,8 @@
|
||||
:id="currentField.uuid"
|
||||
dir="auto"
|
||||
:required="currentField.required"
|
||||
:aria-label="showFieldNames && (currentField.name || currentField.title) ? undefined : (currentField.name || currentField.title || t('select_your_option'))"
|
||||
:aria-describedby="currentField.description ? currentField.uuid + '-desc' : undefined"
|
||||
class="select base-input !text-2xl w-full text-center font-normal"
|
||||
:class="{ 'text-gray-300': !values[currentField.uuid] }"
|
||||
:name="`values[${currentField.uuid}]`"
|
||||
@@ -230,7 +319,7 @@
|
||||
<div v-else-if="currentField.type === 'radio'">
|
||||
<label
|
||||
v-if="showFieldNames && (currentField.name || currentField.title)"
|
||||
:for="currentField.uuid"
|
||||
:id="currentField.uuid + '-radio-label'"
|
||||
dir="auto"
|
||||
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
|
||||
:class="{ 'mb-2': !currentField.description }"
|
||||
@@ -250,6 +339,7 @@
|
||||
</label>
|
||||
<div
|
||||
v-if="currentField.description"
|
||||
:id="currentField.uuid + '-desc'"
|
||||
dir="auto"
|
||||
class="mb-3 px-1 field-description-text"
|
||||
>
|
||||
@@ -268,6 +358,8 @@
|
||||
</div>
|
||||
<div
|
||||
class="space-y-3.5 mx-auto"
|
||||
role="radiogroup"
|
||||
:aria-labelledby="(currentField.name || currentField.title) ? currentField.uuid + '-radio-label' : null"
|
||||
:class="{ hidden: !showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4) }"
|
||||
>
|
||||
<div
|
||||
@@ -309,6 +401,7 @@
|
||||
>
|
||||
<div
|
||||
v-if="currentField.description"
|
||||
:id="currentField.uuid + '-desc'"
|
||||
dir="auto"
|
||||
class="mb-3 px-1 field-description-text"
|
||||
>
|
||||
@@ -402,6 +495,8 @@
|
||||
:remember-signature="rememberSignature"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:require-signing-reason="requireSigningReason"
|
||||
:signature-text="signatureText"
|
||||
:signature-src="signatureSrc"
|
||||
:button-text="submitButtonText"
|
||||
:dry-run="dryRun"
|
||||
:with-disclosure="withDisclosure"
|
||||
@@ -509,6 +604,7 @@
|
||||
<IconInnerShadowTop
|
||||
v-if="isSubmitting"
|
||||
class="mr-1 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{{ submitButtonText }}
|
||||
@@ -520,6 +616,7 @@
|
||||
</button>
|
||||
<div
|
||||
v-if="showFillAllRequiredFields"
|
||||
role="alert"
|
||||
class="text-center mt-1"
|
||||
>
|
||||
{{ t('please_fill_all_required_fields') }}
|
||||
@@ -552,8 +649,10 @@
|
||||
:can-send-email="canSendEmail && !!submitter.email"
|
||||
:submitter-slug="submitterSlug"
|
||||
/>
|
||||
<div
|
||||
<nav
|
||||
v-if="stepFields.length < 80"
|
||||
:aria-label="t('form_progress')"
|
||||
:aria-hidden="isCompleted"
|
||||
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">
|
||||
@@ -561,16 +660,18 @@
|
||||
v-for="(step, index) in stepFields"
|
||||
:key="step[0].uuid"
|
||||
>
|
||||
<a
|
||||
<button
|
||||
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"
|
||||
type="button"
|
||||
:aria-label="`${t('step')} ${index + 1}`"
|
||||
:aria-current="index === currentStep ? 'step' : undefined"
|
||||
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1 p-0"
|
||||
: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)]"
|
||||
@click="isCompleted ? '' : [saveStep(), goToStep(index, true)]"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div
|
||||
v-else
|
||||
class="mt-5"
|
||||
@@ -582,6 +683,7 @@
|
||||
<script>
|
||||
import FieldAreas from './areas'
|
||||
import FormulaFieldAreas from './formula_areas'
|
||||
import AccessibilityAreas from './accessibility_areas'
|
||||
import ImageStep from './image_step'
|
||||
import SignatureStep from './signature_step'
|
||||
import InitialsStep from './initials_step'
|
||||
@@ -640,6 +742,7 @@ export default {
|
||||
name: 'SubmissionForm',
|
||||
components: {
|
||||
FieldAreas,
|
||||
AccessibilityAreas,
|
||||
ImageStep,
|
||||
SignatureStep,
|
||||
AppearsOn,
|
||||
@@ -725,6 +828,16 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
completeButtonContainer: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
completeButtonScrollContainer: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
schema: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -822,6 +935,16 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
filledFieldsIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withAccessibilityAreas: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -832,6 +955,16 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
signatureText: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
signatureSrc: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
previousSignatureValue: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -922,7 +1055,9 @@ export default {
|
||||
isSubmitting: false,
|
||||
isSubmittingComplete: false,
|
||||
submittedValues: {},
|
||||
recalculateButtonDisabledKey: ''
|
||||
isFormStarted: false,
|
||||
recalculateButtonDisabledKey: '',
|
||||
isAccessibilityMode: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -969,6 +1104,10 @@ export default {
|
||||
})
|
||||
})
|
||||
},
|
||||
showCompleteButton () {
|
||||
return this.completeButtonContainer && !this.isCompleted && !this.isInvite && this.isFormStarted &&
|
||||
!this.stepFields.find((fields) => fields.some((f) => f.required && isEmpty(this.submittedValues[f.uuid])))
|
||||
},
|
||||
submitButtonText () {
|
||||
if (this.alwaysMinimize) {
|
||||
return this.t('submit')
|
||||
@@ -1034,7 +1173,11 @@ export default {
|
||||
}
|
||||
},
|
||||
isAnonymousChecboxes () {
|
||||
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4
|
||||
if (this.currentField) {
|
||||
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
isButtonDisabled () {
|
||||
if (this.recalculateButtonDisabledKey) {
|
||||
@@ -1432,6 +1575,7 @@ export default {
|
||||
}
|
||||
},
|
||||
goToStep (stepIndex, scrollToArea = false, clickUpload = false) {
|
||||
this.isInvite = false
|
||||
this.currentStep = stepIndex
|
||||
this.showFillAllRequiredFields = false
|
||||
|
||||
@@ -1446,7 +1590,7 @@ export default {
|
||||
}
|
||||
|
||||
this.enableScrollIntoField = false
|
||||
this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], select')?.focus()
|
||||
this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], input[type="tel"], textarea, select')?.focus()
|
||||
this.enableScrollIntoField = true
|
||||
|
||||
if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) {
|
||||
@@ -1456,6 +1600,10 @@ export default {
|
||||
})
|
||||
},
|
||||
saveStep (formData) {
|
||||
if (!formData && !this.$refs.form) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentFieldUuids = this.currentStepFields.map((f) => f.uuid)
|
||||
const currentFieldType = this.currentField.type
|
||||
|
||||
@@ -1570,6 +1718,8 @@ export default {
|
||||
return Promise.reject(new Error(data.error))
|
||||
}
|
||||
|
||||
this.isFormStarted = true
|
||||
|
||||
const nextStep = (isLastStep && emptyRequiredField) || (forceComplete ? null : this.findNextStep(submitStepIndex))
|
||||
|
||||
if (nextStep) {
|
||||
@@ -1606,6 +1756,7 @@ export default {
|
||||
},
|
||||
async performComplete (resp) {
|
||||
this.isCompleted = true
|
||||
this.isFormVisible = true
|
||||
|
||||
if (resp?.text) {
|
||||
const respData = await resp.text()
|
||||
@@ -1617,6 +1768,15 @@ export default {
|
||||
|
||||
if (this.completedRedirectUrl) {
|
||||
window.location.href = sanitizeUrl(this.completedRedirectUrl)
|
||||
} else {
|
||||
this.$nextTick(() => {
|
||||
const root = this.$root.$el.parentNode.getRootNode()
|
||||
const completedEl = root.getElementById('form_completed')
|
||||
|
||||
if (completedEl) {
|
||||
completedEl.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user