mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 14:42:32 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a3a185310 | ||
|
|
7d381b7aee | ||
|
|
bba5626dea | ||
|
|
bdf5e58929 | ||
|
|
035168230e | ||
|
|
28168f082d | ||
|
|
a29e09ed2f | ||
|
|
e88874e757 | ||
|
|
b6fb7c6977 | ||
|
|
8a60f17d13 | ||
|
|
e1d860a42c | ||
|
|
56da326709 | ||
|
|
672008f8af | ||
|
|
d86b16de6b | ||
|
|
d41710f507 | ||
|
|
2b53166763 | ||
|
|
4e56a16a58 | ||
|
|
ac7cc77017 | ||
|
|
9194879c43 | ||
|
|
1902cdaa55 | ||
|
|
6314987e69 | ||
|
|
65bcbd7737 | ||
|
|
9b25b8538e | ||
|
|
bbf8bb2a94 | ||
|
|
2085227cd9 | ||
|
|
93e0730210 | ||
|
|
25c5c11ab3 | ||
|
|
6ed5b5d35d | ||
|
|
673cc1e0df | ||
|
|
065dcf6f9f | ||
|
|
a66b16ce1c | ||
|
|
e4c0b30284 | ||
|
|
3cb37748e9 | ||
|
|
e54d76f0e2 | ||
|
|
f19e037011 | ||
|
|
ee3f47d918 | ||
|
|
a49a645022 | ||
|
|
cf30441ac6 | ||
|
|
c9bcf866ab | ||
|
|
506609f3cd | ||
|
|
721172c980 | ||
|
|
b183db5b80 | ||
|
|
c7f277f404 | ||
|
|
8ac03a9f8a | ||
|
|
dc1c386d7e | ||
|
|
1eaf4e5bab | ||
|
|
3c0f1c2e73 | ||
|
|
b2790776ef | ||
|
|
2840359396 | ||
|
|
c18ddb99a8 | ||
|
|
432435aa23 | ||
|
|
90c1a6f4ad | ||
|
|
0e8f478c2e | ||
|
|
5f23d5a5ab | ||
|
|
76821a4c2d | ||
|
|
e4a8cca90a | ||
|
|
6024ed4cd1 | ||
|
|
3811f8bbb8 | ||
|
|
306218e5df | ||
|
|
cabfe45608 | ||
|
|
4ade6a5911 | ||
|
|
10d45c3e20 | ||
|
|
6fb247ec06 | ||
|
|
1546258529 | ||
|
|
7ec83b8af6 | ||
|
|
21d0a87c17 | ||
|
|
528f59c1bf | ||
|
|
d1535c4cd8 | ||
|
|
da44d51a3b | ||
|
|
73ce10b26a | ||
|
|
13874b8830 | ||
|
|
ac66809a05 | ||
|
|
f5229d5dec | ||
|
|
222291d11d | ||
|
|
28f47e1093 | ||
|
|
7784346a97 | ||
|
|
ed46af8418 | ||
|
|
095f25a6e9 | ||
|
|
3cdab30dc8 | ||
|
|
8e462047ba | ||
|
|
b7f8b65d9d | ||
|
|
da21c37254 | ||
|
|
a89aef4a89 | ||
|
|
dd6516fd11 | ||
|
|
344a3ce198 | ||
|
|
62f8283587 | ||
|
|
1f89accac3 | ||
|
|
9fcaef4cf7 | ||
|
|
d5738a0631 | ||
|
|
8bf7a1f95a | ||
|
|
04cf36891e | ||
|
|
b2d9948c30 | ||
|
|
a2c9ac1707 | ||
|
|
46a6bd0108 | ||
|
|
5f069e7a40 | ||
|
|
ff57e5c6ae | ||
|
|
f65b6e2d76 | ||
|
|
58fd180ae0 | ||
|
|
cd6503c4c3 | ||
|
|
e8b36c2b6d | ||
|
|
51743f1359 | ||
|
|
504c42646b | ||
|
|
c61e84d1b4 | ||
|
|
b8ab01c46c | ||
|
|
9558060bde | ||
|
|
0741c879f1 | ||
|
|
2e6dd2867e | ||
|
|
9f3fcccb61 | ||
|
|
ae08d7abad | ||
|
|
16808a7ec6 | ||
|
|
b1dfcd6283 | ||
|
|
369f4c0f08 | ||
|
|
827de03579 | ||
|
|
0c7f4e5b43 | ||
|
|
9c700a3fb6 | ||
|
|
89bf83febb | ||
|
|
d44b35cf0a | ||
|
|
891833e273 | ||
|
|
29d3a80d3f | ||
|
|
90fef58684 | ||
|
|
07841722ac | ||
|
|
9e4da5948b | ||
|
|
dc6e4313a1 | ||
|
|
a68cc0b689 | ||
|
|
d057fb0f67 | ||
|
|
7e8f045a29 | ||
|
|
5249d8d18d | ||
|
|
f0fafbadd4 | ||
|
|
8b51079dcc | ||
|
|
354bccd6e8 | ||
|
|
6806772346 | ||
|
|
60082655d4 | ||
|
|
7fe56941fd | ||
|
|
abd498dd33 | ||
|
|
99ca0136ed | ||
|
|
e52830c9b4 | ||
|
|
755decca27 | ||
|
|
41604008d1 | ||
|
|
5bddce8969 | ||
|
|
b25503f141 | ||
|
|
bdf1850448 | ||
|
|
230d3ccf69 | ||
|
|
e378025a2d | ||
|
|
04129ded90 | ||
|
|
a7891f89f8 | ||
|
|
37d4a8e834 |
No files matched your search
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Install Ruby
|
- name: Install Ruby
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: 4.0.1
|
ruby-version: 4.0.5
|
||||||
- name: Cache gems
|
- name: Cache gems
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
- name: Install Ruby
|
- name: Install Ruby
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: 4.0.1
|
ruby-version: 4.0.5
|
||||||
- name: Cache gems
|
- name: Cache gems
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -89,7 +89,7 @@ jobs:
|
|||||||
- name: Install Ruby
|
- name: Install Ruby
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: 4.0.1
|
ruby-version: 4.0.5
|
||||||
- name: Cache gems
|
- name: Cache gems
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -104,7 +104,7 @@ jobs:
|
|||||||
bundle install --jobs 4 --retry 4
|
bundle install --jobs 4 --retry 4
|
||||||
yarn install
|
yarn install
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install libvips
|
sudo apt-get install libvips liblept5
|
||||||
- name: Run Brakeman
|
- name: Run Brakeman
|
||||||
run: bundle exec brakeman -q --exit-on-warn
|
run: bundle exec brakeman -q --exit-on-warn
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
- name: Install Ruby
|
- name: Install Ruby
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: 4.0.1
|
ruby-version: 4.0.5
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
@@ -162,8 +162,8 @@ jobs:
|
|||||||
bundle install --jobs 4 --retry 4
|
bundle install --jobs 4 --retry 4
|
||||||
yarn install
|
yarn install
|
||||||
sudo apt-get update
|
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/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
|
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-$(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
|
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
|
||||||
rm -f pdfium-linux.tgz
|
rm -f pdfium-linux.tgz
|
||||||
- name: Run
|
- name: Run
|
||||||
|
|||||||
+14
-8
@@ -1,19 +1,23 @@
|
|||||||
FROM ruby:4.0.1-alpine AS download
|
FROM ruby:4.0.5-alpine AS download
|
||||||
|
|
||||||
WORKDIR /fonts
|
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-Regular.ttf && \
|
||||||
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.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://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/impallari/DancingScript/master/OFL.txt && \
|
||||||
wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \
|
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 /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 && \
|
mkdir -p /pdfium-linux && \
|
||||||
tar -xzf pdfium-linux.tgz -C /pdfium-linux
|
unzip -q pdfium-linux.zip -d /pdfium-linux
|
||||||
|
|
||||||
FROM ruby:4.0.1-alpine AS webpack
|
FROM ruby:4.0.5-alpine AS webpack
|
||||||
|
|
||||||
ENV RAILS_ENV=production
|
ENV RAILS_ENV=production
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -40,7 +44,7 @@ COPY ./app/views ./app/views
|
|||||||
|
|
||||||
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
|
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
|
||||||
|
|
||||||
FROM ruby:4.0.1-alpine AS app
|
FROM ruby:4.0.5-alpine AS app
|
||||||
|
|
||||||
ENV RAILS_ENV=production
|
ENV RAILS_ENV=production
|
||||||
ENV BUNDLE_WITHOUT="development:test"
|
ENV BUNDLE_WITHOUT="development:test"
|
||||||
@@ -48,7 +52,8 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache libpq vips redis vips-heif 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
|
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
|
||||||
|
|
||||||
@@ -81,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 --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/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=download /model.onnx /app/tmp/model.onnx
|
||||||
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
|
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
|
||||||
|
|
||||||
@@ -94,6 +99,7 @@ WORKDIR /data/docuseal
|
|||||||
ENV HOME=/home/docuseal
|
ENV HOME=/home/docuseal
|
||||||
ENV WORKDIR=/data/docuseal
|
ENV WORKDIR=/data/docuseal
|
||||||
ENV VIPS_MAX_COORD=17000
|
ENV VIPS_MAX_COORD=17000
|
||||||
|
ENV VIPS_BLOCK_UNTRUSTED=1
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
|
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
source 'https://rubygems.org'
|
source 'https://rubygems.org'
|
||||||
|
|
||||||
ruby '4.0.1'
|
ruby '4.0.5'
|
||||||
|
|
||||||
gem 'addressable'
|
gem 'addressable'
|
||||||
gem 'arabic-letter-connector', require: false
|
gem 'arabic-letter-connector', require: false
|
||||||
@@ -21,11 +21,9 @@ gem 'faraday'
|
|||||||
gem 'faraday-follow_redirects'
|
gem 'faraday-follow_redirects'
|
||||||
gem 'google-cloud-storage', require: false
|
gem 'google-cloud-storage', require: false
|
||||||
gem 'hexapdf'
|
gem 'hexapdf'
|
||||||
gem 'image_processing'
|
|
||||||
gem 'jwt', require: false
|
gem 'jwt', require: false
|
||||||
gem 'lograge'
|
gem 'lograge'
|
||||||
gem 'numo-narray-alt', require: false
|
gem 'numo-narray-alt', require: false
|
||||||
gem 'oj'
|
|
||||||
gem 'onnxruntime', require: false
|
gem 'onnxruntime', require: false
|
||||||
gem 'pagy'
|
gem 'pagy'
|
||||||
gem 'pg', require: false
|
gem 'pg', require: false
|
||||||
|
|||||||
+21
-32
@@ -116,7 +116,7 @@ GEM
|
|||||||
erubi (~> 1.4)
|
erubi (~> 1.4)
|
||||||
parser (>= 2.4)
|
parser (>= 2.4)
|
||||||
smart_properties
|
smart_properties
|
||||||
bigdecimal (4.1.0)
|
bigdecimal (4.1.2)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.23.0)
|
bootsnap (1.23.0)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
@@ -144,7 +144,7 @@ GEM
|
|||||||
cldr-plurals-runtime-rb (1.1.0)
|
cldr-plurals-runtime-rb (1.1.0)
|
||||||
cmdparse (3.0.7)
|
cmdparse (3.0.7)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
concurrent-ruby (1.3.6)
|
concurrent-ruby (1.3.7)
|
||||||
connection_pool (3.0.2)
|
connection_pool (3.0.2)
|
||||||
crack (1.0.1)
|
crack (1.0.1)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
@@ -161,7 +161,7 @@ GEM
|
|||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
declarative (0.0.20)
|
declarative (0.0.20)
|
||||||
devise (5.0.3)
|
devise (5.0.4)
|
||||||
bcrypt (~> 3.0)
|
bcrypt (~> 3.0)
|
||||||
orm_adapter (~> 0.1)
|
orm_adapter (~> 0.1)
|
||||||
railties (>= 7.0)
|
railties (>= 7.0)
|
||||||
@@ -195,13 +195,13 @@ GEM
|
|||||||
railties (>= 6.1.0)
|
railties (>= 6.1.0)
|
||||||
faker (3.6.1)
|
faker (3.6.1)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (2.14.1)
|
faraday (2.14.3)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
faraday-follow_redirects (0.5.0)
|
faraday-follow_redirects (0.5.0)
|
||||||
faraday (>= 1, < 3)
|
faraday (>= 1, < 3)
|
||||||
faraday-net_http (3.4.2)
|
faraday-net_http (3.4.4)
|
||||||
net-http (~> 0.5)
|
net-http (~> 0.5)
|
||||||
ferrum (0.17.2)
|
ferrum (0.17.2)
|
||||||
addressable (~> 2.5)
|
addressable (~> 2.5)
|
||||||
@@ -264,18 +264,15 @@ GEM
|
|||||||
strscan (>= 3.1.2)
|
strscan (>= 3.1.2)
|
||||||
i18n (1.14.8)
|
i18n (1.14.8)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
image_processing (1.14.0)
|
|
||||||
mini_magick (>= 4.9.5, < 6)
|
|
||||||
ruby-vips (>= 2.0.17, < 3)
|
|
||||||
io-console (0.8.2)
|
io-console (0.8.2)
|
||||||
irb (1.17.0)
|
irb (1.18.0)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
prism (>= 1.3.0)
|
prism (>= 1.3.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.19.3)
|
json (2.19.9)
|
||||||
jwt (3.1.2)
|
jwt (3.2.0)
|
||||||
base64
|
base64
|
||||||
language_server-protocol (3.17.0.5)
|
language_server-protocol (3.17.0.5)
|
||||||
launchy (3.1.1)
|
launchy (3.1.1)
|
||||||
@@ -308,17 +305,15 @@ GEM
|
|||||||
marcel (1.1.0)
|
marcel (1.1.0)
|
||||||
matrix (0.4.3)
|
matrix (0.4.3)
|
||||||
method_source (1.1.0)
|
method_source (1.1.0)
|
||||||
mini_magick (5.3.1)
|
|
||||||
logger
|
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (6.0.3)
|
minitest (6.0.6)
|
||||||
drb (~> 2.0)
|
drb (~> 2.0)
|
||||||
prism (~> 1.5)
|
prism (~> 1.5)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
multi_json (1.19.1)
|
multi_json (1.19.1)
|
||||||
net-http (0.9.1)
|
net-http (0.9.1)
|
||||||
uri (>= 0.11.1)
|
uri (>= 0.11.1)
|
||||||
net-imap (0.6.4)
|
net-imap (0.6.4.1)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
@@ -328,20 +323,17 @@ GEM
|
|||||||
net-smtp (0.5.1)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.7.5)
|
nio4r (2.7.5)
|
||||||
nokogiri (1.19.3-aarch64-linux-gnu)
|
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.3-aarch64-linux-musl)
|
nokogiri (1.19.4-aarch64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.3-arm64-darwin)
|
nokogiri (1.19.4-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.3-x86_64-linux-musl)
|
nokogiri (1.19.4-x86_64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
numo-narray-alt (0.10.3)
|
numo-narray-alt (0.10.3)
|
||||||
oj (3.16.16)
|
|
||||||
bigdecimal (>= 3.0)
|
|
||||||
ostruct (>= 0.2)
|
|
||||||
onnxruntime (0.10.1-aarch64-linux)
|
onnxruntime (0.10.1-aarch64-linux)
|
||||||
ffi
|
ffi
|
||||||
onnxruntime (0.10.1-arm64-darwin)
|
onnxruntime (0.10.1-arm64-darwin)
|
||||||
@@ -351,7 +343,6 @@ GEM
|
|||||||
openssl (4.0.1)
|
openssl (4.0.1)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.3)
|
|
||||||
package_json (0.2.0)
|
package_json (0.2.0)
|
||||||
pagy (43.4.4)
|
pagy (43.4.4)
|
||||||
json
|
json
|
||||||
@@ -382,7 +373,7 @@ GEM
|
|||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (7.0.5)
|
public_suffix (7.0.5)
|
||||||
puma (7.2.0)
|
puma (8.0.2)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.6)
|
rack (3.2.6)
|
||||||
@@ -429,12 +420,12 @@ GEM
|
|||||||
tsort (>= 0.2)
|
tsort (>= 0.2)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.1)
|
rake (13.4.2)
|
||||||
rdoc (7.2.0)
|
rdoc (7.2.0)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
redis-client (0.28.0)
|
redis-client (0.29.0)
|
||||||
connection_pool
|
connection_pool
|
||||||
regexp_parser (2.11.3)
|
regexp_parser (2.11.3)
|
||||||
reline (0.6.3)
|
reline (0.6.3)
|
||||||
@@ -516,12 +507,12 @@ GEM
|
|||||||
rack-proxy (>= 0.6.1)
|
rack-proxy (>= 0.6.1)
|
||||||
railties (>= 5.2)
|
railties (>= 5.2)
|
||||||
semantic_range (>= 2.3.0)
|
semantic_range (>= 2.3.0)
|
||||||
sidekiq (8.1.2)
|
sidekiq (8.1.6)
|
||||||
connection_pool (>= 3.0.0)
|
connection_pool (>= 3.0.0)
|
||||||
json (>= 2.16.0)
|
json (>= 2.16.0)
|
||||||
logger (>= 1.7.0)
|
logger (>= 1.7.0)
|
||||||
rack (>= 3.2.0)
|
rack (>= 3.2.0)
|
||||||
redis-client (>= 0.26.0)
|
redis-client (>= 0.29.0)
|
||||||
signet (0.21.0)
|
signet (0.21.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
faraday (>= 0.17.5, < 3.a)
|
faraday (>= 0.17.5, < 3.a)
|
||||||
@@ -624,12 +615,10 @@ DEPENDENCIES
|
|||||||
foreman
|
foreman
|
||||||
google-cloud-storage
|
google-cloud-storage
|
||||||
hexapdf
|
hexapdf
|
||||||
image_processing
|
|
||||||
jwt
|
jwt
|
||||||
letter_opener_web
|
letter_opener_web
|
||||||
lograge
|
lograge
|
||||||
numo-narray-alt
|
numo-narray-alt
|
||||||
oj
|
|
||||||
onnxruntime
|
onnxruntime
|
||||||
pagy
|
pagy
|
||||||
pg
|
pg
|
||||||
@@ -662,7 +651,7 @@ DEPENDENCIES
|
|||||||
webmock
|
webmock
|
||||||
|
|
||||||
RUBY VERSION
|
RUBY VERSION
|
||||||
ruby 4.0.1
|
ruby 4.0.5
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
4.0.3
|
4.0.3
|
||||||
@@ -9,7 +9,9 @@ module Api
|
|||||||
|
|
||||||
before_action :set_cors_headers
|
before_action :set_cors_headers
|
||||||
before_action :set_noindex_headers
|
before_action :set_noindex_headers
|
||||||
|
before_action :set_security_headers
|
||||||
|
|
||||||
|
# rubocop:disable Metrics
|
||||||
def show
|
def show
|
||||||
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
|
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
|
||||||
|
|
||||||
@@ -21,6 +23,12 @@ module Api
|
|||||||
|
|
||||||
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
|
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
|
||||||
|
|
||||||
|
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
|
||||||
|
Rollbar.error('Dangerous extension') if defined?(Rollbar)
|
||||||
|
|
||||||
|
return head :unprocessable_content
|
||||||
|
end
|
||||||
|
|
||||||
attachment = blob.attachments.take
|
attachment = blob.attachments.take
|
||||||
|
|
||||||
@record = attachment.record
|
@record = attachment.record
|
||||||
@@ -45,6 +53,7 @@ module Api
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
# rubocop:enable Metrics
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ module Api
|
|||||||
|
|
||||||
before_action :set_cors_headers
|
before_action :set_cors_headers
|
||||||
before_action :set_noindex_headers
|
before_action :set_noindex_headers
|
||||||
|
before_action :set_security_headers
|
||||||
|
|
||||||
# rubocop:disable Metrics
|
# rubocop:disable Metrics
|
||||||
def show
|
def show
|
||||||
@@ -18,6 +19,12 @@ module Api
|
|||||||
|
|
||||||
return head :not_found unless blob
|
return head :not_found unless blob
|
||||||
|
|
||||||
|
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
|
||||||
|
Rollbar.error('Dangerous extension') if defined?(Rollbar)
|
||||||
|
|
||||||
|
return head :unprocessable_content
|
||||||
|
end
|
||||||
|
|
||||||
is_permitted = blob.attachments.any? do |a|
|
is_permitted = blob.attachments.any? do |a|
|
||||||
(current_user && a.record.account.id == current_user.account_id) ||
|
(current_user && a.record.account.id == current_user.account_id) ||
|
||||||
a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } ||
|
a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } ||
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ module Api
|
|||||||
headers['X-Robots-Tag'] = 'noindex'
|
headers['X-Robots-Tag'] = 'noindex'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_security_headers
|
||||||
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
|
end
|
||||||
|
|
||||||
def set_cors_headers
|
def set_cors_headers
|
||||||
headers['Access-Control-Allow-Origin'] = '*'
|
headers['Access-Control-Allow-Origin'] = '*'
|
||||||
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
|
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ module Api
|
|||||||
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||||
|
|
||||||
unless can_upload?(@submitter)
|
unless can_upload?(@submitter)
|
||||||
Rollbar.error("Can't upload: #{@submitter.id}") if defined?(Rollbar)
|
|
||||||
|
|
||||||
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
|
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
|
||||||
end
|
end
|
||||||
|
|
||||||
|
file = params[:file]
|
||||||
|
|
||||||
if params[:type].in?(%w[initials signature])
|
if params[:type].in?(%w[initials signature])
|
||||||
image = Vips::Image.new_from_file(params[:file].path)
|
image = ImageUtils.load_vips(file.read, content_type: file.content_type)
|
||||||
|
|
||||||
if ImageUtils.blank?(image)
|
if ImageUtils.blank?(image)
|
||||||
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
|
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
|
||||||
@@ -31,9 +31,11 @@ module Api
|
|||||||
return render json: { error: "#{params[:type]} error, try to sign on another device" },
|
return render json: { error: "#{params[:type]} error, try to sign on another device" },
|
||||||
status: :unprocessable_content
|
status: :unprocessable_content
|
||||||
end
|
end
|
||||||
|
|
||||||
|
metadata = { analyzed: true, identified: true, width: image.width, height: image.height }
|
||||||
end
|
end
|
||||||
|
|
||||||
attachment = Submitters.create_attachment!(@submitter, params)
|
attachment = Submitters.create_attachment!(@submitter, file, metadata:)
|
||||||
|
|
||||||
if params[:remember_signature] == 'true' && @submitter.email.present?
|
if params[:remember_signature] == 'true' && @submitter.email.present?
|
||||||
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
|
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ module Api
|
|||||||
(@submission.schema_documents || @submission.template.schema_documents).size > 1
|
(@submission.schema_documents || @submission.template.schema_documents).size > 1
|
||||||
|
|
||||||
documents =
|
documents =
|
||||||
if @submission.submitters.all?(&:completed_at?)
|
if @submission.completed_at?
|
||||||
build_completed_documents(@submission, merge: is_merge)
|
build_completed_documents(@submission, merge: is_merge)
|
||||||
else
|
else
|
||||||
build_preview_documents(@submission, merge: is_merge)
|
build_preview_documents(@submission, merge: is_merge)
|
||||||
@@ -30,7 +30,7 @@ module Api
|
|||||||
private
|
private
|
||||||
|
|
||||||
def build_completed_documents(submission, merge: false)
|
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 merge
|
||||||
if submission.merged_document_attachment.blank?
|
if submission.merged_document_attachment.blank?
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ module Api
|
|||||||
load_and_authorize_resource :submission, parent: false
|
load_and_authorize_resource :submission, parent: false
|
||||||
|
|
||||||
def index
|
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[: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?
|
params[:before] = Time.zone.at(params[:before].to_i) if params[:before].present?
|
||||||
@@ -36,20 +36,5 @@ module Api
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
@@ -2,21 +2,39 @@
|
|||||||
|
|
||||||
module Api
|
module Api
|
||||||
class SubmissionsController < ApiBaseController
|
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 :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
|
before_action only: :create do
|
||||||
authorize!(:create, Submission)
|
authorize!(:create, Submission)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
before_action :maybe_return_template_error, only: :create
|
||||||
|
|
||||||
def index
|
def index
|
||||||
submissions = Submissions.search(current_user, @submissions, params[:q])
|
submissions = Submissions.search(current_user, @submissions, params[:q])
|
||||||
submissions = filter_submissions(submissions, params)
|
submissions = filter_submissions(submissions, params)
|
||||||
|
|
||||||
submissions = paginate(submissions.preload(:created_by_user, :submitters,
|
with_fields = params[:include].to_s.include?('fields') || params[:include].to_s.include?('combined_document_url')
|
||||||
template: { folder: :parent_folder },
|
|
||||||
combined_document_attachment: :blob,
|
submissions = paginate(
|
||||||
audit_trail_attachment: :blob))
|
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)
|
expires_at = Accounts.link_expires_at(current_account)
|
||||||
|
|
||||||
@@ -42,7 +60,7 @@ module Api
|
|||||||
end
|
end
|
||||||
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)
|
@submission.audit_trail_attachment = Submissions::EnsureAuditGenerated.call(@submission)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -52,14 +70,6 @@ module Api
|
|||||||
def create
|
def create
|
||||||
Params::SubmissionCreateValidator.call(params)
|
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_email] = true unless params.key?(:send_email)
|
||||||
params[:send_sms] = false unless params.key?(:send_sms)
|
params[:send_sms] = false unless params.key?(:send_sms)
|
||||||
|
|
||||||
@@ -70,10 +80,17 @@ module Api
|
|||||||
Submissions.send_signature_requests(submissions)
|
Submissions.send_signature_requests(submissions)
|
||||||
|
|
||||||
submissions.each do |submission|
|
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|
|
submission.submitters.each do |submitter|
|
||||||
next unless submitter.completed_at?
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -87,6 +104,25 @@ module Api
|
|||||||
render json: { error: e.message }, status: :unprocessable_content
|
render json: { error: e.message }, status: :unprocessable_content
|
||||||
end
|
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
|
def destroy
|
||||||
if params[:permanently].in?(['true', true])
|
if params[:permanently].in?(['true', true])
|
||||||
@submission.destroy!
|
@submission.destroy!
|
||||||
@@ -101,6 +137,41 @@ module Api
|
|||||||
|
|
||||||
private
|
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)
|
def filter_submissions(submissions, params)
|
||||||
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
|
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
|
||||||
submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
|
submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ module Api
|
|||||||
class SubmittersController < ApiBaseController
|
class SubmittersController < ApiBaseController
|
||||||
load_and_authorize_resource :submitter
|
load_and_authorize_resource :submitter
|
||||||
|
|
||||||
|
before_action :maybe_return_submitter_error, only: :update
|
||||||
|
|
||||||
def index
|
def index
|
||||||
submitters = Submitters.search(current_user, @submitters, params[:q])
|
submitters = Submitters.search(current_user, @submitters, params[:q])
|
||||||
|
|
||||||
@@ -36,14 +38,6 @@ module Api
|
|||||||
|
|
||||||
# rubocop:disable Metrics/MethodLength
|
# rubocop:disable Metrics/MethodLength
|
||||||
def update
|
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
|
submission = @submitter.submission
|
||||||
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
|
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
|
||||||
|
|
||||||
@@ -73,7 +67,9 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
if @submitter.completed_at?
|
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]
|
elsif normalized_params[:send_email] || normalized_params[:send_sms]
|
||||||
Submitters.send_signature_requests([@submitter])
|
Submitters.send_signature_requests([@submitter])
|
||||||
end
|
end
|
||||||
@@ -104,7 +100,17 @@ module Api
|
|||||||
|
|
||||||
private
|
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?
|
if params[:completed_after].present?
|
||||||
submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..)
|
submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..)
|
||||||
end
|
end
|
||||||
@@ -177,7 +183,7 @@ module Api
|
|||||||
submitters = submitters.joins(:submission).where(submissions: { template_id: params[:template_id] })
|
submitters = submitters.joins(:submission).where(submissions: { template_id: params[:template_id] })
|
||||||
end
|
end
|
||||||
|
|
||||||
maybe_filder_by_completed_at(submitters, params)
|
maybe_filter_by_completed_at(submitters, params)
|
||||||
end
|
end
|
||||||
|
|
||||||
def assign_external_id(submitter, attrs)
|
def assign_external_id(submitter, attrs)
|
||||||
|
|||||||
@@ -5,24 +5,13 @@ module Api
|
|||||||
load_and_authorize_resource :template
|
load_and_authorize_resource :template
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
@templates = Templates.shared(current_user) if params[:shared].in?(['true', true])
|
||||||
|
|
||||||
templates = filter_templates(@templates, params)
|
templates = filter_templates(@templates, params)
|
||||||
|
|
||||||
templates = paginate(templates.preload(:author, folder: :parent_folder))
|
templates = paginate(templates.preload(:author, folder: :parent_folder))
|
||||||
|
|
||||||
schema_documents =
|
schema_documents, dynamic_documents, preview_image_attachments = preload_relations(templates)
|
||||||
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
|
|
||||||
record_type: 'Template',
|
|
||||||
name: :documents,
|
|
||||||
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
|
|
||||||
.preload(:blob)
|
|
||||||
|
|
||||||
preview_image_attachments =
|
|
||||||
ActiveStorage::Attachment.joins(:blob)
|
|
||||||
.where(blob: { filename: ['0.png', '0.jpg'] })
|
|
||||||
.where(record_id: schema_documents.map(&:id),
|
|
||||||
record_type: 'ActiveStorage::Attachment',
|
|
||||||
name: :preview_images)
|
|
||||||
.preload(:blob)
|
|
||||||
|
|
||||||
expires_at = Accounts.link_expires_at(current_account)
|
expires_at = Accounts.link_expires_at(current_account)
|
||||||
|
|
||||||
@@ -30,6 +19,7 @@ module Api
|
|||||||
data: templates.map do |t|
|
data: templates.map do |t|
|
||||||
Templates::SerializeForApi.call(t,
|
Templates::SerializeForApi.call(t,
|
||||||
schema_documents: schema_documents.select { |e| e.record_id == t.id },
|
schema_documents: schema_documents.select { |e| e.record_id == t.id },
|
||||||
|
dynamic_documents:,
|
||||||
preview_image_attachments:,
|
preview_image_attachments:,
|
||||||
expires_at:)
|
expires_at:)
|
||||||
end,
|
end,
|
||||||
@@ -60,16 +50,19 @@ module Api
|
|||||||
|
|
||||||
archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived)
|
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
|
@template.archived_at = archived == true ? Time.current : nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@template.update!(template_params)
|
@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.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])
|
render json: @template.as_json(only: %i[id updated_at])
|
||||||
end
|
end
|
||||||
@@ -88,8 +81,49 @@ module Api
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def preload_relations(templates)
|
||||||
|
schema_documents =
|
||||||
|
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
|
||||||
|
record_type: 'Template',
|
||||||
|
name: :documents,
|
||||||
|
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
|
||||||
|
.preload(:blob)
|
||||||
|
|
||||||
|
dynamic_document_uuids =
|
||||||
|
templates.flat_map { |t| t.schema.select { |item| item['dynamic'] }.pluck('attachment_uuid') }
|
||||||
|
|
||||||
|
dynamic_documents =
|
||||||
|
if dynamic_document_uuids.present?
|
||||||
|
DynamicDocument.where(template: templates.map(&:id))
|
||||||
|
.where(uuid: dynamic_document_uuids)
|
||||||
|
.preload(current_version: { document_attachment: :blob })
|
||||||
|
.select(:id, :uuid, :template_id, :sha1, :created_at, :updated_at)
|
||||||
|
else
|
||||||
|
DynamicDocument.none
|
||||||
|
end
|
||||||
|
|
||||||
|
preview_attachment_ids =
|
||||||
|
schema_documents.map(&:id) + dynamic_documents.filter_map { |d| d.current_version&.document_attachment&.id }
|
||||||
|
|
||||||
|
preview_image_attachments =
|
||||||
|
ActiveStorage::Attachment.joins(:blob)
|
||||||
|
.where(blob: { filename: ['0.png', '0.jpg'] })
|
||||||
|
.where(record_id: preview_attachment_ids,
|
||||||
|
record_type: 'ActiveStorage::Attachment',
|
||||||
|
name: :preview_images)
|
||||||
|
.preload(:blob)
|
||||||
|
|
||||||
|
[schema_documents, dynamic_documents, preview_image_attachments]
|
||||||
|
end
|
||||||
|
|
||||||
def filter_templates(templates, params)
|
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 = 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[:application_key]) if params[:application_key].present?
|
||||||
templates = templates.where(external_id: params[:external_id]) if params[:external_id].present?
|
templates = templates.where(external_id: params[:external_id]) if params[:external_id].present?
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
class EmailSmtpSettingsController < ApplicationController
|
class EmailSmtpSettingsController < ApplicationController
|
||||||
before_action :load_encrypted_config
|
before_action :load_encrypted_config
|
||||||
authorize_resource :encrypted_config, only: :index
|
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
|
def index; end
|
||||||
|
|
||||||
@@ -23,6 +23,12 @@ class EmailSmtpSettingsController < ApplicationController
|
|||||||
render :index, status: :unprocessable_content
|
render :index, status: :unprocessable_content
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@encrypted_config.destroy!
|
||||||
|
|
||||||
|
redirect_to settings_email_index_path, notice: I18n.t('smtp_settings_have_been_reset')
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def load_encrypted_config
|
def load_encrypted_config
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ class PreviewDocumentPageController < ActionController::API
|
|||||||
preview_image = attachment.preview_images.joins(:blob)
|
preview_image = attachment.preview_images.joins(:blob)
|
||||||
.find_by(blob: { filename: ["#{params[:id]}.png", "#{params[:id]}.jpg"] })
|
.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 =
|
file_path =
|
||||||
if attachment.service.name == :disk
|
if attachment.service.name == :disk
|
||||||
@@ -37,7 +40,7 @@ class PreviewDocumentPageController < ActionController::API
|
|||||||
preview_image =
|
preview_image =
|
||||||
Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
|
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
|
end
|
||||||
|
|
||||||
def find_or_create_document_tempfile_path(attachment)
|
def find_or_create_document_tempfile_path(attachment)
|
||||||
|
|||||||
@@ -16,18 +16,16 @@ class SendSubmissionEmailController < ApplicationController
|
|||||||
@submitter =
|
@submitter =
|
||||||
Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase)
|
Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase)
|
||||||
elsif params[:submission_slug]
|
elsif params[:submission_slug]
|
||||||
submission = Submission.find_by(slug: params[:submission_slug])
|
submission = Submission.find_by!(slug: params[:submission_slug])
|
||||||
|
|
||||||
if submission
|
@submitter = submission.submitters.order(:completed_at).find_by(email: params[:email].to_s.downcase)
|
||||||
@submitter = Submitter.completed.find_by(submission: submission, email: params[:email].to_s.downcase)
|
|
||||||
end
|
|
||||||
|
|
||||||
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter
|
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter
|
||||||
else
|
else
|
||||||
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
|
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||||
end
|
end
|
||||||
|
|
||||||
if @submitter
|
if @submitter && completed_submitter?(@submitter)
|
||||||
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
|
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
|
||||||
|
|
||||||
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
|
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
|
||||||
@@ -41,6 +39,10 @@ class SendSubmissionEmailController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def completed_submitter?(submitter)
|
||||||
|
submitter.completed_at? || (submitter.viewer? && submitter.submission.completed_at?)
|
||||||
|
end
|
||||||
|
|
||||||
def can_send?(submitter)
|
def can_send?(submitter)
|
||||||
return false if submitter.account.archived_at?
|
return false if submitter.account.archived_at?
|
||||||
return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
|
return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
|
||||||
|
|||||||
@@ -93,20 +93,34 @@ class StartFormController < ApplicationController
|
|||||||
|
|
||||||
SearchEntries.enqueue_reindex(submitter)
|
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
|
end
|
||||||
|
|
||||||
def load_resubmit_submitter
|
def load_resubmit_submitter
|
||||||
@resubmit_submitter =
|
@resubmit_submitter =
|
||||||
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
|
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
|
||||||
Submitter.find_by(slug: params[:resubmit])
|
submitter = Submitter.find_by(slug: params[:resubmit])
|
||||||
|
|
||||||
|
submitter if submitter && can_resubmit?(submitter)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def can_resubmit?(submitter)
|
||||||
|
submitter.completed_at? && submitter.completed_at > 14.days.ago &&
|
||||||
|
%w[api embed mcp].exclude?(submitter.submission.source) &&
|
||||||
|
submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false
|
||||||
|
end
|
||||||
|
|
||||||
def authorize_start!
|
def authorize_start!
|
||||||
return redirect_to start_form_path(@template.slug) if @template.archived_at?
|
is_archived = @template.archived_at? || @template.account.archived_at?
|
||||||
|
|
||||||
|
return redirect_to submit_form_path(@resubmit_submitter.slug) if @resubmit_submitter && is_archived
|
||||||
|
return redirect_to start_form_path(@template.slug) if is_archived
|
||||||
|
|
||||||
return if @resubmit_submitter
|
return if @resubmit_submitter
|
||||||
return if @template.shared_link? || (current_user && current_ability.can?(:read, @template))
|
return if @template.shared_link? || (current_user && current_ability.can?(:read, @template))
|
||||||
@@ -127,8 +141,7 @@ class StartFormController < ApplicationController
|
|||||||
|
|
||||||
submitter ||=
|
submitter ||=
|
||||||
Submitter
|
Submitter
|
||||||
.where(submission: template.submissions.where(expire_at: Time.current..)
|
.where(submission: template.submissions.non_expired.active)
|
||||||
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
|
|
||||||
.order(id: :desc)
|
.order(id: :desc)
|
||||||
.where(declined_at: nil)
|
.where(declined_at: nil)
|
||||||
.where(external_id: nil)
|
.where(external_id: nil)
|
||||||
@@ -136,6 +149,8 @@ class StartFormController < ApplicationController
|
|||||||
.then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
|
.then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
|
||||||
.find_or_initialize_by(find_params)
|
.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?
|
submitter.name = required_params['name'] if submitter.new_record?
|
||||||
|
|
||||||
unless @resubmit_submitter
|
unless @resubmit_submitter
|
||||||
|
|||||||
@@ -7,17 +7,27 @@ class SubmissionsArchivedController < ApplicationController
|
|||||||
@submissions = @submissions.left_joins(:template)
|
@submissions = @submissions.left_joins(:template)
|
||||||
@submissions = @submissions.where.not(archived_at: nil)
|
@submissions = @submissions.where.not(archived_at: nil)
|
||||||
.or(@submissions.where.not(templates: { 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.search(current_user, @submissions, params[:q], search_template: true)
|
||||||
@submissions = Submissions::Filter.call(@submissions, current_user, params)
|
@submissions = Submissions::Filter.call(@submissions, current_user, params)
|
||||||
|
|
||||||
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
|
@submissions =
|
||||||
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
|
if params[:status] == 'completed' || params[:completed_at_from].present? || params[:completed_at_to].present?
|
||||||
else
|
@submissions.order(completed_at: :desc)
|
||||||
@submissions.order(id: :desc)
|
else
|
||||||
end
|
@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
|
||||||
end
|
end
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class SubmissionsController < ApplicationController
|
class SubmissionsController < ApplicationController
|
||||||
before_action :load_template, only: %i[new create]
|
load_and_authorize_resource :template, only: %i[new create]
|
||||||
authorize_resource :template, only: %i[new create]
|
|
||||||
|
|
||||||
load_and_authorize_resource :submission, only: %i[show destroy]
|
load_and_authorize_resource :submission, only: %i[show destroy]
|
||||||
|
|
||||||
prepend_before_action :maybe_redirect_com, only: %i[show]
|
prepend_before_action :maybe_redirect_com, only: %i[show]
|
||||||
@@ -23,7 +21,7 @@ class SubmissionsController < ApplicationController
|
|||||||
def show
|
def show
|
||||||
@submission = Submissions.preload_with_pages(@submission)
|
@submission = Submissions.preload_with_pages(@submission)
|
||||||
|
|
||||||
unless @submission.submitters.all?(&:completed_at?)
|
unless @submission.completed_at?
|
||||||
ActiveRecord::Associations::Preloader.new(
|
ActiveRecord::Associations::Preloader.new(
|
||||||
records: [@submission],
|
records: [@submission],
|
||||||
associations: [{ submitters: :start_form_submission_events }]
|
associations: [{ submitters: :start_form_submission_events }]
|
||||||
@@ -38,6 +36,8 @@ class SubmissionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
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'
|
save_template_message(@template, params) if params[:save_message] == '1'
|
||||||
|
|
||||||
[params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1'
|
[params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1'
|
||||||
@@ -87,6 +87,8 @@ class SubmissionsController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def create_submissions(template, submissions_params, params)
|
def create_submissions(template, submissions_params, params)
|
||||||
|
normalize_message_submitter_uuids!(params)
|
||||||
|
|
||||||
submissions_attrs = submissions_params[:submission].to_h.values
|
submissions_attrs = submissions_params[:submission].to_h.values
|
||||||
|
|
||||||
submissions_attrs, _, new_fields =
|
submissions_attrs, _, new_fields =
|
||||||
@@ -112,7 +114,22 @@ class SubmissionsController < ApplicationController
|
|||||||
params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] })
|
params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] })
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_template
|
def normalize_message_submitter_uuids!(params)
|
||||||
@template = Template.accessible_by(current_ability).find(params[:template_id])
|
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
|
||||||
end
|
end
|
||||||
@@ -8,17 +8,27 @@ class SubmissionsDashboardController < ApplicationController
|
|||||||
|
|
||||||
@submissions = @submissions.where(archived_at: nil)
|
@submissions = @submissions.where(archived_at: nil)
|
||||||
.where(templates: { 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.search(current_user, @submissions, params[:q], search_template: true)
|
||||||
@submissions = Submissions::Filter.call(@submissions, current_user, params)
|
@submissions = Submissions::Filter.call(@submissions, current_user, params)
|
||||||
|
|
||||||
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
|
@submissions =
|
||||||
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
|
if params[:status] == 'completed' || params[:completed_at_from].present? || params[:completed_at_to].present?
|
||||||
else
|
@submissions.order(completed_at: :desc)
|
||||||
@submissions.order(id: :desc)
|
else
|
||||||
end
|
@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
|
||||||
end
|
end
|
||||||
@@ -25,7 +25,7 @@ class SubmissionsPreviewController < ApplicationController
|
|||||||
|
|
||||||
raise ActionController::RoutingError, I18n.t('not_found') if @submission.account.archived_at?
|
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))
|
(!current_user || !current_ability.can?(:read, @submission))
|
||||||
raise ActionController::RoutingError, I18n.t('not_found')
|
raise ActionController::RoutingError, I18n.t('not_found')
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class SubmissionsResendEmailController < ApplicationController
|
||||||
|
load_and_authorize_resource :submission
|
||||||
|
|
||||||
|
before_action do
|
||||||
|
authorize!(:manage, :resend_all)
|
||||||
|
authorize!(:update, @submission)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
submitters = @submission.submitters.reject(&:completed_at?).select { |s| s.email.present? && !s.declined_at? }
|
||||||
|
|
||||||
|
if Docuseal.multitenant?
|
||||||
|
recent_submitter_ids =
|
||||||
|
SubmissionEvent.where(submitter_id: submitters.map(&:id),
|
||||||
|
event_type: 'send_email',
|
||||||
|
created_at: 10.hours.ago..Time.current).pluck(:submitter_id).to_set
|
||||||
|
|
||||||
|
submitters = submitters.reject { |s| recent_submitter_ids.include?(s.id) }
|
||||||
|
end
|
||||||
|
|
||||||
|
submitters.each do |submitter|
|
||||||
|
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id)
|
||||||
|
|
||||||
|
submitter.sent_at ||= Time.current
|
||||||
|
submitter.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
notice =
|
||||||
|
if submitters.empty?
|
||||||
|
I18n.t('email_has_been_sent_already')
|
||||||
|
else
|
||||||
|
I18n.t('emails_have_been_sent_to_n_recipients', count: submitters.size)
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_back(fallback_location: submission_path(@submission), notice:)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,6 +4,8 @@ class SubmissionsUnarchiveController < ApplicationController
|
|||||||
load_and_authorize_resource :submission
|
load_and_authorize_resource :submission
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
authorize!(:destroy, @submission)
|
||||||
|
|
||||||
@submission.update!(archived_at: nil)
|
@submission.update!(archived_at: nil)
|
||||||
|
|
||||||
redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_unarchived')
|
redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_unarchived')
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class SubmitFormCompletedDownloadController < ApplicationController
|
|||||||
|
|
||||||
@submitter ||= Submitter.find_by!(slug: submitter_slug)
|
@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
|
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)
|
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||||
|
|
||||||
if !signature_valid && !current_user_submitter?(last_submitter)
|
if !signature_valid && !current_user_submitter?(last_submitter)
|
||||||
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||||
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
|
|
||||||
|
|
||||||
return head :not_found
|
|
||||||
end
|
|
||||||
|
|
||||||
if last_submitter.completed_at < TTL.ago
|
if last_submitter.completed_at < TTL.ago
|
||||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ class SubmitFormController < ApplicationController
|
|||||||
submission = @submitter.submission
|
submission = @submitter.submission
|
||||||
|
|
||||||
return render :email_2fa unless Submitters::AuthorizedForForm.pass_email_2fa?(@submitter, request)
|
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)
|
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
|
||||||
|
|
||||||
@@ -71,6 +74,12 @@ class SubmitFormController < ApplicationController
|
|||||||
status: :unprocessable_content
|
status: :unprocessable_content
|
||||||
end
|
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)
|
Submitters::SubmitValues.call(@submitter, params, request)
|
||||||
|
|
||||||
head :ok
|
head :ok
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ class SubmitFormDeclineController < ApplicationController
|
|||||||
before_action :load_submitter
|
before_action :load_submitter
|
||||||
|
|
||||||
def create
|
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.completed_at? ||
|
||||||
@submitter.submission.archived_at? ||
|
@submitter.submission.archived_at? ||
|
||||||
@submitter.submission.expired? ||
|
@submitter.submission.expired? ||
|
||||||
@submitter.submission.template&.archived_at? ||
|
@submitter.submission.template&.archived_at? ||
|
||||||
|
@submitter.viewer? ||
|
||||||
!Submitters::AuthorizedForForm.call(@submitter,
|
!Submitters::AuthorizedForForm.call(@submitter,
|
||||||
current_user,
|
current_user,
|
||||||
request)
|
request)
|
||||||
@@ -35,6 +37,10 @@ class SubmitFormDeclineController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def declining_disabled?
|
||||||
|
@submitter.account.account_configs.find_by(key: AccountConfig::ALLOW_TO_DECLINE_KEY)&.value == false
|
||||||
|
end
|
||||||
|
|
||||||
def load_submitter
|
def load_submitter
|
||||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class SubmitFormDelegateController < ApplicationController
|
|||||||
@submitter.submission.archived_at? ||
|
@submitter.submission.archived_at? ||
|
||||||
@submitter.submission.expired? ||
|
@submitter.submission.expired? ||
|
||||||
@submitter.submission.template&.archived_at? ||
|
@submitter.submission.template&.archived_at? ||
|
||||||
|
@submitter.viewer? ||
|
||||||
!Submitters::AuthorizedForForm.call(@submitter,
|
!Submitters::AuthorizedForForm.call(@submitter,
|
||||||
current_user,
|
current_user,
|
||||||
request)
|
request)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class SubmitFormDownloadController < ApplicationController
|
|||||||
return head :unprocessable_content if @submitter.declined_at? ||
|
return head :unprocessable_content if @submitter.declined_at? ||
|
||||||
@submitter.submission.archived_at? ||
|
@submitter.submission.archived_at? ||
|
||||||
@submitter.submission.expired? ||
|
@submitter.submission.expired? ||
|
||||||
|
@submitter.submission.completed_at? ||
|
||||||
@submitter.submission.template&.archived_at? ||
|
@submitter.submission.template&.archived_at? ||
|
||||||
AccountConfig.exists?(account_id: @submitter.account_id,
|
AccountConfig.exists?(account_id: @submitter.account_id,
|
||||||
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
|
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_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? ||
|
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? ||
|
||||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||||
return redirect_to submit_form_path(@submitter.slug)
|
return redirect_to submit_form_path(@submitter.slug)
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class SubmitFormInviteController < ApplicationController
|
|||||||
!submitter.submission.archived_at? &&
|
!submitter.submission.archived_at? &&
|
||||||
!submitter.submission.expired? &&
|
!submitter.submission.expired? &&
|
||||||
!submitter.submission.template&.archived_at? &&
|
!submitter.submission.template&.archived_at? &&
|
||||||
|
!submitter.viewer? &&
|
||||||
Submitters::AuthorizedForForm.call(submitter, current_user, request)
|
Submitters::AuthorizedForForm.call(submitter, current_user, request)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,7 @@ class SubmitFormMetadataController < ApplicationController
|
|||||||
def index
|
def index
|
||||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||||
|
|
||||||
return head :not_found if @submitter.declined_at? ||
|
return head :not_found unless authorized_submitter?(@submitter)
|
||||||
@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)
|
|
||||||
|
|
||||||
submission = @submitter.submission
|
submission = @submitter.submission
|
||||||
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
|
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
|
||||||
@@ -34,4 +28,17 @@ class SubmitFormMetadataController < ApplicationController
|
|||||||
|
|
||||||
render json: { text_runs: }
|
render json: { text_runs: }
|
||||||
end
|
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
|
end
|
||||||
@@ -13,7 +13,7 @@ class SubmittersController < ApplicationController
|
|||||||
def update
|
def update
|
||||||
submission = @submitter.submission
|
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')
|
return redirect_back fallback_location: submission_path(submission), alert: I18n.t('submitter_cannot_be_updated')
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -48,11 +48,17 @@ class SubmittersController < ApplicationController
|
|||||||
|
|
||||||
private
|
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)
|
def maybe_resend_email_sms(submitter, params)
|
||||||
if params[:send_email] == '1' && submitter.email.present?
|
if params[:send_email] == '1' && submitter.email.present?
|
||||||
is_sent_recently = Docuseal.multitenant? &&
|
is_sent_recently = Docuseal.multitenant? &&
|
||||||
EmailEvent.exists?(email: submitter.email,
|
EmailEvent.exists?(email: submitter.email,
|
||||||
tag: 'submitter_invitation',
|
tag: %w[submitter_invitation submitter_view_invitation],
|
||||||
emailable: submitter,
|
emailable: submitter,
|
||||||
event_type: 'send',
|
event_type: 'send',
|
||||||
created_at: 4.hours.ago..Time.current)
|
created_at: 4.hours.ago..Time.current)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ class SubmittersResubmitController < ApplicationController
|
|||||||
|
|
||||||
def update
|
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.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,
|
submission = @submitter.account.submissions.new(created_by_user: current_user,
|
||||||
submitters_order: :preserved,
|
submitters_order: :preserved,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ class SubmittersSendEmailController < ApplicationController
|
|||||||
load_and_authorize_resource :submitter
|
load_and_authorize_resource :submitter
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
authorize!(:update, @submitter)
|
||||||
|
|
||||||
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
|
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
|
||||||
event_type: 'send_email',
|
event_type: 'send_email',
|
||||||
created_at: 10.hours.ago..Time.current)
|
created_at: 10.hours.ago..Time.current)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ class TemplateDocumentsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
authorize!(:update, @template)
|
||||||
|
|
||||||
if params[:blobs].blank? && params[:files].blank?
|
if params[:blobs].blank? && params[:files].blank?
|
||||||
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content
|
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TemplateDocumentsCropController < ApplicationController
|
||||||
|
load_and_authorize_resource :template
|
||||||
|
before_action :load_attachment
|
||||||
|
|
||||||
|
rescue_from Leptonica::LeptonicaError do
|
||||||
|
render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content
|
||||||
|
end
|
||||||
|
|
||||||
|
def index
|
||||||
|
render json: { corners: Leptonica.detect_document_corners(@attachment.download) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
authorize!(:update, @template)
|
||||||
|
|
||||||
|
document = Templates::CreateDocumentCrop.call(@template, @attachment, crop_params)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
document: document.as_json(
|
||||||
|
methods: %i[metadata signed_key],
|
||||||
|
include: {
|
||||||
|
preview_images: { methods: %i[url metadata filename] }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_attachment
|
||||||
|
@attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid])
|
||||||
|
end
|
||||||
|
|
||||||
|
def crop_params
|
||||||
|
params.permit(:scan, :rotate, :flip_h, :flip_v, corners: [%i[x y]])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TemplateDocumentsModifyController < ApplicationController
|
||||||
|
load_and_authorize_resource :template
|
||||||
|
|
||||||
|
def create
|
||||||
|
authorize!(:update, @template)
|
||||||
|
|
||||||
|
documents_layout =
|
||||||
|
params.require(:documents).map do |item|
|
||||||
|
item.permit(:attachment_uuid,
|
||||||
|
pages: [:attachment_uuid, :page, :rotate,
|
||||||
|
{ redact: [%i[x y w h color]], replaced_page: %i[attachment_uuid page] }]).to_h
|
||||||
|
end
|
||||||
|
|
||||||
|
Templates::ModifyDocuments.call(@template, documents_layout)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
schema: @template.schema,
|
||||||
|
fields: @template.fields,
|
||||||
|
submitters: @template.submitters,
|
||||||
|
documents: @template.schema_documents.reload.preload(:blob, preview_images_attachments: :blob).as_json(
|
||||||
|
methods: %i[metadata signed_key],
|
||||||
|
include: {
|
||||||
|
preview_images: { methods: %i[url metadata filename] }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rescue Templates::ModifyDocuments::InvalidLayout
|
||||||
|
render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TemplateDocumentsPageObjectsController < ApplicationController
|
||||||
|
load_and_authorize_resource :template
|
||||||
|
|
||||||
|
def index
|
||||||
|
attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid])
|
||||||
|
|
||||||
|
render json: Templates::ModifyDocuments.page_objects(attachment, params[:page].to_i)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -30,9 +30,11 @@ class TemplateFoldersController < ApplicationController
|
|||||||
(@template_folders.size < 7 ? 9 : 6)
|
(@template_folders.size < 7 ? 9 : 6)
|
||||||
end
|
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
|
else
|
||||||
@pagy, @template_folders = pagy(@template_folders, limit: FOLDERS_PER_PAGE)
|
@pagy, @template_folders = pagy(@template_folders, limit: FOLDERS_PER_PAGE)
|
||||||
|
|
||||||
@@ -55,11 +57,10 @@ class TemplateFoldersController < ApplicationController
|
|||||||
|
|
||||||
def selected_order
|
def selected_order
|
||||||
@selected_order ||=
|
@selected_order ||=
|
||||||
if cookies.permanent[:dashboard_templates_order].blank? ||
|
if can?(:manage, :countless)
|
||||||
(cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
|
|
||||||
'created_at'
|
'created_at'
|
||||||
else
|
else
|
||||||
cookies.permanent[:dashboard_templates_order]
|
cookies.permanent[:dashboard_templates_order].presence || 'created_at'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -67,20 +68,20 @@ class TemplateFoldersController < ApplicationController
|
|||||||
params.require(:template_folder).permit(:name)
|
params.require(:template_folder).permit(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_related_submissions
|
def load_related_submissions(template_folder)
|
||||||
@related_submissions =
|
related_submissions =
|
||||||
Submission.accessible_by(current_ability)
|
Submission.accessible_by(current_ability)
|
||||||
.where(archived_at: nil)
|
.where(archived_at: nil)
|
||||||
.where(template_id: current_account.templates.active
|
.where(template_id: current_account.templates.active
|
||||||
.where(folder: [@template_folder, *@template_folder.subfolders])
|
.where(folder: [template_folder, *template_folder.subfolders])
|
||||||
.select(:id))
|
.select(:id))
|
||||||
.preload(:template_accesses, :created_by_user,
|
.preload(:template_accesses, :created_by_user,
|
||||||
template: :author,
|
template: :author,
|
||||||
submitters: :start_form_submission_events)
|
submitters: :start_form_submission_events)
|
||||||
|
|
||||||
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q])
|
related_submissions = Submissions.search(current_user, related_submissions, params[:q])
|
||||||
.order(id: :desc)
|
.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
|
||||||
end
|
end
|
||||||
@@ -10,11 +10,17 @@ class TemplatesArchivedController < ApplicationController
|
|||||||
|
|
||||||
@templates = Templates.search(current_user, @templates, params[:q])
|
@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?
|
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)
|
Submission.accessible_by(current_ability)
|
||||||
.joins(:template)
|
.joins(:template)
|
||||||
.where.not(templates: { archived_at: nil })
|
.where.not(templates: { archived_at: nil })
|
||||||
@@ -22,9 +28,9 @@ class TemplatesArchivedController < ApplicationController
|
|||||||
template: :author,
|
template: :author,
|
||||||
submitters: :start_form_submission_events)
|
submitters: :start_form_submission_events)
|
||||||
|
|
||||||
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q])
|
related_submissions = Submissions.search(current_user, related_submissions, params[:q])
|
||||||
.order(id: :desc)
|
.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
|
||||||
end
|
end
|
||||||
@@ -10,12 +10,12 @@ class TemplatesArchivedSubmissionsController < ApplicationController
|
|||||||
@submissions = Submissions::Filter.call(@submissions, current_user, params)
|
@submissions = Submissions::Filter.call(@submissions, current_user, params)
|
||||||
|
|
||||||
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
|
@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
|
else
|
||||||
@submissions.order(id: :desc)
|
@submissions.order(id: :desc)
|
||||||
end
|
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
|
rescue ActiveRecord::RecordNotFound
|
||||||
redirect_to root_path
|
redirect_to root_path
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ class TemplatesCloneAndReplaceController < ApplicationController
|
|||||||
|
|
||||||
cloned_template = Templates::Clone.call(@template, author: current_user)
|
cloned_template = Templates::Clone.call(@template, author: current_user)
|
||||||
cloned_template.name = File.basename(params[:files].first.original_filename, '.*')
|
cloned_template.name = File.basename(params[:files].first.original_filename, '.*')
|
||||||
|
|
||||||
|
authorize!(:create, cloned_template)
|
||||||
|
|
||||||
cloned_template.save!
|
cloned_template.save!
|
||||||
|
|
||||||
documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true)
|
documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true)
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class TemplatesController < ApplicationController
|
class TemplatesController < ApplicationController
|
||||||
TEMPLATE_FIELDS = %i[id author_id folder_id external_id name slug
|
|
||||||
schema fields submitters variables_schema preferences
|
|
||||||
shared_link source archived_at created_at updated_at].freeze
|
|
||||||
|
|
||||||
load_and_authorize_resource :template
|
load_and_authorize_resource :template
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@@ -18,12 +14,13 @@ class TemplatesController < ApplicationController
|
|||||||
submissions = Submissions::Filter.filter_by_status(submissions, params)
|
submissions = Submissions::Filter.filter_by_status(submissions, params)
|
||||||
|
|
||||||
submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
|
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
|
else
|
||||||
submissions.order(id: :desc)
|
submissions.order(id: :desc)
|
||||||
end
|
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
|
rescue ActiveRecord::RecordNotFound
|
||||||
redirect_to root_path
|
redirect_to root_path
|
||||||
end
|
end
|
||||||
@@ -31,19 +28,7 @@ class TemplatesController < ApplicationController
|
|||||||
def new; end
|
def new; end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
ActiveRecord::Associations::Preloader.new(
|
@template_data = Templates.serialize_for_builder(@template)
|
||||||
records: [@template],
|
|
||||||
associations: [{ schema_documents: [:blob, { preview_images_attachments: :blob }] }]
|
|
||||||
).call
|
|
||||||
|
|
||||||
@template_data =
|
|
||||||
@template.as_json(only: TEMPLATE_FIELDS).merge(
|
|
||||||
documents: @template.schema_documents.as_json(
|
|
||||||
only: %i[id uuid],
|
|
||||||
methods: %i[metadata signed_key],
|
|
||||||
include: { preview_images: { only: %i[id], methods: %i[url metadata filename] } }
|
|
||||||
)
|
|
||||||
).to_json
|
|
||||||
|
|
||||||
render :edit, layout: 'plain'
|
render :edit, layout: 'plain'
|
||||||
end
|
end
|
||||||
@@ -77,8 +62,6 @@ class TemplatesController < ApplicationController
|
|||||||
|
|
||||||
WebhookUrls.enqueue_events(@template, 'template.updated')
|
WebhookUrls.enqueue_events(@template, 'template.updated')
|
||||||
|
|
||||||
TemplateVersions.find_or_create_for(@template, author: current_user) if params[:revision]
|
|
||||||
|
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -11,86 +11,126 @@ class TemplatesDashboardController < ApplicationController
|
|||||||
helper_method :selected_order
|
helper_method :selected_order
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
@default_folder = current_account.default_template_folder
|
||||||
|
|
||||||
@template_folders =
|
@template_folders =
|
||||||
TemplateFolders.filter_active_folders(@template_folders.where(parent_folder_id: nil), @templates)
|
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.search(@template_folders, params[:q])
|
||||||
@template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
|
@template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
|
||||||
|
|
||||||
@pagy, @template_folders = pagy(
|
@shared_templates = Templates.shared(current_user).active
|
||||||
@template_folders,
|
|
||||||
limit: FOLDERS_PER_PAGE,
|
@pagy, @template_folders, @show_default_folder, @show_shared_folder, @show_shared_inline =
|
||||||
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
|
load_folders(@template_folders, @templates, @shared_templates)
|
||||||
)
|
|
||||||
|
|
||||||
if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD
|
if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD
|
||||||
@templates = @templates.none
|
@templates = @templates.none
|
||||||
else
|
else
|
||||||
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
|
if @show_shared_inline
|
||||||
@templates = filter_templates(@templates).preload(:author, :template_accesses)
|
@templates = @shared_templates.preload(:template_sharings)
|
||||||
@templates = Templates::Order.call(@templates, current_user, selected_order)
|
else
|
||||||
|
@templates = @templates.active
|
||||||
|
@templates = @templates.where(folder_id: @default_folder.id) if params[:q].blank?
|
||||||
|
end
|
||||||
|
|
||||||
limit =
|
@pagy, @templates = load_templates(@templates.select_for_list, @pagy.count,
|
||||||
if @template_folders.size < 4
|
show_shared_inline: @show_shared_inline)
|
||||||
TEMPLATES_PER_PAGE
|
|
||||||
else
|
|
||||||
(@template_folders.size < 7 ? 9 : 6)
|
|
||||||
end
|
|
||||||
|
|
||||||
@pagy, @templates = pagy_auto(@templates, limit:)
|
if params[:q].present? && @templates.blank?
|
||||||
|
@related_submissions_pagy, @related_submissions = load_related_submissions
|
||||||
load_related_submissions if params[:q].present? && @templates.blank?
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def filter_templates(templates)
|
def load_templates(templates, folders_count, show_shared_inline: false)
|
||||||
rel = templates.active
|
templates = templates.preload(:author, :template_accesses)
|
||||||
|
|
||||||
if params[:q].blank?
|
templates =
|
||||||
if Docuseal.multitenant? ? current_account.testing? : current_account.linked_account_account
|
if show_shared_inline
|
||||||
shared_account_ids = [current_user.account_id]
|
Templates.search_shared(current_user, templates, params[:q])
|
||||||
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)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else
|
else
|
||||||
rel = rel.where(folder_id: current_account.default_template_folder.id)
|
Templates.search(current_user, templates, params[:q])
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
def selected_order
|
def selected_order
|
||||||
@selected_order ||=
|
@selected_order ||=
|
||||||
if cookies.permanent[:dashboard_templates_order].blank? ||
|
if can?(:manage, :countless)
|
||||||
(cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
|
|
||||||
'created_at'
|
'created_at'
|
||||||
else
|
else
|
||||||
cookies.permanent[:dashboard_templates_order]
|
cookies.permanent[:dashboard_templates_order].presence || 'created_at'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_related_submissions
|
def load_related_submissions
|
||||||
@related_submissions = Submission.accessible_by(current_ability)
|
related_submissions = Submission.accessible_by(current_ability)
|
||||||
.left_joins(:template)
|
.left_joins(:template)
|
||||||
.where(archived_at: nil)
|
.where(archived_at: nil)
|
||||||
.where(templates: { archived_at: nil })
|
.where(templates: { archived_at: nil })
|
||||||
.preload(:template_accesses, :created_by_user,
|
.preload(:template_accesses, :created_by_user,
|
||||||
template: :author,
|
template: :author,
|
||||||
submitters: :start_form_submission_events)
|
submitters: :start_form_submission_events)
|
||||||
|
|
||||||
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q])
|
related_submissions = Submissions.search(current_user, related_submissions, params[:q])
|
||||||
.order(id: :desc)
|
.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
|
||||||
end
|
end
|
||||||
@@ -16,7 +16,12 @@ class TemplatesDetectFieldsController < ApplicationController
|
|||||||
page_number = params[:page].presence&.to_i
|
page_number = params[:page].presence&.to_i
|
||||||
|
|
||||||
documents.each do |document|
|
documents.each do |document|
|
||||||
io = StringIO.new(document.download)
|
io =
|
||||||
|
if document.image?
|
||||||
|
StringIO.new(document.preview_images.joins(:blob).find_by(blob: { filename: ['0.png', '0.jpg'] }).download)
|
||||||
|
else
|
||||||
|
StringIO.new(document.download)
|
||||||
|
end
|
||||||
|
|
||||||
Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)|
|
Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)|
|
||||||
sse.write({ attachment_uuid:, page:, fields: })
|
sse.write({ attachment_uuid:, page:, fields: })
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ class TemplatesFoldersController < ApplicationController
|
|||||||
def edit; end
|
def edit; end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
authorize!(:update, @template)
|
||||||
|
|
||||||
name = [params[:parent_name], params[:name]].compact_blank.join(' / ')
|
name = [params[:parent_name], params[:name]].compact_blank.join(' / ')
|
||||||
|
|
||||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, name)
|
@template.folder = TemplateFolders.find_or_create_by_name(current_user, name)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ class TemplatesPreferencesController < ApplicationController
|
|||||||
|
|
||||||
RESETTABLE_PREFERENCE_KEYS = {
|
RESETTABLE_PREFERENCE_KEYS = {
|
||||||
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY => %w[request_email_subject request_email_body submitters],
|
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
|
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[invitation_reminder_email_subject
|
||||||
invitation_reminder_email_body],
|
invitation_reminder_email_body],
|
||||||
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[documents_copy_email_subject documents_copy_email_body],
|
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[documents_copy_email_subject documents_copy_email_body],
|
||||||
@@ -48,12 +50,12 @@ class TemplatesPreferencesController < ApplicationController
|
|||||||
def template_params
|
def template_params
|
||||||
params.require(:template).permit(
|
params.require(:template).permit(
|
||||||
preferences: %i[bcc_completed request_email_subject request_email_body
|
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
|
invitation_reminder_email_subject invitation_reminder_email_body
|
||||||
documents_copy_email_subject documents_copy_email_body
|
documents_copy_email_subject documents_copy_email_body
|
||||||
documents_copy_email_enabled documents_copy_email_attach_audit
|
documents_copy_email_enabled documents_copy_email_attach_audit
|
||||||
documents_copy_email_attach_documents documents_copy_email_reply_to
|
documents_copy_email_attach_documents documents_copy_email_reply_to
|
||||||
completed_notification_email_attach_documents
|
completed_notification_email_attach_documents completed_redirect_url validate_unique_submitters
|
||||||
completed_redirect_url validate_unique_submitters
|
|
||||||
require_all_submitters submitters_order require_phone_2fa require_email_2fa
|
require_all_submitters submitters_order require_phone_2fa require_email_2fa
|
||||||
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
|
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
|
||||||
completed_notification_email_subject completed_notification_email_body
|
completed_notification_email_subject completed_notification_email_body
|
||||||
|
|||||||
@@ -4,18 +4,7 @@ class TemplatesPreviewController < ApplicationController
|
|||||||
load_and_authorize_resource :template
|
load_and_authorize_resource :template
|
||||||
|
|
||||||
def show
|
def show
|
||||||
ActiveRecord::Associations::Preloader.new(
|
@template_data = Templates.serialize_for_builder(@template)
|
||||||
records: [@template],
|
|
||||||
associations: [{ schema_documents: { preview_images_attachments: :blob } }]
|
|
||||||
).call
|
|
||||||
|
|
||||||
@template_data =
|
|
||||||
@template.as_json.merge(
|
|
||||||
documents: @template.schema_documents.as_json(
|
|
||||||
methods: %i[metadata signed_key],
|
|
||||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
|
||||||
)
|
|
||||||
).to_json
|
|
||||||
|
|
||||||
render :show, layout: 'plain'
|
render :show, layout: 'plain'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ class TemplatesRestoreController < ApplicationController
|
|||||||
load_and_authorize_resource :template
|
load_and_authorize_resource :template
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
authorize!(:destroy, @template)
|
||||||
|
|
||||||
@template.update!(archived_at: nil)
|
@template.update!(archived_at: nil)
|
||||||
|
|
||||||
WebhookUrls.enqueue_events(@template, 'template.updated')
|
WebhookUrls.enqueue_events(@template, 'template.updated')
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TemplatesSharedController < ApplicationController
|
||||||
|
def index
|
||||||
|
authorize!(:read, Template)
|
||||||
|
|
||||||
|
@is_archived = params[:archived] == 'true'
|
||||||
|
|
||||||
|
@templates = Templates.shared(current_user)
|
||||||
|
|
||||||
|
@has_archived = !@is_archived && @templates.archived.exists?
|
||||||
|
|
||||||
|
@templates = @is_archived ? @templates.archived : @templates.active
|
||||||
|
|
||||||
|
@templates = @templates.preload(:author, :template_accesses, :template_sharings)
|
||||||
|
.order(id: :desc)
|
||||||
|
|
||||||
|
@templates = Templates.search_shared(current_user, @templates, params[:q])
|
||||||
|
|
||||||
|
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
|
||||||
|
|
||||||
|
return unless params[:q].present? && @templates.blank?
|
||||||
|
|
||||||
|
@related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_related_submissions(is_archived:)
|
||||||
|
shared_templates = Templates.shared(current_user)
|
||||||
|
shared_templates = is_archived ? shared_templates.archived : shared_templates.active
|
||||||
|
|
||||||
|
related_submissions =
|
||||||
|
Submission.accessible_by(current_ability)
|
||||||
|
.where(template_id: shared_templates.select(:id))
|
||||||
|
.preload(:template_accesses, :created_by_user,
|
||||||
|
template: :author,
|
||||||
|
submitters: :start_form_submission_events)
|
||||||
|
|
||||||
|
related_submissions = related_submissions.where(archived_at: nil) unless is_archived
|
||||||
|
|
||||||
|
related_submissions = Submissions.search(current_user, related_submissions, params[:q])
|
||||||
|
.order(id: :desc)
|
||||||
|
|
||||||
|
pagy_auto(related_submissions.select_for_list, limit: 5)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,7 +5,9 @@ class TemplatesUploadsController < ApplicationController
|
|||||||
|
|
||||||
layout 'plain'
|
layout 'plain'
|
||||||
|
|
||||||
def show; end
|
def show
|
||||||
|
redirect_to root_path if params[:url].blank?
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
url_params = create_file_params_from_url if params[:url].present?
|
url_params = create_file_params_from_url if params[:url].present?
|
||||||
|
|||||||
@@ -14,4 +14,12 @@ class TemplatesVersionsController < ApplicationController
|
|||||||
|
|
||||||
render json: TemplateVersions.serialize(version)
|
render json: TemplateVersions.serialize(version)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
authorize!(:update, @template)
|
||||||
|
|
||||||
|
TemplateVersions.find_or_create_for(@template, author: current_user)
|
||||||
|
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -11,6 +11,12 @@ class UserInitialsController < ApplicationController
|
|||||||
|
|
||||||
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank?
|
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank?
|
||||||
|
|
||||||
|
extension = File.extname(file.original_filename).delete_prefix('.').downcase
|
||||||
|
|
||||||
|
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
|
||||||
|
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
|
||||||
|
end
|
||||||
|
|
||||||
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
|
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
|
||||||
filename: file.original_filename,
|
filename: file.original_filename,
|
||||||
content_type: file.content_type)
|
content_type: file.content_type)
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ class UserSignaturesController < ApplicationController
|
|||||||
|
|
||||||
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank?
|
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank?
|
||||||
|
|
||||||
|
extension = File.extname(file.original_filename).delete_prefix('.').downcase
|
||||||
|
|
||||||
|
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
|
||||||
|
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
|
||||||
|
end
|
||||||
|
|
||||||
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
|
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
|
||||||
filename: file.original_filename,
|
filename: file.original_filename,
|
||||||
content_type: file.content_type)
|
content_type: file.content_type)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import SubmittersAutocomplete from './elements/submitter_autocomplete'
|
|||||||
import FolderAutocomplete from './elements/folder_autocomplete'
|
import FolderAutocomplete from './elements/folder_autocomplete'
|
||||||
import SignatureForm from './elements/signature_form'
|
import SignatureForm from './elements/signature_form'
|
||||||
import SubmitForm from './elements/submit_form'
|
import SubmitForm from './elements/submit_form'
|
||||||
|
import ConvertUpload from './elements/convert_upload'
|
||||||
import PromptPassword from './elements/prompt_password'
|
import PromptPassword from './elements/prompt_password'
|
||||||
import EmailsTextarea from './elements/emails_textarea'
|
import EmailsTextarea from './elements/emails_textarea'
|
||||||
import ToggleSubmit from './elements/toggle_submit'
|
import ToggleSubmit from './elements/toggle_submit'
|
||||||
@@ -41,19 +42,20 @@ import RequiredCheckboxGroup from './elements/required_checkbox_group'
|
|||||||
import PageContainer from './elements/page_container'
|
import PageContainer from './elements/page_container'
|
||||||
import EmailEditor from './elements/email_editor'
|
import EmailEditor from './elements/email_editor'
|
||||||
import MarkdownEditor from './elements/markdown_editor'
|
import MarkdownEditor from './elements/markdown_editor'
|
||||||
|
import HtmlEditor from './elements/html_editor'
|
||||||
import MountOnClick from './elements/mount_on_click'
|
import MountOnClick from './elements/mount_on_click'
|
||||||
import RemoveOnEvent from './elements/remove_on_event'
|
import RemoveOnEvent from './elements/remove_on_event'
|
||||||
import ScrollTo from './elements/scroll_to'
|
import ScrollTo from './elements/scroll_to'
|
||||||
import SetValue from './elements/set_value'
|
import SetValue from './elements/set_value'
|
||||||
import ReviewForm from './elements/review_form'
|
import ReviewForm from './elements/review_form'
|
||||||
import ShowOnValue from './elements/show_on_value'
|
import ShowOnValue from './elements/show_on_value'
|
||||||
import CustomValidation from './elements/custom_validation'
|
|
||||||
import ToggleClasses from './elements/toggle_classes'
|
import ToggleClasses from './elements/toggle_classes'
|
||||||
import AutosizeField from './elements/autosize_field'
|
import AutosizeField from './elements/autosize_field'
|
||||||
import GoogleDriveFilePicker from './elements/google_drive_file_picker'
|
import GoogleDriveFilePicker from './elements/google_drive_file_picker'
|
||||||
import OpenModal from './elements/open_modal'
|
import OpenModal from './elements/open_modal'
|
||||||
import BarChart from './elements/bar_chart'
|
import BarChart from './elements/bar_chart'
|
||||||
import FieldCondition from './elements/field_condition'
|
import FieldCondition from './elements/field_condition'
|
||||||
|
import ConfirmUpload from './elements/confirm_upload'
|
||||||
|
|
||||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||||
|
|
||||||
@@ -112,6 +114,7 @@ safeRegisterElement('submitters-autocomplete', SubmittersAutocomplete)
|
|||||||
safeRegisterElement('folder-autocomplete', FolderAutocomplete)
|
safeRegisterElement('folder-autocomplete', FolderAutocomplete)
|
||||||
safeRegisterElement('signature-form', SignatureForm)
|
safeRegisterElement('signature-form', SignatureForm)
|
||||||
safeRegisterElement('submit-form', SubmitForm)
|
safeRegisterElement('submit-form', SubmitForm)
|
||||||
|
safeRegisterElement('convert-upload', ConvertUpload)
|
||||||
safeRegisterElement('prompt-password', PromptPassword)
|
safeRegisterElement('prompt-password', PromptPassword)
|
||||||
safeRegisterElement('emails-textarea', EmailsTextarea)
|
safeRegisterElement('emails-textarea', EmailsTextarea)
|
||||||
safeRegisterElement('toggle-cookies', ToggleCookies)
|
safeRegisterElement('toggle-cookies', ToggleCookies)
|
||||||
@@ -133,19 +136,20 @@ safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
|
|||||||
safeRegisterElement('page-container', PageContainer)
|
safeRegisterElement('page-container', PageContainer)
|
||||||
safeRegisterElement('email-editor', EmailEditor)
|
safeRegisterElement('email-editor', EmailEditor)
|
||||||
safeRegisterElement('markdown-editor', MarkdownEditor)
|
safeRegisterElement('markdown-editor', MarkdownEditor)
|
||||||
|
safeRegisterElement('html-editor', HtmlEditor)
|
||||||
safeRegisterElement('mount-on-click', MountOnClick)
|
safeRegisterElement('mount-on-click', MountOnClick)
|
||||||
safeRegisterElement('remove-on-event', RemoveOnEvent)
|
safeRegisterElement('remove-on-event', RemoveOnEvent)
|
||||||
safeRegisterElement('scroll-to', ScrollTo)
|
safeRegisterElement('scroll-to', ScrollTo)
|
||||||
safeRegisterElement('set-value', SetValue)
|
safeRegisterElement('set-value', SetValue)
|
||||||
safeRegisterElement('review-form', ReviewForm)
|
safeRegisterElement('review-form', ReviewForm)
|
||||||
safeRegisterElement('show-on-value', ShowOnValue)
|
safeRegisterElement('show-on-value', ShowOnValue)
|
||||||
safeRegisterElement('custom-validation', CustomValidation)
|
|
||||||
safeRegisterElement('toggle-classes', ToggleClasses)
|
safeRegisterElement('toggle-classes', ToggleClasses)
|
||||||
safeRegisterElement('autosize-field', AutosizeField)
|
safeRegisterElement('autosize-field', AutosizeField)
|
||||||
safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
|
safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
|
||||||
safeRegisterElement('open-modal', OpenModal)
|
safeRegisterElement('open-modal', OpenModal)
|
||||||
safeRegisterElement('bar-chart', BarChart)
|
safeRegisterElement('bar-chart', BarChart)
|
||||||
safeRegisterElement('field-condition', FieldCondition)
|
safeRegisterElement('field-condition', FieldCondition)
|
||||||
|
safeRegisterElement('confirm-upload', ConfirmUpload)
|
||||||
|
|
||||||
safeRegisterElement('template-builder', class extends HTMLElement {
|
safeRegisterElement('template-builder', class extends HTMLElement {
|
||||||
connectedCallback () {
|
connectedCallback () {
|
||||||
@@ -197,10 +201,35 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSubmit = (e) => {
|
onSubmit = (e) => {
|
||||||
if (e.detail.success && e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
|
if (e.detail.success) {
|
||||||
e.detail.fetchResponse.response.json().then((data) => {
|
if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
|
||||||
this.component.template.submitters = data.submitters
|
e.detail.fetchResponse.response.json().then((data) => {
|
||||||
})
|
this.component.template.submitters = data.submitters
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.detail?.formSubmission?.formElement?.action?.endsWith('/prefillable_fields')) {
|
||||||
|
e.detail.fetchResponse.response.text().then((data) => {
|
||||||
|
const doc = new DOMParser().parseFromString(data, 'text/html')
|
||||||
|
const fragment = doc.querySelector('turbo-stream template').content
|
||||||
|
|
||||||
|
const prefillableUuidsIndex = {}
|
||||||
|
|
||||||
|
fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => {
|
||||||
|
prefillableUuidsIndex[field.value] = true
|
||||||
|
})
|
||||||
|
|
||||||
|
this.component.template.fields.forEach((field) => {
|
||||||
|
if (prefillableUuidsIndex[field.uuid]) {
|
||||||
|
field.prefillable = true
|
||||||
|
field.readonly = true
|
||||||
|
} else if (field.prefillable) {
|
||||||
|
delete field.prefillable
|
||||||
|
delete field.readonly
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||||
|
|
||||||
|
export default targetable(class extends HTMLElement {
|
||||||
|
static [target.static] = [
|
||||||
|
'prompt',
|
||||||
|
'processing',
|
||||||
|
'logo'
|
||||||
|
]
|
||||||
|
|
||||||
|
connectedCallback () {
|
||||||
|
this.form.addEventListener('submit', this.onSubmit)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback () {
|
||||||
|
this.form.removeEventListener('submit', this.onSubmit)
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit = () => {
|
||||||
|
this.prompt.classList.add('hidden')
|
||||||
|
this.processing.classList.remove('hidden')
|
||||||
|
this.logo.classList.add('animate-bounce')
|
||||||
|
}
|
||||||
|
|
||||||
|
get form () {
|
||||||
|
return this.querySelector('form')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
export function convertImage (sourceFile, targetType, quality) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
|
reader.onload = function (event) {
|
||||||
|
const img = new Image()
|
||||||
|
|
||||||
|
img.onload = function () {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
|
canvas.width = img.width
|
||||||
|
canvas.height = img.height
|
||||||
|
ctx.drawImage(img, 0, 0)
|
||||||
|
canvas.toBlob(function (blob) {
|
||||||
|
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
|
||||||
|
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
|
||||||
|
resolve(newFile)
|
||||||
|
}, targetType, quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
|
||||||
|
|
||||||
|
img.src = event.target.result
|
||||||
|
}
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsDataURL(sourceFile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertImagesInInput (input) {
|
||||||
|
if (!input.files || input.files.length === 0) return
|
||||||
|
|
||||||
|
const dt = new DataTransfer()
|
||||||
|
let didConvert = false
|
||||||
|
|
||||||
|
for (const file of Array.from(input.files)) {
|
||||||
|
let converted = file
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
|
||||||
|
converted = await convertImage(file, 'image/png')
|
||||||
|
didConvert = true
|
||||||
|
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
|
||||||
|
converted = await convertImage(file, 'image/jpeg', 0.9)
|
||||||
|
didConvert = true
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
dt.items.add(converted)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (didConvert) {
|
||||||
|
input.files = dt.files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class extends HTMLElement {
|
||||||
|
connectedCallback () {
|
||||||
|
const input = this.querySelector('input[type="file"]')
|
||||||
|
const form = input.form
|
||||||
|
|
||||||
|
input.addEventListener('change', async () => {
|
||||||
|
await convertImagesInInput(input)
|
||||||
|
|
||||||
|
form.querySelector('[type="submit"]')?.setAttribute('disabled', true)
|
||||||
|
|
||||||
|
form.requestSubmit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export default class extends HTMLElement {
|
|
||||||
connectedCallback () {
|
|
||||||
const input = this.querySelector('input')
|
|
||||||
const invalidMessage = this.dataset.invalidMessage || ''
|
|
||||||
|
|
||||||
input.addEventListener('invalid', () => {
|
|
||||||
input.setCustomValidity(input.value ? invalidMessage : '')
|
|
||||||
})
|
|
||||||
|
|
||||||
input.addEventListener('input', () => {
|
|
||||||
input.setCustomValidity('')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { target, targets, targetable } from '@github/catalyst/lib/targetable'
|
import { target, targets, targetable } from '@github/catalyst/lib/targetable'
|
||||||
|
import { convertImagesInInput } from './convert_upload'
|
||||||
|
|
||||||
const loadingIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
const loadingIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
@@ -150,12 +151,16 @@ export default targetable(class extends HTMLElement {
|
|||||||
if (!this.isLoading) this.hideDraghover()
|
if (!this.isLoading) this.hideDraghover()
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadFiles (files, url) {
|
async uploadFiles (files, url) {
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
|
|
||||||
this.form.action = url
|
this.form.action = url
|
||||||
|
|
||||||
this.form.querySelector('[type="file"]').files = files
|
const input = this.form.querySelector('[type="file"]')
|
||||||
|
|
||||||
|
input.files = files
|
||||||
|
|
||||||
|
await convertImagesInInput(input)
|
||||||
|
|
||||||
this.form.querySelector('[type="submit"]').click()
|
this.form.querySelector('[type="submit"]').click()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ function loadCodeMirror () {
|
|||||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'),
|
import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'),
|
||||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/language'),
|
import(/* webpackChunkName: "email-editor" */ '@codemirror/language'),
|
||||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'),
|
import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'),
|
||||||
|
import(/* webpackChunkName: "email-editor" */ '@codemirror/lint'),
|
||||||
import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow')
|
import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow')
|
||||||
]).then(([view, commands, language, html, htmlflow]) => {
|
]).then(([view, commands, language, html, lint, htmlflow]) => {
|
||||||
return {
|
return {
|
||||||
minimalSetup: [
|
minimalSetup: [
|
||||||
commands.history(),
|
commands.history(),
|
||||||
@@ -19,6 +20,8 @@ function loadCodeMirror () {
|
|||||||
],
|
],
|
||||||
EditorView: view.EditorView,
|
EditorView: view.EditorView,
|
||||||
html: html.html,
|
html: html.html,
|
||||||
|
htmlLanguage: html.htmlLanguage,
|
||||||
|
linter: lint.linter,
|
||||||
htmlflow: htmlflow.default || htmlflow
|
htmlflow: htmlflow.default || htmlflow
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -46,6 +49,70 @@ export default targetable(class extends HTMLElement {
|
|||||||
|
|
||||||
this.previewViewTab.addEventListener('click', this.showPreviewView)
|
this.previewViewTab.addEventListener('click', this.showPreviewView)
|
||||||
this.codeViewTab.addEventListener('click', this.showCodeView)
|
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 = () => {
|
showCodeView = () => {
|
||||||
@@ -76,7 +143,9 @@ export default targetable(class extends HTMLElement {
|
|||||||
this.input = this.querySelector('input[type="hidden"]')
|
this.input = this.querySelector('input[type="hidden"]')
|
||||||
this.input.style.display = 'none'
|
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({
|
this.editorView = new EditorView({
|
||||||
doc: this.input.value,
|
doc: this.input.value,
|
||||||
@@ -85,8 +154,11 @@ export default targetable(class extends HTMLElement {
|
|||||||
html(),
|
html(),
|
||||||
minimalSetup,
|
minimalSetup,
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
|
linter((view) => this.buildDiagnostics(view.state.doc.toString()), { delay: 600 }),
|
||||||
EditorView.updateListener.of(update => {
|
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({
|
EditorView.theme({
|
||||||
'&': {
|
'&': {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { actionable } from '@github/catalyst/lib/actionable'
|
import { actionable } from '@github/catalyst/lib/actionable'
|
||||||
import { target, targetable } from '@github/catalyst/lib/targetable'
|
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||||
|
import { convertImagesInInput } from './convert_upload'
|
||||||
|
|
||||||
export default actionable(targetable(class extends HTMLElement {
|
export default actionable(targetable(class extends HTMLElement {
|
||||||
static [target.static] = [
|
static [target.static] = [
|
||||||
@@ -38,17 +39,21 @@ export default actionable(targetable(class extends HTMLElement {
|
|||||||
this.classList.add('border-base-300', 'hover:bg-base-200/30')
|
this.classList.add('border-base-300', 'hover:bg-base-200/30')
|
||||||
}
|
}
|
||||||
|
|
||||||
onDrop (e) {
|
async onDrop (e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
this.input.files = e.dataTransfer.files
|
this.input.files = e.dataTransfer.files
|
||||||
|
|
||||||
this.uploadFiles(e.dataTransfer.files)
|
await convertImagesInInput(this.input)
|
||||||
|
|
||||||
|
this.uploadFiles(this.input.files)
|
||||||
}
|
}
|
||||||
|
|
||||||
onSelectFiles (e) {
|
async onSelectFiles (e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
|
await convertImagesInInput(this.input)
|
||||||
|
|
||||||
this.uploadFiles(this.input.files)
|
this.uploadFiles(this.input.files)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,649 @@
|
|||||||
|
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||||
|
import { actionable } from '@github/catalyst/lib/actionable'
|
||||||
|
import { LinkTooltip } from './markdown_editor'
|
||||||
|
|
||||||
|
async function loadTiptap () {
|
||||||
|
const [core, document, text, hardBreak, gapcursor, dropcursor, extensions, pmState, pmView] = await Promise.all([
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/core'),
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-document'),
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-text'),
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-hard-break'),
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-gapcursor'),
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-dropcursor'),
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extensions'),
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/state'),
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/view')
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
Editor: core.Editor,
|
||||||
|
Extension: core.Extension,
|
||||||
|
Node: core.Node,
|
||||||
|
Mark: core.Mark,
|
||||||
|
Document: document.default || document,
|
||||||
|
Text: text.default || text,
|
||||||
|
HardBreak: hardBreak.default || hardBreak,
|
||||||
|
Gapcursor: gapcursor.default || gapcursor,
|
||||||
|
Dropcursor: dropcursor.default || dropcursor,
|
||||||
|
UndoRedo: extensions.UndoRedo,
|
||||||
|
Plugin: pmState.Plugin,
|
||||||
|
Decoration: pmView.Decoration,
|
||||||
|
DecorationSet: pmView.DecorationSet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorStylesheet = new CSSStyleSheet()
|
||||||
|
|
||||||
|
editorStylesheet.replaceSync(`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 0 0 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror {
|
||||||
|
word-wrap: break-word;
|
||||||
|
-webkit-font-variant-ligatures: none;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
font-feature-settings: "liga" 0;
|
||||||
|
outline: none;
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.ProseMirror-separator {
|
||||||
|
display: inline !important;
|
||||||
|
border: none !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-gapcursor {
|
||||||
|
display: none;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-gapcursor:after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
width: 20px;
|
||||||
|
border-top: 1px solid black;
|
||||||
|
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ProseMirror-cursor-blink {
|
||||||
|
to {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-hideselection *::selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-hideselection *::-moz-selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-hideselection * {
|
||||||
|
caret-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-focused .ProseMirror-gapcursor {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variable-highlight {
|
||||||
|
background-color: #fef3c7;
|
||||||
|
padding: 1px 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
function collectDomAttrs (dom) {
|
||||||
|
const attrs = {}
|
||||||
|
|
||||||
|
for (let i = 0; i < dom.attributes.length; i++) {
|
||||||
|
attrs[dom.attributes[i].name] = dom.attributes[i].value
|
||||||
|
}
|
||||||
|
|
||||||
|
return { htmlAttrs: attrs }
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSpanDomAttrs (dom) {
|
||||||
|
const result = collectDomAttrs(dom)
|
||||||
|
|
||||||
|
if (result.htmlAttrs.style) {
|
||||||
|
const temp = document.createElement('span')
|
||||||
|
|
||||||
|
temp.style.cssText = result.htmlAttrs.style
|
||||||
|
|
||||||
|
if (['bold', '700'].includes(temp.style.fontWeight)) {
|
||||||
|
temp.style.removeProperty('font-weight')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (temp.style.fontStyle === 'italic') {
|
||||||
|
temp.style.removeProperty('font-style')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (temp.style.textDecoration === 'underline') {
|
||||||
|
temp.style.removeProperty('text-decoration')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (temp.style.cssText) {
|
||||||
|
result.htmlAttrs.style = temp.style.cssText
|
||||||
|
} else {
|
||||||
|
delete result.htmlAttrs.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExtensions ({ Node, Mark, Extension, Plugin, Decoration, DecorationSet }) {
|
||||||
|
const blockNode = (name, tag, content, extra = {}) => Node.create({
|
||||||
|
name,
|
||||||
|
group: 'block',
|
||||||
|
content: content || 'block+',
|
||||||
|
...extra,
|
||||||
|
addAttributes () {
|
||||||
|
return { htmlAttrs: { default: {} } }
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{ tag, getAttrs: collectDomAttrs }]
|
||||||
|
},
|
||||||
|
renderHTML ({ node }) {
|
||||||
|
return [tag, node.attrs.htmlAttrs, 0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const attrsMark = (name, tag) => Mark.create({
|
||||||
|
name,
|
||||||
|
addAttributes () {
|
||||||
|
return { htmlAttrs: { default: {} } }
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{ tag, getAttrs: collectDomAttrs }]
|
||||||
|
},
|
||||||
|
renderHTML ({ mark }) {
|
||||||
|
return [tag, mark.attrs.htmlAttrs, 0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const SpanMark = Mark.create({
|
||||||
|
name: 'span',
|
||||||
|
excludes: '',
|
||||||
|
addAttributes () {
|
||||||
|
return { htmlAttrs: { default: {} } }
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{ tag: 'span', getAttrs: collectSpanDomAttrs }]
|
||||||
|
},
|
||||||
|
renderHTML ({ mark }) {
|
||||||
|
return ['span', mark.attrs.htmlAttrs, 0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleMark = (name, renderTag, parseRules, shortcuts) => Mark.create({
|
||||||
|
name,
|
||||||
|
parseHTML () {
|
||||||
|
return parseRules
|
||||||
|
},
|
||||||
|
renderHTML () {
|
||||||
|
return [renderTag, 0]
|
||||||
|
},
|
||||||
|
addCommands () {
|
||||||
|
const commandName = `toggle${name[0].toUpperCase()}${name.slice(1)}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
[commandName]: () => ({ commands }) => commands.toggleMark(name)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addKeyboardShortcuts () {
|
||||||
|
return {
|
||||||
|
[shortcuts]: () => this.editor.commands.toggleMark(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Heading = Node.create({
|
||||||
|
name: 'heading',
|
||||||
|
group: 'block',
|
||||||
|
content: 'inline*',
|
||||||
|
addAttributes () {
|
||||||
|
return {
|
||||||
|
htmlAttrs: { default: {} },
|
||||||
|
level: { default: 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [1, 2, 3, 4, 5, 6].map((level) => ({
|
||||||
|
tag: `h${level}`,
|
||||||
|
getAttrs: (dom) => ({ ...collectDomAttrs(dom), level })
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
renderHTML ({ node }) {
|
||||||
|
return [`h${node.attrs.level}`, node.attrs.htmlAttrs, 0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const ImageNode = Node.create({
|
||||||
|
name: 'image',
|
||||||
|
inline: true,
|
||||||
|
group: 'inline',
|
||||||
|
draggable: true,
|
||||||
|
addAttributes () {
|
||||||
|
return { htmlAttrs: { default: {} } }
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{ tag: 'img', getAttrs: collectDomAttrs }]
|
||||||
|
},
|
||||||
|
renderHTML ({ node }) {
|
||||||
|
return ['img', node.attrs.htmlAttrs]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const HrNode = Node.create({
|
||||||
|
name: 'horizontalRule',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
addAttributes () {
|
||||||
|
return { htmlAttrs: { default: {} } }
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{ tag: 'hr', getAttrs: collectDomAttrs }]
|
||||||
|
},
|
||||||
|
renderHTML ({ node }) {
|
||||||
|
return ['hr', node.attrs.htmlAttrs]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const StyleNode = Node.create({
|
||||||
|
name: 'style',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
selectable: false,
|
||||||
|
addAttributes () {
|
||||||
|
return {
|
||||||
|
htmlAttrs: { default: {} },
|
||||||
|
css: { default: '' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{ tag: 'style', getAttrs: (dom) => ({ ...collectDomAttrs(dom), css: dom.textContent }) }]
|
||||||
|
},
|
||||||
|
renderHTML ({ node }) {
|
||||||
|
return ['style', node.attrs.htmlAttrs, node.attrs.css]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const EmptySpanNode = Node.create({
|
||||||
|
name: 'emptySpan',
|
||||||
|
inline: true,
|
||||||
|
group: 'inline',
|
||||||
|
atom: true,
|
||||||
|
addAttributes () {
|
||||||
|
return { htmlAttrs: { default: {} } }
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{
|
||||||
|
tag: 'span',
|
||||||
|
priority: 60,
|
||||||
|
getAttrs (dom) {
|
||||||
|
if (dom.childNodes.length === 0 && dom.attributes.length > 0) {
|
||||||
|
return collectDomAttrs(dom)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
renderHTML ({ node }) {
|
||||||
|
return ['span', node.attrs.htmlAttrs]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const LinkMark = Mark.create({
|
||||||
|
name: 'link',
|
||||||
|
inclusive: true,
|
||||||
|
addAttributes () {
|
||||||
|
return { htmlAttrs: { default: {} } }
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{ tag: 'a', getAttrs: collectDomAttrs }]
|
||||||
|
},
|
||||||
|
renderHTML ({ mark }) {
|
||||||
|
return ['a', mark.attrs.htmlAttrs, 0]
|
||||||
|
},
|
||||||
|
addCommands () {
|
||||||
|
return {
|
||||||
|
setLink: ({ href }) => ({ editor, commands }) => {
|
||||||
|
const htmlAttrs = { ...(editor.getAttributes('link').htmlAttrs || {}), href }
|
||||||
|
|
||||||
|
return commands.setMark('link', { htmlAttrs })
|
||||||
|
},
|
||||||
|
unsetLink: () => ({ commands }) => commands.unsetMark('link', { extendEmptyMarkRange: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildDecorations = (doc) => {
|
||||||
|
const decorations = []
|
||||||
|
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g
|
||||||
|
|
||||||
|
doc.descendants((node, pos) => {
|
||||||
|
if (!node.isText) return
|
||||||
|
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = regex.exec(node.text)) !== null) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.inline(pos + match.index, pos + match.index + match[0].length, {
|
||||||
|
class: 'variable-highlight'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return DecorationSet.create(doc, decorations)
|
||||||
|
}
|
||||||
|
|
||||||
|
const VariableHighlight = Extension.create({
|
||||||
|
name: 'variableHighlight',
|
||||||
|
addProseMirrorPlugins () {
|
||||||
|
return [new Plugin({
|
||||||
|
state: {
|
||||||
|
init (_, { doc }) {
|
||||||
|
return buildDecorations(doc)
|
||||||
|
},
|
||||||
|
apply (tr, oldSet) {
|
||||||
|
return tr.docChanged ? buildDecorations(tr.doc) : oldSet
|
||||||
|
}
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations (state) {
|
||||||
|
return this.getState(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return [
|
||||||
|
blockNode('paragraph', 'p', 'inline*'),
|
||||||
|
Heading,
|
||||||
|
blockNode('section', 'section'),
|
||||||
|
blockNode('article', 'article', null, { isolating: true }),
|
||||||
|
blockNode('header', 'header', null, { isolating: true }),
|
||||||
|
blockNode('footer', 'footer', null, { isolating: true }),
|
||||||
|
blockNode('div', 'div'),
|
||||||
|
blockNode('center', 'center'),
|
||||||
|
blockNode('blockquote', 'blockquote'),
|
||||||
|
blockNode('pre', 'pre'),
|
||||||
|
blockNode('orderedList', 'ol', '(listItem | block)+'),
|
||||||
|
blockNode('bulletList', 'ul', '(listItem | block)+'),
|
||||||
|
blockNode('listItem', 'li', 'block+', { group: null }),
|
||||||
|
blockNode('table', 'table', '(colgroup | tableHead | tableBody | tableFoot | tableRow)+'),
|
||||||
|
blockNode('tableHead', 'thead', 'tableRow+', { group: null }),
|
||||||
|
blockNode('tableBody', 'tbody', 'tableRow+', { group: null }),
|
||||||
|
blockNode('tableFoot', 'tfoot', 'tableRow+', { group: null }),
|
||||||
|
blockNode('tableRow', 'tr', '(tableCell | tableHeader)+', { group: null }),
|
||||||
|
blockNode('tableCell', 'td', 'block*', { group: null }),
|
||||||
|
blockNode('tableHeader', 'th', 'block*', { group: null }),
|
||||||
|
blockNode('colgroup', 'colgroup', 'col*', { group: null }),
|
||||||
|
Node.create({
|
||||||
|
name: 'col',
|
||||||
|
atom: true,
|
||||||
|
addAttributes () {
|
||||||
|
return { htmlAttrs: { default: {} } }
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{ tag: 'col', getAttrs: collectDomAttrs }]
|
||||||
|
},
|
||||||
|
renderHTML ({ node }) {
|
||||||
|
return ['col', node.attrs.htmlAttrs]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
ImageNode,
|
||||||
|
HrNode,
|
||||||
|
StyleNode,
|
||||||
|
EmptySpanNode,
|
||||||
|
SpanMark,
|
||||||
|
LinkMark,
|
||||||
|
toggleMark('bold', 'strong', [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }, { style: 'font-weight=700' }], 'Mod-b'),
|
||||||
|
toggleMark('italic', 'em', [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }], 'Mod-i'),
|
||||||
|
toggleMark('underline', 'u', [{ tag: 'u' }, { style: 'text-decoration=underline' }], 'Mod-u'),
|
||||||
|
toggleMark('strike', 's', [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }], 'Mod-Shift-s'),
|
||||||
|
attrsMark('subscript', 'sub'),
|
||||||
|
attrsMark('superscript', 'sup'),
|
||||||
|
VariableHighlight
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default actionable(targetable(class extends HTMLElement {
|
||||||
|
static [target.static] = [
|
||||||
|
'textarea',
|
||||||
|
'editorElement',
|
||||||
|
'boldButton',
|
||||||
|
'italicButton',
|
||||||
|
'underlineButton',
|
||||||
|
'linkButton',
|
||||||
|
'linkTooltipTemplate'
|
||||||
|
]
|
||||||
|
|
||||||
|
async connectedCallback () {
|
||||||
|
if (!this.textarea || !this.editorElement) return
|
||||||
|
|
||||||
|
this.textarea.style.display = 'none'
|
||||||
|
this.adjustShortcutsForPlatform()
|
||||||
|
|
||||||
|
const tiptap = await loadTiptap()
|
||||||
|
|
||||||
|
const { Editor, Extension, Document, Text, HardBreak, UndoRedo, Gapcursor, Dropcursor } = tiptap
|
||||||
|
|
||||||
|
this.emailDocument = new DOMParser().parseFromString(this.textarea.value, 'text/html')
|
||||||
|
|
||||||
|
const shadow = this.editorElement.attachShadow({ mode: 'open' })
|
||||||
|
|
||||||
|
shadow.adoptedStyleSheets = [editorStylesheet]
|
||||||
|
|
||||||
|
this.emailDocument.head.querySelectorAll('style').forEach((style) => {
|
||||||
|
shadow.appendChild(style.cloneNode(true))
|
||||||
|
})
|
||||||
|
|
||||||
|
const container = document.createElement('div')
|
||||||
|
const bodyStyle = this.emailDocument.body.getAttribute('style')
|
||||||
|
|
||||||
|
if (bodyStyle) container.setAttribute('style', bodyStyle)
|
||||||
|
|
||||||
|
shadow.appendChild(container)
|
||||||
|
|
||||||
|
const LinkShortcut = Extension.create({
|
||||||
|
name: 'linkShortcut',
|
||||||
|
addKeyboardShortcuts: () => ({
|
||||||
|
'Mod-k': () => {
|
||||||
|
this.toggleLink()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
this.editor = new Editor({
|
||||||
|
element: container,
|
||||||
|
extensions: [
|
||||||
|
Document,
|
||||||
|
Text,
|
||||||
|
HardBreak,
|
||||||
|
UndoRedo,
|
||||||
|
Gapcursor,
|
||||||
|
Dropcursor,
|
||||||
|
...buildExtensions(tiptap),
|
||||||
|
LinkShortcut
|
||||||
|
],
|
||||||
|
content: this.emailDocument.body.innerHTML,
|
||||||
|
injectCSS: false,
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
dir: 'auto'
|
||||||
|
},
|
||||||
|
handleDOMEvents: {
|
||||||
|
click: (_, event) => {
|
||||||
|
if (event.target.closest('a')) event.preventDefault()
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
this.emailDocument.body.innerHTML = editor.getHTML()
|
||||||
|
|
||||||
|
this.textarea.value = this.emailDocument.documentElement.outerHTML
|
||||||
|
this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
},
|
||||||
|
onSelectionUpdate: ({ editor }) => {
|
||||||
|
this.updateToolbarState()
|
||||||
|
this.handleLinkTooltip(editor)
|
||||||
|
},
|
||||||
|
onBlur: () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.linkTooltip.tooltip.contains(document.activeElement)) {
|
||||||
|
this.linkTooltip.hide()
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.linkTooltip = new LinkTooltip(this, this.editor, this.linkTooltipTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustShortcutsForPlatform () {
|
||||||
|
if ((navigator.userAgentData?.platform)?.toLowerCase()?.includes('mac')) {
|
||||||
|
this.querySelectorAll('.tooltip[data-tip]').forEach(tooltip => {
|
||||||
|
const tip = tooltip.getAttribute('data-tip')
|
||||||
|
|
||||||
|
if (tip && tip.includes('Ctrl')) {
|
||||||
|
tooltip.setAttribute('data-tip', tip.replace(/Ctrl/g, '⌘'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bold (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.editor.chain().focus().toggleBold().run()
|
||||||
|
this.updateToolbarState()
|
||||||
|
}
|
||||||
|
|
||||||
|
italic (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.editor.chain().focus().toggleItalic().run()
|
||||||
|
this.updateToolbarState()
|
||||||
|
}
|
||||||
|
|
||||||
|
underline (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.editor.chain().focus().toggleUnderline().run()
|
||||||
|
this.updateToolbarState()
|
||||||
|
}
|
||||||
|
|
||||||
|
linkSelection (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.toggleLink()
|
||||||
|
this.updateToolbarState()
|
||||||
|
}
|
||||||
|
|
||||||
|
undo (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.editor.chain().focus().undo().run()
|
||||||
|
this.updateToolbarState()
|
||||||
|
}
|
||||||
|
|
||||||
|
redo (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.editor.chain().focus().redo().run()
|
||||||
|
this.updateToolbarState()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateToolbarState () {
|
||||||
|
this.boldButton.classList.toggle('bg-base-200', this.editor.isActive('bold'))
|
||||||
|
this.italicButton.classList.toggle('bg-base-200', this.editor.isActive('italic'))
|
||||||
|
this.underlineButton.classList.toggle('bg-base-200', this.editor.isActive('underline'))
|
||||||
|
this.linkButton.classList.toggle('bg-base-200', this.editor.isActive('link'))
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLinkTooltip (editor) {
|
||||||
|
const { from } = editor.state.selection
|
||||||
|
const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link')
|
||||||
|
|
||||||
|
if (!mark) {
|
||||||
|
if (this.linkTooltip.isVisible()) this.linkTooltip.hide()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.linkTooltip.isVisible() && this.linkTooltip.currentMark === mark) return
|
||||||
|
|
||||||
|
let linkStart = from
|
||||||
|
const start = editor.state.doc.resolve(from).start()
|
||||||
|
|
||||||
|
for (let i = from - 1; i >= start; i--) {
|
||||||
|
if (editor.state.doc.resolve(i).marks().some(m => m.eq(mark))) {
|
||||||
|
linkStart = i
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.linkTooltip.hide()
|
||||||
|
this.linkTooltip.show(mark.attrs.htmlAttrs?.href, linkStart > start ? linkStart - 1 : linkStart)
|
||||||
|
this.linkTooltip.currentMark = mark
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleLink () {
|
||||||
|
if (this.editor.isActive('link')) {
|
||||||
|
this.linkTooltip.hide()
|
||||||
|
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||||
|
this.updateToolbarState()
|
||||||
|
} else {
|
||||||
|
const { from } = this.editor.state.selection
|
||||||
|
|
||||||
|
this.linkTooltip.hide()
|
||||||
|
this.linkTooltip.show(this.editor.getAttributes('link').htmlAttrs?.href, from, { focus: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
insertVariable (e) {
|
||||||
|
const variable = e.target.closest('[data-variable]')?.dataset.variable
|
||||||
|
|
||||||
|
if (variable) {
|
||||||
|
const { from, to } = this.editor.state.selection
|
||||||
|
|
||||||
|
if (variable.includes('link') && from !== to) {
|
||||||
|
this.editor.chain().focus().setLink({ href: `{${variable}}` }).run()
|
||||||
|
} else {
|
||||||
|
this.editor.chain().focus().insertContent(`{${variable}}`).run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback () {
|
||||||
|
this.linkTooltip?.hide()
|
||||||
|
|
||||||
|
if (this.editor) {
|
||||||
|
this.editor.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -35,7 +35,7 @@ function loadTiptap () {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
class LinkTooltip {
|
export class LinkTooltip {
|
||||||
constructor (container, editor, templateEl) {
|
constructor (container, editor, templateEl) {
|
||||||
this.container = container
|
this.container = container
|
||||||
this.editor = editor
|
this.editor = editor
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ safeRegisterElement('submission-form', class extends HTMLElement {
|
|||||||
completedMessage: JSON.parse(this.dataset.completedMessage || '{}'),
|
completedMessage: JSON.parse(this.dataset.completedMessage || '{}'),
|
||||||
completedRedirectUrl: this.dataset.completedRedirectUrl,
|
completedRedirectUrl: this.dataset.completedRedirectUrl,
|
||||||
attachments: reactive(JSON.parse(this.dataset.attachments)),
|
attachments: reactive(JSON.parse(this.dataset.attachments)),
|
||||||
fields: JSON.parse(this.dataset.fields)
|
fields: JSON.parse(this.dataset.fields),
|
||||||
|
completeButtonContainer: document.getElementById('complete_button_container'),
|
||||||
|
completeButtonScrollContainer: document.getElementById('complete_button_container_scroll')
|
||||||
})
|
})
|
||||||
|
|
||||||
this.app.mount(this.appElem)
|
this.app.mount(this.appElem)
|
||||||
|
|||||||
@@ -528,7 +528,7 @@ export default {
|
|||||||
return this.formatDate(
|
return this.formatDate(
|
||||||
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
|
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
|
||||||
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'),
|
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'),
|
||||||
{ withTimePlaceholders: this.modelValue === '{{date}}' }
|
{ withTimePlaceholders: this.modelValue === '{{date}}', utc: this.modelValue !== '{{date}}' }
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
return this.modelValue
|
return this.modelValue
|
||||||
@@ -643,11 +643,15 @@ export default {
|
|||||||
return new Intl.NumberFormat('de-DE').format(number)
|
return new Intl.NumberFormat('de-DE').format(number)
|
||||||
} else if (format === 'space') {
|
} else if (format === 'space') {
|
||||||
return new Intl.NumberFormat('fr-FR').format(number)
|
return new Intl.NumberFormat('fr-FR').format(number)
|
||||||
|
} else if (format === 'percent') {
|
||||||
|
return `${number}%`
|
||||||
|
} else if (format === 'percent_space') {
|
||||||
|
return `${String(number).replace('.', ',')} %`
|
||||||
} else {
|
} else {
|
||||||
return number
|
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 monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
|
||||||
const dayFormats = { D: 'numeric', DD: '2-digit' }
|
const dayFormats = { D: 'numeric', DD: '2-digit' }
|
||||||
const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
|
const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
|
||||||
@@ -669,7 +673,7 @@ export default {
|
|||||||
if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
|
if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
|
||||||
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
|
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
|
||||||
if (/z/.test(format)) opts.timeZoneName = 'short'
|
if (/z/.test(format)) opts.timeZoneName = 'short'
|
||||||
if (!hasTime) opts.timeZone = 'UTC'
|
if (!hasTime && utc) opts.timeZone = 'UTC'
|
||||||
|
|
||||||
const partTypes = {
|
const partTypes = {
|
||||||
M: 'month',
|
M: 'month',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
v-if="withToday"
|
v-if="withToday"
|
||||||
|
type="button"
|
||||||
class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button"
|
class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button"
|
||||||
@click.prevent="[setCurrentDate(), $emit('focus')]"
|
@click.prevent="[setCurrentDate(), $emit('focus')]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -51,6 +51,36 @@
|
|||||||
<script>
|
<script>
|
||||||
import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue'
|
import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||||
|
|
||||||
|
function convertImage (sourceFile, targetType, quality) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
|
reader.onload = function (event) {
|
||||||
|
const img = new Image()
|
||||||
|
|
||||||
|
img.onload = function () {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
|
canvas.width = img.width
|
||||||
|
canvas.height = img.height
|
||||||
|
ctx.drawImage(img, 0, 0)
|
||||||
|
canvas.toBlob(function (blob) {
|
||||||
|
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
|
||||||
|
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
|
||||||
|
resolve(newFile)
|
||||||
|
}, targetType, quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
|
||||||
|
|
||||||
|
img.src = event.target.result
|
||||||
|
}
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsDataURL(sourceFile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'FileDropzone',
|
name: 'FileDropzone',
|
||||||
components: {
|
components: {
|
||||||
@@ -153,8 +183,14 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
if (file.type === 'image/bmp' || file.type === 'image/vnd.microsoft.icon') {
|
try {
|
||||||
file = await this.convertBmpToPng(file)
|
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
|
||||||
|
file = await convertImage(file, 'image/png')
|
||||||
|
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
|
||||||
|
file = await convertImage(file, 'image/jpeg', 0.9)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
@@ -181,32 +217,6 @@ export default {
|
|||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
this.isLoading = false
|
this.isLoading = false
|
||||||
})
|
})
|
||||||
},
|
|
||||||
convertBmpToPng (bmpFile) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
|
|
||||||
reader.onload = function (event) {
|
|
||||||
const img = new Image()
|
|
||||||
|
|
||||||
img.onload = function () {
|
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
|
|
||||||
canvas.width = img.width
|
|
||||||
canvas.height = img.height
|
|
||||||
ctx.drawImage(img, 0, 0)
|
|
||||||
canvas.toBlob(function (blob) {
|
|
||||||
const newFile = new File([blob], bmpFile.name.replace(/\.\w+$/, '.png'), { type: 'image/png' })
|
|
||||||
resolve(newFile)
|
|
||||||
}, 'image/png')
|
|
||||||
}
|
|
||||||
|
|
||||||
img.src = event.target.result
|
|
||||||
}
|
|
||||||
reader.onerror = reject
|
|
||||||
reader.readAsDataURL(bmpFile)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
v-else
|
v-else
|
||||||
id="complete_form_button"
|
id="complete_form_button"
|
||||||
class="btn btn-sm btn-neutral text-white px-4 w-full flex justify-center"
|
class="btn btn-sm btn-neutral text-white px-4 w-full flex justify-center"
|
||||||
form="steps_form"
|
form="complete_form"
|
||||||
type="submit"
|
type="submit"
|
||||||
name="completed"
|
name="completed"
|
||||||
value="true"
|
value="true"
|
||||||
@@ -113,8 +113,41 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
<Teleport
|
||||||
|
v-for="ref in (showCompleteButton ? [completeButtonContainer, completeButtonScrollContainer].filter(Boolean) : [])"
|
||||||
|
:key="ref"
|
||||||
|
:to="ref"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="complete-button btn btn-sm btn-neutral text-white px-4"
|
||||||
|
form="complete_form"
|
||||||
|
type="submit"
|
||||||
|
name="completed"
|
||||||
|
value="true"
|
||||||
|
:disabled="isSubmittingComplete"
|
||||||
|
>
|
||||||
|
<span class="flex items-center">
|
||||||
|
<IconInnerShadowTop
|
||||||
|
v-if="isSubmittingComplete"
|
||||||
|
class="mr-1 animate-spin w-5 h-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{{ t('complete') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Teleport>
|
||||||
|
<form
|
||||||
|
v-if="!isCompleted && !isInvite"
|
||||||
|
id="complete_form"
|
||||||
|
class="hidden"
|
||||||
|
:action="submitPath"
|
||||||
|
method="post"
|
||||||
|
@submit.prevent="submitStep"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="!isFormVisible"
|
v-if="!isFormVisible && currentField"
|
||||||
id="expand_form_button"
|
id="expand_form_button"
|
||||||
class="btn btn-neutral flex text-white absolute bottom-0 w-full mb-3 expand-form-button text-base"
|
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%"
|
style="width: 96%; margin-left: 2%"
|
||||||
@@ -141,6 +174,7 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
|
v-if="currentField"
|
||||||
v-show="isFormVisible"
|
v-show="isFormVisible"
|
||||||
id="form_container"
|
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"
|
class="shadow-md bg-base-100 absolute bottom-0 w-full border-base-200 border p-4 rounded form-container overflow-hidden"
|
||||||
@@ -256,6 +290,7 @@
|
|||||||
:id="currentField.uuid"
|
:id="currentField.uuid"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
:required="currentField.required"
|
: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"
|
:aria-describedby="currentField.description ? currentField.uuid + '-desc' : undefined"
|
||||||
class="select base-input !text-2xl w-full text-center font-normal"
|
class="select base-input !text-2xl w-full text-center font-normal"
|
||||||
:class="{ 'text-gray-300': !values[currentField.uuid] }"
|
:class="{ 'text-gray-300': !values[currentField.uuid] }"
|
||||||
@@ -284,7 +319,7 @@
|
|||||||
<div v-else-if="currentField.type === 'radio'">
|
<div v-else-if="currentField.type === 'radio'">
|
||||||
<label
|
<label
|
||||||
v-if="showFieldNames && (currentField.name || currentField.title)"
|
v-if="showFieldNames && (currentField.name || currentField.title)"
|
||||||
:for="currentField.uuid"
|
:id="currentField.uuid + '-radio-label'"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
|
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
|
||||||
:class="{ 'mb-2': !currentField.description }"
|
:class="{ 'mb-2': !currentField.description }"
|
||||||
@@ -323,6 +358,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="space-y-3.5 mx-auto"
|
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) }"
|
:class="{ hidden: !showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4) }"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -791,6 +828,16 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: null
|
default: null
|
||||||
},
|
},
|
||||||
|
completeButtonContainer: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
completeButtonScrollContainer: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
schema: {
|
schema: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -1008,6 +1055,7 @@ export default {
|
|||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
isSubmittingComplete: false,
|
isSubmittingComplete: false,
|
||||||
submittedValues: {},
|
submittedValues: {},
|
||||||
|
isFormStarted: false,
|
||||||
recalculateButtonDisabledKey: '',
|
recalculateButtonDisabledKey: '',
|
||||||
isAccessibilityMode: false
|
isAccessibilityMode: false
|
||||||
}
|
}
|
||||||
@@ -1056,6 +1104,10 @@ export default {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
showCompleteButton () {
|
||||||
|
return this.completeButtonContainer && !this.isCompleted && !this.isInvite && this.isFormStarted &&
|
||||||
|
!this.stepFields.find((fields) => fields.some((f) => f.required && isEmpty(this.submittedValues[f.uuid])))
|
||||||
|
},
|
||||||
submitButtonText () {
|
submitButtonText () {
|
||||||
if (this.alwaysMinimize) {
|
if (this.alwaysMinimize) {
|
||||||
return this.t('submit')
|
return this.t('submit')
|
||||||
@@ -1121,7 +1173,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
isAnonymousChecboxes () {
|
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 () {
|
isButtonDisabled () {
|
||||||
if (this.recalculateButtonDisabledKey) {
|
if (this.recalculateButtonDisabledKey) {
|
||||||
@@ -1657,6 +1713,8 @@ export default {
|
|||||||
return Promise.reject(new Error(data.error))
|
return Promise.reject(new Error(data.error))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.isFormStarted = true
|
||||||
|
|
||||||
const nextStep = (isLastStep && emptyRequiredField) || (forceComplete ? null : this.findNextStep(submitStepIndex))
|
const nextStep = (isLastStep && emptyRequiredField) || (forceComplete ? null : this.findNextStep(submitStepIndex))
|
||||||
|
|
||||||
if (nextStep) {
|
if (nextStep) {
|
||||||
@@ -1693,6 +1751,7 @@ export default {
|
|||||||
},
|
},
|
||||||
async performComplete (resp) {
|
async performComplete (resp) {
|
||||||
this.isCompleted = true
|
this.isCompleted = true
|
||||||
|
this.isFormVisible = true
|
||||||
|
|
||||||
if (resp?.text) {
|
if (resp?.text) {
|
||||||
const respData = await resp.text()
|
const respData = await resp.text()
|
||||||
|
|||||||
@@ -4,9 +4,17 @@ const en = {
|
|||||||
close: 'Close',
|
close: 'Close',
|
||||||
uploaded_files: 'Uploaded files',
|
uploaded_files: 'Uploaded files',
|
||||||
signature_drawing_area: 'Signature drawing area. Use mouse or touch to draw your signature.',
|
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',
|
kba: 'KBA',
|
||||||
please_upload_an_image_file: 'Please upload an image file',
|
please_upload_an_image_file: 'Please upload an image file',
|
||||||
must_be_characters_length: 'Must be {number} characters long',
|
must_be_characters_length: 'Must be {number} characters long',
|
||||||
|
must_be_valid_ssn: 'Must be a valid SSN (XXX-XX-XXXX)',
|
||||||
|
must_be_valid_ein: 'Must be a valid EIN (XX-XXXXXXX)',
|
||||||
|
must_be_valid_email: 'Must be a valid email address',
|
||||||
|
must_be_valid_url: 'Must be a valid URL (starting with http:// or https://)',
|
||||||
|
must_be_valid_zip: 'Must be a valid ZIP code (XXXXX or XXXXX-XXXX)',
|
||||||
|
must_contain_numbers_only: 'Must contain numbers only',
|
||||||
|
must_contain_letters_only: 'Must contain letters only',
|
||||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
|
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
|
||||||
verify_id: 'Verify ID',
|
verify_id: 'Verify ID',
|
||||||
identity_verification: 'Identity verification',
|
identity_verification: 'Identity verification',
|
||||||
@@ -81,6 +89,7 @@ const en = {
|
|||||||
please_check_the_box_to_continue: 'Please check the box to continue.',
|
please_check_the_box_to_continue: 'Please check the box to continue.',
|
||||||
open_source_documents_software: 'open source documents software',
|
open_source_documents_software: 'open source documents software',
|
||||||
verified_phone_number: 'Verify Phone Number',
|
verified_phone_number: 'Verify Phone Number',
|
||||||
|
country_code: 'Country code',
|
||||||
use_international_format: 'Use international format: +1xxx',
|
use_international_format: 'Use international format: +1xxx',
|
||||||
six_digits_code: '6-digit code',
|
six_digits_code: '6-digit code',
|
||||||
change_phone_number: 'Change phone number',
|
change_phone_number: 'Change phone number',
|
||||||
@@ -113,9 +122,17 @@ const es = {
|
|||||||
close: 'Cerrar',
|
close: 'Cerrar',
|
||||||
uploaded_files: 'Archivos subidos',
|
uploaded_files: 'Archivos subidos',
|
||||||
signature_drawing_area: 'Área de dibujo de firma. Use el ratón o toque para dibujar su firma.',
|
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',
|
kba: 'KBA',
|
||||||
please_upload_an_image_file: 'Por favor, sube un archivo de imagen',
|
please_upload_an_image_file: 'Por favor, sube un archivo de imagen',
|
||||||
must_be_characters_length: 'Debe tener {number} caracteres de longitud',
|
must_be_characters_length: 'Debe tener {number} caracteres de longitud',
|
||||||
|
must_be_valid_ssn: 'Debe ser un SSN válido (XXX-XX-XXXX)',
|
||||||
|
must_be_valid_ein: 'Debe ser un EIN válido (XX-XXXXXXX)',
|
||||||
|
must_be_valid_email: 'Debe ser una dirección de correo electrónico válida',
|
||||||
|
must_be_valid_url: 'Debe ser una URL válida (que comience con http:// o https://)',
|
||||||
|
must_be_valid_zip: 'Debe ser un ZIP válido (XXXXX o XXXXX-XXXX)',
|
||||||
|
must_contain_numbers_only: 'Debe contener solo números',
|
||||||
|
must_contain_letters_only: 'Debe contener solo letras',
|
||||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
|
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
|
||||||
verify_id: 'Verificar ID',
|
verify_id: 'Verificar ID',
|
||||||
identity_verification: 'Verificación de identidad',
|
identity_verification: 'Verificación de identidad',
|
||||||
@@ -190,6 +207,7 @@ const es = {
|
|||||||
please_check_the_box_to_continue: 'Por favor marque la casilla para continuar.',
|
please_check_the_box_to_continue: 'Por favor marque la casilla para continuar.',
|
||||||
open_source_documents_software: 'software de documentos de código abierto',
|
open_source_documents_software: 'software de documentos de código abierto',
|
||||||
verified_phone_number: 'Verificar número de teléfono',
|
verified_phone_number: 'Verificar número de teléfono',
|
||||||
|
country_code: 'Código de país',
|
||||||
use_international_format: 'Usar formato internacional: +1xxx',
|
use_international_format: 'Usar formato internacional: +1xxx',
|
||||||
six_digits_code: 'Código de 6 dígitos',
|
six_digits_code: 'Código de 6 dígitos',
|
||||||
change_phone_number: 'Cambiar número de teléfono',
|
change_phone_number: 'Cambiar número de teléfono',
|
||||||
@@ -222,9 +240,17 @@ const it = {
|
|||||||
close: 'Chiudi',
|
close: 'Chiudi',
|
||||||
uploaded_files: 'File caricati',
|
uploaded_files: 'File caricati',
|
||||||
signature_drawing_area: 'Area di disegno della firma. Usa il mouse o il tocco per disegnare la tua firma.',
|
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',
|
kba: 'KBA',
|
||||||
please_upload_an_image_file: 'Per favore carica un file immagine',
|
please_upload_an_image_file: 'Per favore carica un file immagine',
|
||||||
must_be_characters_length: 'Deve essere lungo {number} caratteri',
|
must_be_characters_length: 'Deve essere lungo {number} caratteri',
|
||||||
|
must_be_valid_ssn: 'Deve essere un SSN valido (XXX-XX-XXXX)',
|
||||||
|
must_be_valid_ein: 'Deve essere un EIN valido (XX-XXXXXXX)',
|
||||||
|
must_be_valid_email: 'Deve essere un indirizzo email valido',
|
||||||
|
must_be_valid_url: 'Deve essere un URL valido (che inizia con http:// o https://)',
|
||||||
|
must_be_valid_zip: 'Deve essere uno ZIP valido (XXXXX o XXXXX-XXXX)',
|
||||||
|
must_contain_numbers_only: 'Deve contenere solo numeri',
|
||||||
|
must_contain_letters_only: 'Deve contenere solo lettere',
|
||||||
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
|
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
|
||||||
verify_id: 'Verifica ID',
|
verify_id: 'Verifica ID',
|
||||||
identity_verification: "Verifica dell'identità",
|
identity_verification: "Verifica dell'identità",
|
||||||
@@ -299,6 +325,7 @@ const it = {
|
|||||||
please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare.',
|
please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare.',
|
||||||
open_source_documents_software: 'software di documenti open source',
|
open_source_documents_software: 'software di documenti open source',
|
||||||
verified_phone_number: 'Verifica numero di telefono',
|
verified_phone_number: 'Verifica numero di telefono',
|
||||||
|
country_code: 'Prefisso internazionale',
|
||||||
use_international_format: 'Usa formato internazionale: +1xxx',
|
use_international_format: 'Usa formato internazionale: +1xxx',
|
||||||
six_digits_code: 'Codice a 6 cifre',
|
six_digits_code: 'Codice a 6 cifre',
|
||||||
change_phone_number: 'Cambia numero di telefono',
|
change_phone_number: 'Cambia numero di telefono',
|
||||||
@@ -331,9 +358,17 @@ const de = {
|
|||||||
close: 'Schließen',
|
close: 'Schließen',
|
||||||
uploaded_files: 'Hochgeladene Dateien',
|
uploaded_files: 'Hochgeladene Dateien',
|
||||||
signature_drawing_area: 'Unterschriftszeichenbereich. Verwenden Sie die Maus oder Berührung, um Ihre Unterschrift zu zeichnen.',
|
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',
|
kba: 'KBA',
|
||||||
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
|
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
|
||||||
must_be_characters_length: 'Muss {number} Zeichen lang sein',
|
must_be_characters_length: 'Muss {number} Zeichen lang sein',
|
||||||
|
must_be_valid_ssn: 'Muss eine gültige SSN sein (XXX-XX-XXXX)',
|
||||||
|
must_be_valid_ein: 'Muss eine gültige EIN sein (XX-XXXXXXX)',
|
||||||
|
must_be_valid_email: 'Muss eine gültige E-Mail-Adresse sein',
|
||||||
|
must_be_valid_url: 'Muss eine gültige URL sein (beginnend mit http:// oder https://)',
|
||||||
|
must_be_valid_zip: 'Muss eine gültige ZIP sein (XXXXX oder XXXXX-XXXX)',
|
||||||
|
must_contain_numbers_only: 'Darf nur Zahlen enthalten',
|
||||||
|
must_contain_letters_only: 'Darf nur Buchstaben enthalten',
|
||||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
|
complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
|
||||||
verify_id: 'ID überprüfen',
|
verify_id: 'ID überprüfen',
|
||||||
identity_verification: 'Identitätsprüfung',
|
identity_verification: 'Identitätsprüfung',
|
||||||
@@ -408,6 +443,7 @@ const de = {
|
|||||||
please_check_the_box_to_continue: 'Bitte aktivieren Sie das Kontrollkästchen, um fortzufahren.',
|
please_check_the_box_to_continue: 'Bitte aktivieren Sie das Kontrollkästchen, um fortzufahren.',
|
||||||
open_source_documents_software: 'Open-Source-Dokumentensoftware',
|
open_source_documents_software: 'Open-Source-Dokumentensoftware',
|
||||||
verified_phone_number: 'Telefonnummer verifizieren',
|
verified_phone_number: 'Telefonnummer verifizieren',
|
||||||
|
country_code: 'Ländervorwahl',
|
||||||
use_international_format: 'Internationales Format verwenden: +1xxx',
|
use_international_format: 'Internationales Format verwenden: +1xxx',
|
||||||
six_digits_code: '6-stelliger Code',
|
six_digits_code: '6-stelliger Code',
|
||||||
change_phone_number: 'Telefonnummer ändern',
|
change_phone_number: 'Telefonnummer ändern',
|
||||||
@@ -440,9 +476,17 @@ const fr = {
|
|||||||
close: 'Fermer',
|
close: 'Fermer',
|
||||||
uploaded_files: 'Fichiers téléchargés',
|
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.',
|
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',
|
kba: 'KBA',
|
||||||
please_upload_an_image_file: 'Veuillez téléverser un fichier image',
|
please_upload_an_image_file: 'Veuillez téléverser un fichier image',
|
||||||
must_be_characters_length: 'Doit comporter {number} caractères',
|
must_be_characters_length: 'Doit comporter {number} caractères',
|
||||||
|
must_be_valid_ssn: 'Doit être un SSN valide (XXX-XX-XXXX)',
|
||||||
|
must_be_valid_ein: 'Doit être un EIN valide (XX-XXXXXXX)',
|
||||||
|
must_be_valid_email: 'Doit être une adresse e-mail valide',
|
||||||
|
must_be_valid_url: 'Doit être une URL valide (commençant par http:// ou https://)',
|
||||||
|
must_be_valid_zip: 'Doit être un ZIP valide (XXXXX ou XXXXX-XXXX)',
|
||||||
|
must_contain_numbers_only: 'Doit contenir uniquement des chiffres',
|
||||||
|
must_contain_letters_only: 'Doit contenir uniquement des lettres',
|
||||||
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
|
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
|
||||||
verify_id: "Vérifier l'ID",
|
verify_id: "Vérifier l'ID",
|
||||||
identity_verification: "Vérification d'identité",
|
identity_verification: "Vérification d'identité",
|
||||||
@@ -517,6 +561,7 @@ const fr = {
|
|||||||
please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer.',
|
please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer.',
|
||||||
open_source_documents_software: 'logiciel de documents open source',
|
open_source_documents_software: 'logiciel de documents open source',
|
||||||
verified_phone_number: 'Vérifier le numéro de téléphone',
|
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',
|
use_international_format: 'Utilisez le format international : +1xxx',
|
||||||
six_digits_code: 'Code à 6 chiffres',
|
six_digits_code: 'Code à 6 chiffres',
|
||||||
change_phone_number: 'Changer de numéro de téléphone',
|
change_phone_number: 'Changer de numéro de téléphone',
|
||||||
@@ -549,9 +594,17 @@ const pl = {
|
|||||||
close: 'Zamknij',
|
close: 'Zamknij',
|
||||||
uploaded_files: 'Przesłane pliki',
|
uploaded_files: 'Przesłane pliki',
|
||||||
signature_drawing_area: 'Obszar rysowania podpisu. Użyj myszy lub dotyku, aby narysować swój podpis.',
|
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',
|
kba: 'KBA',
|
||||||
please_upload_an_image_file: 'Proszę przesłać plik obrazu',
|
please_upload_an_image_file: 'Proszę przesłać plik obrazu',
|
||||||
must_be_characters_length: 'Musi mieć długość {number} znaków',
|
must_be_characters_length: 'Musi mieć długość {number} znaków',
|
||||||
|
must_be_valid_ssn: 'Musi być prawidłowym numerem SSN (XXX-XX-XXXX)',
|
||||||
|
must_be_valid_ein: 'Musi być prawidłowym numerem EIN (XX-XXXXXXX)',
|
||||||
|
must_be_valid_email: 'Musi być prawidłowym adresem e-mail',
|
||||||
|
must_be_valid_url: 'Musi być prawidłowym adresem URL (zaczynającym się od http:// lub https://)',
|
||||||
|
must_be_valid_zip: 'Musi być prawidłowym ZIP (XXXXX lub XXXXX-XXXX)',
|
||||||
|
must_contain_numbers_only: 'Może zawierać tylko cyfry',
|
||||||
|
must_contain_letters_only: 'Może zawierać tylko litery',
|
||||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
|
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
|
||||||
verify_id: 'Zweryfikuj ID',
|
verify_id: 'Zweryfikuj ID',
|
||||||
identity_verification: 'Weryfikacja tożsamości',
|
identity_verification: 'Weryfikacja tożsamości',
|
||||||
@@ -626,6 +679,7 @@ const pl = {
|
|||||||
please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować.',
|
please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować.',
|
||||||
open_source_documents_software: 'oprogramowanie do dokumentów open source',
|
open_source_documents_software: 'oprogramowanie do dokumentów open source',
|
||||||
verified_phone_number: 'Zweryfikuj numer telefonu',
|
verified_phone_number: 'Zweryfikuj numer telefonu',
|
||||||
|
country_code: 'Kod kraju',
|
||||||
use_international_format: 'Użyj międzynarodowego formatu: +1xxx',
|
use_international_format: 'Użyj międzynarodowego formatu: +1xxx',
|
||||||
six_digits_code: '6-cyfrowy kod',
|
six_digits_code: '6-cyfrowy kod',
|
||||||
change_phone_number: 'Zmień numer telefonu',
|
change_phone_number: 'Zmień numer telefonu',
|
||||||
@@ -658,9 +712,17 @@ const uk = {
|
|||||||
close: 'Закрити',
|
close: 'Закрити',
|
||||||
uploaded_files: 'Завантажені файли',
|
uploaded_files: 'Завантажені файли',
|
||||||
signature_drawing_area: 'Область малювання підпису. Використовуйте мишу або дотик, щоб намалювати свій підпис.',
|
signature_drawing_area: 'Область малювання підпису. Використовуйте мишу або дотик, щоб намалювати свій підпис.',
|
||||||
|
initials_drawing_area: 'Область малювання ініціалів. Використовуйте мишу або дотик, щоб намалювати свої ініціали.',
|
||||||
kba: 'KBA',
|
kba: 'KBA',
|
||||||
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
|
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
|
||||||
must_be_characters_length: 'Має містити {number} символів',
|
must_be_characters_length: 'Має містити {number} символів',
|
||||||
|
must_be_valid_ssn: 'Має бути дійсним SSN (XXX-XX-XXXX)',
|
||||||
|
must_be_valid_ein: 'Має бути дійсним EIN (XX-XXXXXXX)',
|
||||||
|
must_be_valid_email: 'Має бути дійсною адресою електронної пошти',
|
||||||
|
must_be_valid_url: 'Має бути дійсним URL (починаючи з http:// або https://)',
|
||||||
|
must_be_valid_zip: 'Має бути дійсним ZIP (XXXXX або XXXXX-XXXX)',
|
||||||
|
must_contain_numbers_only: 'Має містити лише цифри',
|
||||||
|
must_contain_letters_only: 'Має містити лише літери',
|
||||||
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
|
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
|
||||||
verify_id: 'Підтвердження ідентичності',
|
verify_id: 'Підтвердження ідентичності',
|
||||||
identity_verification: 'Ідентифікація особи',
|
identity_verification: 'Ідентифікація особи',
|
||||||
@@ -735,6 +797,7 @@ const uk = {
|
|||||||
please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити.',
|
please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити.',
|
||||||
open_source_documents_software: 'відкрите програмне забезпечення для документів',
|
open_source_documents_software: 'відкрите програмне забезпечення для документів',
|
||||||
verified_phone_number: 'Підтвердіть номер телефону',
|
verified_phone_number: 'Підтвердіть номер телефону',
|
||||||
|
country_code: 'Код країни',
|
||||||
use_international_format: 'Використовуйте міжнародний формат: +1xxx',
|
use_international_format: 'Використовуйте міжнародний формат: +1xxx',
|
||||||
six_digits_code: '6-значний код',
|
six_digits_code: '6-значний код',
|
||||||
change_phone_number: 'Змінити номер телефону',
|
change_phone_number: 'Змінити номер телефону',
|
||||||
@@ -767,9 +830,17 @@ const cs = {
|
|||||||
close: 'Zavřít',
|
close: 'Zavřít',
|
||||||
uploaded_files: 'Nahrané soubory',
|
uploaded_files: 'Nahrané soubory',
|
||||||
signature_drawing_area: 'Oblast pro kreslení podpisu. Použijte myš nebo dotyk k nakreslení podpisu.',
|
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',
|
kba: 'KBA',
|
||||||
please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor',
|
please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor',
|
||||||
must_be_characters_length: 'Musí mít délku {number} znaků',
|
must_be_characters_length: 'Musí mít délku {number} znaků',
|
||||||
|
must_be_valid_ssn: 'Musí být platné SSN (XXX-XX-XXXX)',
|
||||||
|
must_be_valid_ein: 'Musí být platné EIN (XX-XXXXXXX)',
|
||||||
|
must_be_valid_email: 'Musí být platná e-mailová adresa',
|
||||||
|
must_be_valid_url: 'Musí být platná URL (začínající http:// nebo https://)',
|
||||||
|
must_be_valid_zip: 'Musí být platný ZIP (XXXXX nebo XXXXX-XXXX)',
|
||||||
|
must_contain_numbers_only: 'Musí obsahovat pouze čísla',
|
||||||
|
must_contain_letters_only: 'Musí obsahovat pouze písmena',
|
||||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
|
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
|
||||||
verify_id: 'Ověřit ID',
|
verify_id: 'Ověřit ID',
|
||||||
identity_verification: 'Ověření identity',
|
identity_verification: 'Ověření identity',
|
||||||
@@ -844,6 +915,7 @@ const cs = {
|
|||||||
please_check_the_box_to_continue: 'Prosím, zaškrtněte políčko pro pokračování.',
|
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',
|
open_source_documents_software: 'open source software pro dokumenty',
|
||||||
verified_phone_number: 'Ověřte telefonní číslo',
|
verified_phone_number: 'Ověřte telefonní číslo',
|
||||||
|
country_code: 'Předvolba země',
|
||||||
use_international_format: 'Použijte mezinárodní formát: +1xxx',
|
use_international_format: 'Použijte mezinárodní formát: +1xxx',
|
||||||
six_digits_code: '6-místný kód',
|
six_digits_code: '6-místný kód',
|
||||||
change_phone_number: 'Změnit telefonní číslo',
|
change_phone_number: 'Změnit telefonní číslo',
|
||||||
@@ -876,9 +948,17 @@ const pt = {
|
|||||||
close: 'Fechar',
|
close: 'Fechar',
|
||||||
uploaded_files: 'Arquivos enviados',
|
uploaded_files: 'Arquivos enviados',
|
||||||
signature_drawing_area: 'Área de desenho da assinatura. Use o mouse ou toque para desenhar sua assinatura.',
|
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',
|
kba: 'KBA',
|
||||||
please_upload_an_image_file: 'Por favor, envie um arquivo de imagem',
|
please_upload_an_image_file: 'Por favor, envie um arquivo de imagem',
|
||||||
must_be_characters_length: 'Deve ter {number} caracteres',
|
must_be_characters_length: 'Deve ter {number} caracteres',
|
||||||
|
must_be_valid_ssn: 'Deve ser um SSN válido (XXX-XX-XXXX)',
|
||||||
|
must_be_valid_ein: 'Deve ser um EIN válido (XX-XXXXXXX)',
|
||||||
|
must_be_valid_email: 'Deve ser um endereço de e-mail válido',
|
||||||
|
must_be_valid_url: 'Deve ser uma URL válida (começando com http:// ou https://)',
|
||||||
|
must_be_valid_zip: 'Deve ser um ZIP válido (XXXXX ou XXXXX-XXXX)',
|
||||||
|
must_contain_numbers_only: 'Deve conter apenas números',
|
||||||
|
must_contain_letters_only: 'Deve conter apenas letras',
|
||||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
|
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
|
||||||
verify_id: 'Verificar ID',
|
verify_id: 'Verificar ID',
|
||||||
identity_verification: 'Verificação de identidade',
|
identity_verification: 'Verificação de identidade',
|
||||||
@@ -953,6 +1033,7 @@ const pt = {
|
|||||||
please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar.',
|
please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar.',
|
||||||
open_source_documents_software: 'software de documentos de código aberto',
|
open_source_documents_software: 'software de documentos de código aberto',
|
||||||
verified_phone_number: 'Verificar Número de Telefone',
|
verified_phone_number: 'Verificar Número de Telefone',
|
||||||
|
country_code: 'Código do país',
|
||||||
use_international_format: 'Use formato internacional: +1xxx',
|
use_international_format: 'Use formato internacional: +1xxx',
|
||||||
six_digits_code: 'Código de 6 dígitos',
|
six_digits_code: 'Código de 6 dígitos',
|
||||||
change_phone_number: 'Alterar número de telefone',
|
change_phone_number: 'Alterar número de telefone',
|
||||||
@@ -985,9 +1066,17 @@ const he = {
|
|||||||
close: 'סגור',
|
close: 'סגור',
|
||||||
uploaded_files: 'קבצים שהועלו',
|
uploaded_files: 'קבצים שהועלו',
|
||||||
signature_drawing_area: 'אזור ציור חתימה. השתמש בעכבר או במגע כדי לצייר את החתימה שלך.',
|
signature_drawing_area: 'אזור ציור חתימה. השתמש בעכבר או במגע כדי לצייר את החתימה שלך.',
|
||||||
|
initials_drawing_area: 'אזור ציור ראשי תיבות. השתמש בעכבר או במגע כדי לצייר את ראשי התיבות שלך.',
|
||||||
kba: 'KBA',
|
kba: 'KBA',
|
||||||
please_upload_an_image_file: 'אנא העלה קובץ תמונה',
|
please_upload_an_image_file: 'אנא העלה קובץ תמונה',
|
||||||
must_be_characters_length: 'חייב להיות באורך של {number} תווים',
|
must_be_characters_length: 'חייב להיות באורך של {number} תווים',
|
||||||
|
must_be_valid_ssn: 'חייב להיות SSN חוקי (XXX-XX-XXXX)',
|
||||||
|
must_be_valid_ein: 'חייב להיות EIN חוקי (XX-XXXXXXX)',
|
||||||
|
must_be_valid_email: 'חייבת להיות כתובת אימייל חוקית',
|
||||||
|
must_be_valid_url: 'חייבת להיות כתובת URL חוקית (המתחילה ב-http:// או https://)',
|
||||||
|
must_be_valid_zip: 'חייב להיות ZIP חוקי (XXXXX או XXXXX-XXXX)',
|
||||||
|
must_contain_numbers_only: 'חייב להכיל מספרים בלבד',
|
||||||
|
must_contain_letters_only: 'חייב להכיל אותיות בלבד',
|
||||||
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
|
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
|
||||||
verify_id: 'אמת מזהה',
|
verify_id: 'אמת מזהה',
|
||||||
identity_verification: 'אימות זהות',
|
identity_verification: 'אימות זהות',
|
||||||
@@ -1062,6 +1151,7 @@ const he = {
|
|||||||
please_check_the_box_to_continue: 'אנא סמן את התיבה כדי להמשיך.',
|
please_check_the_box_to_continue: 'אנא סמן את התיבה כדי להמשיך.',
|
||||||
open_source_documents_software: 'תוכנה פתוחה למסמכים',
|
open_source_documents_software: 'תוכנה פתוחה למסמכים',
|
||||||
verified_phone_number: 'אימות מספר טלפון',
|
verified_phone_number: 'אימות מספר טלפון',
|
||||||
|
country_code: 'קידומת מדינה',
|
||||||
use_international_format: 'השתמש בפורמט בינלאומי: +1xxx',
|
use_international_format: 'השתמש בפורמט בינלאומי: +1xxx',
|
||||||
six_digits_code: 'קוד שש ספרות',
|
six_digits_code: 'קוד שש ספרות',
|
||||||
change_phone_number: 'שינוי מספר טלפון',
|
change_phone_number: 'שינוי מספר טלפון',
|
||||||
@@ -1094,9 +1184,17 @@ const nl = {
|
|||||||
close: 'Sluiten',
|
close: 'Sluiten',
|
||||||
uploaded_files: 'Geüploade bestanden',
|
uploaded_files: 'Geüploade bestanden',
|
||||||
signature_drawing_area: 'Handtekening tekengebied. Gebruik de muis of aanraking om uw handtekening te tekenen.',
|
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',
|
kba: 'KBA',
|
||||||
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
|
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
|
||||||
must_be_characters_length: 'Moet {number} tekens lang zijn',
|
must_be_characters_length: 'Moet {number} tekens lang zijn',
|
||||||
|
must_be_valid_ssn: 'Moet een geldig SSN zijn (XXX-XX-XXXX)',
|
||||||
|
must_be_valid_ein: 'Moet een geldig EIN zijn (XX-XXXXXXX)',
|
||||||
|
must_be_valid_email: 'Moet een geldig e-mailadres zijn',
|
||||||
|
must_be_valid_url: 'Moet een geldige URL zijn (beginnend met http:// of https://)',
|
||||||
|
must_be_valid_zip: 'Moet een geldige ZIP zijn (XXXXX of XXXXX-XXXX)',
|
||||||
|
must_contain_numbers_only: 'Mag alleen cijfers bevatten',
|
||||||
|
must_contain_letters_only: 'Mag alleen letters bevatten',
|
||||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
|
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
|
||||||
verify_id: 'ID verifiëren',
|
verify_id: 'ID verifiëren',
|
||||||
identity_verification: 'Identiteitsverificatie',
|
identity_verification: 'Identiteitsverificatie',
|
||||||
@@ -1171,6 +1269,7 @@ const nl = {
|
|||||||
please_check_the_box_to_continue: 'Vink het vakje aan om door te gaan.',
|
please_check_the_box_to_continue: 'Vink het vakje aan om door te gaan.',
|
||||||
open_source_documents_software: 'Open source documenten software',
|
open_source_documents_software: 'Open source documenten software',
|
||||||
verified_phone_number: 'Telefoonnummer verifiëren',
|
verified_phone_number: 'Telefoonnummer verifiëren',
|
||||||
|
country_code: 'Landcode',
|
||||||
use_international_format: 'Gebruik internationaal formaat: +1xxx',
|
use_international_format: 'Gebruik internationaal formaat: +1xxx',
|
||||||
six_digits_code: '6-cijferige code',
|
six_digits_code: '6-cijferige code',
|
||||||
change_phone_number: 'Wijzig telefoonnummer',
|
change_phone_number: 'Wijzig telefoonnummer',
|
||||||
@@ -1203,9 +1302,17 @@ const ar = {
|
|||||||
close: 'إغلاق',
|
close: 'إغلاق',
|
||||||
uploaded_files: 'الملفات المرفوعة',
|
uploaded_files: 'الملفات المرفوعة',
|
||||||
signature_drawing_area: 'منطقة رسم التوقيع. استخدم الماوس أو اللمس لرسم توقيعك.',
|
signature_drawing_area: 'منطقة رسم التوقيع. استخدم الماوس أو اللمس لرسم توقيعك.',
|
||||||
|
initials_drawing_area: 'منطقة رسم الحروف الأولى. استخدم الماوس أو اللمس لرسم الحروف الأولى.',
|
||||||
kba: 'KBA',
|
kba: 'KBA',
|
||||||
please_upload_an_image_file: 'يرجى تحميل ملف صورة',
|
please_upload_an_image_file: 'يرجى تحميل ملف صورة',
|
||||||
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
|
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
|
||||||
|
must_be_valid_ssn: 'يجب أن يكون SSN صالحًا (XXX-XX-XXXX)',
|
||||||
|
must_be_valid_ein: 'يجب أن يكون EIN صالحًا (XX-XXXXXXX)',
|
||||||
|
must_be_valid_email: 'يجب أن يكون عنوان بريد إلكتروني صالحًا',
|
||||||
|
must_be_valid_url: 'يجب أن يكون عنوان URL صالحًا (يبدأ بـ http:// أو https://)',
|
||||||
|
must_be_valid_zip: 'يجب أن يكون ZIP صالحًا (XXXXX أو XXXXX-XXXX)',
|
||||||
|
must_contain_numbers_only: 'يجب أن يحتوي على أرقام فقط',
|
||||||
|
must_contain_letters_only: 'يجب أن يحتوي على أحرف فقط',
|
||||||
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
|
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
|
||||||
verify_id: 'تحقق من الهوية',
|
verify_id: 'تحقق من الهوية',
|
||||||
identity_verification: 'التحقق من الهوية',
|
identity_verification: 'التحقق من الهوية',
|
||||||
@@ -1280,6 +1387,7 @@ const ar = {
|
|||||||
please_check_the_box_to_continue: 'الرجاء التحقق من الخانة للمتابعة.',
|
please_check_the_box_to_continue: 'الرجاء التحقق من الخانة للمتابعة.',
|
||||||
open_source_documents_software: 'برنامج وثائق مفتوح المصدر',
|
open_source_documents_software: 'برنامج وثائق مفتوح المصدر',
|
||||||
verified_phone_number: 'تحقق من رقم الهاتف',
|
verified_phone_number: 'تحقق من رقم الهاتف',
|
||||||
|
country_code: 'رمز الدولة',
|
||||||
use_international_format: 'استخدم الشكل الدولي: +1xxx',
|
use_international_format: 'استخدم الشكل الدولي: +1xxx',
|
||||||
six_digits_code: 'رمز مكون من 6 أرقام',
|
six_digits_code: 'رمز مكون من 6 أرقام',
|
||||||
change_phone_number: 'تغيير رقم الهاتف',
|
change_phone_number: 'تغيير رقم الهاتف',
|
||||||
@@ -1312,9 +1420,17 @@ const ko = {
|
|||||||
close: '닫기',
|
close: '닫기',
|
||||||
uploaded_files: '업로드된 파일',
|
uploaded_files: '업로드된 파일',
|
||||||
signature_drawing_area: '서명 그리기 영역. 마우스 또는 터치를 사용하여 서명을 그리세요.',
|
signature_drawing_area: '서명 그리기 영역. 마우스 또는 터치를 사용하여 서명을 그리세요.',
|
||||||
|
initials_drawing_area: '이니셜 그리기 영역. 마우스 또는 터치를 사용하여 이니셜을 그리세요.',
|
||||||
kba: 'KBA',
|
kba: 'KBA',
|
||||||
please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
|
please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
|
||||||
must_be_characters_length: '{number}자여야 합니다',
|
must_be_characters_length: '{number}자여야 합니다',
|
||||||
|
must_be_valid_ssn: '유효한 SSN이어야 합니다 (XXX-XX-XXXX)',
|
||||||
|
must_be_valid_ein: '유효한 EIN이어야 합니다 (XX-XXXXXXX)',
|
||||||
|
must_be_valid_email: '유효한 이메일 주소여야 합니다',
|
||||||
|
must_be_valid_url: '유효한 URL이어야 합니다 (http:// 또는 https://로 시작)',
|
||||||
|
must_be_valid_zip: '유효한 ZIP이어야 합니다 (XXXXX 또는 XXXXX-XXXX)',
|
||||||
|
must_contain_numbers_only: '숫자만 포함해야 합니다',
|
||||||
|
must_contain_letters_only: '문자만 포함해야 합니다',
|
||||||
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
|
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
|
||||||
verify_id: '아이디 확인',
|
verify_id: '아이디 확인',
|
||||||
identity_verification: '신원 확인',
|
identity_verification: '신원 확인',
|
||||||
@@ -1389,6 +1505,7 @@ const ko = {
|
|||||||
please_check_the_box_to_continue: '계속하려면 확인란을 선택하십시오.',
|
please_check_the_box_to_continue: '계속하려면 확인란을 선택하십시오.',
|
||||||
open_source_documents_software: '오픈 소스 문서 소프트웨어',
|
open_source_documents_software: '오픈 소스 문서 소프트웨어',
|
||||||
verified_phone_number: '전화번호 확인됨',
|
verified_phone_number: '전화번호 확인됨',
|
||||||
|
country_code: '국가 번호',
|
||||||
use_international_format: '국제 포맷 사용: +1xxx',
|
use_international_format: '국제 포맷 사용: +1xxx',
|
||||||
six_digits_code: '6자리 코드',
|
six_digits_code: '6자리 코드',
|
||||||
change_phone_number: '전화번호 변경',
|
change_phone_number: '전화번호 변경',
|
||||||
@@ -1421,9 +1538,17 @@ const ja = {
|
|||||||
close: '閉じる',
|
close: '閉じる',
|
||||||
uploaded_files: 'アップロードされたファイル',
|
uploaded_files: 'アップロードされたファイル',
|
||||||
signature_drawing_area: '署名描画エリア。マウスまたはタッチを使用して署名を描いてください。',
|
signature_drawing_area: '署名描画エリア。マウスまたはタッチを使用して署名を描いてください。',
|
||||||
|
initials_drawing_area: 'イニシャル描画エリア。マウスまたはタッチを使用してイニシャルを描いてください。',
|
||||||
kba: 'KBA',
|
kba: 'KBA',
|
||||||
please_upload_an_image_file: '画像ファイルをアップロードしてください',
|
please_upload_an_image_file: '画像ファイルをアップロードしてください',
|
||||||
must_be_characters_length: '{number}文字でなければなりません',
|
must_be_characters_length: '{number}文字でなければなりません',
|
||||||
|
must_be_valid_ssn: '有効なSSNである必要があります (XXX-XX-XXXX)',
|
||||||
|
must_be_valid_ein: '有効なEINである必要があります (XX-XXXXXXX)',
|
||||||
|
must_be_valid_email: '有効なメールアドレスである必要があります',
|
||||||
|
must_be_valid_url: '有効なURLである必要があります (http://またはhttps://で始まる)',
|
||||||
|
must_be_valid_zip: '有効なZIPである必要があります (XXXXX または XXXXX-XXXX)',
|
||||||
|
must_contain_numbers_only: '数字のみを含む必要があります',
|
||||||
|
must_contain_letters_only: '文字のみを含む必要があります',
|
||||||
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
|
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
|
||||||
verify_id: '本人確認',
|
verify_id: '本人確認',
|
||||||
identity_verification: '本人確認',
|
identity_verification: '本人確認',
|
||||||
@@ -1498,6 +1623,7 @@ const ja = {
|
|||||||
please_check_the_box_to_continue: '続行するにはボックスにチェックを入れてください。',
|
please_check_the_box_to_continue: '続行するにはボックスにチェックを入れてください。',
|
||||||
open_source_documents_software: 'オープンソースのドキュメントソフトウェア',
|
open_source_documents_software: 'オープンソースのドキュメントソフトウェア',
|
||||||
verified_phone_number: '電話番号を認証',
|
verified_phone_number: '電話番号を認証',
|
||||||
|
country_code: '国番号',
|
||||||
use_international_format: '国際形式を使用してください:+1xxx',
|
use_international_format: '国際形式を使用してください:+1xxx',
|
||||||
six_digits_code: '6桁のコード',
|
six_digits_code: '6桁のコード',
|
||||||
change_phone_number: '電話番号を変更',
|
change_phone_number: '電話番号を変更',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="btn btn-outline btn-sm reupload-button"
|
class="btn btn-outline btn-sm reupload-button"
|
||||||
@click.prevent="remove"
|
@click.prevent="remove"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -141,6 +141,7 @@
|
|||||||
<img
|
<img
|
||||||
v-if="modelValue || computedPreviousValue"
|
v-if="modelValue || computedPreviousValue"
|
||||||
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
|
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
|
||||||
|
:alt="field.name || t('initials')"
|
||||||
class="mx-auto bg-white border border-base-300 rounded max-h-44"
|
class="mx-auto bg-white border border-base-300 rounded max-h-44"
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -156,6 +157,8 @@
|
|||||||
<canvas
|
<canvas
|
||||||
v-show="!modelValue && !computedPreviousValue"
|
v-show="!modelValue && !computedPreviousValue"
|
||||||
ref="canvas"
|
ref="canvas"
|
||||||
|
role="img"
|
||||||
|
:aria-label="t('initials_drawing_area')"
|
||||||
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
|
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,6 +168,7 @@
|
|||||||
ref="textInput"
|
ref="textInput"
|
||||||
class="base-input !text-2xl w-full mt-6 text-center"
|
class="base-input !text-2xl w-full mt-6 text-center"
|
||||||
:required="field.required && !isInitialsStarted"
|
:required="field.required && !isInitialsStarted"
|
||||||
|
:aria-label="field.name || t('initials')"
|
||||||
:placeholder="`${t('type_initial_here')}...`"
|
:placeholder="`${t('type_initial_here')}...`"
|
||||||
type="text"
|
type="text"
|
||||||
@focus="$emit('focus')"
|
@focus="$emit('focus')"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
:placeholder="t('email')"
|
:placeholder="t('email')"
|
||||||
type="email"
|
type="email"
|
||||||
:required="submitters.includes(submitter)"
|
:required="submitters.includes(submitter)"
|
||||||
autofocus="true"
|
:autofocus="index === 0"
|
||||||
name="submission[submitters][][email]"
|
name="submission[submitters][][email]"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="base-button w-full flex justify-center submit-form-button"
|
class="base-button w-full flex justify-center submit-form-button"
|
||||||
@click="restartKba"
|
@click="restartKba"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<label
|
<label
|
||||||
v-if="showFieldNames && (field.name || field.title)"
|
v-if="showFieldNames && (field.name || field.title)"
|
||||||
:for="field.uuid"
|
:id="field.uuid + '-group-label'"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
|
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
|
||||||
:class="{ 'mb-2': !field.description }"
|
:class="{ 'mb-2': !field.description }"
|
||||||
@@ -44,6 +44,8 @@
|
|||||||
<div
|
<div
|
||||||
class="space-y-3.5 mx-auto"
|
class="space-y-3.5 mx-auto"
|
||||||
:class="{ hidden: !showOptions }"
|
:class="{ hidden: !showOptions }"
|
||||||
|
role="group"
|
||||||
|
:aria-labelledby="showFieldNames && (field.name || field.title) ? field.uuid + '-group-label' : undefined"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(option, index) in field.options"
|
v-for="(option, index) in field.options"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<button
|
<button
|
||||||
v-if="sessionId"
|
v-if="sessionId"
|
||||||
|
type="button"
|
||||||
disabled
|
disabled
|
||||||
class="base-button w-full modal-save-button"
|
class="base-button w-full modal-save-button"
|
||||||
>
|
>
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
:id="field.uuid"
|
:id="field.uuid"
|
||||||
|
type="button"
|
||||||
class="btn bg-[#7B73FF] text-white hover:bg-[#0A2540] text-lg w-full"
|
class="btn bg-[#7B73FF] text-white hover:bg-[#0A2540] text-lg w-full"
|
||||||
:class="{ disabled: isCreatingCheckout }"
|
:class="{ disabled: isCreatingCheckout }"
|
||||||
:disabled="isCreatingCheckout"
|
:disabled="isCreatingCheckout"
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
<select
|
<select
|
||||||
id="country_code_select"
|
id="country_code_select"
|
||||||
class="absolute top-0 bottom-0 right-0 left-0 opacity-0 w-full h-full cursor-pointer"
|
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"
|
:disabled="!!defaultValue"
|
||||||
@change="onCountrySelect(countries.find((country) => country.flag === $event.target.value))"
|
@change="onCountrySelect(countries.find((country) => country.flag === $event.target.value))"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
id="type_text_button"
|
id="type_text_button"
|
||||||
|
ref="typeTextButton"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('draw_signature')"
|
:aria-label="t('draw_signature')"
|
||||||
class="btn btn-outline btn-sm font-medium type-text-button"
|
class="btn btn-outline btn-sm font-medium type-text-button"
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
id="type_text_button"
|
id="type_text_button"
|
||||||
|
ref="typeTextButton"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('type_text')"
|
:aria-label="t('type_text')"
|
||||||
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap type-text-button"
|
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap type-text-button"
|
||||||
@@ -71,7 +73,7 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="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"
|
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"
|
||||||
@click="$refs.takePhotoInput.click()"
|
@click="$refs.takePhotoInput.click()"
|
||||||
>
|
>
|
||||||
@@ -199,7 +201,7 @@
|
|||||||
<canvas
|
<canvas
|
||||||
v-show="!modelValue && !computedPreviousValue"
|
v-show="!modelValue && !computedPreviousValue"
|
||||||
ref="canvas"
|
ref="canvas"
|
||||||
role="application"
|
role="img"
|
||||||
:aria-label="t('signature_drawing_area')"
|
:aria-label="t('signature_drawing_area')"
|
||||||
style="padding: 1px; 0"
|
style="padding: 1px; 0"
|
||||||
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
|
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
|
||||||
@@ -228,6 +230,8 @@
|
|||||||
>
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref="qrCanvas"
|
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"
|
class="h-full"
|
||||||
width="132"
|
width="132"
|
||||||
height="132"
|
height="132"
|
||||||
@@ -242,6 +246,7 @@
|
|||||||
ref="textInput"
|
ref="textInput"
|
||||||
class="base-input !text-2xl w-full mt-6"
|
class="base-input !text-2xl w-full mt-6"
|
||||||
:required="field.required && !isSignatureStarted"
|
:required="field.required && !isSignatureStarted"
|
||||||
|
:aria-label="field.name || t('signature')"
|
||||||
:placeholder="`${t('type_signature_here')}...`"
|
:placeholder="`${t('type_signature_here')}...`"
|
||||||
type="text"
|
type="text"
|
||||||
@input="updateWrittenSignature"
|
@input="updateWrittenSignature"
|
||||||
@@ -251,6 +256,7 @@
|
|||||||
class="select base-input !text-2xl w-full mt-6 text-center"
|
class="select base-input !text-2xl w-full mt-6 text-center"
|
||||||
:class="{ 'text-gray-300': !reason }"
|
:class="{ 'text-gray-300': !reason }"
|
||||||
required
|
required
|
||||||
|
:aria-label="t('select_a_reason')"
|
||||||
:name="`values[${field.preferences.reason_field_uuid}]`"
|
:name="`values[${field.preferences.reason_field_uuid}]`"
|
||||||
@change="$event.target.value === 'other' ? [reason = '', isOtherReason = true] : $emit('update:reason', $event.target.value)"
|
@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"
|
class="base-input !text-2xl w-full mt-6"
|
||||||
required
|
required
|
||||||
:name="`values[${field.preferences.reason_field_uuid}]`"
|
:name="`values[${field.preferences.reason_field_uuid}]`"
|
||||||
|
:aria-label="t('select_a_reason')"
|
||||||
:placeholder="t('type_here_')"
|
:placeholder="t('type_here_')"
|
||||||
:value="reason"
|
:value="reason"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -716,10 +723,12 @@ export default {
|
|||||||
this.clear()
|
this.clear()
|
||||||
this.isTextSignature = !this.isTextSignature
|
this.isTextSignature = !this.isTextSignature
|
||||||
|
|
||||||
if (this.isTextSignature) {
|
this.$nextTick(() => {
|
||||||
this.$nextTick(() => {
|
if (this.isTextSignature) {
|
||||||
if (this.$refs.textInput) {
|
if (this.$refs.textInput) {
|
||||||
if (!this.submitter.name) {
|
if (this.submitter.name) {
|
||||||
|
this.$refs.typeTextButton?.focus()
|
||||||
|
} else {
|
||||||
this.$refs.textInput.focus()
|
this.$refs.textInput.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -727,8 +736,10 @@ export default {
|
|||||||
|
|
||||||
this.$emit('start')
|
this.$emit('start')
|
||||||
}
|
}
|
||||||
})
|
} else {
|
||||||
}
|
this.$refs.typeTextButton?.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
async initTypedSignature () {
|
async initTypedSignature () {
|
||||||
if (this.signatureText) {
|
if (this.signatureText) {
|
||||||
|
|||||||
@@ -41,12 +41,11 @@
|
|||||||
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
|
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:pattern="field.validation?.pattern"
|
:pattern="field.validation?.pattern"
|
||||||
|
:title="validationMessage"
|
||||||
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
|
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
|
||||||
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
|
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||||
type="text"
|
type="text"
|
||||||
:name="`values[${field.uuid}]`"
|
:name="`values[${field.uuid}]`"
|
||||||
@invalid="validationMessage ? $event.target.setCustomValidity(validationMessage) : ''"
|
|
||||||
@input="validationMessage ? $event.target.setCustomValidity('') : ''"
|
|
||||||
@focus="$emit('focus')"
|
@focus="$emit('focus')"
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -138,6 +137,17 @@ export default {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
patternMessageKeys () {
|
||||||
|
return {
|
||||||
|
'^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'must_be_valid_ssn',
|
||||||
|
'^[0-9]{2}-[0-9]{7}$': 'must_be_valid_ein',
|
||||||
|
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'must_be_valid_email',
|
||||||
|
'^https?://.*': 'must_be_valid_url',
|
||||||
|
'^[0-9]{5}(?:-[0-9]{4})?$': 'must_be_valid_zip',
|
||||||
|
'^[0-9]+$': 'must_contain_numbers_only',
|
||||||
|
'^[a-zA-Z]+$': 'must_contain_letters_only'
|
||||||
|
}
|
||||||
|
},
|
||||||
validationMessage () {
|
validationMessage () {
|
||||||
if (this.field.validation?.message) {
|
if (this.field.validation?.message) {
|
||||||
return this.field.validation.message
|
return this.field.validation.message
|
||||||
@@ -149,6 +159,10 @@ export default {
|
|||||||
.join('-')
|
.join('-')
|
||||||
|
|
||||||
return this.t('must_be_characters_length').replace('{number}', number)
|
return this.t('must_be_characters_length').replace('{number}', number)
|
||||||
|
} else if (this.field.validation?.pattern) {
|
||||||
|
const key = this.patternMessageKeys[this.field.validation.pattern]
|
||||||
|
|
||||||
|
return key ? this.t(key) : ''
|
||||||
} else {
|
} else {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,7 +197,8 @@
|
|||||||
v-else-if="!isCheckboxInput"
|
v-else-if="!isCheckboxInput"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
class="max-h-10 opacity-50"
|
class="opacity-50"
|
||||||
|
:style="{ maxHeight: isInlineSize ? '4.4cqmin' : '40px' }"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -620,6 +621,10 @@ export default {
|
|||||||
return new Intl.NumberFormat('de-DE').format(number)
|
return new Intl.NumberFormat('de-DE').format(number)
|
||||||
} else if (format === 'space') {
|
} else if (format === 'space') {
|
||||||
return new Intl.NumberFormat('fr-FR').format(number)
|
return new Intl.NumberFormat('fr-FR').format(number)
|
||||||
|
} else if (format === 'percent') {
|
||||||
|
return `${number}%`
|
||||||
|
} else if (format === 'percent_space') {
|
||||||
|
return `${String(number).replace('.', ',')} %`
|
||||||
} else {
|
} else {
|
||||||
return number
|
return number
|
||||||
}
|
}
|
||||||
@@ -716,19 +721,15 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
drag (e) {
|
|
||||||
if (e.target.id === 'mask' && this.editable) {
|
|
||||||
this.isDragged = true
|
|
||||||
|
|
||||||
this.area.x = (e.offsetX - this.dragFrom.x) / e.target.clientWidth
|
|
||||||
this.area.y = (e.offsetY - this.dragFrom.y) / e.target.clientHeight
|
|
||||||
}
|
|
||||||
},
|
|
||||||
startTouchDrag (e) {
|
startTouchDrag (e) {
|
||||||
if (e.target !== this.$refs.touchTarget && e.target !== this.$refs.touchValueTarget) {
|
if (e.target !== this.$refs.touchTarget && e.target !== this.$refs.touchValueTarget) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.inputMode && (this.isValueInput || this.isCheckboxInput || this.isSelectInput)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
document.activeElement?.blur()
|
document.activeElement?.blur()
|
||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -769,6 +770,8 @@ export default {
|
|||||||
|
|
||||||
this.maybeChangeAreaPage(this.area)
|
this.maybeChangeAreaPage(this.area)
|
||||||
|
|
||||||
|
this.clampAreaBounds(this.area)
|
||||||
|
|
||||||
if (this.isDragged) {
|
if (this.isDragged) {
|
||||||
this.save()
|
this.save()
|
||||||
}
|
}
|
||||||
@@ -836,6 +839,8 @@ export default {
|
|||||||
|
|
||||||
this.maybeChangeAreaPage(this.area)
|
this.maybeChangeAreaPage(this.area)
|
||||||
|
|
||||||
|
this.clampAreaBounds(this.area)
|
||||||
|
|
||||||
if (this.isMoved) {
|
if (this.isMoved) {
|
||||||
this.save()
|
this.save()
|
||||||
}
|
}
|
||||||
@@ -860,18 +865,6 @@ export default {
|
|||||||
area.y = area.y - 1 - (16.0 / this.$parent.$refs.mask.previousSibling.offsetHeight)
|
area.y = area.y - 1 - (16.0 / this.$parent.$refs.mask.previousSibling.offsetHeight)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
stopDrag () {
|
|
||||||
this.$el.getRootNode().removeEventListener('mousemove', this.drag)
|
|
||||||
this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag)
|
|
||||||
|
|
||||||
if (this.isDragged) {
|
|
||||||
this.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isDragged = false
|
|
||||||
|
|
||||||
this.$emit('stop-drag')
|
|
||||||
},
|
|
||||||
startResize () {
|
startResize () {
|
||||||
if (!this.selectedAreasRef.value.includes(this.area)) {
|
if (!this.selectedAreasRef.value.includes(this.area)) {
|
||||||
this.selectedAreasRef.value = [this.area]
|
this.selectedAreasRef.value = [this.area]
|
||||||
@@ -886,6 +879,8 @@ export default {
|
|||||||
this.$el.getRootNode().removeEventListener('mousemove', this.resize)
|
this.$el.getRootNode().removeEventListener('mousemove', this.resize)
|
||||||
this.$el.getRootNode().removeEventListener('mouseup', this.stopResize)
|
this.$el.getRootNode().removeEventListener('mouseup', this.stopResize)
|
||||||
|
|
||||||
|
this.clampAreaBounds(this.area)
|
||||||
|
|
||||||
this.$emit('stop-resize')
|
this.$emit('stop-resize')
|
||||||
|
|
||||||
this.save()
|
this.save()
|
||||||
@@ -924,9 +919,17 @@ export default {
|
|||||||
this.$el.getRootNode().removeEventListener('touchmove', this.touchResize)
|
this.$el.getRootNode().removeEventListener('touchmove', this.touchResize)
|
||||||
this.$el.getRootNode().removeEventListener('touchend', this.stopTouchResize)
|
this.$el.getRootNode().removeEventListener('touchend', this.stopTouchResize)
|
||||||
|
|
||||||
|
this.clampAreaBounds(this.area)
|
||||||
|
|
||||||
this.$emit('stop-resize')
|
this.$emit('stop-resize')
|
||||||
|
|
||||||
this.save()
|
this.save()
|
||||||
|
},
|
||||||
|
clampAreaBounds (area) {
|
||||||
|
area.x = Math.min(Math.max(area.x, 0), 1)
|
||||||
|
area.y = Math.min(Math.max(area.y, 0), 1)
|
||||||
|
area.w = Math.min(Math.max(area.w, 0), 1)
|
||||||
|
area.h = Math.min(Math.max(area.h, 0), 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,6 +309,8 @@
|
|||||||
:data-document-uuid="item.attachment_uuid"
|
:data-document-uuid="item.attachment_uuid"
|
||||||
:accept-file-types="acceptFileTypes"
|
:accept-file-types="acceptFileTypes"
|
||||||
:with-replace-button="withUploadButton"
|
:with-replace-button="withUploadButton"
|
||||||
|
:with-google-drive="withGoogleDrive"
|
||||||
|
:authenticity-token="authenticityToken"
|
||||||
:editable="editable"
|
:editable="editable"
|
||||||
:dynamic-documents="dynamicDocuments"
|
:dynamic-documents="dynamicDocuments"
|
||||||
:with-dynamic-documents="withDynamicDocuments"
|
:with-dynamic-documents="withDynamicDocuments"
|
||||||
@@ -318,6 +320,7 @@
|
|||||||
@replace="onDocumentReplace"
|
@replace="onDocumentReplace"
|
||||||
@up="moveDocument(item, -1)"
|
@up="moveDocument(item, -1)"
|
||||||
@reorder="reorderFields"
|
@reorder="reorderFields"
|
||||||
|
@edit="editModalDocumentUuid = item.attachment_uuid"
|
||||||
@down="moveDocument(item, 1)"
|
@down="moveDocument(item, 1)"
|
||||||
@change="save"
|
@change="save"
|
||||||
/>
|
/>
|
||||||
@@ -355,12 +358,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="pages_container"
|
id="pages_container"
|
||||||
class="w-full overflow-x-hidden mt-0.5 pt-0.5"
|
ref="pagesContainer"
|
||||||
:class="isMobile ? 'overflow-y-auto' : 'overflow-y-hidden md:overflow-y-auto'"
|
class="w-full mt-0.5 pt-0.5"
|
||||||
|
:class="[
|
||||||
|
isMobile ? 'overflow-y-auto' : 'overflow-y-hidden md:overflow-y-auto',
|
||||||
|
zoomLevel > 1 ? 'overflow-x-auto' : 'overflow-x-hidden'
|
||||||
|
]"
|
||||||
|
@wheel="onPagesWheel"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref="documents"
|
ref="documents"
|
||||||
class="pr-3.5 pl-0.5"
|
class="pr-3.5 pl-0.5"
|
||||||
|
:style="zoomLevel > 1 ? { width: `${zoomLevel * 100}%` } : null"
|
||||||
>
|
>
|
||||||
<template v-if="!sortedDocuments.length && (withUploadButton || withAddPageButton)">
|
<template v-if="!sortedDocuments.length && (withUploadButton || withAddPageButton)">
|
||||||
<Dropzone
|
<Dropzone
|
||||||
@@ -451,6 +460,8 @@
|
|||||||
:with-arrows="template.schema.length > 1"
|
:with-arrows="template.schema.length > 1"
|
||||||
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
|
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
|
||||||
:with-replace-button="withUploadButton"
|
:with-replace-button="withUploadButton"
|
||||||
|
:with-google-drive="withGoogleDrive"
|
||||||
|
:authenticity-token="authenticityToken"
|
||||||
:accept-file-types="acceptFileTypes"
|
:accept-file-types="acceptFileTypes"
|
||||||
:document="document"
|
:document="document"
|
||||||
:template="template"
|
:template="template"
|
||||||
@@ -633,6 +644,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-200 ease-out"
|
||||||
|
enter-from-class="translate-y-4 opacity-0"
|
||||||
|
enter-to-class="translate-y-0 opacity-100"
|
||||||
|
leave-active-class="transition-all duration-200 ease-in"
|
||||||
|
leave-from-class="translate-y-0 opacity-100"
|
||||||
|
leave-to-class="translate-y-4 opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="zoomLevel > 1"
|
||||||
|
class="sticky bottom-0 z-40 pointer-events-none"
|
||||||
|
>
|
||||||
|
<div class="absolute left-0 right-0 bottom-4 flex justify-center">
|
||||||
|
<div class="join shadow pointer-events-auto">
|
||||||
|
<span class="join-item bg-base-content text-white pl-2 pr-2.5 h-9 items-center text-sm font-medium cursor-default w-16 flex justify-end">
|
||||||
|
<span>
|
||||||
|
{{ Math.round(zoomLevel * 100) }}%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="join-item bg-base-content text-white h-9 pl-2 pr-3 inline-flex items-center justify-center cursor-pointer hover:opacity-90 border-l border-white/20"
|
||||||
|
@click="zoomLevel = 1"
|
||||||
|
>
|
||||||
|
<IconX class="w-4 h-4 stroke-2" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
<div
|
<div
|
||||||
id="docuseal_modal_container"
|
id="docuseal_modal_container"
|
||||||
class="modal-container"
|
class="modal-container"
|
||||||
@@ -645,6 +686,17 @@
|
|||||||
@close="isRevisionsModalOpen = false"
|
@close="isRevisionsModalOpen = false"
|
||||||
@apply="onRevisionApply"
|
@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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -664,7 +716,8 @@ import DocumentControls from './controls'
|
|||||||
import MobileFields from './mobile_fields'
|
import MobileFields from './mobile_fields'
|
||||||
import FieldSubmitter from './field_submitter'
|
import FieldSubmitter from './field_submitter'
|
||||||
import RevisionsModal from './revisions_modal'
|
import RevisionsModal from './revisions_modal'
|
||||||
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory } from '@tabler/icons-vue'
|
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 { v4 } from 'uuid'
|
||||||
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
|
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
|
||||||
import * as i18n from './i18n'
|
import * as i18n from './i18n'
|
||||||
@@ -706,7 +759,9 @@ export default {
|
|||||||
IconEye,
|
IconEye,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
IconDeviceFloppy,
|
IconDeviceFloppy,
|
||||||
RevisionsModal
|
IconX,
|
||||||
|
RevisionsModal,
|
||||||
|
DocumentsEditorModal
|
||||||
},
|
},
|
||||||
provide () {
|
provide () {
|
||||||
return {
|
return {
|
||||||
@@ -1062,8 +1117,10 @@ export default {
|
|||||||
isDragFile: false,
|
isDragFile: false,
|
||||||
isMathLoaded: false,
|
isMathLoaded: false,
|
||||||
isRevisionsModalOpen: false,
|
isRevisionsModalOpen: false,
|
||||||
|
editModalDocumentUuid: null,
|
||||||
revisions: [],
|
revisions: [],
|
||||||
beforeRevisionSnapshot: null
|
beforeRevisionSnapshot: null,
|
||||||
|
zoomLevel: 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -1636,7 +1693,7 @@ export default {
|
|||||||
|
|
||||||
ref.x = e.clientX - ref.offsetX
|
ref.x = e.clientX - ref.offsetX
|
||||||
ref.y = e.clientY - ref.offsetY
|
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
|
this.isDragFile = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2018,6 +2075,30 @@ export default {
|
|||||||
|
|
||||||
this.isBreakpointLg = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth < breakpointLg
|
this.isBreakpointLg = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth < breakpointLg
|
||||||
},
|
},
|
||||||
|
onPagesWheel (event) {
|
||||||
|
if (!event.ctrlKey && !event.metaKey) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const oldZoom = this.zoomLevel
|
||||||
|
const nextZoom = Math.max(1, Math.min(3, oldZoom - event.deltaY * 0.006))
|
||||||
|
|
||||||
|
if (nextZoom === oldZoom) return
|
||||||
|
|
||||||
|
const rect = this.$refs.pagesContainer.getBoundingClientRect()
|
||||||
|
const cursorX = event.clientX - rect.left
|
||||||
|
const cursorY = event.clientY - rect.top
|
||||||
|
const ratio = nextZoom / oldZoom
|
||||||
|
const nextScrollLeft = (this.$refs.pagesContainer.scrollLeft + cursorX) * ratio - cursorX
|
||||||
|
const nextScrollTop = (this.$refs.pagesContainer.scrollTop + cursorY) * ratio - cursorY
|
||||||
|
|
||||||
|
this.zoomLevel = nextZoom
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$refs.pagesContainer.scrollLeft = nextScrollLeft
|
||||||
|
this.$refs.pagesContainer.scrollTop = nextScrollTop
|
||||||
|
})
|
||||||
|
},
|
||||||
setDocumentRefs (el) {
|
setDocumentRefs (el) {
|
||||||
if (el) {
|
if (el) {
|
||||||
this.documentRefs.push(el)
|
this.documentRefs.push(el)
|
||||||
@@ -2055,6 +2136,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onKeyDown (event) {
|
onKeyDown (event) {
|
||||||
|
if (this.editModalDocumentUuid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === 'Tab' && document.activeElement === document.body) {
|
if (event.key === 'Tab' && document.activeElement === document.body) {
|
||||||
event.stopImmediatePropagation()
|
event.stopImmediatePropagation()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -2512,6 +2597,11 @@ export default {
|
|||||||
|
|
||||||
this.selectedAreasRef.value = [area]
|
this.selectedAreasRef.value = [area]
|
||||||
|
|
||||||
|
area.x = Math.min(Math.max(area.x, 0), 1)
|
||||||
|
area.y = Math.min(Math.max(area.y, 0), 1)
|
||||||
|
area.w = Math.min(Math.max(area.w, 0), 1)
|
||||||
|
area.h = Math.min(Math.max(area.h, 0), 1)
|
||||||
|
|
||||||
this.save()
|
this.save()
|
||||||
} else {
|
} else {
|
||||||
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
|
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
|
||||||
@@ -3068,6 +3158,22 @@ export default {
|
|||||||
onDocumentsReplaceAndTemplateClone (template) {
|
onDocumentsReplaceAndTemplateClone (template) {
|
||||||
window.Turbo.visit(`/templates/${template.id}/edit`)
|
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) {
|
moveDocument (item, direction) {
|
||||||
const currentIndex = this.template.schema.indexOf(item)
|
const currentIndex = this.template.schema.indexOf(item)
|
||||||
|
|
||||||
@@ -3100,15 +3206,6 @@ export default {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
alert(this.t('please_draw_fields_to_prepare_the_document'))
|
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 () {
|
onSaveClick () {
|
||||||
@@ -3125,28 +3222,25 @@ export default {
|
|||||||
if (!this.template.fields.length) {
|
if (!this.template.fields.length) {
|
||||||
alert(this.t('please_draw_fields_to_prepare_the_document'))
|
alert(this.t('please_draw_fields_to_prepare_the_document'))
|
||||||
} else {
|
} else {
|
||||||
const submitterWithoutFields =
|
this.isSaving = true
|
||||||
this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid))
|
|
||||||
|
|
||||||
if (submitterWithoutFields) {
|
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
|
||||||
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)
|
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, revision: this.withRevisions }), ...dynamicDocumentSaves]).then(() => {
|
window.Turbo.visit(`/templates/${this.template.id}`)
|
||||||
window.Turbo.visit(`/templates/${this.template.id}`)
|
}).finally(() => {
|
||||||
}).finally(() => {
|
this.isSaving = false
|
||||||
this.isSaving = false
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scrollToArea (area) {
|
scrollToArea (area) {
|
||||||
@@ -3372,7 +3466,7 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
save ({ force = false, revision = false } = {}) {
|
save ({ force = false } = {}) {
|
||||||
this.pendingFieldAttachmentUuids = []
|
this.pendingFieldAttachmentUuids = []
|
||||||
|
|
||||||
if (this.beforeRevisionSnapshot) {
|
if (this.beforeRevisionSnapshot) {
|
||||||
@@ -3404,8 +3498,7 @@ export default {
|
|||||||
submitters: this.template.submitters,
|
submitters: this.template.submitters,
|
||||||
fields: this.template.fields,
|
fields: this.template.fields,
|
||||||
variables_schema: this.template.variables_schema
|
variables_schema: this.template.variables_schema
|
||||||
},
|
}
|
||||||
...(revision ? { revision: true } : {})
|
|
||||||
}),
|
}),
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
@@ -3414,6 +3507,12 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
captureRevision () {
|
||||||
|
return this.baseFetch(`/templates/${this.template.id}/versions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
},
|
||||||
onDynamicDocumentUpdate () {
|
onDynamicDocumentUpdate () {
|
||||||
this.rebuildVariablesSchema()
|
this.rebuildVariablesSchema()
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
v-if="withReplaceButton"
|
v-if="withReplaceButton"
|
||||||
:template-id="template.id"
|
:template-id="template.id"
|
||||||
:accept-file-types="acceptFileTypes"
|
:accept-file-types="acceptFileTypes"
|
||||||
|
:authenticity-token="authenticityToken"
|
||||||
|
:with-google-drive="withGoogleDrive"
|
||||||
|
:google-drive-file-id="item.google_drive_file_id"
|
||||||
@click.stop
|
@click.stop
|
||||||
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
|
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
|
||||||
/>
|
/>
|
||||||
@@ -73,6 +76,16 @@ export default {
|
|||||||
required: true,
|
required: true,
|
||||||
default: true
|
default: true
|
||||||
},
|
},
|
||||||
|
withGoogleDrive: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
authenticityToken: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
withArrows: {
|
withArrows: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
name="files[]"
|
name="files[]"
|
||||||
:accept="acceptFileTypes"
|
:accept="acceptFileTypes"
|
||||||
multiple
|
multiple
|
||||||
@change="upload"
|
@change="upload()"
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ img.ProseMirror-separator {
|
|||||||
}
|
}
|
||||||
dynamic-variable {
|
dynamic-variable {
|
||||||
background-color: #fef3c7;
|
background-color: #fef3c7;
|
||||||
|
word-break: break-all;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}`)
|
}`)
|
||||||
|
|
||||||
function collectDomAttrs (dom) {
|
function collectDomAttrs (dom) {
|
||||||
@@ -136,11 +138,12 @@ function collectSpanDomAttrs (dom) {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBlockNode (name, tag, content) {
|
function createBlockNode (name, tag, content, extra = {}) {
|
||||||
return Node.create({
|
return Node.create({
|
||||||
name,
|
name,
|
||||||
group: 'block',
|
group: 'block',
|
||||||
content: content || 'block+',
|
content: content || 'block+',
|
||||||
|
...extra,
|
||||||
addAttributes () {
|
addAttributes () {
|
||||||
return {
|
return {
|
||||||
htmlAttrs: { default: {} }
|
htmlAttrs: { default: {} }
|
||||||
@@ -194,9 +197,9 @@ const CustomHeading = Node.create({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const SectionNode = createBlockNode('section', 'section')
|
const SectionNode = createBlockNode('section', 'section')
|
||||||
const ArticleNode = createBlockNode('article', 'article')
|
const ArticleNode = createBlockNode('article', 'article', null, { isolating: true })
|
||||||
const HeaderNode = createBlockNode('header', 'header')
|
const HeaderNode = createBlockNode('header', 'header', null, { isolating: true })
|
||||||
const FooterNode = createBlockNode('footer', 'footer')
|
const FooterNode = createBlockNode('footer', 'footer', null, { isolating: true })
|
||||||
const DivNode = createBlockNode('div', 'div')
|
const DivNode = createBlockNode('div', 'div')
|
||||||
const BlockquoteNode = createBlockNode('blockquote', 'blockquote')
|
const BlockquoteNode = createBlockNode('blockquote', 'blockquote')
|
||||||
const PreNode = createBlockNode('pre', 'pre')
|
const PreNode = createBlockNode('pre', 'pre')
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export default {
|
|||||||
FieldType,
|
FieldType,
|
||||||
IconSettings
|
IconSettings
|
||||||
},
|
},
|
||||||
inject: ['t', 'save', 'backgroundColor', 'dateFormats'],
|
inject: ['t', 'save', 'backgroundColor', 'dateFormats', 'locale'],
|
||||||
provide () {
|
provide () {
|
||||||
return {
|
return {
|
||||||
fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select']
|
fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select']
|
||||||
@@ -308,15 +308,18 @@ export default {
|
|||||||
return { text: 'string', number: 'number', date: 'date', checkbox: 'boolean', radio: 'string', select: 'string' }
|
return { text: 'string', number: 'number', date: 'date', checkbox: 'boolean', radio: 'string', select: 'string' }
|
||||||
},
|
},
|
||||||
numberFormats () {
|
numberFormats () {
|
||||||
return [
|
const formats = ['none', 'usd', 'eur', 'gbp', 'comma', 'dot', 'space']
|
||||||
'none',
|
const spaceLocales = ['fr-FR', 'es-ES', 'pt-PT', 'de-DE', 'it-IT', 'nl-NL']
|
||||||
'usd',
|
|
||||||
'eur',
|
formats.push(spaceLocales.includes(this.locale) ? 'percent_space' : 'percent')
|
||||||
'gbp',
|
|
||||||
'comma',
|
const selectedFormat = this.schema.format
|
||||||
'dot',
|
|
||||||
'space'
|
if (selectedFormat && !formats.includes(selectedFormat)) {
|
||||||
]
|
formats.push(selectedFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
return formats
|
||||||
},
|
},
|
||||||
availableDateFormats () {
|
availableDateFormats () {
|
||||||
const formats = this.dateFormats.length
|
const formats = this.dateFormats.length
|
||||||
@@ -397,6 +400,10 @@ export default {
|
|||||||
return new Intl.NumberFormat('de-DE').format(number)
|
return new Intl.NumberFormat('de-DE').format(number)
|
||||||
} else if (format === 'space') {
|
} else if (format === 'space') {
|
||||||
return new Intl.NumberFormat('fr-FR').format(number)
|
return new Intl.NumberFormat('fr-FR').format(number)
|
||||||
|
} else if (format === 'percent') {
|
||||||
|
return `${number}%`
|
||||||
|
} else if (format === 'percent_space') {
|
||||||
|
return `${String(number).replace('.', ',')} %`
|
||||||
} else {
|
} else {
|
||||||
return number
|
return number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -514,7 +514,7 @@ export default {
|
|||||||
ContextSubmenu,
|
ContextSubmenu,
|
||||||
ContextModal
|
ContextModal
|
||||||
},
|
},
|
||||||
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats'],
|
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats', 'locale'],
|
||||||
props: {
|
props: {
|
||||||
contextMenu: {
|
contextMenu: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -600,7 +600,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
showFont () {
|
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 () {
|
showDescription () {
|
||||||
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)
|
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)
|
||||||
|
|||||||
@@ -452,7 +452,7 @@
|
|||||||
class="pb-0.5 mt-0.5"
|
class="pb-0.5 mt-0.5"
|
||||||
>
|
>
|
||||||
<li
|
<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"
|
class="field-settings-font"
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
@@ -610,7 +610,7 @@ export default {
|
|||||||
IconTypography,
|
IconTypography,
|
||||||
IconX
|
IconX
|
||||||
},
|
},
|
||||||
inject: ['template', 't', 'dateFormats'],
|
inject: ['template', 't', 'dateFormats', 'locale'],
|
||||||
props: {
|
props: {
|
||||||
field: {
|
field: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -691,15 +691,18 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
numberFormats () {
|
numberFormats () {
|
||||||
return [
|
const formats = ['none', 'usd', 'eur', 'gbp', 'comma', 'dot', 'space']
|
||||||
'none',
|
const spaceLocales = ['fr-FR', 'es-ES', 'pt-PT', 'de-DE', 'it-IT', 'nl-NL']
|
||||||
'usd',
|
|
||||||
'eur',
|
formats.push(spaceLocales.includes(this.locale) ? 'percent_space' : 'percent')
|
||||||
'gbp',
|
|
||||||
'comma',
|
const selectedFormat = this.field.preferences?.format
|
||||||
'dot',
|
|
||||||
'space'
|
if (selectedFormat && !formats.includes(selectedFormat)) {
|
||||||
]
|
formats.push(selectedFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
return formats
|
||||||
},
|
},
|
||||||
availableDateFormats () {
|
availableDateFormats () {
|
||||||
const formats = this.dateFormats.length
|
const formats = this.dateFormats.length
|
||||||
@@ -824,6 +827,11 @@ export default {
|
|||||||
return new Intl.NumberFormat('de-DE').format(number)
|
return new Intl.NumberFormat('de-DE').format(number)
|
||||||
} else if (format === 'space') {
|
} else if (format === 'space') {
|
||||||
return new Intl.NumberFormat('fr-FR').format(number)
|
return new Intl.NumberFormat('fr-FR').format(number)
|
||||||
|
} else if (format === 'percent') {
|
||||||
|
return `${number}%`
|
||||||
|
} else if (format === 'percent_space') {
|
||||||
|
// eslint-disable-next-line no-irregular-whitespace
|
||||||
|
return `${String(number).replace('.', ',')} %`
|
||||||
} else {
|
} else {
|
||||||
return number
|
return number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport :to="modalContainerEl">
|
||||||
|
<div
|
||||||
|
class="modal modal-open items-start !animate-none overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute top-0 bottom-0 right-0 left-0"
|
||||||
|
@click.prevent="$emit('close')"
|
||||||
|
/>
|
||||||
|
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
|
||||||
|
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||||
|
<span class="modal-title">
|
||||||
|
Google Drive
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="text-xl modal-close-button"
|
||||||
|
@click.prevent="$emit('close')"
|
||||||
|
>×</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<form
|
||||||
|
v-if="showOauthButton"
|
||||||
|
method="post"
|
||||||
|
:action="oauthPath"
|
||||||
|
@submit="isConnectClicked = true"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="authenticity_token"
|
||||||
|
:value="authenticityToken"
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn bg-white btn-outline w-full text-base font-medium mt-4"
|
||||||
|
data-turbo="false"
|
||||||
|
type="submit"
|
||||||
|
:disabled="isConnectClicked"
|
||||||
|
>
|
||||||
|
<span v-if="isConnectClicked">
|
||||||
|
<span class="flex items-center justify-center space-x-2">
|
||||||
|
<IconInnerShadowTop class="animate-spin" />
|
||||||
|
<span>{{ t('submitting') }}...</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<span class="flex items-center justify-center space-x-2">
|
||||||
|
<IconBrandGoogleDrive />
|
||||||
|
<span>{{ t('connect_google_drive') }}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
class="border border-base-300 rounded-lg"
|
||||||
|
style="width: 100%; height: 440px; background: white;"
|
||||||
|
src="/template_google_drive"
|
||||||
|
/>
|
||||||
|
<div v-if="loading">
|
||||||
|
<div
|
||||||
|
class="bg-white absolute top-0 bottom-0 left-0 right-0 opacity-80 rounded-lg"
|
||||||
|
style="margin: 1px"
|
||||||
|
/>
|
||||||
|
<div class="absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center">
|
||||||
|
<IconInnerShadowTop class="animate-spin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { IconBrandGoogleDrive, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GoogleDrivePickerModal',
|
||||||
|
components: {
|
||||||
|
IconBrandGoogleDrive,
|
||||||
|
IconInnerShadowTop
|
||||||
|
},
|
||||||
|
inject: ['t'],
|
||||||
|
props: {
|
||||||
|
templateId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
authenticityToken: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
reopenAfterAuth: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
modalContainerEl: {
|
||||||
|
type: [Object, String],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['close', 'picked', 'update:loading'],
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
isConnectClicked: false,
|
||||||
|
showOauthButton: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
oauthPath () {
|
||||||
|
const redir = this.reopenAfterAuth
|
||||||
|
? `/templates/${this.templateId}/edit?google_drive_open=1`
|
||||||
|
: `/templates/${this.templateId}/edit`
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
access_type: 'offline',
|
||||||
|
include_granted_scopes: 'true',
|
||||||
|
prompt: 'consent',
|
||||||
|
scope: [
|
||||||
|
'https://www.googleapis.com/auth/userinfo.email',
|
||||||
|
'https://www.googleapis.com/auth/drive.file'
|
||||||
|
].join(' '),
|
||||||
|
oauth_data: new URLSearchParams({ redir }).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/auth/google_oauth2?${new URLSearchParams(params).toString()}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
window.addEventListener('message', this.messageHandler)
|
||||||
|
},
|
||||||
|
beforeUnmount () {
|
||||||
|
window.removeEventListener('message', this.messageHandler)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
messageHandler (event) {
|
||||||
|
if (event.data.type === 'google-drive-files-picked') {
|
||||||
|
const files = event.data.files || []
|
||||||
|
|
||||||
|
if (!files.length) return
|
||||||
|
|
||||||
|
this.$emit('update:loading', true)
|
||||||
|
this.$emit('picked', files)
|
||||||
|
} else if (event.data.type === 'google-drive-picker-loaded') {
|
||||||
|
this.$emit('update:loading', false)
|
||||||
|
} else if (event.data.type === 'google-drive-picker-request-oauth') {
|
||||||
|
this.showOauthButton = true
|
||||||
|
this.$emit('update:loading', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -96,6 +96,24 @@ const en = {
|
|||||||
add_pdf_documents_or_images: 'Add PDF documents or images',
|
add_pdf_documents_or_images: 'Add PDF documents or images',
|
||||||
add_documents_or_images: 'Add documents or images',
|
add_documents_or_images: 'Add documents or images',
|
||||||
add_a_new_document: 'Add a new document',
|
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',
|
replace_existing_document: 'Replace existing document',
|
||||||
clone_and_replace_documents: 'Clone and replace documents',
|
clone_and_replace_documents: 'Clone and replace documents',
|
||||||
required: 'Required',
|
required: 'Required',
|
||||||
@@ -219,7 +237,9 @@ const en = {
|
|||||||
revisions: 'Revisions',
|
revisions: 'Revisions',
|
||||||
apply: 'Apply',
|
apply: 'Apply',
|
||||||
no_revisions_yet: 'No revisions yet',
|
no_revisions_yet: 'No revisions yet',
|
||||||
viewing_revision_from: 'Viewing revision from {date}'
|
viewing_revision_from: 'Viewing revision from {date}',
|
||||||
|
connect_google_drive: 'Connect Google Drive',
|
||||||
|
submitting: 'Submitting'
|
||||||
}
|
}
|
||||||
|
|
||||||
const es = {
|
const es = {
|
||||||
@@ -317,6 +337,24 @@ const es = {
|
|||||||
add_pdf_documents_or_images: 'Agregar documentos PDF o imágenes',
|
add_pdf_documents_or_images: 'Agregar documentos PDF o imágenes',
|
||||||
add_documents_or_images: 'Agregar documentos o imágenes',
|
add_documents_or_images: 'Agregar documentos o imágenes',
|
||||||
add_a_new_document: 'Agregar un nuevo documento',
|
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',
|
replace_existing_document: 'Reemplazar documento existente',
|
||||||
clone_and_replace_documents: 'Clonar y reemplazar documentos',
|
clone_and_replace_documents: 'Clonar y reemplazar documentos',
|
||||||
required: 'Requerido',
|
required: 'Requerido',
|
||||||
@@ -443,7 +481,9 @@ const es = {
|
|||||||
revisions: 'Revisiones',
|
revisions: 'Revisiones',
|
||||||
apply: 'Aplicar',
|
apply: 'Aplicar',
|
||||||
no_revisions_yet: 'Aún no hay revisiones',
|
no_revisions_yet: 'Aún no hay revisiones',
|
||||||
viewing_revision_from: 'Viendo revisión del {date}'
|
viewing_revision_from: 'Viendo revisión del {date}',
|
||||||
|
connect_google_drive: 'Conectar Google Drive',
|
||||||
|
submitting: 'Enviando'
|
||||||
}
|
}
|
||||||
|
|
||||||
const it = {
|
const it = {
|
||||||
@@ -544,6 +584,24 @@ const it = {
|
|||||||
add_pdf_documents_or_images: 'Aggiungi documenti PDF o immagini',
|
add_pdf_documents_or_images: 'Aggiungi documenti PDF o immagini',
|
||||||
add_documents_or_images: 'Aggiungi documenti o immagini',
|
add_documents_or_images: 'Aggiungi documenti o immagini',
|
||||||
add_a_new_document: 'Aggiungi un nuovo documento',
|
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',
|
replace_existing_document: 'Sostituisci documento esistente',
|
||||||
clone_and_replace_documents: 'Clona e sostituisci documenti',
|
clone_and_replace_documents: 'Clona e sostituisci documenti',
|
||||||
required: 'Obbligatorio',
|
required: 'Obbligatorio',
|
||||||
@@ -667,7 +725,9 @@ const it = {
|
|||||||
revisions: 'Revisioni',
|
revisions: 'Revisioni',
|
||||||
apply: 'Applica',
|
apply: 'Applica',
|
||||||
no_revisions_yet: 'Nessuna revisione ancora',
|
no_revisions_yet: 'Nessuna revisione ancora',
|
||||||
viewing_revision_from: 'Visualizzazione revisione del {date}'
|
viewing_revision_from: 'Visualizzazione revisione del {date}',
|
||||||
|
connect_google_drive: 'Connetti Google Drive',
|
||||||
|
submitting: 'Invio'
|
||||||
}
|
}
|
||||||
|
|
||||||
const pt = {
|
const pt = {
|
||||||
@@ -679,7 +739,7 @@ const pt = {
|
|||||||
analyzing_: 'Analisando...',
|
analyzing_: 'Analisando...',
|
||||||
download: 'Baixar',
|
download: 'Baixar',
|
||||||
downloading_: 'Baixando...',
|
downloading_: 'Baixando...',
|
||||||
view: 'Visualizar',
|
view: 'Ver',
|
||||||
payment_link: 'Link de pagamento',
|
payment_link: 'Link de pagamento',
|
||||||
strikeout: 'Tachado',
|
strikeout: 'Tachado',
|
||||||
draw_strikethrough_the_document: 'Desenhe uma linha de tachado no documento',
|
draw_strikethrough_the_document: 'Desenhe uma linha de tachado no documento',
|
||||||
@@ -765,6 +825,24 @@ const pt = {
|
|||||||
add_pdf_documents_or_images: 'Adicionar documentos PDF ou imagens',
|
add_pdf_documents_or_images: 'Adicionar documentos PDF ou imagens',
|
||||||
add_documents_or_images: 'Adicionar documentos ou imagens',
|
add_documents_or_images: 'Adicionar documentos ou imagens',
|
||||||
add_a_new_document: 'Adicionar um novo documento',
|
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',
|
replace_existing_document: 'Substituir documento existente',
|
||||||
clone_and_replace_documents: 'Clonar e substituir documentos',
|
clone_and_replace_documents: 'Clonar e substituir documentos',
|
||||||
required: 'Obrigatório',
|
required: 'Obrigatório',
|
||||||
@@ -891,7 +969,9 @@ const pt = {
|
|||||||
revisions: 'Revisões',
|
revisions: 'Revisões',
|
||||||
apply: 'Aplicar',
|
apply: 'Aplicar',
|
||||||
no_revisions_yet: 'Nenhuma revisão ainda',
|
no_revisions_yet: 'Nenhuma revisão ainda',
|
||||||
viewing_revision_from: 'Visualizando revisão de {date}'
|
viewing_revision_from: 'Visualizando revisão de {date}',
|
||||||
|
connect_google_drive: 'Conectar Google Drive',
|
||||||
|
submitting: 'Enviando'
|
||||||
}
|
}
|
||||||
|
|
||||||
const fr = {
|
const fr = {
|
||||||
@@ -992,6 +1072,24 @@ const fr = {
|
|||||||
add_pdf_documents_or_images: 'Ajouter des documents PDF ou des images',
|
add_pdf_documents_or_images: 'Ajouter des documents PDF ou des images',
|
||||||
add_documents_or_images: 'Ajouter des documents ou des images',
|
add_documents_or_images: 'Ajouter des documents ou des images',
|
||||||
add_a_new_document: 'Ajouter un nouveau document',
|
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',
|
replace_existing_document: 'Remplacer le document existant',
|
||||||
clone_and_replace_documents: 'Cloner et remplacer des documents',
|
clone_and_replace_documents: 'Cloner et remplacer des documents',
|
||||||
required: 'Obligatoire',
|
required: 'Obligatoire',
|
||||||
@@ -1115,7 +1213,9 @@ const fr = {
|
|||||||
revisions: 'Révisions',
|
revisions: 'Révisions',
|
||||||
apply: 'Appliquer',
|
apply: 'Appliquer',
|
||||||
no_revisions_yet: 'Aucune révision pour le moment',
|
no_revisions_yet: 'Aucune révision pour le moment',
|
||||||
viewing_revision_from: 'Affichage de la révision du {date}'
|
viewing_revision_from: 'Affichage de la révision du {date}',
|
||||||
|
connect_google_drive: 'Connecter Google Drive',
|
||||||
|
submitting: 'Soumission'
|
||||||
}
|
}
|
||||||
|
|
||||||
const de = {
|
const de = {
|
||||||
@@ -1216,6 +1316,24 @@ const de = {
|
|||||||
add_pdf_documents_or_images: 'PDF-Dokumente oder Bilder hinzufügen',
|
add_pdf_documents_or_images: 'PDF-Dokumente oder Bilder hinzufügen',
|
||||||
add_documents_or_images: 'Dokumente oder Bilder hinzufügen',
|
add_documents_or_images: 'Dokumente oder Bilder hinzufügen',
|
||||||
add_a_new_document: 'Neues Dokument 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',
|
replace_existing_document: 'Vorhandenes Dokument ersetzen',
|
||||||
clone_and_replace_documents: 'Dokumente klonen und ersetzen',
|
clone_and_replace_documents: 'Dokumente klonen und ersetzen',
|
||||||
required: 'Erforderlich',
|
required: 'Erforderlich',
|
||||||
@@ -1339,7 +1457,9 @@ const de = {
|
|||||||
revisions: 'Revisionen',
|
revisions: 'Revisionen',
|
||||||
apply: 'Anwenden',
|
apply: 'Anwenden',
|
||||||
no_revisions_yet: 'Noch keine Revisionen',
|
no_revisions_yet: 'Noch keine Revisionen',
|
||||||
viewing_revision_from: 'Ansicht der Revision vom {date}'
|
viewing_revision_from: 'Ansicht der Revision vom {date}',
|
||||||
|
connect_google_drive: 'Google Drive verbinden',
|
||||||
|
submitting: 'Übermittlung'
|
||||||
}
|
}
|
||||||
|
|
||||||
const nl = {
|
const nl = {
|
||||||
@@ -1440,6 +1560,24 @@ const nl = {
|
|||||||
add_pdf_documents_or_images: 'PDF-documenten of afbeeldingen toevoegen',
|
add_pdf_documents_or_images: 'PDF-documenten of afbeeldingen toevoegen',
|
||||||
add_documents_or_images: 'Documenten of afbeeldingen toevoegen',
|
add_documents_or_images: 'Documenten of afbeeldingen toevoegen',
|
||||||
add_a_new_document: 'Nieuw document 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',
|
replace_existing_document: 'Bestaand document vervangen',
|
||||||
clone_and_replace_documents: 'Documenten klonen en vervangen',
|
clone_and_replace_documents: 'Documenten klonen en vervangen',
|
||||||
required: 'Vereist',
|
required: 'Vereist',
|
||||||
@@ -1563,7 +1701,9 @@ const nl = {
|
|||||||
revisions: 'Revisies',
|
revisions: 'Revisies',
|
||||||
apply: 'Toepassen',
|
apply: 'Toepassen',
|
||||||
no_revisions_yet: 'Nog geen revisies',
|
no_revisions_yet: 'Nog geen revisies',
|
||||||
viewing_revision_from: 'Revisie van {date} bekijken'
|
viewing_revision_from: 'Revisie van {date} bekijken',
|
||||||
|
connect_google_drive: 'Verbind Google Drive',
|
||||||
|
submitting: 'Indienen'
|
||||||
}
|
}
|
||||||
|
|
||||||
export { en, es, it, pt, fr, de, nl }
|
export { en, es, it, pt, fr, de, nl }
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
:src="image.url"
|
:src="image.url"
|
||||||
:width="width"
|
:width="width"
|
||||||
:height="height"
|
:height="height"
|
||||||
class="rounded"
|
class="w-full h-full rounded"
|
||||||
@load="onImageLoad"
|
@load="onImageLoad"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -36,7 +36,10 @@
|
|||||||
v-if="withReplaceButton"
|
v-if="withReplaceButton"
|
||||||
:template-id="template.id"
|
:template-id="template.id"
|
||||||
:accept-file-types="acceptFileTypes"
|
:accept-file-types="acceptFileTypes"
|
||||||
class="opacity-0 group-hover:opacity-100"
|
:authenticity-token="authenticityToken"
|
||||||
|
:with-google-drive="withGoogleDrive"
|
||||||
|
:google-drive-file-id="item.google_drive_file_id"
|
||||||
|
class="opacity-0 group-hover:opacity-100 has-[label:focus]:opacity-100"
|
||||||
@click.stop
|
@click.stop
|
||||||
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
|
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
|
||||||
/>
|
/>
|
||||||
@@ -69,6 +72,15 @@
|
|||||||
style="min-width: 170px"
|
style="min-width: 170px"
|
||||||
@click="closeDropdown"
|
@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>
|
<li>
|
||||||
<button
|
<button
|
||||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
|
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
|
||||||
@@ -181,7 +193,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import Contenteditable from './contenteditable'
|
import Contenteditable from './contenteditable'
|
||||||
import Upload from './upload'
|
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 ConditionsModal from './conditions_modal'
|
||||||
import ReplaceButton from './replace'
|
import ReplaceButton from './replace'
|
||||||
import GoogleDriveDocumentSettings from './google_drive_document_settings'
|
import GoogleDriveDocumentSettings from './google_drive_document_settings'
|
||||||
@@ -201,7 +213,8 @@ export default {
|
|||||||
IconSortDescending2,
|
IconSortDescending2,
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
IconTrashX,
|
IconTrashX,
|
||||||
IconBolt
|
IconBolt,
|
||||||
|
IconPencil
|
||||||
},
|
},
|
||||||
inject: ['t', 'getFieldTypeIndex', 'baseFetch'],
|
inject: ['t', 'getFieldTypeIndex', 'baseFetch'],
|
||||||
props: {
|
props: {
|
||||||
@@ -232,6 +245,16 @@ export default {
|
|||||||
required: true,
|
required: true,
|
||||||
default: true
|
default: true
|
||||||
},
|
},
|
||||||
|
withGoogleDrive: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
authenticityToken: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
dynamicDocuments: {
|
dynamicDocuments: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true
|
required: true
|
||||||
@@ -247,7 +270,7 @@ export default {
|
|||||||
default: true
|
default: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'reorder'],
|
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'reorder', 'edit'],
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
isShowConditionsModal: false,
|
isShowConditionsModal: false,
|
||||||
|
|||||||
@@ -1,37 +1,109 @@
|
|||||||
<template>
|
<template>
|
||||||
<label
|
<div
|
||||||
:for="inputId"
|
class="inline-flex items-stretch replace-document-control"
|
||||||
class="btn btn-neutral btn-xs text-white transition-none replace-document-button"
|
|
||||||
:class="{ 'opacity-100': isLoading }"
|
:class="{ 'opacity-100': isLoading }"
|
||||||
>
|
>
|
||||||
{{ message }}
|
<label
|
||||||
|
:for="inputId"
|
||||||
|
class="btn btn-neutral btn-xs transition-none replace-document-button"
|
||||||
|
:class="{ 'opacity-100': isLoading, 'pr-0': showGoogleDriveDropdown && !isLoading }"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between w-full h-full">
|
||||||
|
<span class="flex items-center space-x-2 w-full justify-center text-white">
|
||||||
|
{{ message }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="showGoogleDriveDropdown && !isLoading"
|
||||||
|
class="dropdown dropdown-end inline h-full"
|
||||||
|
style="width: 30px"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
tabindex="0"
|
||||||
|
class="flex items-center h-full cursor-pointer text-white"
|
||||||
|
>
|
||||||
|
<IconChevronDown class="w-4 h-4 flex-shrink-0" />
|
||||||
|
</label>
|
||||||
|
<ul
|
||||||
|
tabindex="0"
|
||||||
|
:style="{ backgroundColor }"
|
||||||
|
class="dropdown-content p-2 mt-2 shadow menu text-base mb-1 rounded-box text-right !text-base-content"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click.prevent="openGoogleDriveModal"
|
||||||
|
>
|
||||||
|
<IconBrandGoogleDrive class="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span class="whitespace-nowrap text-sm normal-case font-medium">Google Drive</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
<form
|
<form
|
||||||
ref="form"
|
ref="form"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
>
|
>
|
||||||
|
<input
|
||||||
|
v-if="googleDriveFile"
|
||||||
|
name="google_drive_file_ids[]"
|
||||||
|
:value="googleDriveFile.id"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
:id="inputId"
|
:id="inputId"
|
||||||
ref="input"
|
ref="input"
|
||||||
name="files[]"
|
name="files[]"
|
||||||
type="file"
|
type="file"
|
||||||
:accept="acceptFileTypes"
|
:accept="acceptFileTypes"
|
||||||
@change="upload"
|
@change="upload()"
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
</label>
|
<GoogleDrivePickerModal
|
||||||
|
v-if="showGoogleDriveModal"
|
||||||
|
v-model:loading="isLoadingGoogleDrive"
|
||||||
|
:template-id="templateId"
|
||||||
|
:authenticity-token="authenticityToken"
|
||||||
|
:modal-container-el="modalContainerEl"
|
||||||
|
@close="showGoogleDriveModal = false"
|
||||||
|
@picked="onGoogleDrivePicked"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Upload from './upload'
|
import Upload from './upload'
|
||||||
|
import GoogleDrivePickerModal from './google_drive_picker_modal'
|
||||||
|
import { IconChevronDown, IconBrandGoogleDrive } from '@tabler/icons-vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ReplaceDocument',
|
name: 'ReplaceDocument',
|
||||||
inject: ['baseFetch', 't'],
|
components: {
|
||||||
|
IconChevronDown,
|
||||||
|
IconBrandGoogleDrive,
|
||||||
|
GoogleDrivePickerModal
|
||||||
|
},
|
||||||
|
inject: ['baseFetch', 't', 'backgroundColor'],
|
||||||
props: {
|
props: {
|
||||||
templateId: {
|
templateId: {
|
||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
authenticityToken: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
googleDriveFileId: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
withGoogleDrive: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
acceptFileTypes: {
|
acceptFileTypes: {
|
||||||
type: String,
|
type: String,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -41,16 +113,25 @@ export default {
|
|||||||
emits: ['success'],
|
emits: ['success'],
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
isLoading: false
|
isLoading: false,
|
||||||
|
isLoadingGoogleDrive: true,
|
||||||
|
googleDriveFile: null,
|
||||||
|
showGoogleDriveModal: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
inputId () {
|
inputId () {
|
||||||
return 'el' + Math.random().toString(32).split('.')[1]
|
return 'el' + Math.random().toString(32).split('.')[1]
|
||||||
},
|
},
|
||||||
|
showGoogleDriveDropdown () {
|
||||||
|
return this.withGoogleDrive && !!this.googleDriveFileId
|
||||||
|
},
|
||||||
uploadUrl () {
|
uploadUrl () {
|
||||||
return `/templates/${this.templateId}/documents`
|
return `/templates/${this.templateId}/documents`
|
||||||
},
|
},
|
||||||
|
modalContainerEl () {
|
||||||
|
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
|
||||||
|
},
|
||||||
message () {
|
message () {
|
||||||
if (this.isLoading) {
|
if (this.isLoading) {
|
||||||
return this.t('uploading_')
|
return this.t('uploading_')
|
||||||
@@ -60,7 +141,27 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
upload: Upload.methods.upload
|
upload: Upload.methods.upload,
|
||||||
|
openGoogleDriveModal () {
|
||||||
|
this.showGoogleDriveModal = true
|
||||||
|
this.isLoadingGoogleDrive = true
|
||||||
|
this.googleDriveFile = null
|
||||||
|
this.$el.getRootNode().activeElement?.blur()
|
||||||
|
},
|
||||||
|
onGoogleDrivePicked (files) {
|
||||||
|
this.googleDriveFile = files[0]
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.upload({ path: `/templates/${this.templateId}/google_drive_documents` }).then((resp) => {
|
||||||
|
if (resp.ok) {
|
||||||
|
this.showGoogleDriveModal = false
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
this.isLoadingGoogleDrive = false
|
||||||
|
this.googleDriveFile = null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -61,87 +61,16 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<Teleport
|
<GoogleDrivePickerModal
|
||||||
v-if="showGoogleDriveModal"
|
v-if="showGoogleDriveModal"
|
||||||
:to="modalContainerEl"
|
v-model:loading="isLoadingGoogleDrive"
|
||||||
>
|
:template-id="templateId"
|
||||||
<div
|
:authenticity-token="authenticityToken"
|
||||||
class="modal modal-open items-start !animate-none overflow-y-auto"
|
:modal-container-el="modalContainerEl"
|
||||||
>
|
:reopen-after-auth="true"
|
||||||
<div
|
@close="showGoogleDriveModal = false"
|
||||||
class="absolute top-0 bottom-0 right-0 left-0"
|
@picked="onGoogleDrivePicked"
|
||||||
@click.prevent="showGoogleDriveModal = false"
|
/>
|
||||||
/>
|
|
||||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
|
|
||||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
|
||||||
<span class="modal-title">
|
|
||||||
Google Drive
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
class="text-xl modal-close-button"
|
|
||||||
@click.prevent="showGoogleDriveModal = false"
|
|
||||||
>×</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<form
|
|
||||||
v-if="showGoogleDriveOauthButton"
|
|
||||||
method="post"
|
|
||||||
:action="googleDriveOauthPath"
|
|
||||||
@submit="isConnectGoogleDriveClicked = true"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="authenticity_token"
|
|
||||||
:value="authenticityToken"
|
|
||||||
autocomplete="off"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
id="gdrive_oauth_button"
|
|
||||||
class="btn bg-white btn-outline w-full text-base font-medium mt-4"
|
|
||||||
data-turbo="false"
|
|
||||||
type="submit"
|
|
||||||
:disabled="isConnectGoogleDriveClicked"
|
|
||||||
>
|
|
||||||
<span v-if="isConnectGoogleDriveClicked">
|
|
||||||
<span class="flex items-center justify-center space-x-2">
|
|
||||||
<IconInnerShadowTop class="animate-spin" />
|
|
||||||
<span class="">Submitting...</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
>
|
|
||||||
<span class="flex items-center justify-center space-x-2">
|
|
||||||
<IconBrandGoogleDrive />
|
|
||||||
<span>Connect Google Drive</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="relative"
|
|
||||||
>
|
|
||||||
<iframe
|
|
||||||
class="border border-base-300 rounded-lg"
|
|
||||||
style="width: 100%; height: 440px; background: white;"
|
|
||||||
src="/template_google_drive"
|
|
||||||
/>
|
|
||||||
<div v-if="isLoadingGoogleDrive">
|
|
||||||
<div
|
|
||||||
class="bg-white absolute top-0 bottom-0 left-0 right-0 opacity-80 rounded-lg"
|
|
||||||
style="margin: 1px"
|
|
||||||
/>
|
|
||||||
<div class="absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center">
|
|
||||||
<IconInnerShadowTop class="animate-spin" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
<form
|
<form
|
||||||
ref="form"
|
ref="form"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
@@ -159,7 +88,7 @@
|
|||||||
type="file"
|
type="file"
|
||||||
:accept="acceptFileTypes"
|
:accept="acceptFileTypes"
|
||||||
multiple
|
multiple
|
||||||
@change="upload"
|
@change="upload()"
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,6 +96,74 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { IconUpload, IconInnerShadowTop, IconChevronDown, IconBrandGoogleDrive } from '@tabler/icons-vue'
|
import { IconUpload, IconInnerShadowTop, IconChevronDown, IconBrandGoogleDrive } from '@tabler/icons-vue'
|
||||||
|
import GoogleDrivePickerModal from './google_drive_picker_modal'
|
||||||
|
|
||||||
|
function convertImage (sourceFile, targetType, quality) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
|
reader.onload = function (event) {
|
||||||
|
const img = new Image()
|
||||||
|
|
||||||
|
img.onload = function () {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
|
canvas.width = img.width
|
||||||
|
canvas.height = img.height
|
||||||
|
ctx.drawImage(img, 0, 0)
|
||||||
|
canvas.toBlob(function (blob) {
|
||||||
|
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
|
||||||
|
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
|
||||||
|
resolve(newFile)
|
||||||
|
}, targetType, quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
|
||||||
|
|
||||||
|
img.src = event.target.result
|
||||||
|
}
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsDataURL(sourceFile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertUnsupportedImages (files) {
|
||||||
|
const converted = []
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
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)) {
|
||||||
|
result = await convertImage(file, 'image/jpeg', 0.9)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
converted.push(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'DocumentsUpload',
|
name: 'DocumentsUpload',
|
||||||
@@ -174,7 +171,8 @@ export default {
|
|||||||
IconUpload,
|
IconUpload,
|
||||||
IconInnerShadowTop,
|
IconInnerShadowTop,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconBrandGoogleDrive
|
IconBrandGoogleDrive,
|
||||||
|
GoogleDrivePickerModal
|
||||||
},
|
},
|
||||||
inject: ['baseFetch', 't', 'backgroundColor'],
|
inject: ['baseFetch', 't', 'backgroundColor'],
|
||||||
props: {
|
props: {
|
||||||
@@ -202,11 +200,9 @@ export default {
|
|||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isConnectGoogleDriveClicked: false,
|
isLoadingGoogleDrive: true,
|
||||||
isLoadingGoogleDrive: false,
|
|
||||||
googleDriveFiles: [],
|
googleDriveFiles: [],
|
||||||
showGoogleDriveModal: false,
|
showGoogleDriveModal: false
|
||||||
showGoogleDriveOauthButton: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -219,69 +215,43 @@ export default {
|
|||||||
uploadUrl () {
|
uploadUrl () {
|
||||||
return `/templates/${this.templateId}/documents`
|
return `/templates/${this.templateId}/documents`
|
||||||
},
|
},
|
||||||
googleDriveOauthPath () {
|
|
||||||
const params = {
|
|
||||||
access_type: 'offline',
|
|
||||||
include_granted_scopes: 'true',
|
|
||||||
prompt: 'consent',
|
|
||||||
scope: [
|
|
||||||
'https://www.googleapis.com/auth/userinfo.email',
|
|
||||||
'https://www.googleapis.com/auth/drive.file'
|
|
||||||
].join(' '),
|
|
||||||
oauth_data: new URLSearchParams({
|
|
||||||
redir: `/templates/${this.templateId}/edit?google_drive_open=1`
|
|
||||||
}).toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = new URLSearchParams(params).toString()
|
|
||||||
|
|
||||||
return `/auth/google_oauth2?${query}`
|
|
||||||
},
|
|
||||||
modalContainerEl () {
|
modalContainerEl () {
|
||||||
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
|
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
window.addEventListener('message', this.messageHandler)
|
|
||||||
|
|
||||||
if (this.queryParams.get('google_drive_open') === '1') {
|
if (this.queryParams.get('google_drive_open') === '1') {
|
||||||
this.openGoogleDriveModal()
|
this.openGoogleDriveModal()
|
||||||
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname)
|
window.history.replaceState({}, document.title, window.location.pathname)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeUnmount () {
|
|
||||||
window.removeEventListener('message', this.messageHandler)
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
openGoogleDriveModal () {
|
openGoogleDriveModal () {
|
||||||
this.showGoogleDriveModal = true
|
this.showGoogleDriveModal = true
|
||||||
this.isLoadingGoogleDrive = true
|
this.isLoadingGoogleDrive = true
|
||||||
},
|
},
|
||||||
messageHandler (event) {
|
onGoogleDrivePicked (files) {
|
||||||
if (event.data.type === 'google-drive-files-picked') {
|
this.googleDriveFiles = files
|
||||||
this.googleDriveFiles = event.data.files || []
|
|
||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.isLoadingGoogleDrive = true
|
this.upload({ path: `/templates/${this.templateId}/google_drive_documents` }).then((resp) => {
|
||||||
|
if (resp.ok) {
|
||||||
this.upload({ path: `/templates/${this.templateId}/google_drive_documents` }).then((resp) => {
|
this.showGoogleDriveModal = false
|
||||||
if (resp.ok) {
|
}
|
||||||
this.showGoogleDriveModal = false
|
}).finally(() => {
|
||||||
}
|
this.isLoadingGoogleDrive = false
|
||||||
}).finally(() => {
|
this.googleDriveFiles = []
|
||||||
this.isLoadingGoogleDrive = false
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
} else if (event.data.type === 'google-drive-picker-loaded') {
|
})
|
||||||
this.isLoadingGoogleDrive = false
|
|
||||||
} else if (event.data.type === 'google-drive-picker-request-oauth') {
|
|
||||||
this.showGoogleDriveOauthButton = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async upload ({ path } = {}) {
|
async upload ({ path } = {}) {
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
|
|
||||||
|
if (this.$refs.input) {
|
||||||
|
await convertImagesInInput(this.$refs.input)
|
||||||
|
}
|
||||||
|
|
||||||
return this.baseFetch(path || this.uploadUrl, {
|
return this.baseFetch(path || this.uploadUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ class ProcessSubmissionExpiredJob
|
|||||||
return if submission.archived_at?
|
return if submission.archived_at?
|
||||||
return if submission.template&.archived_at?
|
return if submission.template&.archived_at?
|
||||||
return if submission.submitters.where.not(declined_at: nil).exists?
|
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')
|
WebhookUrls.enqueue_events(submission, 'submission.expired')
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,30 +5,45 @@ class ProcessSubmitterCompletionJob
|
|||||||
|
|
||||||
def perform(params = {})
|
def perform(params = {})
|
||||||
submitter = Submitter.find(params['submitter_id'])
|
submitter = Submitter.find(params['submitter_id'])
|
||||||
|
submission = submitter.submission
|
||||||
|
|
||||||
create_completed_submitter!(submitter)
|
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)
|
Submissions::EnsureResultGenerated.call(submitter)
|
||||||
|
|
||||||
if is_all_completed && submitter.completed_at == submitter.submission.submitters.maximum(:completed_at)
|
if is_last
|
||||||
if submitter.submission.account.account_configs.exists?(key: AccountConfig::COMBINE_PDF_RESULT_KEY, value: true)
|
if submission.account.account_configs.exists?(key: AccountConfig::COMBINE_PDF_RESULT_KEY, value: true)
|
||||||
Submissions::EnsureCombinedGenerated.call(submitter)
|
Submissions::EnsureCombinedGenerated.call(submitter)
|
||||||
end
|
end
|
||||||
|
|
||||||
Submissions::EnsureAuditGenerated.call(submitter.submission)
|
Submissions::EnsureAuditGenerated.call(submission)
|
||||||
|
|
||||||
enqueue_completed_emails(submitter)
|
enqueue_completed_emails(submitter)
|
||||||
end
|
end
|
||||||
|
|
||||||
create_completed_documents!(submitter)
|
create_completed_documents!(submitter)
|
||||||
|
|
||||||
if !is_all_completed && submitter.submission.submitters_order_preserved? && params['send_invitation_email'] != false
|
if !submission.completed_at && submission.submitters_order_preserved? && params['send_invitation_email'] != false &&
|
||||||
enqueue_next_submitter_request_notification(submitter)
|
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
|
end
|
||||||
|
|
||||||
enqueue_completed_webhooks(submitter, is_all_completed:)
|
enqueue_completed_webhooks(submitter, is_last:)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_completed_submitter!(submitter)
|
def create_completed_submitter!(submitter)
|
||||||
@@ -77,7 +92,7 @@ class ProcessSubmitterCompletionJob
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def enqueue_completed_webhooks(submitter, is_all_completed: false)
|
def enqueue_completed_webhooks(submitter, is_last: false)
|
||||||
event_uuids = {}
|
event_uuids = {}
|
||||||
|
|
||||||
WebhookUrls.for_account_id(submitter.account_id, %w[form.completed submission.completed]).each do |webhook|
|
WebhookUrls.for_account_id(submitter.account_id, %w[form.completed submission.completed]).each do |webhook|
|
||||||
@@ -89,7 +104,7 @@ class ProcessSubmitterCompletionJob
|
|||||||
'webhook_url_id' => webhook.id)
|
'webhook_url_id' => webhook.id)
|
||||||
end
|
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
|
event_uuids['submission.completed'] ||= SecureRandom.uuid
|
||||||
|
|
||||||
@@ -137,7 +152,7 @@ class ProcessSubmitterCompletionJob
|
|||||||
return if configs.value['enabled'] == false
|
return if configs.value['enabled'] == false
|
||||||
|
|
||||||
to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == 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?
|
return if to.blank?
|
||||||
|
|
||||||
@@ -157,9 +172,9 @@ class ProcessSubmitterCompletionJob
|
|||||||
bcc.to_s.scan(User::EMAIL_REGEXP)
|
bcc.to_s.scan(User::EMAIL_REGEXP)
|
||||||
end
|
end
|
||||||
|
|
||||||
def enqueue_next_submitter_request_notification(submitter)
|
def enqueue_next_submitter_request_notification(submitter) # rubocop:disable Metrics/PerceivedComplexity
|
||||||
submission = submitter.submission
|
submission = submitter.submission
|
||||||
submitters_index = submission.submitters.index_by(&:uuid)
|
submitters_index = submission.submitters.reject(&:viewer?).index_by(&:uuid)
|
||||||
|
|
||||||
next_submitter_items =
|
next_submitter_items =
|
||||||
if submission.template_submitters.any? { |s| s['order'] }
|
if submission.template_submitters.any? { |s| s['order'] }
|
||||||
@@ -188,5 +203,43 @@ class ProcessSubmitterCompletionJob
|
|||||||
next_submitters = submitters_index.values_at(*Array.wrap(next_submitter_items).pluck('uuid')).compact
|
next_submitters = submitters_index.values_at(*Array.wrap(next_submitter_items).pluck('uuid')).compact
|
||||||
|
|
||||||
Submitters.send_signature_requests(next_submitters)
|
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
|
||||||
end
|
end
|
||||||
@@ -3,8 +3,6 @@
|
|||||||
class ReindexSearchEntryJob
|
class ReindexSearchEntryJob
|
||||||
include Sidekiq::Job
|
include Sidekiq::Job
|
||||||
|
|
||||||
InvalidFormat = Class.new(StandardError)
|
|
||||||
|
|
||||||
def perform(params = {})
|
def perform(params = {})
|
||||||
entry = SearchEntry.find_or_initialize_by(params.slice('record_type', 'record_id'))
|
entry = SearchEntry.find_or_initialize_by(params.slice('record_type', 'record_id'))
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ class SendSubmitterInvitationEmailJob
|
|||||||
submitter = Submitter.find(params['submitter_id'])
|
submitter = Submitter.find(params['submitter_id'])
|
||||||
|
|
||||||
return if submitter.completed_at?
|
return if submitter.completed_at?
|
||||||
|
return if submitter.declined_at?
|
||||||
return if submitter.submission.archived_at?
|
return if submitter.submission.archived_at?
|
||||||
|
return if submitter.submission.expired?
|
||||||
return if submitter.template&.archived_at?
|
return if submitter.template&.archived_at?
|
||||||
return if submitter.submission.source == 'invite' && !Accounts.can_send_emails?(submitter.account, on_events: true)
|
return if submitter.submission.source == 'invite' && !Accounts.can_send_emails?(submitter.account, on_events: true)
|
||||||
|
|
||||||
@@ -17,7 +19,12 @@ class SendSubmitterInvitationEmailJob
|
|||||||
return
|
return
|
||||||
end
|
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)
|
Submitters::ValidateSending.call(submitter, mail)
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 256 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user