Compare commits

..
173 Commits
Author SHA1 Message Date
Alex Turchyn 154d706be1 Merge from docusealco/wip 2025-07-21 13:38:05 +03:00
Alex Turchyn aaae257c75 group initial and signature images in audit logs 2025-07-20 19:56:10 +03:00
Pete Matsyburka ec944a2283 add subfolders 2025-07-20 08:28:19 +03:00
Pete Matsyburka 69d63ecffe with signature id reason 2025-07-18 11:08:22 +03:00
Pete Matsyburka 06a1e2992d fix redirect url 2025-07-17 18:33:24 +03:00
Pete Matsyburka ec9293bac0 with signature id 2025-07-17 10:54:44 +03:00
Pete Matsyburka 8bf727c43c adjust font auto 2025-07-15 09:19:49 +03:00
Pete Matsyburka 708b8afdf5 optimize 2025-07-14 17:13:04 +03:00
Pete Matsyburka 6f7b9b1ebf code highligh 2025-07-14 13:43:26 +03:00
Pete Matsyburka 4f48c4fd9f fix gh font 2025-07-14 13:36:34 +03:00
Pete Matsyburka 9dac90e6cc cleanup 2025-07-14 13:29:39 +03:00
Alex Turchyn 0741a6f485 Merge from docusealco/wip 2025-07-14 13:26:14 +03:00
Pete Matsyburka 1f338dfd2b adjust font size 2025-07-14 12:53:15 +03:00
Pete Matsyburka 0310c26edd fix safari template folder drag&drop 2025-07-12 12:35:19 +03:00
Pete Matsyburka e43570d4c1 adjust nav 2025-07-12 11:51:10 +03:00
Pete Matsyburka d7142c2935 adjust setup nav 2025-07-12 11:16:14 +03:00
Pete Matsyburka fa6dc04ef1 add gh tooltip 2025-07-12 10:57:07 +03:00
Pete Matsyburka c5ec7a4b37 image step validation 2025-07-11 21:52:55 +03:00
Pete Matsyburka bd3d643318 simplify nav 2025-07-11 21:24:00 +03:00
Pete Matsyburka 1ae635963c update eid 2025-07-11 19:54:03 +03:00
Alex Turchyn a61c3e798d shared link 2fa 2025-07-11 09:30:24 +03:00
Pete Matsyburka 931addedbe adjust submitter index 2025-07-10 23:10:40 +03:00
Pete Matsyburka 9aa4a547cf with submitter timezone 2025-07-10 20:56:34 +03:00
Pete Matsyburka 82a7df5e3f add pdf/a-3b 2025-07-10 12:58:19 +03:00
Pete Matsyburka 0ff87383d0 fix selfsign 2025-07-08 16:48:57 +03:00
Pete Matsyburka ea3dec443f refactor 2025-07-07 15:40:23 +03:00
Alex Turchyn d7a895b98b Merge from docusealco/wip 2025-07-07 15:36:18 +03:00
Pete Matsyburka b90b7b7845 add select field font 2025-07-07 10:57:21 +03:00
Pete Matsyburka 3d05d75f2d fix search 2025-07-07 07:36:27 +03:00
Pete Matsyburka e701881377 store error body 2025-07-05 16:36:55 +03:00
Pete Matsyburka 4615e3e48f refresh webhook status 2025-07-05 15:39:42 +03:00
Alex Turchyn 988a5361a6 add webhook events 2025-07-04 22:28:01 +03:00
Pete Matsyburka 1b60b42428 text size 2025-07-04 13:33:52 +03:00
Pete Matsyburka 7b74487214 add length validation 2025-07-04 13:03:22 +03:00
Pete Matsyburka 91d98ed267 rotate pages 2025-07-04 09:47:52 +03:00
Pete Matsyburka 24fbb7de06 require phone 2fa 2025-07-03 10:20:30 +03:00
Pete Matsyburka 91df5b2407 optimize image blank 2025-07-02 17:44:18 +03:00
Pete Matsyburka eef2d9d599 use union all 2025-07-01 09:16:50 +03:00
Pete Matsyburka 8ad70b7e74 adjust folder query 2025-07-01 09:02:40 +03:00
Pete Matsyburka 8b775103c7 fix spec 2025-07-01 08:35:25 +03:00
Pete Matsyburka 8c2475aac1 add gh action timouts 2025-07-01 08:05:42 +03:00
Pete Matsyburka d3236ae4a1 adjust sort order 2025-06-30 23:57:49 +03:00
Pete Matsyburka 8fd9af3a6a fix ci 2025-06-30 13:11:21 +03:00
Alex Turchyn 4f94858901 Merge from docusealco/wip 2025-06-30 12:56:05 +03:00
Pete Matsyburka d0ac4ad222 fix sqlite remove template 2025-06-30 11:44:55 +03:00
Pete Matsyburka 10e4476f4a fix signature prefill 2025-06-30 11:41:07 +03:00
Pete Matsyburka 74411d883a adjust link default parties 2025-06-29 12:31:41 +03:00
Alex Turchyn 18ee2d5316 add shared link form fields preferences 2025-06-28 23:06:25 +03:00
Pete Matsyburka b4f80422eb update doc 2025-06-28 13:30:05 +03:00
Pete Matsyburka 7fe9ab7601 update params 2025-06-28 10:37:10 +03:00
Pete Matsyburka 13ad614e6b fix rubocop 2025-06-28 10:17:50 +03:00
Alex Turchyn 7b1b4bcf2c show shared link enable button on disabled link page 2025-06-28 09:58:22 +03:00
Alex Turchyn a6715d4034 add meta description translations 2025-06-27 23:25:52 +03:00
Pete Matsyburka 254995c6c6 fix missing font variant 2025-06-27 14:45:29 +03:00
Pete Matsyburka 0e33e23d1f fix prefill condition 2025-06-27 12:49:25 +03:00
Pete Matsyburka 57503eefd2 adjust search index 2025-06-27 12:08:57 +03:00
Pete Matsyburka cd477c880c render image as file 2025-06-26 21:50:12 +03:00
Pete Matsyburka bf2f5b24dc update docs 2025-06-26 17:19:53 +03:00
Pete Matsyburka 7eba4865bc fix signature remember 2025-06-26 11:28:38 +03:00
Pete Matsyburka 64cd24a24d validate blank images 2025-06-26 11:04:25 +03:00
Pete Matsyburka de5acd0949 fix one-off 2025-06-26 10:05:16 +03:00
Pete Matsyburka 6ebbc3edac pdfium read text 2025-06-26 09:51:09 +03:00
Pete Matsyburka 2020f6c136 make shared templates searchable 2025-06-26 07:47:29 +03:00
Pete Matsyburka ee4acd5a82 validate signature image 2025-06-25 22:31:19 +03:00
Pete Matsyburka 2e487ac936 fix timezone selector 2025-06-25 21:36:03 +03:00
Pete Matsyburka dee5fc7c07 fix preview 2025-06-25 16:34:25 +03:00
Pete Matsyburka e130584fec fix timezone selector 2025-06-25 09:38:52 +03:00
Pete Matsyburka ade5c4832f fix bulk table mappings 2025-06-24 12:49:52 +03:00
Pete Matsyburka 33b14d5278 fix sqlite query 2025-06-23 20:34:30 +03:00
Pete Matsyburka 48e73c2c81 fix png quality 2025-06-23 14:53:35 +03:00
Pete Matsyburka 894d98e92b update widget version 2025-06-23 13:33:44 +03:00
Alex Turchyn 14c84eff93 Merge from docusealco/wip 2025-06-23 13:19:06 +03:00
Pete Matsyburka 01d982b093 embed preview toggle 2025-06-22 14:54:12 +03:00
Pete Matsyburka 35852ef6ab add onprem search reindex 2025-06-21 23:04:11 +03:00
Pete Matsyburka 47b235dc5f fix search 2025-06-21 22:17:54 +03:00
Pete Matsyburka 0e11d2a581 fix tour link 2025-06-21 17:11:26 +03:00
Pete Matsyburka e57e11f206 optimize query 2025-06-21 14:58:20 +03:00
Alex Turchyn 51f639065c add translations 2025-06-20 21:14:22 +03:00
Pete Matsyburka 6c1d4b466f adjust server selector 2025-06-20 13:41:42 +03:00
Pete Matsyburka b940c597dc fix pdfium error 2025-06-20 02:16:31 +03:00
Pete Matsyburka 1ce67bcb39 adjust folder autocomplete 2025-06-19 22:49:47 +03:00
Pete Matsyburka 5050a67d08 add datenow type 2025-06-19 19:58:22 +03:00
Pete Matsyburka 76321cef3c optimize autocomplete 2025-06-19 16:06:35 +03:00
Pete Matsyburka 8f3a203994 markdown link 2025-06-19 12:45:44 +03:00
Pete Matsyburka ea24562861 fix time format 2025-06-19 12:25:35 +03:00
Pete Matsyburka 4a888d048e fix empty file generate 2025-06-19 12:06:08 +03:00
Alex Turchyn 36331bb993 show found submissions when searching on templates 2025-06-19 12:05:43 +03:00
Pete Matsyburka 6f0e7c0ca8 skip autocomplete 2025-06-18 23:53:36 +03:00
Pete Matsyburka 2491b7b7e3 adjust indexes 2025-06-18 11:56:42 +03:00
Pete Matsyburka 7965c17f46 fix serializer 2025-06-17 18:03:28 +03:00
Pete Matsyburka 5519459519 refactor 2025-06-17 12:42:57 +03:00
Pete Matsyburka d8bf2cc101 add index 2025-06-17 11:03:40 +03:00
Pete Matsyburka 2aece98d67 add pro routes 2025-06-16 22:37:50 +03:00
Pete Matsyburka f602497255 require all submitters 2025-06-16 22:17:14 +03:00
Pete Matsyburka 11db68cdc7 fix merge 2025-06-16 18:55:23 +03:00
Pete Matsyburka bed98f4344 optimize query 2025-06-16 17:02:05 +03:00
Pete Matsyburka 055f7e2bc1 Merge from docusealco/wip 2025-06-15 23:53:35 +03:00
Pete Matsyburka dae3b4b6b5 add completed at index 2025-06-15 12:26:11 +03:00
Pete Matsyburka f2888a08dc fix query 2025-06-14 08:53:45 +03:00
Pete Matsyburka 95627c5963 add audit timezone 2025-06-13 22:37:04 +03:00
Pete Matsyburka 4bb3721db0 add submitter timezone 2025-06-13 19:15:05 +03:00
Pete Matsyburka b12111383b fix update oneoff 2025-06-11 23:39:19 +03:00
Pete Matsyburka 263c0ff939 fix migration 2025-06-11 18:50:01 +03:00
Pete Matsyburka efc8ba6b0a fix merge 2025-06-11 17:55:10 +03:00
Pete Matsyburka 4c530b3f88 fix query 2025-06-11 12:10:24 +03:00
Pete Matsyburka f6358c9d47 fix brakeman 2025-06-11 09:32:43 +03:00
Pete Matsyburka b3baea2f6f optimize query 2025-06-11 09:28:10 +03:00
Pete Matsyburka c0594a8b74 fix brakeman 2025-06-11 09:16:57 +03:00
Pete Matsyburka 1403401b03 permit field required 2025-06-10 11:26:12 +03:00
Pete Matsyburka 700184f776 add ngram 2025-06-10 09:19:13 +03:00
Pete Matsyburka b6a1a3ee4d fix archived 2025-06-09 21:46:26 +03:00
Pete Matsyburka 8cbf5802b5 fix signature upload 2025-06-09 17:06:20 +03:00
Pete Matsyburka ab9706afef fix template fields 2025-06-09 14:20:17 +03:00
Pete Matsyburka f07f9d8cf9 fix oneoff edit 2025-06-09 14:20:17 +03:00
Pete Matsyburka 591caecb92 fix redirect 2025-06-09 14:20:17 +03:00
Pete Matsyburka ef4c2b7213 update gem 2025-06-08 16:54:59 +03:00
Pete Matsyburka 18999255a9 remove constant 2025-06-08 13:34:41 +03:00
Pete Matsyburka 0a2a1de6b9 brakeman ignore 2025-06-07 21:28:01 +03:00
Pete Matsyburka 1b857b6eb3 fix preview 2025-06-07 21:12:37 +03:00
Pete Matsyburka e5e6b11c95 fix image name 2025-06-07 16:20:57 +03:00
Pete Matsyburka 3ec3f58d55 fix mysql migration 2025-06-07 09:55:45 +03:00
Pete Matsyburka 3bfce862ec fix reindex 2025-06-07 09:52:26 +03:00
Pete Matsyburka c01b67f7ec index template 2025-06-06 23:14:17 +03:00
Pete Matsyburka ea0efe9314 fix form cells 2025-06-06 22:10:00 +03:00
Pete Matsyburka 28b583691f partial download toggle 2025-06-06 16:28:53 +03:00
Pete Matsyburka 33811945be fulltext search 2025-06-06 16:03:16 +03:00
Pete Matsyburka 9d1860f038 fix specs 2025-06-06 08:47:56 +03:00
Pete Matsyburka f03166b4ea one-off submission 2025-06-06 08:47:56 +03:00
Pete Matsyburka c59d948d41 add stamp to all pages 2025-06-04 08:44:55 +03:00
Pete Matsyburka 57d63dd2f2 fix empty option condition 2025-06-03 22:29:14 +03:00
Alex Turchyn c88715daec Merge from docusealco/wip 2025-06-03 12:22:34 +03:00
Pete Matsyburka 0375dad7c9 smaller audit initial 2025-06-03 10:13:49 +03:00
Pete Matsyburka de3f83db96 use metadata lang 2025-06-03 09:52:46 +03:00
Pete Matsyburka a7bb96464c completed action shared link 2025-06-01 22:42:44 +03:00
Pete Matsyburka 1d48fe9621 fix spec 2025-06-01 15:51:43 +03:00
Pete Matsyburka 9544bb3d76 Revert "shared link true default"
This reverts commit e41fe5fbca.
2025-06-01 15:23:20 +03:00
Pete Matsyburka 4e94664008 refactor start form 2025-06-01 15:23:19 +03:00
Pete Matsyburka e41fe5fbca shared link true default 2025-06-01 13:21:11 +03:00
Pete Matsyburka 24d713dec6 resubmit shared template 2025-06-01 12:27:36 +03:00
Pete Matsyburka f464e1c46a add log 2025-06-01 12:22:16 +03:00
Pete Matsyburka 85d52d1f28 refactor policy 2025-06-01 11:38:08 +03:00
Alex Turchyn e77b5fa2c9 add optional shared link to templates 2025-06-01 11:07:52 +03:00
Alex Turchyn c91b4a765b add Privacy Policy and eSignature Disclosure links 2025-06-01 09:20:42 +03:00
Pete Matsyburka 2a031c14c8 fix js error 2025-05-30 16:58:20 +03:00
Pete Matsyburka f7dfc0b2ed add email event date index 2025-05-30 11:12:33 +03:00
Pete Matsyburka 3b894f5281 fix 2025-05-30 10:36:08 +03:00
Alex Turchyn 9645332e48 add countless pagination 2025-05-30 09:12:42 +03:00
Pete Matsyburka 5670390b37 fix signature reason 2025-05-29 17:31:09 +03:00
Pete Matsyburka 260bd6d5d5 fix signature field size 2025-05-29 17:24:03 +03:00
Alex Turchyn fa0733f480 improve user email validation 2025-05-29 14:24:17 +03:00
Pete Matsyburka 8b9678a434 fix heif 2025-05-28 23:01:34 +03:00
Pete Matsyburka 43669bf095 do not prefill optional signature 2025-05-28 21:51:17 +03:00
Pete Matsyburka 2ef8df3f0c optimize query 2025-05-28 10:23:36 +03:00
Alex Turchyn 1c9d15aa7e add translations for pagination 2025-05-28 10:06:06 +03:00
Alex Turchyn 9fcd8955d3 add 'field-area-active-label' theme class 2025-05-27 21:10:58 +03:00
Pete Matsyburka 5e44ba49b8 refactor 2025-05-26 22:26:14 +03:00
Pete Matsyburka ac1f90e711 do not email smtp settings 2025-05-26 14:46:33 +03:00
Alex Turchyn c6eae66127 Merge from docusealco/wip 2025-05-26 13:26:19 +03:00
Pete Matsyburka 08aa902e07 set from user id 2025-05-26 11:58:47 +03:00
Pete Matsyburka 1690337699 fix pdf image result 2025-05-26 10:11:01 +03:00
Pete Matsyburka f78cf8cc6e pdfium 2025-05-26 10:01:27 +03:00
Pete Matsyburka 343118d00d fix bmp 2025-05-24 11:54:18 +03:00
Alex Turchyn a3bd37c9a2 Improve error message when does not match in completed form 2025-05-24 11:25:49 +03:00
Alex Turchyn 8bc13a5900 fix german translations 2025-05-23 10:52:47 +03:00
Pete Matsyburka 21138c1d02 remove log 2025-05-22 15:41:19 +03:00
Alex Turchyn 38674a9fc9 fix accidental checkbox addition 2025-05-21 22:18:25 +03:00
Pete Matsyburka a7ed0b86a4 dot rspec 2025-05-21 10:20:43 +03:00
Pete Matsyburka 91a96f06c8 remove account config action 2025-05-20 22:50:43 +03:00
Alex Turchyn 23631b75f2 add template_accesses factory 2025-05-20 19:36:45 +03:00
Pete Matsyburka 0fb9c2bbf6 fix gmail normalize 2025-05-20 19:32:23 +03:00
Pete Matsyburka 14fa8fc8e7 fix expired 2025-05-20 11:18:08 +03:00
Pete Matsyburka 047c192988 fix typo 2025-05-20 09:49:17 +03:00
Pete Matsyburka bfa244c8fa add ico and bmp support 2025-05-20 09:49:16 +03:00
303 changed files with 20757 additions and 1159 deletions

No files matched your search

+9 -1
View File
@@ -6,6 +6,7 @@ jobs:
rubocop: rubocop:
name: Rubocop name: Rubocop
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Ruby - name: Install Ruby
@@ -30,6 +31,7 @@ jobs:
erblint: erblint:
name: Erblint name: Erblint
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Ruby - name: Install Ruby
@@ -54,6 +56,7 @@ jobs:
eslint: eslint:
name: ESLint name: ESLint
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Node.js - name: Install Node.js
@@ -80,6 +83,7 @@ jobs:
brakeman: brakeman:
name: Brakeman name: Brakeman
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Ruby - name: Install Ruby
@@ -107,6 +111,7 @@ jobs:
rspec: rspec:
name: RSpec name: RSpec
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
services: services:
postgres: postgres:
@@ -157,7 +162,10 @@ 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 -y libvips
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
rm -f pdfium-linux.tgz
- name: Run - name: Run
env: env:
RAILS_ENV: test RAILS_ENV: test
+1
View File
@@ -8,6 +8,7 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-24.04-arm
timeout-minutes: 20
steps: steps:
- name: Checkout code - name: Checkout code
+1
View File
@@ -0,0 +1 @@
--require rails_helper
+2 -2
View File
@@ -63,10 +63,10 @@ RSpec/MultipleExpectations:
Max: 25 Max: 25
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 50 Max: 500
RSpec/MultipleMemoizedHelpers: RSpec/MultipleMemoizedHelpers:
Max: 9 Max: 15
Metrics/BlockNesting: Metrics/BlockNesting:
Max: 5 Max: 5
+16 -5
View File
@@ -1,8 +1,17 @@
FROM ruby:3.4.2-alpine AS fonts FROM ruby:3.4.2-alpine AS download
WORKDIR /fonts WORKDIR /fonts
RUN apk --no-cache add fontforge wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt RUN apk --no-cache add fontforge wget && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")' RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
@@ -41,7 +50,7 @@ ENV OPENSSL_CONF=/app/openssl_legacy.cnf
WORKDIR /app WORKDIR /app
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils redis libheif@edge vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev@edge redis libheif@edge vips-heif@edge gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN echo $'.include = /etc/ssl/openssl.cnf\n\ RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\ \n\
@@ -70,8 +79,10 @@ COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./ COPY LICENSE README.md Rakefile config.ru .version ./
COPY .version ./public/version COPY .version ./public/version
COPY --from=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=fonts /fonts/FreeSans.ttf /usr/share/fonts/freefont COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
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=webpack /app/public/packs ./public/packs COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts RUN ln -s /fonts /app/public/fonts
+1
View File
@@ -34,6 +34,7 @@ gem 'rails'
gem 'rails_autolink' gem 'rails_autolink'
gem 'rails-i18n' gem 'rails-i18n'
gem 'rotp' gem 'rotp'
gem 'rouge', require: false
gem 'rqrcode' gem 'rqrcode'
gem 'ruby-vips' gem 'ruby-vips'
gem 'rubyXL' gem 'rubyXL'
+3 -1
View File
@@ -387,7 +387,7 @@ GEM
puma (6.5.0) puma (6.5.0)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.1.14) rack (3.1.16)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (2.0.0) rack-session (2.0.0)
@@ -453,6 +453,7 @@ GEM
retriable (3.1.2) retriable (3.1.2)
rexml (3.4.0) rexml (3.4.0)
rotp (6.3.0) rotp (6.3.0)
rouge (4.5.2)
rqrcode (2.2.0) rqrcode (2.2.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 1.0) rqrcode_core (~> 1.0)
@@ -632,6 +633,7 @@ DEPENDENCIES
rails-i18n rails-i18n
rails_autolink rails_autolink
rotp rotp
rouge
rqrcode rqrcode
rspec-rails rspec-rails
rubocop rubocop
+12 -2
View File
@@ -1,8 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
class AccountConfigsController < ApplicationController class AccountConfigsController < ApplicationController
before_action :load_account_config before_action :load_account_config, only: :create
authorize_resource :account_config authorize_resource :account_config, only: :create
load_and_authorize_resource :account_config, only: :destroy
ALLOWED_KEYS = [ ALLOWED_KEYS = [
AccountConfig::ALLOW_TYPED_SIGNATURE, AccountConfig::ALLOW_TYPED_SIGNATURE,
@@ -30,6 +32,14 @@ class AccountConfigsController < ApplicationController
head :ok head :ok
end end
def destroy
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
@account_config.destroy!
redirect_back(fallback_location: root_path)
end
private private
def load_account_config def load_account_config
@@ -10,6 +10,12 @@ module Api
def create def create
submitter = Submitter.find_by!(slug: params[:submitter_slug]) submitter = Submitter.find_by!(slug: params[:submitter_slug])
if params[:type].in?(%w[initials signature]) && ImageUtils.blank?(Vips::Image.new_from_file(params[:file].path))
Rollbar.error("Empty signature: #{submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_entity
end
attachment = Submitters.create_attachment!(submitter, params) attachment = Submitters.create_attachment!(submitter, params)
if params[:remember_signature] == 'true' && submitter.email.present? if params[:remember_signature] == 'true' && submitter.email.present?
@@ -11,8 +11,9 @@ module Api
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?
submitters = paginate( submitters = paginate(
submitters.preload(template: :folder, submission: [:submitters, { audit_trail_attachment: :blob, submitters.preload(template: { folder: :parent_folder },
combined_document_attachment: :blob }], submission: [:submitters, { audit_trail_attachment: :blob,
combined_document_attachment: :blob }],
documents_attachments: :blob, attachments_attachments: :blob), documents_attachments: :blob, attachments_attachments: :blob),
field: :completed_at field: :completed_at
) )
+13 -15
View File
@@ -10,11 +10,11 @@ module Api
end end
def index def index
submissions = Submissions.search(@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, submissions = paginate(submissions.preload(:created_by_user, :submitters,
template: :folder, template: { folder: :parent_folder },
combined_document_attachment: :blob, combined_document_attachment: :blob,
audit_trail_attachment: :blob)) audit_trail_attachment: :blob))
@@ -63,12 +63,7 @@ module Api
submissions = create_submissions(@template, params) submissions = create_submissions(@template, params)
WebhookUrls.for_account_id(@template.account_id, 'submission.created').each do |webhook_url| WebhookUrls.enqueue_events(submissions, 'submission.created')
submissions.each do |submission|
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submission.id,
'webhook_url_id' => webhook_url.id)
end
end
Submissions.send_signature_requests(submissions) Submissions.send_signature_requests(submissions)
@@ -80,6 +75,8 @@ module Api
end end
end end
SearchEntries.enqueue_reindex(submissions)
render json: build_create_json(submissions) render json: build_create_json(submissions)
rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError, rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError,
DownloadUtils::UnableToDownload => e DownloadUtils::UnableToDownload => e
@@ -94,10 +91,7 @@ module Api
else else
@submission.update!(archived_at: Time.current) @submission.update!(archived_at: Time.current)
WebhookUrls.for_account_id(@submission.account_id, 'submission.archived').each do |webhook_url| WebhookUrls.enqueue_events(@submission, 'submission.archived')
SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id,
'webhook_url_id' => webhook_url.id)
end
end end
render json: @submission.as_json(only: %i[id archived_at]) render json: @submission.as_json(only: %i[id archived_at])
@@ -110,7 +104,10 @@ module Api
submissions = submissions.where(slug: params[:slug]) if params[:slug].present? submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
if params[:template_folder].present? if params[:template_folder].present?
submissions = submissions.joins(template: :folder).where(folder: { name: params[:template_folder] }) folders =
TemplateFolders.filter_by_full_name(TemplateFolder.accessible_by(current_ability), params[:template_folder])
submissions = submissions.joins(:template).where(template: { folder_id: folders.pluck(:id) })
end end
if params.key?(:archived) if params.key?(:archived)
@@ -183,14 +180,15 @@ module Api
def submissions_params def submissions_params
permitted_attrs = [ permitted_attrs = [
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last, :send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last,
:expire_at, :require_phone_2fa, :expire_at, :name,
{ {
message: %i[subject body], message: %i[subject body],
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role, submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :reply_to, :go_to_last, :completed, :phone, :application_key, :external_id, :reply_to, :go_to_last,
:require_phone_2fa,
{ metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body], { metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body],
fields: [:name, :uuid, :default_value, :value, :title, :description, fields: [:name, :uuid, :default_value, :value, :title, :description,
:readonly, :validation_pattern, :invalid_message, :readonly, :required, :validation_pattern, :invalid_message,
{ default_value: [], value: [], preferences: {} }] }]] { default_value: [], value: [], preferences: {} }] }]]
} }
] ]
@@ -13,10 +13,7 @@ module Api
SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request) SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request)
WebhookUrls.for_account_id(@submitter.account_id, 'form.viewed').each do |webhook_url| WebhookUrls.enqueue_events(@submitter, 'form.viewed')
SendFormViewedWebhookRequestJob.perform_async('submitter_id' => @submitter.id,
'webhook_url_id' => webhook_url.id)
end
render json: {} render json: {}
end end
+19 -13
View File
@@ -5,7 +5,7 @@ module Api
load_and_authorize_resource :submitter load_and_authorize_resource :submitter
def index def index
submitters = Submitters.search(@submitters, params[:q]) submitters = Submitters.search(current_user, @submitters, params[:q])
submitters = filter_submitters(submitters, params) submitters = filter_submitters(submitters, params)
@@ -37,14 +37,16 @@ module Api
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_entity return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_entity
end end
role = @submitter.submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name'] submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
normalized_params, new_attachments = normalized_params, new_attachments = Submissions::NormalizeParamUtils.normalize_submitter_params!(
Submissions::NormalizeParamUtils.normalize_submitter_params!(submitter_params.merge(role:), @submitter.template, submitter_params.merge(role:),
for_submitter: @submitter) @submitter.template || Template.new(submitters: submission.template_submitters, account: @submitter.account),
for_submitter: @submitter
)
Submissions::CreateFromSubmitters.maybe_set_template_fields(@submitter.submission, Submissions::CreateFromSubmitters.maybe_set_template_fields(submission, [normalized_params],
[normalized_params],
default_submitter_uuid: @submitter.uuid) default_submitter_uuid: @submitter.uuid)
assign_submitter_attrs(@submitter, normalized_params) assign_submitter_attrs(@submitter, normalized_params)
@@ -65,10 +67,10 @@ module Api
Submitters.send_signature_requests([@submitter]) Submitters.send_signature_requests([@submitter])
end end
render json: Submitters::SerializeForApi.call(@submitter, with_template: false, SearchEntries.enqueue_reindex(@submitter)
with_urls: true,
with_events: false, render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_urls: true,
params:) with_events: false, params:)
rescue Submitters::NormalizeValues::BaseError, DownloadUtils::UnableToDownload => e rescue Submitters::NormalizeValues::BaseError, DownloadUtils::UnableToDownload => e
Rollbar.warning(e) if defined?(Rollbar) Rollbar.warning(e) if defined?(Rollbar)
@@ -80,9 +82,9 @@ module Api
submitter_params.permit( submitter_params.permit(
:send_email, :send_sms, :reply_to, :completed_redirect_url, :uuid, :name, :email, :role, :send_email, :send_sms, :reply_to, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :go_to_last, :completed, :phone, :application_key, :external_id, :go_to_last, :require_phone_2fa,
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body], { metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
fields: [[:name, :uuid, :default_value, :value, fields: [[:name, :uuid, :default_value, :value, :required,
:readonly, :validation_pattern, :invalid_message, :readonly, :validation_pattern, :invalid_message,
{ default_value: [], value: [], preferences: {} }]] } { default_value: [], value: [], preferences: {} }]] }
) )
@@ -191,6 +193,10 @@ module Api
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms') submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to') submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to')
if submitter_preferences.key?('require_phone_2fa')
submitter.preferences['require_phone_2fa'] = submitter_preferences['require_phone_2fa']
end
if submitter_preferences.key?('go_to_last') if submitter_preferences.key?('go_to_last')
submitter.preferences['go_to_last'] = submitter_preferences['go_to_last'] submitter.preferences['go_to_last'] = submitter_preferences['go_to_last']
end end
@@ -21,16 +21,16 @@ module Api
) )
cloned_template.source = :api cloned_template.source = :api
cloned_template.save!
schema_documents = Templates::CloneAttachments.call(template: cloned_template, schema_documents = Templates::CloneAttachments.call(template: cloned_template,
original_template: @template, original_template: @template,
documents: params[:documents]) documents: params[:documents])
WebhookUrls.for_account_id(cloned_template.account_id, 'template.created').each do |webhook_url| cloned_template.save!
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id,
'webhook_url_id' => webhook_url.id) WebhookUrls.enqueue_events(cloned_template, 'template.created')
end
SearchEntries.enqueue_reindex(cloned_template)
render json: Templates::SerializeForApi.call(cloned_template, schema_documents) render json: Templates::SerializeForApi.call(cloned_template, schema_documents)
end end
+12 -7
View File
@@ -7,7 +7,7 @@ module Api
def index def index
templates = filter_templates(@templates, params) templates = filter_templates(@templates, params)
templates = paginate(templates.preload(:author, :folder)) templates = paginate(templates.preload(:author, folder: :parent_folder))
schema_documents = schema_documents =
ActiveStorage::Attachment.where(record_id: templates.map(&:id), ActiveStorage::Attachment.where(record_id: templates.map(&:id),
@@ -65,10 +65,9 @@ module Api
@template.update!(template_params) @template.update!(template_params)
WebhookUrls.for_account_id(@template.account_id, 'template.updated').each do |webhook_url| SearchEntries.enqueue_reindex(@template)
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id,
'webhook_url_id' => webhook_url.id) WebhookUrls.enqueue_events(@template, 'template.updated')
end
render json: @template.as_json(only: %i[id updated_at]) render json: @template.as_json(only: %i[id updated_at])
end end
@@ -86,12 +85,17 @@ module Api
private private
def filter_templates(templates, params) def filter_templates(templates, params)
templates = Templates.search(templates, params[:q]) templates = Templates.search(current_user, templates, params[:q])
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?
templates = templates.where(slug: params[:slug]) if params[:slug].present? templates = templates.where(slug: params[:slug]) if params[:slug].present?
templates = templates.joins(:folder).where(folder: { name: params[:folder] }) if params[:folder].present?
if params[:folder].present?
folders = TemplateFolders.filter_by_full_name(TemplateFolder.accessible_by(current_ability), params[:folder])
templates = templates.where(folder_id: folders.pluck(:id))
end
templates templates
end end
@@ -100,6 +104,7 @@ module Api
permitted_params = [ permitted_params = [
:name, :name,
:external_id, :external_id,
:shared_link,
{ {
submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email]], submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email]],
fields: [[:uuid, :submitter_uuid, :name, :type, fields: [[:uuid, :submitter_uuid, :name, :type,
@@ -55,6 +55,14 @@ class ApplicationController < ActionController::Base
request.session[:impersonated_user_id] = user.uuid request.session[:impersonated_user_id] = user.uuid
end end
def pagy_auto(collection, **keyword_args)
if current_ability.can?(:manage, :countless)
pagy_countless(collection, **keyword_args)
else
pagy(collection, **keyword_args)
end
end
private private
def with_locale(&) def with_locale(&)
@@ -9,7 +9,9 @@ class EmailSmtpSettingsController < ApplicationController
def create def create
if @encrypted_config.update(email_configs) if @encrypted_config.update(email_configs)
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email'] || current_user.email).deliver_now! unless Docuseal.multitenant?
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email'] || current_user.email).deliver_now!
end
redirect_to settings_email_index_path, notice: I18n.t('changes_have_been_saved') redirect_to settings_email_index_path, notice: I18n.t('changes_have_been_saved')
else else
+4
View File
@@ -5,8 +5,12 @@ class ErrorsController < ActionController::Base
'This feature is available in Pro Edition: https://www.docuseal.com/pricing' 'This feature is available in Pro Edition: https://www.docuseal.com/pricing'
ENTERPRISE_PATHS = [ ENTERPRISE_PATHS = [
'/submissions/html',
'/api/submissions/html',
'/templates/html', '/templates/html',
'/api/templates/html', '/api/templates/html',
'/submissions/pdf',
'/api/submissions/pdf',
'/templates/pdf', '/templates/pdf',
'/api/templates/pdf', '/api/templates/pdf',
'/templates/doc', '/templates/doc',
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class SearchEntriesReindexController < ApplicationController
def create
authorize!(:manage, EncryptedConfig)
ReindexAllSearchEntriesJob.perform_async
AccountConfig.find_or_initialize_by(account_id: Account.minimum(:id), key: :fulltext_search)
.update!(value: true)
Docuseal.instance_variable_set(:@fulltext_search, nil)
redirect_back(fallback_location: settings_account_path,
notice: "Started building search index. Visit #{root_url}jobs/busy to check progress.")
end
end
@@ -10,27 +10,37 @@ class SendSubmissionEmailController < ApplicationController
SEND_DURATION = 30.minutes SEND_DURATION = 30.minutes
def create def create
@submitter = if params[:template_slug]
if params[:template_slug] template = Template.find_by!(slug: params[:template_slug])
Submitter.joins(submission: :template).find_by!(email: params[:email].to_s.downcase,
template: { slug: params[:template_slug] }) @submitter =
elsif params[:submission_slug] Submitter.completed.where(submission: template.submissions).find_by!(email: params[:email].to_s.downcase)
Submitter.joins(:submission).find_by!(email: params[:email].to_s.downcase, elsif params[:submission_slug]
submission: { slug: params[:submission_slug] }) submission = Submission.find_by(slug: params[:submission_slug])
else
Submitter.find_by!(slug: params[:submitter_slug]) if submission
@submitter = Submitter.completed.find_by(submission: submission, email: params[:email].to_s.downcase)
end end
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter
else
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
end
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes) RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
unless EmailEvent.exists?(tag: :submitter_documents_copy, email: @submitter.email, emailable: @submitter, SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! unless already_sent?(@submitter)
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later!
end
respond_to do |f| respond_to do |f|
f.html { render :success } f.html { render :success }
f.json { head :ok } f.json { head :ok }
end end
end end
private
def already_sent?(submitter)
EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
end
end end
+1
View File
@@ -34,6 +34,7 @@ class SetupController < ApplicationController
{ key: EncryptedConfig::ESIGN_CERTS_KEY, value: GenerateCertificate.call.transform_values(&:to_pem) } { key: EncryptedConfig::ESIGN_CERTS_KEY, value: GenerateCertificate.call.transform_values(&:to_pem) }
] ]
@account.encrypted_configs.create!(encrypted_configs) @account.encrypted_configs.create!(encrypted_configs)
@account.account_configs.create!(key: :fulltext_search, value: true) if SearchEntry.table_exists?
Docuseal.refresh_default_url_options! Docuseal.refresh_default_url_options!
+146 -45
View File
@@ -6,31 +6,43 @@ class StartFormController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check skip_authorization_check
around_action :with_browser_locale, only: %i[show 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
COOKIES_TTL = 12.hours
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
def show def show
raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa'] == true raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa']
@submitter = @template.submissions.new(account_id: @template.account_id) if @template.shared_link?
.submitters.new(account_id: @template.account_id, @submitter = @template.submissions.new(account_id: @template.account_id)
uuid: (filter_undefined_submitters(@template).first || .submitters.new(account_id: @template.account_id,
@template.submitters.first)['uuid']) uuid: (filter_undefined_submitters(@template).first ||
@template.submitters.first)['uuid'])
render :email_verification if params[:email_verification]
else
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
return render :private if current_user && current_ability.can?(:read, @template)
raise ActionController::RoutingError, I18n.t('not_found')
end
end end
def update def update
return redirect_to start_form_path(@template.slug) if @template.archived_at?
@submitter = find_or_initialize_submitter(@template, submitter_params) @submitter = find_or_initialize_submitter(@template, submitter_params)
if @submitter.completed_at? if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email]) 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 filter_undefined_submitters(@template).size > 1 && @submitter.new_record?
@error_message = multiple_submitters_error_message @error_message = multiple_submitters_error_message
return render :show return render :show, status: :unprocessable_entity
end end
if (is_new_record = @submitter.new_record?) if (is_new_record = @submitter.new_record?)
@@ -41,64 +53,112 @@ class StartFormController < ApplicationController
@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 @submitter.save if @template.preferences['shared_link_2fa'] == true
if is_new_record handle_require_2fa(@submitter, is_new_record:)
enqueue_submission_create_webhooks(@submitter) elsif @submitter.errors.blank? && @submitter.save
enqueue_new_submitter_jobs(@submitter) if is_new_record
if @submitter.submission.expire_at?
ProcessSubmissionExpiredJob.perform_at(@submitter.submission.expire_at,
'submission_id' => @submitter.submission_id)
end
end
redirect_to submit_form_path(@submitter.slug) redirect_to submit_form_path(@submitter.slug)
else else
render :show render :show, status: :unprocessable_entity
end end
end end
end end
def completed def completed
return redirect_to start_form_path(@template.slug) if !@template.shared_link? || @template.archived_at?
submitter_params = params.permit(:name, :email, :phone).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
end
required_fields = @template.preferences.fetch('link_form_fields', ['email'])
required_params = required_fields.index_with { |key| submitter_params[key] }
raise ActionController::RoutingError, I18n.t('not_found') if required_params.any? { |_, v| v.blank? } ||
required_params.except('name').compact_blank.blank?
@submitter = Submitter.where(submission: @template.submissions) @submitter = Submitter.where(submission: @template.submissions)
.where.not(completed_at: nil) .where.not(completed_at: nil)
.find_by!(email: params[:email]) .find_by!(required_params)
end end
private private
def enqueue_submission_create_webhooks(submitter) def enqueue_new_submitter_jobs(submitter)
WebhookUrls.for_account_id(submitter.account_id, 'submission.created').each do |webhook_url| WebhookUrls.enqueue_events(submitter.submission, 'submission.created')
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
'webhook_url_id' => webhook_url.id) SearchEntries.enqueue_reindex(submitter)
end
return unless submitter.submission.expire_at?
ProcessSubmissionExpiredJob.perform_at(submitter.submission.expire_at, 'submission_id' => submitter.submission_id)
end
def load_resubmit_submitter
@resubmit_submitter =
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
Submitter.find_by(slug: params[:resubmit])
end
end
def authorize_start!
return redirect_to start_form_path(@template.slug) if @template.archived_at?
return if @resubmit_submitter
return if @template.shared_link? || (current_user && current_ability.can?(:read, @template))
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
redirect_to start_form_path(@template.slug)
end end
def find_or_initialize_submitter(template, submitter_params) def find_or_initialize_submitter(template, submitter_params)
Submitter.where(submission: template.submissions.where(expire_at: Time.current..) required_fields = template.preferences.fetch('link_form_fields', ['email'])
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
.order(id: :desc) required_params = required_fields.index_with { |key| submitter_params[key] }
.where(declined_at: nil)
.where(external_id: nil) find_params = required_params.except('name')
.where(ip: [nil, request.remote_ip])
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel } submitter = Submitter.new if find_params.compact_blank.blank?
.find_or_initialize_by(email: submitter_params[:email], **submitter_params.compact_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 end
def assign_submission_attributes(submitter, template) def assign_submission_attributes(submitter, template)
resubmit_submitter =
(Submitter.where(submission: template.submissions).find_by(slug: params[:resubmit]) if params[:resubmit].present?)
submitter.assign_attributes( submitter.assign_attributes(
uuid: (filter_undefined_submitters(template).first || @template.submitters.first)['uuid'], uuid: (filter_undefined_submitters(template).first || @template.submitters.first)['uuid'],
ip: request.remote_ip, ip: request.remote_ip,
ua: request.user_agent, ua: request.user_agent,
values: resubmit_submitter&.preferences&.fetch('default_values', nil) || {}, values: @resubmit_submitter&.preferences&.fetch('default_values', nil) || {},
preferences: resubmit_submitter&.preferences.presence || { 'send_email' => true }, preferences: @resubmit_submitter&.preferences.presence || { 'send_email' => true },
metadata: resubmit_submitter&.metadata.presence || {} metadata: @resubmit_submitter&.metadata.presence || {}
) )
submitter.assign_attributes(@resubmit_submitter.slice(:name, :email, :phone)) if @resubmit_submitter
if submitter.values.present? if submitter.values.present?
resubmit_submitter.attachments.each do |attachment| @resubmit_submitter.attachments.each do |attachment|
submitter.attachments << attachment.dup if submitter.values.value?(attachment.uuid) submitter.attachments << attachment.dup if submitter.values.value?(attachment.uuid)
end end
end end
@@ -116,19 +176,25 @@ class StartFormController < ApplicationController
end end
def filter_undefined_submitters(template) def filter_undefined_submitters(template)
Templates.filter_undefined_submitters(template) Templates.filter_undefined_submitters(template.submitters)
end 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
slug = params[:slug] || params[:start_form_slug] @template =
if @resubmit_submitter
@template = Template.find_by!(slug:) @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
@@ -138,4 +204,39 @@ class StartFormController < ApplicationController
I18n.t('not_found') I18n.t('not_found')
end end
end end
def handle_require_2fa(submitter, is_new_record:)
return render :show, status: :unprocessable_entity if submitter.errors.present?
is_otp_verified = Submitters.verify_link_otp!(params[:one_time_code], submitter)
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)
else
render :show, status: :unprocessable_entity
end
else
Submitters.send_shared_link_email_verification_code(submitter, request:)
render :email_verification
end
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
redirect_to start_form_path(submitter.submission.template.slug,
params: submitter_params.merge(email_verification: true)),
alert: e.message
rescue RateLimit::LimitApproached
redirect_to start_form_path(submitter.submission.template.slug,
params: submitter_params.merge(email_verification: true)),
alert: I18n.t(:too_many_attempts)
end
end end
@@ -0,0 +1,31 @@
# frozen_string_literal: true
class StartFormEmail2faSendController < ApplicationController
around_action :with_browser_locale
skip_before_action :authenticate_user!
skip_authorization_check
def create
@template = Template.find_by!(slug: params[:slug])
@submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(**submitter_params, account_id: @template.account_id)
Submitters.send_shared_link_email_verification_code(@submitter, request:)
redir_params = { notice: I18n.t(:code_has_been_resent) } if params[:resend]
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)),
**redir_params
rescue Submitters::UnableToSendCode => e
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)),
alert: e.message
end
private
def submitter_params
params.require(:submitter).permit(:name, :email, :phone)
end
end
@@ -12,6 +12,7 @@ class SubmissionEventsController < ApplicationController
'send_2fa_sms' => '2fa', 'send_2fa_sms' => '2fa',
'send_sms' => 'send', 'send_sms' => 'send',
'phone_verified' => 'phone_check', 'phone_verified' => 'phone_check',
'email_verified' => 'email_check',
'click_sms' => 'hand_click', 'click_sms' => 'hand_click',
'decline_form' => 'x', 'decline_form' => 'x',
'start_verification' => 'player_play', 'start_verification' => 'player_play',
@@ -4,12 +4,12 @@ class SubmissionsArchivedController < ApplicationController
load_and_authorize_resource :submission, parent: false load_and_authorize_resource :submission, parent: false
def index def index
@submissions = @submissions.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, template: :author)
@submissions = Submissions.search(@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 = if params[:completed_at_from].present? || params[:completed_at_to].present?
@@ -18,6 +18,6 @@ class SubmissionsArchivedController < ApplicationController
@submissions.order(id: :desc) @submissions.order(id: :desc)
end end
@pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events)) @pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
end end
end end
+9 -17
View File
@@ -8,6 +8,10 @@ class SubmissionsController < ApplicationController
prepend_before_action :maybe_redirect_com, only: %i[show] prepend_before_action :maybe_redirect_com, only: %i[show]
before_action only: :create do
authorize!(:create, Submission)
end
def show def show
@submission = Submissions.preload_with_pages(@submission) @submission = Submissions.preload_with_pages(@submission)
@@ -26,8 +30,6 @@ class SubmissionsController < ApplicationController
end end
def create def create
authorize!(:create, Submission)
save_template_message(@template, params) if params[:save_message] == '1' save_template_message(@template, params) if params[:save_message] == '1'
if params[:is_custom_message] != '1' if params[:is_custom_message] != '1'
@@ -52,10 +54,12 @@ class SubmissionsController < ApplicationController
params: params.merge('send_completed_email' => true)) params: params.merge('send_completed_email' => true))
end end
enqueue_submission_created_webhooks(@template, submissions) WebhookUrls.enqueue_events(submissions, 'submission.created')
Submissions.send_signature_requests(submissions) Submissions.send_signature_requests(submissions)
SearchEntries.enqueue_reindex(submissions)
redirect_to template_path(@template), notice: I18n.t('new_recipients_have_been_added') redirect_to template_path(@template), notice: I18n.t('new_recipients_have_been_added')
rescue Submissions::CreateFromSubmitters::BaseError => e rescue Submissions::CreateFromSubmitters::BaseError => e
render turbo_stream: turbo_stream.replace(:submitters_error, render turbo_stream: turbo_stream.replace(:submitters_error,
@@ -73,15 +77,12 @@ class SubmissionsController < ApplicationController
else else
@submission.update!(archived_at: Time.current) @submission.update!(archived_at: Time.current)
WebhookUrls.for_account_id(@submission.account_id, 'submission.archived').each do |webhook_url| WebhookUrls.enqueue_events(@submission, 'submission.archived')
SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id,
'webhook_url_id' => webhook_url.id)
end
I18n.t('submission_has_been_archived') I18n.t('submission_has_been_archived')
end end
redirect_back(fallback_location: template_path(@submission.template), notice:) redirect_back(fallback_location: @submission.template_id ? template_path(@submission.template) : root_path, notice:)
end end
private private
@@ -93,15 +94,6 @@ class SubmissionsController < ApplicationController
template.save! template.save!
end end
def enqueue_submission_created_webhooks(template, submissions)
WebhookUrls.for_account_id(template.account_id, 'submission.created').each do |webhook_url|
submissions.each do |submission|
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submission.id,
'webhook_url_id' => webhook_url.id)
end
end
end
def submissions_params def submissions_params
params.permit(submission: { submitters: [%i[uuid email phone name]] }) params.permit(submission: { submitters: [%i[uuid email phone name]] })
end end
@@ -4,13 +4,13 @@ class SubmissionsDashboardController < ApplicationController
load_and_authorize_resource :submission, parent: false load_and_authorize_resource :submission, parent: false
def index def index
@submissions = @submissions.joins(:template) @submissions = @submissions.left_joins(:template)
@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, template: :author)
@submissions = Submissions.search(@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 = if params[:completed_at_from].present? || params[:completed_at_to].present?
@@ -19,6 +19,6 @@ class SubmissionsDashboardController < ApplicationController
@submissions.order(id: :desc) @submissions.order(id: :desc)
end end
@pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events)) @pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
end end
end end
@@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class SubmissionsPreviewController < ApplicationController class SubmissionsPreviewController < ApplicationController
around_action :with_browser_locale
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check skip_authorization_check
@@ -27,7 +28,7 @@ class SubmissionsPreviewController < ApplicationController
raise ActionController::RoutingError, I18n.t('not_found') raise ActionController::RoutingError, I18n.t('not_found')
end end
if !submission_valid_ttl?(@submission) && !signature_valid if use_signature?(@submission) && !signature_valid
Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar) Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar)
return redirect_to submissions_preview_completed_path(@submission.slug) return redirect_to submissions_preview_completed_path(@submission.slug)
@@ -47,9 +48,15 @@ class SubmissionsPreviewController < ApplicationController
private private
def submission_valid_ttl?(submission) def use_signature?(submission)
return true if current_user && current_user.account.submissions.exists?(id: submission.id) return false if current_user && can?(:read, submission)
return true if submission.submitters.any? { |e| e.preferences['require_phone_2fa'] }
return true if submission.template&.preferences&.dig('require_phone_2fa')
!submission_valid_ttl?(submission)
end
def submission_valid_ttl?(submission)
last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at) last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at)
last_submitter && last_submitter.completed_at > TTL.ago last_submitter && last_submitter.completed_at > TTL.ago
+23 -8
View File
@@ -8,6 +8,8 @@ class SubmitFormController < ApplicationController
skip_authorization_check skip_authorization_check
before_action :load_submitter, only: %i[show update completed] before_action :load_submitter, only: %i[show update completed]
before_action :maybe_render_locked_page, only: :show
before_action :maybe_require_link_2fa, only: %i[show update]
CONFIG_KEYS = [].freeze CONFIG_KEYS = [].freeze
@@ -15,19 +17,14 @@ class SubmitFormController < ApplicationController
submission = @submitter.submission submission = @submitter.submission
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 render :archived if submission.template.archived_at? ||
submission.archived_at? ||
@submitter.account.archived_at?
return render :expired if submission.expired?
return render :declined if @submitter.declined_at?
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS) @form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
return render :awaiting if (@form_configs[:enforce_signing_order] || return render :awaiting if (@form_configs[:enforce_signing_order] ||
submission.template.preferences['submitters_order'] == 'preserved') && submission.template&.preferences&.dig('submitters_order') == 'preserved') &&
!Submitters.current_submitter_order?(@submitter) !Submitters.current_submitter_order?(@submitter)
Submitters.preload_with_pages(@submitter) Submissions.preload_with_pages(submission)
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user) Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
@@ -54,7 +51,7 @@ class SubmitFormController < ApplicationController
return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_entity return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_entity
end end
if @submitter.template.archived_at? || @submitter.submission.archived_at? if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at?
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_entity return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_entity
end end
@@ -84,6 +81,24 @@ class SubmitFormController < ApplicationController
private private
def maybe_require_link_2fa
return if @submitter.submission.source != 'link'
return unless @submitter.submission.template&.preferences&.dig('shared_link_2fa') == true
return if cookies.encrypted[:email_2fa_slug] == @submitter.slug
return if @submitter.email == current_user&.email && current_user&.account_id == @submitter.account_id
redirect_to start_form_path(@submitter.submission.template.slug)
end
def maybe_render_locked_page
return render :archived if @submitter.submission.template&.archived_at? ||
@submitter.submission.archived_at? ||
@submitter.account.archived_at?
return render :expired if @submitter.submission.expired?
render :declined if @submitter.declined_at?
end
def load_submitter def load_submitter
@submitter = Submitter.find_by!(slug: params[:slug] || params[:submit_form_slug]) @submitter = Submitter.find_by!(slug: params[:slug] || params[:submit_form_slug])
end end
@@ -11,7 +11,7 @@ class SubmitFormDeclineController < ApplicationController
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?
ApplicationRecord.transaction do ApplicationRecord.transaction do
submitter.update!(declined_at: Time.current) submitter.update!(declined_at: Time.current)
@@ -25,10 +25,7 @@ class SubmitFormDeclineController < ApplicationController
SubmitterMailer.declined_email(submitter, user).deliver_later! SubmitterMailer.declined_email(submitter, user).deliver_later!
end end
WebhookUrls.for_account_id(submitter.account_id, 'form.declined').each do |webhook_url| WebhookUrls.enqueue_events(submitter, 'form.declined')
SendFormDeclinedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
'webhook_url_id' => webhook_url.id)
end
redirect_to submit_form_path(submitter.slug) redirect_to submit_form_path(submitter.slug)
end end
@@ -14,7 +14,10 @@ class SubmitFormDownloadController < ApplicationController
return head :unprocessable_entity if @submitter.declined_at? || return head :unprocessable_entity if @submitter.declined_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? ||
AccountConfig.exists?(account_id: @submitter.account_id,
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
value: false)
last_completed_submitter = @submitter.submission.submitters last_completed_submitter = @submitter.submission.submitters
.where.not(id: @submitter.id) .where.not(id: @submitter.id)
@@ -25,7 +28,7 @@ class SubmitFormDownloadController < ApplicationController
if last_completed_submitter if last_completed_submitter
Submitters.select_attachments_for_download(last_completed_submitter) Submitters.select_attachments_for_download(last_completed_submitter)
else else
@submitter.submission.template.schema_documents.preload(:blob) @submitter.submission.schema_documents.preload(:blob)
end end
urls = attachments.map do |attachment| urls = attachments.map do |attachment|
@@ -12,7 +12,7 @@ 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?
if @submitter.submission.template.archived_at? || @submitter.submission.archived_at? if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at?
return redirect_to submit_form_path(@submitter.slug) return redirect_to submit_form_path(@submitter.slug)
end end
@@ -45,7 +45,7 @@ class SubmitFormInviteController < ApplicationController
!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?
end end
def filter_invite_submitters(submitter, key = 'invite_by_uuid') def filter_invite_submitters(submitter, key = 'invite_by_uuid')
@@ -8,7 +8,7 @@ class SubmitFormValuesController < ApplicationController
submitter = Submitter.find_by!(slug: params[:submit_form_slug]) submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return render json: {} if submitter.completed_at? || submitter.declined_at? return render json: {} if submitter.completed_at? || submitter.declined_at?
return render json: {} if submitter.submission.template.archived_at? || return render json: {} if submitter.submission.template&.archived_at? ||
submitter.submission.archived_at? || submitter.submission.archived_at? ||
submitter.submission.expired? submitter.submission.expired?
@@ -22,13 +22,17 @@ class SubmittersAutocompleteController < ApplicationController
def search_submitters(submitters) def search_submitters(submitters)
if SELECT_COLUMNS.include?(params[:field]) if SELECT_COLUMNS.include?(params[:field])
column = Submitter.arel_table[params[:field].to_sym] if Docuseal.fulltext_search?
Submitters.fulltext_search_field(current_user, submitters, params[:q], params[:field])
else
column = Submitter.arel_table[params[:field].to_sym]
term = "#{params[:q].downcase}%" term = "#{params[:q].downcase}%"
submitters.where(column.matches(term)) submitters.where(column.matches(term))
end
else else
Submitters.search(submitters, params[:q]) Submitters.search(current_user, submitters, params[:q])
end end
end end
end end
+2
View File
@@ -38,6 +38,8 @@ class SubmittersController < ApplicationController
if @submitter.save if @submitter.save
maybe_resend_email_sms(@submitter, params) maybe_resend_email_sms(@submitter, params)
SearchEntries.enqueue_reindex(@submitter)
redirect_back fallback_location: submission_path(submission), notice: I18n.t('changes_have_been_saved') redirect_back fallback_location: submission_path(submission), notice: I18n.t('changes_have_been_saved')
else else
redirect_back fallback_location: submission_path(submission), alert: I18n.t('unable_to_save') redirect_back fallback_location: submission_path(submission), alert: I18n.t('unable_to_save')
@@ -6,13 +6,15 @@ 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
submission = @submitter.template.submissions.new(created_by_user: current_user, submission = @submitter.account.submissions.new(created_by_user: current_user,
submitters_order: :preserved, submitters_order: :preserved,
**@submitter.submission.slice(:template_fields, **@submitter.submission.slice(:template_fields,
:account_id, :account_id,
:template_schema, :name,
:template_submitters, :template_id,
:preferences)) :template_schema,
:template_submitters,
:preferences))
@submitter.submission.submitters.each do |submitter| @submitter.submission.submitters.each do |submitter|
new_submitter = submission.submitters.new(submitter.slice(:uuid, :email, :phone, :name, new_submitter = submission.submitters.new(submitter.slice(:uuid, :email, :phone, :name,
@@ -27,6 +29,10 @@ class SubmittersResubmitController < ApplicationController
submission.save! submission.save!
@submitter.submission.documents_attachments.each do |attachment|
submission.documents_attachments.create!(uuid: attachment.uuid, blob_id: attachment.blob_id)
end
redirect_to submit_form_path(slug: @new_submitter.slug) redirect_to submit_form_path(slug: @new_submitter.slug)
end end
@@ -3,12 +3,31 @@
class TemplateFoldersAutocompleteController < ApplicationController class TemplateFoldersAutocompleteController < ApplicationController
load_and_authorize_resource :template_folder, parent: false load_and_authorize_resource :template_folder, parent: false
LIMIT = 100 LIMIT = 30
def index def index
template_folders = @template_folders.joins(:templates).where(templates: { archived_at: nil }).distinct parent_name, name =
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT) if params[:parent_name].present?
[params[:parent_name], params[:q]]
else
params[:q].to_s.split(' /', 2).map(&:squish)
end
render json: template_folders.as_json(only: %i[name archived_at]) if name
parent_folder = @template_folders.find_by(name: parent_name, parent_folder_id: nil)
else
name = parent_name
end
template_folders = TemplateFolders.filter_active_folders(@template_folders.where(parent_folder:),
Template.accessible_by(current_ability))
name = name.to_s.downcase
template_folders = TemplateFolders.search(template_folders, name).order(id: :desc).limit(LIMIT)
render json: template_folders.preload(:parent_folder)
.sort_by { |e| e.name.downcase.index(name) || Float::MAX }
.as_json(only: %i[name archived_at], methods: %i[full_name])
end end
end end
+61 -6
View File
@@ -3,13 +3,41 @@
class TemplateFoldersController < ApplicationController class TemplateFoldersController < ApplicationController
load_and_authorize_resource :template_folder load_and_authorize_resource :template_folder
def show helper_method :selected_order
@templates = @template_folder.templates.active.accessible_by(current_ability)
.preload(:author, :template_accesses)
@templates = Templates.search(@templates, params[:q])
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
@pagy, @templates = pagy(@templates, limit: 12) TEMPLATES_PER_PAGE = 12
FOLDERS_PER_PAGE = 18
def show
@templates = Template.active.accessible_by(current_ability)
.where(folder: [@template_folder, *(params[:q].present? ? @template_folder.subfolders : [])])
.preload(:author, :template_accesses)
@template_folders =
@template_folder.subfolders.where(id: Template.accessible_by(current_ability).active.select(:folder_id))
@template_folders = TemplateFolders.search(@template_folders, params[:q])
@template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
if @templates.exists?
@templates = Templates.search(current_user, @templates, params[:q])
@templates = Templates::Order.call(@templates, current_user, selected_order)
limit =
if @template_folders.size < 4
TEMPLATES_PER_PAGE
else
(@template_folders.size < 7 ? 9 : 6)
end
@pagy, @templates = pagy_auto(@templates, limit:)
load_related_submissions if params[:q].present? && @templates.blank?
else
@pagy, @template_folders = pagy(@template_folders, limit: FOLDERS_PER_PAGE)
@templates = @templates.none
end
end end
def edit; end def edit; end
@@ -25,7 +53,34 @@ class TemplateFoldersController < ApplicationController
private private
def selected_order
@selected_order ||=
if cookies.permanent[:dashboard_templates_order].blank? ||
(cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
'created_at'
else
cookies.permanent[:dashboard_templates_order]
end
end
def template_folder_params def template_folder_params
params.require(:template_folder).permit(:name) params.require(:template_folder).permit(:name)
end end
def load_related_submissions
@related_submissions =
Submission.accessible_by(current_ability)
.where(archived_at: nil)
.where(template_id: current_account.templates.active
.where(folder: [@template_folder, *@template_folder.subfolders])
.select(:id))
.preload(:template_accesses, :created_by_user,
template: :author,
submitters: :start_form_submission_events)
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q])
.order(id: :desc)
@related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5)
end
end end
@@ -4,9 +4,27 @@ class TemplatesArchivedController < ApplicationController
load_and_authorize_resource :template, parent: false load_and_authorize_resource :template, parent: false
def index def index
@templates = @templates.where.not(archived_at: nil).preload(:author, :folder, :template_accesses).order(id: :desc) @templates = @templates.where.not(archived_at: nil)
@templates = Templates.search(@templates, params[:q]) .preload(:author, :template_accesses, folder: :parent_folder)
.order(id: :desc)
@pagy, @templates = pagy(@templates, limit: 12) @templates = Templates.search(current_user, @templates, params[:q])
@pagy, @templates = pagy_auto(@templates, limit: 12)
return unless params[:q].present? && @templates.blank?
@related_submissions =
Submission.accessible_by(current_ability)
.joins(:template)
.where.not(templates: { archived_at: nil })
.preload(:template_accesses, :created_by_user,
template: :author,
submitters: :start_form_submission_events)
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q])
.order(id: :desc)
@related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5)
end end
end end
@@ -6,7 +6,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController
def index def index
@submissions = @submissions.where.not(archived_at: nil) @submissions = @submissions.where.not(archived_at: nil)
@submissions = Submissions.search(@submissions, params[:q], search_values: true) @submissions = Submissions.search(current_user, @submissions, params[:q], search_values: 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 = if params[:completed_at_from].present? || params[:completed_at_to].present?
@@ -15,7 +15,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController
@submissions.order(id: :desc) @submissions.order(id: :desc)
end end
@pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events)) @pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
redirect_to root_path redirect_to root_path
end end
@@ -22,6 +22,8 @@ class TemplatesCloneAndReplaceController < ApplicationController
Templates::CloneAttachments.call(template: cloned_template, original_template: @template, Templates::CloneAttachments.call(template: cloned_template, original_template: @template,
excluded_attachment_uuids: documents.map(&:uuid)) excluded_attachment_uuids: documents.map(&:uuid))
SearchEntries.enqueue_reindex(cloned_template)
respond_to do |f| respond_to do |f|
f.html { redirect_to edit_template_path(cloned_template) } f.html { redirect_to edit_template_path(cloned_template) }
f.json { render json: { id: cloned_template.id } } f.json { render json: { id: cloned_template.id } }
+13 -19
View File
@@ -8,7 +8,7 @@ class TemplatesController < ApplicationController
def show def show
submissions = @template.submissions.accessible_by(current_ability) submissions = @template.submissions.accessible_by(current_ability)
submissions = submissions.active if @template.archived_at.blank? submissions = submissions.active if @template.archived_at.blank?
submissions = Submissions.search(submissions, params[:q], search_values: true) submissions = Submissions.search(current_user, submissions, params[:q], search_values: true)
submissions = Submissions::Filter.call(submissions, current_user, params.except(:status)) submissions = Submissions::Filter.call(submissions, current_user, params.except(:status))
@base_submissions = submissions @base_submissions = submissions
@@ -21,7 +21,7 @@ class TemplatesController < ApplicationController
submissions.order(id: :desc) submissions.order(id: :desc)
end end
@pagy, @submissions = pagy(submissions.preload(:template_accesses, submitters: :start_form_submission_events)) @pagy, @submissions = pagy_auto(submissions.preload(:template_accesses, submitters: :start_form_submission_events))
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
redirect_to root_path redirect_to root_path
end end
@@ -72,7 +72,9 @@ class TemplatesController < ApplicationController
if @template.save if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
enqueue_template_created_webhooks(@template) SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
maybe_redirect_to_template(@template) maybe_redirect_to_template(@template)
else else
@@ -81,9 +83,15 @@ class TemplatesController < ApplicationController
end end
def update def update
@template.update!(template_params) @template.assign_attributes(template_params)
enqueue_template_updated_webhooks(@template) is_name_changed = @template.name_changed?
@template.save!
SearchEntries.enqueue_reindex(@template) if is_name_changed
WebhookUrls.enqueue_events(@template, 'template.updated')
head :ok head :ok
end end
@@ -134,20 +142,6 @@ class TemplatesController < ApplicationController
end end
end end
def enqueue_template_created_webhooks(template)
WebhookUrls.for_account_id(template.account_id, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id,
'webhook_url_id' => webhook_url.id)
end
end
def enqueue_template_updated_webhooks(template)
WebhookUrls.for_account_id(template.account_id, 'template.updated').each do |webhook_url|
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => template.id,
'webhook_url_id' => webhook_url.id)
end
end
def load_base_template def load_base_template
return if params[:base_template_id].blank? return if params[:base_template_id].blank?
@@ -8,16 +8,18 @@ class TemplatesDashboardController < ApplicationController
TEMPLATES_PER_PAGE = 12 TEMPLATES_PER_PAGE = 12
FOLDERS_PER_PAGE = 18 FOLDERS_PER_PAGE = 18
helper_method :selected_order
def index def index
@template_folders = @template_folders.where(id: @templates.active.select(:folder_id)) @template_folders =
TemplateFolders.filter_active_folders(@template_folders.where(parent_folder_id: nil), @templates)
@template_folders = TemplateFolders.search(@template_folders, params[:q]) @template_folders = TemplateFolders.search(@template_folders, params[:q])
@template_folders = sort_template_folders(@template_folders, current_user, @template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
cookies.permanent[:dashboard_templates_order])
@pagy, @template_folders = pagy( @pagy, @template_folders = pagy(
@template_folders, @template_folders,
items: FOLDERS_PER_PAGE, limit: FOLDERS_PER_PAGE,
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1 page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
) )
@@ -25,8 +27,8 @@ class TemplatesDashboardController < ApplicationController
@templates = @templates.none @templates = @templates.none
else else
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME } @template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
@templates = filter_templates(@templates) @templates = filter_templates(@templates).preload(:author, :template_accesses)
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order]) @templates = Templates::Order.call(@templates, current_user, selected_order)
limit = limit =
if @template_folders.size < 4 if @template_folders.size < 4
@@ -35,14 +37,16 @@ class TemplatesDashboardController < ApplicationController
(@template_folders.size < 7 ? 9 : 6) (@template_folders.size < 7 ? 9 : 6)
end end
@pagy, @templates = pagy(@templates, limit:) @pagy, @templates = pagy_auto(@templates, limit:)
load_related_submissions if params[:q].present? && @templates.blank?
end end
end end
private private
def filter_templates(templates) def filter_templates(templates)
rel = templates.active.preload(:author, :template_accesses) rel = templates.active
if params[:q].blank? if params[:q].blank?
if Docuseal.multitenant? ? current_account.testing? : current_account.linked_account_account if Docuseal.multitenant? ? current_account.testing? : current_account.linked_account_account
@@ -51,46 +55,42 @@ class TemplatesDashboardController < ApplicationController
shared_template_ids = TemplateSharing.where(account_id: shared_account_ids).select(:template_id) shared_template_ids = TemplateSharing.where(account_id: shared_account_ids).select(:template_id)
rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids)) 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) rel = rel.where(folder_id: current_account.default_template_folder.id)
end end
end end
Templates.search(rel, params[:q]) Templates.search(current_user, rel, params[:q])
end end
def sort_template_folders(template_folders, current_user, order) def selected_order
case order @selected_order ||=
when 'used_at' if cookies.permanent[:dashboard_templates_order].blank? ||
subquery = (cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
Template.left_joins(:submissions) 'created_at'
.group(:folder_id) else
.where(account_id: current_user.account_id) cookies.permanent[:dashboard_templates_order]
.select( end
:folder_id, end
Template.arel_table[:updated_at].maximum.as('updated_at_max'),
Submission.arel_table[:created_at].maximum.as('submission_created_at_max')
)
template_folders = template_folders.joins( def load_related_submissions
Template.arel_table @related_submissions = Submission.accessible_by(current_ability)
.join(subquery.arel.as('templates'), Arel::Nodes::OuterJoin) .left_joins(:template)
.on(TemplateFolder.arel_table[:id].eq(Template.arel_table[:folder_id])) .where(archived_at: nil)
.join_sources .where(templates: { archived_at: nil })
) .preload(:template_accesses, :created_by_user,
template: :author,
submitters: :start_form_submission_events)
template_folders.order( @related_submissions = Submissions.search(current_user, @related_submissions, params[:q])
Arel::Nodes::Case.new .order(id: :desc)
.when(Template.arel_table[:submission_created_at_max].gt(Template.arel_table[:updated_at_max]))
.then(Template.arel_table[:submission_created_at_max]) @related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5)
.else(Template.arel_table[:updated_at_max])
.desc
)
when 'name'
template_folders.order(name: :asc)
else
template_folders.order(id: :desc)
end
end end
end end
@@ -6,7 +6,9 @@ class TemplatesFoldersController < ApplicationController
def edit; end def edit; end
def update def update
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:name]) name = [params[:parent_name], params[:name]].compact_blank.join(' / ')
@template.folder = TemplateFolders.find_or_create_by_name(current_user, name)
if @template.save if @template.save
redirect_back(fallback_location: template_path(@template), notice: I18n.t('document_template_has_been_moved')) redirect_back(fallback_location: template_path(@template), notice: I18n.t('document_template_has_been_moved'))
@@ -13,7 +13,7 @@ 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']) }
Submitters.preload_with_pages(@submitter) 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)
@@ -25,13 +25,13 @@ class TemplatesPreferencesController < ApplicationController
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
submitters_order require_phone_2fa require_all_submitters submitters_order require_phone_2fa
default_expire_at_duration default_expire_at_duration shared_link_2fa
default_expire_at default_expire_at
completed_notification_email_subject completed_notification_email_body completed_notification_email_subject completed_notification_email_body
completed_notification_email_enabled completed_notification_email_attach_audit] + completed_notification_email_enabled completed_notification_email_attach_audit] +
[completed_message: %i[title body], [completed_message: %i[title body],
submitters: [%i[uuid request_email_subject request_email_body]]] submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: []]
).tap do |attrs| ).tap do |attrs|
attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1' attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1'
@@ -0,0 +1,25 @@
# frozen_string_literal: true
class TemplatesShareLinkController < ApplicationController
load_and_authorize_resource :template
def show; end
def create
authorize!(:update, @template)
@template.update!(template_params)
if params[:redir].present?
redirect_to params[:redir]
else
head :ok
end
end
private
def template_params
params.require(:template).permit(:shared_link)
end
end
@@ -23,7 +23,9 @@ class TemplatesUploadsController < ApplicationController
@template.update!(schema:) @template.update!(schema:)
enqueue_template_created_webhooks(@template) WebhookUrls.enqueue_events(@template, 'template.created')
SearchEntries.enqueue_reindex(@template)
redirect_to edit_template_path(@template) redirect_to edit_template_path(@template)
rescue Templates::CreateAttachments::PdfEncrypted rescue Templates::CreateAttachments::PdfEncrypted
@@ -66,11 +68,4 @@ class TemplatesUploadsController < ApplicationController
{ files: [file] } { files: [file] }
end end
def enqueue_template_created_webhooks(template)
WebhookUrls.for_account_id(template.account_id, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id,
'webhook_url_id' => webhook_url.id)
end
end
end end
@@ -0,0 +1,66 @@
# frozen_string_literal: true
class WebhookEventsController < ApplicationController
load_and_authorize_resource :webhook_url, parent: false, id_param: :webhook_id
before_action :load_webhook_event
def show
return unless current_ability.can?(:read, @webhook_event.record)
@data =
case @webhook_event.event_type
when 'form.started', 'form.completed', 'form.declined', 'form.viewed'
Submitters::SerializeForWebhook.call(@webhook_event.record)
when 'submission.created', 'submission.completed', 'submission.expired'
Submissions::SerializeForApi.call(@webhook_event.record)
when 'template.created', 'template.updated'
Templates::SerializeForApi.call(@webhook_event.record)
when 'submission.archived'
@webhook_event.record.as_json(only: %i[id archived_at])
end
end
def resend
id_key = WebhookUrls::EVENT_TYPE_ID_KEYS.fetch(@webhook_event.event_type.split('.').first)
last_attempt_id = @webhook_event.webhook_attempts.maximum(:id)
WebhookUrls::EVENT_TYPE_TO_JOB_CLASS[@webhook_event.event_type].perform_async(
id_key => @webhook_event.record_id,
'webhook_url_id' => @webhook_event.webhook_url_id,
'event_uuid' => @webhook_event.uuid,
'attempt' => SendWebhookRequest::MANUAL_ATTEMPT,
'last_status' => 0
)
render turbo_stream: [
turbo_stream.after(
params[:button_id],
helpers.tag.submit_form(
helpers.button_to('', refresh_settings_webhook_event_path(@webhook_url.id, @webhook_event.uuid),
params: { last_attempt_id: }),
class: 'hidden', data: { interval: 3_000 }
)
)
]
end
def refresh
return head :ok if @webhook_event.webhook_attempts.maximum(:id) == params[:last_attempt_id].to_i
render turbo_stream: [
turbo_stream.replace(helpers.dom_id(@webhook_event),
partial: 'event_row',
locals: { with_status: true, webhook_url: @webhook_url, webhook_event: @webhook_event }),
turbo_stream.replace(helpers.dom_id(@webhook_event, :drawer_events),
partial: 'drawer_events',
locals: { webhook_url: @webhook_url, webhook_event: @webhook_event })
]
end
private
def load_webhook_event
@webhook_event = @webhook_url.webhook_events.find_by!(uuid: params[:id])
end
end
+25 -4
View File
@@ -8,10 +8,26 @@ class WebhookSettingsController < ApplicationController
@webhook_urls = @webhook_urls.order(id: :desc) @webhook_urls = @webhook_urls.order(id: :desc)
@webhook_url = @webhook_urls.first_or_initialize @webhook_url = @webhook_urls.first_or_initialize
render @webhook_urls.size > 1 ? 'index' : 'show' if @webhook_urls.size > 1
render :index
else
@webhook_events = @webhook_url.webhook_events
@webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status])
@pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc))
render :show
end
end end
def show; end def show
@webhook_events = @webhook_url.webhook_events
@webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status])
@pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc))
end
def new; end def new; end
@@ -37,13 +53,18 @@ class WebhookSettingsController < ApplicationController
def resend def resend
submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last
authorize!(:read, submitter)
if submitter.blank? || @webhook_url.blank? if submitter.blank? || @webhook_url.blank?
return redirect_back(fallback_location: settings_webhooks_path, return redirect_back(fallback_location: settings_webhooks_path,
alert: I18n.t('unable_to_resend_webhook_request')) alert: I18n.t('unable_to_resend_webhook_request'))
end end
SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id, SendTestWebhookRequestJob.perform_async(
'webhook_url_id' => @webhook_url.id) 'submitter_id' => submitter.id,
'event_uuid' => SecureRandom.uuid,
'webhook_url_id' => @webhook_url.id
)
redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_request_has_been_sent')) redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_request_has_been_sent'))
end end
+6
View File
@@ -24,6 +24,7 @@ import SubmitForm from './elements/submit_form'
import PromptPassword from './elements/prompt_password' import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea' import EmailsTextarea from './elements/emails_textarea'
import ToggleOnSubmit from './elements/toggle_on_submit' import ToggleOnSubmit from './elements/toggle_on_submit'
import CheckOnClick from './elements/check_on_click'
import PasswordInput from './elements/password_input' import PasswordInput from './elements/password_input'
import SearchInput from './elements/search_input' import SearchInput from './elements/search_input'
import ToggleAttribute from './elements/toggle_attribute' import ToggleAttribute from './elements/toggle_attribute'
@@ -34,6 +35,8 @@ import SetDateButton from './elements/set_date_button'
import IndeterminateCheckbox from './elements/indeterminate_checkbox' import IndeterminateCheckbox from './elements/indeterminate_checkbox'
import AppTour from './elements/app_tour' import AppTour from './elements/app_tour'
import DashboardDropzone from './elements/dashboard_dropzone' import DashboardDropzone from './elements/dashboard_dropzone'
import RequiredCheckboxGroup from './elements/required_checkbox_group'
import PageContainer from './elements/page_container'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -103,6 +106,9 @@ safeRegisterElement('set-date-button', SetDateButton)
safeRegisterElement('indeterminate-checkbox', IndeterminateCheckbox) safeRegisterElement('indeterminate-checkbox', IndeterminateCheckbox)
safeRegisterElement('app-tour', AppTour) safeRegisterElement('app-tour', AppTour)
safeRegisterElement('dashboard-dropzone', DashboardDropzone) safeRegisterElement('dashboard-dropzone', DashboardDropzone)
safeRegisterElement('check-on-click', CheckOnClick)
safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
safeRegisterElement('page-container', PageContainer)
safeRegisterElement('template-builder', class extends HTMLElement { safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
+8
View File
@@ -147,3 +147,11 @@ button[disabled] .enabled {
outline-offset: 3px; outline-offset: 3px;
outline-color: hsl(var(--bc) / 0.2); outline-color: hsl(var(--bc) / 0.2);
} }
.font-times {
font-family: "Times New Roman", Times, ui-serif, serif, Cambria, Georgia;
}
.font-courier {
font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco;
}
+14
View File
@@ -0,0 +1,14 @@
export default class extends HTMLElement {
connectedCallback () {
this.addEventListener('click', () => {
if (this.element && !this.element.disabled && !this.element.checked) {
this.element.checked = true
this.element.dispatchEvent(new Event('change', { bubbles: true }))
}
})
}
get element () {
return document.getElementById(this.dataset.elementId)
}
}
@@ -3,8 +3,6 @@ export default class extends HTMLElement {
this.clearChecked() this.clearChecked()
this.addEventListener('click', (e) => { this.addEventListener('click', (e) => {
e.stopPropagation()
const text = this.dataset.text || this.innerText.trim() const text = this.dataset.text || this.innerText.trim()
if (navigator.clipboard) { if (navigator.clipboard) {
+29 -3
View File
@@ -15,6 +15,7 @@ export default targetable(class extends HTMLElement {
static [target.static] = [ static [target.static] = [
'form', 'form',
'fileDropzone', 'fileDropzone',
'folderDropzone',
'fileDropzoneLoading' 'fileDropzoneLoading'
] ]
@@ -25,12 +26,13 @@ export default targetable(class extends HTMLElement {
window.addEventListener('dragleave', this.onWindowDragleave) window.addEventListener('dragleave', this.onWindowDragleave)
this.fileDropzone?.addEventListener('drop', this.onDropFile) this.fileDropzone?.addEventListener('drop', this.onDropFile)
this.folderDropzone?.addEventListener('drop', this.onDropNewFolder)
this.folderCards.forEach((el) => el.addEventListener('drop', (e) => this.onDropFolder(e, el))) this.folderCards.forEach((el) => el.addEventListener('drop', (e) => this.onDropFolder(e, el)))
this.templateCards.forEach((el) => el.addEventListener('drop', this.onDropTemplate)) this.templateCards.forEach((el) => el.addEventListener('drop', this.onDropTemplate))
this.templateCards.forEach((el) => el.addEventListener('dragstart', this.onTemplateDragStart)) this.templateCards.forEach((el) => el.addEventListener('dragstart', this.onTemplateDragStart))
return [this.fileDropzone, ...this.folderCards, ...this.templateCards].forEach((el) => { return [this.fileDropzone, this.folderDropzone, ...this.folderCards, ...this.templateCards].forEach((el) => {
el?.addEventListener('dragover', this.onDragover) el?.addEventListener('dragover', this.onDragover)
el?.addEventListener('dragleave', this.onDragleave) el?.addEventListener('dragleave', this.onDragleave)
}) })
@@ -46,6 +48,10 @@ export default targetable(class extends HTMLElement {
onTemplateDragStart = (e) => { onTemplateDragStart = (e) => {
const id = e.target.href.split('/').pop() const id = e.target.href.split('/').pop()
this.folderCards.forEach((el) => el.classList.remove('bg-base-200', 'before:hidden'))
this.folderDropzone?.classList?.remove('hidden')
window.flash?.remove()
e.dataTransfer.effectAllowed = 'move' e.dataTransfer.effectAllowed = 'move'
if (id) { if (id) {
@@ -98,13 +104,13 @@ export default targetable(class extends HTMLElement {
el.classList.add('opacity-50') el.classList.add('opacity-50')
if (e.dataTransfer.files.length) { if (e.dataTransfer.files.length) {
const params = new URLSearchParams({ folder_name: el.innerText }).toString() const params = new URLSearchParams({ folder_name: el.innerText.trim() }).toString()
this.uploadFiles(e.dataTransfer.files, `/templates_upload?${params}`) this.uploadFiles(e.dataTransfer.files, `/templates_upload?${params}`)
} else { } else {
const formData = new FormData() const formData = new FormData()
formData.append('name', el.innerText) formData.append('name', el.dataset.fullName)
fetch(`/templates/${templateId}/folder`, { fetch(`/templates/${templateId}/folder`, {
method: 'PUT', method: 'PUT',
@@ -176,6 +182,24 @@ export default targetable(class extends HTMLElement {
} }
} }
onDropNewFolder (e) {
e.preventDefault()
const templateId = e.dataTransfer.getData('template_id')
const a = document.createElement('a')
a.href = `/templates/${templateId}/folder/edit?autocomplete=false`
a.dataset.turboFrame = 'modal'
a.classList.add('hidden')
document.body.append(a)
a.click()
a.remove()
}
onDragleave () { onDragleave () {
this.style.backgroundColor = null this.style.backgroundColor = null
@@ -199,6 +223,7 @@ export default targetable(class extends HTMLElement {
this.isDrag = true this.isDrag = true
window.flash?.remove()
this.fileDropzone?.classList?.remove('hidden') this.fileDropzone?.classList?.remove('hidden')
this.hiddenOnDrag.forEach((el) => { el.style.display = 'none' }) this.hiddenOnDrag.forEach((el) => { el.style.display = 'none' })
@@ -212,6 +237,7 @@ export default targetable(class extends HTMLElement {
this.isDrag = false this.isDrag = false
this.fileDropzone?.classList?.add('hidden') this.fileDropzone?.classList?.add('hidden')
this.folderDropzone?.classList?.add('hidden')
this.hiddenOnDrag.forEach((el) => { el.style.display = null }) this.hiddenOnDrag.forEach((el) => { el.style.display = null })
@@ -2,6 +2,8 @@ import autocomplete from 'autocompleter'
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
if (this.dataset.enabled === 'false') return
autocomplete({ autocomplete({
input: this.input, input: this.input,
preventSubmit: this.dataset.submitOnSelect === 'true' ? 0 : 1, preventSubmit: this.dataset.submitOnSelect === 'true' ? 0 : 1,
@@ -14,12 +16,16 @@ export default class extends HTMLElement {
} }
onSelect = (item) => { onSelect = (item) => {
this.input.value = item.name this.input.value = this.dataset.parentName ? item.name : item.full_name
} }
fetch = (text, resolve) => { fetch = (text, resolve) => {
const queryParams = new URLSearchParams({ q: text }) const queryParams = new URLSearchParams({ q: text })
if (this.dataset.parentName) {
queryParams.append('parent_name', this.dataset.parentName)
}
fetch('/template_folders_autocomplete?' + queryParams).then(async (resp) => { fetch('/template_folders_autocomplete?' + queryParams).then(async (resp) => {
const items = await resp.json() const items = await resp.json()
@@ -34,7 +40,7 @@ export default class extends HTMLElement {
div.setAttribute('dir', 'auto') div.setAttribute('dir', 'auto')
div.textContent = item.name div.textContent = this.dataset.parentName ? item.name : item.full_name
return div return div
} }
+12
View File
@@ -0,0 +1,12 @@
export default class extends HTMLElement {
connectedCallback () {
const image = this.querySelector('img')
image.addEventListener('load', (e) => {
image.setAttribute('width', e.target.naturalWidth)
image.setAttribute('height', e.target.naturalHeight)
this.style.aspectRatio = `${e.target.naturalWidth} / ${e.target.naturalHeight}`
})
}
}
@@ -0,0 +1,17 @@
export default class extends HTMLElement {
connectedCallback () {
this.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', this.handleChange)
})
}
handleChange = () => {
if (this.checkedCount !== 0) {
this.closest('form')?.requestSubmit()
}
}
get checkedCount () {
return this.querySelectorAll('input[type="checkbox"]:checked').length
}
}
+13 -1
View File
@@ -1,5 +1,17 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.querySelector('form').requestSubmit() if (this.dataset.interval) {
this.interval = setInterval(() => {
this.querySelector('form').requestSubmit()
}, parseInt(this.dataset.interval))
} else {
this.querySelector('form').requestSubmit()
}
}
disconnectedCallback () {
if (this.interval) {
clearInterval(this.interval)
}
} }
} }
@@ -36,7 +36,9 @@ export default class extends HTMLElement {
} }
fetch = (text, resolve) => { fetch = (text, resolve) => {
const q = text.split(/[;,\s]+/).pop().trim() const q = this.dataset.field === 'email'
? text.split(/[;,\s]+/).pop().trim()
: text
if (q) { if (q) {
const queryParams = new URLSearchParams({ q, field: this.dataset.field }) const queryParams = new URLSearchParams({ q, field: this.dataset.field })
+2
View File
@@ -5,6 +5,7 @@ import DownloadButton from './elements/download_button'
import ToggleSubmit from './elements/toggle_submit' import ToggleSubmit from './elements/toggle_submit'
import FetchForm from './elements/fetch_form' import FetchForm from './elements/fetch_form'
import ScrollButtons from './elements/scroll_buttons' import ScrollButtons from './elements/scroll_buttons'
import PageContainer from './elements/page_container'
const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options) const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
@@ -12,6 +13,7 @@ safeRegisterElement('download-button', DownloadButton)
safeRegisterElement('toggle-submit', ToggleSubmit) safeRegisterElement('toggle-submit', ToggleSubmit)
safeRegisterElement('fetch-form', FetchForm) safeRegisterElement('fetch-form', FetchForm)
safeRegisterElement('scroll-buttons', ScrollButtons) safeRegisterElement('scroll-buttons', ScrollButtons)
safeRegisterElement('page-container', PageContainer)
safeRegisterElement('submission-form', class extends HTMLElement { safeRegisterElement('submission-form', class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
+8
View File
@@ -70,3 +70,11 @@ button[disabled] .enabled {
.base-radio { .base-radio {
@apply radio bg-white radio-sm; @apply radio bg-white radio-sm;
} }
.font-times {
font-family: "Times New Roman", Times, ui-serif, serif, Cambria, Georgia;
}
.font-courier {
font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco;
}
+51 -16
View File
@@ -2,8 +2,8 @@
<div <div
class="flex absolute lg:text-base -outline-offset-1 field-area" class="flex absolute lg:text-base -outline-offset-1 field-area"
dir="auto" dir="auto"
:style="computedStyle" :style="[computedStyle, fontStyle]"
:class="{ 'text-[1.6vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10 field-area-active': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }" :class="{ 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10 field-area-active': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
> >
<div <div
v-if="(!withFieldPlaceholder || !field.name || field.type === 'cells') && !isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid" v-if="(!withFieldPlaceholder || !field.name || field.type === 'cells') && !isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
@@ -23,7 +23,7 @@
</div> </div>
<div <div
v-if="isActive && withLabel && (!area.option_uuid || !option.value)" v-if="isActive && withLabel && (!area.option_uuid || !option.value)"
class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap pointer-events-none" class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap pointer-events-none field-area-active-label"
> >
<template v-if="area.option_uuid && !option.value"> <template v-if="area.option_uuid && !option.value">
{{ optionValue(option) }} {{ optionValue(option) }}
@@ -55,12 +55,12 @@
> >
<div <div
v-else-if="field.type === 'signature' && signature" v-else-if="field.type === 'signature' && signature"
class="flex justify-between h-full gap-1 overflow-hidden" class="flex justify-between h-full gap-1 overflow-hidden w-full"
:class="isNarrow ? 'flex-row' : 'flex-col'" :class="isNarrow && (isShowSignatureId || field.preferences?.reason_field_uuid) ? 'flex-row' : 'flex-col'"
> >
<div <div
class="flex overflow-hidden" class="flex overflow-hidden"
:class="isNarrow ? 'w-1/2' : 'flex-grow'" :class="isNarrow && (isShowSignatureId || field.preferences?.reason_field_uuid) ? 'w-1/2' : 'flex-grow'"
style="min-height: 50%" style="min-height: 50%"
> >
<img <img
@@ -69,7 +69,7 @@
> >
</div> </div>
<div <div
v-if="withSignatureId" v-if="isShowSignatureId || field.preferences?.reason_field_uuid"
class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem]" class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem]"
:class="isNarrow ? 'w-1/2' : 'w-full'" :class="isNarrow ? 'w-1/2' : 'w-full'"
> >
@@ -236,6 +236,11 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
isInlineSize: {
type: Boolean,
required: false,
default: true
},
submitter: { submitter: {
type: Object, type: Object,
required: false, required: false,
@@ -328,6 +333,13 @@ export default {
verification: this.t('verify_id') verification: this.t('verify_id')
} }
}, },
isShowSignatureId () {
if ([true, false].includes(this.field.preferences?.with_signature_id)) {
return this.field.preferences.with_signature_id
} else {
return this.withSignatureId
}
},
alignClasses () { alignClasses () {
if (!this.field.preferences) { if (!this.field.preferences) {
return { 'items-center': true } return { 'items-center': true }
@@ -348,8 +360,8 @@ export default {
} }
return { return {
'font-mono': this.field.preferences.font === 'Courier', 'font-courier': this.field.preferences.font === 'Courier',
'font-serif': this.field.preferences.font === 'Times', 'font-times': this.field.preferences.font === 'Times',
'font-bold': ['bold_italic', 'bold'].includes(this.field.preferences.font_type), 'font-bold': ['bold_italic', 'bold'].includes(this.field.preferences.font_type),
italic: ['bold_italic', 'italic'].includes(this.field.preferences.font_type) italic: ['bold_italic', 'italic'].includes(this.field.preferences.font_type)
} }
@@ -427,6 +439,34 @@ export default {
return [] return []
} }
}, },
fontStyle () {
let fontSize = ''
if (this.isInlineSize) {
if (this.textOverflowChars) {
fontSize = `${this.fontSizePx / 1.5 / 10}cqmin`
} else {
fontSize = `${this.fontSizePx / 10}cqmin`
}
} else {
if (this.textOverflowChars) {
fontSize = `clamp(1pt, ${this.fontSizePx / 1.5 / 10}vw, ${this.fontSizePx / 1.5}px)`
} else {
fontSize = `clamp(1pt, ${this.fontSizePx / 10}vw, ${this.fontSizePx}px)`
}
}
return { fontSize, lineHeight: `calc(${fontSize} * ${this.lineHeight})` }
},
fontSizePx () {
return parseInt(this.field?.preferences?.font_size || 11) * this.fontScale
},
lineHeight () {
return 1.3
},
fontScale () {
return 1000 / 612.0
},
computedStyle () { computedStyle () {
const { x, y, w, h } = this.area const { x, y, w, h } = this.area
@@ -437,11 +477,6 @@ export default {
height: h * 100 + '%' height: h * 100 + '%'
} }
if (this.field.preferences?.font_size) {
style.fontSize = `clamp(4pt, 1.6vw, ${parseInt(this.field.preferences.font_size) * 1.23}pt)`
style.lineHeight = `clamp(6pt, 2.0vw, ${parseInt(this.field.preferences.font_size) * 1.23 + 3}pt)`
}
if (this.field.preferences?.color) { if (this.field.preferences?.color) {
style.color = this.field.preferences.color style.color = this.field.preferences.color
} }
@@ -456,7 +491,7 @@ export default {
modelValue () { modelValue () {
this.$nextTick(() => { this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.modelValue}`.length)) { if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.modelValue}`.length)) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0 this.textOverflowChars = this.$refs.textContainer.scrollHeight > (this.$refs.textContainer.clientHeight + 1) ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0
} }
}) })
} }
@@ -464,7 +499,7 @@ export default {
mounted () { mounted () {
this.$nextTick(() => { this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer) { if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0 this.textOverflowChars = this.$refs.textContainer.scrollHeight > (this.$refs.textContainer.clientHeight + 1) ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0
} }
}) })
}, },
+4
View File
@@ -23,6 +23,7 @@
:area="area" :area="area"
:submittable="submittable" :submittable="submittable"
:field-index="fieldIndex" :field-index="fieldIndex"
:is-inline-size="isInlineSize"
:scroll-padding="scrollPadding" :scroll-padding="scrollPadding"
:submitter="submitter" :submitter="submitter"
:with-field-placeholder="withFieldPlaceholder" :with-field-placeholder="withFieldPlaceholder"
@@ -110,6 +111,9 @@ export default {
} }
}, },
computed: { computed: {
isInlineSize () {
return CSS.supports('container-type: size')
},
isMobileContainer () { isMobileContainer () {
const root = this.$root.$el.parentNode.getRootNode() const root = this.$root.$el.parentNode.getRootNode()
const container = root.body || root.querySelector('div') const container = root.body || root.querySelector('div')
+13 -1
View File
@@ -95,7 +95,19 @@ export default {
}, },
methods: { methods: {
onDropFiles (e) { onDropFiles (e) {
this.uploadFiles(e.dataTransfer.files) const files = Array.from(e.dataTransfer.files).filter((f) => {
if (this.accept === 'image/*') {
return f.type.startsWith('image')
} else {
return true
}
})
if (this.accept === 'image/*' && !files.length) {
alert(this.t('please_upload_an_image_file'))
} else {
this.uploadFiles(files)
}
}, },
onSelectFiles (e) { onSelectFiles (e) {
e.preventDefault() e.preventDefault()
+2 -1
View File
@@ -67,7 +67,7 @@
<button <button
v-if="!isFormVisible" v-if="!isFormVisible"
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" 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%"
@click.prevent="[isFormVisible = true, scrollIntoField(currentField)]" @click.prevent="[isFormVisible = true, scrollIntoField(currentField)]"
> >
@@ -1390,6 +1390,7 @@ export default {
if (isLastStep && !emptyRequiredField && !this.inviteSubmitters.length && !this.optionalInviteSubmitters.length) { if (isLastStep && !emptyRequiredField && !this.inviteSubmitters.length && !this.optionalInviteSubmitters.length) {
formData.append('completed', 'true') formData.append('completed', 'true')
formData.append('timezone', Intl.DateTimeFormat().resolvedOptions().timeZone)
} }
let saveStepRequest let saveStepRequest
@@ -14,6 +14,7 @@
<FieldArea <FieldArea
v-if="isMathLoaded" v-if="isMathLoaded"
:model-value="calculateFormula(field)" :model-value="calculateFormula(field)"
:is-inline-size="isInlineSize"
:field="field" :field="field"
:area="area" :area="area"
:submittable="false" :submittable="false"
@@ -54,6 +55,11 @@ export default {
isMathLoaded: false isMathLoaded: false
} }
}, },
computed: {
isInlineSize () {
return CSS.supports('container-type: size')
}
},
async mounted () { async mounted () {
const { const {
create, create,
+28
View File
@@ -1,4 +1,6 @@
const en = { const en = {
please_upload_an_image_file: 'Please upload an image file',
must_be_characters_length: 'Must be {number} characters length',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.', complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
verify_id: 'Verify ID', verify_id: 'Verify ID',
identity_verification: 'Identity verification', identity_verification: 'Identity verification',
@@ -98,6 +100,8 @@ const en = {
} }
const es = { const es = {
please_upload_an_image_file: 'Por favor, sube un archivo de imagen',
must_be_characters_length: 'Debe tener {number} caracteres de longitud',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.', complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
verify_id: 'Verificar ID', verify_id: 'Verificar ID',
identity_verification: 'Verificación de identidad', identity_verification: 'Verificación de identidad',
@@ -197,6 +201,8 @@ const es = {
} }
const it = { const it = {
please_upload_an_image_file: 'Per favore carica un file immagine',
must_be_characters_length: 'Deve essere lungo {number} caratteri',
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.", complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
verify_id: 'Verifica ID', verify_id: 'Verifica ID',
identity_verification: "Verifica dell'identità", identity_verification: "Verifica dell'identità",
@@ -296,6 +302,8 @@ const it = {
} }
const de = { const de = {
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
must_be_characters_length: 'Muss {number} Zeichen lang sein',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vervollständigen Sie alle erforderlichen Felder, um mit der Identitätsverifizierung fortzufahren.', complete_all_required_fields_to_proceed_with_identity_verification: 'Vervollständigen Sie alle erforderlichen Felder, um mit der Identitätsverifizierung fortzufahren.',
verify_id: 'ID überprüfen', verify_id: 'ID überprüfen',
identity_verification: 'Identitätsüberprüfung', identity_verification: 'Identitätsüberprüfung',
@@ -395,6 +403,8 @@ const de = {
} }
const fr = { const fr = {
please_upload_an_image_file: 'Veuillez télécharger un fichier image',
must_be_characters_length: 'Doit contenir {number} caractères',
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour continuer la vérification de l'identité.", complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour continuer la vérification de l'identité.",
verify_id: "Vérification de l'ID", verify_id: "Vérification de l'ID",
identity_verification: "Vérification de l'identité", identity_verification: "Vérification de l'identité",
@@ -494,6 +504,8 @@ const fr = {
} }
const pl = { const pl = {
please_upload_an_image_file: 'Proszę przesłać plik obrazu',
must_be_characters_length: 'Musi mieć długość {number} znaków',
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.', complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
verify_id: 'Zweryfikuj ID', verify_id: 'Zweryfikuj ID',
identity_verification: 'Weryfikacja tożsamości', identity_verification: 'Weryfikacja tożsamości',
@@ -593,6 +605,8 @@ const pl = {
} }
const uk = { const uk = {
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
must_be_characters_length: 'Має містити {number} символів',
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.", complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
verify_id: 'Підтвердження ідентичності', verify_id: 'Підтвердження ідентичності',
identity_verification: 'Ідентифікація особи', identity_verification: 'Ідентифікація особи',
@@ -692,6 +706,8 @@ const uk = {
} }
const cs = { const cs = {
please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor',
must_be_characters_length: 'Musí mít délku {number} znaků',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.', complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
verify_id: 'Ověřit ID', verify_id: 'Ověřit ID',
identity_verification: 'Ověření identity', identity_verification: 'Ověření identity',
@@ -791,6 +807,8 @@ const cs = {
} }
const pt = { const pt = {
please_upload_an_image_file: 'Por favor, envie um arquivo de imagem',
must_be_characters_length: 'Deve ter {number} caracteres',
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.', complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
verify_id: 'Verificar ID', verify_id: 'Verificar ID',
identity_verification: 'Verificação de identidade', identity_verification: 'Verificação de identidade',
@@ -890,6 +908,8 @@ const pt = {
} }
const he = { const he = {
please_upload_an_image_file: 'אנא העלה קובץ תמונה',
must_be_characters_length: 'חייב להיות באורך של {number} תווים',
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.', complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
verify_id: 'אמת מזהה', verify_id: 'אמת מזהה',
identity_verification: 'אימות זהות', identity_verification: 'אימות זהות',
@@ -989,6 +1009,8 @@ const he = {
} }
const nl = { const nl = {
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
must_be_characters_length: 'Moet {number} tekens lang zijn',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.', complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
verify_id: 'Verifiëren ID', verify_id: 'Verifiëren ID',
identity_verification: 'Identiteitsverificatie', identity_verification: 'Identiteitsverificatie',
@@ -1088,6 +1110,8 @@ const nl = {
} }
const ar = { const ar = {
please_upload_an_image_file: 'يرجى تحميل ملف صورة',
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.', complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
verify_id: 'تحقق من الهوية', verify_id: 'تحقق من الهوية',
identity_verification: 'التحقق من الهوية', identity_verification: 'التحقق من الهوية',
@@ -1187,6 +1211,8 @@ const ar = {
} }
const ko = { const ko = {
please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
must_be_characters_length: '{number}자여야 합니다',
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.', complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
verify_id: '아이디 확인', verify_id: '아이디 확인',
identity_verification: '신원 확인', identity_verification: '신원 확인',
@@ -1286,6 +1312,8 @@ const ko = {
} }
const ja = { const ja = {
please_upload_an_image_file: '画像ファイルをアップロードしてください',
must_be_characters_length: '{number}文字でなければなりません',
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。', complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
verify_id: '本人確認', verify_id: '本人確認',
identity_verification: '本人確認', identity_verification: '本人確認',
@@ -203,7 +203,7 @@ export default {
emits: ['attached', 'update:model-value', 'start', 'minimize', 'focus'], emits: ['attached', 'update:model-value', 'start', 'minimize', 'focus'],
data () { data () {
return { return {
isInitialsStarted: !!this.previousValue, isInitialsStarted: false,
isUsePreviousValue: true, isUsePreviousValue: true,
isDrawInitials: false, isDrawInitials: false,
uploadImageInputKey: Math.random().toString() uploadImageInputKey: Math.random().toString()
@@ -211,13 +211,16 @@ export default {
}, },
computed: { computed: {
computedPreviousValue () { computedPreviousValue () {
if (this.isUsePreviousValue) { if (this.isUsePreviousValue && this.field.required === true) {
return this.previousValue return this.previousValue
} else { } else {
return null return null
} }
} }
}, },
created () {
this.isInitialsStarted = !!this.computedPreviousValue
},
async mounted () { async mounted () {
this.$nextTick(() => { this.$nextTick(() => {
if (this.$refs.canvas) { if (this.$refs.canvas) {
@@ -362,11 +365,20 @@ export default {
formData.append('file', file) formData.append('file', file)
formData.append('submitter_slug', this.submitterSlug) formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments') formData.append('name', 'attachments')
formData.append('type', 'initials')
return fetch(this.baseUrl + '/api/attachments', { return fetch(this.baseUrl + '/api/attachments', {
method: 'POST', method: 'POST',
body: formData body: formData
}).then((resp) => resp.json()).then((attachment) => { }).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
return Promise.reject(new Error(data.error))
}
const attachment = await resp.json()
this.$emit('attached', attachment) this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid) this.$emit('update:model-value', attachment.uuid)
@@ -133,7 +133,7 @@
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"
> >
<FileDropzone <FileDropzone
v-if="format === 'upload' && !modelValue" v-if="format === 'upload' && !modelValue && !computedPreviousValue"
:message="`${t('upload')} ${field.name || t('signature')}`" :message="`${t('upload')} ${field.name || t('signature')}`"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:dry-run="dryRun" :dry-run="dryRun"
@@ -201,7 +201,7 @@
</div> </div>
</div> </div>
<input <input
v-if="isTextSignature" v-if="isTextSignature && !modelValue && !computedPreviousValue"
id="signature_text_input" id="signature_text_input"
ref="textInput" ref="textInput"
class="base-input !text-2xl w-full mt-6" class="base-input !text-2xl w-full mt-6"
@@ -391,7 +391,7 @@ export default {
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason'], emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason'],
data () { data () {
return { return {
isSignatureStarted: !!this.previousValue, isSignatureStarted: false,
isShowQr: false, isShowQr: false,
isOtherReason: false, isOtherReason: false,
isUsePreviousValue: true, isUsePreviousValue: true,
@@ -414,7 +414,7 @@ export default {
} }
}, },
computedPreviousValue () { computedPreviousValue () {
if (this.isUsePreviousValue) { if (this.isUsePreviousValue && this.field.required === true) {
return this.previousValue return this.previousValue
} else { } else {
return null return null
@@ -422,6 +422,8 @@ export default {
} }
}, },
created () { created () {
this.isSignatureStarted = !!this.computedPreviousValue
if (this.requireSigningReason) { if (this.requireSigningReason) {
this.field.preferences ||= {} this.field.preferences ||= {}
this.field.preferences.reason_field_uuid ||= v4() this.field.preferences.reason_field_uuid ||= v4()
@@ -716,11 +718,20 @@ export default {
formData.append('submitter_slug', this.submitterSlug) formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments') formData.append('name', 'attachments')
formData.append('remember_signature', this.rememberSignature) formData.append('remember_signature', this.rememberSignature)
formData.append('type', 'signature')
return fetch(this.baseUrl + '/api/attachments', { return fetch(this.baseUrl + '/api/attachments', {
method: 'POST', method: 'POST',
body: formData body: formData
}).then((resp) => resp.json()).then((attachment) => { }).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
return Promise.reject(new Error(data.error))
}
const attachment = await resp.json()
this.$emit('attached', attachment) this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid) this.$emit('update:model-value', attachment.uuid)
+28 -4
View File
@@ -43,8 +43,8 @@
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`" :placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text" type="text"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@invalid="field.validation?.message ? $event.target.setCustomValidity(field.validation.message) : ''" @invalid="validationMessage ? $event.target.setCustomValidity(validationMessage) : ''"
@input="field.validation?.message ? $event.target.setCustomValidity('') : ''" @input="validationMessage ? $event.target.setCustomValidity('') : ''"
@focus="$emit('focus')" @focus="$emit('focus')"
> >
<textarea <textarea
@@ -127,6 +127,28 @@ export default {
return null return null
} }
}, },
lengthValidation () {
if (this.field.validation?.pattern) {
return this.field.validation.pattern.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups
} else {
return null
}
},
validationMessage () {
if (this.field.validation?.message) {
return this.field.validation.message
} else if (this.lengthValidation) {
const number =
[this.lengthValidation.min, this.lengthValidation.max]
.filter(Boolean)
.filter((v, i, a) => a.indexOf(v) === i)
.join('-')
return this.t('must_be_characters_length').replace('{number}', number)
} else {
return ''
}
},
text: { text: {
set (value) { set (value) {
this.$emit('update:model-value', value) this.$emit('update:model-value', value)
@@ -151,8 +173,10 @@ export default {
resizeTextarea () { resizeTextarea () {
const textarea = this.$refs.textarea const textarea = this.$refs.textarea
textarea.style.height = 'auto' if (textarea) {
textarea.style.height = Math.min(250, textarea.scrollHeight) + 'px' textarea.style.height = 'auto'
textarea.style.height = Math.min(250, textarea.scrollHeight) + 'px'
}
}, },
toggleTextArea () { toggleTextArea () {
this.isTextArea = true this.isTextArea = true
@@ -149,7 +149,8 @@ export default {
return fetch(this.baseUrl + `/api/identity_verification/${this.field.uuid}`, { return fetch(this.baseUrl + `/api/identity_verification/${this.field.uuid}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug submitter_slug: this.submitterSlug,
redirect_url: document.location.href
}), }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => { }).then(async (resp) => {
+41 -6
View File
@@ -140,6 +140,7 @@
:background-color="'white'" :background-color="'white'"
:with-required="false" :with-required="false"
:with-areas="false" :with-areas="false"
:with-signature-id="withSignatureId"
@click-formula="isShowFormulaModal = true" @click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true" @click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true" @click-description="isShowDescriptionModal = true"
@@ -172,7 +173,8 @@
> >
<div <div
v-if="isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas && field.type !== 'checkbox')" v-if="isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas && field.type !== 'checkbox')"
:class="{ 'w-full h-full': ['cells', 'checkbox'].includes(field.type), 'text-[1.6vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars }" :class="{ 'w-full h-full': ['cells', 'checkbox'].includes(field.type) }"
:style="fontStyle"
> >
<div <div
ref="textContainer" ref="textContainer"
@@ -315,7 +317,7 @@ export default {
FieldSubmitter, FieldSubmitter,
IconX IconX
}, },
inject: ['template', 'selectedAreaRef', 'save', 't'], inject: ['template', 'selectedAreaRef', 'save', 't', 'isInlineSize'],
props: { props: {
area: { area: {
type: Object, type: Object,
@@ -346,6 +348,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
withSignatureId: {
type: Boolean,
required: false,
default: null
},
defaultSubmitters: { defaultSubmitters: {
type: Array, type: Array,
required: false, required: false,
@@ -385,6 +392,34 @@ export default {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels, fieldLabels: FieldType.computed.fieldLabels,
fieldIcons: FieldType.computed.fieldIcons, fieldIcons: FieldType.computed.fieldIcons,
fontStyle () {
let fontSize = ''
if (this.isInlineSize) {
if (this.textOverflowChars) {
fontSize = `${this.fontSizePx / 1.5 / 10}cqmin`
} else {
fontSize = `${this.fontSizePx / 10}cqmin`
}
} else {
if (this.textOverflowChars) {
fontSize = `clamp(1pt, ${this.fontSizePx / 1.5 / 10}vw, ${this.fontSizePx / 1.5}px)`
} else {
fontSize = `clamp(1pt, ${this.fontSizePx / 10}vw, ${this.fontSizePx}px)`
}
}
return { fontSize, lineHeight: `calc(${fontSize} * ${this.lineHeight})` }
},
fontSizePx () {
return parseInt(this.field?.preferences?.font_size || 11) * this.fontScale
},
lineHeight () {
return 1.3
},
fontScale () {
return 1040 / 612.0
},
isDefaultValuePresent () { isDefaultValuePresent () {
if (this.field?.type === 'radio' && this.field?.areas?.length > 1) { if (this.field?.type === 'radio' && this.field?.areas?.length > 1) {
return false return false
@@ -413,8 +448,8 @@ export default {
'justify-center': this.field.preferences.align === 'center', 'justify-center': this.field.preferences.align === 'center',
'justify-start': this.field.preferences.align === 'left', 'justify-start': this.field.preferences.align === 'left',
'justify-end': this.field.preferences.align === 'right', 'justify-end': this.field.preferences.align === 'right',
'font-mono': this.field.preferences.font === 'Courier', 'font-courier': this.field.preferences.font === 'Courier',
'font-serif': this.field.preferences.font === 'Times', 'font-times': this.field.preferences.font === 'Times',
'font-bold': ['bold_italic', 'bold'].includes(this.field.preferences.font_type), 'font-bold': ['bold_italic', 'bold'].includes(this.field.preferences.font_type),
italic: ['bold_italic', 'italic'].includes(this.field.preferences.font_type) italic: ['bold_italic', 'italic'].includes(this.field.preferences.font_type)
} }
@@ -491,7 +526,7 @@ export default {
'field.default_value' () { 'field.default_value' () {
this.$nextTick(() => { this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) { if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0 this.textOverflowChars = (this.$el.clientHeight + 1) < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
} }
}) })
} }
@@ -499,7 +534,7 @@ export default {
mounted () { mounted () {
this.$nextTick(() => { this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) { if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0 this.textOverflowChars = (this.$el.clientHeight + 1) < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
} }
}) })
}, },
+79 -6
View File
@@ -79,13 +79,52 @@
name="buttons" name="buttons"
/> />
<template v-else> <template v-else>
<form
v-if="withSignYourselfButton && template.submitters.length < 2"
target="_blank"
data-turbo="false"
class="inline"
method="post"
:action="`/d/${template.slug}`"
@submit="maybeShowErrorTemplateAlert"
>
<input
type="hidden"
name="_method"
value="put"
autocomplete="off"
>
<input
type="hidden"
name="authenticity_token"
:value="authenticityToken"
autocomplete="off"
>
<input
type="hidden"
name="selfsign"
value="true"
autocomplete="off"
>
<button
class="btn btn-primary btn-ghost text-base hidden md:flex"
type="submit"
>
<IconWritingSign
width="22"
class="inline"
/>
<span class="hidden md:inline">
{{ t('sign_yourself') }}
</span>
</button>
</form>
<a <a
v-if="withSignYourselfButton" v-else-if="withSignYourselfButton"
id="sign_yourself_button" id="sign_yourself_button"
:href="template.submitters.length > 1 ? `/templates/${template.id}/submissions/new?selfsign=true` : `/d/${template.slug}`" :href="`/templates/${template.id}/submissions/new?selfsign=true`"
class="btn btn-primary btn-ghost text-base hidden md:flex" class="btn btn-primary btn-ghost text-base hidden md:flex"
:target="template.submitters.length > 1 ? '' : '_blank'" data-turbo-frame="modal"
:data-turbo-frame="template.submitters.length > 1 ? 'modal' : ''"
@click="maybeShowErrorTemplateAlert" @click="maybeShowErrorTemplateAlert"
> >
<IconWritingSign <IconWritingSign
@@ -291,6 +330,7 @@
:input-mode="inputMode" :input-mode="inputMode"
:default-fields="[...defaultRequiredFields, ...defaultFields]" :default-fields="[...defaultRequiredFields, ...defaultFields]"
:allow-draw="!onlyDefinedFields || drawField" :allow-draw="!onlyDefinedFields || drawField"
:with-signature-id="withSignatureId"
:data-document-uuid="document.uuid" :data-document-uuid="document.uuid"
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:drag-field-placeholder="fieldsDragFieldRef.value || dragField" :drag-field-placeholder="fieldsDragFieldRef.value || dragField"
@@ -397,6 +437,7 @@
:default-required-fields="defaultRequiredFields" :default-required-fields="defaultRequiredFields"
:field-types="fieldTypes" :field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters" :with-sticky-submitters="withStickySubmitters"
:with-signature-id="withSignatureId"
:only-defined-fields="onlyDefinedFields" :only-defined-fields="onlyDefinedFields"
:editable="editable" :editable="editable"
:show-tour-start-form="showTourStartForm" :show-tour-start-form="showTourStartForm"
@@ -502,7 +543,9 @@ export default {
withPayment: this.withPayment, withPayment: this.withPayment,
isPaymentConnected: this.isPaymentConnected, isPaymentConnected: this.isPaymentConnected,
withFormula: this.withFormula, withFormula: this.withFormula,
withSignatureId: this.withSignatureId,
withConditions: this.withConditions, withConditions: this.withConditions,
isInlineSize: this.isInlineSize,
defaultDrawFieldType: this.defaultDrawFieldType, defaultDrawFieldType: this.defaultDrawFieldType,
selectedAreaRef: computed(() => this.selectedAreaRef), selectedAreaRef: computed(() => this.selectedAreaRef),
fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef) fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef)
@@ -523,6 +566,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
withSignatureId: {
type: Boolean,
required: false,
default: null
},
backgroundColor: { backgroundColor: {
type: String, type: String,
required: false, required: false,
@@ -754,6 +802,9 @@ export default {
language () { language () {
return this.locale.split('-')[0].toLowerCase() return this.locale.split('-')[0].toLowerCase()
}, },
isInlineSize () {
return CSS.supports('container-type: size')
},
isMobile () { isMobile () {
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent) const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
@@ -1013,12 +1064,23 @@ export default {
field.readonly = true field.readonly = true
} }
if (type === 'datenow') {
field.type = 'date'
field.readonly = true
field.default_value = '{{date}}'
}
if (type === 'date') { if (type === 'date') {
field.preferences = { field.preferences = {
format: this.defaultDateFormat format: this.defaultDateFormat
} }
} }
if (type === 'signature' && [true, false].includes(this.withSignatureId)) {
field.preferences ||= {}
field.preferences.with_signature_id = this.withSignatureId
}
this.template.fields.push(field) this.template.fields.push(field)
this.save() this.save()
@@ -1279,7 +1341,7 @@ export default {
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth) area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
} }
}, },
onDraw (area) { onDraw ({ area, isTooSmall }) {
if (this.drawField) { if (this.drawField) {
if (this.drawOption) { if (this.drawOption) {
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid) const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
@@ -1374,7 +1436,7 @@ export default {
area.y -= area.h / 2 area.y -= area.h / 2
} }
if (area.w) { if (area.w && (type !== 'checkbox' || this.drawFieldType || !isTooSmall)) {
this.addField(type, area) this.addField(type, area)
this.selectedAreaRef.value = area this.selectedAreaRef.value = area
@@ -1411,6 +1473,12 @@ export default {
} }
} }
if (field.type === 'datenow') {
field.type = 'date'
field.readonly = true
field.default_value = '{{date}}'
}
if (['stamp', 'heading'].includes(field.type)) { if (['stamp', 'heading'].includes(field.type)) {
field.readonly = true field.readonly = true
} }
@@ -1419,6 +1487,11 @@ export default {
field.preferences ||= {} field.preferences ||= {}
field.preferences.format ||= this.defaultDateFormat field.preferences.format ||= this.defaultDateFormat
} }
if (field.type === 'signature' && [true, false].includes(this.withSignatureId)) {
field.preferences ||= {}
field.preferences.with_signature_id = this.withSignatureId
}
} }
const fieldArea = { const fieldArea = {
+12 -5
View File
@@ -10,6 +10,7 @@
:data-page="index" :data-page="index"
:areas="areasIndex[index]" :areas="areasIndex[index]"
:allow-draw="allowDraw" :allow-draw="allowDraw"
:with-signature-id="withSignatureId"
:is-drag="isDrag" :is-drag="isDrag"
:with-field-placeholder="withFieldPlaceholder" :with-field-placeholder="withFieldPlaceholder"
:default-fields="defaultFields" :default-fields="defaultFields"
@@ -23,12 +24,13 @@
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })" @drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
@remove-area="$emit('remove-area', $event)" @remove-area="$emit('remove-area', $event)"
@scroll-to="scrollToArea" @scroll-to="scrollToArea"
@draw="$emit('draw', {...$event, attachment_uuid: document.uuid })" @draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })"
/> />
</div> </div>
</template> </template>
<script> <script>
import Page from './page' import Page from './page'
import { reactive } from 'vue'
export default { export default {
name: 'TemplateDocument', name: 'TemplateDocument',
@@ -65,6 +67,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
withSignatureId: {
type: Boolean,
required: false,
default: null
},
drawFieldType: { drawFieldType: {
type: String, type: String,
required: false, required: false,
@@ -123,14 +130,14 @@ export default {
return this.document.metadata?.pdf?.number_of_pages || this.document.preview_images.length return this.document.metadata?.pdf?.number_of_pages || this.document.preview_images.length
}, },
sortedPreviewImages () { sortedPreviewImages () {
const lazyloadMetadata = this.document.preview_images[this.document.preview_images.length - 1].metadata const lazyloadMetadata = this.document.preview_images[this.document.preview_images.length - 1]?.metadata || { width: 1400, height: 1812 }
return [...Array(this.numberOfPages).keys()].map((i) => { return [...Array(this.numberOfPages).keys()].map((i) => {
return this.previewImagesIndex[i] || { return this.previewImagesIndex[i] || reactive({
metadata: lazyloadMetadata, metadata: { ...lazyloadMetadata },
id: Math.random().toString(), id: Math.random().toString(),
url: this.basePreviewUrl + `/preview/${this.document.signed_uuid || this.document.uuid}/${i}.jpg` url: this.basePreviewUrl + `/preview/${this.document.signed_uuid || this.document.uuid}/${i}.jpg`
} })
}) })
}, },
previewImagesIndex () { previewImagesIndex () {
@@ -125,6 +125,7 @@
:field="field" :field="field"
:default-field="defaultField" :default-field="defaultField"
:editable="editable" :editable="editable"
:with-signature-id="withSignatureId"
:background-color="dropdownBgColor" :background-color="dropdownBgColor"
@click-formula="isShowFormulaModal = true" @click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true" @click-font="isShowFontModal = true"
@@ -302,6 +303,11 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
withSignatureId: {
type: Boolean,
required: false,
default: null
},
withOptions: { withOptions: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -153,13 +153,13 @@
<option <option
v-for="(key, value) in validations" v-for="(key, value) in validations"
:key="key" :key="key"
:selected="field.validation?.pattern ? value === field.validation.pattern : value === 'none'" :selected="lengthValidation ? key == 'length' : (field.validation?.pattern ? value === field.validation.pattern : key === 'none')"
:value="value" :value="key"
> >
{{ t(key) }} {{ t(key) }}
</option> </option>
<option <option
:selected="field.validation && !validations[field.validation.pattern]" :selected="field.validation && !validations[field.validation.pattern] && !lengthValidation"
:value="validations[field.validation?.pattern] || !field.validation?.pattern ? 'custom' : field.validation?.pattern" :value="validations[field.validation?.pattern] || !field.validation?.pattern ? 'custom' : field.validation?.pattern"
> >
{{ t('custom') }} {{ t('custom') }}
@@ -174,7 +174,51 @@
</label> </label>
</div> </div>
<div <div
v-if="['text', 'cells'].includes(field.type) && field.validation && !validations[field.validation.pattern]" v-if="['text', 'cells'].includes(field.type) && field.validation && lengthValidation"
class="py-1.5 px-1 relative flex space-x-1"
@click.stop
>
<div class="w-1/2 relative">
<input
:placeholder="t('min')"
type="number"
min="0"
:value="lengthValidation.min"
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
@input="field.validation.pattern = `.{${$event.target.value || 0},${lengthValidation.max || ''}}`"
@blur="save"
>
<label
v-if="lengthValidation.min"
:style="{ backgroundColor }"
class="absolute -top-2.5 left-1.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('min') }}
</label>
</div>
<div class="w-1/2 relative">
<input
:placeholder="t('max')"
type="number"
min="1"
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
:value="lengthValidation.max"
@input="field.validation.pattern = `.{${lengthValidation.min},${$event.target.value || ''}}`"
@blur="save"
>
<label
v-if="lengthValidation.max"
:style="{ backgroundColor }"
class="absolute -top-2.5 left-1.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('max') }}
</label>
</div>
</div>
<div
v-if="['text', 'cells'].includes(field.type) && field.validation && !validations[field.validation.pattern] && !lengthValidation"
class="py-1.5 px-1 relative" class="py-1.5 px-1 relative"
@click.stop @click.stop
> >
@@ -195,6 +239,27 @@
{{ t('regexp_validation') }} {{ t('regexp_validation') }}
</label> </label>
</div> </div>
<div
v-if="['text', 'cells'].includes(field.type) && field.validation && !validations[field.validation.pattern] && !lengthValidation"
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="field.validation.message"
:placeholder="t('error_message')"
dir="auto"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
>
<label
v-if="field.validation.message"
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('error_message') }}
</label>
</div>
<div <div
v-if="field.type === 'date'" v-if="field.type === 'date'"
class="py-1.5 px-1 relative" class="py-1.5 px-1 relative"
@@ -255,6 +320,21 @@
{{ t('format') }} {{ t('format') }}
</label> </label>
</div> </div>
<li
v-if="[true, false].includes(withSignatureId) && field.type === 'signature'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
:checked="field.preferences?.with_signature_id"
type="checkbox"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.required))"
class="toggle toggle-xs"
@change="[field.preferences ||= {}, field.preferences.with_signature_id = $event.target.checked, save()]"
>
<span class="label-text">{{ t('signature_id') }}</span>
</label>
</li>
<li <li
v-if="withRequired && field.type !== 'phone' && field.type !== 'stamp' && field.type !== 'verification'" v-if="withRequired && field.type !== 'phone' && field.type !== 'stamp' && field.type !== 'verification'"
@click.stop @click.stop
@@ -330,7 +410,7 @@
v-if="field.type != 'stamp'" v-if="field.type != 'stamp'"
class="pb-0.5 mt-0.5" class="pb-0.5 mt-0.5"
> >
<li v-if="['text', 'number', 'date'].includes(field.type)"> <li v-if="['text', 'number', 'date', 'select'].includes(field.type)">
<label <label
class="label-text cursor-pointer text-center w-full flex items-center" class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-font')" @click="$emit('click-font')"
@@ -424,7 +504,7 @@
</a> </a>
</li> </li>
</template> </template>
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells'].includes(field.type)"> <li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)">
<a <a
href="#" href="#"
class="text-sm py-1 px-2" class="text-sm py-1 px-2"
@@ -460,6 +540,11 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
withSignatureId: {
type: Boolean,
required: false,
default: null
},
backgroundColor: { backgroundColor: {
type: String, type: String,
required: false, required: false,
@@ -489,6 +574,7 @@ export default {
emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition', 'click-font', 'remove-area'], emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition', 'click-font', 'remove-area'],
data () { data () {
return { return {
selectedValidation: ''
} }
}, },
computed: { computed: {
@@ -533,8 +619,16 @@ export default {
return formats return formats
}, },
lengthValidation () {
if (this.field.validation?.pattern && this.selectedValidation !== 'custom') {
return this.field.validation.pattern.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups
} else {
return null
}
},
validations () { validations () {
return { return {
'.{0,}': 'length',
'^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'ssn', '^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'ssn',
'^[0-9]{2}-[0-9]{7}$': 'ein', '^[0-9]{2}-[0-9]{7}$': 'ein',
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'email', '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'email',
@@ -553,13 +647,21 @@ export default {
methods: { methods: {
onChangeValidation (event) { onChangeValidation (event) {
if (event.target.value === 'custom') { if (event.target.value === 'custom') {
this.field.validation = { pattern: '' } this.selectedValidation = 'custom'
this.field.validation = { pattern: '', message: '' }
this.$nextTick(() => this.$refs.validationCustom.focus()) this.$nextTick(() => this.$refs.validationCustom.focus())
} else if (event.target.value) { } else if (event.target.value) {
this.field.validation ||= {} this.field.validation ||= {}
this.field.validation.pattern = event.target.value this.field.validation.pattern =
Object.keys(this.validations).find(key => this.validations[key] === event.target.value)
this.selectedValidation = event.target.value
delete this.field.validation.message
} else { } else {
this.selectedValidation = ''
delete this.field.validation delete this.field.validation
} }
@@ -51,7 +51,7 @@
</template> </template>
<script> <script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconHeading, IconId } from '@tabler/icons-vue' import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconHeading, IconId, IconCalendarCheck } from '@tabler/icons-vue'
export default { export default {
name: 'FiledTypeDropdown', name: 'FiledTypeDropdown',
@@ -101,6 +101,7 @@ export default {
signature: this.t('signature'), signature: this.t('signature'),
initials: this.t('initials'), initials: this.t('initials'),
date: this.t('date'), date: this.t('date'),
datenow: this.t('date_signed'),
number: this.t('number'), number: this.t('number'),
image: this.t('image'), image: this.t('image'),
file: this.t('file'), file: this.t('file'),
@@ -142,6 +143,7 @@ export default {
signature: IconWritingSign, signature: IconWritingSign,
initials: IconLetterCaseUpper, initials: IconLetterCaseUpper,
date: IconCalendarEvent, date: IconCalendarEvent,
datenow: IconCalendarCheck,
number: IconSquareNumber1, number: IconSquareNumber1,
image: IconPhoto, image: IconPhoto,
checkbox: IconCheckbox, checkbox: IconCheckbox,
@@ -164,7 +166,7 @@ export default {
return acc return acc
}, {}) }, {})
} else { } else {
return Object.fromEntries(Object.entries(this.fieldIcons).filter(([key]) => key !== 'heading')) return Object.fromEntries(Object.entries(this.fieldIcons).filter(([key]) => key !== 'heading' && key !== 'datenow'))
} }
} }
}, },
+7 -1
View File
@@ -26,6 +26,7 @@
:field="field" :field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)" :type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
:editable="editable" :editable="editable"
:with-signature-id="withSignatureId"
:default-field="defaultFieldsIndex[field.name]" :default-field="defaultFieldsIndex[field.name]"
:draggable="editable" :draggable="editable"
@dragstart="[fieldsDragFieldRef.value = field, removeDragOverlay($event), setDragPlaceholder($event)]" @dragstart="[fieldsDragFieldRef.value = field, removeDragOverlay($event), setDragPlaceholder($event)]"
@@ -253,6 +254,11 @@ export default {
required: false, required: false,
default: null default: null
}, },
withSignatureId: {
type: Boolean,
required: false,
default: null
},
template: { template: {
type: Object, type: Object,
required: true required: true
@@ -347,7 +353,7 @@ export default {
return acc return acc
}, {}) }, {})
} else { } else {
return Object.fromEntries(Object.entries(this.fieldIcons).filter(([key]) => key !== 'heading')) return Object.fromEntries(Object.entries(this.fieldIcons).filter(([key]) => key !== 'heading' && key !== 'datenow'))
} }
}, },
submitterFields () { submitterFields () {
@@ -163,7 +163,7 @@
class="flex border border-base-content/20 rounded-xl bg-white px-4 h-16 modal-field-font-preview" class="flex border border-base-content/20 rounded-xl bg-white px-4 h-16 modal-field-font-preview"
:style="{ :style="{
color: preferences.color || 'black', color: preferences.color || 'black',
fontSize: (preferences.font_size || 12) + 'pt', fontSize: (preferences.font_size || 11) + 'pt',
}" }"
:class="textClasses" :class="textClasses"
> >
@@ -222,8 +222,8 @@ export default {
fonts () { fonts () {
return [ return [
{ value: null, label: 'Default' }, { value: null, label: 'Default' },
{ value: 'Times', label: 'Times', class: 'font-serif' }, { value: 'Times', label: 'Times', class: 'font-times' },
{ value: 'Courier', label: 'Courier', class: 'font-mono' } { value: 'Courier', label: 'Courier', class: 'font-courier' }
] ]
}, },
types () { types () {
@@ -258,8 +258,8 @@ export default {
}, },
textClasses () { textClasses () {
return { return {
'font-mono': this.preferences.font === 'Courier', 'font-courier': this.preferences.font === 'Courier',
'font-serif': this.preferences.font === 'Times', 'font-times': this.preferences.font === 'Times',
'justify-center': this.preferences.align === 'center', 'justify-center': this.preferences.align === 'center',
'justify-start': this.preferences.align === 'left', 'justify-start': this.preferences.align === 'left',
'justify-end': this.preferences.align === 'right', 'justify-end': this.preferences.align === 'right',
+36
View File
@@ -1,6 +1,12 @@
const en = { const en = {
signature_id: 'Signature ID',
error_message: 'Error message',
length: 'Length',
min: 'Min',
max: 'Max',
font: 'Font', font: 'Font',
party: 'Party', party: 'Party',
date_signed: 'Date Signed',
method: 'Method', method: 'Method',
reorder_fields: 'Reorder fields', reorder_fields: 'Reorder fields',
verify_id: 'Verify ID', verify_id: 'Verify ID',
@@ -167,6 +173,12 @@ const en = {
} }
const es = { const es = {
signature_id: 'ID de Firma',
error_message: 'Mensaje de error',
length: 'Longitud',
min: 'Mín',
max: 'Máx',
date_signed: 'Fecha actual',
fuente: 'Fuente', fuente: 'Fuente',
party: 'Parte', party: 'Parte',
method: 'Método', method: 'Método',
@@ -335,6 +347,12 @@ const es = {
} }
const it = { const it = {
signature_id: 'ID firma',
error_message: 'Messaggio di errore',
length: 'Lunghezza',
min: 'Min',
max: 'Max',
date_signed: 'Data attuale',
font: 'Carattere', font: 'Carattere',
party: 'Parte', party: 'Parte',
method: 'Metodo', method: 'Metodo',
@@ -503,6 +521,12 @@ const it = {
} }
const pt = { const pt = {
signature_id: 'ID da Assinatura',
error_message: 'Mensagem de erro',
length: 'Comprimento',
min: 'Mín',
max: 'Máx',
date_signed: 'Data atual',
fonte: 'Fonte', fonte: 'Fonte',
party: 'Parte', party: 'Parte',
method: 'Método', method: 'Método',
@@ -671,6 +695,12 @@ const pt = {
} }
const fr = { const fr = {
signature_id: 'ID de signature',
error_message: 'Message d\'erreur',
length: 'Longueur',
min: 'Min',
max: 'Max',
date_signed: 'Date actuelle',
font: 'Police', font: 'Police',
party: 'Partie', party: 'Partie',
method: 'Méthode', method: 'Méthode',
@@ -839,6 +869,12 @@ const fr = {
} }
const de = { const de = {
signature_id: 'Signatur-ID',
error_message: 'Fehlermeldung',
length: 'Länge',
min: 'Min',
max: 'Max',
date_now: 'Akt. Datum',
font: 'Schriftart', font: 'Schriftart',
party: 'Partei', party: 'Partei',
method: 'Verfahren', method: 'Verfahren',
@@ -406,7 +406,7 @@ export default {
this.mappings.every((m) => m.column_index !== index) this.mappings.every((m) => m.column_index !== index)
}) })
if (columnIndex !== -1) { if (columnIndex !== -1 && this.rows.some((row) => row[columnIndex])) {
this.mappings.push({ uuid: v4(), field_name: field.name, column_index: columnIndex, submitter_uuid: submitter.uuid }) this.mappings.push({ uuid: v4(), field_name: field.name, column_index: columnIndex, submitter_uuid: submitter.uuid })
} }
}) })
+19 -7
View File
@@ -1,8 +1,9 @@
<template> <template>
<div <div
class="relative select-none" class="relative select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute"
:class="{ 'cursor-crosshair': allowDraw }" :class="{ 'cursor-crosshair': allowDraw, 'touch-none': !!drawField }"
:style="drawField ? 'touch-action: none' : ''" style="container-type: size"
:style="{ aspectRatio: `${width} / ${height}`}"
> >
<img <img
ref="image" ref="image"
@@ -10,7 +11,7 @@
:src="image.url" :src="image.url"
:width="width" :width="width"
:height="height" :height="height"
class="border rounded mb-4" class="rounded"
@load="onImageLoad" @load="onImageLoad"
> >
<div <div
@@ -26,6 +27,7 @@
:field="item.field" :field="item.field"
:editable="editable" :editable="editable"
:with-field-placeholder="withFieldPlaceholder" :with-field-placeholder="withFieldPlaceholder"
:with-signature-id="withSignatureId"
:default-field="defaultFieldsIndex[item.field.name]" :default-field="defaultFieldsIndex[item.field.name]"
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:max-page="totalPages - 1" :max-page="totalPages - 1"
@@ -77,6 +79,11 @@ export default {
required: false, required: false,
default: null default: null
}, },
withSignatureId: {
type: Boolean,
required: false,
default: null
},
areas: { areas: {
type: Array, type: Array,
required: false, required: false,
@@ -191,8 +198,8 @@ export default {
}, },
methods: { methods: {
onImageLoad (e) { onImageLoad (e) {
e.target.setAttribute('width', e.target.naturalWidth) this.image.metadata.width = e.target.naturalWidth
e.target.setAttribute('height', e.target.naturalHeight) this.image.metadata.height = e.target.naturalHeight
}, },
setAreaRefs (el) { setAreaRefs (el) {
if (el) { if (el) {
@@ -290,7 +297,12 @@ export default {
area.cell_w = this.newArea.cell_w area.cell_w = this.newArea.cell_w
} }
this.$emit('draw', area) const dx = Math.abs(e.offsetX - this.$refs.mask.clientWidth * this.newArea.initialX)
const dy = Math.abs(e.offsetY - this.$refs.mask.clientHeight * this.newArea.initialY)
const isTooSmall = dx < 8 && dy < 8
this.$emit('draw', { area, isTooSmall })
} }
this.showMask = false this.showMask = false
@@ -0,0 +1,21 @@
# frozen_string_literal: true
class GenerateAttachmentPreviewJob
include Sidekiq::Job
InvalidFormat = Class.new(StandardError)
sidekiq_options queue: :images
def perform(params = {})
attachment = ActiveStorage::Attachment.find(params['attachment_id'])
if attachment.content_type == Templates::ProcessDocument::PDF_CONTENT_TYPE
Templates::ProcessDocument.generate_pdf_preview_images(attachment, attachment.download)
elsif attachment.image?
Templates::ProcessDocument.generate_preview_image(attachment, attachment.download)
else
raise InvalidFormat, attachment.id
end
end
end
+2 -5
View File
@@ -7,13 +7,10 @@ class ProcessSubmissionExpiredJob
submission = Submission.find(params['submission_id']) submission = Submission.find(params['submission_id'])
return if submission.archived_at? return if submission.archived_at?
return if submission.template.archived_at? return if submission.template&.archived_at?
return if submission.submitters.where.not(declined_at: nil).exists? return if submission.submitters.where.not(declined_at: nil).exists?
return unless submission.submitters.exists?(completed_at: nil) return unless submission.submitters.exists?(completed_at: nil)
WebhookUrls.for_account_id(submission.account_id, %w[submission.expired]).each do |webhook| WebhookUrls.enqueue_events(submission, 'submission.expired')
SendSubmissionExpiredWebhookRequestJob.perform_async('submission_id' => submission.id,
'webhook_url_id' => webhook.id)
end
end end
end end
+15 -7
View File
@@ -63,16 +63,24 @@ class ProcessSubmitterCompletionJob
end end
def enqueue_completed_webhooks(submitter, is_all_completed: false) def enqueue_completed_webhooks(submitter, is_all_completed: false)
event_uuids = {}
WebhookUrls.for_account_id(submitter.account_id, %w[form.completed submission.completed]).each do |webhook| WebhookUrls.for_account_id(submitter.account_id, %w[form.completed submission.completed]).each do |webhook|
if webhook.events.include?('form.completed') if webhook.events.include?('form.completed')
event_uuids['form.completed'] ||= SecureRandom.uuid
SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id, SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
'event_uuid' => event_uuids['form.completed'],
'webhook_url_id' => webhook.id) 'webhook_url_id' => webhook.id)
end end
if webhook.events.include?('submission.completed') && is_all_completed next unless webhook.events.include?('submission.completed') && is_all_completed
SendSubmissionCompletedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
'webhook_url_id' => webhook.id) event_uuids['submission.completed'] ||= SecureRandom.uuid
end
SendSubmissionCompletedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
'event_uuid' => event_uuids['submission.completed'],
'webhook_url_id' => webhook.id)
end end
end end
@@ -82,7 +90,7 @@ class ProcessSubmitterCompletionJob
user = submission.created_by_user || submitter.template.author user = submission.created_by_user || submitter.template.author
if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false && if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false &&
submitter.template.preferences['completed_notification_email_enabled'] != false submitter.template&.preferences&.dig('completed_notification_email_enabled') != false
if submission.submitters.map(&:email).exclude?(user.email) && if submission.submitters.map(&:email).exclude?(user.email) &&
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false && user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false &&
user.role != 'integration' user.role != 'integration'
@@ -98,7 +106,7 @@ class ProcessSubmitterCompletionJob
end end
def maybe_enqueue_copy_emails(submitter) def maybe_enqueue_copy_emails(submitter)
return if submitter.template.preferences['documents_copy_email_enabled'] == false return if submitter.template&.preferences&.dig('documents_copy_email_enabled') == false
configs = AccountConfigs.find_or_initialize_for_key(submitter.account, configs = AccountConfigs.find_or_initialize_for_key(submitter.account,
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY) AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
@@ -119,7 +127,7 @@ class ProcessSubmitterCompletionJob
def build_bcc_addresses(submission) def build_bcc_addresses(submission)
bcc = submission.preferences['bcc_completed'].presence || bcc = submission.preferences['bcc_completed'].presence ||
submission.template.preferences['bcc_completed'].presence || submission.template&.preferences&.dig('bcc_completed').presence ||
submission.account.account_configs submission.account.account_configs
.find_by(key: AccountConfig::BCC_EMAILS)&.value .find_by(key: AccountConfig::BCC_EMAILS)&.value
@@ -0,0 +1,9 @@
# frozen_string_literal: true
class ReindexAllSearchEntriesJob
include Sidekiq::Job
def perform
SearchEntries.reindex_all
end
end
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
class ReindexSearchEntryJob
include Sidekiq::Job
InvalidFormat = Class.new(StandardError)
def perform(params = {})
entry = SearchEntry.find_or_initialize_by(params.slice('record_type', 'record_id'))
SearchEntries.reindex_record(entry.record)
end
end
@@ -20,6 +20,9 @@ class SendFormCompletedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.completed', resp = SendWebhookRequest.call(webhook_url, event_type: 'form.completed',
event_uuid: params['event_uuid'],
record: submitter,
attempt:,
data: Submitters::SerializeForWebhook.call(submitter)) data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
@@ -18,13 +18,15 @@ class SendFormDeclinedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.declined', resp = SendWebhookRequest.call(webhook_url, event_type: 'form.declined',
event_uuid: params['event_uuid'],
record: submitter,
attempt:,
data: Submitters::SerializeForWebhook.call(submitter)) data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, { SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submitter_id' => submitter.id, **params,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
@@ -18,13 +18,15 @@ class SendFormStartedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.started', resp = SendWebhookRequest.call(webhook_url, event_type: 'form.started',
event_uuid: params['event_uuid'],
record: submitter,
attempt:,
data: Submitters::SerializeForWebhook.call(submitter)) data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, { SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submitter_id' => submitter.id, **params,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
@@ -18,13 +18,15 @@ class SendFormViewedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.viewed', resp = SendWebhookRequest.call(webhook_url, event_type: 'form.viewed',
event_uuid: params['event_uuid'],
record: submitter,
attempt:,
data: Submitters::SerializeForWebhook.call(submitter)) data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, { SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submitter_id' => submitter.id, **params,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
@@ -16,13 +16,15 @@ class SendSubmissionArchivedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.archived') return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.archived')
resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.archived', resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.archived',
event_uuid: params['event_uuid'],
record: submission,
attempt:,
data: submission.as_json(only: %i[id archived_at])) data: submission.as_json(only: %i[id archived_at]))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, { SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submission_id' => submission.id, **params,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
@@ -16,6 +16,9 @@ class SendSubmissionCompletedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.completed') return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.completed')
resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.completed', resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.completed',
event_uuid: params['event_uuid'],
record: submission,
attempt:,
data: Submissions::SerializeForApi.call(submission)) data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
@@ -16,13 +16,15 @@ class SendSubmissionCreatedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.created') return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.created')
resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.created', resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.created',
event_uuid: params['event_uuid'],
record: submission,
attempt:,
data: Submissions::SerializeForApi.call(submission)) data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, { SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submission_id' => submission.id, **params,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
@@ -16,13 +16,15 @@ class SendSubmissionExpiredWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.expired') return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.expired')
resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.expired', resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.expired',
event_uuid: params['event_uuid'],
record: submission,
attempt:,
data: Submissions::SerializeForApi.call(submission)) data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionExpiredWebhookRequestJob.perform_in((2**attempt).minutes, { SendSubmissionExpiredWebhookRequestJob.perform_in((2**attempt).minutes, {
'submission_id' => submission.id, **params,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
@@ -16,13 +16,15 @@ class SendTemplateCreatedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.created') return if webhook_url.url.blank? || webhook_url.events.exclude?('template.created')
resp = SendWebhookRequest.call(webhook_url, event_type: 'template.created', resp = SendWebhookRequest.call(webhook_url, event_type: 'template.created',
event_uuid: params['event_uuid'],
record: template,
attempt:,
data: Templates::SerializeForApi.call(template)) data: Templates::SerializeForApi.call(template))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, { SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
'template_id' => template.id, **params,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
@@ -16,13 +16,15 @@ class SendTemplateUpdatedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.updated') return if webhook_url.url.blank? || webhook_url.events.exclude?('template.updated')
resp = SendWebhookRequest.call(webhook_url, event_type: 'template.updated', resp = SendWebhookRequest.call(webhook_url, event_type: 'template.updated',
event_uuid: params['event_uuid'],
record: template,
attempt:,
data: Templates::SerializeForApi.call(template)) data: Templates::SerializeForApi.call(template))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, { SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, {
'template_id' => template.id, **params,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
+26
View File
@@ -0,0 +1,26 @@
# frozen_string_literal: true
class SendTestWebhookRequestJob
include Sidekiq::Job
sidekiq_options retry: 0
USER_AGENT = 'DocuSeal.com Webhook'
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
return unless webhook_url && submitter
Faraday.post(webhook_url.url,
{
event_type: 'form.completed',
timestamp: Time.current.iso8601,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT,
**webhook_url.secret.to_h)
end
end
Loaded 100 of 303 files, more files were not shown because too many files have changed in this diff. Show more