mirror of
https://github.com/docusealco/docuseal
synced 2026-11-05 14:42:33 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27a0d10525 | ||
|
|
d25f0812ca | ||
|
|
9e2aa6f8c8 | ||
|
|
11c76bf554 | ||
|
|
e5733d88c0 | ||
|
|
6d38c9f375 | ||
|
|
91ec29ad2c | ||
|
|
d374c7fa10 | ||
|
|
ca0daf6d0b | ||
|
|
076c3d805a | ||
|
|
1f149ac67a | ||
|
|
616f7b4fd8 | ||
|
|
6f4527038b | ||
|
|
8ed2883504 | ||
|
|
651b84a460 | ||
|
|
73c4f158ff | ||
|
|
6f1cc34433 | ||
|
|
a77029ffcb | ||
|
|
0480572ef5 | ||
|
|
807463c465 | ||
|
|
0f61053976 | ||
|
|
b8cf525034 | ||
|
|
fef203daed | ||
|
|
41b38cc594 | ||
|
|
4e4354af99 | ||
|
|
113b61588b | ||
|
|
c1aac6b763 | ||
|
|
f85fd3cff7 | ||
|
|
004a22c1c8 | ||
|
|
69343b1bdd | ||
|
|
81a306928c | ||
|
|
2bf3d42ed6 | ||
|
|
eec08266c9 | ||
|
|
cbbc75b485 | ||
|
|
3d93bbe5aa | ||
|
|
5308e3e15b | ||
|
|
22c146d564 | ||
|
|
c8d3af9841 | ||
|
|
ee1d45d461 | ||
|
|
27d075ed63 | ||
|
|
df63acf0c8 | ||
|
|
185827c951 | ||
|
|
e361cbabf4 | ||
|
|
e867bf5c48 | ||
|
|
726d0ea4f3 | ||
|
|
90be34b243 | ||
|
|
e87c06d811 | ||
|
|
54454d1dfc | ||
|
|
726174deff | ||
|
|
52eb8ac863 | ||
|
|
97d91d8f27 | ||
|
|
dd358a8401 | ||
|
|
8e254b7d3c | ||
|
|
b9018fa442 | ||
|
|
09310f3690 | ||
|
|
2dd38260ba | ||
|
|
ffb3247cdc | ||
|
|
c59c470321 | ||
|
|
f35510ffd6 | ||
|
|
eeec852b3b | ||
|
|
485b7f2c8f | ||
|
|
b6498ec049 | ||
|
|
dc1de72e18 | ||
|
|
7dddef4c94 | ||
|
|
a2d8b85549 | ||
|
|
4d68783b82 | ||
|
|
b05c3b2030 | ||
|
|
784ae9aa4b | ||
|
|
03b5956cbc | ||
|
|
2876c9a18e | ||
|
|
c4b7be3925 | ||
|
|
e2ffbdac1d | ||
|
|
0d197bb2eb | ||
|
|
55089bee7f | ||
|
|
b96fe74db1 | ||
|
|
2fc1ba3037 | ||
|
|
c67d37207e | ||
|
|
dba50cd1bf | ||
|
|
edf2c0d9ed | ||
|
|
35f7c36f28 | ||
|
|
3a24ecac63 | ||
|
|
50ec7682b5 | ||
|
|
3667993caf | ||
|
|
68e8cd1188 | ||
|
|
3eebf6d2cf | ||
|
|
34d0e00910 | ||
|
|
b8ab7190d2 | ||
|
|
59fe8a7917 | ||
|
|
d56070827b | ||
|
|
74dbd4bed3 | ||
|
|
807e82b21e | ||
|
|
66862e93c8 | ||
|
|
270461ad3b | ||
|
|
3edd1ab32d | ||
|
|
e77998e53c | ||
|
|
1421bc2bce | ||
|
|
cbbbfdda80 | ||
|
|
2d510081be | ||
|
|
704893ee1d | ||
|
|
88612913a6 | ||
|
|
ce6823ed38 | ||
|
|
9074d23b3c | ||
|
|
76d0e76fd5 | ||
|
|
5ec8e4ccc7 | ||
|
|
b8c29324b8 | ||
|
|
972f38242e | ||
|
|
7ce6c29f4d | ||
|
|
2c6ddd7f90 | ||
|
|
ae50b2e323 | ||
|
|
152cbc11a6 | ||
|
|
3a406de655 | ||
|
|
2e31cda08c | ||
|
|
094b7f47bd | ||
|
|
99ac349ecc | ||
|
|
8830e03cf3 | ||
|
|
706f3d6d65 | ||
|
|
12b49f5fa7 | ||
|
|
85327760b9 | ||
|
|
c94ed9c3d4 | ||
|
|
bc50735d2a | ||
|
|
d05e125fa9 | ||
|
|
48d595ca81 | ||
|
|
a156de5e75 | ||
|
|
a7003bf4d0 | ||
|
|
5fe75c84ff | ||
|
|
4549a04fc5 | ||
|
|
0ea6b912a3 | ||
|
|
9b86fa3d40 | ||
|
|
374f66c59f | ||
|
|
da43d881f1 | ||
|
|
9cbfa3a0ba | ||
|
|
77a925f33b | ||
|
|
3f7d7beedd | ||
|
|
40d91df732 | ||
|
|
c8e783a210 | ||
|
|
61fc24d35d | ||
|
|
73734b0de7 | ||
|
|
09bd831d54 | ||
|
|
2c9f1b999e | ||
|
|
6a3a185310 | ||
|
|
7d381b7aee | ||
|
|
bba5626dea | ||
|
|
bdf5e58929 | ||
|
|
035168230e | ||
|
|
28168f082d | ||
|
|
a29e09ed2f | ||
|
|
e88874e757 | ||
|
|
b6fb7c6977 | ||
|
|
8a60f17d13 | ||
|
|
e1d860a42c | ||
|
|
56da326709 | ||
|
|
672008f8af | ||
|
|
d86b16de6b | ||
|
|
d41710f507 | ||
|
|
2b53166763 | ||
|
|
4e56a16a58 | ||
|
|
ac7cc77017 | ||
|
|
9194879c43 | ||
|
|
1902cdaa55 | ||
|
|
6314987e69 | ||
|
|
65bcbd7737 | ||
|
|
9b25b8538e | ||
|
|
bbf8bb2a94 | ||
|
|
2085227cd9 | ||
|
|
93e0730210 | ||
|
|
25c5c11ab3 | ||
|
|
6ed5b5d35d | ||
|
|
673cc1e0df | ||
|
|
065dcf6f9f | ||
|
|
a66b16ce1c | ||
|
|
e4c0b30284 | ||
|
|
3cb37748e9 | ||
|
|
e54d76f0e2 | ||
|
|
f19e037011 | ||
|
|
ee3f47d918 | ||
|
|
a49a645022 | ||
|
|
cf30441ac6 | ||
|
|
c9bcf866ab | ||
|
|
506609f3cd | ||
|
|
721172c980 | ||
|
|
b183db5b80 | ||
|
|
c7f277f404 | ||
|
|
8ac03a9f8a | ||
|
|
dc1c386d7e | ||
|
|
1eaf4e5bab | ||
|
|
3c0f1c2e73 | ||
|
|
b2790776ef | ||
|
|
2840359396 | ||
|
|
c18ddb99a8 | ||
|
|
432435aa23 | ||
|
|
90c1a6f4ad | ||
|
|
0e8f478c2e | ||
|
|
5f23d5a5ab | ||
|
|
76821a4c2d | ||
|
|
e4a8cca90a | ||
|
|
6024ed4cd1 | ||
|
|
3811f8bbb8 | ||
|
|
306218e5df | ||
|
|
cabfe45608 | ||
|
|
4ade6a5911 | ||
|
|
10d45c3e20 | ||
|
|
6fb247ec06 | ||
|
|
1546258529 | ||
|
|
7ec83b8af6 | ||
|
|
21d0a87c17 | ||
|
|
528f59c1bf | ||
|
|
d1535c4cd8 | ||
|
|
da44d51a3b | ||
|
|
73ce10b26a | ||
|
|
13874b8830 | ||
|
|
ac66809a05 | ||
|
|
f5229d5dec | ||
|
|
222291d11d | ||
|
|
28f47e1093 | ||
|
|
7784346a97 | ||
|
|
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 |
No files matched your search
+20
-26
@@ -104,55 +104,46 @@ 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
|
||||||
|
|
||||||
rspec:
|
rspec:
|
||||||
name: RSpec
|
name: RSpec
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 15
|
||||||
|
container:
|
||||||
|
image: ruby:4.0.5-alpine
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:14
|
image: postgres:18
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: docuseal_test
|
POSTGRES_DB: docuseal_test
|
||||||
ports: ["5432:5432"]
|
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd "pg_isready -U postgres -d docuseal_test"
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Install packages
|
||||||
|
run: apk add --no-cache build-base git libpq-dev yaml-dev nodejs yarn tar zstd gzip wget unzip vips leptonica chromium
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install Ruby
|
|
||||||
uses: ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
ruby-version: 4.0.5
|
|
||||||
- name: Set up Node
|
|
||||||
uses: actions/setup-node@v1
|
|
||||||
with:
|
|
||||||
node-version: 20.19.0
|
|
||||||
- name: Install Chrome
|
|
||||||
uses: browser-actions/setup-chrome@latest
|
|
||||||
with:
|
|
||||||
chrome-version: 125
|
|
||||||
- name: Cache node_modules
|
- name: Cache node_modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: node_modules
|
path: node_modules
|
||||||
key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}
|
key: ${{ runner.os }}-musl-node-${{ hashFiles('**/yarn.lock') }}
|
||||||
- name: Cache gems
|
- name: Cache gems
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: vendor/bundle
|
path: vendor/bundle
|
||||||
key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
|
key: ${{ runner.os }}-musl-gem-${{ hashFiles('**/Gemfile.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-gem-
|
${{ runner.os }}-musl-gem-
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
RAILS_ENV: test
|
RAILS_ENV: test
|
||||||
@@ -161,17 +152,20 @@ jobs:
|
|||||||
bundle config path vendor/bundle
|
bundle config path vendor/bundle
|
||||||
bundle install --jobs 4 --retry 4
|
bundle install --jobs 4 --retry 4
|
||||||
yarn install
|
yarn install
|
||||||
sudo apt-get update
|
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260813/pdfium-musl-$(uname -m).zip"
|
||||||
sudo apt-get install -y libvips
|
case "$(uname -m)" in
|
||||||
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"
|
x86_64) echo "c5c7dde243ecb66ab0819c8193515ef38ad53549fe260f3c2dfd93ea56eda2e7 pdfium-linux.zip" ;;
|
||||||
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
|
aarch64) echo "64c4483449b1b4dccc696ad0c5c96e0b7f74dcc57b4f23c676b7a70671b0bbb5 pdfium-linux.zip" ;;
|
||||||
rm -f pdfium-linux.tgz
|
esac | sha256sum -c -
|
||||||
|
unzip -q pdfium-linux.zip -d /tmp/pdfium-linux
|
||||||
|
cp /tmp/pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
|
||||||
|
rm -rf pdfium-linux.zip /tmp/pdfium-linux
|
||||||
- name: Run
|
- name: Run
|
||||||
env:
|
env:
|
||||||
RAILS_ENV: test
|
RAILS_ENV: test
|
||||||
NODE_ENV: test
|
NODE_ENV: test
|
||||||
COVERAGE: true
|
COVERAGE: true
|
||||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/docuseal_test
|
DATABASE_URL: postgres://postgres:postgres@postgres:5432/docuseal_test
|
||||||
run: |
|
run: |
|
||||||
bundle exec rake db:create
|
bundle exec rake db:create
|
||||||
bundle exec rake db:migrate
|
bundle exec rake db:migrate
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Create .version file
|
- name: Create .version file
|
||||||
run: echo ${{ github.ref_name }} > .version
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: echo "$REF_NAME" > .version
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
|
|||||||
+10
-5
@@ -2,16 +2,20 @@ 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/20260813/pdfium-musl-$(uname -m).zip" && \
|
||||||
|
case "$(uname -m)" in \
|
||||||
|
x86_64) echo "c5c7dde243ecb66ab0819c8193515ef38ad53549fe260f3c2dfd93ea56eda2e7 pdfium-linux.zip" ;; \
|
||||||
|
aarch64) echo "64c4483449b1b4dccc696ad0c5c96e0b7f74dcc57b4f23c676b7a70671b0bbb5 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.5-alpine AS webpack
|
FROM ruby:4.0.5-alpine AS webpack
|
||||||
|
|
||||||
@@ -48,7 +52,8 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache libpq vips redis onnxruntime
|
RUN apk add --no-cache libpq vips redis onnxruntime leptonica && \
|
||||||
|
rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test
|
||||||
|
|
||||||
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+95
-105
@@ -1,31 +1,31 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
action_text-trix (2.1.18)
|
action_text-trix (2.1.19)
|
||||||
railties
|
railties
|
||||||
actioncable (8.1.3)
|
actioncable (8.1.3.1)
|
||||||
actionpack (= 8.1.3)
|
actionpack (= 8.1.3.1)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.3.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (8.1.3)
|
actionmailbox (8.1.3.1)
|
||||||
actionpack (= 8.1.3)
|
actionpack (= 8.1.3.1)
|
||||||
activejob (= 8.1.3)
|
activejob (= 8.1.3.1)
|
||||||
activerecord (= 8.1.3)
|
activerecord (= 8.1.3.1)
|
||||||
activestorage (= 8.1.3)
|
activestorage (= 8.1.3.1)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.3.1)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
actionmailer (8.1.3)
|
actionmailer (8.1.3.1)
|
||||||
actionpack (= 8.1.3)
|
actionpack (= 8.1.3.1)
|
||||||
actionview (= 8.1.3)
|
actionview (= 8.1.3.1)
|
||||||
activejob (= 8.1.3)
|
activejob (= 8.1.3.1)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.3.1)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (8.1.3)
|
actionpack (8.1.3.1)
|
||||||
actionview (= 8.1.3)
|
actionview (= 8.1.3.1)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.3.1)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
rack-session (>= 1.0.1)
|
rack-session (>= 1.0.1)
|
||||||
@@ -33,36 +33,36 @@ GEM
|
|||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (8.1.3)
|
actiontext (8.1.3.1)
|
||||||
action_text-trix (~> 2.1.15)
|
action_text-trix (~> 2.1.15)
|
||||||
actionpack (= 8.1.3)
|
actionpack (= 8.1.3.1)
|
||||||
activerecord (= 8.1.3)
|
activerecord (= 8.1.3.1)
|
||||||
activestorage (= 8.1.3)
|
activestorage (= 8.1.3.1)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.3.1)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (8.1.3)
|
actionview (8.1.3.1)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.3.1)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
activejob (8.1.3)
|
activejob (8.1.3.1)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.3.1)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (8.1.3)
|
activemodel (8.1.3.1)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.3.1)
|
||||||
activerecord (8.1.3)
|
activerecord (8.1.3.1)
|
||||||
activemodel (= 8.1.3)
|
activemodel (= 8.1.3.1)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.3.1)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activestorage (8.1.3)
|
activestorage (8.1.3.1)
|
||||||
actionpack (= 8.1.3)
|
actionpack (= 8.1.3.1)
|
||||||
activejob (= 8.1.3)
|
activejob (= 8.1.3.1)
|
||||||
activerecord (= 8.1.3)
|
activerecord (= 8.1.3.1)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.3.1)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (8.1.3)
|
activesupport (8.1.3.1)
|
||||||
base64
|
base64
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||||
@@ -144,12 +144,12 @@ 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.8)
|
||||||
connection_pool (3.0.2)
|
connection_pool (3.0.2)
|
||||||
crack (1.0.1)
|
crack (1.0.1)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.7)
|
||||||
csv (3.3.5)
|
csv (3.3.5)
|
||||||
csv-safe (3.3.1)
|
csv-safe (3.3.1)
|
||||||
csv (~> 3.0)
|
csv (~> 3.0)
|
||||||
@@ -179,7 +179,7 @@ GEM
|
|||||||
dotenv (3.2.0)
|
dotenv (3.2.0)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
email_typo (0.2.3)
|
email_typo (0.2.3)
|
||||||
erb (6.0.4)
|
erb (6.0.6)
|
||||||
erb_lint (0.9.0)
|
erb_lint (0.9.0)
|
||||||
activesupport
|
activesupport
|
||||||
better_html (>= 2.0.1)
|
better_html (>= 2.0.1)
|
||||||
@@ -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.2)
|
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)
|
||||||
@@ -217,7 +217,7 @@ GEM
|
|||||||
foreman (0.90.0)
|
foreman (0.90.0)
|
||||||
thor (~> 1.4)
|
thor (~> 1.4)
|
||||||
geom2d (0.4.1)
|
geom2d (0.4.1)
|
||||||
globalid (1.3.0)
|
globalid (1.4.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
google-apis-core (1.0.2)
|
google-apis-core (1.0.2)
|
||||||
addressable (~> 2.8, >= 2.8.7)
|
addressable (~> 2.8, >= 2.8.7)
|
||||||
@@ -262,11 +262,8 @@ GEM
|
|||||||
geom2d (~> 0.4, >= 0.4.1)
|
geom2d (~> 0.4, >= 0.4.1)
|
||||||
openssl (>= 2.2.1)
|
openssl (>= 2.2.1)
|
||||||
strscan (>= 3.1.2)
|
strscan (>= 3.1.2)
|
||||||
i18n (1.14.8)
|
i18n (1.15.2)
|
||||||
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.18.0)
|
irb (1.18.0)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
@@ -274,7 +271,7 @@ GEM
|
|||||||
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.5)
|
json (2.21.2)
|
||||||
jwt (3.2.0)
|
jwt (3.2.0)
|
||||||
base64
|
base64
|
||||||
language_server-protocol (3.17.0.5)
|
language_server-protocol (3.17.0.5)
|
||||||
@@ -296,29 +293,27 @@ GEM
|
|||||||
activesupport (>= 4)
|
activesupport (>= 4)
|
||||||
railties (>= 4)
|
railties (>= 4)
|
||||||
request_store (~> 1.0)
|
request_store (~> 1.0)
|
||||||
loofah (2.25.1)
|
loofah (2.25.2)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.9.0)
|
mail (2.9.1)
|
||||||
logger
|
logger
|
||||||
mini_mime (>= 0.1.1)
|
mini_mime (>= 0.1.1)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.1.0)
|
marcel (1.2.1)
|
||||||
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.6)
|
minitest (6.0.6)
|
||||||
drb (~> 2.0)
|
drb (~> 2.0)
|
||||||
prism (~> 1.5)
|
prism (~> 1.5)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.4)
|
||||||
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.6)
|
||||||
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,9 +343,8 @@ 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.6.1)
|
||||||
json
|
json
|
||||||
uri
|
uri
|
||||||
yaml
|
yaml
|
||||||
@@ -366,7 +357,7 @@ GEM
|
|||||||
pg (1.6.3-arm64-darwin)
|
pg (1.6.3-arm64-darwin)
|
||||||
pg (1.6.3-x86_64-linux)
|
pg (1.6.3-x86_64-linux)
|
||||||
pg (1.6.3-x86_64-linux-musl)
|
pg (1.6.3-x86_64-linux-musl)
|
||||||
pp (0.6.3)
|
pp (0.6.4)
|
||||||
prettyprint
|
prettyprint
|
||||||
pretender (1.0.0)
|
pretender (1.0.0)
|
||||||
actionpack (>= 7.2)
|
actionpack (>= 7.2)
|
||||||
@@ -378,11 +369,8 @@ GEM
|
|||||||
reline (>= 0.6.0)
|
reline (>= 0.6.0)
|
||||||
pry-rails (0.3.11)
|
pry-rails (0.3.11)
|
||||||
pry (>= 0.13.0)
|
pry (>= 0.13.0)
|
||||||
psych (5.3.1)
|
|
||||||
date
|
|
||||||
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)
|
||||||
@@ -395,33 +383,33 @@ GEM
|
|||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.3.1)
|
rackup (2.3.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
rails (8.1.3)
|
rails (8.1.3.1)
|
||||||
actioncable (= 8.1.3)
|
actioncable (= 8.1.3.1)
|
||||||
actionmailbox (= 8.1.3)
|
actionmailbox (= 8.1.3.1)
|
||||||
actionmailer (= 8.1.3)
|
actionmailer (= 8.1.3.1)
|
||||||
actionpack (= 8.1.3)
|
actionpack (= 8.1.3.1)
|
||||||
actiontext (= 8.1.3)
|
actiontext (= 8.1.3.1)
|
||||||
actionview (= 8.1.3)
|
actionview (= 8.1.3.1)
|
||||||
activejob (= 8.1.3)
|
activejob (= 8.1.3.1)
|
||||||
activemodel (= 8.1.3)
|
activemodel (= 8.1.3.1)
|
||||||
activerecord (= 8.1.3)
|
activerecord (= 8.1.3.1)
|
||||||
activestorage (= 8.1.3)
|
activestorage (= 8.1.3.1)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.3.1)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.1.3)
|
railties (= 8.1.3.1)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
rails-html-sanitizer (1.7.0)
|
rails-html-sanitizer (1.7.1)
|
||||||
loofah (~> 2.25)
|
loofah (~> 2.25, >= 2.25.2)
|
||||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||||
rails-i18n (8.1.0)
|
rails-i18n (8.1.0)
|
||||||
i18n (>= 0.7, < 2)
|
i18n (>= 0.7, < 2)
|
||||||
railties (>= 8.0.0, < 9)
|
railties (>= 8.0.0, < 9)
|
||||||
railties (8.1.3)
|
railties (8.1.3.1)
|
||||||
actionpack (= 8.1.3)
|
actionpack (= 8.1.3.1)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.3.1)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
@@ -430,11 +418,16 @@ GEM
|
|||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.4.2)
|
rake (13.4.2)
|
||||||
rdoc (7.2.0)
|
rbs (4.1.0)
|
||||||
erb
|
logger
|
||||||
psych (>= 4.0.0)
|
prism (>= 1.6.0)
|
||||||
tsort
|
tsort
|
||||||
redis-client (0.28.0)
|
rdoc (8.0.0)
|
||||||
|
erb
|
||||||
|
prism (>= 1.6.0)
|
||||||
|
rbs (>= 4.0.0)
|
||||||
|
tsort
|
||||||
|
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 +509,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)
|
||||||
@@ -534,12 +527,11 @@ GEM
|
|||||||
simplecov-html (0.13.2)
|
simplecov-html (0.13.2)
|
||||||
simplecov_json_formatter (0.1.4)
|
simplecov_json_formatter (0.1.4)
|
||||||
smart_properties (1.17.0)
|
smart_properties (1.17.0)
|
||||||
sqlite3 (2.9.2-aarch64-linux-gnu)
|
sqlite3 (2.9.5-aarch64-linux-gnu)
|
||||||
sqlite3 (2.9.2-aarch64-linux-musl)
|
sqlite3 (2.9.5-aarch64-linux-musl)
|
||||||
sqlite3 (2.9.2-arm64-darwin)
|
sqlite3 (2.9.5-arm64-darwin)
|
||||||
sqlite3 (2.9.2-x86_64-linux-gnu)
|
sqlite3 (2.9.5-x86_64-linux-gnu)
|
||||||
sqlite3 (2.9.2-x86_64-linux-musl)
|
sqlite3 (2.9.5-x86_64-linux-musl)
|
||||||
stringio (3.2.0)
|
|
||||||
strip_attributes (2.0.1)
|
strip_attributes (2.0.1)
|
||||||
activemodel (>= 3.0, < 9.0)
|
activemodel (>= 3.0, < 9.0)
|
||||||
strscan (3.1.8)
|
strscan (3.1.8)
|
||||||
@@ -579,14 +571,14 @@ GEM
|
|||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
webrick (1.9.2)
|
webrick (1.9.2)
|
||||||
websocket-driver (0.8.0)
|
websocket-driver (0.8.2)
|
||||||
base64
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
yaml (0.4.0)
|
yaml (0.4.0)
|
||||||
zeitwerk (2.7.5)
|
zeitwerk (2.8.2)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
aarch64-linux
|
aarch64-linux
|
||||||
@@ -624,12 +616,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
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class AccountConfigsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
raise InvalidKey unless allowed_keys.include?(@account_config.key)
|
raise InvalidKey unless allowed_destroy_keys.include?(@account_config.key)
|
||||||
|
|
||||||
@account_config.destroy!
|
@account_config.destroy!
|
||||||
|
|
||||||
@@ -50,6 +50,10 @@ class AccountConfigsController < ApplicationController
|
|||||||
ALLOWED_KEYS
|
ALLOWED_KEYS
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def allowed_destroy_keys
|
||||||
|
ALLOWED_KEYS
|
||||||
|
end
|
||||||
|
|
||||||
def load_account_config
|
def load_account_config
|
||||||
raise InvalidKey unless allowed_keys.include?(account_config_params[:key])
|
raise InvalidKey unless allowed_keys.include?(account_config_params[:key])
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ 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
|
||||||
|
|
||||||
@@ -33,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, file)
|
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?
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class ApplicationController < ActionController::Base
|
|||||||
rescue_from RateLimit::LimitApproached do |e|
|
rescue_from RateLimit::LimitApproached do |e|
|
||||||
Rollbar.error(e) if defined?(Rollbar)
|
Rollbar.error(e) if defined?(Rollbar)
|
||||||
|
|
||||||
redirect_to request.referer, alert: 'Too many requests', status: :too_many_requests
|
redirect_back_or_to root_path, alert: 'Too many requests'
|
||||||
end
|
end
|
||||||
|
|
||||||
if Rails.env.production? || Rails.env.test?
|
if Rails.env.production? || Rails.env.test?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ class EsignSettingsController < ApplicationController
|
|||||||
prepend_before_action :maybe_redirect_com, only: %i[show]
|
prepend_before_action :maybe_redirect_com, only: %i[show]
|
||||||
|
|
||||||
before_action :load_encrypted_config
|
before_action :load_encrypted_config
|
||||||
authorize_resource :encrypted_config, parent: false, only: %i[new create]
|
authorize_resource :encrypted_config, parent: false
|
||||||
authorize_resource :encrypted_config, only: %i[update destroy show]
|
|
||||||
|
|
||||||
def show
|
def show
|
||||||
cert_data = @encrypted_config.value || {}
|
cert_data = @encrypted_config.value || {}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Mcp
|
||||||
|
class CreateTemplateController < McpBaseController
|
||||||
|
SCHEMA = {
|
||||||
|
name: 'create_template',
|
||||||
|
title: 'Create Template',
|
||||||
|
description: 'Create a document template. Provide a URL to upload a PDF/DOCX file, or provide only a name ' \
|
||||||
|
'to create an empty template and receive an edit URL where the file can be uploaded via the UI.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Template name (used as the template name and required when url is not provided)'
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Optional URL of a PDF or DOCX file to upload. If omitted, an empty template is ' \
|
||||||
|
'created and the returned edit_url can be used to upload a file via the UI.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: %w[name]
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: true
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
||||||
|
def call
|
||||||
|
account = current_user.account
|
||||||
|
|
||||||
|
@template = Template.new(
|
||||||
|
account:,
|
||||||
|
author: current_user,
|
||||||
|
folder: account.default_template_folder,
|
||||||
|
source: :mcp,
|
||||||
|
name: mcp_params['name'].to_s.presence || 'New Template',
|
||||||
|
fields: [],
|
||||||
|
schema: []
|
||||||
|
)
|
||||||
|
|
||||||
|
authorize!(:create, @template)
|
||||||
|
|
||||||
|
if mcp_params['url'].present?
|
||||||
|
tempfile = Tempfile.new
|
||||||
|
tempfile.binmode
|
||||||
|
tempfile.write(DownloadUtils.call(mcp_params['url'], validate: true).body)
|
||||||
|
tempfile.rewind
|
||||||
|
|
||||||
|
filename = File.basename(URI.decode_www_form_component(mcp_params['url']))
|
||||||
|
|
||||||
|
file = ActionDispatch::Http::UploadedFile.new(
|
||||||
|
tempfile:,
|
||||||
|
filename:,
|
||||||
|
type: Marcel::MimeType.for(tempfile)
|
||||||
|
)
|
||||||
|
|
||||||
|
@template.name = mcp_params['name'].presence || File.basename(filename, '.*')
|
||||||
|
@template.save!
|
||||||
|
|
||||||
|
documents, = Templates::CreateAttachments.call(@template, { files: [file] }, extract_fields: true)
|
||||||
|
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
|
||||||
|
|
||||||
|
if @template.fields.blank?
|
||||||
|
@template.fields = Templates::ProcessDocument.normalize_attachment_fields(@template, documents)
|
||||||
|
end
|
||||||
|
|
||||||
|
@template.update!(schema:)
|
||||||
|
else
|
||||||
|
@template.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
WebhookUrls.enqueue_events(@template, 'template.created')
|
||||||
|
|
||||||
|
SearchEntries.enqueue_reindex(@template)
|
||||||
|
|
||||||
|
render_tool_result(
|
||||||
|
id: @template.id,
|
||||||
|
name: @template.name,
|
||||||
|
edit_url: edit_template_url(@template)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Mcp
|
||||||
|
class LoadTemplateController < McpBaseController
|
||||||
|
SCHEMA = {
|
||||||
|
name: 'load_template',
|
||||||
|
title: 'Load Template',
|
||||||
|
description: 'Load a template with its fields. Each field includes name, type, and the signing role name.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
template_id: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'Template identifier'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: %w[template_id]
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: false
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def call
|
||||||
|
@template = Template.accessible_by(current_ability).find(mcp_params['template_id'])
|
||||||
|
|
||||||
|
authorize!(:read, @template)
|
||||||
|
|
||||||
|
submitters_index = @template.submitters.index_by { |s| s['uuid'] }
|
||||||
|
|
||||||
|
roles = @template.submitters.pluck('name')
|
||||||
|
|
||||||
|
fields = @template.fields.filter_map do |field|
|
||||||
|
next if field['name'].blank?
|
||||||
|
|
||||||
|
{
|
||||||
|
name: field['name'],
|
||||||
|
type: field['type'],
|
||||||
|
role: submitters_index[field['submitter_uuid']]&.dig('name')
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
render_tool_result(
|
||||||
|
id: @template.id,
|
||||||
|
name: @template.name,
|
||||||
|
roles: roles,
|
||||||
|
fields: fields
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Mcp
|
||||||
|
class McpBaseController < ActionController::API
|
||||||
|
wrap_parameters false
|
||||||
|
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :verify_mcp_enabled!
|
||||||
|
check_authorization
|
||||||
|
|
||||||
|
before_action do
|
||||||
|
raise CanCan::AccessDenied unless can?(:manage, :mcp)
|
||||||
|
end
|
||||||
|
|
||||||
|
rescue_from CanCan::AccessDenied do
|
||||||
|
render_error(-32_603, 'Forbidden', status: :forbidden)
|
||||||
|
end
|
||||||
|
|
||||||
|
rescue_from ActiveRecord::RecordNotFound do
|
||||||
|
render_tool_error('Not found')
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def default_url_options
|
||||||
|
Docuseal.default_url_options
|
||||||
|
end
|
||||||
|
|
||||||
|
def mcp_body
|
||||||
|
request.request_parameters
|
||||||
|
end
|
||||||
|
|
||||||
|
def mcp_params
|
||||||
|
mcp_body.dig('params', 'arguments') || {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_result(result)
|
||||||
|
render json: { jsonrpc: '2.0', id: mcp_body['id'], result: }
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_error(code, message, id: nil, status: :ok)
|
||||||
|
render json: { jsonrpc: '2.0', id:, error: { code:, message: } }, status:
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_tool_result(data)
|
||||||
|
render_result(content: [{ type: 'text', text: data.to_json }])
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_tool_error(message)
|
||||||
|
render_result(content: [{ type: 'text', text: message }], isError: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def authenticate_user!
|
||||||
|
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify_mcp_enabled!
|
||||||
|
return if Docuseal.multitenant?
|
||||||
|
|
||||||
|
return if AccountConfig.exists?(account_id: current_user.account_id,
|
||||||
|
key: AccountConfig::ENABLE_MCP_KEY,
|
||||||
|
value: true)
|
||||||
|
|
||||||
|
render json: { error: 'MCP is disabled' }, status: :forbidden
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_user
|
||||||
|
@current_user ||= user_from_api_key
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_from_api_key
|
||||||
|
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
|
||||||
|
|
||||||
|
return if token.blank?
|
||||||
|
|
||||||
|
sha256 = Digest::SHA256.hexdigest(token)
|
||||||
|
|
||||||
|
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Mcp
|
||||||
|
class ProtocolController < McpBaseController
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
|
def ok
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize_request
|
||||||
|
render_result(
|
||||||
|
protocolVersion: '2025-11-25',
|
||||||
|
serverInfo: {
|
||||||
|
name: 'DocuSeal',
|
||||||
|
version: Docuseal.version.to_s
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
tools: {
|
||||||
|
listChanged: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialized_notification
|
||||||
|
head :accepted
|
||||||
|
end
|
||||||
|
|
||||||
|
def ping
|
||||||
|
render_result({})
|
||||||
|
end
|
||||||
|
|
||||||
|
def tools_list
|
||||||
|
render_result(tools: McpController::TOOLS)
|
||||||
|
end
|
||||||
|
|
||||||
|
def method_not_found
|
||||||
|
render_error(-32_601, "Method not found: #{mcp_body['method']}", id: mcp_body['id'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def tool_not_found
|
||||||
|
render_error(-32_602, "Unknown tool: #{mcp_body.dig('params', 'name')}", id: mcp_body['id'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_error
|
||||||
|
render_error(-32_700, 'Parse error', status: :bad_request)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Mcp
|
||||||
|
class SearchDocumentsController < McpBaseController
|
||||||
|
SCHEMA = {
|
||||||
|
name: 'search_documents',
|
||||||
|
title: 'Search Documents',
|
||||||
|
description: 'Search signed or pending documents by submitter name, email, phone, or template name',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
q: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Search by submitter name, email, phone, or template name'
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'The number of results to return (default 10)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: %w[q]
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: false
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def call
|
||||||
|
authorize!(:read, Submission)
|
||||||
|
|
||||||
|
submissions = Submissions.search(current_user, Submission.accessible_by(current_ability).active,
|
||||||
|
mcp_params['q'], search_template: true)
|
||||||
|
|
||||||
|
limit = mcp_params.fetch('limit', 10).to_i
|
||||||
|
limit = 10 if limit <= 0
|
||||||
|
limit = [limit, 100].min
|
||||||
|
submissions = submissions.preload(:submitters, :template)
|
||||||
|
.order(id: :desc)
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
data = submissions.map do |submission|
|
||||||
|
{
|
||||||
|
id: submission.id,
|
||||||
|
template_name: submission.template&.name,
|
||||||
|
status: Submissions::SerializeForApi.build_status(submission, submission.submitters),
|
||||||
|
submitters: submission.submitters.map do |s|
|
||||||
|
{ email: s.email, name: s.name, phone: s.phone, status: s.status }
|
||||||
|
end,
|
||||||
|
documents_url: submission_url(submission.id)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
render_tool_result(data)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Mcp
|
||||||
|
class SearchTemplatesController < McpBaseController
|
||||||
|
SCHEMA = {
|
||||||
|
name: 'search_templates',
|
||||||
|
title: 'Search Templates',
|
||||||
|
description: 'Search document templates by name',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
q: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Search query to filter templates by name'
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'The number of templates to return (default 10)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: %w[q]
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: false
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def call
|
||||||
|
authorize!(:read, Template)
|
||||||
|
|
||||||
|
templates = Templates.search(current_user, Template.accessible_by(current_ability).active, mcp_params['q'])
|
||||||
|
|
||||||
|
limit = mcp_params.fetch('limit', 10).to_i
|
||||||
|
limit = 10 if limit <= 0
|
||||||
|
limit = [limit, 100].min
|
||||||
|
templates = templates.order(id: :desc).limit(limit)
|
||||||
|
|
||||||
|
render_tool_result(templates.map { |t| { id: t.id, name: t.name } })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Mcp
|
||||||
|
class SendDocumentsController < McpBaseController
|
||||||
|
SCHEMA = {
|
||||||
|
name: 'send_documents',
|
||||||
|
title: 'Send Documents',
|
||||||
|
description: 'Send a document template for signing to specified submitters',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
template_id: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'Template identifier'
|
||||||
|
},
|
||||||
|
submitters: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'The list of submitters (signers)',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
email: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Submitter email address'
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Submitter name'
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Submitter phone number in E.164 format'
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Signing role name from the template'
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Prefill field values for this submitter (fields become readonly)',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Field name'
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
description: 'Prefilled value for the field'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: %w[name value]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: %w[template_id submitters]
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: true
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# rubocop:disable Metrics
|
||||||
|
def call
|
||||||
|
@template = Template.accessible_by(current_ability).find(mcp_params['template_id'])
|
||||||
|
|
||||||
|
authorize!(:read, @template)
|
||||||
|
|
||||||
|
return render_tool_error('Template has been archived') if @template.archived_at?
|
||||||
|
|
||||||
|
authorize!(:create, Submission.new(template: @template, account_id: current_user.account_id))
|
||||||
|
|
||||||
|
return render_tool_error('Template has no fields') if @template.fields.blank?
|
||||||
|
|
||||||
|
submitters = (mcp_params['submitters'] || []).map do |s|
|
||||||
|
attrs = s.slice('email', 'name', 'role', 'phone').compact_blank
|
||||||
|
|
||||||
|
fields = Array.wrap(s['fields']).filter_map do |f|
|
||||||
|
next if f['name'].blank?
|
||||||
|
|
||||||
|
{ 'name' => f['name'], 'default_value' => f['value'], 'readonly' => true }
|
||||||
|
end
|
||||||
|
|
||||||
|
attrs['fields'] = fields if fields.present?
|
||||||
|
|
||||||
|
attrs.with_indifferent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
submissions = Submissions.create_from_submitters(
|
||||||
|
template: @template,
|
||||||
|
user: current_user,
|
||||||
|
source: :mcp,
|
||||||
|
submitters_order: @template.preferences['submitters_order'].presence || 'random',
|
||||||
|
submissions_attrs: { submitters: },
|
||||||
|
params: { 'send_email' => true, 'submitters' => submitters }
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_tool_error('No valid submitters provided') if submissions.blank?
|
||||||
|
|
||||||
|
WebhookUrls.enqueue_events(submissions, 'submission.created')
|
||||||
|
|
||||||
|
Submissions.send_signature_requests(submissions)
|
||||||
|
|
||||||
|
SearchEntries.enqueue_reindex(submissions)
|
||||||
|
|
||||||
|
submission = submissions.first
|
||||||
|
|
||||||
|
render_tool_result(id: submission.id, status: 'pending')
|
||||||
|
rescue Submissions::CreateFromSubmitters::BaseError => e
|
||||||
|
render_tool_error(e.message)
|
||||||
|
end
|
||||||
|
# rubocop:enable Metrics
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,58 +1,44 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class McpController < ActionController::API
|
class McpController < ActionController::Metal
|
||||||
before_action :authenticate_user!
|
TOOL_CONTROLLERS = {
|
||||||
before_action :verify_mcp_enabled!
|
'search_templates' => Mcp::SearchTemplatesController,
|
||||||
|
'load_template' => Mcp::LoadTemplateController,
|
||||||
|
'create_template' => Mcp::CreateTemplateController,
|
||||||
|
'send_documents' => Mcp::SendDocumentsController,
|
||||||
|
'search_documents' => Mcp::SearchDocumentsController
|
||||||
|
}.freeze
|
||||||
|
|
||||||
before_action do
|
TOOLS = TOOL_CONTROLLERS.map { |_, controller| controller::SCHEMA }.freeze
|
||||||
authorize!(:manage, :mcp)
|
|
||||||
end
|
|
||||||
|
|
||||||
def call
|
def call
|
||||||
return head :ok if request.raw_post.blank?
|
return Mcp::ProtocolController.dispatch(:ok, request, response) if request.raw_post.blank?
|
||||||
|
|
||||||
body = JSON.parse(request.raw_post)
|
body = JSON.parse(request.raw_post)
|
||||||
|
body = nil unless body.is_a?(Hash)
|
||||||
|
|
||||||
result = Mcp::HandleRequest.call(body, current_user, current_ability)
|
request.request_parameters = body || {}
|
||||||
|
|
||||||
if result
|
action =
|
||||||
render json: result
|
case body&.dig('method')
|
||||||
|
when 'initialize' then :initialize_request
|
||||||
|
when 'notifications/initialized' then :initialized_notification
|
||||||
|
when 'ping' then :ping
|
||||||
|
when 'tools/list' then :tools_list
|
||||||
|
when 'tools/call'
|
||||||
|
tool = TOOL_CONTROLLERS[body.dig('params', 'name')]
|
||||||
|
|
||||||
|
return tool.dispatch(:call, request, response) if tool
|
||||||
|
|
||||||
|
:tool_not_found
|
||||||
else
|
else
|
||||||
head :accepted
|
:method_not_found
|
||||||
end
|
end
|
||||||
rescue CanCan::AccessDenied
|
|
||||||
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_603, message: 'Forbidden' } }, status: :forbidden
|
Mcp::ProtocolController.dispatch(action, request, response)
|
||||||
rescue JSON::ParserError
|
rescue JSON::ParserError
|
||||||
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_700, message: 'Parse error' } }, status: :bad_request
|
request.request_parameters = {}
|
||||||
end
|
|
||||||
|
|
||||||
private
|
Mcp::ProtocolController.dispatch(:parse_error, request, response)
|
||||||
|
|
||||||
def authenticate_user!
|
|
||||||
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
|
|
||||||
end
|
|
||||||
|
|
||||||
def verify_mcp_enabled!
|
|
||||||
return if Docuseal.multitenant?
|
|
||||||
|
|
||||||
return if AccountConfig.exists?(account_id: current_user.account_id,
|
|
||||||
key: AccountConfig::ENABLE_MCP_KEY,
|
|
||||||
value: true)
|
|
||||||
|
|
||||||
render json: { error: 'MCP is disabled' }, status: :forbidden
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_user
|
|
||||||
@current_user ||= user_from_api_key
|
|
||||||
end
|
|
||||||
|
|
||||||
def user_from_api_key
|
|
||||||
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
|
|
||||||
|
|
||||||
return if token.blank?
|
|
||||||
|
|
||||||
sha256 = Digest::SHA256.hexdigest(token)
|
|
||||||
|
|
||||||
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -20,6 +20,8 @@ class MfaSetupController < ApplicationController
|
|||||||
|
|
||||||
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_configured')
|
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_configured')
|
||||||
else
|
else
|
||||||
|
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
|
||||||
|
|
||||||
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
|
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
|
||||||
|
|
||||||
@error_message = I18n.t('code_is_invalid')
|
@error_message = I18n.t('code_is_invalid')
|
||||||
@@ -34,6 +36,8 @@ class MfaSetupController < ApplicationController
|
|||||||
|
|
||||||
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed')
|
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed')
|
||||||
else
|
else
|
||||||
|
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
|
||||||
|
|
||||||
@error_message = I18n.t('code_is_invalid')
|
@error_message = I18n.t('code_is_invalid')
|
||||||
|
|
||||||
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_content
|
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_content
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class NotificationsSettingsController < ApplicationController
|
|||||||
|
|
||||||
before_action :build_account_config, only: :create
|
before_action :build_account_config, only: :create
|
||||||
authorize_resource :account_config, only: :create
|
authorize_resource :account_config, only: :create
|
||||||
|
before_action :authorize_email_reminders!, only: :create
|
||||||
|
|
||||||
def index; end
|
def index; end
|
||||||
|
|
||||||
@@ -21,6 +22,14 @@ class NotificationsSettingsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def authorize_email_reminders!
|
||||||
|
return unless Docuseal.multitenant?
|
||||||
|
return if @account_config.key != AccountConfig::SUBMITTER_REMINDERS
|
||||||
|
return if can?(:manage, :email_reminders)
|
||||||
|
|
||||||
|
redirect_back fallback_location: settings_notifications_path, alert: I18n.t('unlock_with_docuseal_pro')
|
||||||
|
end
|
||||||
|
|
||||||
def build_account_config
|
def build_account_config
|
||||||
@account_config =
|
@account_config =
|
||||||
AccountConfig.find_or_initialize_by(account: current_account, key: email_config_params[:key])
|
AccountConfig.find_or_initialize_by(account: current_account, key: email_config_params[:key])
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ class PreviewDocumentPageController < ActionController::API
|
|||||||
|
|
||||||
FORMAT = Templates::ProcessDocument::FORMAT
|
FORMAT = Templates::ProcessDocument::FORMAT
|
||||||
|
|
||||||
|
TMPFILE_PREFIX = 'attachment-'
|
||||||
|
TMPFILE_TTL = 5.minutes
|
||||||
|
TMPFILE_MAX_TOTAL_SIZE = Docuseal.multitenant? ? 400.megabytes : 1.gigabyte
|
||||||
|
|
||||||
def show
|
def show
|
||||||
result_data =
|
result_data =
|
||||||
ApplicationRecord.signed_id_verifier.verified(params[:signed_key], purpose: :attachment)
|
ApplicationRecord.signed_id_verifier.verified(params[:signed_key], purpose: :attachment)
|
||||||
@@ -25,36 +29,68 @@ 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),
|
||||||
file_path =
|
allow_other_host: true
|
||||||
if attachment.service.name == :disk
|
|
||||||
ActiveStorage::Blob.service.path_for(attachment.key)
|
|
||||||
else
|
|
||||||
find_or_create_document_tempfile_path(attachment)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
preview_image =
|
preview_image =
|
||||||
Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
|
open_attachment_io(attachment) do |io|
|
||||||
|
Templates::ProcessDocument.generate_pdf_preview_from_io(attachment, io, params[:id].to_i)
|
||||||
redirect_to preview_image.url, allow_other_host: true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_or_create_document_tempfile_path(attachment)
|
redirect_to preview_image.url(time: ActiveStorage::Attachment.service_url_time), allow_other_host: true
|
||||||
file_path = "#{Dir.tmpdir}/attachment-#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}"
|
end
|
||||||
|
|
||||||
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
|
def open_attachment_io(attachment, &)
|
||||||
f.flock(File::LOCK_EX)
|
return File.open(ActiveStorage::Blob.service.path_for(attachment.key), 'rb', &) if attachment.service.name == :disk
|
||||||
|
|
||||||
|
file_name = "#{TMPFILE_PREFIX}#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}"
|
||||||
|
|
||||||
|
file_path = File.join(Dir.tmpdir, file_name)
|
||||||
|
|
||||||
|
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |file|
|
||||||
|
file.flock(File::LOCK_EX)
|
||||||
|
|
||||||
# rubocop:disable Style/ZeroLengthPredicate
|
# rubocop:disable Style/ZeroLengthPredicate
|
||||||
if f.size.zero?
|
if file.size.zero?
|
||||||
f.binmode
|
cleanup_stale_tempfiles
|
||||||
|
|
||||||
f.write(attachment.download)
|
file.binmode
|
||||||
|
|
||||||
|
file.write(attachment.download)
|
||||||
|
else
|
||||||
|
FileUtils.touch(file_path)
|
||||||
end
|
end
|
||||||
# rubocop:enable Style/ZeroLengthPredicate
|
# rubocop:enable Style/ZeroLengthPredicate
|
||||||
|
|
||||||
|
file.flock(File::LOCK_UN)
|
||||||
|
|
||||||
|
yield file
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
file_path
|
def cleanup_stale_tempfiles
|
||||||
|
entries =
|
||||||
|
Dir.glob(File.join(Dir.tmpdir, "#{TMPFILE_PREFIX}*")).filter_map do |path|
|
||||||
|
stat = File.stat(path)
|
||||||
|
|
||||||
|
[path, stat.mtime, stat.size]
|
||||||
|
rescue Errno::ENOENT
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
total_size = entries.sum(&:last)
|
||||||
|
stale_time = TMPFILE_TTL.ago
|
||||||
|
|
||||||
|
entries.sort_by(&:second).each do |path, mtime, size|
|
||||||
|
break if mtime > stale_time && total_size <= TMPFILE_MAX_TOTAL_SIZE
|
||||||
|
|
||||||
|
File.unlink(path)
|
||||||
|
|
||||||
|
total_size -= size
|
||||||
|
rescue Errno::ENOENT
|
||||||
|
nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class SettingsController < ApplicationController
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
|
def index; end
|
||||||
|
end
|
||||||
@@ -8,13 +8,9 @@ class StartFormController < ApplicationController
|
|||||||
|
|
||||||
around_action :with_browser_locale, only: %i[show update completed]
|
around_action :with_browser_locale, only: %i[show update completed]
|
||||||
before_action :maybe_redirect_com, only: %i[show completed]
|
before_action :maybe_redirect_com, only: %i[show completed]
|
||||||
before_action :load_resubmit_submitter, only: :update
|
|
||||||
before_action :load_template
|
before_action :load_template
|
||||||
before_action :authorize_start!, only: :update
|
before_action :authorize_start!, only: :update
|
||||||
|
|
||||||
COOKIES_TTL = 12.hours
|
|
||||||
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
|
|
||||||
|
|
||||||
def show
|
def show
|
||||||
if @template.preferences['require_phone_2fa'] || @template.preferences['require_email_2fa']
|
if @template.preferences['require_phone_2fa'] || @template.preferences['require_email_2fa']
|
||||||
raise ActionController::RoutingError, I18n.t('not_found')
|
raise ActionController::RoutingError, I18n.t('not_found')
|
||||||
@@ -23,8 +19,7 @@ class StartFormController < ApplicationController
|
|||||||
if @template.shared_link?
|
if @template.shared_link?
|
||||||
@submitter = @template.submissions.new(account_id: @template.account_id)
|
@submitter = @template.submissions.new(account_id: @template.account_id)
|
||||||
.submitters.new(account_id: @template.account_id,
|
.submitters.new(account_id: @template.account_id,
|
||||||
uuid: (filter_undefined_submitters(@template).first ||
|
uuid: Submitters::StartForm.first_submitter_uuid(@template))
|
||||||
@template.submitters.first)['uuid'])
|
|
||||||
render :email_verification if params[:email_verification]
|
render :email_verification if params[:email_verification]
|
||||||
else
|
else
|
||||||
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
|
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
|
||||||
@@ -41,24 +36,28 @@ class StartFormController < ApplicationController
|
|||||||
if @submitter.completed_at?
|
if @submitter.completed_at?
|
||||||
redirect_to start_form_completed_path(@template.slug, submitter_params.compact_blank)
|
redirect_to start_form_completed_path(@template.slug, submitter_params.compact_blank)
|
||||||
else
|
else
|
||||||
if filter_undefined_submitters(@template).size > 1 && @submitter.new_record?
|
if Templates.filter_undefined_submitters(@template.submitters).size > 1 && @submitter.new_record?
|
||||||
@error_message = multiple_submitters_error_message
|
@error_message = multiple_submitters_error_message
|
||||||
|
|
||||||
return render :show, status: :unprocessable_content
|
return render :show, status: :unprocessable_content
|
||||||
end
|
end
|
||||||
|
|
||||||
if (is_new_record = @submitter.new_record?)
|
if (is_new_record = @submitter.new_record?)
|
||||||
assign_submission_attributes(@submitter, @template)
|
Submitters::StartForm.assign_submission_attributes(
|
||||||
|
@submitter, @template, ip: request.remote_ip, user_agent: request.user_agent
|
||||||
|
)
|
||||||
|
|
||||||
Submissions::AssignDefinedSubmitters.call(@submitter.submission)
|
Submissions::AssignDefinedSubmitters.call(@submitter.submission)
|
||||||
else
|
else
|
||||||
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
|
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
|
||||||
end
|
end
|
||||||
|
|
||||||
if @template.preferences['shared_link_2fa'] == true
|
if require_link_2fa?(@template, @submitter)
|
||||||
handle_require_2fa(@submitter, is_new_record:)
|
handle_require_2fa(@template, @submitter)
|
||||||
elsif @submitter.errors.blank? && @submitter.save
|
elsif @submitter.errors.blank? && @submitter.save
|
||||||
enqueue_new_submitter_jobs(@submitter) if is_new_record
|
Submitters::StartForm.enqueue_new_submitter_jobs(@submitter) if is_new_record
|
||||||
|
|
||||||
|
Submitters::StartForm.assign_start_form_cookie(@submitter, request)
|
||||||
|
|
||||||
redirect_to submit_form_path(@submitter.slug)
|
redirect_to submit_form_path(@submitter.slug)
|
||||||
else
|
else
|
||||||
@@ -88,27 +87,40 @@ class StartFormController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def enqueue_new_submitter_jobs(submitter)
|
def find_or_initialize_submitter(template, submitter_params)
|
||||||
WebhookUrls.enqueue_events(submitter.submission, 'submission.created')
|
required_fields = template.preferences.fetch('link_form_fields', ['email'])
|
||||||
|
|
||||||
SearchEntries.enqueue_reindex(submitter)
|
blank_fields = required_fields.select { |key| submitter_params[key].blank? }
|
||||||
|
|
||||||
return unless submitter.submission.expire_at?
|
submitter =
|
||||||
|
if blank_fields.present?
|
||||||
ProcessSubmissionExpiredJob.perform_at(submitter.submission.expire_at, 'submission_id' => submitter.submission_id)
|
Submitter.new(submitter_params)
|
||||||
|
else
|
||||||
|
Submitters::StartForm.find_or_initialize_submitter(
|
||||||
|
template, submitter_params, exclude_completed: params[:resubmit].present?,
|
||||||
|
request:, current_user:
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_resubmit_submitter
|
blank_fields.each { |key| submitter.errors.add(key.to_sym, :blank) }
|
||||||
@resubmit_submitter =
|
|
||||||
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
|
submitter
|
||||||
Submitter.find_by(slug: params[:resubmit])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def require_link_2fa?(template, submitter)
|
||||||
|
return true if template.preferences['shared_link_2fa'] == true
|
||||||
|
return false if cookies.encrypted[:start_form_slug] == submitter.slug
|
||||||
|
return false if current_user && submitter.email == current_user.email &&
|
||||||
|
current_user.account_id == submitter.account_id
|
||||||
|
|
||||||
|
!submitter.new_record?
|
||||||
end
|
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 start_form_path(@template.slug) if is_archived
|
||||||
|
|
||||||
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))
|
||||||
|
|
||||||
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
|
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
|
||||||
@@ -116,89 +128,14 @@ class StartFormController < ApplicationController
|
|||||||
redirect_to start_form_path(@template.slug)
|
redirect_to start_form_path(@template.slug)
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_or_initialize_submitter(template, submitter_params)
|
|
||||||
required_fields = template.preferences.fetch('link_form_fields', ['email'])
|
|
||||||
|
|
||||||
required_params = required_fields.index_with { |key| submitter_params[key] }
|
|
||||||
|
|
||||||
find_params = required_params.except('name')
|
|
||||||
|
|
||||||
submitter = Submitter.new if find_params.compact_blank.blank?
|
|
||||||
|
|
||||||
submitter ||=
|
|
||||||
Submitter
|
|
||||||
.where(submission: template.submissions.where(expire_at: Time.current..)
|
|
||||||
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
|
|
||||||
.order(id: :desc)
|
|
||||||
.where(declined_at: nil)
|
|
||||||
.where(external_id: nil)
|
|
||||||
.where(template.preferences['shared_link_2fa'] == true ? {} : { ip: [nil, request.remote_ip] })
|
|
||||||
.then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
|
|
||||||
.find_or_initialize_by(find_params)
|
|
||||||
|
|
||||||
submitter.name = required_params['name'] if submitter.new_record?
|
|
||||||
|
|
||||||
unless @resubmit_submitter
|
|
||||||
required_params.each do |key, value|
|
|
||||||
submitter.errors.add(key.to_sym, :blank) if value.blank?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
submitter
|
|
||||||
end
|
|
||||||
|
|
||||||
def assign_submission_attributes(submitter, template)
|
|
||||||
submitter.assign_attributes(
|
|
||||||
uuid: (filter_undefined_submitters(template).first || @template.submitters.first)['uuid'],
|
|
||||||
ip: request.remote_ip,
|
|
||||||
ua: request.user_agent,
|
|
||||||
values: @resubmit_submitter&.preferences&.fetch('default_values', nil) || {},
|
|
||||||
preferences: @resubmit_submitter&.preferences.presence || { 'send_email' => true },
|
|
||||||
metadata: @resubmit_submitter&.metadata.presence || {}
|
|
||||||
)
|
|
||||||
|
|
||||||
submitter.assign_attributes(@resubmit_submitter.slice(:name, :email, :phone)) if @resubmit_submitter
|
|
||||||
|
|
||||||
if submitter.values.present?
|
|
||||||
@resubmit_submitter.attachments.each do |attachment|
|
|
||||||
submitter.attachments << attachment.dup if submitter.values.value?(attachment.uuid)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
submitter.submission ||= Submission.new(template:,
|
|
||||||
account_id: template.account_id,
|
|
||||||
template_submitters: template.submitters,
|
|
||||||
expire_at: Templates.build_default_expire_at(template),
|
|
||||||
submitters: [submitter],
|
|
||||||
source: :link)
|
|
||||||
|
|
||||||
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submitter.submission)
|
|
||||||
|
|
||||||
submitter.account_id = submitter.submission.account_id
|
|
||||||
|
|
||||||
submitter
|
|
||||||
end
|
|
||||||
|
|
||||||
def filter_undefined_submitters(template)
|
|
||||||
Templates.filter_undefined_submitters(template.submitters)
|
|
||||||
end
|
|
||||||
|
|
||||||
def submitter_params
|
def submitter_params
|
||||||
return { 'email' => current_user.email, 'name' => current_user.full_name } if params[:selfsign]
|
|
||||||
return @resubmit_submitter.slice(:name, :phone, :email) if @resubmit_submitter.present?
|
|
||||||
|
|
||||||
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
|
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
|
||||||
attrs[:email] = Submissions.normalize_email(attrs[:email])
|
attrs[:email] = Submissions.normalize_email(attrs[:email])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_template
|
def load_template
|
||||||
@template =
|
@template = Template.find_by!(slug: params[:slug] || params[:start_form_slug])
|
||||||
if @resubmit_submitter
|
|
||||||
@resubmit_submitter.template
|
|
||||||
else
|
|
||||||
Template.find_by!(slug: params[:slug] || params[:start_form_slug])
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def multiple_submitters_error_message
|
def multiple_submitters_error_message
|
||||||
@@ -209,38 +146,27 @@ class StartFormController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_require_2fa(submitter, is_new_record:)
|
def handle_require_2fa(template, submitter)
|
||||||
return render :show, status: :unprocessable_content if submitter.errors.present?
|
return render :show, status: :unprocessable_content if submitter.errors.present?
|
||||||
|
|
||||||
is_otp_verified = Submitters.verify_link_otp!(params[:one_time_code], submitter)
|
if Submitters::StartForm.verify_2fa_and_save_submitter(submitter, request, is_new_record: submitter.new_record?)
|
||||||
|
|
||||||
if cookies.encrypted[:email_2fa_slug] == submitter.slug || is_otp_verified
|
|
||||||
if submitter.save
|
|
||||||
enqueue_new_submitter_jobs(submitter) if is_new_record
|
|
||||||
|
|
||||||
if is_otp_verified
|
|
||||||
SubmissionEvents.create_with_tracking_data(submitter, 'email_verified', request)
|
|
||||||
|
|
||||||
cookies.encrypted[:email_2fa_slug] =
|
|
||||||
{ value: submitter.slug, expires: COOKIES_TTL.from_now, **COOKIES_DEFAULTS }
|
|
||||||
end
|
|
||||||
|
|
||||||
redirect_to submit_form_path(submitter.slug)
|
redirect_to submit_form_path(submitter.slug)
|
||||||
else
|
else
|
||||||
render :show, status: :unprocessable_content
|
if defined?(Rollbar) && template.preferences['shared_link_2fa'] != true
|
||||||
|
Rollbar.info("2FA link requested: #{submitter.id}")
|
||||||
end
|
end
|
||||||
else
|
|
||||||
Submitters.send_shared_link_email_verification_code(submitter, request:)
|
Submitters.send_shared_link_email_verification_code(submitter, request:)
|
||||||
|
|
||||||
render :email_verification
|
render :email_verification
|
||||||
end
|
end
|
||||||
|
rescue Submitters::StartForm::NotSaved
|
||||||
|
render :show, status: :unprocessable_content
|
||||||
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
|
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
|
||||||
redirect_to start_form_path(submitter.submission.template.slug,
|
redirect_to start_form_path(template.slug, params: submitter_params.merge(email_verification: true)),
|
||||||
params: submitter_params.merge(email_verification: true)),
|
|
||||||
alert: e.message
|
alert: e.message
|
||||||
rescue RateLimit::LimitApproached
|
rescue RateLimit::LimitApproached
|
||||||
redirect_to start_form_path(submitter.submission.template.slug,
|
redirect_to start_form_path(template.slug, params: submitter_params.merge(email_verification: true)),
|
||||||
params: submitter_params.merge(email_verification: true)),
|
|
||||||
alert: I18n.t(:too_many_attempts)
|
alert: I18n.t(:too_many_attempts)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -6,12 +6,17 @@ class StartFormEmail2faSendController < ApplicationController
|
|||||||
skip_before_action :authenticate_user!
|
skip_before_action :authenticate_user!
|
||||||
skip_authorization_check
|
skip_authorization_check
|
||||||
|
|
||||||
def create
|
before_action :load_template
|
||||||
@template = Template.find_by!(slug: params[:slug])
|
before_action :authorize_start!
|
||||||
|
|
||||||
|
def create
|
||||||
@submitter = @template.submissions.new(account_id: @template.account_id)
|
@submitter = @template.submissions.new(account_id: @template.account_id)
|
||||||
.submitters.new(**submitter_params, account_id: @template.account_id)
|
.submitters.new(**submitter_params, account_id: @template.account_id)
|
||||||
|
|
||||||
|
if @submitter.email.blank?
|
||||||
|
return redirect_to start_form_path(@template.slug), alert: I18n.t(:provide_your_email_to_start)
|
||||||
|
end
|
||||||
|
|
||||||
Submitters.send_shared_link_email_verification_code(@submitter, request:)
|
Submitters.send_shared_link_email_verification_code(@submitter, request:)
|
||||||
|
|
||||||
redir_params = { notice: I18n.t(:code_has_been_resent) } if params[:resend]
|
redir_params = { notice: I18n.t(:code_has_been_resent) } if params[:resend]
|
||||||
@@ -25,7 +30,34 @@ class StartFormEmail2faSendController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def load_template
|
||||||
|
@template = Template.find_by!(slug: params[:slug])
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_start!
|
||||||
|
is_archived = @template.archived_at? || @template.account.archived_at?
|
||||||
|
|
||||||
|
return redirect_to start_form_path(@template.slug) if is_archived
|
||||||
|
|
||||||
|
return if (@template.shared_link? || (current_user && current_ability.can?(:read, @template))) &&
|
||||||
|
(@template.preferences['shared_link_2fa'] == true || submitter_exists?(@template, params))
|
||||||
|
|
||||||
|
Rollbar.warning("Not 2FA shared template: #{@template.id}") if defined?(Rollbar)
|
||||||
|
|
||||||
|
redirect_to start_form_path(@template.slug)
|
||||||
|
end
|
||||||
|
|
||||||
|
def submitter_exists?(template, params)
|
||||||
|
email = Submissions.normalize_email(params.dig(:submitter, :email))
|
||||||
|
|
||||||
|
return false if email.blank?
|
||||||
|
|
||||||
|
Submitters::StartForm.build_submitters_scope(template, exclude_completed: true).exists?(email:)
|
||||||
|
end
|
||||||
|
|
||||||
def submitter_params
|
def submitter_params
|
||||||
params.require(:submitter).permit(:name, :email, :phone)
|
params.require(:submitter).permit(:name, :email, :phone).tap do |attrs|
|
||||||
|
attrs[:email] = Submissions.normalize_email(attrs[:email])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class StartFormResubmitController < ApplicationController
|
||||||
|
layout 'form'
|
||||||
|
|
||||||
|
skip_before_action :authenticate_user!
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
|
around_action :with_browser_locale
|
||||||
|
before_action :load_resubmit_submitter
|
||||||
|
before_action :load_template
|
||||||
|
before_action :authorize_start!
|
||||||
|
|
||||||
|
def update
|
||||||
|
@submitter = find_or_initialize_submitter(@template, @resubmit_submitter)
|
||||||
|
|
||||||
|
if Templates.filter_undefined_submitters(@template.submitters).size > 1 && @submitter.new_record?
|
||||||
|
@error_message = multiple_submitters_error_message
|
||||||
|
|
||||||
|
return render 'start_form/show', status: :unprocessable_content
|
||||||
|
end
|
||||||
|
|
||||||
|
if (is_new_record = @submitter.new_record?)
|
||||||
|
Submitters::StartForm.assign_submission_attributes(
|
||||||
|
@submitter, @template,
|
||||||
|
ip: request.remote_ip, user_agent: request.user_agent, resubmit_submitter: @resubmit_submitter,
|
||||||
|
source: @resubmit_submitter.submission.source_self? ? :self : :link
|
||||||
|
)
|
||||||
|
|
||||||
|
Submissions::AssignDefinedSubmitters.call(@submitter.submission)
|
||||||
|
else
|
||||||
|
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
|
||||||
|
end
|
||||||
|
|
||||||
|
if @template.preferences['shared_link_2fa'] == true
|
||||||
|
handle_require_2fa(@submitter)
|
||||||
|
elsif @submitter.save
|
||||||
|
Submitters::StartForm.enqueue_new_submitter_jobs(@submitter) if is_new_record
|
||||||
|
|
||||||
|
Submitters::StartForm.assign_start_form_cookie(@submitter, request)
|
||||||
|
|
||||||
|
redirect_to submit_form_path(@submitter.slug)
|
||||||
|
else
|
||||||
|
render 'start_form/show', status: :unprocessable_content
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def find_or_initialize_submitter(template, resubmit_submitter)
|
||||||
|
Submitters::StartForm.build_submitters_scope(template, exclude_completed: true)
|
||||||
|
.find_by(slug: cookies.encrypted[:start_form_slug],
|
||||||
|
email: resubmit_submitter.email) || Submitter.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_resubmit_submitter
|
||||||
|
@resubmit_submitter = Submitter.find_by(slug: params[:resubmit])
|
||||||
|
|
||||||
|
raise ActiveRecord::RecordNotFound if @resubmit_submitter.blank? ||
|
||||||
|
!Submitters::StartForm.can_resubmit?(@resubmit_submitter)
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_template
|
||||||
|
@template = @resubmit_submitter.template
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_start!
|
||||||
|
redirect_to submit_form_path(@resubmit_submitter.slug) if @template.archived_at? || @template.account.archived_at?
|
||||||
|
end
|
||||||
|
|
||||||
|
def multiple_submitters_error_message
|
||||||
|
if current_user&.account_id == @template.account_id
|
||||||
|
helpers.t('this_submission_has_multiple_signers_which_prevents_the_use_of_a_sharing_link_html')
|
||||||
|
else
|
||||||
|
I18n.t('not_found')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_require_2fa(submitter)
|
||||||
|
if Submitters::StartForm.verify_2fa_and_save_submitter(submitter, request,
|
||||||
|
is_new_record: submitter.new_record?)
|
||||||
|
redirect_to submit_form_path(submitter.slug)
|
||||||
|
else
|
||||||
|
Submitters.send_shared_link_email_verification_code(submitter, request:)
|
||||||
|
|
||||||
|
render 'start_form/email_verification'
|
||||||
|
end
|
||||||
|
rescue Submitters::StartForm::NotSaved
|
||||||
|
render 'start_form/show', status: :unprocessable_content
|
||||||
|
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
|
||||||
|
flash.now[:alert] = e.message
|
||||||
|
|
||||||
|
render 'start_form/email_verification'
|
||||||
|
rescue RateLimit::LimitApproached
|
||||||
|
flash.now[:alert] = I18n.t(:too_many_attempts)
|
||||||
|
|
||||||
|
render 'start_form/email_verification'
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class StartFormSelfController < ApplicationController
|
||||||
|
load_resource :template, parent: false
|
||||||
|
|
||||||
|
around_action :with_browser_locale
|
||||||
|
before_action :authorize_start!
|
||||||
|
|
||||||
|
def update
|
||||||
|
@submitter = Submitters::StartForm.build_submitters_scope(@template, exclude_completed: true, source: :self)
|
||||||
|
.find_or_initialize_by(email: current_user.email)
|
||||||
|
|
||||||
|
@submitter = Submitter.new(email: current_user.email) if @submitter.persisted? &&
|
||||||
|
outdated_template_fields?(@submitter, @template)
|
||||||
|
|
||||||
|
if (is_new_record = @submitter.new_record?)
|
||||||
|
@submitter.name = current_user.full_name
|
||||||
|
|
||||||
|
Submitters::StartForm.assign_submission_attributes(
|
||||||
|
@submitter, @template, ip: request.remote_ip, user_agent: request.user_agent, source: :self
|
||||||
|
)
|
||||||
|
|
||||||
|
Submissions::AssignDefinedSubmitters.call(@submitter.submission)
|
||||||
|
else
|
||||||
|
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
|
||||||
|
end
|
||||||
|
|
||||||
|
@submitter.save!
|
||||||
|
|
||||||
|
Submitters::StartForm.enqueue_new_submitter_jobs(@submitter) if is_new_record
|
||||||
|
|
||||||
|
Submitters::StartForm.assign_start_form_cookie(@submitter, request)
|
||||||
|
|
||||||
|
redirect_to submit_form_path(@submitter.slug)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def outdated_template_fields?(submitter, template)
|
||||||
|
template_fields = submitter.submission.template_fields
|
||||||
|
|
||||||
|
template_fields.present? && template_fields != template.fields
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_start!
|
||||||
|
authorize!(:read, @template)
|
||||||
|
|
||||||
|
redirect_to template_path(@template) if @template.archived_at?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -24,5 +24,7 @@ class SubmissionEventsController < ApplicationController
|
|||||||
|
|
||||||
load_and_authorize_resource :submission
|
load_and_authorize_resource :submission
|
||||||
|
|
||||||
def index; end
|
def index
|
||||||
|
render :index, layout: 'plain'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -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?
|
||||||
|
@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))
|
||||||
|
|
||||||
|
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 }]
|
||||||
@@ -35,10 +33,14 @@ class SubmissionsController < ApplicationController
|
|||||||
|
|
||||||
def new
|
def new
|
||||||
authorize!(:new, Submission)
|
authorize!(:new, Submission)
|
||||||
|
|
||||||
|
render :new, layout: 'plain'
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
save_template_message(@template, params) if params[:save_message] == '1'
|
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' && can?(:update, @template)
|
||||||
|
|
||||||
[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 +89,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 +116,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?
|
||||||
|
@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))
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class SubmissionsUnarchiveController < ApplicationController
|
|||||||
load_and_authorize_resource :submission
|
load_and_authorize_resource :submission
|
||||||
|
|
||||||
def create
|
def create
|
||||||
authorize!(:update, @submission)
|
authorize!(:destroy, @submission)
|
||||||
|
|
||||||
@submission.update!(archived_at: nil)
|
@submission.update!(archived_at: nil)
|
||||||
|
|
||||||
|
|||||||
@@ -10,29 +10,26 @@ class SubmitFormCompletedDownloadController < ApplicationController
|
|||||||
def index
|
def index
|
||||||
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||||
|
|
||||||
signature_valid =
|
signature_valid = @submitter&.slug == submitter_slug
|
||||||
if @submitter&.slug == submitter_slug
|
|
||||||
true
|
@submitter = Submitter.find_by!(slug: submitter_slug) unless signature_valid
|
||||||
else
|
|
||||||
@submitter = nil
|
unless completed_submitter?(@submitter)
|
||||||
|
Rollbar.error("Not completed: #{@submitter.id}") if defined?(Rollbar)
|
||||||
|
|
||||||
|
return head :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
@submitter ||= Submitter.find_by!(slug: submitter_slug)
|
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
|
||||||
|
|
||||||
Submissions::EnsureResultGenerated.call(@submitter)
|
last_submitter = @submitter.submission.submitters.completed.order(:completed_at).last
|
||||||
|
|
||||||
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
|
||||||
|
|
||||||
return head :not_found unless last_submitter
|
return head :not_found unless last_submitter
|
||||||
|
|
||||||
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)
|
||||||
@@ -64,6 +61,10 @@ class SubmitFormCompletedDownloadController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def completed_submitter?(submitter)
|
||||||
|
submitter.completed_at? || (submitter.viewer? && submitter.submission.completed_at?)
|
||||||
|
end
|
||||||
|
|
||||||
def current_user_submitter?(submitter)
|
def current_user_submitter?(submitter)
|
||||||
current_user && current_ability.can?(:read, submitter)
|
current_user && current_ability.can?(:read, submitter)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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,17 +7,19 @@ 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)
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
@submitter.update!(declined_at: Time.current)
|
@submitter.update!(declined_at: Time.current, values: @submitter.preferences['default_values'] || {})
|
||||||
|
|
||||||
SubmissionEvents.create_with_tracking_data(@submitter, 'decline_form', request, { reason: params[:reason] })
|
SubmissionEvents.create_with_tracking_data(@submitter, 'decline_form', request, { reason: params[:reason] })
|
||||||
end
|
end
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ class SubmitFormEmail2fasController < ApplicationController
|
|||||||
|
|
||||||
before_action :load_submitter
|
before_action :load_submitter
|
||||||
|
|
||||||
COOKIES_TTL = 12.hours
|
|
||||||
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
|
|
||||||
|
|
||||||
def create
|
def create
|
||||||
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
|
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
|
||||||
|
|
||||||
@@ -20,7 +17,8 @@ class SubmitFormEmail2fasController < ApplicationController
|
|||||||
SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
|
SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
|
||||||
|
|
||||||
cookies.encrypted[:email_2fa_slug] =
|
cookies.encrypted[:email_2fa_slug] =
|
||||||
{ value: @submitter.slug, expires: COOKIES_TTL.from_now, **COOKIES_DEFAULTS }
|
{ value: @submitter.slug, expires: Submitters::StartForm::COOKIES_TTL.from_now,
|
||||||
|
**Submitters::StartForm::COOKIES_DEFAULTS }
|
||||||
|
|
||||||
redirect_to submit_form_path(@submitter.slug)
|
redirect_to submit_form_path(@submitter.slug)
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -36,7 +36,7 @@ class SubmittersAutocompleteController < ApplicationController
|
|||||||
else
|
else
|
||||||
column = Submitter.arel_table[field.to_sym]
|
column = Submitter.arel_table[field.to_sym]
|
||||||
|
|
||||||
term = "#{params[:q].downcase}%"
|
term = "#{ActiveRecord::Base.sanitize_sql_like(params[:q].downcase)}%"
|
||||||
|
|
||||||
submitters.where(column.matches(term))
|
submitters.where(column.matches(term))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ class SubmittersController < ApplicationController
|
|||||||
if @submitter.preferences['email_message_uuid'].present?
|
if @submitter.preferences['email_message_uuid'].present?
|
||||||
@submitter.account.email_messages.find_by(uuid: @submitter.preferences['email_message_uuid'])
|
@submitter.account.email_messages.find_by(uuid: @submitter.preferences['email_message_uuid'])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
render :edit, layout: 'plain'
|
||||||
end
|
end
|
||||||
|
|
||||||
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 +50,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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,32 +14,23 @@ 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
|
||||||
|
|
||||||
def new; end
|
def new
|
||||||
|
render :new, layout: 'plain'
|
||||||
|
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
|
||||||
|
|||||||
@@ -11,76 +11,116 @@ 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)
|
|
||||||
|
|
||||||
limit =
|
|
||||||
if @template_folders.size < 4
|
|
||||||
TEMPLATES_PER_PAGE
|
|
||||||
else
|
else
|
||||||
(@template_folders.size < 7 ? 9 : 6)
|
@templates = @templates.active
|
||||||
|
@templates = @templates.where(folder_id: @default_folder.id) if params[:q].blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
@pagy, @templates = pagy_auto(@templates, limit:)
|
@pagy, @templates = load_templates(@templates.select_for_list, @pagy.count,
|
||||||
|
show_shared_inline: @show_shared_inline)
|
||||||
|
|
||||||
load_related_submissions if params[:q].present? && @templates.blank?
|
if params[:q].present? && @templates.blank?
|
||||||
|
@related_submissions_pagy, @related_submissions = load_related_submissions
|
||||||
|
end
|
||||||
end
|
end
|
||||||
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
|
||||||
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
Templates.search(current_user, rel, params[:q])
|
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 })
|
||||||
@@ -88,9 +128,9 @@ class TemplatesDashboardController < 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
|
||||||
@@ -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: })
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ class TemplatesFormPreviewController < ApplicationController
|
|||||||
|
|
||||||
@submitter.submission.submitters = @template.submitters.map { |item| Submitter.new(uuid: item['uuid']) }
|
@submitter.submission.submitters = @template.submitters.map { |item| Submitter.new(uuid: item['uuid']) }
|
||||||
|
|
||||||
|
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(@submitter.submission, preview: true)
|
||||||
|
|
||||||
Submissions.preload_with_pages(@submitter.submission)
|
Submissions.preload_with_pages(@submitter.submission)
|
||||||
|
|
||||||
@attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments)
|
@attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments)
|
||||||
.preload(:blob).index_by(&:uuid)
|
.preload(:blob).index_by(&:uuid)
|
||||||
|
|
||||||
@form_configs = Submitters::FormConfigs.call(@submitter)
|
@form_configs = Submitters::FormConfigs.call(@submitter, SubmitFormController::CONFIG_KEYS)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -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],
|
||||||
@@ -12,7 +14,9 @@ class TemplatesPreferencesController < ApplicationController
|
|||||||
completed_notification_email_body]
|
completed_notification_email_body]
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def show; end
|
def show
|
||||||
|
render :show, layout: 'plain'
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
authorize!(:update, @template)
|
authorize!(:update, @template)
|
||||||
@@ -48,12 +52,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,7 +4,7 @@ class TemplatesRestoreController < ApplicationController
|
|||||||
load_and_authorize_resource :template
|
load_and_authorize_resource :template
|
||||||
|
|
||||||
def create
|
def create
|
||||||
authorize!(:update, @template)
|
authorize!(:destroy, @template)
|
||||||
|
|
||||||
@template.update!(archived_at: nil)
|
@template.update!(archived_at: nil)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
class TemplatesShareLinkController < ApplicationController
|
class TemplatesShareLinkController < ApplicationController
|
||||||
load_and_authorize_resource :template
|
load_and_authorize_resource :template
|
||||||
|
|
||||||
def show; end
|
def show
|
||||||
|
render :show, layout: 'plain'
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
authorize!(:update, @template)
|
authorize!(:update, @template)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TemplatesSharedController < ApplicationController
|
||||||
|
def index
|
||||||
|
authorize!(:read, Template)
|
||||||
|
|
||||||
|
@is_archived = params[:archived] == 'true'
|
||||||
|
|
||||||
|
@templates = Templates.shared(current_user)
|
||||||
|
|
||||||
|
@has_archived = !@is_archived && @templates.archived.exists?
|
||||||
|
|
||||||
|
@templates = @is_archived ? @templates.archived : @templates.active
|
||||||
|
|
||||||
|
@templates = @templates.preload(:author, :template_accesses, :template_sharings)
|
||||||
|
.order(id: :desc)
|
||||||
|
|
||||||
|
@templates = Templates.search_shared(current_user, @templates, params[:q])
|
||||||
|
|
||||||
|
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
|
||||||
|
|
||||||
|
return unless params[:q].present? && @templates.blank?
|
||||||
|
|
||||||
|
@related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_related_submissions(is_archived:)
|
||||||
|
shared_templates = Templates.shared(current_user)
|
||||||
|
shared_templates = is_archived ? shared_templates.archived : shared_templates.active
|
||||||
|
|
||||||
|
related_submissions =
|
||||||
|
Submission.accessible_by(current_ability)
|
||||||
|
.where(template_id: shared_templates.select(:id))
|
||||||
|
.preload(:template_accesses, :created_by_user,
|
||||||
|
template: :author,
|
||||||
|
submitters: :start_form_submission_events)
|
||||||
|
|
||||||
|
related_submissions = related_submissions.where(archived_at: nil) unless is_archived
|
||||||
|
|
||||||
|
related_submissions = Submissions.search(current_user, related_submissions, params[:q])
|
||||||
|
.order(id: :desc)
|
||||||
|
|
||||||
|
pagy_auto(related_submissions.select_for_list, limit: 5)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,7 +5,11 @@ class TemplatesUploadsController < ApplicationController
|
|||||||
|
|
||||||
layout 'plain'
|
layout 'plain'
|
||||||
|
|
||||||
def show; end
|
def show
|
||||||
|
url_uri = Addressable::URI.parse(params[:url].to_s)
|
||||||
|
|
||||||
|
redirect_to root_path if url_uri.normalized_scheme != 'https' || url_uri.host.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?
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ class UsersController < ApplicationController
|
|||||||
authorize!(:manage, account)
|
authorize!(:manage, account)
|
||||||
|
|
||||||
@user.account = account
|
@user.account = account
|
||||||
|
|
||||||
|
authorize!(:create, @user)
|
||||||
end
|
end
|
||||||
|
|
||||||
if @user.update(attrs.except(*(current_user == @user ? %i[password otp_required_for_login role] : %i[password])))
|
if @user.update(attrs.except(*(current_user == @user ? %i[password otp_required_for_login role] : %i[password])))
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ class VerifyPdfSignatureController < ApplicationController
|
|||||||
skip_authorization_check
|
skip_authorization_check
|
||||||
|
|
||||||
def create
|
def create
|
||||||
pdfs =
|
|
||||||
params[:files].map do |file|
|
|
||||||
HexaPDF::Document.new(io: file.open)
|
|
||||||
end
|
|
||||||
|
|
||||||
trusted_certs = Accounts.load_trusted_certs(current_account)
|
trusted_certs = Accounts.load_trusted_certs(current_account)
|
||||||
|
|
||||||
|
signatures =
|
||||||
|
params[:files].map do |file|
|
||||||
|
VerifyPdfSignature.call(file.open, trusted_certs)
|
||||||
|
end
|
||||||
|
|
||||||
render turbo_stream: turbo_stream.replace('result', partial: 'result',
|
render turbo_stream: turbo_stream.replace('result', partial: 'result',
|
||||||
locals: { pdfs:, files: params[:files], trusted_certs: })
|
locals: { signatures:, files: params[:files] })
|
||||||
rescue HexaPDF::MalformedPDFError
|
rescue Pdfium::PdfiumError
|
||||||
render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div(I18n.t('invalid_pdf'), id: 'result'))
|
render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div(I18n.t('invalid_pdf'), id: 'result'))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -42,6 +42,7 @@ 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'
|
||||||
@@ -54,6 +55,10 @@ 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 ScrollFade from './elements/scroll_fade'
|
||||||
|
import OpenModalMobile from './elements/open_modal_mobile'
|
||||||
|
import HistoryBack from './elements/history_back'
|
||||||
|
|
||||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||||
|
|
||||||
@@ -134,6 +139,7 @@ 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)
|
||||||
@@ -146,10 +152,15 @@ 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('scroll-fade', ScrollFade)
|
||||||
|
safeRegisterElement('open-modal-mobile', OpenModalMobile)
|
||||||
|
safeRegisterElement('history-back', HistoryBack)
|
||||||
|
|
||||||
safeRegisterElement('template-builder', class extends HTMLElement {
|
safeRegisterElement('template-builder', class extends HTMLElement {
|
||||||
connectedCallback () {
|
connectedCallback () {
|
||||||
document.addEventListener('turbo:submit-end', this.onSubmit)
|
document.addEventListener('turbo:submit-end', this.onSubmit)
|
||||||
|
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||||
|
|
||||||
this.appElem = document.createElement('div')
|
this.appElem = document.createElement('div')
|
||||||
|
|
||||||
@@ -160,6 +171,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
|||||||
this.app = createApp(TemplateBuilder, {
|
this.app = createApp(TemplateBuilder, {
|
||||||
template,
|
template,
|
||||||
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
|
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
|
||||||
|
dateFormats: JSON.parse(this.dataset.dateFormats || '[]'),
|
||||||
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
|
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
|
||||||
backgroundColor: '#faf7f5',
|
backgroundColor: '#faf7f5',
|
||||||
locale: this.dataset.locale,
|
locale: this.dataset.locale,
|
||||||
@@ -176,7 +188,10 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
|||||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||||
withCustomFields: true,
|
withCustomFields: true,
|
||||||
withPayment: this.dataset.withPayment === 'true',
|
withPayment: this.dataset.withPayment === 'true',
|
||||||
isPaymentConnected: this.dataset.isPaymentConnected === 'true',
|
isStripeConnected: this.dataset.isPaymentConnected === 'true' || this.dataset.isStripeConnected === 'true',
|
||||||
|
withStripe: this.dataset.withStripe !== 'false',
|
||||||
|
withPaypal: this.dataset.withPaypal === 'true',
|
||||||
|
isPaypalConnected: this.dataset.isPaypalConnected === 'true',
|
||||||
withFormula: this.dataset.withFormula === 'true',
|
withFormula: this.dataset.withFormula === 'true',
|
||||||
withSendButton: this.dataset.withSendButton !== 'false',
|
withSendButton: this.dataset.withSendButton !== 'false',
|
||||||
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
|
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
|
||||||
@@ -197,15 +212,46 @@ 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) {
|
||||||
|
if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
|
||||||
e.detail.fetchResponse.response.json().then((data) => {
|
e.detail.fetchResponse.response.json().then((data) => {
|
||||||
this.component.template.submitters = data.submitters
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeCache = () => {
|
||||||
|
this.app?.unmount()
|
||||||
|
this.appElem?.remove()
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback () {
|
disconnectedCallback () {
|
||||||
document.removeEventListener('turbo:submit-end', this.onSubmit)
|
document.removeEventListener('turbo:submit-end', this.onSubmit)
|
||||||
|
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||||
|
|
||||||
this.app?.unmount()
|
this.app?.unmount()
|
||||||
this.appElem?.remove()
|
this.appElem?.remove()
|
||||||
@@ -214,6 +260,8 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
|||||||
|
|
||||||
safeRegisterElement('import-list', class extends HTMLElement {
|
safeRegisterElement('import-list', class extends HTMLElement {
|
||||||
connectedCallback () {
|
connectedCallback () {
|
||||||
|
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||||
|
|
||||||
this.appElem = document.createElement('div')
|
this.appElem = document.createElement('div')
|
||||||
|
|
||||||
this.app = createApp(ImportList, {
|
this.app = createApp(ImportList, {
|
||||||
@@ -228,7 +276,14 @@ safeRegisterElement('import-list', class extends HTMLElement {
|
|||||||
this.appendChild(this.appElem)
|
this.appendChild(this.appElem)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeCache = () => {
|
||||||
|
this.app?.unmount()
|
||||||
|
this.appElem?.remove()
|
||||||
|
}
|
||||||
|
|
||||||
disconnectedCallback () {
|
disconnectedCallback () {
|
||||||
|
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||||
|
|
||||||
this.app?.unmount()
|
this.app?.unmount()
|
||||||
this.appElem?.remove()
|
this.appElem?.remove()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,13 @@ button[disabled] .enabled, button.btn-disabled .enabled {
|
|||||||
bottom: auto;
|
bottom: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (hover: none), (pointer: coarse) {
|
||||||
|
.tooltip-no-touch:before,
|
||||||
|
.tooltip-no-touch:after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.autocomplete {
|
.autocomplete {
|
||||||
background: white;
|
background: white;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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({
|
||||||
'&': {
|
'&': {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export default class extends HTMLElement {
|
||||||
|
connectedCallback () {
|
||||||
|
this.addEventListener('click', this.onClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback () {
|
||||||
|
this.removeEventListener('click', this.onClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,667 @@
|
|||||||
|
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||||
|
import { actionable } from '@github/catalyst/lib/actionable'
|
||||||
|
import { LinkTooltip } from './markdown_editor'
|
||||||
|
|
||||||
|
async function loadTiptap () {
|
||||||
|
const [core, document, text, hardBreak, gapcursor, dropcursor, extensions, pmState, pmView] = await Promise.all([
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/core'),
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-document'),
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-text'),
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-hard-break'),
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-gapcursor'),
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-dropcursor'),
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extensions'),
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/state'),
|
||||||
|
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/view')
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
Editor: core.Editor,
|
||||||
|
Extension: core.Extension,
|
||||||
|
Node: core.Node,
|
||||||
|
Mark: core.Mark,
|
||||||
|
Document: document.default || document,
|
||||||
|
Text: text.default || text,
|
||||||
|
HardBreak: hardBreak.default || hardBreak,
|
||||||
|
Gapcursor: gapcursor.default || gapcursor,
|
||||||
|
Dropcursor: dropcursor.default || dropcursor,
|
||||||
|
UndoRedo: extensions.UndoRedo,
|
||||||
|
Plugin: pmState.Plugin,
|
||||||
|
Decoration: pmView.Decoration,
|
||||||
|
DecorationSet: pmView.DecorationSet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorStylesheet = new CSSStyleSheet()
|
||||||
|
|
||||||
|
editorStylesheet.replaceSync(`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 0 0 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror {
|
||||||
|
word-wrap: break-word;
|
||||||
|
-webkit-font-variant-ligatures: none;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
font-feature-settings: "liga" 0;
|
||||||
|
outline: none;
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.ProseMirror-separator {
|
||||||
|
display: inline !important;
|
||||||
|
border: none !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-gapcursor {
|
||||||
|
display: none;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-gapcursor:after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
width: 20px;
|
||||||
|
border-top: 1px solid black;
|
||||||
|
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ProseMirror-cursor-blink {
|
||||||
|
to {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-hideselection *::selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-hideselection *::-moz-selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-hideselection * {
|
||||||
|
caret-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-focused .ProseMirror-gapcursor {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variable-highlight {
|
||||||
|
background-color: #fef3c7;
|
||||||
|
padding: 1px 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
const DROP_ATTRS = [
|
||||||
|
'srcdoc', 'xlink:href', 'srcset', 'action', 'formaction', 'poster',
|
||||||
|
'background', 'data', 'cite', 'ping', 'longdesc', 'manifest', 'profile'
|
||||||
|
]
|
||||||
|
|
||||||
|
const SAFE_URL_REGEXP = /^(?:https?:\/\/|data:image\/|blob:|mailto:|tel:|\{|#)/i
|
||||||
|
|
||||||
|
function isSafeAttr (name, value) {
|
||||||
|
const lowerName = name.toLowerCase()
|
||||||
|
|
||||||
|
if (lowerName.startsWith('on') || DROP_ATTRS.includes(lowerName)) return false
|
||||||
|
if ((lowerName === 'href' || lowerName === 'src') && !SAFE_URL_REGEXP.test(value.trim())) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDomAttrs (dom) {
|
||||||
|
const attrs = {}
|
||||||
|
|
||||||
|
for (let i = 0; i < dom.attributes.length; i++) {
|
||||||
|
const { name, value } = dom.attributes[i]
|
||||||
|
|
||||||
|
if (isSafeAttr(name, value)) attrs[name] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return { htmlAttrs: attrs }
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSpanDomAttrs (dom) {
|
||||||
|
const result = collectDomAttrs(dom)
|
||||||
|
|
||||||
|
if (result.htmlAttrs.style) {
|
||||||
|
const temp = document.createElement('span')
|
||||||
|
|
||||||
|
temp.style.cssText = result.htmlAttrs.style
|
||||||
|
|
||||||
|
if (['bold', '700'].includes(temp.style.fontWeight)) {
|
||||||
|
temp.style.removeProperty('font-weight')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (temp.style.fontStyle === 'italic') {
|
||||||
|
temp.style.removeProperty('font-style')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (temp.style.textDecoration === 'underline') {
|
||||||
|
temp.style.removeProperty('text-decoration')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (temp.style.cssText) {
|
||||||
|
result.htmlAttrs.style = temp.style.cssText
|
||||||
|
} else {
|
||||||
|
delete result.htmlAttrs.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExtensions ({ Node, Mark, Extension, Plugin, Decoration, DecorationSet }) {
|
||||||
|
const blockNode = (name, tag, content, extra = {}) => Node.create({
|
||||||
|
name,
|
||||||
|
group: 'block',
|
||||||
|
content: content || 'block+',
|
||||||
|
...extra,
|
||||||
|
addAttributes () {
|
||||||
|
return { htmlAttrs: { default: {} } }
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{ tag, getAttrs: collectDomAttrs }]
|
||||||
|
},
|
||||||
|
renderHTML ({ node }) {
|
||||||
|
return [tag, node.attrs.htmlAttrs, 0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const attrsMark = (name, tag) => Mark.create({
|
||||||
|
name,
|
||||||
|
addAttributes () {
|
||||||
|
return { htmlAttrs: { default: {} } }
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{ tag, getAttrs: collectDomAttrs }]
|
||||||
|
},
|
||||||
|
renderHTML ({ mark }) {
|
||||||
|
return [tag, mark.attrs.htmlAttrs, 0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const SpanMark = Mark.create({
|
||||||
|
name: 'span',
|
||||||
|
excludes: '',
|
||||||
|
addAttributes () {
|
||||||
|
return { htmlAttrs: { default: {} } }
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{ tag: 'span', getAttrs: collectSpanDomAttrs }]
|
||||||
|
},
|
||||||
|
renderHTML ({ mark }) {
|
||||||
|
return ['span', mark.attrs.htmlAttrs, 0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleMark = (name, renderTag, parseRules, shortcuts) => Mark.create({
|
||||||
|
name,
|
||||||
|
parseHTML () {
|
||||||
|
return parseRules
|
||||||
|
},
|
||||||
|
renderHTML () {
|
||||||
|
return [renderTag, 0]
|
||||||
|
},
|
||||||
|
addCommands () {
|
||||||
|
const commandName = `toggle${name[0].toUpperCase()}${name.slice(1)}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
[commandName]: () => ({ commands }) => commands.toggleMark(name)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addKeyboardShortcuts () {
|
||||||
|
return {
|
||||||
|
[shortcuts]: () => this.editor.commands.toggleMark(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Heading = Node.create({
|
||||||
|
name: 'heading',
|
||||||
|
group: 'block',
|
||||||
|
content: 'inline*',
|
||||||
|
addAttributes () {
|
||||||
|
return {
|
||||||
|
htmlAttrs: { default: {} },
|
||||||
|
level: { default: 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [1, 2, 3, 4, 5, 6].map((level) => ({
|
||||||
|
tag: `h${level}`,
|
||||||
|
getAttrs: (dom) => ({ ...collectDomAttrs(dom), level })
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
renderHTML ({ node }) {
|
||||||
|
return [`h${node.attrs.level}`, node.attrs.htmlAttrs, 0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const ImageNode = Node.create({
|
||||||
|
name: 'image',
|
||||||
|
inline: true,
|
||||||
|
group: 'inline',
|
||||||
|
draggable: true,
|
||||||
|
addAttributes () {
|
||||||
|
return { htmlAttrs: { default: {} } }
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{ tag: 'img', getAttrs: collectDomAttrs }]
|
||||||
|
},
|
||||||
|
renderHTML ({ node }) {
|
||||||
|
return ['img', node.attrs.htmlAttrs]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const HrNode = Node.create({
|
||||||
|
name: 'horizontalRule',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
addAttributes () {
|
||||||
|
return { htmlAttrs: { default: {} } }
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{ tag: 'hr', getAttrs: collectDomAttrs }]
|
||||||
|
},
|
||||||
|
renderHTML ({ node }) {
|
||||||
|
return ['hr', node.attrs.htmlAttrs]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const StyleNode = Node.create({
|
||||||
|
name: 'style',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
selectable: false,
|
||||||
|
addAttributes () {
|
||||||
|
return {
|
||||||
|
htmlAttrs: { default: {} },
|
||||||
|
css: { default: '' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{ tag: 'style', getAttrs: (dom) => ({ ...collectDomAttrs(dom), css: dom.textContent }) }]
|
||||||
|
},
|
||||||
|
renderHTML ({ node }) {
|
||||||
|
return ['style', node.attrs.htmlAttrs, node.attrs.css]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const EmptySpanNode = Node.create({
|
||||||
|
name: 'emptySpan',
|
||||||
|
inline: true,
|
||||||
|
group: 'inline',
|
||||||
|
atom: true,
|
||||||
|
addAttributes () {
|
||||||
|
return { htmlAttrs: { default: {} } }
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{
|
||||||
|
tag: 'span',
|
||||||
|
priority: 60,
|
||||||
|
getAttrs (dom) {
|
||||||
|
if (dom.childNodes.length === 0 && dom.attributes.length > 0) {
|
||||||
|
return collectDomAttrs(dom)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
renderHTML ({ node }) {
|
||||||
|
return ['span', node.attrs.htmlAttrs]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const LinkMark = Mark.create({
|
||||||
|
name: 'link',
|
||||||
|
inclusive: true,
|
||||||
|
addAttributes () {
|
||||||
|
return { htmlAttrs: { default: {} } }
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{ tag: 'a', getAttrs: collectDomAttrs }]
|
||||||
|
},
|
||||||
|
renderHTML ({ mark }) {
|
||||||
|
return ['a', mark.attrs.htmlAttrs, 0]
|
||||||
|
},
|
||||||
|
addCommands () {
|
||||||
|
return {
|
||||||
|
setLink: ({ href }) => ({ editor, commands }) => {
|
||||||
|
const htmlAttrs = { ...(editor.getAttributes('link').htmlAttrs || {}), href }
|
||||||
|
|
||||||
|
return commands.setMark('link', { htmlAttrs })
|
||||||
|
},
|
||||||
|
unsetLink: () => ({ commands }) => commands.unsetMark('link', { extendEmptyMarkRange: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildDecorations = (doc) => {
|
||||||
|
const decorations = []
|
||||||
|
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g
|
||||||
|
|
||||||
|
doc.descendants((node, pos) => {
|
||||||
|
if (!node.isText) return
|
||||||
|
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = regex.exec(node.text)) !== null) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.inline(pos + match.index, pos + match.index + match[0].length, {
|
||||||
|
class: 'variable-highlight'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return DecorationSet.create(doc, decorations)
|
||||||
|
}
|
||||||
|
|
||||||
|
const VariableHighlight = Extension.create({
|
||||||
|
name: 'variableHighlight',
|
||||||
|
addProseMirrorPlugins () {
|
||||||
|
return [new Plugin({
|
||||||
|
state: {
|
||||||
|
init (_, { doc }) {
|
||||||
|
return buildDecorations(doc)
|
||||||
|
},
|
||||||
|
apply (tr, oldSet) {
|
||||||
|
return tr.docChanged ? buildDecorations(tr.doc) : oldSet
|
||||||
|
}
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations (state) {
|
||||||
|
return this.getState(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return [
|
||||||
|
blockNode('paragraph', 'p', 'inline*'),
|
||||||
|
Heading,
|
||||||
|
blockNode('section', 'section'),
|
||||||
|
blockNode('article', 'article', null, { isolating: true }),
|
||||||
|
blockNode('header', 'header', null, { isolating: true }),
|
||||||
|
blockNode('footer', 'footer', null, { isolating: true }),
|
||||||
|
blockNode('div', 'div'),
|
||||||
|
blockNode('center', 'center'),
|
||||||
|
blockNode('blockquote', 'blockquote'),
|
||||||
|
blockNode('pre', 'pre'),
|
||||||
|
blockNode('orderedList', 'ol', '(listItem | block)+'),
|
||||||
|
blockNode('bulletList', 'ul', '(listItem | block)+'),
|
||||||
|
blockNode('listItem', 'li', 'block+', { group: null }),
|
||||||
|
blockNode('table', 'table', '(colgroup | tableHead | tableBody | tableFoot | tableRow)+'),
|
||||||
|
blockNode('tableHead', 'thead', 'tableRow+', { group: null }),
|
||||||
|
blockNode('tableBody', 'tbody', 'tableRow+', { group: null }),
|
||||||
|
blockNode('tableFoot', 'tfoot', 'tableRow+', { group: null }),
|
||||||
|
blockNode('tableRow', 'tr', '(tableCell | tableHeader)+', { group: null }),
|
||||||
|
blockNode('tableCell', 'td', 'block*', { group: null }),
|
||||||
|
blockNode('tableHeader', 'th', 'block*', { group: null }),
|
||||||
|
blockNode('colgroup', 'colgroup', 'col*', { group: null }),
|
||||||
|
Node.create({
|
||||||
|
name: 'col',
|
||||||
|
atom: true,
|
||||||
|
addAttributes () {
|
||||||
|
return { htmlAttrs: { default: {} } }
|
||||||
|
},
|
||||||
|
parseHTML () {
|
||||||
|
return [{ tag: 'col', getAttrs: collectDomAttrs }]
|
||||||
|
},
|
||||||
|
renderHTML ({ node }) {
|
||||||
|
return ['col', node.attrs.htmlAttrs]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
ImageNode,
|
||||||
|
HrNode,
|
||||||
|
StyleNode,
|
||||||
|
EmptySpanNode,
|
||||||
|
SpanMark,
|
||||||
|
LinkMark,
|
||||||
|
toggleMark('bold', 'strong', [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }, { style: 'font-weight=700' }], 'Mod-b'),
|
||||||
|
toggleMark('italic', 'em', [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }], 'Mod-i'),
|
||||||
|
toggleMark('underline', 'u', [{ tag: 'u' }, { style: 'text-decoration=underline' }], 'Mod-u'),
|
||||||
|
toggleMark('strike', 's', [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }], 'Mod-Shift-s'),
|
||||||
|
attrsMark('subscript', 'sub'),
|
||||||
|
attrsMark('superscript', 'sup'),
|
||||||
|
VariableHighlight
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default actionable(targetable(class extends HTMLElement {
|
||||||
|
static [target.static] = [
|
||||||
|
'textarea',
|
||||||
|
'editorElement',
|
||||||
|
'boldButton',
|
||||||
|
'italicButton',
|
||||||
|
'underlineButton',
|
||||||
|
'linkButton',
|
||||||
|
'linkTooltipTemplate'
|
||||||
|
]
|
||||||
|
|
||||||
|
async connectedCallback () {
|
||||||
|
if (!this.textarea || !this.editorElement) return
|
||||||
|
|
||||||
|
this.textarea.style.display = 'none'
|
||||||
|
this.adjustShortcutsForPlatform()
|
||||||
|
|
||||||
|
const tiptap = await loadTiptap()
|
||||||
|
|
||||||
|
const { Editor, Extension, Document, Text, HardBreak, UndoRedo, Gapcursor, Dropcursor } = tiptap
|
||||||
|
|
||||||
|
this.emailDocument = new DOMParser().parseFromString(this.textarea.value, 'text/html')
|
||||||
|
|
||||||
|
const shadow = this.editorElement.attachShadow({ mode: 'open' })
|
||||||
|
|
||||||
|
shadow.adoptedStyleSheets = [editorStylesheet]
|
||||||
|
|
||||||
|
this.emailDocument.head.querySelectorAll('style').forEach((style) => {
|
||||||
|
shadow.appendChild(style.cloneNode(true))
|
||||||
|
})
|
||||||
|
|
||||||
|
const container = document.createElement('div')
|
||||||
|
const bodyStyle = this.emailDocument.body.getAttribute('style')
|
||||||
|
|
||||||
|
if (bodyStyle) container.setAttribute('style', bodyStyle)
|
||||||
|
|
||||||
|
shadow.appendChild(container)
|
||||||
|
|
||||||
|
const LinkShortcut = Extension.create({
|
||||||
|
name: 'linkShortcut',
|
||||||
|
addKeyboardShortcuts: () => ({
|
||||||
|
'Mod-k': () => {
|
||||||
|
this.toggleLink()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
this.editor = new Editor({
|
||||||
|
element: container,
|
||||||
|
extensions: [
|
||||||
|
Document,
|
||||||
|
Text,
|
||||||
|
HardBreak,
|
||||||
|
UndoRedo,
|
||||||
|
Gapcursor,
|
||||||
|
Dropcursor,
|
||||||
|
...buildExtensions(tiptap),
|
||||||
|
LinkShortcut
|
||||||
|
],
|
||||||
|
content: this.emailDocument.body.innerHTML,
|
||||||
|
injectCSS: false,
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
dir: 'auto'
|
||||||
|
},
|
||||||
|
handleDOMEvents: {
|
||||||
|
click: (_, event) => {
|
||||||
|
if (event.target.closest('a')) event.preventDefault()
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
this.emailDocument.body.innerHTML = editor.getHTML()
|
||||||
|
|
||||||
|
this.textarea.value = this.emailDocument.documentElement.outerHTML
|
||||||
|
this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
},
|
||||||
|
onSelectionUpdate: ({ editor }) => {
|
||||||
|
this.updateToolbarState()
|
||||||
|
this.handleLinkTooltip(editor)
|
||||||
|
},
|
||||||
|
onBlur: () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.linkTooltip.tooltip.contains(document.activeElement)) {
|
||||||
|
this.linkTooltip.hide()
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.linkTooltip = new LinkTooltip(this, this.editor, this.linkTooltipTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustShortcutsForPlatform () {
|
||||||
|
if ((navigator.userAgentData?.platform)?.toLowerCase()?.includes('mac')) {
|
||||||
|
this.querySelectorAll('.tooltip[data-tip]').forEach(tooltip => {
|
||||||
|
const tip = tooltip.getAttribute('data-tip')
|
||||||
|
|
||||||
|
if (tip && tip.includes('Ctrl')) {
|
||||||
|
tooltip.setAttribute('data-tip', tip.replace(/Ctrl/g, '⌘'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bold (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.editor.chain().focus().toggleBold().run()
|
||||||
|
this.updateToolbarState()
|
||||||
|
}
|
||||||
|
|
||||||
|
italic (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.editor.chain().focus().toggleItalic().run()
|
||||||
|
this.updateToolbarState()
|
||||||
|
}
|
||||||
|
|
||||||
|
underline (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.editor.chain().focus().toggleUnderline().run()
|
||||||
|
this.updateToolbarState()
|
||||||
|
}
|
||||||
|
|
||||||
|
linkSelection (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.toggleLink()
|
||||||
|
this.updateToolbarState()
|
||||||
|
}
|
||||||
|
|
||||||
|
undo (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.editor.chain().focus().undo().run()
|
||||||
|
this.updateToolbarState()
|
||||||
|
}
|
||||||
|
|
||||||
|
redo (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.editor.chain().focus().redo().run()
|
||||||
|
this.updateToolbarState()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateToolbarState () {
|
||||||
|
this.boldButton.classList.toggle('bg-base-200', this.editor.isActive('bold'))
|
||||||
|
this.italicButton.classList.toggle('bg-base-200', this.editor.isActive('italic'))
|
||||||
|
this.underlineButton.classList.toggle('bg-base-200', this.editor.isActive('underline'))
|
||||||
|
this.linkButton.classList.toggle('bg-base-200', this.editor.isActive('link'))
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLinkTooltip (editor) {
|
||||||
|
const { from } = editor.state.selection
|
||||||
|
const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link')
|
||||||
|
|
||||||
|
if (!mark) {
|
||||||
|
if (this.linkTooltip.isVisible()) this.linkTooltip.hide()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.linkTooltip.isVisible() && this.linkTooltip.currentMark === mark) return
|
||||||
|
|
||||||
|
let linkStart = from
|
||||||
|
const start = editor.state.doc.resolve(from).start()
|
||||||
|
|
||||||
|
for (let i = from - 1; i >= start; i--) {
|
||||||
|
if (editor.state.doc.resolve(i).marks().some(m => m.eq(mark))) {
|
||||||
|
linkStart = i
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.linkTooltip.hide()
|
||||||
|
this.linkTooltip.show(mark.attrs.htmlAttrs?.href, linkStart > start ? linkStart - 1 : linkStart)
|
||||||
|
this.linkTooltip.currentMark = mark
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleLink () {
|
||||||
|
if (this.editor.isActive('link')) {
|
||||||
|
this.linkTooltip.hide()
|
||||||
|
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||||
|
this.updateToolbarState()
|
||||||
|
} else {
|
||||||
|
const { from } = this.editor.state.selection
|
||||||
|
|
||||||
|
this.linkTooltip.hide()
|
||||||
|
this.linkTooltip.show(this.editor.getAttributes('link').htmlAttrs?.href, from, { focus: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
insertVariable (e) {
|
||||||
|
const variable = e.target.closest('[data-variable]')?.dataset.variable
|
||||||
|
|
||||||
|
if (variable) {
|
||||||
|
const { from, to } = this.editor.state.selection
|
||||||
|
|
||||||
|
if (variable.includes('link') && from !== to) {
|
||||||
|
this.editor.chain().focus().setLink({ href: `{${variable}}` }).run()
|
||||||
|
} else {
|
||||||
|
this.editor.chain().focus().insertContent(`{${variable}}`).run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback () {
|
||||||
|
this.linkTooltip?.hide()
|
||||||
|
|
||||||
|
if (this.editor) {
|
||||||
|
this.editor.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -35,7 +35,7 @@ function loadTiptap () {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
class LinkTooltip {
|
export class LinkTooltip {
|
||||||
constructor (container, editor, templateEl) {
|
constructor (container, editor, templateEl) {
|
||||||
this.container = container
|
this.container = container
|
||||||
this.editor = editor
|
this.editor = editor
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export default class extends HTMLElement {
|
||||||
|
connectedCallback () {
|
||||||
|
if (this.dataset.disabled === 'true') return
|
||||||
|
if (!this.isMobile()) return
|
||||||
|
if (window.innerWidth >= 768) return
|
||||||
|
|
||||||
|
this.querySelectorAll('a[data-turbo-frame]').forEach((link) => {
|
||||||
|
link.dataset.turboFrame = '_top'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
isMobile () {
|
||||||
|
const isTouchWebkit = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit|android/i.test(navigator.userAgent)
|
||||||
|
|
||||||
|
return isTouchWebkit || /iPhone|iPad|iPod/i.test(navigator.userAgent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export default class extends HTMLElement {
|
||||||
|
connectedCallback () {
|
||||||
|
this.addEventListener('scroll', this.updateFade, { passive: true })
|
||||||
|
window.addEventListener('resize', this.updateFade)
|
||||||
|
|
||||||
|
this.updateFade()
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback () {
|
||||||
|
window.removeEventListener('resize', this.updateFade)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFade = () => {
|
||||||
|
const maxScroll = this.scrollWidth - this.clientWidth
|
||||||
|
const fadeGradient = {
|
||||||
|
right: 'linear-gradient(to right, black calc(100% - 24px), transparent)',
|
||||||
|
left: 'linear-gradient(to right, transparent, black 24px)',
|
||||||
|
both: 'linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent)'
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = 'none'
|
||||||
|
|
||||||
|
if (maxScroll > 4) {
|
||||||
|
if (this.scrollLeft <= 4) {
|
||||||
|
state = 'right'
|
||||||
|
} else if (this.scrollLeft >= maxScroll - 4) {
|
||||||
|
state = 'left'
|
||||||
|
} else {
|
||||||
|
state = 'both'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyFade(fadeGradient[state] || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFade (gradient) {
|
||||||
|
this.style.maskImage = gradient
|
||||||
|
this.style.webkitMaskImage = gradient
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,5 @@
|
|||||||
export default class extends HTMLElement {
|
export default class extends HTMLElement {
|
||||||
connectedCallback () {
|
connectedCallback () {
|
||||||
this.input.addEventListener('focus', () => {
|
|
||||||
if (this.title) {
|
|
||||||
this.title.classList.add('hidden', 'md:block')
|
|
||||||
this.input.classList.add('w-60')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.input.addEventListener('blur', (e) => {
|
|
||||||
if (this.title && !e.target.value) {
|
|
||||||
this.title.classList.remove('hidden')
|
|
||||||
this.input.classList.remove('w-60')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.button.addEventListener('click', (event) => {
|
this.button.addEventListener('click', (event) => {
|
||||||
if (!this.input.value && document.activeElement !== this.input) {
|
if (!this.input.value && document.activeElement !== this.input) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -21,16 +7,22 @@ export default class extends HTMLElement {
|
|||||||
this.input.focus()
|
this.input.focus()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback () {
|
||||||
|
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeCache = () => {
|
||||||
|
this.input.value = this.input.getAttribute('value') || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
get input () {
|
get input () {
|
||||||
return this.querySelector('input')
|
return this.querySelector('input')
|
||||||
}
|
}
|
||||||
|
|
||||||
get title () {
|
|
||||||
return document.querySelector(this.dataset.title)
|
|
||||||
}
|
|
||||||
|
|
||||||
get button () {
|
get button () {
|
||||||
return this.querySelector('button')
|
return this.querySelector('button')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default actionable(class extends HTMLElement {
|
|||||||
|
|
||||||
document.addEventListener('keyup', this.onEscKey)
|
document.addEventListener('keyup', this.onEscKey)
|
||||||
|
|
||||||
document.addEventListener('turbo:before-cache', this.close)
|
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||||
|
|
||||||
if (this.dataset.closeAfterSubmit !== 'false') {
|
if (this.dataset.closeAfterSubmit !== 'false') {
|
||||||
document.addEventListener('turbo:submit-end', this.onSubmit)
|
document.addEventListener('turbo:submit-end', this.onSubmit)
|
||||||
@@ -18,7 +18,7 @@ export default actionable(class extends HTMLElement {
|
|||||||
|
|
||||||
document.removeEventListener('keyup', this.onEscKey)
|
document.removeEventListener('keyup', this.onEscKey)
|
||||||
document.removeEventListener('turbo:submit-end', this.onSubmit)
|
document.removeEventListener('turbo:submit-end', this.onSubmit)
|
||||||
document.removeEventListener('turbo:before-cache', this.close)
|
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit = (e) => {
|
onSubmit = (e) => {
|
||||||
@@ -33,6 +33,12 @@ export default actionable(class extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeCache = () => {
|
||||||
|
if (this.closest('turbo-frame')?.src) {
|
||||||
|
this.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
close = (e) => {
|
close = (e) => {
|
||||||
e?.preventDefault()
|
e?.preventDefault()
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ safeRegisterElement('submission-form', class extends HTMLElement {
|
|||||||
scrollPadding: this.dataset.scrollPadding || '-80px',
|
scrollPadding: this.dataset.scrollPadding || '-80px',
|
||||||
signatureText: this.dataset.signatureText,
|
signatureText: this.dataset.signatureText,
|
||||||
language: this.dataset.language,
|
language: this.dataset.language,
|
||||||
|
paymentProvider: this.dataset.paymentProvider,
|
||||||
dryRun: this.dataset.dryRun === 'true',
|
dryRun: this.dataset.dryRun === 'true',
|
||||||
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
|
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
|
||||||
withSignatureId: this.dataset.withSignatureId === 'true',
|
withSignatureId: this.dataset.withSignatureId === 'true',
|
||||||
|
|||||||
@@ -69,8 +69,8 @@
|
|||||||
:attachments-index="attachmentsIndex"
|
:attachments-index="attachmentsIndex"
|
||||||
/>
|
/>
|
||||||
<FieldArea
|
<FieldArea
|
||||||
v-else-if="item.type === 'formula_area' && isMathLoaded"
|
v-else-if="item.type === 'formula_area' && (isMathLoaded || item.field.type === 'text')"
|
||||||
:model-value="calculateFormula(item.field)"
|
:model-value="item.field.type === 'text' ? evalTextFormula(item.field) : calculateFormula(item.field)"
|
||||||
:is-inline-size="isInlineSize"
|
:is-inline-size="isInlineSize"
|
||||||
:field="item.field"
|
:field="item.field"
|
||||||
:area="item.area"
|
:area="item.area"
|
||||||
@@ -289,6 +289,7 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
normalizeFormula: FormulaAreas.methods.normalizeFormula,
|
normalizeFormula: FormulaAreas.methods.normalizeFormula,
|
||||||
calculateFormula: FormulaAreas.methods.calculateFormula,
|
calculateFormula: FormulaAreas.methods.calculateFormula,
|
||||||
|
evalTextFormula: FormulaAreas.methods.evalTextFormula,
|
||||||
scrollInContainer: FieldAreas.methods.scrollInContainer,
|
scrollInContainer: FieldAreas.methods.scrollInContainer,
|
||||||
scrollIntoArea: FieldAreas.methods.scrollIntoArea,
|
scrollIntoArea: FieldAreas.methods.scrollIntoArea,
|
||||||
scrollIntoField: FieldAreas.methods.scrollIntoField,
|
scrollIntoField: FieldAreas.methods.scrollIntoField,
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{{ new Date(signature.created_at).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', timeZoneName: 'short' }) }}
|
{{ new Date(signature.created_at).toLocaleString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric', timeZoneName: 'short' }) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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
|
||||||
@@ -651,7 +651,7 @@ export default {
|
|||||||
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' }
|
||||||
@@ -673,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')]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="complete-button btn btn-sm btn-neutral text-white px-4"
|
class="complete-button btn btn-sm btn-neutral text-white px-4"
|
||||||
form="steps_form"
|
form="complete_form"
|
||||||
type="submit"
|
type="submit"
|
||||||
name="completed"
|
name="completed"
|
||||||
value="true"
|
value="true"
|
||||||
@@ -138,8 +138,16 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</Teleport>
|
</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%"
|
||||||
@@ -166,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"
|
||||||
@@ -281,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] }"
|
||||||
@@ -309,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 }"
|
||||||
@@ -348,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
|
||||||
@@ -548,6 +560,8 @@
|
|||||||
:fields="formulaFields"
|
:fields="formulaFields"
|
||||||
:values="values"
|
:values="values"
|
||||||
:readonly-values="readonlyFieldValues"
|
:readonly-values="readonlyFieldValues"
|
||||||
|
:fetch-options="fetchOptions"
|
||||||
|
:provider="paymentProvider"
|
||||||
@attached="attachments.push($event)"
|
@attached="attachments.push($event)"
|
||||||
@focus="scrollIntoField(currentField)"
|
@focus="scrollIntoField(currentField)"
|
||||||
@submit="!isSubmitting && submitStep()"
|
@submit="!isSubmitting && submitStep()"
|
||||||
@@ -560,6 +574,7 @@
|
|||||||
:empty-value-required-step="emptyValueRequiredStep"
|
:empty-value-required-step="emptyValueRequiredStep"
|
||||||
:field="currentField"
|
:field="currentField"
|
||||||
:submitter-slug="submitterSlug"
|
:submitter-slug="submitterSlug"
|
||||||
|
:fetch-options="fetchOptions"
|
||||||
:values="values"
|
:values="values"
|
||||||
@submit="!isSubmitting && submitStep()"
|
@submit="!isSubmitting && submitStep()"
|
||||||
/>
|
/>
|
||||||
@@ -572,6 +587,7 @@
|
|||||||
:empty-value-required-step="emptyValueRequiredStep"
|
:empty-value-required-step="emptyValueRequiredStep"
|
||||||
:field="currentField"
|
:field="currentField"
|
||||||
:submitter-slug="submitterSlug"
|
:submitter-slug="submitterSlug"
|
||||||
|
:fetch-options="fetchOptions"
|
||||||
:values="values"
|
:values="values"
|
||||||
@focus="scrollIntoField(currentField)"
|
@focus="scrollIntoField(currentField)"
|
||||||
@submit="!isSubmitting && submitStep()"
|
@submit="!isSubmitting && submitStep()"
|
||||||
@@ -988,6 +1004,11 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
|
paymentProvider: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
values: {
|
values: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -1161,7 +1182,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
isAnonymousChecboxes () {
|
isAnonymousChecboxes () {
|
||||||
|
if (this.currentField) {
|
||||||
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4
|
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) {
|
||||||
@@ -1559,6 +1584,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
goToStep (stepIndex, scrollToArea = false, clickUpload = false) {
|
goToStep (stepIndex, scrollToArea = false, clickUpload = false) {
|
||||||
|
this.isInvite = false
|
||||||
this.currentStep = stepIndex
|
this.currentStep = stepIndex
|
||||||
this.showFillAllRequiredFields = false
|
this.showFillAllRequiredFields = false
|
||||||
|
|
||||||
@@ -1583,6 +1609,10 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
saveStep (formData) {
|
saveStep (formData) {
|
||||||
|
if (!formData && !this.$refs.form) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const currentFieldUuids = this.currentStepFields.map((f) => f.uuid)
|
const currentFieldUuids = this.currentStepFields.map((f) => f.uuid)
|
||||||
const currentFieldType = this.currentField.type
|
const currentFieldType = this.currentField.type
|
||||||
|
|
||||||
@@ -1749,7 +1779,7 @@ export default {
|
|||||||
window.location.href = sanitizeUrl(this.completedRedirectUrl)
|
window.location.href = sanitizeUrl(this.completedRedirectUrl)
|
||||||
} else {
|
} else {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
const root = this.$root.$el.parentNode.getRootNode()
|
const root = this.$root.$el.parentNode?.getRootNode() || document
|
||||||
const completedEl = root.getElementById('form_completed')
|
const completedEl = root.getElementById('form_completed')
|
||||||
|
|
||||||
if (completedEl) {
|
if (completedEl) {
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
:to="findPageElementForArea(area)"
|
:to="findPageElementForArea(area)"
|
||||||
>
|
>
|
||||||
<FieldArea
|
<FieldArea
|
||||||
v-if="isMathLoaded"
|
v-if="isMathLoaded || field.type === 'text'"
|
||||||
:model-value="calculateFormula(field)"
|
:model-value="field.type === 'text' ? evalTextFormula(field) : calculateFormula(field)"
|
||||||
:is-inline-size="isInlineSize"
|
:is-inline-size="isInlineSize"
|
||||||
:field="field"
|
:field="field"
|
||||||
:area="area"
|
:area="area"
|
||||||
@@ -95,6 +95,25 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return this.math.evaluate(transformedFormula.toLowerCase())
|
return this.math.evaluate(transformedFormula.toLowerCase())
|
||||||
|
},
|
||||||
|
evalTextFormula (field, depth = 0) {
|
||||||
|
if (depth > 10) return ''
|
||||||
|
|
||||||
|
return field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||||
|
const formulaField = this.fieldsUuidIndex[uuid]
|
||||||
|
|
||||||
|
if (formulaField?.preferences?.formula) {
|
||||||
|
if (formulaField.type === 'text') {
|
||||||
|
return this.evalTextFormula(formulaField, depth + 1)
|
||||||
|
} else if (this.isMathLoaded) {
|
||||||
|
return this.calculateFormula(formulaField)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = this.readonlyValues[uuid] ?? this.values[uuid]
|
||||||
|
|
||||||
|
return Array.isArray(value) ? value.join(', ') : (value ?? '')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const en = {
|
|||||||
close: 'Close',
|
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',
|
||||||
@@ -88,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',
|
||||||
@@ -105,6 +107,7 @@ const en = {
|
|||||||
email_has_been_sent: 'Email has been sent',
|
email_has_been_sent: 'Email has been sent',
|
||||||
processing: 'Processing',
|
processing: 'Processing',
|
||||||
pay_with_stripe: 'Pay with Stripe',
|
pay_with_stripe: 'Pay with Stripe',
|
||||||
|
pay_with_paypal: 'Pay with PayPal',
|
||||||
reupload: 'Reupload',
|
reupload: 'Reupload',
|
||||||
upload: 'Upload',
|
upload: 'Upload',
|
||||||
files: 'Files',
|
files: 'Files',
|
||||||
@@ -120,6 +123,7 @@ 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',
|
||||||
@@ -204,6 +208,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',
|
||||||
@@ -221,6 +226,7 @@ const es = {
|
|||||||
email_has_been_sent: 'El correo electrónico ha sido enviado',
|
email_has_been_sent: 'El correo electrónico ha sido enviado',
|
||||||
processing: 'Procesando',
|
processing: 'Procesando',
|
||||||
pay_with_stripe: 'Pagar con Stripe',
|
pay_with_stripe: 'Pagar con Stripe',
|
||||||
|
pay_with_paypal: 'Pagar con PayPal',
|
||||||
reupload: 'Volver a subir',
|
reupload: 'Volver a subir',
|
||||||
upload: 'Subir',
|
upload: 'Subir',
|
||||||
files: 'Archivos',
|
files: 'Archivos',
|
||||||
@@ -236,6 +242,7 @@ 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',
|
||||||
@@ -320,6 +327,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',
|
||||||
@@ -337,6 +345,7 @@ const it = {
|
|||||||
email_has_been_sent: "L'email è stata inviata",
|
email_has_been_sent: "L'email è stata inviata",
|
||||||
processing: 'Elaborazione',
|
processing: 'Elaborazione',
|
||||||
pay_with_stripe: 'Paga con Stripe',
|
pay_with_stripe: 'Paga con Stripe',
|
||||||
|
pay_with_paypal: 'Paga con PayPal',
|
||||||
reupload: 'Ricarica',
|
reupload: 'Ricarica',
|
||||||
upload: 'Carica',
|
upload: 'Carica',
|
||||||
files: 'File',
|
files: 'File',
|
||||||
@@ -352,6 +361,7 @@ 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',
|
||||||
@@ -436,6 +446,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',
|
||||||
@@ -453,6 +464,7 @@ const de = {
|
|||||||
email_has_been_sent: 'E-Mail wurde gesendet',
|
email_has_been_sent: 'E-Mail wurde gesendet',
|
||||||
processing: 'Verarbeitung',
|
processing: 'Verarbeitung',
|
||||||
pay_with_stripe: 'Mit Stripe bezahlen',
|
pay_with_stripe: 'Mit Stripe bezahlen',
|
||||||
|
pay_with_paypal: 'Mit PayPal bezahlen',
|
||||||
reupload: 'Erneut hochladen',
|
reupload: 'Erneut hochladen',
|
||||||
upload: 'Hochladen',
|
upload: 'Hochladen',
|
||||||
files: 'Dateien',
|
files: 'Dateien',
|
||||||
@@ -468,6 +480,7 @@ 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',
|
||||||
@@ -552,6 +565,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',
|
||||||
@@ -569,6 +583,7 @@ const fr = {
|
|||||||
email_has_been_sent: "L'e-mail a été envoyé",
|
email_has_been_sent: "L'e-mail a été envoyé",
|
||||||
processing: 'Traitement en cours',
|
processing: 'Traitement en cours',
|
||||||
pay_with_stripe: 'Payer avec Stripe',
|
pay_with_stripe: 'Payer avec Stripe',
|
||||||
|
pay_with_paypal: 'Payer avec PayPal',
|
||||||
reupload: 'Retéléverser',
|
reupload: 'Retéléverser',
|
||||||
upload: 'Téléverser',
|
upload: 'Téléverser',
|
||||||
files: 'Fichiers',
|
files: 'Fichiers',
|
||||||
@@ -584,6 +599,7 @@ 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',
|
||||||
@@ -668,6 +684,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',
|
||||||
@@ -685,6 +702,7 @@ const pl = {
|
|||||||
email_has_been_sent: 'E-mail został wysłany',
|
email_has_been_sent: 'E-mail został wysłany',
|
||||||
processing: 'Przetwarzanie',
|
processing: 'Przetwarzanie',
|
||||||
pay_with_stripe: 'Zapłać za pomocą Stripe',
|
pay_with_stripe: 'Zapłać za pomocą Stripe',
|
||||||
|
pay_with_paypal: 'Zapłać za pomocą PayPal',
|
||||||
reupload: 'Ponowne przesłanie',
|
reupload: 'Ponowne przesłanie',
|
||||||
upload: 'Przesyłanie',
|
upload: 'Przesyłanie',
|
||||||
files: 'Pliki',
|
files: 'Pliki',
|
||||||
@@ -700,6 +718,7 @@ 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} символів',
|
||||||
@@ -784,6 +803,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: 'Змінити номер телефону',
|
||||||
@@ -801,6 +821,7 @@ const uk = {
|
|||||||
email_has_been_sent: 'Електронний лист був відправлений',
|
email_has_been_sent: 'Електронний лист був відправлений',
|
||||||
processing: 'Обробка',
|
processing: 'Обробка',
|
||||||
pay_with_stripe: 'Сплатити за допомогою Stripe',
|
pay_with_stripe: 'Сплатити за допомогою Stripe',
|
||||||
|
pay_with_paypal: 'Сплатити за допомогою PayPal',
|
||||||
reupload: 'Перезавантажити',
|
reupload: 'Перезавантажити',
|
||||||
upload: 'Завантажити',
|
upload: 'Завантажити',
|
||||||
files: 'Файли',
|
files: 'Файли',
|
||||||
@@ -816,6 +837,7 @@ 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ů',
|
||||||
@@ -900,6 +922,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',
|
||||||
@@ -917,6 +940,7 @@ const cs = {
|
|||||||
email_has_been_sent: 'E-mail byl odeslán',
|
email_has_been_sent: 'E-mail byl odeslán',
|
||||||
processing: 'Zpracování',
|
processing: 'Zpracování',
|
||||||
pay_with_stripe: 'Zaplatit přes Stripe',
|
pay_with_stripe: 'Zaplatit přes Stripe',
|
||||||
|
pay_with_paypal: 'Zaplatit přes PayPal',
|
||||||
reupload: 'Znovu nahrát',
|
reupload: 'Znovu nahrát',
|
||||||
upload: 'Nahrát',
|
upload: 'Nahrát',
|
||||||
files: 'Soubory',
|
files: 'Soubory',
|
||||||
@@ -932,6 +956,7 @@ 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',
|
||||||
@@ -1016,6 +1041,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',
|
||||||
@@ -1033,6 +1059,7 @@ const pt = {
|
|||||||
email_has_been_sent: 'Email enviado',
|
email_has_been_sent: 'Email enviado',
|
||||||
processing: 'Processamento',
|
processing: 'Processamento',
|
||||||
pay_with_stripe: 'Pagar com Stripe',
|
pay_with_stripe: 'Pagar com Stripe',
|
||||||
|
pay_with_paypal: 'Pagar com PayPal',
|
||||||
reupload: 'Recarregar',
|
reupload: 'Recarregar',
|
||||||
upload: 'Carregar',
|
upload: 'Carregar',
|
||||||
files: 'Arquivos',
|
files: 'Arquivos',
|
||||||
@@ -1048,6 +1075,7 @@ 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} תווים',
|
||||||
@@ -1132,6 +1160,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: 'שינוי מספר טלפון',
|
||||||
@@ -1149,6 +1178,7 @@ const he = {
|
|||||||
email_has_been_sent: 'האימייל נשלח',
|
email_has_been_sent: 'האימייל נשלח',
|
||||||
processing: 'מעבד',
|
processing: 'מעבד',
|
||||||
pay_with_stripe: 'שלם עם סטרייפ',
|
pay_with_stripe: 'שלם עם סטרייפ',
|
||||||
|
pay_with_paypal: 'שלם עם פייפאל',
|
||||||
reupload: 'העלה שוב',
|
reupload: 'העלה שוב',
|
||||||
upload: 'העלאה',
|
upload: 'העלאה',
|
||||||
files: 'קבצים',
|
files: 'קבצים',
|
||||||
@@ -1164,6 +1194,7 @@ 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',
|
||||||
@@ -1248,6 +1279,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',
|
||||||
@@ -1265,6 +1297,7 @@ const nl = {
|
|||||||
email_has_been_sent: 'E-mail is verzonden',
|
email_has_been_sent: 'E-mail is verzonden',
|
||||||
processing: 'Verwerken',
|
processing: 'Verwerken',
|
||||||
pay_with_stripe: 'Betalen met Stripe',
|
pay_with_stripe: 'Betalen met Stripe',
|
||||||
|
pay_with_paypal: 'Betalen met PayPal',
|
||||||
reupload: 'Opnieuw uploaden',
|
reupload: 'Opnieuw uploaden',
|
||||||
upload: 'Uploaden',
|
upload: 'Uploaden',
|
||||||
files: 'Bestanden',
|
files: 'Bestanden',
|
||||||
@@ -1280,6 +1313,7 @@ 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} حرفًا',
|
||||||
@@ -1364,6 +1398,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: 'تغيير رقم الهاتف',
|
||||||
@@ -1381,6 +1416,7 @@ const ar = {
|
|||||||
email_has_been_sent: 'تم إرسال البريد الإلكتروني',
|
email_has_been_sent: 'تم إرسال البريد الإلكتروني',
|
||||||
processing: 'جارٍ المعالجة',
|
processing: 'جارٍ المعالجة',
|
||||||
pay_with_stripe: 'الدفع بواسطة Stripe',
|
pay_with_stripe: 'الدفع بواسطة Stripe',
|
||||||
|
pay_with_paypal: 'الدفع بواسطة PayPal',
|
||||||
reupload: 'إعادة التحميل',
|
reupload: 'إعادة التحميل',
|
||||||
upload: 'تحميل',
|
upload: 'تحميل',
|
||||||
files: 'الملفات',
|
files: 'الملفات',
|
||||||
@@ -1396,6 +1432,7 @@ 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}자여야 합니다',
|
||||||
@@ -1480,6 +1517,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: '전화번호 변경',
|
||||||
@@ -1497,6 +1535,7 @@ const ko = {
|
|||||||
email_has_been_sent: '이메일이 전송되었습니다',
|
email_has_been_sent: '이메일이 전송되었습니다',
|
||||||
processing: '처리 중',
|
processing: '처리 중',
|
||||||
pay_with_stripe: '스트라이프로 결제',
|
pay_with_stripe: '스트라이프로 결제',
|
||||||
|
pay_with_paypal: 'PayPal로 결제',
|
||||||
reupload: '다시 업로드',
|
reupload: '다시 업로드',
|
||||||
upload: '업로드',
|
upload: '업로드',
|
||||||
files: '파일',
|
files: '파일',
|
||||||
@@ -1512,6 +1551,7 @@ 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}文字でなければなりません',
|
||||||
@@ -1596,6 +1636,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: '電話番号を変更',
|
||||||
@@ -1613,6 +1654,7 @@ const ja = {
|
|||||||
email_has_been_sent: 'メールが送信されました',
|
email_has_been_sent: 'メールが送信されました',
|
||||||
processing: '処理中',
|
processing: '処理中',
|
||||||
pay_with_stripe: 'Stripeで支払う',
|
pay_with_stripe: 'Stripeで支払う',
|
||||||
|
pay_with_paypal: 'PayPalで支払う',
|
||||||
reupload: '再アップロード',
|
reupload: '再アップロード',
|
||||||
upload: 'アップロード',
|
upload: 'アップロード',
|
||||||
files: 'ファイル',
|
files: 'ファイル',
|
||||||
|
|||||||
@@ -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,7 +168,8 @@
|
|||||||
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"
|
||||||
:placeholder="`${t('type_initial_here')}...`"
|
:aria-label="field.name || t('initials')"
|
||||||
|
:placeholder="`${t('type_initial_here')}${field.required ? '...' : ' (' + t('optional') + ')'}`"
|
||||||
type="text"
|
type="text"
|
||||||
@focus="$emit('focus')"
|
@focus="$emit('focus')"
|
||||||
@input="updateWrittenInitials"
|
@input="updateWrittenInitials"
|
||||||
@@ -289,7 +293,7 @@ export default {
|
|||||||
|
|
||||||
if (!this.isDrawInitials) {
|
if (!this.isDrawInitials) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
if (this.$refs.textInput) {
|
if (this.$refs.textInput && this.field.required === true) {
|
||||||
this.initTextInitial()
|
this.initTextInitial()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
@@ -311,6 +312,11 @@ export default {
|
|||||||
values: {
|
values: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
fetchOptions: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: () => ({})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
emits: ['submit'],
|
emits: ['submit'],
|
||||||
@@ -493,8 +499,9 @@ export default {
|
|||||||
|
|
||||||
const resp = await fetch(this.baseUrl + '/api/kba', {
|
const resp = await fetch(this.baseUrl + '/api/kba', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: JSON.stringify(payload),
|
||||||
body: JSON.stringify(payload)
|
...this.fetchOptions,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
@@ -549,7 +556,8 @@ export default {
|
|||||||
reference: this.reference,
|
reference: this.reference,
|
||||||
submitter_slug: this.submitterSlug
|
submitter_slug: this.submitterSlug
|
||||||
}),
|
}),
|
||||||
headers: { 'Content-Type': 'application/json' }
|
...this.fetchOptions,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<label
|
<label
|
||||||
v-if="!modelValue && !sessionId"
|
v-if="!modelValue && !isProcessing"
|
||||||
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
|
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
|
||||||
>
|
>
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
@@ -25,14 +25,15 @@
|
|||||||
class="hidden"
|
class="hidden"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="modelValue && !sessionId"
|
v-if="modelValue && !isProcessing"
|
||||||
class=" text-2xl mb-2"
|
class=" text-2xl mb-2"
|
||||||
>
|
>
|
||||||
{{ t('already_paid') }}
|
{{ t('already_paid') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<button
|
<button
|
||||||
v-if="sessionId"
|
v-if="isProcessing"
|
||||||
|
type="button"
|
||||||
disabled
|
disabled
|
||||||
class="base-button w-full modal-save-button"
|
class="base-button w-full modal-save-button"
|
||||||
>
|
>
|
||||||
@@ -47,8 +48,9 @@
|
|||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
:id="field.uuid"
|
:id="field.uuid"
|
||||||
class="btn bg-[#7B73FF] text-white hover:bg-[#0A2540] text-lg w-full"
|
type="button"
|
||||||
:class="{ disabled: isCreatingCheckout }"
|
class="btn text-white text-lg w-full"
|
||||||
|
:class="[isPaypal ? 'bg-[#003087] hover:bg-[#012169]' : 'bg-[#7B73FF] hover:bg-[#0A2540]', { disabled: isCreatingCheckout }]"
|
||||||
:disabled="isCreatingCheckout"
|
:disabled="isCreatingCheckout"
|
||||||
@click.prevent="postCheckout"
|
@click.prevent="postCheckout"
|
||||||
>
|
>
|
||||||
@@ -57,12 +59,16 @@
|
|||||||
width="22"
|
width="22"
|
||||||
class="animate-spin"
|
class="animate-spin"
|
||||||
/>
|
/>
|
||||||
|
<IconBrandPaypal
|
||||||
|
v-else-if="isPaypal"
|
||||||
|
width="22"
|
||||||
|
/>
|
||||||
<IconBrandStripe
|
<IconBrandStripe
|
||||||
v-else
|
v-else
|
||||||
width="22"
|
width="22"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
{{ t('pay_with_stripe') }}
|
{{ isPaypal ? t('pay_with_paypal') : t('pay_with_stripe') }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,13 +76,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { IconBrandStripe, IconInnerShadowTop, IconLoader } from '@tabler/icons-vue'
|
import { IconBrandStripe, IconBrandPaypal, IconInnerShadowTop, IconLoader } from '@tabler/icons-vue'
|
||||||
import MarkdownContent from './markdown_content'
|
import MarkdownContent from './markdown_content'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'PaymentStep',
|
name: 'PaymentStep',
|
||||||
components: {
|
components: {
|
||||||
IconBrandStripe,
|
IconBrandStripe,
|
||||||
|
IconBrandPaypal,
|
||||||
MarkdownContent,
|
MarkdownContent,
|
||||||
IconInnerShadowTop,
|
IconInnerShadowTop,
|
||||||
IconLoader
|
IconLoader
|
||||||
@@ -109,12 +116,23 @@ export default {
|
|||||||
submitterSlug: {
|
submitterSlug: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
fetchOptions: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: () => ({})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
emits: ['focus', 'submit', 'update:model-value', 'attached'],
|
emits: ['focus', 'submit', 'update:model-value', 'attached'],
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
isCreatingCheckout: false,
|
isCreatingCheckout: false,
|
||||||
|
isProcessing: false,
|
||||||
isMathLoaded: false
|
isMathLoaded: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -129,8 +147,14 @@ export default {
|
|||||||
queryParams () {
|
queryParams () {
|
||||||
return new URLSearchParams(window.location.search)
|
return new URLSearchParams(window.location.search)
|
||||||
},
|
},
|
||||||
|
isPaypal () {
|
||||||
|
return this.provider === 'paypal'
|
||||||
|
},
|
||||||
|
paymentEndpoint () {
|
||||||
|
return this.isPaypal ? '/api/paypal_payments' : '/api/stripe_payments'
|
||||||
|
},
|
||||||
sessionId () {
|
sessionId () {
|
||||||
return this.queryParams.get('stripe_session_id')
|
return this.queryParams.get('stripe_session_id') || this.queryParams.get('paypal_order_id') || (this.isPaypal && this.queryParams.get('token'))
|
||||||
},
|
},
|
||||||
defaultName () {
|
defaultName () {
|
||||||
if (this.field.preferences?.price_id || this.field.preferences?.payment_link_id) {
|
if (this.field.preferences?.price_id || this.field.preferences?.payment_link_id) {
|
||||||
@@ -157,6 +181,8 @@ export default {
|
|||||||
},
|
},
|
||||||
async mounted () {
|
async mounted () {
|
||||||
if (this.sessionId) {
|
if (this.sessionId) {
|
||||||
|
this.isProcessing = true
|
||||||
|
|
||||||
this.$emit('submit')
|
this.$emit('submit')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +199,21 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
normalizedPaymentUrl () {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
const keys = ['stripe_session_id', 'paypal_order_id', 'field_uuid']
|
||||||
|
|
||||||
|
if (this.isPaypal) {
|
||||||
|
keys.push('token', 'PayerID')
|
||||||
|
}
|
||||||
|
|
||||||
|
keys.forEach((key) => url.searchParams.delete(key))
|
||||||
|
|
||||||
|
return url.toString()
|
||||||
|
},
|
||||||
|
normalizedPaymentParams () {
|
||||||
|
window.history.replaceState({}, document.title, this.normalizedPaymentUrl())
|
||||||
|
},
|
||||||
calculateFormula () {
|
calculateFormula () {
|
||||||
const transformedFormula = this.normalizeFormula(this.field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
|
const transformedFormula = this.normalizeFormula(this.field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||||
return this.readonlyValues[uuid] || this.values[uuid] || 0.0
|
return this.readonlyValues[uuid] || this.values[uuid] || 0.0
|
||||||
@@ -193,24 +234,31 @@ export default {
|
|||||||
},
|
},
|
||||||
async submit () {
|
async submit () {
|
||||||
if (this.sessionId) {
|
if (this.sessionId) {
|
||||||
return fetch(this.baseUrl + '/api/stripe_payments/' + this.sessionId, {
|
return fetch(this.baseUrl + this.paymentEndpoint + '/' + this.sessionId, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
submitter_slug: this.submitterSlug
|
submitter_slug: this.submitterSlug
|
||||||
}),
|
}),
|
||||||
headers: { 'Content-Type': 'application/json' }
|
...this.fetchOptions,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
|
||||||
}).then(async (resp) => {
|
}).then(async (resp) => {
|
||||||
if (resp.status === 422 || resp.status === 500) {
|
if (resp.status === 422 || resp.status === 500) {
|
||||||
|
this.isProcessing = false
|
||||||
|
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
|
|
||||||
alert(data.error || 'Unexpected error')
|
if (data.error) {
|
||||||
|
alert(data.error)
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.reject(new Error(data.error))
|
return Promise.reject(new Error(data.error))
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachment = await resp.json()
|
const attachment = await resp.json()
|
||||||
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname)
|
this.isProcessing = false
|
||||||
|
|
||||||
|
this.normalizedPaymentParams()
|
||||||
|
|
||||||
this.$emit('update:model-value', attachment.uuid)
|
this.$emit('update:model-value', attachment.uuid)
|
||||||
this.$emit('attached', attachment)
|
this.$emit('attached', attachment)
|
||||||
@@ -224,15 +272,16 @@ export default {
|
|||||||
postCheckout ({ checkStatus } = {}) {
|
postCheckout ({ checkStatus } = {}) {
|
||||||
this.isCreatingCheckout = true
|
this.isCreatingCheckout = true
|
||||||
|
|
||||||
fetch(this.baseUrl + '/api/stripe_payments', {
|
fetch(this.baseUrl + this.paymentEndpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
submitter_slug: this.submitterSlug,
|
submitter_slug: this.submitterSlug,
|
||||||
field_uuid: this.field.uuid,
|
field_uuid: this.field.uuid,
|
||||||
check_status: checkStatus,
|
check_status: checkStatus,
|
||||||
referer: document.location.href
|
referer: this.normalizedPaymentUrl()
|
||||||
}),
|
}),
|
||||||
headers: { 'Content-Type': 'application/json' }
|
...this.fetchOptions,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
|
||||||
}).then(async (resp) => {
|
}).then(async (resp) => {
|
||||||
if (resp.status === 422 || resp.status === 500) {
|
if (resp.status === 422 || resp.status === 500) {
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
required
|
required
|
||||||
maxlength="6"
|
maxlength="6"
|
||||||
autofocus
|
autofocus
|
||||||
inputmode="decimal"
|
inputmode="numeric"
|
||||||
@input="onInputCode"
|
@input="onInputCode"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between mt-2 -mb-2 md:-mb-4">
|
<div class="flex justify-between mt-2 -mb-2 md:-mb-4">
|
||||||
@@ -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,7 +246,8 @@
|
|||||||
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"
|
||||||
:placeholder="`${t('type_signature_here')}...`"
|
:aria-label="field.name || t('signature')"
|
||||||
|
:placeholder="`${t('type_signature_here')}${field.required ? '...' : ' (' + t('optional') + ')'}`"
|
||||||
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"
|
||||||
@@ -528,7 +535,7 @@ export default {
|
|||||||
this.$nextTick(() => this.drawSignatureSrc())
|
this.$nextTick(() => this.drawSignatureSrc())
|
||||||
} else if (this.isTextSignature) {
|
} else if (this.isTextSignature) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
if (this.$refs.textInput) {
|
if (this.$refs.textInput && this.field.required === true) {
|
||||||
this.initTypedSignature()
|
this.initTypedSignature()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ export default {
|
|||||||
submitterSlug: {
|
submitterSlug: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
fetchOptions: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: () => ({})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
emits: ['focus', 'submit', 'update:model-value', 'attached'],
|
emits: ['focus', 'submit', 'update:model-value', 'attached'],
|
||||||
@@ -163,7 +168,8 @@ export default {
|
|||||||
submitter_slug: this.submitterSlug,
|
submitter_slug: this.submitterSlug,
|
||||||
redirect_url: document.location.href
|
redirect_url: document.location.href
|
||||||
}),
|
}),
|
||||||
headers: { 'Content-Type': 'application/json' }
|
...this.fetchOptions,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
|
||||||
}).then(async (resp) => {
|
}).then(async (resp) => {
|
||||||
this.eidEasyData = await resp.json()
|
this.eidEasyData = await resp.json()
|
||||||
|
|
||||||
@@ -205,7 +211,8 @@ export default {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
submitter_slug: this.submitterSlug
|
submitter_slug: this.submitterSlug
|
||||||
}),
|
}),
|
||||||
headers: { 'Content-Type': 'application/json' }
|
...this.fetchOptions,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (resp.status === 404) {
|
if (resp.status === 404) {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -318,7 +319,7 @@ export default {
|
|||||||
default: false
|
default: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title'],
|
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title', 'multi-select'],
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
isContenteditable: false,
|
isContenteditable: false,
|
||||||
@@ -725,6 +726,10 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.inputMode && (this.isValueInput || this.isCheckboxInput || this.isSelectInput)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
document.activeElement?.blur()
|
document.activeElement?.blur()
|
||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -793,6 +798,8 @@ export default {
|
|||||||
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(this.area), 1)
|
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(this.area), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.$emit('multi-select', e)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
data-turbo="false"
|
data-turbo="false"
|
||||||
class="inline"
|
class="inline"
|
||||||
method="post"
|
method="post"
|
||||||
:action="`/d/${template.slug}`"
|
:action="`/start_form_self/${template.id}`"
|
||||||
@submit="maybeShowErrorTemplateAlert"
|
@submit="maybeShowErrorTemplateAlert"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -125,12 +125,6 @@
|
|||||||
:value="authenticityToken"
|
:value="authenticityToken"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
>
|
>
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="selfsign"
|
|
||||||
value="true"
|
|
||||||
autocomplete="off"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-ghost text-base hidden md:flex"
|
class="btn btn-primary btn-ghost text-base hidden md:flex"
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -164,7 +158,7 @@
|
|||||||
v-if="withSendButton"
|
v-if="withSendButton"
|
||||||
id="send_button"
|
id="send_button"
|
||||||
:href="`/templates/${template.id}/submissions/new?with_link=true`"
|
:href="`/templates/${template.id}/submissions/new?with_link=true`"
|
||||||
data-turbo-frame="modal"
|
:data-turbo-frame="isMobile && isBreakpointLg ? '_top' : 'modal'"
|
||||||
class="white-button md:!px-6"
|
class="white-button md:!px-6"
|
||||||
@click="maybeShowErrorTemplateAlert"
|
@click="maybeShowErrorTemplateAlert"
|
||||||
>
|
>
|
||||||
@@ -202,7 +196,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
class="dropdown dropdown-end"
|
class="dropdown dropdown-end"
|
||||||
:class="{ 'dropdown-open': isDownloading }"
|
:class="{ 'dropdown-open': isDownloading || isPreviewLoading }"
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -221,15 +215,23 @@
|
|||||||
:href="`/templates/${template.id}/form`"
|
:href="`/templates/${template.id}/form`"
|
||||||
data-turbo="false"
|
data-turbo="false"
|
||||||
class="flex items-center justify-center space-x-2"
|
class="flex items-center justify-center space-x-2"
|
||||||
|
@click.exact="isPreviewLoading = true"
|
||||||
>
|
>
|
||||||
<IconEye class="w-6 h-6 flex-shrink-0" />
|
<IconInnerShadowTop
|
||||||
|
v-if="isPreviewLoading"
|
||||||
|
class="animate-spin w-6 h-6 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<IconEye
|
||||||
|
v-else
|
||||||
|
class="w-6 h-6 flex-shrink-0"
|
||||||
|
/>
|
||||||
<span class="whitespace-nowrap">{{ t('save_and_preview') }}</span>
|
<span class="whitespace-nowrap">{{ t('save_and_preview') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
:href="`/templates/${template.id}/preferences`"
|
:href="`/templates/${template.id}/preferences`"
|
||||||
data-turbo-frame="modal"
|
:data-turbo-frame="isMobile && isBreakpointLg ? '_top' : 'modal'"
|
||||||
class="flex space-x-2"
|
class="flex space-x-2"
|
||||||
@click="closeDropdown"
|
@click="closeDropdown"
|
||||||
>
|
>
|
||||||
@@ -281,6 +283,10 @@
|
|||||||
:href="`/templates/${template.id}`"
|
:href="`/templates/${template.id}`"
|
||||||
class="base-button"
|
class="base-button"
|
||||||
>
|
>
|
||||||
|
<IconX
|
||||||
|
width="20"
|
||||||
|
class="md:hidden stroke-2"
|
||||||
|
/>
|
||||||
<span class="hidden md:inline">
|
<span class="hidden md:inline">
|
||||||
{{ t('back') }}
|
{{ t('back') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -309,6 +315,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 +326,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 +364,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 +466,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 +650,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 +692,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 +722,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 +765,9 @@ export default {
|
|||||||
IconEye,
|
IconEye,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
IconDeviceFloppy,
|
IconDeviceFloppy,
|
||||||
RevisionsModal
|
IconX,
|
||||||
|
RevisionsModal,
|
||||||
|
DocumentsEditorModal
|
||||||
},
|
},
|
||||||
provide () {
|
provide () {
|
||||||
return {
|
return {
|
||||||
@@ -724,7 +785,10 @@ export default {
|
|||||||
withVerification: this.withVerification,
|
withVerification: this.withVerification,
|
||||||
withKba: this.withKba,
|
withKba: this.withKba,
|
||||||
withPayment: this.withPayment,
|
withPayment: this.withPayment,
|
||||||
isPaymentConnected: this.isPaymentConnected,
|
isStripeConnected: this.isStripeConnected,
|
||||||
|
withStripe: this.withStripe,
|
||||||
|
withPaypal: this.withPaypal,
|
||||||
|
isPaypalConnected: this.isPaypalConnected,
|
||||||
withFormula: this.withFormula,
|
withFormula: this.withFormula,
|
||||||
withConditions: this.withConditions,
|
withConditions: this.withConditions,
|
||||||
withCustomFields: this.withCustomFields,
|
withCustomFields: this.withCustomFields,
|
||||||
@@ -994,7 +1058,22 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
isPaymentConnected: {
|
isStripeConnected: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
withStripe: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
withPaypal: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
isPaypalConnected: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
default: false
|
default: false
|
||||||
@@ -1045,6 +1124,7 @@ export default {
|
|||||||
documentRefs: [],
|
documentRefs: [],
|
||||||
isBreakpointLg: false,
|
isBreakpointLg: false,
|
||||||
isDownloading: false,
|
isDownloading: false,
|
||||||
|
isPreviewLoading: false,
|
||||||
isLoadingBlankPage: false,
|
isLoadingBlankPage: false,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
isDetectingPageFields: false,
|
isDetectingPageFields: false,
|
||||||
@@ -1062,8 +1142,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: {
|
||||||
@@ -1184,7 +1266,7 @@ export default {
|
|||||||
|
|
||||||
return formulaFields.reduce((acc, f) => {
|
return formulaFields.reduce((acc, f) => {
|
||||||
if (this.conditionalFieldIndex[f.uuid] !== false) {
|
if (this.conditionalFieldIndex[f.uuid] !== false) {
|
||||||
acc[f.uuid] = this.calculateFormula(f)
|
acc[f.uuid] = f.type === 'text' ? this.evalTextFormula(f) : this.calculateFormula(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc
|
return acc
|
||||||
@@ -1306,6 +1388,10 @@ export default {
|
|||||||
if (document.location.search?.includes('stripe_connect_success')) {
|
if (document.location.search?.includes('stripe_connect_success')) {
|
||||||
document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
|
document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (document.location.search?.includes('paypal_connect_success')) {
|
||||||
|
document.querySelector('a[href^="/auth/paypal_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.template.schema.forEach((item) => {
|
this.template.schema.forEach((item) => {
|
||||||
@@ -1447,6 +1533,25 @@ export default {
|
|||||||
|
|
||||||
return this.math.evaluate(transformedFormula.toLowerCase())
|
return this.math.evaluate(transformedFormula.toLowerCase())
|
||||||
},
|
},
|
||||||
|
evalTextFormula (field, depth = 0) {
|
||||||
|
if (depth > 10) return ''
|
||||||
|
|
||||||
|
return field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||||
|
const formulaField = this.fieldsUuidIndex[uuid]
|
||||||
|
|
||||||
|
if (formulaField?.preferences?.formula) {
|
||||||
|
if (formulaField.type === 'text') {
|
||||||
|
return this.evalTextFormula(formulaField, depth + 1)
|
||||||
|
} else if (this.isMathLoaded) {
|
||||||
|
return this.calculateFormula(formulaField)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = formulaField?.default_value
|
||||||
|
|
||||||
|
return Array.isArray(value) ? value.join(', ') : (value ?? '')
|
||||||
|
})
|
||||||
|
},
|
||||||
hasFormulaDependencyValue (field) {
|
hasFormulaDependencyValue (field) {
|
||||||
const normalized = this.normalizeFormula(field.preferences.formula)
|
const normalized = this.normalizeFormula(field.preferences.formula)
|
||||||
|
|
||||||
@@ -1636,7 +1741,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 +2123,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 +2184,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()
|
||||||
@@ -3073,6 +3206,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)
|
||||||
|
|
||||||
@@ -3105,15 +3254,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 () {
|
||||||
@@ -3129,12 +3269,6 @@ 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 {
|
|
||||||
const submitterWithoutFields =
|
|
||||||
this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid))
|
|
||||||
|
|
||||||
if (submitterWithoutFields) {
|
|
||||||
alert(this.t('please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed').replaceAll('{submitter_name}', submitterWithoutFields.name))
|
|
||||||
} else {
|
} else {
|
||||||
this.isSaving = true
|
this.isSaving = true
|
||||||
|
|
||||||
@@ -3156,7 +3290,6 @@ export default {
|
|||||||
this.isSaving = false
|
this.isSaving = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
scrollToArea (area) {
|
scrollToArea (area) {
|
||||||
const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid)
|
const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid)
|
||||||
|
|||||||
@@ -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
Loaded 100 of 343 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user