mirror of
https://github.com/docusealco/docuseal.git
synced 2026-08-07 07:14:43 +00:00
Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2d8b85549 | |||
| 4d68783b82 | |||
| b05c3b2030 | |||
| 784ae9aa4b | |||
| 03b5956cbc | |||
| 2876c9a18e | |||
| c4b7be3925 | |||
| e2ffbdac1d | |||
| 0d197bb2eb | |||
| 55089bee7f | |||
| b96fe74db1 | |||
| 2fc1ba3037 | |||
| c67d37207e | |||
| dba50cd1bf | |||
| edf2c0d9ed | |||
| 35f7c36f28 | |||
| 3a24ecac63 | |||
| 50ec7682b5 | |||
| 3667993caf | |||
| 68e8cd1188 | |||
| 3eebf6d2cf | |||
| 34d0e00910 | |||
| b8ab7190d2 | |||
| 59fe8a7917 | |||
| d56070827b | |||
| 74dbd4bed3 | |||
| 807e82b21e | |||
| 66862e93c8 | |||
| 270461ad3b | |||
| 3edd1ab32d | |||
| e77998e53c | |||
| 1421bc2bce | |||
| cbbbfdda80 | |||
| 2d510081be | |||
| 704893ee1d | |||
| 88612913a6 | |||
| ce6823ed38 | |||
| 9074d23b3c | |||
| 76d0e76fd5 | |||
| 5ec8e4ccc7 | |||
| b8c29324b8 | |||
| 972f38242e | |||
| 7ce6c29f4d | |||
| 2c6ddd7f90 | |||
| ae50b2e323 | |||
| 152cbc11a6 | |||
| 3a406de655 | |||
| 2e31cda08c | |||
| 094b7f47bd | |||
| 99ac349ecc | |||
| 8830e03cf3 | |||
| 706f3d6d65 | |||
| 12b49f5fa7 | |||
| 85327760b9 | |||
| c94ed9c3d4 | |||
| bc50735d2a | |||
| d05e125fa9 | |||
| 48d595ca81 | |||
| a156de5e75 | |||
| a7003bf4d0 | |||
| 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 |
@@ -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
|
||||
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
bundle install --jobs 4 --retry 4
|
||||
yarn install
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libvips
|
||||
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
|
||||
|
||||
@@ -30,7 +30,9 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create .version file
|
||||
run: echo ${{ github.ref_name }} > .version
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: echo "$REF_NAME" > .version
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
|
||||
+9
-5
@@ -2,16 +2,20 @@ FROM ruby:4.0.5-alpine AS download
|
||||
|
||||
WORKDIR /fonts
|
||||
|
||||
RUN apk --no-cache add 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://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \
|
||||
wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \
|
||||
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
|
||||
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-musl-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
|
||||
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
|
||||
|
||||
FROM ruby:4.0.5-alpine AS webpack
|
||||
|
||||
@@ -48,7 +52,7 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache libpq vips redis onnxruntime && \
|
||||
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
|
||||
@@ -82,7 +86,7 @@ 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/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
|
||||
|
||||
|
||||
+91
-90
@@ -1,31 +1,31 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
action_text-trix (2.1.18)
|
||||
action_text-trix (2.1.19)
|
||||
railties
|
||||
actioncable (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actioncable (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionmailbox (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionmailer (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionpack (8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
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.3)
|
||||
actiontext (8.1.3.1)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionpack (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionview (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activejob (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activerecord (8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activemodel (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
activerecord (8.1.3.1)
|
||||
activemodel (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activestorage (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.3)
|
||||
activesupport (8.1.3.1)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
@@ -144,12 +144,12 @@ 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.8)
|
||||
connection_pool (3.0.2)
|
||||
crack (1.0.1)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
crass (1.0.7)
|
||||
csv (3.3.5)
|
||||
csv-safe (3.3.1)
|
||||
csv (~> 3.0)
|
||||
@@ -179,7 +179,7 @@ GEM
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
email_typo (0.2.3)
|
||||
erb (6.0.4)
|
||||
erb (6.0.6)
|
||||
erb_lint (0.9.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
@@ -195,13 +195,13 @@ GEM
|
||||
railties (>= 6.1.0)
|
||||
faker (3.6.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.2)
|
||||
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.2)
|
||||
addressable (~> 2.5)
|
||||
@@ -217,7 +217,7 @@ GEM
|
||||
foreman (0.90.0)
|
||||
thor (~> 1.4)
|
||||
geom2d (0.4.1)
|
||||
globalid (1.3.0)
|
||||
globalid (1.4.0)
|
||||
activesupport (>= 6.1)
|
||||
google-apis-core (1.0.2)
|
||||
addressable (~> 2.8, >= 2.8.7)
|
||||
@@ -262,7 +262,7 @@ GEM
|
||||
geom2d (~> 0.4, >= 0.4.1)
|
||||
openssl (>= 2.2.1)
|
||||
strscan (>= 3.1.2)
|
||||
i18n (1.14.8)
|
||||
i18n (1.15.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
io-console (0.8.2)
|
||||
irb (1.18.0)
|
||||
@@ -271,7 +271,7 @@ GEM
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.19.8)
|
||||
json (2.21.1)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
language_server-protocol (3.17.0.5)
|
||||
@@ -293,27 +293,27 @@ GEM
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.25.1)
|
||||
loofah (2.25.2)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.9.0)
|
||||
mail (2.9.1)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.1.0)
|
||||
marcel (1.2.1)
|
||||
matrix (0.4.3)
|
||||
method_source (1.1.0)
|
||||
mini_mime (1.1.5)
|
||||
minitest (6.0.6)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
msgpack (1.8.4)
|
||||
multi_json (1.19.1)
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-imap (0.6.4)
|
||||
net-imap (0.6.6)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -323,15 +323,15 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.3-aarch64-linux-gnu)
|
||||
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-aarch64-linux-musl)
|
||||
nokogiri (1.19.4-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-arm64-darwin)
|
||||
nokogiri (1.19.4-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-linux-musl)
|
||||
nokogiri (1.19.4-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
numo-narray-alt (0.10.3)
|
||||
onnxruntime (0.10.1-aarch64-linux)
|
||||
@@ -344,7 +344,7 @@ GEM
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
package_json (0.2.0)
|
||||
pagy (43.4.4)
|
||||
pagy (43.6.1)
|
||||
json
|
||||
uri
|
||||
yaml
|
||||
@@ -357,7 +357,7 @@ GEM
|
||||
pg (1.6.3-arm64-darwin)
|
||||
pg (1.6.3-x86_64-linux)
|
||||
pg (1.6.3-x86_64-linux-musl)
|
||||
pp (0.6.3)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
pretender (1.0.0)
|
||||
actionpack (>= 7.2)
|
||||
@@ -369,11 +369,8 @@ GEM
|
||||
reline (>= 0.6.0)
|
||||
pry-rails (0.3.11)
|
||||
pry (>= 0.13.0)
|
||||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
public_suffix (7.0.5)
|
||||
puma (7.2.0)
|
||||
puma (8.0.2)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.6)
|
||||
@@ -386,33 +383,33 @@ GEM
|
||||
rack (>= 1.3)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
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)
|
||||
rails (8.1.3.1)
|
||||
actioncable (= 8.1.3.1)
|
||||
actionmailbox (= 8.1.3.1)
|
||||
actionmailer (= 8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
actiontext (= 8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activemodel (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.3)
|
||||
railties (= 8.1.3.1)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.7.0)
|
||||
loofah (~> 2.25)
|
||||
rails-html-sanitizer (1.7.1)
|
||||
loofah (~> 2.25, >= 2.25.2)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (8.1.0)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 8.0.0, < 9)
|
||||
railties (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
railties (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -421,9 +418,14 @@ GEM
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.4.2)
|
||||
rdoc (7.2.0)
|
||||
rbs (4.1.0)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.0.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
tsort
|
||||
redis-client (0.29.0)
|
||||
connection_pool
|
||||
@@ -525,12 +527,11 @@ GEM
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
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)
|
||||
sqlite3 (2.9.5-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.5-aarch64-linux-musl)
|
||||
sqlite3 (2.9.5-arm64-darwin)
|
||||
sqlite3 (2.9.5-x86_64-linux-gnu)
|
||||
sqlite3 (2.9.5-x86_64-linux-musl)
|
||||
strip_attributes (2.0.1)
|
||||
activemodel (>= 3.0, < 9.0)
|
||||
strscan (3.1.8)
|
||||
@@ -570,14 +571,14 @@ GEM
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.2)
|
||||
websocket-driver (0.8.0)
|
||||
websocket-driver (0.8.2)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
yaml (0.4.0)
|
||||
zeitwerk (2.7.5)
|
||||
zeitwerk (2.8.2)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
|
||||
@@ -37,7 +37,7 @@ class AccountConfigsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
raise InvalidKey unless allowed_keys.include?(@account_config.key)
|
||||
raise InvalidKey unless allowed_destroy_keys.include?(@account_config.key)
|
||||
|
||||
@account_config.destroy!
|
||||
|
||||
@@ -50,6 +50,10 @@ class AccountConfigsController < ApplicationController
|
||||
ALLOWED_KEYS
|
||||
end
|
||||
|
||||
def allowed_destroy_keys
|
||||
ALLOWED_KEYS
|
||||
end
|
||||
|
||||
def load_account_config
|
||||
raise InvalidKey unless allowed_keys.include?(account_config_params[:key])
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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,14 +38,6 @@ 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']
|
||||
|
||||
@@ -73,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
|
||||
@@ -104,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
|
||||
@@ -177,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)
|
||||
|
||||
@@ -5,6 +5,8 @@ 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))
|
||||
@@ -48,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
|
||||
@@ -112,7 +117,13 @@ module Api
|
||||
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
|
||||
|
||||
@@ -14,8 +14,7 @@ class EsignSettingsController < ApplicationController
|
||||
prepend_before_action :maybe_redirect_com, only: %i[show]
|
||||
|
||||
before_action :load_encrypted_config
|
||||
authorize_resource :encrypted_config, parent: false, only: %i[new create]
|
||||
authorize_resource :encrypted_config, only: %i[update destroy show]
|
||||
authorize_resource :encrypted_config, parent: false
|
||||
|
||||
def show
|
||||
cert_data = @encrypted_config.value || {}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
class CreateTemplateController < McpBaseController
|
||||
SCHEMA = {
|
||||
name: 'create_template',
|
||||
title: 'Create Template',
|
||||
description: 'Create a document template. Provide a URL to upload a PDF/DOCX file, or provide only a name ' \
|
||||
'to create an empty template and receive an edit URL where the file can be uploaded via the UI.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Template name (used as the template name and required when url is not provided)'
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'Optional URL of a PDF or DOCX file to upload. If omitted, an empty template is ' \
|
||||
'created and the returned edit_url can be used to upload a file via the UI.'
|
||||
}
|
||||
},
|
||||
required: %w[name]
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true
|
||||
}
|
||||
}.freeze
|
||||
|
||||
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
||||
def call
|
||||
account = current_user.account
|
||||
|
||||
@template = Template.new(
|
||||
account:,
|
||||
author: current_user,
|
||||
folder: account.default_template_folder,
|
||||
source: :mcp,
|
||||
name: mcp_params['name'].to_s.presence || 'New Template',
|
||||
fields: [],
|
||||
schema: []
|
||||
)
|
||||
|
||||
authorize!(:create, @template)
|
||||
|
||||
if mcp_params['url'].present?
|
||||
tempfile = Tempfile.new
|
||||
tempfile.binmode
|
||||
tempfile.write(DownloadUtils.call(mcp_params['url'], validate: true).body)
|
||||
tempfile.rewind
|
||||
|
||||
filename = File.basename(URI.decode_www_form_component(mcp_params['url']))
|
||||
|
||||
file = ActionDispatch::Http::UploadedFile.new(
|
||||
tempfile:,
|
||||
filename:,
|
||||
type: Marcel::MimeType.for(tempfile)
|
||||
)
|
||||
|
||||
@template.name = mcp_params['name'].presence || File.basename(filename, '.*')
|
||||
@template.save!
|
||||
|
||||
documents, = Templates::CreateAttachments.call(@template, { files: [file] }, extract_fields: true)
|
||||
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
|
||||
|
||||
if @template.fields.blank?
|
||||
@template.fields = Templates::ProcessDocument.normalize_attachment_fields(@template, documents)
|
||||
end
|
||||
|
||||
@template.update!(schema:)
|
||||
else
|
||||
@template.save!
|
||||
end
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.created')
|
||||
|
||||
SearchEntries.enqueue_reindex(@template)
|
||||
|
||||
render_tool_result(
|
||||
id: @template.id,
|
||||
name: @template.name,
|
||||
edit_url: edit_template_url(@template)
|
||||
)
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
class LoadTemplateController < McpBaseController
|
||||
SCHEMA = {
|
||||
name: 'load_template',
|
||||
title: 'Load Template',
|
||||
description: 'Load a template with its fields. Each field includes name, type, and the signing role name.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
template_id: {
|
||||
type: 'integer',
|
||||
description: 'Template identifier'
|
||||
}
|
||||
},
|
||||
required: %w[template_id]
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
}.freeze
|
||||
|
||||
def call
|
||||
@template = Template.accessible_by(current_ability).find(mcp_params['template_id'])
|
||||
|
||||
authorize!(:read, @template)
|
||||
|
||||
submitters_index = @template.submitters.index_by { |s| s['uuid'] }
|
||||
|
||||
roles = @template.submitters.pluck('name')
|
||||
|
||||
fields = @template.fields.filter_map do |field|
|
||||
next if field['name'].blank?
|
||||
|
||||
{
|
||||
name: field['name'],
|
||||
type: field['type'],
|
||||
role: submitters_index[field['submitter_uuid']]&.dig('name')
|
||||
}
|
||||
end
|
||||
|
||||
render_tool_result(
|
||||
id: @template.id,
|
||||
name: @template.name,
|
||||
roles: roles,
|
||||
fields: fields
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
class McpBaseController < ActionController::API
|
||||
wrap_parameters false
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :verify_mcp_enabled!
|
||||
check_authorization
|
||||
|
||||
before_action do
|
||||
raise CanCan::AccessDenied unless can?(:manage, :mcp)
|
||||
end
|
||||
|
||||
rescue_from CanCan::AccessDenied do
|
||||
render_error(-32_603, 'Forbidden', status: :forbidden)
|
||||
end
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound do
|
||||
render_tool_error('Not found')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_url_options
|
||||
Docuseal.default_url_options
|
||||
end
|
||||
|
||||
def mcp_body
|
||||
request.request_parameters
|
||||
end
|
||||
|
||||
def mcp_params
|
||||
mcp_body.dig('params', 'arguments') || {}
|
||||
end
|
||||
|
||||
def render_result(result)
|
||||
render json: { jsonrpc: '2.0', id: mcp_body['id'], result: }
|
||||
end
|
||||
|
||||
def render_error(code, message, id: nil, status: :ok)
|
||||
render json: { jsonrpc: '2.0', id:, error: { code:, message: } }, status:
|
||||
end
|
||||
|
||||
def render_tool_result(data)
|
||||
render_result(content: [{ type: 'text', text: data.to_json }])
|
||||
end
|
||||
|
||||
def render_tool_error(message)
|
||||
render_result(content: [{ type: 'text', text: message }], isError: true)
|
||||
end
|
||||
|
||||
def authenticate_user!
|
||||
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
|
||||
end
|
||||
|
||||
def verify_mcp_enabled!
|
||||
return if Docuseal.multitenant?
|
||||
|
||||
return if AccountConfig.exists?(account_id: current_user.account_id,
|
||||
key: AccountConfig::ENABLE_MCP_KEY,
|
||||
value: true)
|
||||
|
||||
render json: { error: 'MCP is disabled' }, status: :forbidden
|
||||
end
|
||||
|
||||
def current_user
|
||||
@current_user ||= user_from_api_key
|
||||
end
|
||||
|
||||
def user_from_api_key
|
||||
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
|
||||
|
||||
return if token.blank?
|
||||
|
||||
sha256 = Digest::SHA256.hexdigest(token)
|
||||
|
||||
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
class ProtocolController < McpBaseController
|
||||
skip_authorization_check
|
||||
|
||||
def ok
|
||||
head :ok
|
||||
end
|
||||
|
||||
def initialize_request
|
||||
render_result(
|
||||
protocolVersion: '2025-11-25',
|
||||
serverInfo: {
|
||||
name: 'DocuSeal',
|
||||
version: Docuseal.version.to_s
|
||||
},
|
||||
capabilities: {
|
||||
tools: {
|
||||
listChanged: false
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def initialized_notification
|
||||
head :accepted
|
||||
end
|
||||
|
||||
def ping
|
||||
render_result({})
|
||||
end
|
||||
|
||||
def tools_list
|
||||
render_result(tools: McpController::TOOLS)
|
||||
end
|
||||
|
||||
def method_not_found
|
||||
render_error(-32_601, "Method not found: #{mcp_body['method']}", id: mcp_body['id'])
|
||||
end
|
||||
|
||||
def tool_not_found
|
||||
render_error(-32_602, "Unknown tool: #{mcp_body.dig('params', 'name')}", id: mcp_body['id'])
|
||||
end
|
||||
|
||||
def parse_error
|
||||
render_error(-32_700, 'Parse error', status: :bad_request)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
class SearchDocumentsController < McpBaseController
|
||||
SCHEMA = {
|
||||
name: 'search_documents',
|
||||
title: 'Search Documents',
|
||||
description: 'Search signed or pending documents by submitter name, email, phone, or template name',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: {
|
||||
type: 'string',
|
||||
description: 'Search by submitter name, email, phone, or template name'
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
description: 'The number of results to return (default 10)'
|
||||
}
|
||||
},
|
||||
required: %w[q]
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
}.freeze
|
||||
|
||||
def call
|
||||
authorize!(:read, Submission)
|
||||
|
||||
submissions = Submissions.search(current_user, Submission.accessible_by(current_ability).active,
|
||||
mcp_params['q'], search_template: true)
|
||||
|
||||
limit = mcp_params.fetch('limit', 10).to_i
|
||||
limit = 10 if limit <= 0
|
||||
limit = [limit, 100].min
|
||||
submissions = submissions.preload(:submitters, :template)
|
||||
.order(id: :desc)
|
||||
.limit(limit)
|
||||
|
||||
data = submissions.map do |submission|
|
||||
{
|
||||
id: submission.id,
|
||||
template_name: submission.template&.name,
|
||||
status: Submissions::SerializeForApi.build_status(submission, submission.submitters),
|
||||
submitters: submission.submitters.map do |s|
|
||||
{ email: s.email, name: s.name, phone: s.phone, status: s.status }
|
||||
end,
|
||||
documents_url: submission_url(submission.id)
|
||||
}
|
||||
end
|
||||
|
||||
render_tool_result(data)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
class SearchTemplatesController < McpBaseController
|
||||
SCHEMA = {
|
||||
name: 'search_templates',
|
||||
title: 'Search Templates',
|
||||
description: 'Search document templates by name',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: {
|
||||
type: 'string',
|
||||
description: 'Search query to filter templates by name'
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
description: 'The number of templates to return (default 10)'
|
||||
}
|
||||
},
|
||||
required: %w[q]
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
}.freeze
|
||||
|
||||
def call
|
||||
authorize!(:read, Template)
|
||||
|
||||
templates = Templates.search(current_user, Template.accessible_by(current_ability).active, mcp_params['q'])
|
||||
|
||||
limit = mcp_params.fetch('limit', 10).to_i
|
||||
limit = 10 if limit <= 0
|
||||
limit = [limit, 100].min
|
||||
templates = templates.order(id: :desc).limit(limit)
|
||||
|
||||
render_tool_result(templates.map { |t| { id: t.id, name: t.name } })
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,120 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
class SendDocumentsController < McpBaseController
|
||||
SCHEMA = {
|
||||
name: 'send_documents',
|
||||
title: 'Send Documents',
|
||||
description: 'Send a document template for signing to specified submitters',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
template_id: {
|
||||
type: 'integer',
|
||||
description: 'Template identifier'
|
||||
},
|
||||
submitters: {
|
||||
type: 'array',
|
||||
description: 'The list of submitters (signers)',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
email: {
|
||||
type: 'string',
|
||||
description: 'Submitter email address'
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Submitter name'
|
||||
},
|
||||
phone: {
|
||||
type: 'string',
|
||||
description: 'Submitter phone number in E.164 format'
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
description: 'Signing role name from the template'
|
||||
},
|
||||
fields: {
|
||||
type: 'array',
|
||||
description: 'Prefill field values for this submitter (fields become readonly)',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Field name'
|
||||
},
|
||||
value: {
|
||||
description: 'Prefilled value for the field'
|
||||
}
|
||||
},
|
||||
required: %w[name value]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
required: %w[template_id submitters]
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true
|
||||
}
|
||||
}.freeze
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def call
|
||||
@template = Template.accessible_by(current_ability).find(mcp_params['template_id'])
|
||||
|
||||
authorize!(:read, @template)
|
||||
|
||||
return render_tool_error('Template has been archived') if @template.archived_at?
|
||||
|
||||
authorize!(:create, Submission.new(template: @template, account_id: current_user.account_id))
|
||||
|
||||
return render_tool_error('Template has no fields') if @template.fields.blank?
|
||||
|
||||
submitters = (mcp_params['submitters'] || []).map do |s|
|
||||
attrs = s.slice('email', 'name', 'role', 'phone').compact_blank
|
||||
|
||||
fields = Array.wrap(s['fields']).filter_map do |f|
|
||||
next if f['name'].blank?
|
||||
|
||||
{ 'name' => f['name'], 'default_value' => f['value'], 'readonly' => true }
|
||||
end
|
||||
|
||||
attrs['fields'] = fields if fields.present?
|
||||
|
||||
attrs.with_indifferent_access
|
||||
end
|
||||
|
||||
submissions = Submissions.create_from_submitters(
|
||||
template: @template,
|
||||
user: current_user,
|
||||
source: :mcp,
|
||||
submitters_order: @template.preferences['submitters_order'].presence || 'random',
|
||||
submissions_attrs: { submitters: },
|
||||
params: { 'send_email' => true, 'submitters' => submitters }
|
||||
)
|
||||
|
||||
return render_tool_error('No valid submitters provided') if submissions.blank?
|
||||
|
||||
WebhookUrls.enqueue_events(submissions, 'submission.created')
|
||||
|
||||
Submissions.send_signature_requests(submissions)
|
||||
|
||||
SearchEntries.enqueue_reindex(submissions)
|
||||
|
||||
submission = submissions.first
|
||||
|
||||
render_tool_result(id: submission.id, status: 'pending')
|
||||
rescue Submissions::CreateFromSubmitters::BaseError => e
|
||||
render_tool_error(e.message)
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
end
|
||||
end
|
||||
@@ -1,58 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class McpController < ActionController::API
|
||||
before_action :authenticate_user!
|
||||
before_action :verify_mcp_enabled!
|
||||
class McpController < ActionController::Metal
|
||||
TOOL_CONTROLLERS = {
|
||||
'search_templates' => Mcp::SearchTemplatesController,
|
||||
'load_template' => Mcp::LoadTemplateController,
|
||||
'create_template' => Mcp::CreateTemplateController,
|
||||
'send_documents' => Mcp::SendDocumentsController,
|
||||
'search_documents' => Mcp::SearchDocumentsController
|
||||
}.freeze
|
||||
|
||||
before_action do
|
||||
authorize!(:manage, :mcp)
|
||||
end
|
||||
TOOLS = TOOL_CONTROLLERS.map { |_, controller| controller::SCHEMA }.freeze
|
||||
|
||||
def call
|
||||
return head :ok if request.raw_post.blank?
|
||||
return Mcp::ProtocolController.dispatch(:ok, request, response) if request.raw_post.blank?
|
||||
|
||||
body = JSON.parse(request.raw_post)
|
||||
body = nil unless body.is_a?(Hash)
|
||||
|
||||
result = Mcp::HandleRequest.call(body, current_user, current_ability)
|
||||
request.request_parameters = body || {}
|
||||
|
||||
if result
|
||||
render json: result
|
||||
else
|
||||
head :accepted
|
||||
end
|
||||
rescue CanCan::AccessDenied
|
||||
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_603, message: 'Forbidden' } }, status: :forbidden
|
||||
action =
|
||||
case body&.dig('method')
|
||||
when 'initialize' then :initialize_request
|
||||
when 'notifications/initialized' then :initialized_notification
|
||||
when 'ping' then :ping
|
||||
when 'tools/list' then :tools_list
|
||||
when 'tools/call'
|
||||
tool = TOOL_CONTROLLERS[body.dig('params', 'name')]
|
||||
|
||||
return tool.dispatch(:call, request, response) if tool
|
||||
|
||||
:tool_not_found
|
||||
else
|
||||
:method_not_found
|
||||
end
|
||||
|
||||
Mcp::ProtocolController.dispatch(action, request, response)
|
||||
rescue JSON::ParserError
|
||||
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_700, message: 'Parse error' } }, status: :bad_request
|
||||
end
|
||||
request.request_parameters = {}
|
||||
|
||||
private
|
||||
|
||||
def authenticate_user!
|
||||
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
|
||||
end
|
||||
|
||||
def verify_mcp_enabled!
|
||||
return if Docuseal.multitenant?
|
||||
|
||||
return if AccountConfig.exists?(account_id: current_user.account_id,
|
||||
key: AccountConfig::ENABLE_MCP_KEY,
|
||||
value: true)
|
||||
|
||||
render json: { error: 'MCP is disabled' }, status: :forbidden
|
||||
end
|
||||
|
||||
def current_user
|
||||
@current_user ||= user_from_api_key
|
||||
end
|
||||
|
||||
def user_from_api_key
|
||||
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
|
||||
|
||||
return if token.blank?
|
||||
|
||||
sha256 = Digest::SHA256.hexdigest(token)
|
||||
|
||||
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
|
||||
Mcp::ProtocolController.dispatch(:parse_error, request, response)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,6 +20,8 @@ class MfaSetupController < ApplicationController
|
||||
|
||||
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_configured')
|
||||
else
|
||||
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
|
||||
|
||||
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
|
||||
|
||||
@error_message = I18n.t('code_is_invalid')
|
||||
@@ -34,6 +36,8 @@ class MfaSetupController < ApplicationController
|
||||
|
||||
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed')
|
||||
else
|
||||
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
|
||||
|
||||
@error_message = I18n.t('code_is_invalid')
|
||||
|
||||
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_content
|
||||
|
||||
@@ -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,7 +40,7 @@ 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)
|
||||
|
||||
@@ -16,18 +16,16 @@ class SendSubmissionEmailController < ApplicationController
|
||||
@submitter =
|
||||
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
|
||||
|
||||
if @submitter
|
||||
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)
|
||||
@@ -41,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,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SettingsController < ApplicationController
|
||||
skip_authorization_check
|
||||
|
||||
def index; end
|
||||
end
|
||||
@@ -93,9 +93,12 @@ 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
|
||||
@@ -108,12 +111,16 @@ class StartFormController < ApplicationController
|
||||
end
|
||||
|
||||
def can_resubmit?(submitter)
|
||||
submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false
|
||||
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 submit_form_path(@resubmit_submitter.slug) if @resubmit_submitter && @template.archived_at?
|
||||
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))
|
||||
@@ -134,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)
|
||||
@@ -143,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
|
||||
|
||||
@@ -24,5 +24,7 @@ class SubmissionEventsController < ApplicationController
|
||||
|
||||
load_and_authorize_resource :submission
|
||||
|
||||
def index; end
|
||||
def index
|
||||
render :index, layout: 'plain'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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,7 +21,7 @@ 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 }]
|
||||
@@ -35,9 +33,13 @@ class SubmissionsController < ApplicationController
|
||||
|
||||
def new
|
||||
authorize!(:new, Submission)
|
||||
|
||||
render :new, layout: 'plain'
|
||||
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 +89,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 +116,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
|
||||
|
||||
@@ -25,7 +25,7 @@ class SubmissionsPreviewController < ApplicationController
|
||||
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ class SubmissionsUnarchiveController < ApplicationController
|
||||
load_and_authorize_resource :submission
|
||||
|
||||
def create
|
||||
authorize!(:update, @submission)
|
||||
authorize!(:destroy, @submission)
|
||||
|
||||
@submission.update!(archived_at: nil)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class SubmitFormCompletedDownloadController < ApplicationController
|
||||
|
||||
@submitter ||= Submitter.find_by!(slug: submitter_slug)
|
||||
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
|
||||
|
||||
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
|
||||
@@ -28,11 +28,7 @@ class SubmitFormCompletedDownloadController < ApplicationController
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
if !signature_valid && !current_user_submitter?(last_submitter)
|
||||
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
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)
|
||||
|
||||
@@ -18,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)
|
||||
|
||||
@@ -71,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
|
||||
|
||||
@@ -7,11 +7,13 @@ class SubmitFormDeclineController < ApplicationController
|
||||
before_action :load_submitter
|
||||
|
||||
def create
|
||||
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
|
||||
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)
|
||||
@@ -35,6 +37,10 @@ class SubmitFormDeclineController < ApplicationController
|
||||
|
||||
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
|
||||
|
||||
@@ -12,6 +12,7 @@ class SubmitFormDelegateController < ApplicationController
|
||||
@submitter.submission.archived_at? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
@submitter.viewer? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter,
|
||||
current_user,
|
||||
request)
|
||||
|
||||
@@ -14,6 +14,7 @@ class SubmitFormDownloadController < ApplicationController
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -7,13 +7,7 @@ class SubmitFormMetadataController < ApplicationController
|
||||
def index
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
return head :not_found if @submitter.declined_at? ||
|
||||
@submitter.completed_at? ||
|
||||
@submitter.submission.archived_at? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
@submitter.account.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
return head :not_found unless authorized_submitter?(@submitter)
|
||||
|
||||
submission = @submitter.submission
|
||||
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
|
||||
@@ -34,4 +28,17 @@ class SubmitFormMetadataController < ApplicationController
|
||||
|
||||
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
|
||||
|
||||
@@ -8,12 +8,14 @@ class SubmittersController < ApplicationController
|
||||
if @submitter.preferences['email_message_uuid'].present?
|
||||
@submitter.account.email_messages.find_by(uuid: @submitter.preferences['email_message_uuid'])
|
||||
end
|
||||
|
||||
render :edit, layout: 'plain'
|
||||
end
|
||||
|
||||
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 +50,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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,17 +14,20 @@ 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
|
||||
|
||||
def new; end
|
||||
def new
|
||||
render :new, layout: 'plain'
|
||||
end
|
||||
|
||||
def edit
|
||||
@template_data = Templates.serialize_for_builder(@template)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,11 +13,13 @@ class TemplatesFormPreviewController < ApplicationController
|
||||
|
||||
@submitter.submission.submitters = @template.submitters.map { |item| Submitter.new(uuid: item['uuid']) }
|
||||
|
||||
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(@submitter.submission, preview: true)
|
||||
|
||||
Submissions.preload_with_pages(@submitter.submission)
|
||||
|
||||
@attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments)
|
||||
.preload(:blob).index_by(&:uuid)
|
||||
|
||||
@form_configs = Submitters::FormConfigs.call(@submitter)
|
||||
@form_configs = Submitters::FormConfigs.call(@submitter, SubmitFormController::CONFIG_KEYS)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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],
|
||||
@@ -12,7 +14,9 @@ class TemplatesPreferencesController < ApplicationController
|
||||
completed_notification_email_body]
|
||||
}.freeze
|
||||
|
||||
def show; end
|
||||
def show
|
||||
render :show, layout: 'plain'
|
||||
end
|
||||
|
||||
def create
|
||||
authorize!(:update, @template)
|
||||
@@ -48,12 +52,12 @@ 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
|
||||
|
||||
@@ -4,7 +4,7 @@ class TemplatesRestoreController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
authorize!(:update, @template)
|
||||
authorize!(:destroy, @template)
|
||||
|
||||
@template.update!(archived_at: nil)
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
class TemplatesShareLinkController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def show; end
|
||||
def show
|
||||
render :show, layout: 'plain'
|
||||
end
|
||||
|
||||
def create
|
||||
authorize!(:update, @template)
|
||||
|
||||
@@ -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
|
||||
@@ -42,6 +42,7 @@ 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'
|
||||
@@ -55,6 +56,9 @@ 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 ScrollFade from './elements/scroll_fade'
|
||||
import OpenModalMobile from './elements/open_modal_mobile'
|
||||
import HistoryBack from './elements/history_back'
|
||||
|
||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||
|
||||
@@ -135,6 +139,7 @@ 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)
|
||||
@@ -148,10 +153,14 @@ safeRegisterElement('open-modal', OpenModal)
|
||||
safeRegisterElement('bar-chart', BarChart)
|
||||
safeRegisterElement('field-condition', FieldCondition)
|
||||
safeRegisterElement('confirm-upload', ConfirmUpload)
|
||||
safeRegisterElement('scroll-fade', ScrollFade)
|
||||
safeRegisterElement('open-modal-mobile', OpenModalMobile)
|
||||
safeRegisterElement('history-back', HistoryBack)
|
||||
|
||||
safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
document.addEventListener('turbo:submit-end', this.onSubmit)
|
||||
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
|
||||
this.appElem = document.createElement('div')
|
||||
|
||||
@@ -162,6 +171,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,
|
||||
@@ -178,7 +188,10 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||
withCustomFields: true,
|
||||
withPayment: this.dataset.withPayment === 'true',
|
||||
isPaymentConnected: this.dataset.isPaymentConnected === 'true',
|
||||
isStripeConnected: this.dataset.isPaymentConnected === 'true' || this.dataset.isStripeConnected === 'true',
|
||||
withStripe: this.dataset.withStripe !== 'false',
|
||||
withPaypal: this.dataset.withPaypal === 'true',
|
||||
isPaypalConnected: this.dataset.isPaypalConnected === 'true',
|
||||
withFormula: this.dataset.withFormula === 'true',
|
||||
withSendButton: this.dataset.withSendButton !== 'false',
|
||||
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
|
||||
@@ -231,8 +244,14 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeCache = () => {
|
||||
this.app?.unmount()
|
||||
this.appElem?.remove()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:submit-end', this.onSubmit)
|
||||
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
|
||||
this.app?.unmount()
|
||||
this.appElem?.remove()
|
||||
@@ -241,6 +260,8 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
|
||||
safeRegisterElement('import-list', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
|
||||
this.appElem = document.createElement('div')
|
||||
|
||||
this.app = createApp(ImportList, {
|
||||
@@ -255,7 +276,14 @@ safeRegisterElement('import-list', class extends HTMLElement {
|
||||
this.appendChild(this.appElem)
|
||||
}
|
||||
|
||||
onBeforeCache = () => {
|
||||
this.app?.unmount()
|
||||
this.appElem?.remove()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
|
||||
this.app?.unmount()
|
||||
this.appElem?.remove()
|
||||
}
|
||||
|
||||
@@ -114,6 +114,13 @@ button[disabled] .enabled, button.btn-disabled .enabled {
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.tooltip-no-touch:before,
|
||||
.tooltip-no-touch:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.autocomplete {
|
||||
background: white;
|
||||
z-index: 1000;
|
||||
|
||||
@@ -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({
|
||||
'&': {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('click', this.onClick)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.removeEventListener('click', this.onClick)
|
||||
}
|
||||
|
||||
onClick = (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
window.history.back()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
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;
|
||||
}
|
||||
`)
|
||||
|
||||
const DROP_ATTRS = [
|
||||
'srcdoc', 'xlink:href', 'srcset', 'action', 'formaction', 'poster',
|
||||
'background', 'data', 'cite', 'ping', 'longdesc', 'manifest', 'profile'
|
||||
]
|
||||
|
||||
const SAFE_URL_REGEXP = /^(?:https?:\/\/|data:image\/|blob:|mailto:|tel:|\{|#)/i
|
||||
|
||||
function isSafeAttr (name, value) {
|
||||
const lowerName = name.toLowerCase()
|
||||
|
||||
if (lowerName.startsWith('on') || DROP_ATTRS.includes(lowerName)) return false
|
||||
if ((lowerName === 'href' || lowerName === 'src') && !SAFE_URL_REGEXP.test(value.trim())) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function collectDomAttrs (dom) {
|
||||
const attrs = {}
|
||||
|
||||
for (let i = 0; i < dom.attributes.length; i++) {
|
||||
const { name, value } = dom.attributes[i]
|
||||
|
||||
if (isSafeAttr(name, value)) attrs[name] = 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,17 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
if (this.dataset.disabled === 'true') return
|
||||
if (!this.isMobile()) return
|
||||
if (window.innerWidth >= 768) return
|
||||
|
||||
this.querySelectorAll('a[data-turbo-frame]').forEach((link) => {
|
||||
link.dataset.turboFrame = '_top'
|
||||
})
|
||||
}
|
||||
|
||||
isMobile () {
|
||||
const isTouchWebkit = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit|android/i.test(navigator.userAgent)
|
||||
|
||||
return isTouchWebkit || /iPhone|iPad|iPod/i.test(navigator.userAgent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('scroll', this.updateFade, { passive: true })
|
||||
window.addEventListener('resize', this.updateFade)
|
||||
|
||||
this.updateFade()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
window.removeEventListener('resize', this.updateFade)
|
||||
}
|
||||
|
||||
updateFade = () => {
|
||||
const maxScroll = this.scrollWidth - this.clientWidth
|
||||
const fadeGradient = {
|
||||
right: 'linear-gradient(to right, black calc(100% - 24px), transparent)',
|
||||
left: 'linear-gradient(to right, transparent, black 24px)',
|
||||
both: 'linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent)'
|
||||
}
|
||||
|
||||
let state = 'none'
|
||||
|
||||
if (maxScroll > 4) {
|
||||
if (this.scrollLeft <= 4) {
|
||||
state = 'right'
|
||||
} else if (this.scrollLeft >= maxScroll - 4) {
|
||||
state = 'left'
|
||||
} else {
|
||||
state = 'both'
|
||||
}
|
||||
}
|
||||
|
||||
this.applyFade(fadeGradient[state] || '')
|
||||
}
|
||||
|
||||
applyFade (gradient) {
|
||||
this.style.maskImage = gradient
|
||||
this.style.webkitMaskImage = gradient
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,5 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.input.addEventListener('focus', () => {
|
||||
if (this.title) {
|
||||
this.title.classList.add('hidden', 'md:block')
|
||||
this.input.classList.add('w-60')
|
||||
}
|
||||
})
|
||||
|
||||
this.input.addEventListener('blur', (e) => {
|
||||
if (this.title && !e.target.value) {
|
||||
this.title.classList.remove('hidden')
|
||||
this.input.classList.remove('w-60')
|
||||
}
|
||||
})
|
||||
|
||||
this.button.addEventListener('click', (event) => {
|
||||
if (!this.input.value && document.activeElement !== this.input) {
|
||||
event.preventDefault()
|
||||
@@ -21,16 +7,22 @@ export default class extends HTMLElement {
|
||||
this.input.focus()
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
}
|
||||
|
||||
onBeforeCache = () => {
|
||||
this.input.value = this.input.getAttribute('value') || ''
|
||||
}
|
||||
|
||||
get input () {
|
||||
return this.querySelector('input')
|
||||
}
|
||||
|
||||
get title () {
|
||||
return document.querySelector(this.dataset.title)
|
||||
}
|
||||
|
||||
get button () {
|
||||
return this.querySelector('button')
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export default actionable(class extends HTMLElement {
|
||||
|
||||
document.addEventListener('keyup', this.onEscKey)
|
||||
|
||||
document.addEventListener('turbo:before-cache', this.close)
|
||||
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
|
||||
if (this.dataset.closeAfterSubmit !== 'false') {
|
||||
document.addEventListener('turbo:submit-end', this.onSubmit)
|
||||
@@ -18,7 +18,7 @@ export default actionable(class extends HTMLElement {
|
||||
|
||||
document.removeEventListener('keyup', this.onEscKey)
|
||||
document.removeEventListener('turbo:submit-end', this.onSubmit)
|
||||
document.removeEventListener('turbo:before-cache', this.close)
|
||||
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
}
|
||||
|
||||
onSubmit = (e) => {
|
||||
@@ -33,6 +33,12 @@ export default actionable(class extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeCache = () => {
|
||||
if (this.closest('turbo-frame')?.src) {
|
||||
this.remove()
|
||||
}
|
||||
}
|
||||
|
||||
close = (e) => {
|
||||
e?.preventDefault()
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ safeRegisterElement('submission-form', class extends HTMLElement {
|
||||
scrollPadding: this.dataset.scrollPadding || '-80px',
|
||||
signatureText: this.dataset.signatureText,
|
||||
language: this.dataset.language,
|
||||
paymentProvider: this.dataset.paymentProvider,
|
||||
dryRun: this.dataset.dryRun === 'true',
|
||||
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
|
||||
withSignatureId: this.dataset.withSignatureId === 'true',
|
||||
|
||||
@@ -69,8 +69,8 @@
|
||||
:attachments-index="attachmentsIndex"
|
||||
/>
|
||||
<FieldArea
|
||||
v-else-if="item.type === 'formula_area' && isMathLoaded"
|
||||
:model-value="calculateFormula(item.field)"
|
||||
v-else-if="item.type === 'formula_area' && (isMathLoaded || item.field.type === 'text')"
|
||||
:model-value="item.field.type === 'text' ? evalTextFormula(item.field) : calculateFormula(item.field)"
|
||||
:is-inline-size="isInlineSize"
|
||||
:field="item.field"
|
||||
:area="item.area"
|
||||
@@ -289,6 +289,7 @@ export default {
|
||||
methods: {
|
||||
normalizeFormula: FormulaAreas.methods.normalizeFormula,
|
||||
calculateFormula: FormulaAreas.methods.calculateFormula,
|
||||
evalTextFormula: FormulaAreas.methods.evalTextFormula,
|
||||
scrollInContainer: FieldAreas.methods.scrollInContainer,
|
||||
scrollIntoArea: FieldAreas.methods.scrollIntoArea,
|
||||
scrollIntoField: FieldAreas.methods.scrollIntoField,
|
||||
|
||||
@@ -528,7 +528,7 @@ export default {
|
||||
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'),
|
||||
{ withTimePlaceholders: this.modelValue === '{{date}}' }
|
||||
{ withTimePlaceholders: this.modelValue === '{{date}}', utc: this.modelValue !== '{{date}}' }
|
||||
)
|
||||
} catch {
|
||||
return this.modelValue
|
||||
@@ -651,7 +651,7 @@ export default {
|
||||
return number
|
||||
}
|
||||
},
|
||||
formatDate (date, format, { withTimePlaceholders = false } = {}) {
|
||||
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' }
|
||||
@@ -673,7 +673,7 @@ export default {
|
||||
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) opts.timeZone = 'UTC'
|
||||
if (!hasTime && utc) opts.timeZone = 'UTC'
|
||||
|
||||
const partTypes = {
|
||||
M: 'month',
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
@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%"
|
||||
@@ -174,6 +174,7 @@
|
||||
/>
|
||||
</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"
|
||||
@@ -289,6 +290,7 @@
|
||||
: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] }"
|
||||
@@ -317,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 }"
|
||||
@@ -356,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
|
||||
@@ -556,6 +560,7 @@
|
||||
:fields="formulaFields"
|
||||
:values="values"
|
||||
:readonly-values="readonlyFieldValues"
|
||||
:provider="paymentProvider"
|
||||
@attached="attachments.push($event)"
|
||||
@focus="scrollIntoField(currentField)"
|
||||
@submit="!isSubmitting && submitStep()"
|
||||
@@ -996,6 +1001,11 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
paymentProvider: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
required: false,
|
||||
@@ -1169,7 +1179,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) {
|
||||
@@ -1567,6 +1581,7 @@ export default {
|
||||
}
|
||||
},
|
||||
goToStep (stepIndex, scrollToArea = false, clickUpload = false) {
|
||||
this.isInvite = false
|
||||
this.currentStep = stepIndex
|
||||
this.showFillAllRequiredFields = false
|
||||
|
||||
@@ -1591,6 +1606,10 @@ export default {
|
||||
})
|
||||
},
|
||||
saveStep (formData) {
|
||||
if (!formData && !this.$refs.form) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentFieldUuids = this.currentStepFields.map((f) => f.uuid)
|
||||
const currentFieldType = this.currentField.type
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
:to="findPageElementForArea(area)"
|
||||
>
|
||||
<FieldArea
|
||||
v-if="isMathLoaded"
|
||||
:model-value="calculateFormula(field)"
|
||||
v-if="isMathLoaded || field.type === 'text'"
|
||||
:model-value="field.type === 'text' ? evalTextFormula(field) : calculateFormula(field)"
|
||||
:is-inline-size="isInlineSize"
|
||||
:field="field"
|
||||
:area="area"
|
||||
@@ -95,6 +95,25 @@ export default {
|
||||
})
|
||||
|
||||
return this.math.evaluate(transformedFormula.toLowerCase())
|
||||
},
|
||||
evalTextFormula (field, depth = 0) {
|
||||
if (depth > 10) return ''
|
||||
|
||||
return field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
const formulaField = this.fieldsUuidIndex[uuid]
|
||||
|
||||
if (formulaField?.preferences?.formula) {
|
||||
if (formulaField.type === 'text') {
|
||||
return this.evalTextFormula(formulaField, depth + 1)
|
||||
} else if (this.isMathLoaded) {
|
||||
return this.calculateFormula(formulaField)
|
||||
}
|
||||
}
|
||||
|
||||
const value = this.readonlyValues[uuid] ?? this.values[uuid]
|
||||
|
||||
return Array.isArray(value) ? value.join(', ') : (value ?? '')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const en = {
|
||||
close: 'Close',
|
||||
uploaded_files: 'Uploaded files',
|
||||
signature_drawing_area: 'Signature drawing area. Use mouse or touch to draw your signature.',
|
||||
initials_drawing_area: 'Initials drawing area. Use mouse or touch to draw your initials.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Please upload an image file',
|
||||
must_be_characters_length: 'Must be {number} characters long',
|
||||
@@ -88,6 +89,7 @@ const en = {
|
||||
please_check_the_box_to_continue: 'Please check the box to continue.',
|
||||
open_source_documents_software: 'open source documents software',
|
||||
verified_phone_number: 'Verify Phone Number',
|
||||
country_code: 'Country code',
|
||||
use_international_format: 'Use international format: +1xxx',
|
||||
six_digits_code: '6-digit code',
|
||||
change_phone_number: 'Change phone number',
|
||||
@@ -105,6 +107,7 @@ const en = {
|
||||
email_has_been_sent: 'Email has been sent',
|
||||
processing: 'Processing',
|
||||
pay_with_stripe: 'Pay with Stripe',
|
||||
pay_with_paypal: 'Pay with PayPal',
|
||||
reupload: 'Reupload',
|
||||
upload: 'Upload',
|
||||
files: 'Files',
|
||||
@@ -120,6 +123,7 @@ const es = {
|
||||
close: 'Cerrar',
|
||||
uploaded_files: 'Archivos subidos',
|
||||
signature_drawing_area: 'Área de dibujo de firma. Use el ratón o toque para dibujar su firma.',
|
||||
initials_drawing_area: 'Área de dibujo de iniciales. Use el ratón o toque para dibujar sus iniciales.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Por favor, sube un archivo de imagen',
|
||||
must_be_characters_length: 'Debe tener {number} caracteres de longitud',
|
||||
@@ -204,6 +208,7 @@ const es = {
|
||||
please_check_the_box_to_continue: 'Por favor marque la casilla para continuar.',
|
||||
open_source_documents_software: 'software de documentos de código abierto',
|
||||
verified_phone_number: 'Verificar número de teléfono',
|
||||
country_code: 'Código de país',
|
||||
use_international_format: 'Usar formato internacional: +1xxx',
|
||||
six_digits_code: 'Código de 6 dígitos',
|
||||
change_phone_number: 'Cambiar número de teléfono',
|
||||
@@ -221,6 +226,7 @@ const es = {
|
||||
email_has_been_sent: 'El correo electrónico ha sido enviado',
|
||||
processing: 'Procesando',
|
||||
pay_with_stripe: 'Pagar con Stripe',
|
||||
pay_with_paypal: 'Pagar con PayPal',
|
||||
reupload: 'Volver a subir',
|
||||
upload: 'Subir',
|
||||
files: 'Archivos',
|
||||
@@ -236,6 +242,7 @@ const it = {
|
||||
close: 'Chiudi',
|
||||
uploaded_files: 'File caricati',
|
||||
signature_drawing_area: 'Area di disegno della firma. Usa il mouse o il tocco per disegnare la tua firma.',
|
||||
initials_drawing_area: 'Area di disegno delle iniziali. Usa il mouse o il tocco per disegnare le tue iniziali.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Per favore carica un file immagine',
|
||||
must_be_characters_length: 'Deve essere lungo {number} caratteri',
|
||||
@@ -320,6 +327,7 @@ const it = {
|
||||
please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare.',
|
||||
open_source_documents_software: 'software di documenti open source',
|
||||
verified_phone_number: 'Verifica numero di telefono',
|
||||
country_code: 'Prefisso internazionale',
|
||||
use_international_format: 'Usa formato internazionale: +1xxx',
|
||||
six_digits_code: 'Codice a 6 cifre',
|
||||
change_phone_number: 'Cambia numero di telefono',
|
||||
@@ -337,6 +345,7 @@ const it = {
|
||||
email_has_been_sent: "L'email è stata inviata",
|
||||
processing: 'Elaborazione',
|
||||
pay_with_stripe: 'Paga con Stripe',
|
||||
pay_with_paypal: 'Paga con PayPal',
|
||||
reupload: 'Ricarica',
|
||||
upload: 'Carica',
|
||||
files: 'File',
|
||||
@@ -352,6 +361,7 @@ const de = {
|
||||
close: 'Schließen',
|
||||
uploaded_files: 'Hochgeladene Dateien',
|
||||
signature_drawing_area: 'Unterschriftszeichenbereich. Verwenden Sie die Maus oder Berührung, um Ihre Unterschrift zu zeichnen.',
|
||||
initials_drawing_area: 'Zeichenbereich für Initialen. Verwenden Sie die Maus oder Berührung, um Ihre Initialen zu zeichnen.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
|
||||
must_be_characters_length: 'Muss {number} Zeichen lang sein',
|
||||
@@ -436,6 +446,7 @@ const de = {
|
||||
please_check_the_box_to_continue: 'Bitte aktivieren Sie das Kontrollkästchen, um fortzufahren.',
|
||||
open_source_documents_software: 'Open-Source-Dokumentensoftware',
|
||||
verified_phone_number: 'Telefonnummer verifizieren',
|
||||
country_code: 'Ländervorwahl',
|
||||
use_international_format: 'Internationales Format verwenden: +1xxx',
|
||||
six_digits_code: '6-stelliger Code',
|
||||
change_phone_number: 'Telefonnummer ändern',
|
||||
@@ -453,6 +464,7 @@ const de = {
|
||||
email_has_been_sent: 'E-Mail wurde gesendet',
|
||||
processing: 'Verarbeitung',
|
||||
pay_with_stripe: 'Mit Stripe bezahlen',
|
||||
pay_with_paypal: 'Mit PayPal bezahlen',
|
||||
reupload: 'Erneut hochladen',
|
||||
upload: 'Hochladen',
|
||||
files: 'Dateien',
|
||||
@@ -468,6 +480,7 @@ const fr = {
|
||||
close: 'Fermer',
|
||||
uploaded_files: 'Fichiers téléchargés',
|
||||
signature_drawing_area: 'Zone de dessin de signature. Utilisez la souris ou le toucher pour dessiner votre signature.',
|
||||
initials_drawing_area: 'Zone de dessin des initiales. Utilisez la souris ou le toucher pour dessiner vos initiales.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Veuillez téléverser un fichier image',
|
||||
must_be_characters_length: 'Doit comporter {number} caractères',
|
||||
@@ -552,6 +565,7 @@ const fr = {
|
||||
please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer.',
|
||||
open_source_documents_software: 'logiciel de documents open source',
|
||||
verified_phone_number: 'Vérifier le numéro de téléphone',
|
||||
country_code: 'Indicatif du pays',
|
||||
use_international_format: 'Utilisez le format international : +1xxx',
|
||||
six_digits_code: 'Code à 6 chiffres',
|
||||
change_phone_number: 'Changer de numéro de téléphone',
|
||||
@@ -569,6 +583,7 @@ const fr = {
|
||||
email_has_been_sent: "L'e-mail a été envoyé",
|
||||
processing: 'Traitement en cours',
|
||||
pay_with_stripe: 'Payer avec Stripe',
|
||||
pay_with_paypal: 'Payer avec PayPal',
|
||||
reupload: 'Retéléverser',
|
||||
upload: 'Téléverser',
|
||||
files: 'Fichiers',
|
||||
@@ -584,6 +599,7 @@ const pl = {
|
||||
close: 'Zamknij',
|
||||
uploaded_files: 'Przesłane pliki',
|
||||
signature_drawing_area: 'Obszar rysowania podpisu. Użyj myszy lub dotyku, aby narysować swój podpis.',
|
||||
initials_drawing_area: 'Obszar rysowania inicjałów. Użyj myszy lub dotyku, aby narysować swoje inicjały.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Proszę przesłać plik obrazu',
|
||||
must_be_characters_length: 'Musi mieć długość {number} znaków',
|
||||
@@ -668,6 +684,7 @@ const pl = {
|
||||
please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować.',
|
||||
open_source_documents_software: 'oprogramowanie do dokumentów open source',
|
||||
verified_phone_number: 'Zweryfikuj numer telefonu',
|
||||
country_code: 'Kod kraju',
|
||||
use_international_format: 'Użyj międzynarodowego formatu: +1xxx',
|
||||
six_digits_code: '6-cyfrowy kod',
|
||||
change_phone_number: 'Zmień numer telefonu',
|
||||
@@ -685,6 +702,7 @@ const pl = {
|
||||
email_has_been_sent: 'E-mail został wysłany',
|
||||
processing: 'Przetwarzanie',
|
||||
pay_with_stripe: 'Zapłać za pomocą Stripe',
|
||||
pay_with_paypal: 'Zapłać za pomocą PayPal',
|
||||
reupload: 'Ponowne przesłanie',
|
||||
upload: 'Przesyłanie',
|
||||
files: 'Pliki',
|
||||
@@ -700,6 +718,7 @@ const uk = {
|
||||
close: 'Закрити',
|
||||
uploaded_files: 'Завантажені файли',
|
||||
signature_drawing_area: 'Область малювання підпису. Використовуйте мишу або дотик, щоб намалювати свій підпис.',
|
||||
initials_drawing_area: 'Область малювання ініціалів. Використовуйте мишу або дотик, щоб намалювати свої ініціали.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
|
||||
must_be_characters_length: 'Має містити {number} символів',
|
||||
@@ -784,6 +803,7 @@ const uk = {
|
||||
please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити.',
|
||||
open_source_documents_software: 'відкрите програмне забезпечення для документів',
|
||||
verified_phone_number: 'Підтвердіть номер телефону',
|
||||
country_code: 'Код країни',
|
||||
use_international_format: 'Використовуйте міжнародний формат: +1xxx',
|
||||
six_digits_code: '6-значний код',
|
||||
change_phone_number: 'Змінити номер телефону',
|
||||
@@ -801,6 +821,7 @@ const uk = {
|
||||
email_has_been_sent: 'Електронний лист був відправлений',
|
||||
processing: 'Обробка',
|
||||
pay_with_stripe: 'Сплатити за допомогою Stripe',
|
||||
pay_with_paypal: 'Сплатити за допомогою PayPal',
|
||||
reupload: 'Перезавантажити',
|
||||
upload: 'Завантажити',
|
||||
files: 'Файли',
|
||||
@@ -816,6 +837,7 @@ const cs = {
|
||||
close: 'Zavřít',
|
||||
uploaded_files: 'Nahrané soubory',
|
||||
signature_drawing_area: 'Oblast pro kreslení podpisu. Použijte myš nebo dotyk k nakreslení podpisu.',
|
||||
initials_drawing_area: 'Oblast pro kreslení iniciál. Použijte myš nebo dotyk k nakreslení iniciál.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor',
|
||||
must_be_characters_length: 'Musí mít délku {number} znaků',
|
||||
@@ -900,6 +922,7 @@ const cs = {
|
||||
please_check_the_box_to_continue: 'Prosím, zaškrtněte políčko pro pokračování.',
|
||||
open_source_documents_software: 'open source software pro dokumenty',
|
||||
verified_phone_number: 'Ověřte telefonní číslo',
|
||||
country_code: 'Předvolba země',
|
||||
use_international_format: 'Použijte mezinárodní formát: +1xxx',
|
||||
six_digits_code: '6-místný kód',
|
||||
change_phone_number: 'Změnit telefonní číslo',
|
||||
@@ -917,6 +940,7 @@ const cs = {
|
||||
email_has_been_sent: 'E-mail byl odeslán',
|
||||
processing: 'Zpracování',
|
||||
pay_with_stripe: 'Zaplatit přes Stripe',
|
||||
pay_with_paypal: 'Zaplatit přes PayPal',
|
||||
reupload: 'Znovu nahrát',
|
||||
upload: 'Nahrát',
|
||||
files: 'Soubory',
|
||||
@@ -932,6 +956,7 @@ const pt = {
|
||||
close: 'Fechar',
|
||||
uploaded_files: 'Arquivos enviados',
|
||||
signature_drawing_area: 'Área de desenho da assinatura. Use o mouse ou toque para desenhar sua assinatura.',
|
||||
initials_drawing_area: 'Área de desenho das iniciais. Use o mouse ou toque para desenhar suas iniciais.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Por favor, envie um arquivo de imagem',
|
||||
must_be_characters_length: 'Deve ter {number} caracteres',
|
||||
@@ -1016,6 +1041,7 @@ const pt = {
|
||||
please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar.',
|
||||
open_source_documents_software: 'software de documentos de código aberto',
|
||||
verified_phone_number: 'Verificar Número de Telefone',
|
||||
country_code: 'Código do país',
|
||||
use_international_format: 'Use formato internacional: +1xxx',
|
||||
six_digits_code: 'Código de 6 dígitos',
|
||||
change_phone_number: 'Alterar número de telefone',
|
||||
@@ -1033,6 +1059,7 @@ const pt = {
|
||||
email_has_been_sent: 'Email enviado',
|
||||
processing: 'Processamento',
|
||||
pay_with_stripe: 'Pagar com Stripe',
|
||||
pay_with_paypal: 'Pagar com PayPal',
|
||||
reupload: 'Recarregar',
|
||||
upload: 'Carregar',
|
||||
files: 'Arquivos',
|
||||
@@ -1048,6 +1075,7 @@ const he = {
|
||||
close: 'סגור',
|
||||
uploaded_files: 'קבצים שהועלו',
|
||||
signature_drawing_area: 'אזור ציור חתימה. השתמש בעכבר או במגע כדי לצייר את החתימה שלך.',
|
||||
initials_drawing_area: 'אזור ציור ראשי תיבות. השתמש בעכבר או במגע כדי לצייר את ראשי התיבות שלך.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'אנא העלה קובץ תמונה',
|
||||
must_be_characters_length: 'חייב להיות באורך של {number} תווים',
|
||||
@@ -1132,6 +1160,7 @@ const he = {
|
||||
please_check_the_box_to_continue: 'אנא סמן את התיבה כדי להמשיך.',
|
||||
open_source_documents_software: 'תוכנה פתוחה למסמכים',
|
||||
verified_phone_number: 'אימות מספר טלפון',
|
||||
country_code: 'קידומת מדינה',
|
||||
use_international_format: 'השתמש בפורמט בינלאומי: +1xxx',
|
||||
six_digits_code: 'קוד שש ספרות',
|
||||
change_phone_number: 'שינוי מספר טלפון',
|
||||
@@ -1149,6 +1178,7 @@ const he = {
|
||||
email_has_been_sent: 'האימייל נשלח',
|
||||
processing: 'מעבד',
|
||||
pay_with_stripe: 'שלם עם סטרייפ',
|
||||
pay_with_paypal: 'שלם עם פייפאל',
|
||||
reupload: 'העלה שוב',
|
||||
upload: 'העלאה',
|
||||
files: 'קבצים',
|
||||
@@ -1164,6 +1194,7 @@ const nl = {
|
||||
close: 'Sluiten',
|
||||
uploaded_files: 'Geüploade bestanden',
|
||||
signature_drawing_area: 'Handtekening tekengebied. Gebruik de muis of aanraking om uw handtekening te tekenen.',
|
||||
initials_drawing_area: 'Tekengebied voor initialen. Gebruik de muis of aanraking om uw initialen te tekenen.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
|
||||
must_be_characters_length: 'Moet {number} tekens lang zijn',
|
||||
@@ -1248,6 +1279,7 @@ const nl = {
|
||||
please_check_the_box_to_continue: 'Vink het vakje aan om door te gaan.',
|
||||
open_source_documents_software: 'Open source documenten software',
|
||||
verified_phone_number: 'Telefoonnummer verifiëren',
|
||||
country_code: 'Landcode',
|
||||
use_international_format: 'Gebruik internationaal formaat: +1xxx',
|
||||
six_digits_code: '6-cijferige code',
|
||||
change_phone_number: 'Wijzig telefoonnummer',
|
||||
@@ -1265,6 +1297,7 @@ const nl = {
|
||||
email_has_been_sent: 'E-mail is verzonden',
|
||||
processing: 'Verwerken',
|
||||
pay_with_stripe: 'Betalen met Stripe',
|
||||
pay_with_paypal: 'Betalen met PayPal',
|
||||
reupload: 'Opnieuw uploaden',
|
||||
upload: 'Uploaden',
|
||||
files: 'Bestanden',
|
||||
@@ -1280,6 +1313,7 @@ const ar = {
|
||||
close: 'إغلاق',
|
||||
uploaded_files: 'الملفات المرفوعة',
|
||||
signature_drawing_area: 'منطقة رسم التوقيع. استخدم الماوس أو اللمس لرسم توقيعك.',
|
||||
initials_drawing_area: 'منطقة رسم الحروف الأولى. استخدم الماوس أو اللمس لرسم الحروف الأولى.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'يرجى تحميل ملف صورة',
|
||||
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
|
||||
@@ -1364,6 +1398,7 @@ const ar = {
|
||||
please_check_the_box_to_continue: 'الرجاء التحقق من الخانة للمتابعة.',
|
||||
open_source_documents_software: 'برنامج وثائق مفتوح المصدر',
|
||||
verified_phone_number: 'تحقق من رقم الهاتف',
|
||||
country_code: 'رمز الدولة',
|
||||
use_international_format: 'استخدم الشكل الدولي: +1xxx',
|
||||
six_digits_code: 'رمز مكون من 6 أرقام',
|
||||
change_phone_number: 'تغيير رقم الهاتف',
|
||||
@@ -1381,6 +1416,7 @@ const ar = {
|
||||
email_has_been_sent: 'تم إرسال البريد الإلكتروني',
|
||||
processing: 'جارٍ المعالجة',
|
||||
pay_with_stripe: 'الدفع بواسطة Stripe',
|
||||
pay_with_paypal: 'الدفع بواسطة PayPal',
|
||||
reupload: 'إعادة التحميل',
|
||||
upload: 'تحميل',
|
||||
files: 'الملفات',
|
||||
@@ -1396,6 +1432,7 @@ const ko = {
|
||||
close: '닫기',
|
||||
uploaded_files: '업로드된 파일',
|
||||
signature_drawing_area: '서명 그리기 영역. 마우스 또는 터치를 사용하여 서명을 그리세요.',
|
||||
initials_drawing_area: '이니셜 그리기 영역. 마우스 또는 터치를 사용하여 이니셜을 그리세요.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
|
||||
must_be_characters_length: '{number}자여야 합니다',
|
||||
@@ -1480,6 +1517,7 @@ const ko = {
|
||||
please_check_the_box_to_continue: '계속하려면 확인란을 선택하십시오.',
|
||||
open_source_documents_software: '오픈 소스 문서 소프트웨어',
|
||||
verified_phone_number: '전화번호 확인됨',
|
||||
country_code: '국가 번호',
|
||||
use_international_format: '국제 포맷 사용: +1xxx',
|
||||
six_digits_code: '6자리 코드',
|
||||
change_phone_number: '전화번호 변경',
|
||||
@@ -1497,6 +1535,7 @@ const ko = {
|
||||
email_has_been_sent: '이메일이 전송되었습니다',
|
||||
processing: '처리 중',
|
||||
pay_with_stripe: '스트라이프로 결제',
|
||||
pay_with_paypal: 'PayPal로 결제',
|
||||
reupload: '다시 업로드',
|
||||
upload: '업로드',
|
||||
files: '파일',
|
||||
@@ -1512,6 +1551,7 @@ const ja = {
|
||||
close: '閉じる',
|
||||
uploaded_files: 'アップロードされたファイル',
|
||||
signature_drawing_area: '署名描画エリア。マウスまたはタッチを使用して署名を描いてください。',
|
||||
initials_drawing_area: 'イニシャル描画エリア。マウスまたはタッチを使用してイニシャルを描いてください。',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: '画像ファイルをアップロードしてください',
|
||||
must_be_characters_length: '{number}文字でなければなりません',
|
||||
@@ -1596,6 +1636,7 @@ const ja = {
|
||||
please_check_the_box_to_continue: '続行するにはボックスにチェックを入れてください。',
|
||||
open_source_documents_software: 'オープンソースのドキュメントソフトウェア',
|
||||
verified_phone_number: '電話番号を認証',
|
||||
country_code: '国番号',
|
||||
use_international_format: '国際形式を使用してください:+1xxx',
|
||||
six_digits_code: '6桁のコード',
|
||||
change_phone_number: '電話番号を変更',
|
||||
@@ -1613,6 +1654,7 @@ const ja = {
|
||||
email_has_been_sent: 'メールが送信されました',
|
||||
processing: '処理中',
|
||||
pay_with_stripe: 'Stripeで支払う',
|
||||
pay_with_paypal: 'PayPalで支払う',
|
||||
reupload: '再アップロード',
|
||||
upload: 'アップロード',
|
||||
files: 'ファイル',
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
<img
|
||||
v-if="modelValue || computedPreviousValue"
|
||||
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
|
||||
:alt="field.name || t('initials')"
|
||||
class="mx-auto bg-white border border-base-300 rounded max-h-44"
|
||||
>
|
||||
<div class="relative">
|
||||
@@ -156,6 +157,8 @@
|
||||
<canvas
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
ref="canvas"
|
||||
role="img"
|
||||
:aria-label="t('initials_drawing_area')"
|
||||
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
|
||||
/>
|
||||
</div>
|
||||
@@ -165,7 +168,8 @@
|
||||
ref="textInput"
|
||||
class="base-input !text-2xl w-full mt-6 text-center"
|
||||
:required="field.required && !isInitialsStarted"
|
||||
:placeholder="`${t('type_initial_here')}...`"
|
||||
:aria-label="field.name || t('initials')"
|
||||
:placeholder="`${t('type_initial_here')}${field.required ? '...' : ' (' + t('optional') + ')'}`"
|
||||
type="text"
|
||||
@focus="$emit('focus')"
|
||||
@input="updateWrittenInitials"
|
||||
@@ -289,7 +293,7 @@ export default {
|
||||
|
||||
if (!this.isDrawInitials) {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.textInput) {
|
||||
if (this.$refs.textInput && this.field.required === true) {
|
||||
this.initTextInitial()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
:placeholder="t('email')"
|
||||
type="email"
|
||||
:required="submitters.includes(submitter)"
|
||||
autofocus="true"
|
||||
:autofocus="index === 0"
|
||||
name="submission[submitters][][email]"
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<label
|
||||
v-if="showFieldNames && (field.name || field.title)"
|
||||
:for="field.uuid"
|
||||
:id="field.uuid + '-group-label'"
|
||||
dir="auto"
|
||||
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
@@ -44,6 +44,8 @@
|
||||
<div
|
||||
class="space-y-3.5 mx-auto"
|
||||
:class="{ hidden: !showOptions }"
|
||||
role="group"
|
||||
:aria-labelledby="showFieldNames && (field.name || field.title) ? field.uuid + '-group-label' : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in field.options"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<label
|
||||
v-if="!modelValue && !sessionId"
|
||||
v-if="!modelValue && !isProcessing"
|
||||
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
|
||||
>
|
||||
<MarkdownContent
|
||||
@@ -25,14 +25,14 @@
|
||||
class="hidden"
|
||||
>
|
||||
<div
|
||||
v-if="modelValue && !sessionId"
|
||||
v-if="modelValue && !isProcessing"
|
||||
class=" text-2xl mb-2"
|
||||
>
|
||||
{{ t('already_paid') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<button
|
||||
v-if="sessionId"
|
||||
v-if="isProcessing"
|
||||
type="button"
|
||||
disabled
|
||||
class="base-button w-full modal-save-button"
|
||||
@@ -49,8 +49,8 @@
|
||||
v-else
|
||||
:id="field.uuid"
|
||||
type="button"
|
||||
class="btn bg-[#7B73FF] text-white hover:bg-[#0A2540] text-lg w-full"
|
||||
:class="{ disabled: isCreatingCheckout }"
|
||||
class="btn text-white text-lg w-full"
|
||||
:class="[isPaypal ? 'bg-[#003087] hover:bg-[#012169]' : 'bg-[#7B73FF] hover:bg-[#0A2540]', { disabled: isCreatingCheckout }]"
|
||||
:disabled="isCreatingCheckout"
|
||||
@click.prevent="postCheckout"
|
||||
>
|
||||
@@ -59,12 +59,16 @@
|
||||
width="22"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<IconBrandPaypal
|
||||
v-else-if="isPaypal"
|
||||
width="22"
|
||||
/>
|
||||
<IconBrandStripe
|
||||
v-else
|
||||
width="22"
|
||||
/>
|
||||
<span>
|
||||
{{ t('pay_with_stripe') }}
|
||||
{{ isPaypal ? t('pay_with_paypal') : t('pay_with_stripe') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -72,13 +76,14 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconBrandStripe, IconInnerShadowTop, IconLoader } from '@tabler/icons-vue'
|
||||
import { IconBrandStripe, IconBrandPaypal, IconInnerShadowTop, IconLoader } from '@tabler/icons-vue'
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
export default {
|
||||
name: 'PaymentStep',
|
||||
components: {
|
||||
IconBrandStripe,
|
||||
IconBrandPaypal,
|
||||
MarkdownContent,
|
||||
IconInnerShadowTop,
|
||||
IconLoader
|
||||
@@ -111,12 +116,18 @@ export default {
|
||||
submitterSlug: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
provider: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['focus', 'submit', 'update:model-value', 'attached'],
|
||||
data () {
|
||||
return {
|
||||
isCreatingCheckout: false,
|
||||
isProcessing: false,
|
||||
isMathLoaded: false
|
||||
}
|
||||
},
|
||||
@@ -131,8 +142,14 @@ export default {
|
||||
queryParams () {
|
||||
return new URLSearchParams(window.location.search)
|
||||
},
|
||||
isPaypal () {
|
||||
return this.provider === 'paypal'
|
||||
},
|
||||
paymentEndpoint () {
|
||||
return this.isPaypal ? '/api/paypal_payments' : '/api/stripe_payments'
|
||||
},
|
||||
sessionId () {
|
||||
return this.queryParams.get('stripe_session_id')
|
||||
return this.queryParams.get('stripe_session_id') || this.queryParams.get('paypal_order_id') || (this.isPaypal && this.queryParams.get('token'))
|
||||
},
|
||||
defaultName () {
|
||||
if (this.field.preferences?.price_id || this.field.preferences?.payment_link_id) {
|
||||
@@ -159,6 +176,8 @@ export default {
|
||||
},
|
||||
async mounted () {
|
||||
if (this.sessionId) {
|
||||
this.isProcessing = true
|
||||
|
||||
this.$emit('submit')
|
||||
}
|
||||
|
||||
@@ -175,6 +194,21 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
normalizedPaymentUrl () {
|
||||
const url = new URL(window.location.href)
|
||||
const keys = ['stripe_session_id', 'paypal_order_id', 'field_uuid']
|
||||
|
||||
if (this.isPaypal) {
|
||||
keys.push('token', 'PayerID')
|
||||
}
|
||||
|
||||
keys.forEach((key) => url.searchParams.delete(key))
|
||||
|
||||
return url.toString()
|
||||
},
|
||||
normalizedPaymentParams () {
|
||||
window.history.replaceState({}, document.title, this.normalizedPaymentUrl())
|
||||
},
|
||||
calculateFormula () {
|
||||
const transformedFormula = this.normalizeFormula(this.field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
return this.readonlyValues[uuid] || this.values[uuid] || 0.0
|
||||
@@ -195,7 +229,7 @@ export default {
|
||||
},
|
||||
async submit () {
|
||||
if (this.sessionId) {
|
||||
return fetch(this.baseUrl + '/api/stripe_payments/' + this.sessionId, {
|
||||
return fetch(this.baseUrl + this.paymentEndpoint + '/' + this.sessionId, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug
|
||||
@@ -203,16 +237,22 @@ export default {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then(async (resp) => {
|
||||
if (resp.status === 422 || resp.status === 500) {
|
||||
this.isProcessing = false
|
||||
|
||||
const data = await resp.json()
|
||||
|
||||
alert(data.error || 'Unexpected error')
|
||||
if (data.error) {
|
||||
alert(data.error)
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(data.error))
|
||||
}
|
||||
|
||||
const attachment = await resp.json()
|
||||
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
this.isProcessing = false
|
||||
|
||||
this.normalizedPaymentParams()
|
||||
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
this.$emit('attached', attachment)
|
||||
@@ -226,13 +266,13 @@ export default {
|
||||
postCheckout ({ checkStatus } = {}) {
|
||||
this.isCreatingCheckout = true
|
||||
|
||||
fetch(this.baseUrl + '/api/stripe_payments', {
|
||||
fetch(this.baseUrl + this.paymentEndpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug,
|
||||
field_uuid: this.field.uuid,
|
||||
check_status: checkStatus,
|
||||
referer: document.location.href
|
||||
referer: this.normalizedPaymentUrl()
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then(async (resp) => {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
required
|
||||
maxlength="6"
|
||||
autofocus
|
||||
inputmode="decimal"
|
||||
inputmode="numeric"
|
||||
@input="onInputCode"
|
||||
>
|
||||
<div class="flex justify-between mt-2 -mb-2 md:-mb-4">
|
||||
@@ -81,6 +81,7 @@
|
||||
<select
|
||||
id="country_code_select"
|
||||
class="absolute top-0 bottom-0 right-0 left-0 opacity-0 w-full h-full cursor-pointer"
|
||||
:aria-label="t('country_code')"
|
||||
:disabled="!!defaultValue"
|
||||
@change="onCountrySelect(countries.find((country) => country.flag === $event.target.value))"
|
||||
>
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
>
|
||||
<button
|
||||
id="type_text_button"
|
||||
ref="typeTextButton"
|
||||
type="button"
|
||||
:aria-label="t('draw_signature')"
|
||||
class="btn btn-outline btn-sm font-medium type-text-button"
|
||||
@@ -49,6 +50,7 @@
|
||||
>
|
||||
<button
|
||||
id="type_text_button"
|
||||
ref="typeTextButton"
|
||||
type="button"
|
||||
:aria-label="t('type_text')"
|
||||
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap type-text-button"
|
||||
@@ -71,7 +73,7 @@
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('take_photo')"
|
||||
:aria-label="t('upload')"
|
||||
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"
|
||||
@click="$refs.takePhotoInput.click()"
|
||||
>
|
||||
@@ -199,7 +201,7 @@
|
||||
<canvas
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
ref="canvas"
|
||||
role="application"
|
||||
role="img"
|
||||
:aria-label="t('signature_drawing_area')"
|
||||
style="padding: 1px; 0"
|
||||
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
|
||||
@@ -228,6 +230,8 @@
|
||||
>
|
||||
<canvas
|
||||
ref="qrCanvas"
|
||||
role="img"
|
||||
:aria-label="t('scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature')"
|
||||
class="h-full"
|
||||
width="132"
|
||||
height="132"
|
||||
@@ -242,7 +246,8 @@
|
||||
ref="textInput"
|
||||
class="base-input !text-2xl w-full mt-6"
|
||||
:required="field.required && !isSignatureStarted"
|
||||
:placeholder="`${t('type_signature_here')}...`"
|
||||
:aria-label="field.name || t('signature')"
|
||||
:placeholder="`${t('type_signature_here')}${field.required ? '...' : ' (' + t('optional') + ')'}`"
|
||||
type="text"
|
||||
@input="updateWrittenSignature"
|
||||
>
|
||||
@@ -251,6 +256,7 @@
|
||||
class="select base-input !text-2xl w-full mt-6 text-center"
|
||||
:class="{ 'text-gray-300': !reason }"
|
||||
required
|
||||
:aria-label="t('select_a_reason')"
|
||||
:name="`values[${field.preferences.reason_field_uuid}]`"
|
||||
@change="$event.target.value === 'other' ? [reason = '', isOtherReason = true] : $emit('update:reason', $event.target.value)"
|
||||
>
|
||||
@@ -296,6 +302,7 @@
|
||||
class="base-input !text-2xl w-full mt-6"
|
||||
required
|
||||
:name="`values[${field.preferences.reason_field_uuid}]`"
|
||||
:aria-label="t('select_a_reason')"
|
||||
:placeholder="t('type_here_')"
|
||||
:value="reason"
|
||||
type="text"
|
||||
@@ -528,7 +535,7 @@ export default {
|
||||
this.$nextTick(() => this.drawSignatureSrc())
|
||||
} else if (this.isTextSignature) {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.textInput) {
|
||||
if (this.$refs.textInput && this.field.required === true) {
|
||||
this.initTypedSignature()
|
||||
}
|
||||
})
|
||||
@@ -716,10 +723,12 @@ export default {
|
||||
this.clear()
|
||||
this.isTextSignature = !this.isTextSignature
|
||||
|
||||
if (this.isTextSignature) {
|
||||
this.$nextTick(() => {
|
||||
this.$nextTick(() => {
|
||||
if (this.isTextSignature) {
|
||||
if (this.$refs.textInput) {
|
||||
if (!this.submitter.name) {
|
||||
if (this.submitter.name) {
|
||||
this.$refs.typeTextButton?.focus()
|
||||
} else {
|
||||
this.$refs.textInput.focus()
|
||||
}
|
||||
|
||||
@@ -727,8 +736,10 @@ export default {
|
||||
|
||||
this.$emit('start')
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
this.$refs.typeTextButton?.focus()
|
||||
}
|
||||
})
|
||||
},
|
||||
async initTypedSignature () {
|
||||
if (this.signatureText) {
|
||||
|
||||
@@ -319,7 +319,7 @@ export default {
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title'],
|
||||
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title', 'multi-select'],
|
||||
data () {
|
||||
return {
|
||||
isContenteditable: false,
|
||||
@@ -726,6 +726,10 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.inputMode && (this.isValueInput || this.isCheckboxInput || this.isSelectInput)) {
|
||||
return
|
||||
}
|
||||
|
||||
document.activeElement?.blur()
|
||||
|
||||
e.preventDefault()
|
||||
@@ -794,6 +798,8 @@ export default {
|
||||
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(this.area), 1)
|
||||
}
|
||||
|
||||
this.$emit('multi-select', e)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
v-if="withSendButton"
|
||||
id="send_button"
|
||||
:href="`/templates/${template.id}/submissions/new?with_link=true`"
|
||||
data-turbo-frame="modal"
|
||||
:data-turbo-frame="isMobile && isBreakpointLg ? '_top' : 'modal'"
|
||||
class="white-button md:!px-6"
|
||||
@click="maybeShowErrorTemplateAlert"
|
||||
>
|
||||
@@ -202,7 +202,7 @@
|
||||
</button>
|
||||
<div
|
||||
class="dropdown dropdown-end"
|
||||
:class="{ 'dropdown-open': isDownloading }"
|
||||
:class="{ 'dropdown-open': isDownloading || isPreviewLoading }"
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
@@ -221,15 +221,23 @@
|
||||
:href="`/templates/${template.id}/form`"
|
||||
data-turbo="false"
|
||||
class="flex items-center justify-center space-x-2"
|
||||
@click.exact="isPreviewLoading = true"
|
||||
>
|
||||
<IconEye class="w-6 h-6 flex-shrink-0" />
|
||||
<IconInnerShadowTop
|
||||
v-if="isPreviewLoading"
|
||||
class="animate-spin w-6 h-6 flex-shrink-0"
|
||||
/>
|
||||
<IconEye
|
||||
v-else
|
||||
class="w-6 h-6 flex-shrink-0"
|
||||
/>
|
||||
<span class="whitespace-nowrap">{{ t('save_and_preview') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
:href="`/templates/${template.id}/preferences`"
|
||||
data-turbo-frame="modal"
|
||||
:data-turbo-frame="isMobile && isBreakpointLg ? '_top' : 'modal'"
|
||||
class="flex space-x-2"
|
||||
@click="closeDropdown"
|
||||
>
|
||||
@@ -281,6 +289,10 @@
|
||||
:href="`/templates/${template.id}`"
|
||||
class="base-button"
|
||||
>
|
||||
<IconX
|
||||
width="20"
|
||||
class="md:hidden stroke-2"
|
||||
/>
|
||||
<span class="hidden md:inline">
|
||||
{{ t('back') }}
|
||||
</span>
|
||||
@@ -320,6 +332,7 @@
|
||||
@replace="onDocumentReplace"
|
||||
@up="moveDocument(item, -1)"
|
||||
@reorder="reorderFields"
|
||||
@edit="editModalDocumentUuid = item.attachment_uuid"
|
||||
@down="moveDocument(item, 1)"
|
||||
@change="save"
|
||||
/>
|
||||
@@ -363,6 +376,7 @@
|
||||
isMobile ? 'overflow-y-auto' : 'overflow-y-hidden md:overflow-y-auto',
|
||||
zoomLevel > 1 ? 'overflow-x-auto' : 'overflow-x-hidden'
|
||||
]"
|
||||
@wheel="onPagesWheel"
|
||||
>
|
||||
<div
|
||||
ref="documents"
|
||||
@@ -684,6 +698,17 @@
|
||||
@close="isRevisionsModalOpen = false"
|
||||
@apply="onRevisionApply"
|
||||
/>
|
||||
<DocumentsEditorModal
|
||||
v-if="editModalDocumentUuid"
|
||||
:template="template"
|
||||
:authenticity-token="authenticityToken"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:base-url="baseUrl"
|
||||
:page-preview-format="pagePreviewFormat"
|
||||
:scroll-to-attachment-uuid="editModalDocumentUuid"
|
||||
@saved="onDocumentsModified"
|
||||
@close="editModalDocumentUuid = null"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -703,6 +728,7 @@ import DocumentControls from './controls'
|
||||
import MobileFields from './mobile_fields'
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import RevisionsModal from './revisions_modal'
|
||||
import DocumentsEditorModal from './documents_editor_modal'
|
||||
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory, IconX } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
|
||||
@@ -746,7 +772,8 @@ export default {
|
||||
IconHistory,
|
||||
IconDeviceFloppy,
|
||||
IconX,
|
||||
RevisionsModal
|
||||
RevisionsModal,
|
||||
DocumentsEditorModal
|
||||
},
|
||||
provide () {
|
||||
return {
|
||||
@@ -764,7 +791,10 @@ export default {
|
||||
withVerification: this.withVerification,
|
||||
withKba: this.withKba,
|
||||
withPayment: this.withPayment,
|
||||
isPaymentConnected: this.isPaymentConnected,
|
||||
isStripeConnected: this.isStripeConnected,
|
||||
withStripe: this.withStripe,
|
||||
withPaypal: this.withPaypal,
|
||||
isPaypalConnected: this.isPaypalConnected,
|
||||
withFormula: this.withFormula,
|
||||
withConditions: this.withConditions,
|
||||
withCustomFields: this.withCustomFields,
|
||||
@@ -1034,7 +1064,22 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
isPaymentConnected: {
|
||||
isStripeConnected: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withStripe: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withPaypal: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
isPaypalConnected: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
@@ -1085,6 +1130,7 @@ export default {
|
||||
documentRefs: [],
|
||||
isBreakpointLg: false,
|
||||
isDownloading: false,
|
||||
isPreviewLoading: false,
|
||||
isLoadingBlankPage: false,
|
||||
isSaving: false,
|
||||
isDetectingPageFields: false,
|
||||
@@ -1102,6 +1148,7 @@ export default {
|
||||
isDragFile: false,
|
||||
isMathLoaded: false,
|
||||
isRevisionsModalOpen: false,
|
||||
editModalDocumentUuid: null,
|
||||
revisions: [],
|
||||
beforeRevisionSnapshot: null,
|
||||
zoomLevel: 1
|
||||
@@ -1225,7 +1272,7 @@ export default {
|
||||
|
||||
return formulaFields.reduce((acc, f) => {
|
||||
if (this.conditionalFieldIndex[f.uuid] !== false) {
|
||||
acc[f.uuid] = this.calculateFormula(f)
|
||||
acc[f.uuid] = f.type === 'text' ? this.evalTextFormula(f) : this.calculateFormula(f)
|
||||
}
|
||||
|
||||
return acc
|
||||
@@ -1347,6 +1394,10 @@ export default {
|
||||
if (document.location.search?.includes('stripe_connect_success')) {
|
||||
document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
|
||||
}
|
||||
|
||||
if (document.location.search?.includes('paypal_connect_success')) {
|
||||
document.querySelector('a[href^="/auth/paypal_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
this.template.schema.forEach((item) => {
|
||||
@@ -1354,8 +1405,6 @@ export default {
|
||||
this.pendingFieldAttachmentUuids.push(item.attachment_uuid)
|
||||
}
|
||||
})
|
||||
|
||||
this.$refs.pagesContainer.addEventListener('wheel', this.onPagesWheel, { passive: false })
|
||||
},
|
||||
unmounted () {
|
||||
document.removeEventListener('keyup', this.onKeyUp)
|
||||
@@ -1363,8 +1412,6 @@ export default {
|
||||
|
||||
window.removeEventListener('resize', this.onWindowResize)
|
||||
window.removeEventListener('dragleave', this.onWindowDragLeave)
|
||||
|
||||
this.$refs.pagesContainer.removeEventListener('wheel', this.onPagesWheel)
|
||||
},
|
||||
beforeUpdate () {
|
||||
this.documentRefs = []
|
||||
@@ -1492,6 +1539,25 @@ export default {
|
||||
|
||||
return this.math.evaluate(transformedFormula.toLowerCase())
|
||||
},
|
||||
evalTextFormula (field, depth = 0) {
|
||||
if (depth > 10) return ''
|
||||
|
||||
return field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
const formulaField = this.fieldsUuidIndex[uuid]
|
||||
|
||||
if (formulaField?.preferences?.formula) {
|
||||
if (formulaField.type === 'text') {
|
||||
return this.evalTextFormula(formulaField, depth + 1)
|
||||
} else if (this.isMathLoaded) {
|
||||
return this.calculateFormula(formulaField)
|
||||
}
|
||||
}
|
||||
|
||||
const value = formulaField?.default_value
|
||||
|
||||
return Array.isArray(value) ? value.join(', ') : (value ?? '')
|
||||
})
|
||||
},
|
||||
hasFormulaDependencyValue (field) {
|
||||
const normalized = this.normalizeFormula(field.preferences.formula)
|
||||
|
||||
@@ -1681,7 +1747,7 @@ export default {
|
||||
|
||||
ref.x = e.clientX - ref.offsetX
|
||||
ref.y = e.clientY - ref.offsetY
|
||||
} else if (e.dataTransfer?.types?.includes('Files')) {
|
||||
} else if (e.dataTransfer?.types?.includes('Files') && !this.editModalDocumentUuid) {
|
||||
this.isDragFile = true
|
||||
}
|
||||
},
|
||||
@@ -2124,6 +2190,10 @@ export default {
|
||||
}
|
||||
},
|
||||
onKeyDown (event) {
|
||||
if (this.editModalDocumentUuid) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Tab' && document.activeElement === document.body) {
|
||||
event.stopImmediatePropagation()
|
||||
event.preventDefault()
|
||||
@@ -3142,6 +3212,22 @@ export default {
|
||||
onDocumentsReplaceAndTemplateClone (template) {
|
||||
window.Turbo.visit(`/templates/${template.id}/edit`)
|
||||
},
|
||||
onDocumentsModified (data) {
|
||||
this.template.schema = data.schema
|
||||
this.template.fields = data.fields
|
||||
this.template.submitters = data.submitters
|
||||
this.template.documents = data.documents
|
||||
|
||||
this.selectedAreasRef.value = []
|
||||
|
||||
if (!this.template.submitters.find((s) => s.uuid === this.selectedSubmitter?.uuid)) {
|
||||
this.selectedSubmitter = this.template.submitters[0]
|
||||
}
|
||||
|
||||
this.editModalDocumentUuid = null
|
||||
|
||||
this.save()
|
||||
},
|
||||
moveDocument (item, direction) {
|
||||
const currentIndex = this.template.schema.indexOf(item)
|
||||
|
||||
@@ -3174,15 +3260,6 @@ export default {
|
||||
e.preventDefault()
|
||||
|
||||
alert(this.t('please_draw_fields_to_prepare_the_document'))
|
||||
} else {
|
||||
const submitterWithoutFields =
|
||||
this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid))
|
||||
|
||||
if (submitterWithoutFields) {
|
||||
e.preventDefault()
|
||||
|
||||
alert(this.t('please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed').replaceAll('{submitter_name}', submitterWithoutFields.name))
|
||||
}
|
||||
}
|
||||
},
|
||||
onSaveClick () {
|
||||
@@ -3199,32 +3276,25 @@ export default {
|
||||
if (!this.template.fields.length) {
|
||||
alert(this.t('please_draw_fields_to_prepare_the_document'))
|
||||
} else {
|
||||
const submitterWithoutFields =
|
||||
this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid))
|
||||
this.isSaving = true
|
||||
|
||||
if (submitterWithoutFields) {
|
||||
alert(this.t('please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed').replaceAll('{submitter_name}', submitterWithoutFields.name))
|
||||
} else {
|
||||
this.isSaving = true
|
||||
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
|
||||
|
||||
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
|
||||
dynamicDocumentRefs.map((ref) => ref.update())
|
||||
|
||||
dynamicDocumentRefs.map((ref) => ref.update())
|
||||
this.rebuildVariablesSchema({ disable: false })
|
||||
|
||||
this.rebuildVariablesSchema({ disable: false })
|
||||
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
|
||||
|
||||
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
|
||||
Promise.all([this.save({ force: true }), ...dynamicDocumentSaves]).then(() => {
|
||||
if (this.withRevisions) {
|
||||
this.captureRevision()
|
||||
}
|
||||
|
||||
Promise.all([this.save({ force: true }), ...dynamicDocumentSaves]).then(() => {
|
||||
if (this.withRevisions) {
|
||||
this.captureRevision()
|
||||
}
|
||||
|
||||
window.Turbo.visit(`/templates/${this.template.id}`)
|
||||
}).finally(() => {
|
||||
this.isSaving = false
|
||||
})
|
||||
}
|
||||
window.Turbo.visit(`/templates/${this.template.id}`)
|
||||
}).finally(() => {
|
||||
this.isSaving = false
|
||||
})
|
||||
}
|
||||
},
|
||||
scrollToArea (area) {
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<div
|
||||
class="flex-1 min-h-0 flex items-center justify-center px-6 py-4"
|
||||
style="container-type: size"
|
||||
>
|
||||
<div
|
||||
ref="pageEl"
|
||||
class="relative select-none"
|
||||
:style="pageStyle"
|
||||
>
|
||||
<img
|
||||
:src="imageUrl"
|
||||
:width="metadata.width"
|
||||
:height="metadata.height"
|
||||
class="absolute border rounded pointer-events-none"
|
||||
style="left: 50%; top: 50%; width: 100%; height: auto"
|
||||
:style="imageStyle"
|
||||
>
|
||||
<svg
|
||||
class="absolute inset-0 w-full h-full pointer-events-none"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
:d="dimPath"
|
||||
fill="black"
|
||||
fill-opacity="0.4"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<polygon
|
||||
:points="polygonPoints"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
stroke-width="0.4"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
v-for="(corner, cornerIndex) in displayCorners"
|
||||
:key="cornerIndex"
|
||||
class="absolute w-5 h-5 -ml-2.5 -mt-2.5 rounded-full bg-white border-2 border-neutral-600 cursor-move shadow"
|
||||
:style="{ left: `${corner.x * 100}%`, top: `${corner.y * 100}%` }"
|
||||
@mousedown.prevent="onCornerMousedown(cornerIndex)"
|
||||
@touchstart.prevent="onCornerTouchstart(cornerIndex)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-56 flex-none border-l px-4 py-4 space-y-2 flex flex-col">
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
|
||||
:disabled="!!isProcessing"
|
||||
@click.prevent="submit(true)"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
v-if="isProcessing === 'scan'"
|
||||
class="w-4 h-4 animate-spin"
|
||||
/>
|
||||
<IconScan
|
||||
v-else
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
{{ t('crop_and_scan') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
|
||||
:disabled="!!isProcessing"
|
||||
@click.prevent="submit(false)"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
v-if="isProcessing === 'crop'"
|
||||
class="w-4 h-4 animate-spin"
|
||||
/>
|
||||
<IconCrop
|
||||
v-else
|
||||
width="22"
|
||||
height="22"
|
||||
style="margin-left: -3px"
|
||||
:stroke-width="1.5"
|
||||
/>
|
||||
<span :style="{ 'margin-left': isProcessing === 'crop' ? '0px' : '-3px' }">
|
||||
{{ t('crop') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
|
||||
@click.prevent="$emit('cancel')"
|
||||
>
|
||||
<IconX class="w-4 h-4" />
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<div class="border-t !mt-3 !mb-1" />
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
|
||||
@click.prevent="rotateCw"
|
||||
>
|
||||
<IconRotateClockwise class="w-4 h-4" />
|
||||
{{ t('rotate') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
|
||||
:class="{ 'btn-active': flipH }"
|
||||
@click.prevent="toggleFlip('flipH')"
|
||||
>
|
||||
<IconFlipVertical class="w-4 h-4" />
|
||||
{{ t('flip_horizontal') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
|
||||
:class="{ 'btn-active': flipV }"
|
||||
@click.prevent="toggleFlip('flipV')"
|
||||
>
|
||||
<IconFlipHorizontal class="w-4 h-4" />
|
||||
{{ t('flip_vertical') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconCrop, IconScan, IconInnerShadowTop, IconX, IconRotateClockwise, IconFlipHorizontal, IconFlipVertical } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'DocumentsEditorCrop',
|
||||
components: {
|
||||
IconCrop,
|
||||
IconScan,
|
||||
IconInnerShadowTop,
|
||||
IconX,
|
||||
IconRotateClockwise,
|
||||
IconFlipHorizontal,
|
||||
IconFlipVertical
|
||||
},
|
||||
inject: ['t', 'baseFetch', 'isInlineSize'],
|
||||
props: {
|
||||
templateId: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
page: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
imageUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
metadata: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['apply', 'cancel'],
|
||||
data () {
|
||||
return {
|
||||
corners: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 0, y: 1 }
|
||||
],
|
||||
cornersTouched: false,
|
||||
rotate: this.page.rotate || 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
draggingIndex: null,
|
||||
isProcessing: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
displayWidth () {
|
||||
return this.rotate % 180 ? this.metadata.height : this.metadata.width
|
||||
},
|
||||
displayHeight () {
|
||||
return this.rotate % 180 ? this.metadata.width : this.metadata.height
|
||||
},
|
||||
pageStyle () {
|
||||
const ratio = this.displayWidth / this.displayHeight
|
||||
|
||||
return {
|
||||
aspectRatio: `${this.displayWidth} / ${this.displayHeight}`,
|
||||
width: this.isInlineSize ? `min(100cqw, calc(100cqh * ${ratio}))` : `min(100%, calc(78vh * ${ratio}))`
|
||||
}
|
||||
},
|
||||
imageStyle () {
|
||||
const scale = this.rotate % 180 ? this.metadata.width / this.metadata.height : 1
|
||||
const scaleX = (this.flipH ? -1 : 1) * scale
|
||||
const scaleY = (this.flipV ? -1 : 1) * scale
|
||||
|
||||
return {
|
||||
transform: `translate(-50%, -50%) rotate(${this.rotate}deg) scale(${scaleX}, ${scaleY})`
|
||||
}
|
||||
},
|
||||
displayCorners () {
|
||||
return this.corners.map((corner) => this.transformPoint(corner, this.rotate, this.flipH, this.flipV))
|
||||
},
|
||||
polygonPoints () {
|
||||
return this.displayCorners.map((corner) => `${corner.x * 100},${corner.y * 100}`).join(' ')
|
||||
},
|
||||
dimPath () {
|
||||
const quad = this.displayCorners.map((corner) => `${corner.x * 100} ${corner.y * 100}`)
|
||||
|
||||
return `M 0 0 H 100 V 100 H 0 Z M ${quad.join(' L ')} Z`
|
||||
}
|
||||
},
|
||||
created () {
|
||||
const query = new URLSearchParams({ attachment_uuid: this.page.sourceUuid })
|
||||
|
||||
this.baseFetch(`/templates/${this.templateId}/documents_crop?${query}`).then(async (resp) => {
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
|
||||
if (data.corners?.length === 4 && !this.cornersTouched) {
|
||||
this.corners = data.corners.map((corner) => ({ x: corner.x, y: corner.y }))
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
beforeUnmount () {
|
||||
window.removeEventListener('mousemove', this.onMousemove)
|
||||
window.removeEventListener('mouseup', this.onMouseup)
|
||||
window.removeEventListener('touchmove', this.onTouchmove)
|
||||
window.removeEventListener('touchend', this.onTouchend)
|
||||
window.removeEventListener('touchcancel', this.onTouchend)
|
||||
},
|
||||
methods: {
|
||||
transformPoint (point, rotate, flipH, flipV) {
|
||||
let { x, y } = point
|
||||
|
||||
if (flipH) {
|
||||
x = 1 - x
|
||||
}
|
||||
|
||||
if (flipV) {
|
||||
y = 1 - y
|
||||
}
|
||||
|
||||
if (rotate === 90) {
|
||||
return { x: 1 - y, y: x }
|
||||
} else if (rotate === 180) {
|
||||
return { x: 1 - x, y: 1 - y }
|
||||
} else if (rotate === 270) {
|
||||
return { x: y, y: 1 - x }
|
||||
} else {
|
||||
return { x, y }
|
||||
}
|
||||
},
|
||||
inverseTransformPoint (point, rotate, flipH, flipV) {
|
||||
let { x, y } = point
|
||||
|
||||
if (rotate === 90) {
|
||||
[x, y] = [y, 1 - x]
|
||||
} else if (rotate === 180) {
|
||||
[x, y] = [1 - x, 1 - y]
|
||||
} else if (rotate === 270) {
|
||||
[x, y] = [1 - y, x]
|
||||
}
|
||||
|
||||
if (flipH) {
|
||||
x = 1 - x
|
||||
}
|
||||
|
||||
if (flipV) {
|
||||
y = 1 - y
|
||||
}
|
||||
|
||||
return { x, y }
|
||||
},
|
||||
rotateCw () {
|
||||
this.rotate = (this.rotate + 90) % 360
|
||||
},
|
||||
toggleFlip (key) {
|
||||
this[key] = !this[key]
|
||||
},
|
||||
pagePoint (event) {
|
||||
const rect = this.$refs.pageEl.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
x: Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1),
|
||||
y: Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 1)
|
||||
}
|
||||
},
|
||||
startCornerDrag (index) {
|
||||
this.draggingIndex = index
|
||||
this.cornersTouched = true
|
||||
},
|
||||
dragCorner (point) {
|
||||
if (this.draggingIndex === null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.corners[this.draggingIndex] = this.inverseTransformPoint(this.pagePoint(point), this.rotate, this.flipH, this.flipV)
|
||||
},
|
||||
onCornerMousedown (index) {
|
||||
this.startCornerDrag(index)
|
||||
|
||||
window.addEventListener('mousemove', this.onMousemove)
|
||||
window.addEventListener('mouseup', this.onMouseup, { once: true })
|
||||
},
|
||||
onMousemove (event) {
|
||||
this.dragCorner(event)
|
||||
},
|
||||
onMouseup () {
|
||||
window.removeEventListener('mousemove', this.onMousemove)
|
||||
|
||||
this.draggingIndex = null
|
||||
},
|
||||
onCornerTouchstart (index) {
|
||||
this.startCornerDrag(index)
|
||||
|
||||
window.addEventListener('touchmove', this.onTouchmove, { passive: false })
|
||||
window.addEventListener('touchend', this.onTouchend)
|
||||
window.addEventListener('touchcancel', this.onTouchend)
|
||||
},
|
||||
onTouchmove (event) {
|
||||
this.dragCorner(event.touches[0])
|
||||
},
|
||||
onTouchend () {
|
||||
window.removeEventListener('touchmove', this.onTouchmove)
|
||||
window.removeEventListener('touchend', this.onTouchend)
|
||||
window.removeEventListener('touchcancel', this.onTouchend)
|
||||
|
||||
this.draggingIndex = null
|
||||
},
|
||||
submit (scan) {
|
||||
this.isProcessing = scan ? 'scan' : 'crop'
|
||||
|
||||
this.baseFetch(`/templates/${this.templateId}/documents_crop`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
attachment_uuid: this.page.sourceUuid,
|
||||
corners: this.corners,
|
||||
rotate: this.rotate || undefined,
|
||||
flip_h: this.flipH,
|
||||
flip_v: this.flipV,
|
||||
scan
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then(async (resp) => {
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
|
||||
if (resp.ok) {
|
||||
this.$emit('apply', data.document)
|
||||
} else if (data.error) {
|
||||
alert(data.error)
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isProcessing = null
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="relative"
|
||||
:style="boxStyle"
|
||||
>
|
||||
<img
|
||||
:src="imageUrl"
|
||||
:width="metadata.width"
|
||||
:height="metadata.height"
|
||||
class="rounded border pointer-events-none outline outline-1 -outline-offset-1 transition-[outline-color] duration-75"
|
||||
:class="[
|
||||
page.rotate % 180 ? 'absolute inset-0 m-auto w-full' : 'w-full',
|
||||
selected ? 'outline-neutral-400' : 'outline-transparent'
|
||||
]"
|
||||
:style="imageStyle"
|
||||
:loading="lazy ? 'lazy' : 'eager'"
|
||||
>
|
||||
<div
|
||||
v-if="areas.length || page.redact.length"
|
||||
class="absolute pointer-events-none"
|
||||
:style="overlayStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(item, areaIndex) in areas"
|
||||
:key="areaIndex"
|
||||
class="absolute border rounded-sm opacity-70"
|
||||
:class="[areaBorderColor(item.submitterIndex), areaBgColor(item.submitterIndex)]"
|
||||
:style="{
|
||||
left: `${item.area.x * 100}%`,
|
||||
top: `${item.area.y * 100}%`,
|
||||
width: `${item.area.w * 100}%`,
|
||||
height: `${item.area.h * 100}%`
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
v-for="(rect, rectIndex) in page.redact"
|
||||
:key="`redact-${rectIndex}`"
|
||||
class="absolute"
|
||||
:class="rect.color === 'white' ? 'bg-white' : 'bg-black'"
|
||||
:style="{
|
||||
left: `${rect.x * 100}%`,
|
||||
top: `${rect.y * 100}%`,
|
||||
width: `${rect.w * 100}%`,
|
||||
height: `${rect.h * 100}%`
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="withActions"
|
||||
class="absolute top-1 right-1 flex space-x-1 group-hover:opacity-100"
|
||||
:class="selected ? 'opacity-100' : 'opacity-0'"
|
||||
>
|
||||
<span
|
||||
v-if="extraAction"
|
||||
class="tooltip tooltip-bottom"
|
||||
:data-tip="t(extraAction)"
|
||||
>
|
||||
<button
|
||||
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content transition-colors p-0"
|
||||
style="width: 22px; height: 22px; min-height: 22px"
|
||||
@click.stop.prevent="$emit(extraAction)"
|
||||
>
|
||||
<IconEraser
|
||||
v-if="extraAction === 'redact'"
|
||||
:width="14"
|
||||
:height="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<IconCrop
|
||||
v-else
|
||||
:width="20"
|
||||
:height="20"
|
||||
:stroke-width="1.2"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
class="tooltip tooltip-bottom"
|
||||
:data-tip="t('rotate')"
|
||||
>
|
||||
<button
|
||||
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content transition-colors p-0"
|
||||
style="width: 22px; height: 22px; min-height: 22px"
|
||||
@click.stop.prevent="$emit('rotate')"
|
||||
>
|
||||
<IconRotateClockwise
|
||||
:width="14"
|
||||
:height="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
class="tooltip tooltip-bottom"
|
||||
:data-tip="t('remove')"
|
||||
>
|
||||
<button
|
||||
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content transition-colors p-0"
|
||||
style="width: 22px; height: 22px; min-height: 22px"
|
||||
@click.stop.prevent="$emit('remove')"
|
||||
>
|
||||
<IconX
|
||||
:width="14"
|
||||
:height="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="pageNumber"
|
||||
class="text-center text-sm pt-1 pointer-events-none"
|
||||
>
|
||||
{{ t('page') }} {{ pageNumber }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconRotateClockwise, IconX, IconEraser, IconCrop } from '@tabler/icons-vue'
|
||||
import Area from './area.vue'
|
||||
|
||||
export default {
|
||||
name: 'DocumentsEditorPage',
|
||||
components: {
|
||||
IconRotateClockwise,
|
||||
IconX,
|
||||
IconEraser,
|
||||
IconCrop
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
page: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
imageUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
metadata: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
areas: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
selected: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withActions: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
pageNumber: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
lazy: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
extraAction: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['rotate', 'remove', 'redact', 'crop'],
|
||||
computed: {
|
||||
borderColors: Area.computed.borderColors,
|
||||
bgColors: Area.computed.bgColors,
|
||||
boxStyle () {
|
||||
if (!this.page.rotate || !(this.page.rotate % 180)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { aspectRatio: `${this.metadata.height} / ${this.metadata.width}` }
|
||||
},
|
||||
imageStyle () {
|
||||
if (!this.page.rotate) {
|
||||
return null
|
||||
}
|
||||
|
||||
let transform = `rotate(${this.page.rotate}deg)`
|
||||
|
||||
if (this.page.rotate % 180) {
|
||||
transform += ` scale(${this.metadata.width / this.metadata.height})`
|
||||
}
|
||||
|
||||
return { transform }
|
||||
},
|
||||
overlayStyle () {
|
||||
if (!this.page.rotate || !(this.page.rotate % 180)) {
|
||||
return { inset: '0', transform: this.page.rotate ? `rotate(${this.page.rotate}deg)` : undefined }
|
||||
}
|
||||
|
||||
return {
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: '100%',
|
||||
aspectRatio: `${this.metadata.width} / ${this.metadata.height}`,
|
||||
transform: `translate(-50%, -50%) rotate(${this.page.rotate}deg) scale(${this.metadata.width / this.metadata.height})`
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
areaBorderColor (submitterIndex) {
|
||||
return this.borderColors[Math.max(submitterIndex, 0) % this.borderColors.length]
|
||||
},
|
||||
areaBgColor (submitterIndex) {
|
||||
return this.bgColors[Math.max(submitterIndex, 0) % this.bgColors.length]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,490 @@
|
||||
<template>
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div
|
||||
ref="pageEl"
|
||||
class="relative mx-auto select-none cursor-crosshair"
|
||||
:style="pageStyle"
|
||||
@mousedown.prevent="onMousedown"
|
||||
@touchstart.prevent="onTouchstart"
|
||||
>
|
||||
<img
|
||||
:src="imageUrl"
|
||||
:width="metadata.width"
|
||||
:height="metadata.height"
|
||||
class="absolute border rounded pointer-events-none"
|
||||
style="left: 50%; top: 50%; width: 100%; height: auto"
|
||||
:style="imageStyle"
|
||||
>
|
||||
<div
|
||||
class="absolute pointer-events-none"
|
||||
:style="overlayStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(rect, rectIndex) in redactRects"
|
||||
:key="`rect-${rectIndex}`"
|
||||
class="absolute pointer-events-none"
|
||||
:class="color === 'white' ? 'bg-white' : 'bg-black'"
|
||||
:style="{
|
||||
left: `${rect.x * 100}%`,
|
||||
top: `${rect.y * 100}%`,
|
||||
width: `${rect.w * 100}%`,
|
||||
height: `${rect.h * 100}%`
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="marquee"
|
||||
class="absolute border border-neutral-600 bg-neutral-600/10 pointer-events-none"
|
||||
:style="marqueeStyle"
|
||||
/>
|
||||
<div
|
||||
v-if="!imagePage && textNodes && !textNodes.length && !imageNodes.length"
|
||||
class="absolute inset-x-0 top-0 flex justify-center pt-4 pointer-events-none"
|
||||
>
|
||||
<span class="bg-base-100/90 border border-neutral-200 rounded-lg shadow px-4 py-2 text-sm">
|
||||
{{ t('there_is_no_text_to_redact_on_this_page') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-56 flex-none border-l px-4 py-4 space-y-2">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm pl-1">{{ t('color') }}</span>
|
||||
<div
|
||||
class="join rounded"
|
||||
style="height: 28px"
|
||||
>
|
||||
<button
|
||||
v-for="option in colors"
|
||||
:key="option"
|
||||
class="btn btn-sm join-item !h-7 !min-h-0 bg-white input-bordered hover:border-base-content/20 hover:bg-base-100/50 px-2"
|
||||
:class="{ '!bg-base-200': color === option }"
|
||||
@click.prevent="color = option"
|
||||
>
|
||||
<span
|
||||
class="block w-10 h-4 border border-base-content/30"
|
||||
:style="{ backgroundColor: option }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
|
||||
:disabled="!hasRedactions && !wasReset"
|
||||
@click.prevent="apply"
|
||||
>
|
||||
<IconCheck class="w-4 h-4" />
|
||||
{{ t('apply') }}
|
||||
</button>
|
||||
<div class="border-t !mt-3 !mb-1" />
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
|
||||
:disabled="!hasRedactions"
|
||||
@click.prevent="reset"
|
||||
>
|
||||
<IconRotate class="w-4 h-4" />
|
||||
{{ t('reset') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
|
||||
@click.prevent="$emit('cancel')"
|
||||
>
|
||||
<IconX class="w-4 h-4" />
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconCheck, IconRotate, IconX } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'DocumentsEditorRedact',
|
||||
components: {
|
||||
IconCheck,
|
||||
IconRotate,
|
||||
IconX
|
||||
},
|
||||
inject: ['t', 'baseFetch'],
|
||||
props: {
|
||||
templateId: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
page: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
imageUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
metadata: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
imagePage: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
pageObjectsCache: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
emits: ['apply', 'cancel'],
|
||||
data () {
|
||||
return {
|
||||
textNodes: null,
|
||||
imageNodes: [],
|
||||
selectedNodes: {},
|
||||
freeRects: [],
|
||||
rects: [],
|
||||
color: 'black',
|
||||
wasReset: false,
|
||||
marquee: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
colors () {
|
||||
return ['black', 'white']
|
||||
},
|
||||
rotate () {
|
||||
return this.page.rotate || 0
|
||||
},
|
||||
displayWidth () {
|
||||
return this.rotate % 180 ? this.metadata.height : this.metadata.width
|
||||
},
|
||||
displayHeight () {
|
||||
return this.rotate % 180 ? this.metadata.width : this.metadata.height
|
||||
},
|
||||
pageStyle () {
|
||||
return { aspectRatio: `${this.displayWidth} / ${this.displayHeight}` }
|
||||
},
|
||||
imageStyle () {
|
||||
const scale = this.rotate % 180 ? this.metadata.width / this.metadata.height : 1
|
||||
|
||||
return {
|
||||
transform: `translate(-50%, -50%) rotate(${this.rotate}deg) scale(${scale})`
|
||||
}
|
||||
},
|
||||
overlayStyle () {
|
||||
if (!this.rotate || !(this.rotate % 180)) {
|
||||
return { inset: '0', transform: this.rotate ? `rotate(${this.rotate}deg)` : undefined }
|
||||
}
|
||||
|
||||
return {
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: '100%',
|
||||
aspectRatio: `${this.metadata.width} / ${this.metadata.height}`,
|
||||
transform: `translate(-50%, -50%) rotate(${this.rotate}deg) scale(${this.metadata.width / this.metadata.height})`
|
||||
}
|
||||
},
|
||||
hasRedactions () {
|
||||
if (this.imagePage) {
|
||||
return this.rects.length > 0
|
||||
}
|
||||
|
||||
return Object.keys(this.selectedNodes).length > 0 || this.freeRects.length > 0
|
||||
},
|
||||
redactRects () {
|
||||
if (this.imagePage) {
|
||||
return this.rects
|
||||
}
|
||||
|
||||
return this.textNodes ? [...this.buildRedactRects(), ...this.freeRects] : []
|
||||
},
|
||||
marqueeStyle () {
|
||||
const left = Math.min(this.marquee.x1, this.marquee.x2)
|
||||
const top = Math.min(this.marquee.y1, this.marquee.y2)
|
||||
|
||||
return {
|
||||
left: `${left * 100}%`,
|
||||
top: `${top * 100}%`,
|
||||
width: `${Math.abs(this.marquee.x2 - this.marquee.x1) * 100}%`,
|
||||
height: `${Math.abs(this.marquee.y2 - this.marquee.y1) * 100}%`
|
||||
}
|
||||
}
|
||||
},
|
||||
created () {
|
||||
if ((this.page.redact || []).some((rect) => rect.color === 'white')) {
|
||||
this.color = 'white'
|
||||
}
|
||||
|
||||
if (this.imagePage) {
|
||||
this.rects = (this.page.redact || []).map((rect) => ({ ...rect }))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const cacheKey = `${this.page.sourceUuid}-${this.page.sourcePage}`
|
||||
|
||||
if (this.pageObjectsCache[cacheKey]) {
|
||||
this.textNodes = this.pageObjectsCache[cacheKey].text_nodes
|
||||
this.imageNodes = this.pageObjectsCache[cacheKey].image_nodes
|
||||
|
||||
this.preselectNodes()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const query = new URLSearchParams({ attachment_uuid: this.page.sourceUuid, page: this.page.sourcePage })
|
||||
|
||||
this.baseFetch(`/templates/${this.templateId}/documents_page_objects?${query}`).then(async (resp) => {
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
|
||||
this.pageObjectsCache[cacheKey] = data
|
||||
this.textNodes = data.text_nodes
|
||||
this.imageNodes = data.image_nodes
|
||||
|
||||
this.preselectNodes()
|
||||
} else {
|
||||
this.$emit('cancel')
|
||||
}
|
||||
})
|
||||
},
|
||||
beforeUnmount () {
|
||||
window.removeEventListener('mousemove', this.onMousemove)
|
||||
window.removeEventListener('mouseup', this.onMouseup)
|
||||
window.removeEventListener('touchmove', this.onTouchmove)
|
||||
window.removeEventListener('touchend', this.onTouchend)
|
||||
window.removeEventListener('touchcancel', this.onTouchend)
|
||||
},
|
||||
methods: {
|
||||
inverseRotatePoint (point, rotate) {
|
||||
let { x, y } = point
|
||||
|
||||
if (rotate === 90) {
|
||||
[x, y] = [y, 1 - x]
|
||||
} else if (rotate === 180) {
|
||||
[x, y] = [1 - x, 1 - y]
|
||||
} else if (rotate === 270) {
|
||||
[x, y] = [1 - y, x]
|
||||
}
|
||||
|
||||
return { x, y }
|
||||
},
|
||||
apply () {
|
||||
const rects = this.redactRects.map((rect) => {
|
||||
const next = { x: rect.x, y: rect.y, w: rect.w, h: rect.h }
|
||||
|
||||
if (this.color === 'white') {
|
||||
next.color = 'white'
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
this.$emit('apply', rects)
|
||||
},
|
||||
reset () {
|
||||
this.wasReset = true
|
||||
|
||||
if (this.imagePage) {
|
||||
this.rects = []
|
||||
} else {
|
||||
this.selectedNodes = {}
|
||||
this.freeRects = []
|
||||
}
|
||||
},
|
||||
boxesIntersect (a, b) {
|
||||
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
|
||||
},
|
||||
preselectNodes () {
|
||||
const nodeRects = []
|
||||
|
||||
;(this.page.redact || []).forEach((rect) => {
|
||||
if (this.imageNodes.some((node) => this.boxesIntersect(node, rect))) {
|
||||
this.freeRects.push({ ...rect })
|
||||
} else {
|
||||
nodeRects.push(rect)
|
||||
}
|
||||
})
|
||||
|
||||
this.textNodes.forEach((node, index) => {
|
||||
const centerX = node.x + (node.w / 2)
|
||||
const centerY = node.y + (node.h / 2)
|
||||
|
||||
const isInside = nodeRects.some((rect) => {
|
||||
return centerX >= rect.x && centerX <= rect.x + rect.w &&
|
||||
centerY >= rect.y && centerY <= rect.y + rect.h
|
||||
})
|
||||
|
||||
if (isInside) {
|
||||
this.selectedNodes[index] = true
|
||||
}
|
||||
})
|
||||
},
|
||||
buildRedactRects () {
|
||||
const nodes = this.textNodes.filter((_, index) => this.selectedNodes[index])
|
||||
|
||||
const sorted = nodes.slice().sort((a, b) => {
|
||||
const diff = (a.y + (a.h / 2)) - (b.y + (b.h / 2))
|
||||
|
||||
return Math.abs(diff) < Math.min(a.h, b.h) / 2 ? a.x - b.x : diff
|
||||
})
|
||||
|
||||
const rects = []
|
||||
|
||||
sorted.forEach((node) => {
|
||||
const last = rects[rects.length - 1]
|
||||
|
||||
const sameLine = last &&
|
||||
Math.abs((node.y + (node.h / 2)) - (last.y + (last.h / 2))) < Math.max(last.h, node.h) / 2
|
||||
|
||||
if (sameLine && node.x <= last.x + last.w + (node.h * 0.7)) {
|
||||
const right = Math.max(last.x + last.w, node.x + node.w)
|
||||
const bottom = Math.max(last.y + last.h, node.y + node.h)
|
||||
|
||||
last.x = Math.min(last.x, node.x)
|
||||
last.y = Math.min(last.y, node.y)
|
||||
last.w = right - last.x
|
||||
last.h = bottom - last.y
|
||||
} else {
|
||||
rects.push({ x: node.x, y: node.y, w: node.w, h: node.h })
|
||||
}
|
||||
})
|
||||
|
||||
return rects
|
||||
},
|
||||
pagePoint (event) {
|
||||
const rect = this.$refs.pageEl.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
x: Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1),
|
||||
y: Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 1)
|
||||
}
|
||||
},
|
||||
startMarquee (point) {
|
||||
const start = this.pagePoint(point)
|
||||
|
||||
this.marquee = { x1: start.x, y1: start.y, x2: start.x, y2: start.y }
|
||||
},
|
||||
updateMarquee (point) {
|
||||
if (!this.marquee) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = this.pagePoint(point)
|
||||
|
||||
this.marquee.x2 = next.x
|
||||
this.marquee.y2 = next.y
|
||||
},
|
||||
onMousedown (event) {
|
||||
if (event.button !== 0 || (!this.imagePage && !this.textNodes)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.startMarquee(event)
|
||||
|
||||
window.addEventListener('mousemove', this.onMousemove)
|
||||
window.addEventListener('mouseup', this.onMouseup, { once: true })
|
||||
},
|
||||
onMousemove (event) {
|
||||
this.updateMarquee(event)
|
||||
},
|
||||
onMouseup () {
|
||||
window.removeEventListener('mousemove', this.onMousemove)
|
||||
|
||||
this.finishMarquee()
|
||||
},
|
||||
onTouchstart (event) {
|
||||
if (!this.imagePage && !this.textNodes) {
|
||||
return
|
||||
}
|
||||
|
||||
this.startMarquee(event.touches[0])
|
||||
|
||||
window.addEventListener('touchmove', this.onTouchmove, { passive: false })
|
||||
window.addEventListener('touchend', this.onTouchend)
|
||||
window.addEventListener('touchcancel', this.onTouchend)
|
||||
},
|
||||
onTouchmove (event) {
|
||||
this.updateMarquee(event.touches[0])
|
||||
},
|
||||
onTouchend () {
|
||||
window.removeEventListener('touchmove', this.onTouchmove)
|
||||
window.removeEventListener('touchend', this.onTouchend)
|
||||
window.removeEventListener('touchcancel', this.onTouchend)
|
||||
|
||||
this.finishMarquee()
|
||||
},
|
||||
finishMarquee () {
|
||||
if (!this.marquee) {
|
||||
return
|
||||
}
|
||||
|
||||
const start = this.inverseRotatePoint({ x: this.marquee.x1, y: this.marquee.y1 }, this.rotate)
|
||||
const finish = this.inverseRotatePoint({ x: this.marquee.x2, y: this.marquee.y2 }, this.rotate)
|
||||
|
||||
const left = Math.min(start.x, finish.x)
|
||||
const right = Math.max(start.x, finish.x)
|
||||
const top = Math.min(start.y, finish.y)
|
||||
const bottom = Math.max(start.y, finish.y)
|
||||
|
||||
this.marquee = null
|
||||
|
||||
if (right - left < 0.005 && bottom - top < 0.005) {
|
||||
if (this.imagePage) {
|
||||
const index = this.rects.findIndex((rect) => {
|
||||
return left >= rect.x && left <= rect.x + rect.w && top >= rect.y && top <= rect.y + rect.h
|
||||
})
|
||||
|
||||
if (index !== -1) {
|
||||
this.rects.splice(index, 1)
|
||||
}
|
||||
} else {
|
||||
const rectIndex = this.freeRects.findIndex((rect) => {
|
||||
return left >= rect.x && left <= rect.x + rect.w && top >= rect.y && top <= rect.y + rect.h
|
||||
})
|
||||
|
||||
if (rectIndex !== -1) {
|
||||
this.freeRects.splice(rectIndex, 1)
|
||||
} else {
|
||||
const index = this.textNodes.findIndex((node) => {
|
||||
return left >= node.x && left <= node.x + node.w && top >= node.y && top <= node.y + node.h
|
||||
})
|
||||
|
||||
if (index !== -1) {
|
||||
if (this.selectedNodes[index]) {
|
||||
delete this.selectedNodes[index]
|
||||
} else {
|
||||
this.selectedNodes[index] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (this.imagePage) {
|
||||
this.rects.push({ x: left, y: top, w: right - left, h: bottom - top })
|
||||
} else {
|
||||
const marqueeBox = { x: left, y: top, w: right - left, h: bottom - top }
|
||||
|
||||
this.textNodes.forEach((node, index) => {
|
||||
if (node.x < right && node.x + node.w > left && node.y < bottom && node.y + node.h > top) {
|
||||
this.selectedNodes[index] = true
|
||||
}
|
||||
})
|
||||
|
||||
this.imageNodes.forEach((node) => {
|
||||
if (!this.boxesIntersect(node, marqueeBox)) {
|
||||
return
|
||||
}
|
||||
|
||||
const x = Math.max(node.x, marqueeBox.x)
|
||||
const y = Math.max(node.y, marqueeBox.y)
|
||||
const w = Math.min(node.x + node.w, marqueeBox.x + marqueeBox.w) - x
|
||||
const h = Math.min(node.y + node.h, marqueeBox.y + marqueeBox.h) - y
|
||||
|
||||
this.freeRects.push({ x, y, w, h })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -98,11 +98,29 @@ dynamic-variable {
|
||||
overflow-wrap: anywhere;
|
||||
}`)
|
||||
|
||||
const DROP_ATTRS = [
|
||||
'srcdoc', 'xlink:href', 'srcset', 'action', 'formaction', 'poster',
|
||||
'background', 'data', 'cite', 'ping', 'longdesc', 'manifest', 'profile'
|
||||
]
|
||||
|
||||
const SAFE_URL_REGEXP = /^(?:https?:\/\/|data:image\/|blob:|mailto:|tel:|\{|#)/i
|
||||
|
||||
function isSafeAttr (name, value) {
|
||||
const lowerName = name.toLowerCase()
|
||||
|
||||
if (lowerName.startsWith('on') || DROP_ATTRS.includes(lowerName)) return false
|
||||
if ((lowerName === 'href' || lowerName === 'src') && !SAFE_URL_REGEXP.test(value.trim())) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function collectDomAttrs (dom) {
|
||||
const attrs = {}
|
||||
|
||||
for (let i = 0; i < dom.attributes.length; i++) {
|
||||
attrs[dom.attributes[i].name] = dom.attributes[i].value
|
||||
const { name, value } = dom.attributes[i]
|
||||
|
||||
if (isSafeAttr(name, value)) attrs[name] = value
|
||||
}
|
||||
|
||||
return { htmlAttrs: attrs }
|
||||
|
||||
@@ -646,9 +646,7 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
|
||||
container.innerHTML = clipboardHtml
|
||||
const container = new DOMParser().parseFromString(clipboardHtml, 'text/html').body
|
||||
|
||||
const fieldNodes = [...container.querySelectorAll('dynamic-field[data-field][data-area]')]
|
||||
|
||||
|
||||
@@ -494,6 +494,7 @@ import FieldType from './field_type.vue'
|
||||
import FieldSettings from './field_settings.vue'
|
||||
import ContextSubmenu from './field_context_submenu.vue'
|
||||
import ContextModal from './field_context_modal.vue'
|
||||
import { stripeConnectedRef } from './payment_settings'
|
||||
|
||||
export default {
|
||||
name: 'FieldContextMenu',
|
||||
@@ -600,7 +601,8 @@ export default {
|
||||
}
|
||||
},
|
||||
showFont () {
|
||||
return ['text', 'number', 'date', 'select', 'heading', 'cells'].includes(this.field.type)
|
||||
return ['text', 'number', 'date', 'select', 'heading', 'cells'].includes(this.field.type) ||
|
||||
(['radio', 'multiple'].includes(this.field.type) && this.field.areas?.every((a) => !a.option_uuid))
|
||||
},
|
||||
showDescription () {
|
||||
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)
|
||||
@@ -609,7 +611,7 @@ export default {
|
||||
return this.withCondition && !['stamp', 'heading'].includes(this.field.type)
|
||||
},
|
||||
showFormula () {
|
||||
return this.field.type === 'number' || this.field.type === 'payment'
|
||||
return this.field.type === 'number' || this.field.type === 'payment' || !!this.field.preferences?.formula
|
||||
},
|
||||
showRequired () {
|
||||
return this.withRequired && !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type)
|
||||
@@ -715,12 +717,18 @@ export default {
|
||||
currentCurrency () {
|
||||
return this.field.preferences?.currency || 'USD'
|
||||
},
|
||||
isStripeConnected: () => stripeConnectedRef.value,
|
||||
priceTypeOptions () {
|
||||
return [
|
||||
const options = [
|
||||
{ value: 'one_off', label: this.t('fixed') },
|
||||
{ value: 'formula', label: this.t('formula') },
|
||||
{ value: 'payment_link', label: this.t('payment_link') }
|
||||
{ value: 'formula', label: this.t('formula') }
|
||||
]
|
||||
|
||||
if (this.isStripeConnected) {
|
||||
options.push({ value: 'payment_link', label: this.t('payment_link') })
|
||||
}
|
||||
|
||||
return options
|
||||
},
|
||||
currentPriceType () {
|
||||
if (this.field.preferences?.formula) {
|
||||
|
||||
@@ -452,7 +452,7 @@
|
||||
class="pb-0.5 mt-0.5"
|
||||
>
|
||||
<li
|
||||
v-if="['text', 'number', 'date', 'select', 'heading', 'cells'].includes(field.type)"
|
||||
v-if="['text', 'number', 'date', 'select', 'heading', 'cells'].includes(field.type) || (['radio', 'multiple'].includes(field.type) && field.areas?.every((a) => !a.option_uuid))"
|
||||
class="field-settings-font"
|
||||
>
|
||||
<label
|
||||
@@ -500,7 +500,7 @@
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type == 'number'"
|
||||
v-if="field.type == 'number' || field.preferences?.formula"
|
||||
class="field-settings-formula"
|
||||
>
|
||||
<label
|
||||
@@ -830,6 +830,7 @@ export default {
|
||||
} else if (format === 'percent') {
|
||||
return `${number}%`
|
||||
} else if (format === 'percent_space') {
|
||||
// eslint-disable-next-line no-irregular-whitespace
|
||||
return `${String(number).replace('.', ',')} %`
|
||||
} else {
|
||||
return number
|
||||
|
||||
@@ -61,7 +61,10 @@
|
||||
@input="resizeTextarea"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3 mt-1">
|
||||
<div
|
||||
v-if="field.type !== 'text'"
|
||||
class="mb-3 mt-1"
|
||||
>
|
||||
<div
|
||||
target="blank"
|
||||
class="text-sm mb-2 inline space-x-2 font-mono"
|
||||
@@ -161,7 +164,9 @@ export default {
|
||||
computed: {
|
||||
fields () {
|
||||
return this.template.fields.reduce((acc, f) => {
|
||||
if (f !== this.field && this.isNumberField(f) && (!f.preferences?.formula || !f.preferences.formula.includes(this.field.uuid))) {
|
||||
const isAllowed = this.field.type === 'text' ? this.isTextField(f) : this.isNumberField(f)
|
||||
|
||||
if (f !== this.field && isAllowed && (!f.preferences?.formula || !f.preferences.formula.includes(this.field.uuid))) {
|
||||
acc.push(f)
|
||||
}
|
||||
|
||||
@@ -179,6 +184,9 @@ export default {
|
||||
isNumberField (field) {
|
||||
return field.type === 'number' || (['radio', 'select'].includes(field.type) && field.options?.every((o) => String(o.value).match(/^[\d.-]+$/)))
|
||||
},
|
||||
isTextField (field) {
|
||||
return ['text', 'number', 'select', 'radio', 'cells', 'phone'].includes(field.type)
|
||||
},
|
||||
humanizeFormula (text) {
|
||||
return text.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
const foundField = this.template.fields.find((f) => f.uuid === uuid)
|
||||
|
||||
@@ -96,6 +96,24 @@ const en = {
|
||||
add_pdf_documents_or_images: 'Add PDF documents or images',
|
||||
add_documents_or_images: 'Add documents or images',
|
||||
add_a_new_document: 'Add a new document',
|
||||
edit_documents: 'Edit documents',
|
||||
move_forward: 'Move forward',
|
||||
move_backward: 'Move backward',
|
||||
remove_page: 'Remove page',
|
||||
merge_with_previous: 'Merge with previous',
|
||||
merge_with_next: 'Merge with next',
|
||||
move_up: 'Move up',
|
||||
move_down: 'Move down',
|
||||
rotate: 'Rotate',
|
||||
redact: 'Redact',
|
||||
crop: 'Crop',
|
||||
crop_and_scan: 'Crop & Scan',
|
||||
flip_horizontal: 'Flip horizontal',
|
||||
flip_vertical: 'Flip vertical',
|
||||
there_is_no_text_to_redact_on_this_page: 'This page contains only images. Redact tool can be used only with text pages',
|
||||
color: 'Color',
|
||||
reset: 'Reset',
|
||||
upload_to_document: 'Upload to "{document}"',
|
||||
replace_existing_document: 'Replace existing document',
|
||||
clone_and_replace_documents: 'Clone and replace documents',
|
||||
required: 'Required',
|
||||
@@ -319,6 +337,24 @@ const es = {
|
||||
add_pdf_documents_or_images: 'Agregar documentos PDF o imágenes',
|
||||
add_documents_or_images: 'Agregar documentos o imágenes',
|
||||
add_a_new_document: 'Agregar un nuevo documento',
|
||||
edit_documents: 'Editar documentos',
|
||||
move_forward: 'Mover adelante',
|
||||
move_backward: 'Mover atrás',
|
||||
remove_page: 'Eliminar página',
|
||||
merge_with_previous: 'Combinar con el anterior',
|
||||
merge_with_next: 'Combinar con el siguiente',
|
||||
move_up: 'Mover arriba',
|
||||
move_down: 'Mover abajo',
|
||||
rotate: 'Rotar',
|
||||
redact: 'Censurar',
|
||||
crop: 'Recortar',
|
||||
crop_and_scan: 'Recortar y escanear',
|
||||
flip_horizontal: 'Voltear horizontal',
|
||||
flip_vertical: 'Voltear vertical',
|
||||
there_is_no_text_to_redact_on_this_page: 'Esta página contiene solo imágenes. La herramienta de censura solo puede usarse con páginas de texto',
|
||||
color: 'Color',
|
||||
reset: 'Restablecer',
|
||||
upload_to_document: 'Subir a "{document}"',
|
||||
replace_existing_document: 'Reemplazar documento existente',
|
||||
clone_and_replace_documents: 'Clonar y reemplazar documentos',
|
||||
required: 'Requerido',
|
||||
@@ -548,6 +584,24 @@ const it = {
|
||||
add_pdf_documents_or_images: 'Aggiungi documenti PDF o immagini',
|
||||
add_documents_or_images: 'Aggiungi documenti o immagini',
|
||||
add_a_new_document: 'Aggiungi un nuovo documento',
|
||||
edit_documents: 'Modifica documenti',
|
||||
move_forward: 'Sposta avanti',
|
||||
move_backward: 'Sposta indietro',
|
||||
remove_page: 'Rimuovi pagina',
|
||||
merge_with_previous: 'Unisci al precedente',
|
||||
merge_with_next: 'Unisci al successivo',
|
||||
move_up: 'Sposta su',
|
||||
move_down: 'Sposta giù',
|
||||
rotate: 'Ruota',
|
||||
redact: 'Oscura',
|
||||
crop: 'Ritaglia',
|
||||
crop_and_scan: 'Ritaglia e scansiona',
|
||||
flip_horizontal: 'Rifletti orizzontale',
|
||||
flip_vertical: 'Rifletti verticale',
|
||||
there_is_no_text_to_redact_on_this_page: 'Questa pagina contiene solo immagini. Lo strumento di oscuramento può essere usato solo con pagine di testo',
|
||||
color: 'Colore',
|
||||
reset: 'Reimposta',
|
||||
upload_to_document: 'Carica in "{document}"',
|
||||
replace_existing_document: 'Sostituisci documento esistente',
|
||||
clone_and_replace_documents: 'Clona e sostituisci documenti',
|
||||
required: 'Obbligatorio',
|
||||
@@ -771,6 +825,24 @@ const pt = {
|
||||
add_pdf_documents_or_images: 'Adicionar documentos PDF ou imagens',
|
||||
add_documents_or_images: 'Adicionar documentos ou imagens',
|
||||
add_a_new_document: 'Adicionar um novo documento',
|
||||
edit_documents: 'Editar documentos',
|
||||
move_forward: 'Mover para frente',
|
||||
move_backward: 'Mover para trás',
|
||||
remove_page: 'Remover página',
|
||||
merge_with_previous: 'Mesclar com o anterior',
|
||||
merge_with_next: 'Mesclar com o próximo',
|
||||
move_up: 'Mover para cima',
|
||||
move_down: 'Mover para baixo',
|
||||
rotate: 'Girar',
|
||||
redact: 'Censurar',
|
||||
crop: 'Cortar',
|
||||
crop_and_scan: 'Cortar e digitalizar',
|
||||
flip_horizontal: 'Inverter horizontal',
|
||||
flip_vertical: 'Inverter vertical',
|
||||
there_is_no_text_to_redact_on_this_page: 'Esta página contém apenas imagens. A ferramenta de censura só pode ser usada com páginas de texto',
|
||||
color: 'Cor',
|
||||
reset: 'Redefinir',
|
||||
upload_to_document: 'Enviar para "{document}"',
|
||||
replace_existing_document: 'Substituir documento existente',
|
||||
clone_and_replace_documents: 'Clonar e substituir documentos',
|
||||
required: 'Obrigatório',
|
||||
@@ -1000,6 +1072,24 @@ const fr = {
|
||||
add_pdf_documents_or_images: 'Ajouter des documents PDF ou des images',
|
||||
add_documents_or_images: 'Ajouter des documents ou des images',
|
||||
add_a_new_document: 'Ajouter un nouveau document',
|
||||
edit_documents: 'Modifier les documents',
|
||||
move_forward: 'Déplacer en avant',
|
||||
move_backward: 'Déplacer en arrière',
|
||||
remove_page: 'Supprimer la page',
|
||||
merge_with_previous: 'Fusionner avec le précédent',
|
||||
merge_with_next: 'Fusionner avec le suivant',
|
||||
move_up: 'Déplacer vers le haut',
|
||||
move_down: 'Déplacer vers le bas',
|
||||
rotate: 'Pivoter',
|
||||
redact: 'Caviarder',
|
||||
crop: 'Rogner',
|
||||
crop_and_scan: 'Rogner et numériser',
|
||||
flip_horizontal: 'Miroir horizontal',
|
||||
flip_vertical: 'Miroir vertical',
|
||||
there_is_no_text_to_redact_on_this_page: 'Cette page ne contient que des images. L\'outil de caviardage ne peut être utilisé qu\'avec des pages de texte',
|
||||
color: 'Couleur',
|
||||
reset: 'Réinitialiser',
|
||||
upload_to_document: 'Téléverser dans "{document}"',
|
||||
replace_existing_document: 'Remplacer le document existant',
|
||||
clone_and_replace_documents: 'Cloner et remplacer des documents',
|
||||
required: 'Obligatoire',
|
||||
@@ -1226,6 +1316,24 @@ const de = {
|
||||
add_pdf_documents_or_images: 'PDF-Dokumente oder Bilder hinzufügen',
|
||||
add_documents_or_images: 'Dokumente oder Bilder hinzufügen',
|
||||
add_a_new_document: 'Neues Dokument hinzufügen',
|
||||
edit_documents: 'Dokumente bearbeiten',
|
||||
move_forward: 'Nach vorne',
|
||||
move_backward: 'Nach hinten',
|
||||
remove_page: 'Seite entfernen',
|
||||
merge_with_previous: 'Mit vorherigem zusammenführen',
|
||||
merge_with_next: 'Mit nächstem zusammenführen',
|
||||
move_up: 'Nach oben verschieben',
|
||||
move_down: 'Nach unten verschieben',
|
||||
rotate: 'Drehen',
|
||||
redact: 'Schwärzen',
|
||||
crop: 'Zuschneiden',
|
||||
crop_and_scan: 'Zuschneiden & Scan',
|
||||
flip_horizontal: 'Horizontal spiegeln',
|
||||
flip_vertical: 'Vertikal spiegeln',
|
||||
there_is_no_text_to_redact_on_this_page: 'Diese Seite enthält nur Bilder. Das Schwärzungswerkzeug kann nur mit Textseiten verwendet werden',
|
||||
color: 'Farbe',
|
||||
reset: 'Zurücksetzen',
|
||||
upload_to_document: 'In "{document}" hochladen',
|
||||
replace_existing_document: 'Vorhandenes Dokument ersetzen',
|
||||
clone_and_replace_documents: 'Dokumente klonen und ersetzen',
|
||||
required: 'Erforderlich',
|
||||
@@ -1452,6 +1560,24 @@ const nl = {
|
||||
add_pdf_documents_or_images: 'PDF-documenten of afbeeldingen toevoegen',
|
||||
add_documents_or_images: 'Documenten of afbeeldingen toevoegen',
|
||||
add_a_new_document: 'Nieuw document toevoegen',
|
||||
edit_documents: 'Documenten bewerken',
|
||||
move_forward: 'Naar voren',
|
||||
move_backward: 'Naar achteren',
|
||||
remove_page: 'Pagina verwijderen',
|
||||
merge_with_previous: 'Samenvoegen met vorige',
|
||||
merge_with_next: 'Samenvoegen met volgende',
|
||||
move_up: 'Omhoog verplaatsen',
|
||||
move_down: 'Omlaag verplaatsen',
|
||||
rotate: 'Draaien',
|
||||
redact: 'Redigeren',
|
||||
crop: 'Bijsnijden',
|
||||
crop_and_scan: 'Bijsnijden en scannen',
|
||||
flip_horizontal: 'Horizontaal spiegelen',
|
||||
flip_vertical: 'Verticaal spiegelen',
|
||||
there_is_no_text_to_redact_on_this_page: 'Deze pagina bevat alleen afbeeldingen. De redactietool kan alleen worden gebruikt met tekstpagina\'s',
|
||||
color: 'Kleur',
|
||||
reset: 'Opnieuw instellen',
|
||||
upload_to_document: 'Uploaden naar "{document}"',
|
||||
replace_existing_document: 'Bestaand document vervangen',
|
||||
clone_and_replace_documents: 'Documenten klonen en vervangen',
|
||||
required: 'Vereist',
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
@scroll-to="$emit('scroll-to', $event)"
|
||||
@add-custom-field="$emit('add-custom-field', $event)"
|
||||
@contextmenu="openAreaContextMenu($event, item.area, item.field)"
|
||||
@multi-select="openMultiSelectContextMenu"
|
||||
@click-title="closeContextMenu"
|
||||
/>
|
||||
<FieldArea
|
||||
@@ -408,6 +409,12 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.selectedAreasRef.value.length >= 2) {
|
||||
this.openSelectionContextMenu(event)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -443,6 +450,13 @@ export default {
|
||||
areas: this.selectedAreasRef.value
|
||||
}
|
||||
},
|
||||
openMultiSelectContextMenu (event) {
|
||||
if (this.selectedAreasRef.value.length >= 2) {
|
||||
this.openSelectionContextMenu(event)
|
||||
} else {
|
||||
this.closeContextMenu()
|
||||
}
|
||||
},
|
||||
handleSelectionCopy () {
|
||||
this.$emit('copy-selected-areas')
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<span
|
||||
class="dropdown dropdown-end field-settings-dropdown"
|
||||
:class="{ 'dropdown-open': withForceOpen && ((!field.preferences?.price && !field.preferences?.formula && !field.preferences?.price_id && !field.preferences?.payment_link_id) || !isConnected) && !isLoading }"
|
||||
:class="{ 'dropdown-open': withForceOpen && !field.preferences?.price && !field.preferences?.formula && !field.preferences?.price_id && !field.preferences?.payment_link_id && !isLoading }"
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
@@ -21,7 +21,7 @@
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<div
|
||||
v-if="!('price_id' in field.preferences) && !('payment_link_id' in field.preferences)"
|
||||
v-if="isPaypal || (!('price_id' in field.preferences) && !('payment_link_id' in field.preferences))"
|
||||
class="field-settings-currency py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
@@ -52,14 +52,14 @@
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
v-if="'payment_link_id' in field.preferences"
|
||||
v-if="isStripe && 'payment_link_id' in field.preferences"
|
||||
v-model="field.preferences.payment_link_id"
|
||||
placeholder="plink_XXXXX"
|
||||
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
|
||||
@blur="save"
|
||||
>
|
||||
<input
|
||||
v-else-if="'price_id' in field.preferences"
|
||||
v-else-if="isStripe && 'price_id' in field.preferences"
|
||||
v-model="field.preferences.price_id"
|
||||
placeholder="Price ID: price_XXXXX"
|
||||
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
|
||||
@@ -89,7 +89,10 @@
|
||||
>
|
||||
{{ 'payment_link_id' in field.preferences ? t('payment_link') : t('price') }}
|
||||
</label>
|
||||
<div class="flex items-center justify-center">
|
||||
<div
|
||||
v-if="isStripe && stripeConnected"
|
||||
class="flex items-center justify-center"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
class="hover:underline"
|
||||
@@ -120,21 +123,22 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isConnected || isOauthSuccess"
|
||||
class="field-settings-stripe-connect py-1.5 px-1 relative"
|
||||
v-if="!stripeConnected && !paypalConnected"
|
||||
class="flex flex-col gap-1 py-1.5 px-1"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
v-if="isConnected && isOauthSuccess"
|
||||
class="text-sm text-center"
|
||||
<a
|
||||
v-if="withPaypal"
|
||||
:href="paypalConnectUrl"
|
||||
target="_blank"
|
||||
data-turbo="false"
|
||||
class="btn bg-[#003087] hover:bg-[#012169] btn-sm text-white w-full"
|
||||
>
|
||||
<IconCircleCheck
|
||||
class="inline text-green-600 w-4 h-4"
|
||||
/>
|
||||
Stripe Connected
|
||||
</div>
|
||||
<IconBrandPaypal class="w-4 h-4 inline" />
|
||||
<span>Connect PayPal</span>
|
||||
</a>
|
||||
<form
|
||||
v-if="!isConnected"
|
||||
v-if="withStripe"
|
||||
data-turbo="false"
|
||||
action="/auth/stripe_connect"
|
||||
accept-charset="UTF-8"
|
||||
@@ -170,33 +174,25 @@
|
||||
:disabled="isLoading"
|
||||
class="btn bg-[#7B73FF] hover:bg-[#0A2540] btn-sm text-white w-full"
|
||||
>
|
||||
<span
|
||||
<IconInnerShadowTop
|
||||
v-if="isLoading"
|
||||
class="flex items-center space-x-1"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
class="w-4 h-4 animate-spin inline"
|
||||
/>
|
||||
<span>
|
||||
Connect Stripe
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="w-4 h-4 animate-spin inline"
|
||||
/>
|
||||
<IconBrandStripe
|
||||
v-else
|
||||
class="flex items-center space-x-1"
|
||||
>
|
||||
<IconBrandStripe
|
||||
class="w-4 h-4 inline"
|
||||
/>
|
||||
<span>
|
||||
Connect Stripe
|
||||
</span>
|
||||
</span>
|
||||
class="w-4 h-4 inline"
|
||||
/>
|
||||
<span>Connect Stripe</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
v-if="isStripe && !stripeConnected"
|
||||
class="px-1 pb-1.5"
|
||||
@click.stop
|
||||
>
|
||||
<a
|
||||
v-if="!isConnected"
|
||||
class="block link text-center mt-1"
|
||||
class="block link text-center text-xs"
|
||||
href="https://www.docuseal.com/blog/accept-payments-and-request-signatures-with-ease"
|
||||
target="_blank"
|
||||
data-turbo="false"
|
||||
@@ -272,24 +268,25 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconMathFunction, IconSettings, IconCircleCheck, IconInfoCircle, IconBrandStripe, IconInnerShadowTop, IconRouteAltLeft, IconForms } from '@tabler/icons-vue'
|
||||
import { IconMathFunction, IconSettings, IconInfoCircle, IconBrandStripe, IconBrandPaypal, IconInnerShadowTop, IconRouteAltLeft, IconForms } from '@tabler/icons-vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isConnected = ref(false)
|
||||
export const stripeConnectedRef = ref(false)
|
||||
export const paypalConnectedRef = ref(false)
|
||||
|
||||
export default {
|
||||
name: 'PaymentSettings',
|
||||
components: {
|
||||
IconSettings,
|
||||
IconCircleCheck,
|
||||
IconRouteAltLeft,
|
||||
IconInfoCircle,
|
||||
IconForms,
|
||||
IconMathFunction,
|
||||
IconInnerShadowTop,
|
||||
IconBrandStripe
|
||||
IconBrandStripe,
|
||||
IconBrandPaypal
|
||||
},
|
||||
inject: ['backgroundColor', 'save', 'currencies', 't', 'isPaymentConnected'],
|
||||
inject: ['backgroundColor', 'save', 'currencies', 't', 'isStripeConnected', 'isPaypalConnected', 'withStripe', 'withPaypal'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -318,13 +315,27 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isConnected: () => isConnected.value,
|
||||
isOauthSuccess () {
|
||||
return document.location.search?.includes('stripe_connect_success')
|
||||
stripeConnected: () => stripeConnectedRef.value,
|
||||
paypalConnected: () => paypalConnectedRef.value,
|
||||
provider () {
|
||||
if (this.paypalConnected && !this.stripeConnected) return 'paypal'
|
||||
if (this.stripeConnected && !this.paypalConnected) return 'stripe'
|
||||
if (this.withPaypal && !this.withStripe) return 'paypal'
|
||||
|
||||
return 'stripe'
|
||||
},
|
||||
isStripe () {
|
||||
return this.provider === 'stripe'
|
||||
},
|
||||
isPaypal () {
|
||||
return this.provider === 'paypal'
|
||||
},
|
||||
redirectUri () {
|
||||
return document.location.origin + '/auth/stripe_connect/callback'
|
||||
},
|
||||
paypalConnectUrl () {
|
||||
return `/auth/paypal_connect?redir=${encodeURIComponent(document.location.href)}`
|
||||
},
|
||||
defaultCurrencies () {
|
||||
return ['USD', 'EUR', 'GBP', 'CAD', 'AUD']
|
||||
},
|
||||
@@ -363,26 +374,53 @@ export default {
|
||||
mounted () {
|
||||
this.field.preferences.currency ||= this.defaultCurrency
|
||||
|
||||
isConnected.value ||= this.isPaymentConnected
|
||||
stripeConnectedRef.value ||= this.isStripeConnected
|
||||
paypalConnectedRef.value ||= this.isPaypalConnected
|
||||
|
||||
if (!this.isConnected) {
|
||||
this.checkStatus()
|
||||
if (this.withStripe && !this.stripeConnected && !this.paypalConnected) {
|
||||
this.checkStripeStatus()
|
||||
}
|
||||
|
||||
if (this.withPaypal && !this.paypalConnected && !this.stripeConnected) {
|
||||
this.checkPaypalStatus()
|
||||
window.addEventListener('focus', this.onWindowFocus)
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
window.removeEventListener('focus', this.onWindowFocus)
|
||||
},
|
||||
methods: {
|
||||
checkStatus () {
|
||||
onWindowFocus () {
|
||||
if (this.withPaypal && !this.paypalConnected) {
|
||||
this.checkPaypalStatus()
|
||||
}
|
||||
},
|
||||
checkStripeStatus () {
|
||||
this.isLoading = true
|
||||
|
||||
fetch('/api/stripe_connect').then(async (resp) => {
|
||||
const { status } = await resp.json()
|
||||
|
||||
if (status === 'connected') {
|
||||
isConnected.value = true
|
||||
stripeConnectedRef.value = true
|
||||
|
||||
window.removeEventListener('focus', this.onWindowFocus)
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
},
|
||||
checkPaypalStatus () {
|
||||
fetch('/api/paypal_connect').then(async (resp) => {
|
||||
const { status } = await resp.json()
|
||||
|
||||
if (status === 'connected') {
|
||||
paypalConnectedRef.value = true
|
||||
|
||||
window.removeEventListener('focus', this.onWindowFocus)
|
||||
}
|
||||
})
|
||||
},
|
||||
closeDropdown () {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
|
||||
@@ -72,6 +72,15 @@
|
||||
style="min-width: 170px"
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<li v-if="!item.dynamic">
|
||||
<button
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm whitespace-nowrap"
|
||||
@click.stop="$emit('edit', item); closeDropdown()"
|
||||
>
|
||||
<IconPencil class="w-4 h-4" />
|
||||
<span>{{ t('edit') }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
|
||||
@@ -184,7 +193,7 @@
|
||||
<script>
|
||||
import Contenteditable from './contenteditable'
|
||||
import Upload from './upload'
|
||||
import { IconRouteAltLeft, IconSortDescending2, IconDotsVertical, IconTrashX, IconBolt, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
import { IconRouteAltLeft, IconSortDescending2, IconDotsVertical, IconTrashX, IconBolt, IconInnerShadowTop, IconPencil } from '@tabler/icons-vue'
|
||||
import ConditionsModal from './conditions_modal'
|
||||
import ReplaceButton from './replace'
|
||||
import GoogleDriveDocumentSettings from './google_drive_document_settings'
|
||||
@@ -204,7 +213,8 @@ export default {
|
||||
IconSortDescending2,
|
||||
IconDotsVertical,
|
||||
IconTrashX,
|
||||
IconBolt
|
||||
IconBolt,
|
||||
IconPencil
|
||||
},
|
||||
inject: ['t', 'getFieldTypeIndex', 'baseFetch'],
|
||||
props: {
|
||||
@@ -260,7 +270,7 @@ export default {
|
||||
default: true
|
||||
}
|
||||
},
|
||||
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'reorder'],
|
||||
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'reorder', 'edit'],
|
||||
data () {
|
||||
return {
|
||||
isShowConditionsModal: false,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="inline-flex items-stretch replace-document-control">
|
||||
<div
|
||||
class="inline-flex items-stretch replace-document-control"
|
||||
:class="{ 'opacity-100': isLoading }"
|
||||
>
|
||||
<label
|
||||
:for="inputId"
|
||||
class="btn btn-neutral btn-xs transition-none replace-document-button"
|
||||
|
||||
@@ -9,6 +9,62 @@
|
||||
@mousedown.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<label
|
||||
v-if="requiredFields.length"
|
||||
class="field-settings-required w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
:checked="isAllRequired"
|
||||
:indeterminate="isMixedRequired"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
:disabled="!editable"
|
||||
@change="handleToggleRequired($event.target.checked)"
|
||||
@click.stop
|
||||
>
|
||||
<span>{{ t('required') }}</span>
|
||||
</label>
|
||||
<label
|
||||
v-if="readOnlyFields.length"
|
||||
class="field-settings-read-only w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
:checked="isAllReadOnly"
|
||||
:indeterminate="isMixedReadOnly"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
:disabled="!editable"
|
||||
@change="handleToggleReadOnly($event.target.checked)"
|
||||
@click.stop
|
||||
>
|
||||
<span>{{ t('read_only') }}</span>
|
||||
</label>
|
||||
<hr
|
||||
v-if="requiredFields.length || readOnlyFields.length"
|
||||
class="my-1 border-neutral-200"
|
||||
>
|
||||
<button
|
||||
v-if="showFont"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="openFontModal"
|
||||
>
|
||||
<IconTypography class="w-4 h-4" />
|
||||
<span>{{ t('font') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showCondition"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="openConditionModal"
|
||||
>
|
||||
<IconRouteAltLeft class="w-4 h-4" />
|
||||
<span>{{ t('condition') }}</span>
|
||||
</button>
|
||||
<hr
|
||||
v-if="showFont || showCondition"
|
||||
class="my-1 border-neutral-200"
|
||||
>
|
||||
<ContextSubmenu
|
||||
:icon="IconLayoutAlignMiddle"
|
||||
:label="t('align')"
|
||||
@@ -61,26 +117,6 @@
|
||||
<span>{{ t('height') }}</span>
|
||||
</button>
|
||||
</ContextSubmenu>
|
||||
<hr
|
||||
v-if="showFont || showCondition"
|
||||
class="my-1 border-neutral-200"
|
||||
>
|
||||
<button
|
||||
v-if="showFont"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="openFontModal"
|
||||
>
|
||||
<IconTypography class="w-4 h-4" />
|
||||
<span>{{ t('font') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showCondition"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="openConditionModal"
|
||||
>
|
||||
<IconRouteAltLeft class="w-4 h-4" />
|
||||
<span>{{ t('condition') }}</span>
|
||||
</button>
|
||||
<hr class="my-1 border-neutral-200">
|
||||
<button
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
|
||||
@@ -192,6 +228,24 @@ export default {
|
||||
return this.template.fields.find((f) => f.areas?.includes(area))
|
||||
}).filter(Boolean)
|
||||
},
|
||||
requiredFields () {
|
||||
return this.selectedFields.filter((f) => !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(f.type))
|
||||
},
|
||||
readOnlyFields () {
|
||||
return this.selectedFields.filter((f) => ['text', 'number', 'radio', 'multiple', 'select'].includes(f.type))
|
||||
},
|
||||
isAllRequired () {
|
||||
return this.requiredFields.every((f) => f.required)
|
||||
},
|
||||
isMixedRequired () {
|
||||
return !this.isAllRequired && this.requiredFields.some((f) => f.required)
|
||||
},
|
||||
isAllReadOnly () {
|
||||
return this.readOnlyFields.every((f) => f.readonly)
|
||||
},
|
||||
isMixedReadOnly () {
|
||||
return !this.isAllReadOnly && this.readOnlyFields.some((f) => f.readonly)
|
||||
},
|
||||
isMac () {
|
||||
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
|
||||
},
|
||||
@@ -234,6 +288,16 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
handleToggleRequired (value) {
|
||||
this.requiredFields.forEach((field) => { field.required = value })
|
||||
|
||||
this.save()
|
||||
},
|
||||
handleToggleReadOnly (value) {
|
||||
this.readOnlyFields.forEach((field) => { field.readonly = value })
|
||||
|
||||
this.save()
|
||||
},
|
||||
onKeyDown (event) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -128,31 +128,39 @@ function convertImage (sourceFile, targetType, quality) {
|
||||
})
|
||||
}
|
||||
|
||||
async function convertImagesInInput (input) {
|
||||
if (!input.files || input.files.length === 0) return
|
||||
export async function convertUnsupportedImages (files) {
|
||||
const converted = []
|
||||
|
||||
const dt = new DataTransfer()
|
||||
let didConvert = false
|
||||
|
||||
for (const file of Array.from(input.files)) {
|
||||
let converted = file
|
||||
for (const file of Array.from(files)) {
|
||||
let result = 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
|
||||
result = 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)) {
|
||||
converted = await convertImage(file, 'image/jpeg', 0.9)
|
||||
didConvert = true
|
||||
result = await convertImage(file, 'image/jpeg', 0.9)
|
||||
}
|
||||
} catch (e) {
|
||||
alert(e.message)
|
||||
}
|
||||
|
||||
dt.items.add(converted)
|
||||
converted.push(result)
|
||||
}
|
||||
|
||||
if (didConvert) {
|
||||
return converted
|
||||
}
|
||||
|
||||
async function convertImagesInInput (input) {
|
||||
if (!input.files || input.files.length === 0) return
|
||||
|
||||
const originals = Array.from(input.files)
|
||||
const converted = await convertUnsupportedImages(originals)
|
||||
|
||||
if (converted.some((file, index) => file !== originals[index])) {
|
||||
const dt = new DataTransfer()
|
||||
|
||||
converted.forEach((file) => dt.items.add(file))
|
||||
|
||||
input.files = dt.files
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ class ProcessSubmissionExpiredJob
|
||||
return if submission.archived_at?
|
||||
return if submission.template&.archived_at?
|
||||
return if submission.submitters.where.not(declined_at: nil).exists?
|
||||
return unless submission.submitters.exists?(completed_at: nil)
|
||||
return if submission.completed_at?
|
||||
return if params['expire_at'] && submission.expire_at&.to_i != params['expire_at']
|
||||
|
||||
WebhookUrls.enqueue_events(submission, 'submission.expired')
|
||||
end
|
||||
|
||||
@@ -3,32 +3,50 @@
|
||||
class ProcessSubmitterCompletionJob
|
||||
include Sidekiq::Job
|
||||
|
||||
BCC_LIMIT = 14
|
||||
TooManyBcc = Class.new(StandardError)
|
||||
|
||||
def perform(params = {})
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
submission = submitter.submission
|
||||
|
||||
create_completed_submitter!(submitter)
|
||||
|
||||
is_all_completed = !submitter.submission.submitters.exists?(completed_at: nil)
|
||||
is_last =
|
||||
if params.key?('is_last')
|
||||
params['is_last']
|
||||
else
|
||||
viewer_uuids = submission.template_submitters.to_a.filter_map { |s| s['uuid'] if s['is_viewer'] }
|
||||
|
||||
incomplete = submission.submitters.where(completed_at: nil)
|
||||
incomplete = incomplete.where.not(uuid: viewer_uuids) if viewer_uuids.present?
|
||||
|
||||
!incomplete.exists? &&
|
||||
submitter.completed_at == submission.submitters.maximum(:completed_at)
|
||||
end
|
||||
|
||||
Submissions::EnsureResultGenerated.call(submitter)
|
||||
|
||||
if is_all_completed && submitter.completed_at == submitter.submission.submitters.maximum(:completed_at)
|
||||
if submitter.submission.account.account_configs.exists?(key: AccountConfig::COMBINE_PDF_RESULT_KEY, value: true)
|
||||
if is_last
|
||||
if submission.account.account_configs.exists?(key: AccountConfig::COMBINE_PDF_RESULT_KEY, value: true)
|
||||
Submissions::EnsureCombinedGenerated.call(submitter)
|
||||
end
|
||||
|
||||
Submissions::EnsureAuditGenerated.call(submitter.submission)
|
||||
Submissions::EnsureAuditGenerated.call(submission)
|
||||
|
||||
enqueue_completed_emails(submitter)
|
||||
end
|
||||
|
||||
create_completed_documents!(submitter)
|
||||
|
||||
if !is_all_completed && submitter.submission.submitters_order_preserved? && params['send_invitation_email'] != false
|
||||
enqueue_next_submitter_request_notification(submitter)
|
||||
if !submission.completed_at && submission.submitters_order_preserved? && params['send_invitation_email'] != false &&
|
||||
Submission.exists?(id: submission.id, completed_at: nil)
|
||||
next_submitters = enqueue_next_submitter_request_notification(submitter)
|
||||
|
||||
enqueue_next_submitter_viewer_notification(submission, next_submitters) unless is_last
|
||||
end
|
||||
|
||||
enqueue_completed_webhooks(submitter, is_all_completed:)
|
||||
enqueue_completed_webhooks(submitter, is_last:)
|
||||
end
|
||||
|
||||
def create_completed_submitter!(submitter)
|
||||
@@ -77,7 +95,7 @@ class ProcessSubmitterCompletionJob
|
||||
end
|
||||
end
|
||||
|
||||
def enqueue_completed_webhooks(submitter, is_all_completed: false)
|
||||
def enqueue_completed_webhooks(submitter, is_last: false)
|
||||
event_uuids = {}
|
||||
|
||||
WebhookUrls.for_account_id(submitter.account_id, %w[form.completed submission.completed]).each do |webhook|
|
||||
@@ -89,7 +107,7 @@ class ProcessSubmitterCompletionJob
|
||||
'webhook_url_id' => webhook.id)
|
||||
end
|
||||
|
||||
next unless webhook.events.include?('submission.completed') && is_all_completed
|
||||
next unless webhook.events.include?('submission.completed') && is_last
|
||||
|
||||
event_uuids['submission.completed'] ||= SecureRandom.uuid
|
||||
|
||||
@@ -118,16 +136,24 @@ class ProcessSubmitterCompletionJob
|
||||
true
|
||||
end
|
||||
|
||||
build_bcc_addresses(submission).each do |to|
|
||||
next if is_sent_to_user && to == user.email
|
||||
|
||||
SubmitterMailer.completed_email(submitter, user, to:).deliver_later!
|
||||
end
|
||||
enqueue_bcc_completed_emails(submitter, user, is_sent_to_user)
|
||||
end
|
||||
|
||||
maybe_enqueue_copy_emails(submitter)
|
||||
end
|
||||
|
||||
def enqueue_bcc_completed_emails(submitter, user, is_sent_to_user)
|
||||
bcc_addresses = build_bcc_addresses(submitter.submission)
|
||||
|
||||
raise TooManyBcc, submitter.account_id if Docuseal.multitenant? && bcc_addresses.size > BCC_LIMIT
|
||||
|
||||
bcc_addresses.each do |to|
|
||||
next if is_sent_to_user && to == user.email
|
||||
|
||||
SubmitterMailer.completed_email(submitter, user, to:).deliver_later!
|
||||
end
|
||||
end
|
||||
|
||||
def maybe_enqueue_copy_emails(submitter)
|
||||
return if submitter.template&.preferences&.dig('documents_copy_email_enabled') == false
|
||||
|
||||
@@ -137,7 +163,7 @@ class ProcessSubmitterCompletionJob
|
||||
return if configs.value['enabled'] == false
|
||||
|
||||
to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
|
||||
.sort_by(&:completed_at).select(&:email?).map(&:friendly_name)
|
||||
.sort_by { |e| e.completed_at || Time.current }.select(&:email?).map(&:friendly_name)
|
||||
|
||||
return if to.blank?
|
||||
|
||||
@@ -157,9 +183,9 @@ class ProcessSubmitterCompletionJob
|
||||
bcc.to_s.scan(User::EMAIL_REGEXP)
|
||||
end
|
||||
|
||||
def enqueue_next_submitter_request_notification(submitter)
|
||||
def enqueue_next_submitter_request_notification(submitter) # rubocop:disable Metrics/PerceivedComplexity
|
||||
submission = submitter.submission
|
||||
submitters_index = submission.submitters.index_by(&:uuid)
|
||||
submitters_index = submission.submitters.reject(&:viewer?).index_by(&:uuid)
|
||||
|
||||
next_submitter_items =
|
||||
if submission.template_submitters.any? { |s| s['order'] }
|
||||
@@ -188,5 +214,43 @@ class ProcessSubmitterCompletionJob
|
||||
next_submitters = submitters_index.values_at(*Array.wrap(next_submitter_items).pluck('uuid')).compact
|
||||
|
||||
Submitters.send_signature_requests(next_submitters)
|
||||
|
||||
next_submitters
|
||||
end
|
||||
|
||||
def enqueue_next_submitter_viewer_notification(submission, next_submitters)
|
||||
viewers = submission.submitters.select(&:viewer?)
|
||||
|
||||
return [] if viewers.blank?
|
||||
|
||||
next_submitter_uuids = next_submitters.to_set(&:uuid)
|
||||
viewers_index = viewers.index_by(&:uuid)
|
||||
|
||||
next_viewers =
|
||||
if submission.template_submitters.any? { |s| s['order'] }
|
||||
next_orders = submission.template_submitters
|
||||
.select { |s| next_submitter_uuids.include?(s['uuid']) }
|
||||
.pluck('order')
|
||||
|
||||
submission.template_submitters.filter_map do |s|
|
||||
viewers_index[s['uuid']] if next_orders.include?(s['order'])
|
||||
end
|
||||
else
|
||||
preceding_submitter_uuid = nil
|
||||
|
||||
submission.template_submitters.filter_map do |template_submitter|
|
||||
viewer = viewers_index[template_submitter['uuid']]
|
||||
|
||||
if viewer
|
||||
viewer if next_submitter_uuids.include?(preceding_submitter_uuid)
|
||||
else
|
||||
preceding_submitter_uuid = template_submitter['uuid']
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Submitters.send_signature_requests(next_viewers)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,7 +7,9 @@ class SendSubmitterInvitationEmailJob
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
|
||||
return if submitter.completed_at?
|
||||
return if submitter.declined_at?
|
||||
return if submitter.submission.archived_at?
|
||||
return if submitter.submission.expired?
|
||||
return if submitter.template&.archived_at?
|
||||
return if submitter.submission.source == 'invite' && !Accounts.can_send_emails?(submitter.account, on_events: true)
|
||||
|
||||
@@ -17,7 +19,12 @@ class SendSubmitterInvitationEmailJob
|
||||
return
|
||||
end
|
||||
|
||||
mail = SubmitterMailer.invitation_email(submitter)
|
||||
mail =
|
||||
if submitter.viewer?
|
||||
SubmitterMailer.invitation_view_email(submitter)
|
||||
else
|
||||
SubmitterMailer.invitation_email(submitter)
|
||||
end
|
||||
|
||||
Submitters::ValidateSending.call(submitter, mail)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class SubmitterMailer < ApplicationMailer
|
||||
|
||||
template_submitters_index = @email_message.blank? ? build_submitter_preferences_index(@submitter) : {}
|
||||
|
||||
@body = @email_message&.body.presence ||
|
||||
@body = @email_message&.normalized_body.presence ||
|
||||
template_submitters_index.dig(@submitter.uuid, 'request_email_body').presence ||
|
||||
@submitter.template&.preferences&.dig('request_email_body').presence
|
||||
|
||||
@@ -45,6 +45,45 @@ class SubmitterMailer < ApplicationMailer
|
||||
end
|
||||
end
|
||||
|
||||
def invitation_view_email(submitter)
|
||||
@current_account = submitter.submission.account
|
||||
@submitter = submitter
|
||||
|
||||
if submitter.preferences['email_message_uuid']
|
||||
@email_message = submitter.account.email_messages.find_by(uuid: submitter.preferences['email_message_uuid'])
|
||||
end
|
||||
|
||||
template_submitters_index = @email_message.blank? ? build_submitter_preferences_index(@submitter) : {}
|
||||
|
||||
@body = @email_message&.normalized_body.presence ||
|
||||
@submitter.template&.preferences&.dig('invitation_view_email_body').presence ||
|
||||
template_submitters_index.dig(@submitter.uuid, 'request_email_body').presence
|
||||
|
||||
@subject = @email_message&.subject.presence ||
|
||||
@submitter.template&.preferences&.dig('invitation_view_email_subject').presence ||
|
||||
template_submitters_index.dig(@submitter.uuid, 'request_email_subject').presence
|
||||
|
||||
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY)
|
||||
@body ||= fetch_config_email_body(@email_config, @submitter)
|
||||
|
||||
assign_message_metadata('submitter_view_invitation', @submitter)
|
||||
|
||||
reply_to = build_submitter_reply_to(@submitter, email_config: @email_config)
|
||||
|
||||
maybe_set_custom_domain(@submitter)
|
||||
|
||||
I18n.with_locale(@current_account.locale) do
|
||||
subject = build_invite_subject(@subject, @email_config, submitter)
|
||||
|
||||
mail(
|
||||
to: @submitter.friendly_name,
|
||||
from: from_address_for_submitter(submitter),
|
||||
subject:,
|
||||
reply_to:
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def completed_email(submitter, user, to: nil)
|
||||
@current_account = submitter.submission.account
|
||||
@submitter = submitter
|
||||
@@ -53,8 +92,6 @@ class SubmitterMailer < ApplicationMailer
|
||||
|
||||
template_preferences = @submission.template&.preferences || {}
|
||||
|
||||
Submissions::EnsureResultGenerated.call(submitter)
|
||||
|
||||
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
|
||||
|
||||
add_completed_email_attachments!(
|
||||
@@ -97,7 +134,7 @@ class SubmitterMailer < ApplicationMailer
|
||||
to: user.role == 'integration' ? user.friendly_name.sub(/\+\w+@/, '@') : user.friendly_name,
|
||||
reply_to: @submitter.friendly_name,
|
||||
subject: I18n.t(:name_declined_by_submitter,
|
||||
name: (@submission.name || @submission.template.name).truncate(20),
|
||||
name: (@submission.name || @submission.template&.name).to_s.truncate(20),
|
||||
submitter: @submitter.name || @submitter.email || @submitter.phone))
|
||||
end
|
||||
end
|
||||
@@ -109,8 +146,6 @@ class SubmitterMailer < ApplicationMailer
|
||||
|
||||
template_preferences = @submitter.template&.preferences || {}
|
||||
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
|
||||
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
|
||||
|
||||
add_completed_email_attachments!(
|
||||
@@ -144,6 +179,7 @@ class SubmitterMailer < ApplicationMailer
|
||||
end
|
||||
|
||||
def otp_verification_email(submitter, locale: nil)
|
||||
@current_account = submitter.account
|
||||
@submitter = submitter
|
||||
@otp_code = EmailVerificationCodes.generate([submitter.email.downcase.strip, submitter.slug].join(':'))
|
||||
|
||||
@@ -171,7 +207,7 @@ class SubmitterMailer < ApplicationMailer
|
||||
end
|
||||
|
||||
def add_completed_email_attachments!(submitter, with_audit_log: true, with_documents: true)
|
||||
documents = with_documents ? Submitters.select_attachments_for_download(submitter) : []
|
||||
documents = with_documents ? select_completed_documents(submitter) : []
|
||||
|
||||
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
|
||||
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
|
||||
@@ -215,6 +251,8 @@ class SubmitterMailer < ApplicationMailer
|
||||
def build_invite_subject(subject, email_config, submitter)
|
||||
if email_config || subject
|
||||
ReplaceEmailVariables.call(subject || email_config.value['subject'], submitter:)
|
||||
elsif submitter.viewer?
|
||||
I18n.t(:you_are_invited_to_view_a_document)
|
||||
elsif submitter.with_signature_fields?
|
||||
I18n.t(:you_are_invited_to_sign_a_document)
|
||||
else
|
||||
@@ -226,6 +264,14 @@ class SubmitterMailer < ApplicationMailer
|
||||
submitter.template&.preferences&.dig('submitters').to_a.index_by { |e| e['uuid'] }
|
||||
end
|
||||
|
||||
def select_completed_documents(submitter)
|
||||
last_submitter = Submitter.where(submission_id: submitter.submission_id).completed.order(:completed_at).last
|
||||
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
Submitters.select_attachments_for_download(last_submitter)
|
||||
end
|
||||
|
||||
def add_attachments_with_size_limit(submitter, storage_attachments, current_size, filename_format = nil)
|
||||
total_size = current_size
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class Account < ApplicationRecord
|
||||
has_many :encrypted_configs, dependent: :destroy
|
||||
has_many :account_configs, dependent: :destroy
|
||||
has_many :email_messages, dependent: :destroy
|
||||
has_many :email_message_assets, dependent: :destroy
|
||||
has_many :templates, dependent: :destroy
|
||||
has_many :template_folders, dependent: :destroy
|
||||
has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) },
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#
|
||||
class AccountConfig < ApplicationRecord
|
||||
SUBMITTER_INVITATION_EMAIL_KEY = 'submitter_invitation_email'
|
||||
SUBMITTER_VIEW_INVITATION_EMAIL_KEY = 'submitter_view_invitation_email'
|
||||
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY = 'submitter_invitation_reminder_email'
|
||||
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
|
||||
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
|
||||
@@ -47,6 +48,7 @@ class AccountConfig < ApplicationRecord
|
||||
WITH_SIGNATURE_ID = 'with_signature_id'
|
||||
WITH_FILE_LINKS_KEY = 'with_file_links'
|
||||
WITH_SIGNATURE_ID_REASON_KEY = 'with_signature_id_reason'
|
||||
WITH_SIGNATURE_ID_COMPLETED_AT_KEY = 'with_signature_id_completed_at'
|
||||
RECIPIENT_FORM_FIELDS_KEY = 'recipient_form_fields'
|
||||
WITH_AUDIT_VALUES_KEY = 'with_audit_values'
|
||||
WITH_AUDIT_SENDER_KEY = 'with_audit_sender'
|
||||
@@ -58,11 +60,13 @@ class AccountConfig < ApplicationRecord
|
||||
COMBINE_PDF_RESULT_KEY = 'combine_pdf_result_key'
|
||||
DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format'
|
||||
TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields'
|
||||
TEMPLATE_DATE_FORMATS_KEY = 'template_date_formats'
|
||||
POLICY_LINKS_KEY = 'policy_links'
|
||||
ENABLE_MCP_KEY = 'enable_mcp'
|
||||
|
||||
EMAIL_VARIABLES = {
|
||||
SUBMITTER_INVITATION_EMAIL_KEY => %w[template.name submitter.link account.name].freeze,
|
||||
SUBMITTER_VIEW_INVITATION_EMAIL_KEY => %w[template.name submitter.link account.name].freeze,
|
||||
SUBMITTER_COMPLETED_EMAIL_KEY => %w[template.name submission.submitters submission.link].freeze,
|
||||
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[template.name submitter.link account.name].freeze,
|
||||
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[template.name documents.link account.name].freeze
|
||||
@@ -75,6 +79,12 @@ class AccountConfig < ApplicationRecord
|
||||
'body' => I18n.t(:submitter_invitation_email_sign_body)
|
||||
}
|
||||
},
|
||||
SUBMITTER_VIEW_INVITATION_EMAIL_KEY => lambda {
|
||||
{
|
||||
'subject' => I18n.t(:you_are_invited_to_view_a_document),
|
||||
'body' => I18n.t(:submitter_invitation_email_view_body)
|
||||
}
|
||||
},
|
||||
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => lambda {
|
||||
{
|
||||
'subject' => I18n.t(:you_are_invited_to_sign_a_document),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user