Compare commits

...

155 Commits

Author SHA1 Message Date
Alex Turchyn 21299dc65f Merge pull from docusealco/wip 2025-08-25 12:37:42 +03:00
Pete Matsyburka 0df0946c2a fix markdown 2025-08-25 10:11:36 +03:00
Pete Matsyburka 1bb38d50a1 adjust formula font 2025-08-24 18:16:43 +03:00
Pete Matsyburka 242987cb41 icon size 2025-08-24 14:25:36 +03:00
Pete Matsyburka 5456abde54 adjust date input 2025-08-24 10:08:47 +03:00
Pete Matsyburka 4a07427440 adjust paste radio 2025-08-24 09:27:51 +03:00
Pete Matsyburka cf60dae2ff fix modal title 2025-08-23 21:46:51 +03:00
Pete Matsyburka 659997c6c1 add 2 options by default 2025-08-23 21:33:46 +03:00
Pete Matsyburka 389a47a152 expand options 2025-08-23 21:16:07 +03:00
Pete Matsyburka b6b81ca487 editable readonly custom field 2025-08-23 20:52:57 +03:00
Pete Matsyburka 719c1786f1 set default value 2025-08-23 20:16:14 +03:00
Pete Matsyburka 4685bac4b3 fix serializers 2025-08-23 11:28:26 +03:00
Pete Matsyburka a05275cc08 update json 2025-08-23 10:41:49 +03:00
Pete Matsyburka 1abf1b1658 fix formula 2025-08-22 18:11:09 +03:00
Pete Matsyburka 8893dc5698 pwa orientation any 2025-08-22 18:11:05 +03:00
Pete Matsyburka f8467b2da1 nested formula 2025-08-21 14:36:49 +03:00
Pete Matsyburka f39b8b000e use csv safe 2025-08-21 13:58:53 +03:00
Pete Matsyburka 961036fa31 update widget 2025-08-21 10:22:06 +03:00
Pete Matsyburka 8b9056894e sanitize url 2025-08-20 21:31:15 +03:00
Pete Matsyburka 7f979e9396 add number validation 2025-08-20 18:32:20 +03:00
Pete Matsyburka 8db1192d0e update hexapdf 2025-08-19 16:34:59 +03:00
Pete Matsyburka 3bd515951a stamp without completed 2025-08-19 12:40:45 +03:00
Pete Matsyburka 2bd5b9676b add with verification 2025-08-19 12:25:23 +03:00
Alex Turchyn 993698ec01 Merge from docusealco/wip 2025-08-18 12:33:13 +03:00
Pete Matsyburka fcdc44ddbe update rails 2025-08-14 09:44:04 +03:00
Pete Matsyburka 0cb6b0ae12 fix zip 2025-08-13 11:18:49 +03:00
Pete Matsyburka 0388927c5b fix readonly date 2025-08-12 22:33:38 +03:00
Pete Matsyburka 2c8f5d2129 debug file 2025-08-12 20:01:52 +03:00
Pete Matsyburka 2bffbf83c6 handle zip upload 2025-08-12 13:07:16 +03:00
Pete Matsyburka ec01529014 show readonly fields 2025-08-11 16:58:14 +03:00
Pete Matsyburka ea1abf5662 fix test email 2025-08-08 16:33:19 +03:00
Pete Matsyburka fcd5263af1 add hour minute variable 2025-08-07 11:33:11 +03:00
Pete Matsyburka 1fa8616dbc fix submitters order 2025-08-04 14:46:46 +03:00
Alex Turchyn f627277b45 Merge from docusealco/wip 2025-08-04 12:59:10 +03:00
Pete Matsyburka 809c3ea270 fix template 2fa email 2025-07-31 19:23:27 +03:00
Pete Matsyburka 9caa9d79d4 fix tooltip 2025-07-31 13:32:28 +03:00
Pete Matsyburka a8f1c1c323 add new signature formats 2025-07-31 13:13:31 +03:00
Pete Matsyburka 59fbea5c5f fix migration 2025-07-30 18:45:28 +03:00
Pete Matsyburka d3273d879a add order ui 2025-07-30 10:22:38 +03:00
Pete Matsyburka ea227e82c9 force smtp auto 2025-07-29 22:49:20 +03:00
Pete Matsyburka 676b330aa3 add submitters order param 2025-07-29 17:47:47 +03:00
Pete Matsyburka 8d3295e125 better signature validation 2025-07-29 13:51:11 +03:00
Pete Matsyburka 8cb74e0bcc fix build 2025-07-28 16:55:21 +03:00
Alex Turchyn 26749a4dbc Merge from docusealco/wip 2025-07-28 12:44:35 +03:00
Pete Matsyburka 14915d37b4 fix build 2025-07-28 09:02:17 +03:00
Pete Matsyburka 16d6d58efb fix typo 2025-07-27 10:42:56 +03:00
Pete Matsyburka b2b97a313a prefillable fields 2025-07-27 10:27:12 +03:00
Pete Matsyburka 2974e6a64f remove unused posthog 2025-07-25 09:41:24 +03:00
Pete Matsyburka dae34d0aa5 remove multiple signatures toggle 2025-07-24 15:13:39 +03:00
Alex Turchyn e9d0671184 remove duplicate translations 2025-07-24 14:39:28 +03:00
Alex Turchyn 897e33bf6a simplify shared links modal UI 2025-07-24 10:13:34 +03:00
Alex Turchyn f37e93a409 remove duplicate translations 2025-07-24 09:43:17 +03:00
Alex Turchyn 561ac63c93 email editor 2025-07-24 09:41:57 +03:00
Pete Matsyburka 149e4be07a do not send invitation if completed 2025-07-23 10:20:21 +03:00
Pete Matsyburka eb98dc9e06 adjust folder form 2025-07-22 15:18:48 +03:00
Pete Matsyburka ce7d965358 update 2025-07-22 10:24:40 +03:00
Pete Matsyburka bb73024859 folder name to string 2025-07-22 09:49:58 +03:00
Pete Matsyburka 631a442e2a fix builder file drop modal close 2025-07-21 16:43:03 +03:00
Pete Matsyburka 20a082ae8e adjust image field validation 2025-07-21 16:26:08 +03:00
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
289 changed files with 19769 additions and 1337 deletions
+5
View File
@@ -6,6 +6,7 @@ jobs:
rubocop:
name: Rubocop
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install Ruby
@@ -30,6 +31,7 @@ jobs:
erblint:
name: Erblint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install Ruby
@@ -54,6 +56,7 @@ jobs:
eslint:
name: ESLint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install Node.js
@@ -80,6 +83,7 @@ jobs:
brakeman:
name: Brakeman
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install Ruby
@@ -107,6 +111,7 @@ jobs:
rspec:
name: RSpec
runs-on: ubuntu-latest
timeout-minutes: 10
services:
postgres:
+25 -11
View File
@@ -1,13 +1,27 @@
name: Build Docker Images
on:
push:
tags:
- "*.*.*"
workflow_dispatch:
inputs:
version:
description: Version
type: string
required: true
image:
description: QEMU image
type: string
required: false
default: tonistiigi/binfmt:latest
os:
description: OS
type: string
required: false
default: ubuntu-24.04-arm
jobs:
build:
runs-on: ubuntu-24.04-arm
runs-on: ${{ inputs.os }}
timeout-minutes: 20
steps:
- name: Checkout code
@@ -15,23 +29,23 @@ jobs:
with:
submodules: recursive
-
name: Docker meta
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
docuseal/docuseal
tags: |
type=semver,pattern={{version}}
images: docuseal/docuseal
tags: latest,${{ inputs.version }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: ${{ inputs.image }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create .version file
run: echo ${{ github.ref_name }} > .version
run: echo ${{ inputs.version }} > .version
- name: Login to Docker Hub
uses: docker/login-action@v3
+2 -2
View File
@@ -63,10 +63,10 @@ RSpec/MultipleExpectations:
Max: 25
RSpec/ExampleLength:
Max: 50
Max: 500
RSpec/MultipleMemoizedHelpers:
Max: 9
Max: 15
Metrics/BlockNesting:
Max: 5
+1 -1
View File
@@ -50,7 +50,7 @@ ENV OPENSSL_CONF=/app/openssl_legacy.cnf
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@edge redis libheif@edge vips-heif@edge 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 yaml-dev 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\
\n\
+2
View File
@@ -11,6 +11,7 @@ gem 'azure-storage-blob', require: false
gem 'bootsnap', require: false
gem 'cancancan'
gem 'csv'
gem 'csv-safe'
gem 'devise'
gem 'devise-two-factor'
gem 'dotenv', require: false
@@ -34,6 +35,7 @@ gem 'rails'
gem 'rails_autolink'
gem 'rails-i18n'
gem 'rotp'
gem 'rouge', require: false
gem 'rqrcode'
gem 'ruby-vips'
gem 'rubyXL'
+105 -90
View File
@@ -1,29 +1,29 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (8.0.1)
actionpack (= 8.0.1)
activesupport (= 8.0.1)
actioncable (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.0.1)
actionpack (= 8.0.1)
activejob (= 8.0.1)
activerecord (= 8.0.1)
activestorage (= 8.0.1)
activesupport (= 8.0.1)
actionmailbox (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
mail (>= 2.8.0)
actionmailer (8.0.1)
actionpack (= 8.0.1)
actionview (= 8.0.1)
activejob (= 8.0.1)
activesupport (= 8.0.1)
actionmailer (8.0.2.1)
actionpack (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activesupport (= 8.0.2.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.1)
actionview (= 8.0.1)
activesupport (= 8.0.1)
actionpack (8.0.2.1)
actionview (= 8.0.2.1)
activesupport (= 8.0.2.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -31,35 +31,35 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.0.1)
actionpack (= 8.0.1)
activerecord (= 8.0.1)
activestorage (= 8.0.1)
activesupport (= 8.0.1)
actiontext (8.0.2.1)
actionpack (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.1)
activesupport (= 8.0.1)
actionview (8.0.2.1)
activesupport (= 8.0.2.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.0.1)
activesupport (= 8.0.1)
activejob (8.0.2.1)
activesupport (= 8.0.2.1)
globalid (>= 0.3.6)
activemodel (8.0.1)
activesupport (= 8.0.1)
activerecord (8.0.1)
activemodel (= 8.0.1)
activesupport (= 8.0.1)
activemodel (8.0.2.1)
activesupport (= 8.0.2.1)
activerecord (8.0.2.1)
activemodel (= 8.0.2.1)
activesupport (= 8.0.2.1)
timeout (>= 0.4.0)
activestorage (8.0.1)
actionpack (= 8.0.1)
activejob (= 8.0.1)
activerecord (= 8.0.1)
activesupport (= 8.0.1)
activestorage (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activesupport (= 8.0.2.1)
marcel (~> 1.0)
activesupport (8.0.1)
activesupport (8.0.2.1)
base64
benchmark (>= 0.3)
bigdecimal
@@ -104,9 +104,9 @@ GEM
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
base64 (0.2.0)
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.4.0)
benchmark (0.4.1)
better_html (2.1.1)
actionview (>= 6.0)
activesupport (>= 6.0)
@@ -114,7 +114,7 @@ GEM
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
bigdecimal (3.1.8)
bigdecimal (3.2.2)
bindex (0.8.1)
bootsnap (1.18.4)
msgpack (~> 1.2)
@@ -141,8 +141,8 @@ GEM
cldr-plurals-runtime-rb (1.1.0)
cmdparse (3.0.7)
coderay (1.1.3)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
crack (1.0.0)
bigdecimal
rexml
@@ -150,6 +150,8 @@ GEM
css_parser (1.21.0)
addressable
csv (3.3.2)
csv-safe (3.3.1)
csv (~> 3.0)
cuprite (0.15.1)
capybara (~> 3.0)
ferrum (~> 0.15.0)
@@ -174,8 +176,9 @@ GEM
rake (>= 12.0.0, < 14.0.0)
docile (1.4.1)
dotenv (3.1.7)
drb (2.2.1)
drb (2.2.3)
email_typo (0.2.3)
erb (5.0.2)
erb_lint (0.7.0)
activesupport
better_html (>= 2.0.1)
@@ -270,23 +273,25 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
hashdiff (1.1.2)
hexapdf (1.0.3)
hexapdf (1.4.0)
cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1)
strscan (>= 3.1.2)
htmlentities (4.3.4)
httpclient (2.8.3)
i18n (1.14.6)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
image_processing (1.13.0)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.0)
irb (1.14.3)
io-console (0.8.1)
irb (1.15.2)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.9.1)
json (2.13.2)
jwt (2.9.3)
base64
language_server-protocol (3.17.0.3)
@@ -300,13 +305,13 @@ GEM
letter_opener (~> 1.9)
railties (>= 6.1)
rexml
logger (1.6.4)
logger (1.7.0)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.23.1)
loofah (2.24.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -319,8 +324,8 @@ GEM
method_source (1.1.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
mini_portile2 (2.8.8)
minitest (5.25.4)
mini_portile2 (2.8.9)
minitest (5.25.5)
msgpack (1.7.5)
multi_json (1.15.0)
multipart-post (2.4.1)
@@ -328,36 +333,36 @@ GEM
mysql2 (0.5.6)
net-http-persistent (4.0.5)
connection_pool (~> 2.2)
net-imap (0.5.6)
net-imap (0.5.9)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.5.0)
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.8)
nokogiri (1.18.9)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.8-aarch64-linux-gnu)
nokogiri (1.18.9-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.8-aarch64-linux-musl)
nokogiri (1.18.9-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.8-arm64-darwin)
nokogiri (1.18.9-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-gnu)
nokogiri (1.18.9-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-musl)
nokogiri (1.18.9-x86_64-linux-musl)
racc (~> 1.4)
oj (3.16.8)
oj (3.16.11)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
openssl (3.3.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.1)
ostruct (0.6.3)
package_json (0.1.0)
pagy (9.3.3)
parallel (1.26.3)
@@ -365,6 +370,8 @@ GEM
ast (~> 2.4.1)
racc
pg (1.5.9)
pp (0.6.2)
prettyprint
premailer (1.27.0)
addressable
css_parser (>= 1.19.0)
@@ -375,42 +382,44 @@ GEM
premailer (~> 1.7, >= 1.7.9)
pretender (0.5.0)
actionpack (>= 6.1)
prettyprint (0.2.0)
pry (0.15.0)
coderay (~> 1.1)
method_source (~> 1.0)
pry-rails (0.3.11)
pry (>= 0.13.0)
psych (5.2.2)
psych (5.2.6)
date
stringio
public_suffix (6.0.1)
puma (6.5.0)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.1.16)
rack (3.2.0)
rack-proxy (0.7.7)
rack
rack-session (2.0.0)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.1.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
rails (8.0.1)
actioncable (= 8.0.1)
actionmailbox (= 8.0.1)
actionmailer (= 8.0.1)
actionpack (= 8.0.1)
actiontext (= 8.0.1)
actionview (= 8.0.1)
activejob (= 8.0.1)
activemodel (= 8.0.1)
activerecord (= 8.0.1)
activestorage (= 8.0.1)
activesupport (= 8.0.1)
rails (8.0.2.1)
actioncable (= 8.0.2.1)
actionmailbox (= 8.0.2.1)
actionmailer (= 8.0.2.1)
actionpack (= 8.0.2.1)
actiontext (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activemodel (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
bundler (>= 1.15.0)
railties (= 8.0.1)
rails-dom-testing (2.2.0)
railties (= 8.0.2.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
@@ -424,22 +433,23 @@ GEM
actionview (> 3.1)
activesupport (> 3.1)
railties (> 3.1)
railties (8.0.1)
actionpack (= 8.0.1)
activesupport (= 8.0.1)
railties (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.2.1)
rdoc (6.10.0)
rake (13.3.0)
rdoc (6.14.2)
erb
psych (>= 4.0.0)
redis-client (0.23.0)
connection_pool
regexp_parser (2.9.3)
reline (0.6.0)
reline (0.6.2)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
@@ -453,6 +463,7 @@ GEM
retriable (3.1.2)
rexml (3.4.0)
rotp (6.3.0)
rouge (4.5.2)
rqrcode (2.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
@@ -537,10 +548,11 @@ GEM
sqlite3 (2.5.0-arm64-darwin)
sqlite3 (2.5.0-x86_64-linux-gnu)
sqlite3 (2.5.0-x86_64-linux-musl)
stringio (3.1.2)
stringio (3.1.7)
strip_attributes (1.14.1)
activemodel (>= 3.0, < 9.0)
thor (1.3.2)
strscan (3.1.5)
thor (1.4.0)
timeout (0.4.3)
trailblazer-option (0.1.2)
turbo-rails (2.0.11)
@@ -573,12 +585,13 @@ GEM
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1)
websocket-driver (0.7.6)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.1)
zeitwerk (2.7.3)
PLATFORMS
aarch64-linux
@@ -601,6 +614,7 @@ DEPENDENCIES
cancancan
capybara
csv
csv-safe
cuprite
debug
devise
@@ -632,6 +646,7 @@ DEPENDENCIES
rails-i18n
rails_autolink
rotp
rouge
rqrcode
rspec-rails
rubocop
+2 -2
View File
@@ -27,7 +27,7 @@ class AccountsController < ApplicationController
unless URI.parse(@encrypted_config.value.to_s).class.in?([URI::HTTP, URI::HTTPS])
@encrypted_config.errors.add(:value, I18n.t('should_be_a_valid_url'))
return render :show, status: :unprocessable_entity
return render :show, status: :unprocessable_content
end
@encrypted_config.save!
@@ -39,7 +39,7 @@ class AccountsController < ApplicationController
redirect_to settings_account_path, notice: I18n.t('account_information_has_been_updated')
end
rescue ActiveRecord::RecordInvalid
render :show, status: :unprocessable_entity
render :show, status: :unprocessable_content
end
def destroy
+2 -2
View File
@@ -16,7 +16,7 @@ module Api
check_authorization
rescue_from Params::BaseValidator::InvalidParameterError do |e|
render json: { error: e.message }, status: :unprocessable_entity
render json: { error: e.message }, status: :unprocessable_content
end
rescue_from RateLimit::LimitApproached do |e|
@@ -33,7 +33,7 @@ module Api
rescue_from JSON::ParserError do |e|
Rollbar.warning(e) if defined?(Rollbar)
render json: { error: "JSON parse error: #{e.message}" }, status: :unprocessable_entity
render json: { error: "JSON parse error: #{e.message}" }, status: :unprocessable_content
end
end
@@ -10,6 +10,23 @@ module Api
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
if params[:type].in?(%w[initials signature])
image = Vips::Image.new_from_file(params[:file].path)
if ImageUtils.blank?(image)
Rollbar.error("Empty signature: #{submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content
end
if ImageUtils.error?(image)
Rollbar.error("Error signature: #{submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} error, try to sign on another device" },
status: :unprocessable_content
end
end
attachment = Submitters.create_attachment!(submitter, params)
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?
submitters = paginate(
submitters.preload(template: :folder, submission: [:submitters, { audit_trail_attachment: :blob,
combined_document_attachment: :blob }],
submitters.preload(template: { folder: :parent_folder },
submission: [:submitters, { audit_trail_attachment: :blob,
combined_document_attachment: :blob }],
documents_attachments: :blob, attachments_attachments: :blob),
field: :completed_at
)
+13 -17
View File
@@ -14,7 +14,7 @@ module Api
submissions = filter_submissions(submissions, params)
submissions = paginate(submissions.preload(:created_by_user, :submitters,
template: :folder,
template: { folder: :parent_folder },
combined_document_attachment: :blob,
audit_trail_attachment: :blob))
@@ -50,12 +50,12 @@ module Api
def create
Params::SubmissionCreateValidator.call(params)
return render json: { error: 'Template not found' }, status: :unprocessable_entity if @template.nil?
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
if @template.fields.blank?
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template does not contain fields' }, status: :unprocessable_entity
return render json: { error: 'Template does not contain fields' }, status: :unprocessable_content
end
params[:send_email] = true unless params.key?(:send_email)
@@ -63,12 +63,7 @@ module Api
submissions = create_submissions(@template, params)
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
WebhookUrls.enqueue_events(submissions, 'submission.created')
Submissions.send_signature_requests(submissions)
@@ -87,7 +82,7 @@ module Api
DownloadUtils::UnableToDownload => e
Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_entity
render json: { error: e.message }, status: :unprocessable_content
end
def destroy
@@ -96,10 +91,7 @@ module Api
else
@submission.update!(archived_at: Time.current)
WebhookUrls.for_account_id(@submission.account_id, 'submission.archived').each do |webhook_url|
SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(@submission, 'submission.archived')
end
render json: @submission.as_json(only: %i[id archived_at])
@@ -112,7 +104,10 @@ module Api
submissions = submissions.where(slug: params[:slug]) if params[:slug].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
if params.key?(:archived)
@@ -185,15 +180,16 @@ module Api
def submissions_params
permitted_attrs = [
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last,
:expire_at, :name,
:require_phone_2fa, :expire_at, :name,
{
message: %i[subject body],
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :reply_to, :go_to_last,
:require_phone_2fa, :order,
{ metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body],
fields: [:name, :uuid, :default_value, :value, :title, :description,
:readonly, :required, :validation_pattern, :invalid_message,
{ default_value: [], value: [], preferences: {} }] }]]
{ default_value: [], value: [], preferences: {}, validation: {} }] }]]
}
]
@@ -13,10 +13,7 @@ module Api
SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request)
WebhookUrls.for_account_id(@submitter.account_id, 'form.viewed').each do |webhook_url|
SendFormViewedWebhookRequestJob.perform_async('submitter_id' => @submitter.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(@submitter, 'form.viewed')
render json: {}
end
+7 -3
View File
@@ -34,7 +34,7 @@ module Api
def update
if @submitter.completed_at?
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_content
end
submission = @submitter.submission
@@ -74,7 +74,7 @@ module Api
rescue Submitters::NormalizeValues::BaseError, DownloadUtils::UnableToDownload => e
Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_entity
render json: { error: e.message }, status: :unprocessable_content
end
def submitter_params
@@ -82,7 +82,7 @@ module Api
submitter_params.permit(
: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],
fields: [[:name, :uuid, :default_value, :value, :required,
:readonly, :validation_pattern, :invalid_message,
@@ -193,6 +193,10 @@ module Api
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to')
if submitter_preferences.key?('require_phone_2fa')
submitter.preferences['require_phone_2fa'] = submitter_preferences['require_phone_2fa']
end
if submitter_preferences.key?('go_to_last')
submitter.preferences['go_to_last'] = submitter_preferences['go_to_last']
end
@@ -28,10 +28,7 @@ module Api
cloned_template.save!
WebhookUrls.for_account_id(cloned_template.account_id, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(cloned_template, 'template.created')
SearchEntries.enqueue_reindex(cloned_template)
+12 -9
View File
@@ -7,7 +7,7 @@ module Api
def index
templates = filter_templates(@templates, params)
templates = paginate(templates.preload(:author, :folder))
templates = paginate(templates.preload(:author, folder: :parent_folder))
schema_documents =
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
@@ -67,10 +67,7 @@ module Api
SearchEntries.enqueue_reindex(@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
WebhookUrls.enqueue_events(@template, 'template.updated')
render json: @template.as_json(only: %i[id updated_at])
end
@@ -93,7 +90,12 @@ module Api
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(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
end
@@ -104,14 +106,15 @@ module Api
: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 order]],
fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description,
:title, :description, :prefillable,
{ preferences: {},
default_value: [],
conditions: [%i[field_uuid value action operation]],
options: [%i[value uuid]],
validation: %i[message pattern],
validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
}
]
+3 -3
View File
@@ -7,8 +7,8 @@ module Api
def merge
files = params[:files] || []
return render json: { error: 'Files are required' }, status: :unprocessable_entity if files.blank?
return render json: { error: 'At least 2 files are required' }, status: :unprocessable_entity if files.size < 2
return render json: { error: 'Files are required' }, status: :unprocessable_content if files.blank?
return render json: { error: 'At least 2 files are required' }, status: :unprocessable_content if files.size < 2
render json: {
data: Base64.encode64(PdfUtils.merge(files.map { |base64| StringIO.new(Base64.decode64(base64)) }).string)
@@ -35,7 +35,7 @@ module Api
end
}
rescue HexaPDF::MalformedPDFError
render json: { error: 'Malformed PDF' }, status: :unprocessable_entity
render json: { error: 'Malformed PDF' }, status: :unprocessable_content
end
end
end
@@ -15,12 +15,12 @@ class EmailSmtpSettingsController < ApplicationController
redirect_to settings_email_index_path, notice: I18n.t('changes_have_been_saved')
else
render :index, status: :unprocessable_entity
render :index, status: :unprocessable_content
end
rescue StandardError => e
flash[:alert] = e.message
render :index, status: :unprocessable_entity
render :index, status: :unprocessable_content
end
private
+4
View File
@@ -5,8 +5,12 @@ class ErrorsController < ActionController::Base
'This feature is available in Pro Edition: https://www.docuseal.com/pricing'
ENTERPRISE_PATHS = [
'/submissions/html',
'/api/submissions/html',
'/templates/html',
'/api/templates/html',
'/submissions/pdf',
'/api/submissions/pdf',
'/templates/pdf',
'/api/templates/pdf',
'/templates/doc',
+2 -2
View File
@@ -53,7 +53,7 @@ class EsignSettingsController < ApplicationController
@cert_record.errors.add(:name, I18n.t('already_exists'))
return render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'),
status: :unprocessable_entity
status: :unprocessable_content
end
save_new_cert!(@encrypted_config, @cert_record)
@@ -64,7 +64,7 @@ class EsignSettingsController < ApplicationController
@cert_record.errors.add(:password, e.message)
render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), status: :unprocessable_entity
render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), status: :unprocessable_content
end
def update
+2 -2
View File
@@ -24,7 +24,7 @@ class MfaSetupController < ApplicationController
@error_message = I18n.t('code_is_invalid')
render turbo_stream: turbo_stream.replace(:mfa_form, partial: 'mfa_setup/form'), status: :unprocessable_entity
render turbo_stream: turbo_stream.replace(:mfa_form, partial: 'mfa_setup/form'), status: :unprocessable_content
end
end
@@ -36,7 +36,7 @@ class MfaSetupController < ApplicationController
else
@error_message = I18n.t('code_is_invalid')
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_entity
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_content
end
end
+2 -2
View File
@@ -11,7 +11,7 @@ class ProfileController < ApplicationController
if current_user.update(contact_params)
redirect_to settings_profile_index_path, notice: I18n.t('contact_information_has_been_update')
else
render :index, status: :unprocessable_entity
render :index, status: :unprocessable_content
end
end
@@ -20,7 +20,7 @@ class ProfileController < ApplicationController
bypass_sign_in(current_user)
redirect_to settings_profile_index_path, notice: I18n.t('password_has_been_changed')
else
render :index, status: :unprocessable_entity
render :index, status: :unprocessable_content
end
end
@@ -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
@@ -11,11 +11,16 @@ class SendSubmissionEmailController < ApplicationController
def create
if params[:template_slug]
@submitter = Submitter.completed.joins(submission: :template).find_by!(email: params[:email].to_s.downcase,
template: { slug: params[:template_slug] })
template = Template.find_by!(slug: params[:template_slug])
@submitter =
Submitter.completed.where(submission: template.submissions).find_by!(email: params[:email].to_s.downcase)
elsif params[:submission_slug]
@submitter = Submitter.completed.joins(:submission).find_by(email: params[:email].to_s.downcase,
submission: { slug: params[:submission_slug] })
submission = Submission.find_by(slug: params[:submission_slug])
if submission
@submitter = Submitter.completed.find_by(submission: submission, email: params[:email].to_s.downcase)
end
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter
else
@@ -24,14 +29,18 @@ class SendSubmissionEmailController < ApplicationController
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
unless EmailEvent.exists?(tag: :submitter_documents_copy, email: @submitter.email, emailable: @submitter,
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later!
end
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! unless already_sent?(@submitter)
respond_to do |f|
f.html { render :success }
f.json { head :ok }
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
+1 -1
View File
@@ -16,7 +16,7 @@ class SessionsController < Devise::SessionsController
end
if User.exists?(email:, otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_entity
return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_content
end
super
+4 -3
View File
@@ -23,10 +23,10 @@ class SetupController < ApplicationController
unless URI.parse(encrypted_config_params[:value].to_s).class.in?([URI::HTTP, URI::HTTPS])
@encrypted_config.errors.add(:value, I18n.t('should_be_a_valid_url'))
return render :index, status: :unprocessable_entity
return render :index, status: :unprocessable_content
end
return render :index, status: :unprocessable_entity unless @account.valid?
return render :index, status: :unprocessable_content unless @account.valid?
if @user.save
encrypted_configs = [
@@ -34,6 +34,7 @@ class SetupController < ApplicationController
{ key: EncryptedConfig::ESIGN_CERTS_KEY, value: GenerateCertificate.call.transform_values(&:to_pem) }
]
@account.encrypted_configs.create!(encrypted_configs)
@account.account_configs.create!(key: :fulltext_search, value: true) if SearchEntry.table_exists?
Docuseal.refresh_default_url_options!
@@ -41,7 +42,7 @@ class SetupController < ApplicationController
redirect_to newsletter_path
else
render :index, status: :unprocessable_entity
render :index, status: :unprocessable_content
end
end
+94 -27
View File
@@ -6,12 +6,15 @@ class StartFormController < ApplicationController
skip_before_action :authenticate_user!
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 :load_resubmit_submitter, only: :update
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
raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa']
@@ -20,6 +23,7 @@ class StartFormController < ApplicationController
.submitters.new(account_id: @template.account_id,
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)
@@ -33,12 +37,12 @@ class StartFormController < ApplicationController
@submitter = find_or_initialize_submitter(@template, submitter_params)
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
if filter_undefined_submitters(@template).size > 1 && @submitter.new_record?
@error_message = multiple_submitters_error_message
return render :show
return render :show, status: :unprocessable_content
end
if (is_new_record = @submitter.new_record?)
@@ -49,21 +53,14 @@ class StartFormController < ApplicationController
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
end
if @submitter.save
if is_new_record
enqueue_submission_create_webhooks(@submitter)
SearchEntries.enqueue_reindex(@submitter)
if @submitter.submission.expire_at?
ProcessSubmissionExpiredJob.perform_at(@submitter.submission.expire_at,
'submission_id' => @submitter.submission_id)
end
end
if @template.preferences['shared_link_2fa'] == true
handle_require_2fa(@submitter, is_new_record:)
elsif @submitter.errors.blank? && @submitter.save
enqueue_new_submitter_jobs(@submitter) if is_new_record
redirect_to submit_form_path(@submitter.slug)
else
render :show
render :show, status: :unprocessable_content
end
end
end
@@ -71,13 +68,34 @@ class StartFormController < ApplicationController
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)
.where.not(completed_at: nil)
.find_by!(email: params[:email])
.find_by!(required_params)
end
private
def enqueue_new_submitter_jobs(submitter)
WebhookUrls.enqueue_events(submitter.submission, 'submission.created')
SearchEntries.enqueue_reindex(submitter)
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'])
@@ -96,23 +114,35 @@ class StartFormController < ApplicationController
redirect_to start_form_path(@template.slug)
end
def enqueue_submission_create_webhooks(submitter)
WebhookUrls.for_account_id(submitter.account_id, 'submission.created').each do |webhook_url|
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
'webhook_url_id' => webhook_url.id)
end
end
def find_or_initialize_submitter(template, submitter_params)
Submitter
required_fields = template.preferences.fetch('link_form_fields', ['email'])
required_params = required_fields.index_with { |key| submitter_params[key] }
find_params = required_params.except('name')
submitter = Submitter.new if find_params.compact_blank.blank?
submitter ||=
Submitter
.where(submission: template.submissions.where(expire_at: Time.current..)
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
.order(id: :desc)
.where(declined_at: nil)
.where(external_id: nil)
.where(ip: [nil, request.remote_ip])
.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(email: submitter_params[:email], **submitter_params.compact_blank)
.find_or_initialize_by(find_params)
submitter.name = required_params['name'] if submitter.new_record?
unless @resubmit_submitter
required_params.each do |key, value|
submitter.errors.add(key.to_sym, :blank) if value.blank?
end
end
submitter
end
def assign_submission_attributes(submitter, template)
@@ -125,6 +155,8 @@ class StartFormController < ApplicationController
metadata: @resubmit_submitter&.metadata.presence || {}
)
submitter.assign_attributes(@resubmit_submitter.slice(:name, :email, :phone)) if @resubmit_submitter
if submitter.values.present?
@resubmit_submitter.attachments.each do |attachment|
submitter.attachments << attachment.dup if submitter.values.value?(attachment.uuid)
@@ -148,7 +180,7 @@ class StartFormController < ApplicationController
end
def submitter_params
return current_user.slice(:email) if params[:selfsign]
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|
@@ -172,4 +204,39 @@ class StartFormController < ApplicationController
I18n.t('not_found')
end
end
def handle_require_2fa(submitter, is_new_record:)
return render :show, status: :unprocessable_content 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_content
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
@@ -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
@@ -13,7 +13,7 @@ class StorageSettingsController < ApplicationController
redirect_to settings_storage_index_path, notice: I18n.t('changes_have_been_saved')
else
render :index, status: :unprocessable_entity
render :index, status: :unprocessable_content
end
end
@@ -12,6 +12,7 @@ class SubmissionEventsController < ApplicationController
'send_2fa_sms' => '2fa',
'send_sms' => 'send',
'phone_verified' => 'phone_check',
'email_verified' => 'email_check',
'click_sms' => 'hand_click',
'decline_form' => 'x',
'start_verification' => 'player_play',
+13 -24
View File
@@ -32,10 +32,7 @@ class SubmissionsController < ApplicationController
def create
save_template_message(@template, params) if params[:save_message] == '1'
if params[:is_custom_message] != '1'
params.delete(:subject)
params.delete(:body)
end
[params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1'
submissions =
if params[:emails].present?
@@ -46,15 +43,20 @@ class SubmissionsController < ApplicationController
emails: params[:emails],
params: params.merge('send_completed_email' => true))
else
submissions_attrs = submissions_params[:submission].to_h.values
submissions_attrs, =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, @template)
Submissions.create_from_submitters(template: @template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
submissions_attrs: submissions_params[:submission].to_h.values,
submissions_attrs:,
params: params.merge('send_completed_email' => true))
end
enqueue_submission_created_webhooks(@template, submissions)
WebhookUrls.enqueue_events(submissions, 'submission.created')
Submissions.send_signature_requests(submissions)
@@ -62,10 +64,9 @@ class SubmissionsController < ApplicationController
redirect_to template_path(@template), notice: I18n.t('new_recipients_have_been_added')
rescue Submissions::CreateFromSubmitters::BaseError => e
render turbo_stream: turbo_stream.replace(:submitters_error,
partial: 'submissions/error',
locals: { error: e.message }),
status: :unprocessable_entity
render turbo_stream: turbo_stream.replace(:submitters_error, partial: 'submissions/error',
locals: { error: e.message }),
status: :unprocessable_content
end
def destroy
@@ -77,10 +78,7 @@ class SubmissionsController < ApplicationController
else
@submission.update!(archived_at: Time.current)
WebhookUrls.for_account_id(@submission.account_id, 'submission.archived').each do |webhook_url|
SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(@submission, 'submission.archived')
I18n.t('submission_has_been_archived')
end
@@ -97,17 +95,8 @@ class SubmissionsController < ApplicationController
template.save!
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
params.permit(submission: { submitters: [%i[uuid email phone name]] })
params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] })
end
def load_template
@@ -28,7 +28,7 @@ class SubmissionsPreviewController < ApplicationController
raise ActionController::RoutingError, I18n.t('not_found')
end
if !submission_valid_ttl?(@submission) && !signature_valid
if use_signature?(@submission) && !signature_valid
Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar)
return redirect_to submissions_preview_completed_path(@submission.slug)
@@ -48,9 +48,15 @@ class SubmissionsPreviewController < ApplicationController
private
def submission_valid_ttl?(submission)
return true if current_user && current_user.account.submissions.exists?(id: submission.id)
def use_signature?(submission)
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 && last_submitter.completed_at > TTL.ago
+17 -7
View File
@@ -9,6 +9,7 @@ class SubmitFormController < ApplicationController
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
@@ -47,20 +48,20 @@ class SubmitFormController < ApplicationController
def update
if @submitter.completed_at?
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_content
end
if @submitter.template&.archived_at? || @submitter.submission.archived_at?
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_entity
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at?
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
if @submitter.submission.expired?
return render json: { error: I18n.t('form_has_been_expired') }, status: :unprocessable_entity
return render json: { error: I18n.t('form_has_been_expired') }, status: :unprocessable_content
end
if @submitter.declined_at?
return render json: { error: I18n.t('form_has_been_declined') },
status: :unprocessable_entity
status: :unprocessable_content
end
Submitters::SubmitValues.call(@submitter, params, request)
@@ -69,9 +70,9 @@ class SubmitFormController < ApplicationController
rescue Submitters::SubmitValues::RequiredFieldError => e
Rollbar.warning("Required field #{@submitter.id}: #{e.message}") if defined?(Rollbar)
render json: { field_uuid: e.message }, status: :unprocessable_entity
render json: { field_uuid: e.message }, status: :unprocessable_content
rescue Submitters::SubmitValues::ValidationError => e
render json: { error: e.message }, status: :unprocessable_entity
render json: { error: e.message }, status: :unprocessable_content
end
def completed; end
@@ -80,6 +81,15 @@ class SubmitFormController < ApplicationController
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? ||
@@ -25,10 +25,7 @@ class SubmitFormDeclineController < ApplicationController
SubmitterMailer.declined_email(submitter, user).deliver_later!
end
WebhookUrls.for_account_id(submitter.account_id, 'form.declined').each do |webhook_url|
SendFormDeclinedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(submitter, 'form.declined')
redirect_to submit_form_path(submitter.slug)
end
@@ -11,13 +11,13 @@ class SubmitFormDownloadController < ApplicationController
return redirect_to submitter_download_index_path(@submitter.slug) if @submitter.completed_at?
return head :unprocessable_entity if @submitter.declined_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
AccountConfig.exists?(account_id: @submitter.account_id,
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
value: false)
return head :unprocessable_content if @submitter.declined_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@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
.where.not(id: @submitter.id)
@@ -12,7 +12,7 @@ class SubmitFormDrawSignatureController < ApplicationController
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)
end
@@ -7,7 +7,7 @@ class SubmitFormInviteController < ApplicationController
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :unprocessable_entity unless can_invite?(submitter)
return head :unprocessable_content unless can_invite?(submitter)
invite_submitters = filter_invite_submitters(submitter, 'invite_by_uuid')
optional_invite_submitters = filter_invite_submitters(submitter, 'optional_invite_by_uuid')
@@ -34,7 +34,7 @@ class SubmitFormInviteController < ApplicationController
head :ok
else
head :unprocessable_entity
head :unprocessable_content
end
end
@@ -45,7 +45,7 @@ class SubmitFormInviteController < ApplicationController
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template.archived_at?
!submitter.submission.template&.archived_at?
end
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])
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.expired?
@@ -22,7 +22,7 @@ class SubmittersAutocompleteController < ApplicationController
def search_submitters(submitters)
if SELECT_COLUMNS.include?(params[:field])
if Docuseal.fulltext_search?(current_user)
if Docuseal.fulltext_search?
Submitters.fulltext_search_field(current_user, submitters, params[:q], params[:field])
else
column = Submitter.arel_table[params[:field].to_sym]
@@ -5,7 +5,7 @@ class TemplateDocumentsController < ApplicationController
def create
if params[:blobs].blank? && params[:files].blank?
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_entity
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content
end
old_fields_hash = @template.fields.hash
@@ -28,6 +28,6 @@ class TemplateDocumentsController < ApplicationController
)
}
rescue Templates::CreateAttachments::PdfEncrypted
render json: { error: 'PDF encrypted', status: 'pdf_encrypted' }, status: :unprocessable_entity
render json: { error: 'PDF encrypted', status: 'pdf_encrypted' }, status: :unprocessable_content
end
end
@@ -3,12 +3,31 @@
class TemplateFoldersAutocompleteController < ApplicationController
load_and_authorize_resource :template_folder, parent: false
LIMIT = 100
LIMIT = 30
def index
template_folders = @template_folders.joins(:templates).where(templates: { archived_at: nil }).distinct
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT)
parent_name, name =
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
+61 -6
View File
@@ -3,13 +3,41 @@
class TemplateFoldersController < ApplicationController
load_and_authorize_resource :template_folder
def show
@templates = @template_folder.templates.active.accessible_by(current_ability)
.preload(:author, :template_accesses)
@templates = Templates.search(current_user, @templates, params[:q])
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
helper_method :selected_order
@pagy, @templates = pagy_auto(@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
def edit; end
@@ -25,7 +53,34 @@ class TemplateFoldersController < ApplicationController
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
params.require(:template_folder).permit(:name)
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
@@ -4,9 +4,27 @@ class TemplatesArchivedController < ApplicationController
load_and_authorize_resource :template, parent: false
def index
@templates = @templates.where.not(archived_at: nil).preload(:author, :folder, :template_accesses).order(id: :desc)
@templates = @templates.where.not(archived_at: nil)
.preload(:author, :template_accesses, folder: :parent_folder)
.order(id: :desc)
@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
@@ -4,7 +4,7 @@ class TemplatesCloneAndReplaceController < ApplicationController
load_and_authorize_resource :template
def create
return head :unprocessable_entity if params[:files].blank?
return head :unprocessable_content if params[:files].blank?
ActiveRecord::Associations::Preloader.new(
records: [@template],
@@ -31,7 +31,7 @@ class TemplatesCloneAndReplaceController < ApplicationController
rescue Templates::CreateAttachments::PdfEncrypted
respond_to do |f|
f.html { render turbo_stream: turbo_stream.append(params[:form_id], html: helpers.tag.prompt_password) }
f.json { render json: { error: 'PDF encrypted', status: 'pdf_encrypted' }, status: :unprocessable_entity }
f.json { render json: { error: 'PDF encrypted', status: 'pdf_encrypted' }, status: :unprocessable_content }
end
end
end
+7 -20
View File
@@ -74,11 +74,11 @@ class TemplatesController < ApplicationController
SearchEntries.enqueue_reindex(@template)
enqueue_template_created_webhooks(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
maybe_redirect_to_template(@template)
else
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_entity
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_content
end
end
@@ -91,7 +91,7 @@ class TemplatesController < ApplicationController
SearchEntries.enqueue_reindex(@template) if is_name_changed
enqueue_template_updated_webhooks(@template)
WebhookUrls.enqueue_events(@template, 'template.updated')
head :ok
end
@@ -117,14 +117,15 @@ class TemplatesController < ApplicationController
params.require(:template).permit(
:name,
{ schema: [[:attachment_uuid, :name, { conditions: [%i[field_uuid value action operation]] }]],
submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid optional_invite_by_uuid email]],
submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid optional_invite_by_uuid email order]],
fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description,
:title, :description, :prefillable,
{ preferences: {},
default_value: [],
conditions: [%i[field_uuid value action operation]],
options: [%i[value uuid]],
validation: %i[message pattern],
validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
)
end
@@ -142,20 +143,6 @@ class TemplatesController < ApplicationController
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
return if params[:base_template_id].blank?
@@ -8,12 +8,14 @@ class TemplatesDashboardController < ApplicationController
TEMPLATES_PER_PAGE = 12
FOLDERS_PER_PAGE = 18
helper_method :selected_order
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 = sort_template_folders(@template_folders, current_user,
cookies.permanent[:dashboard_templates_order])
@template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
@pagy, @template_folders = pagy(
@template_folders,
@@ -26,7 +28,7 @@ class TemplatesDashboardController < ApplicationController
else
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
@templates = filter_templates(@templates).preload(:author, :template_accesses)
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
@templates = Templates::Order.call(@templates, current_user, selected_order)
limit =
if @template_folders.size < 4
@@ -36,6 +38,8 @@ class TemplatesDashboardController < ApplicationController
end
@pagy, @templates = pagy_auto(@templates, limit:)
load_related_submissions if params[:q].present? && @templates.blank?
end
end
@@ -53,10 +57,8 @@ class TemplatesDashboardController < ApplicationController
rel = Template.where(
Template.arel_table[:id].in(
Arel::Nodes::Union.new(
rel.where(folder_id: current_account.default_template_folder.id).select(:id).arel,
shared_template_ids.arel
)
rel.where(folder_id: current_account.default_template_folder.id).select(:id).arel
.union(:all, shared_template_ids.arel)
)
)
else
@@ -67,37 +69,28 @@ class TemplatesDashboardController < ApplicationController
Templates.search(current_user, rel, params[:q])
end
def sort_template_folders(template_folders, current_user, order)
case order
when 'used_at'
subquery =
Template.left_joins(:submissions)
.group(:folder_id)
.where(account_id: current_user.account_id)
.select(
:folder_id,
Template.arel_table[:updated_at].maximum.as('updated_at_max'),
Submission.arel_table[:created_at].maximum.as('submission_created_at_max')
)
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
template_folders = template_folders.joins(
Template.arel_table
.join(subquery.arel.as('templates'), Arel::Nodes::OuterJoin)
.on(TemplateFolder.arel_table[:id].eq(Template.arel_table[:folder_id]))
.join_sources
)
def load_related_submissions
@related_submissions = Submission.accessible_by(current_ability)
.left_joins(:template)
.where(archived_at: nil)
.where(templates: { archived_at: nil })
.preload(:template_accesses, :created_by_user,
template: :author,
submitters: :start_form_submission_events)
template_folders.order(
Arel::Nodes::Case.new
.when(Template.arel_table[:submission_created_at_max].gt(Template.arel_table[:updated_at_max]))
.then(Template.arel_table[:submission_created_at_max])
.else(Template.arel_table[:updated_at_max])
.desc
)
when 'name'
template_folders.order(name: :asc)
else
template_folders.order(id: :desc)
end
@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
@@ -3,6 +3,8 @@
class TemplatesDebugController < ApplicationController
load_and_authorize_resource :template
DEBUG_FILE = ''
def show
attachment = @template.documents.first
@@ -16,6 +18,8 @@ class TemplatesDebugController < ApplicationController
@template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment]))
debug_file if DEBUG_FILE.present?
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: { preview_images_attachments: :blob }]
@@ -31,4 +35,27 @@ class TemplatesDebugController < ApplicationController
render 'templates/edit', layout: 'plain'
end
def debug_file
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(File.read(DEBUG_FILE))
tempfile.rewind
filename = File.basename(DEBUG_FILE)
file = ActionDispatch::Http::UploadedFile.new(
tempfile:,
filename:,
type: Marcel::MimeType.for(tempfile)
)
params = { files: [file] }
documents = Templates::CreateAttachments.call(@template, params)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
@template.update!(schema:)
end
end
@@ -6,7 +6,9 @@ class TemplatesFoldersController < ApplicationController
def edit; end
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
redirect_back(fallback_location: template_path(@template), notice: I18n.t('document_template_has_been_moved'))
@@ -25,13 +25,13 @@ class TemplatesPreferencesController < ApplicationController
documents_copy_email_attach_documents documents_copy_email_reply_to
completed_notification_email_attach_documents
completed_redirect_url validate_unique_submitters
submitters_order require_phone_2fa
default_expire_at_duration
default_expire_at
require_all_submitters submitters_order require_phone_2fa
default_expire_at_duration shared_link_2fa
default_expire_at request_email_enabled
completed_notification_email_subject completed_notification_email_body
completed_notification_email_enabled completed_notification_email_attach_audit] +
[completed_message: %i[title body],
submitters: [%i[uuid request_email_subject request_email_body]]]
submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: []]
).tap do |attrs|
attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1'
@@ -0,0 +1,26 @@
# frozen_string_literal: true
class TemplatesPrefillableFieldsController < ApplicationController
PREFILLABLE_FIELD_TYPES = %w[text number cells date checkbox select radio phone].freeze
load_and_authorize_resource :template
def create
authorize!(:update, @template)
field = @template.fields.find { |f| f['uuid'] == params[:field_uuid] }
if params[:prefillable] == 'false'
field.delete('prefillable')
field.delete('readonly')
elsif params[:prefillable] == 'true'
field['prefillable'] = true
field['readonly'] = true
end
@template.save!
render turbo_stream: turbo_stream.replace(:prefillable_fields_list, partial: 'list',
locals: { template: @template })
end
end
@@ -9,6 +9,10 @@ class TemplatesRecipientsController < ApplicationController
@template.submitters =
submitters_params.map { |s| s.reject { |_, v| v.is_a?(String) && v.blank? } }
if @template.submitters.each_with_index.all? { |s, index| s['order'] == index }
@template.submitters.each { |s| s.delete('order') }
end
@template.save!
render json: { submitters: @template.submitters }
@@ -18,7 +22,7 @@ class TemplatesRecipientsController < ApplicationController
def submitters_params
permit_params = { submitters: [%i[name uuid is_requester optional_invite_by_uuid
invite_by_uuid linked_to_uuid email option]] }
invite_by_uuid linked_to_uuid email option order]] }
params.require(:template).permit(permit_params).fetch(:submitters, {}).values.filter_map do |s|
next if s[:uuid].blank?
@@ -29,6 +33,7 @@ class TemplatesRecipientsController < ApplicationController
s.delete(:is_requester)
end
s[:order] = s[:order].to_i if s[:order].present?
s.delete(:invite_by_uuid) if s[:invite_by_uuid].blank?
s.delete(:optional_invite_by_uuid) if s[:optional_invite_by_uuid].blank?
@@ -10,7 +10,11 @@ class TemplatesShareLinkController < ApplicationController
@template.update!(template_params)
head :ok
if params[:redir].present?
redirect_to params[:redir]
else
head :ok
end
end
private
@@ -23,7 +23,7 @@ class TemplatesUploadsController < ApplicationController
@template.update!(schema:)
enqueue_template_created_webhooks(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
SearchEntries.enqueue_reindex(@template)
@@ -68,11 +68,4 @@ class TemplatesUploadsController < ApplicationController
{ files: [file] }
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
+3 -3
View File
@@ -27,7 +27,7 @@ class UsersController < ApplicationController
if User.accessible_by(current_ability).exists?(email: @user.email)
@user.errors.add(:email, I18n.t('already_exists'))
return render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_entity
return render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_content
end
@user.role = User::ADMIN_ROLE unless role_valid?(@user.role)
@@ -37,7 +37,7 @@ class UsersController < ApplicationController
redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_invited')
else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_entity
render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_content
end
end
@@ -57,7 +57,7 @@ class UsersController < ApplicationController
if @user.update(attrs.except(current_user == @user ? :role : nil))
redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_updated')
else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_content
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_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
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
@@ -37,13 +53,18 @@ class WebhookSettingsController < ApplicationController
def resend
submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last
authorize!(:read, submitter)
if submitter.blank? || @webhook_url.blank?
return redirect_back(fallback_location: settings_webhooks_path,
alert: I18n.t('unable_to_resend_webhook_request'))
end
SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
'webhook_url_id' => @webhook_url.id)
SendTestWebhookRequestJob.perform_async(
'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'))
end
+8
View File
@@ -35,6 +35,10 @@ import SetDateButton from './elements/set_date_button'
import IndeterminateCheckbox from './elements/indeterminate_checkbox'
import AppTour from './elements/app_tour'
import DashboardDropzone from './elements/dashboard_dropzone'
import RequiredCheckboxGroup from './elements/required_checkbox_group'
import PageContainer from './elements/page_container'
import EmailEditor from './elements/email_editor'
import MountOnClick from './elements/mount_on_click'
import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -105,6 +109,10 @@ safeRegisterElement('indeterminate-checkbox', IndeterminateCheckbox)
safeRegisterElement('app-tour', AppTour)
safeRegisterElement('dashboard-dropzone', DashboardDropzone)
safeRegisterElement('check-on-click', CheckOnClick)
safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
safeRegisterElement('page-container', PageContainer)
safeRegisterElement('email-editor', EmailEditor)
safeRegisterElement('mount-on-click', MountOnClick)
safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () {
+8
View File
@@ -147,3 +147,11 @@ button[disabled] .enabled {
outline-offset: 3px;
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;
}
+1 -1
View File
@@ -1,7 +1,7 @@
export default class extends HTMLElement {
connectedCallback () {
this.addEventListener('click', () => {
if (!this.element.checked) {
if (this.element && !this.element.disabled && !this.element.checked) {
this.element.checked = true
this.element.dispatchEvent(new Event('change', { bubbles: true }))
}
+29 -3
View File
@@ -15,6 +15,7 @@ export default targetable(class extends HTMLElement {
static [target.static] = [
'form',
'fileDropzone',
'folderDropzone',
'fileDropzoneLoading'
]
@@ -25,12 +26,13 @@ export default targetable(class extends HTMLElement {
window.addEventListener('dragleave', this.onWindowDragleave)
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.templateCards.forEach((el) => el.addEventListener('drop', this.onDropTemplate))
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('dragleave', this.onDragleave)
})
@@ -46,6 +48,10 @@ export default targetable(class extends HTMLElement {
onTemplateDragStart = (e) => {
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'
if (id) {
@@ -98,13 +104,13 @@ export default targetable(class extends HTMLElement {
el.classList.add('opacity-50')
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}`)
} else {
const formData = new FormData()
formData.append('name', el.innerText)
formData.append('name', el.dataset.fullName)
fetch(`/templates/${templateId}/folder`, {
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 () {
this.style.backgroundColor = null
@@ -199,6 +223,7 @@ export default targetable(class extends HTMLElement {
this.isDrag = true
window.flash?.remove()
this.fileDropzone?.classList?.remove('hidden')
this.hiddenOnDrag.forEach((el) => { el.style.display = 'none' })
@@ -212,6 +237,7 @@ export default targetable(class extends HTMLElement {
this.isDrag = false
this.fileDropzone?.classList?.add('hidden')
this.folderDropzone?.classList?.add('hidden')
this.hiddenOnDrag.forEach((el) => { el.style.display = null })
+131
View File
@@ -0,0 +1,131 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
let loaderPromise = null
function loadCodeMirror () {
if (!loaderPromise) {
loaderPromise = Promise.all([
import(/* webpackChunkName: "email-editor" */ '@codemirror/view'),
import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'),
import(/* webpackChunkName: "email-editor" */ '@codemirror/language'),
import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'),
import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow')
]).then(([view, commands, language, html, htmlflow]) => {
return {
minimalSetup: [
commands.history(),
language.syntaxHighlighting(language.defaultHighlightStyle, { fallback: true }),
view.keymap.of([...commands.defaultKeymap, ...commands.historyKeymap])
],
EditorView: view.EditorView,
html: html.html,
htmlflow: htmlflow.default || htmlflow
}
})
}
return loaderPromise
}
export default targetable(class extends HTMLElement {
static [target.static] = [
'codeViewTab',
'previewViewTab',
'editorContainer',
'previewIframe'
]
connectedCallback () {
this.mount()
if (this.input.value) {
this.showPreviewView()
} else {
this.showCodeView()
}
this.previewViewTab.addEventListener('click', this.showPreviewView)
this.codeViewTab.addEventListener('click', this.showCodeView)
}
showCodeView = () => {
this.editorView.dispatch({
changes: { from: 0, to: this.editorView.state.doc.length, insert: this.input.value }
})
this.previewViewTab.classList.remove('tab-active', 'tab-bordered')
this.previewViewTab.classList.add('pb-[3px]')
this.codeViewTab.classList.remove('pb-[3px]')
this.codeViewTab.classList.add('tab-active', 'tab-bordered')
this.editorContainer.classList.remove('hidden')
this.previewIframe.classList.add('hidden')
}
showPreviewView = () => {
this.previewIframe.srcdoc = this.input.value
this.codeViewTab.classList.remove('tab-active', 'tab-bordered')
this.codeViewTab.classList.add('pb-[3px]')
this.previewViewTab.classList.remove('pb-[3px]')
this.previewViewTab.classList.add('tab-active', 'tab-bordered')
this.editorContainer.classList.add('hidden')
this.previewIframe.classList.remove('hidden')
}
async mount () {
this.input = this.querySelector('input[type="hidden"]')
this.input.style.display = 'none'
const { EditorView, minimalSetup, html, htmlflow } = await loadCodeMirror()
this.editorView = new EditorView({
doc: this.input.value,
parent: this.editorContainer,
extensions: [
html(),
minimalSetup,
EditorView.lineWrapping,
EditorView.updateListener.of(update => {
if (update.docChanged) this.input.value = update.state.doc.toString()
}),
EditorView.theme({
'&': {
backgroundColor: 'white',
color: 'black',
fontSize: '14px',
fontFamily: 'monospace'
},
'&.cm-focused': {
outline: 'none'
},
'&.cm-editor': {
borderRadius: '0.375rem',
border: 'none'
},
'.cm-gutters': {
display: 'none'
}
})
]
})
this.previewIframe.srcdoc = this.editorView.state.doc.toString()
this.previewIframe.onload = () => {
const previewIframeDoc = this.previewIframe.contentDocument
if (previewIframeDoc.body) {
previewIframeDoc.body.contentEditable = true
}
const contentDocument = this.previewIframe.contentDocument || this.previewIframe.contentWindow.document
contentDocument.body.addEventListener('input', async () => {
const html = contentDocument.documentElement.outerHTML.replace(' contenteditable="true"', '')
const prettifiedHtml = await htmlflow(html)
this.input.value = prettifiedHtml
})
}
}
})
@@ -2,6 +2,8 @@ import autocomplete from 'autocompleter'
export default class extends HTMLElement {
connectedCallback () {
if (this.dataset.enabled === 'false') return
autocomplete({
input: this.input,
preventSubmit: this.dataset.submitOnSelect === 'true' ? 0 : 1,
@@ -14,12 +16,16 @@ export default class extends HTMLElement {
}
onSelect = (item) => {
this.input.value = item.name
this.input.value = this.dataset.parentName ? item.name : item.full_name
}
fetch = (text, resolve) => {
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) => {
const items = await resp.json()
@@ -34,7 +40,7 @@ export default class extends HTMLElement {
div.setAttribute('dir', 'auto')
div.textContent = item.name
div.textContent = this.dataset.parentName ? item.name : item.full_name
return div
}
+11
View File
@@ -0,0 +1,11 @@
export default class extends HTMLElement {
connectedCallback () {
this.addEventListener('click', () => {
document.body.append(this.template.content)
})
}
get template () {
return document.getElementById(this.dataset.templateId)
}
}
+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 {
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)
}
}
}
@@ -7,5 +7,9 @@ export default actionable(class extends HTMLElement {
elementIds.forEach((elementId) => {
document.getElementById(elementId).classList.toggle('hidden', (event.target.dataset.toggleId || event.target.value) !== elementId)
})
if (this.dataset.focusId) {
document.getElementById(this.dataset.focusId)?.focus()
}
}
})
+2
View File
@@ -5,6 +5,7 @@ import DownloadButton from './elements/download_button'
import ToggleSubmit from './elements/toggle_submit'
import FetchForm from './elements/fetch_form'
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)
@@ -12,6 +13,7 @@ safeRegisterElement('download-button', DownloadButton)
safeRegisterElement('toggle-submit', ToggleSubmit)
safeRegisterElement('fetch-form', FetchForm)
safeRegisterElement('scroll-buttons', ScrollButtons)
safeRegisterElement('page-container', PageContainer)
safeRegisterElement('submission-form', class extends HTMLElement {
connectedCallback () {
this.appElem = document.createElement('div')
+8
View File
@@ -70,3 +70,11 @@ button[disabled] .enabled {
.base-radio {
@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;
}
+68 -20
View File
@@ -2,8 +2,8 @@
<div
class="flex absolute lg:text-base -outline-offset-1 field-area"
dir="auto"
:style="computedStyle"
: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 }"
:style="[computedStyle, fontStyle]"
:class="{ 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10 field-area-active': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
>
<div
v-if="(!withFieldPlaceholder || !field.name || field.type === 'cells') && !isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
@@ -29,8 +29,15 @@
{{ optionValue(option) }}
</template>
<template v-else>
{{ field.title || field.name || fieldNames[field.type] }}
<template v-if="field.type === 'checkbox' && !field.name">
<MarkdownContent
v-if="field.title"
:text-only="true"
:string="field.title"
/>
<template v-else>
{{ field.name || fieldNames[field.type] }}
</template>
<template v-if="field.type === 'checkbox' && !field.name && !field.title">
{{ fieldIndex + 1 }}
</template>
<template v-else-if="!field.required && field.type !== 'checkbox'">
@@ -56,11 +63,11 @@
<div
v-else-if="field.type === 'signature' && signature"
class="flex justify-between h-full gap-1 overflow-hidden w-full"
:class="isNarrow && (withSignatureId || field.preferences?.reason_field_uuid) ? 'flex-row' : 'flex-col'"
:class="isNarrow && (isShowSignatureId || field.preferences?.reason_field_uuid) ? 'flex-row' : 'flex-col'"
>
<div
class="flex overflow-hidden"
:class="isNarrow && (withSignatureId || field.preferences?.reason_field_uuid) ? 'w-1/2' : 'flex-grow'"
:class="isNarrow && (isShowSignatureId || field.preferences?.reason_field_uuid) ? 'w-1/2' : 'flex-grow'"
style="min-height: 50%"
>
<img
@@ -69,7 +76,7 @@
>
</div>
<div
v-if="withSignatureId || field.preferences?.reason_field_uuid"
v-if="isShowSignatureId || field.preferences?.reason_field_uuid"
class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem]"
:class="isNarrow ? 'w-1/2' : 'w-full'"
>
@@ -222,12 +229,14 @@
</template>
<script>
import MarkdownContent from './markdown_content'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconId } from '@tabler/icons-vue'
export default {
name: 'FieldArea',
components: {
IconPaperclip,
MarkdownContent,
IconCheck
},
inject: ['t'],
@@ -236,6 +245,11 @@ export default {
type: Object,
required: true
},
isInlineSize: {
type: Boolean,
required: false,
default: true
},
submitter: {
type: Object,
required: false,
@@ -328,6 +342,13 @@ export default {
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 () {
if (!this.field.preferences) {
return { 'items-center': true }
@@ -348,8 +369,8 @@ export default {
}
return {
'font-mono': this.field.preferences.font === 'Courier',
'font-serif': this.field.preferences.font === 'Times',
'font-courier': this.field.preferences.font === 'Courier',
'font-times': this.field.preferences.font === 'Times',
'font-bold': ['bold_italic', 'bold'].includes(this.field.preferences.font_type),
italic: ['bold_italic', 'italic'].includes(this.field.preferences.font_type)
}
@@ -410,10 +431,14 @@ export default {
},
formattedDate () {
if (this.field.type === 'date' && this.modelValue) {
return this.formatDate(
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
)
try {
return this.formatDate(
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
)
} catch {
return this.modelValue
}
} else {
return ''
}
@@ -427,6 +452,34 @@ export default {
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 () {
const { x, y, w, h } = this.area
@@ -437,11 +490,6 @@ export default {
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) {
style.color = this.field.preferences.color
}
@@ -456,7 +504,7 @@ export default {
modelValue () {
this.$nextTick(() => {
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 +512,7 @@ export default {
mounted () {
this.$nextTick(() => {
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"
:submittable="submittable"
:field-index="fieldIndex"
:is-inline-size="isInlineSize"
:scroll-padding="scrollPadding"
:submitter="submitter"
:with-field-placeholder="withFieldPlaceholder"
@@ -110,6 +111,9 @@ export default {
}
},
computed: {
isInlineSize () {
return CSS.supports('container-type: size')
},
isMobileContainer () {
const root = this.$root.$el.parentNode.getRootNode()
const container = root.body || root.querySelector('div')
+3 -6
View File
@@ -25,7 +25,7 @@
<div class="space-y-3 mt-5">
<a
v-if="completedButton.url"
:href="sanitizeHref(completedButton.url)"
:href="sanitizeUrl(completedButton.url)"
rel="noopener noreferrer nofollow"
class="white-button flex items-center w-full completed-form-completed-button"
>
@@ -102,6 +102,7 @@
<script>
import { IconCircleCheck, IconBrandGithub, IconMail, IconDownload, IconInnerShadowTop, IconLogin } from '@tabler/icons-vue'
import MarkdownContent from './markdown_content'
import { sanitizeUrl } from '@braintree/sanitize-url'
export default {
name: 'FormCompleted',
@@ -198,6 +199,7 @@ export default {
})
},
methods: {
sanitizeUrl,
sendCopyToEmail () {
this.isSendingCopy = true
@@ -252,11 +254,6 @@ export default {
this.isDownloading = false
})
},
sanitizeHref (href) {
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
return href.replace(/javascript:/g, '')
}
},
downloadSafariIos (urls) {
const fileRequests = urls.map((url) => {
return fetch(url).then(async (resp) => {
@@ -26,6 +26,7 @@
</template>
</label>
<button
v-if="withToday"
class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button"
@click.prevent="[setCurrentDate(), $emit('focus')]"
>
@@ -46,6 +47,8 @@
:id="field.uuid"
ref="input"
v-model="value"
:min="validationMin"
:max="validationMax"
class="base-input !text-2xl text-center w-full"
:required="field.required"
type="date"
@@ -89,6 +92,44 @@ export default {
},
emits: ['update:model-value', 'focus', 'submit'],
computed: {
dateNowString () {
const today = new Date()
const yyyy = today.getFullYear()
const mm = String(today.getMonth() + 1).padStart(2, '0')
const dd = String(today.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
},
validationMin () {
if (this.field.validation?.min) {
return ['{{date}}', '{date}'].includes(this.field.validation.min) ? this.dateNowString : this.field.validation.min
} else {
return ''
}
},
validationMax () {
if (this.field.validation?.max) {
return ['{{date}}', '{date}'].includes(this.field.validation.max) ? this.dateNowString : this.field.validation.max
} else {
return ''
}
},
withToday () {
const todayDate = new Date().setHours(0, 0, 0, 0)
if (this.validationMin) {
if (new Date(this.validationMin).setHours(0, 0, 0, 0) <= todayDate) {
return this.validationMax ? (new Date(this.validationMax).setHours(0, 0, 0, 0) >= todayDate) : true
} else {
return false
}
} else if (this.validationMax) {
return new Date(this.validationMax).setHours(0, 0, 0, 0) >= todayDate
} else {
return true
}
},
value: {
set (value) {
this.$emit('update:model-value', value)
+28 -4
View File
@@ -95,16 +95,40 @@ export default {
},
methods: {
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) {
e.preventDefault()
this.uploadFiles(this.$refs.input.files).then(() => {
if (this.$refs.input) {
this.$refs.input.value = ''
const files = Array.from(this.$refs.input.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).then(() => {
if (this.$refs.input) {
this.$refs.input.value = ''
}
})
}
},
async uploadFiles (files) {
this.isLoading = true
+3 -2
View File
@@ -67,7 +67,7 @@
<button
v-if="!isFormVisible"
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%"
@click.prevent="[isFormVisible = true, scrollIntoField(currentField)]"
>
@@ -572,6 +572,7 @@ import FormCompleted from './completed'
import { IconInnerShadowTop, IconArrowsDiagonal, IconWritingSign, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import AppearsOn from './appears_on'
import i18n from './i18n'
import { sanitizeUrl } from '@braintree/sanitize-url'
const isEmpty = (obj) => {
if (obj == null) return true
@@ -1476,7 +1477,7 @@ export default {
}
if (this.completedRedirectUrl) {
window.location.href = this.completedRedirectUrl
window.location.href = sanitizeUrl(this.completedRedirectUrl)
}
}
}
@@ -14,6 +14,7 @@
<FieldArea
v-if="isMathLoaded"
:model-value="calculateFormula(field)"
:is-inline-size="isInlineSize"
:field="field"
:area="area"
:submittable="false"
@@ -54,6 +55,18 @@ export default {
isMathLoaded: false
}
},
computed: {
isInlineSize () {
return CSS.supports('container-type: size')
},
fieldsUuidIndex () {
return this.fields.reduce((acc, field) => {
acc[field.uuid] = field
return acc
}, {})
}
},
async mounted () {
const {
create,
@@ -90,8 +103,19 @@ export default {
findPageElementForArea (area) {
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${area.attachment_uuid}-${area.page}`)
},
normalizeFormula (formula, depth = 0) {
if (depth > 10) return formula
return formula.replace(/{{(.*?)}}/g, (match, uuid) => {
if (this.fieldsUuidIndex[uuid]) {
return `(${this.normalizeFormula(this.fieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
} else {
return match
}
})
},
calculateFormula (field) {
const transformedFormula = field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
const transformedFormula = this.normalizeFormula(field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
return this.readonlyValues[uuid] || this.values[uuid] || 0.0
})
+28
View File
@@ -1,4 +1,6 @@
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.',
verify_id: 'Verify ID',
identity_verification: 'Identity verification',
@@ -98,6 +100,8 @@ const en = {
}
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.',
verify_id: 'Verificar ID',
identity_verification: 'Verificación de identidad',
@@ -197,6 +201,8 @@ const es = {
}
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à.",
verify_id: 'Verifica ID',
identity_verification: "Verifica dell'identità",
@@ -296,6 +302,8 @@ const it = {
}
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.',
verify_id: 'ID überprüfen',
identity_verification: 'Identitätsüberprüfung',
@@ -395,6 +403,8 @@ const de = {
}
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é.",
verify_id: "Vérification de l'ID",
identity_verification: "Vérification de l'identité",
@@ -494,6 +504,8 @@ const fr = {
}
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.',
verify_id: 'Zweryfikuj ID',
identity_verification: 'Weryfikacja tożsamości',
@@ -593,6 +605,8 @@ const pl = {
}
const uk = {
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
must_be_characters_length: 'Має містити {number} символів',
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
verify_id: 'Підтвердження ідентичності',
identity_verification: 'Ідентифікація особи',
@@ -692,6 +706,8 @@ const uk = {
}
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.',
verify_id: 'Ověřit ID',
identity_verification: 'Ověření identity',
@@ -791,6 +807,8 @@ const cs = {
}
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.',
verify_id: 'Verificar ID',
identity_verification: 'Verificação de identidade',
@@ -890,6 +908,8 @@ const pt = {
}
const he = {
please_upload_an_image_file: 'אנא העלה קובץ תמונה',
must_be_characters_length: 'חייב להיות באורך של {number} תווים',
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
verify_id: 'אמת מזהה',
identity_verification: 'אימות זהות',
@@ -989,6 +1009,8 @@ const he = {
}
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.',
verify_id: 'Verifiëren ID',
identity_verification: 'Identiteitsverificatie',
@@ -1088,6 +1110,8 @@ const nl = {
}
const ar = {
please_upload_an_image_file: 'يرجى تحميل ملف صورة',
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
verify_id: 'تحقق من الهوية',
identity_verification: 'التحقق من الهوية',
@@ -1187,6 +1211,8 @@ const ar = {
}
const ko = {
please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
must_be_characters_length: '{number}자여야 합니다',
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
verify_id: '아이디 확인',
identity_verification: '신원 확인',
@@ -1286,6 +1312,8 @@ const ko = {
}
const ja = {
please_upload_an_image_file: '画像ファイルをアップロードしてください',
must_be_characters_length: '{number}文字でなければなりません',
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
verify_id: '本人確認',
identity_verification: '本人確認',
@@ -203,7 +203,7 @@ export default {
emits: ['attached', 'update:model-value', 'start', 'minimize', 'focus'],
data () {
return {
isInitialsStarted: !!this.previousValue,
isInitialsStarted: false,
isUsePreviousValue: true,
isDrawInitials: false,
uploadImageInputKey: Math.random().toString()
@@ -218,6 +218,9 @@ export default {
}
}
},
created () {
this.isInitialsStarted = !!this.computedPreviousValue
},
async mounted () {
this.$nextTick(() => {
if (this.$refs.canvas) {
@@ -362,11 +365,20 @@ export default {
formData.append('file', file)
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
formData.append('type', 'initials')
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
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('update:model-value', attachment.uuid)
@@ -1,35 +1,32 @@
<template>
<span>
<template
v-for="(item, index) in items"
:key="index"
>
<a
v-if="item.startsWith('<a') && item.endsWith('</a>')"
:href="sanitizeHref(extractAttr(item, 'href'))"
rel="noopener noreferrer nofollow"
:class="extractAttr(item, 'class') || 'link'"
target="_blank"
>
{{ extractText(item) }}
</a>
<b
v-else-if="item.startsWith('<b>') || item.startsWith('<strong>')"
>
{{ extractText(item) }}
</b>
<i
v-else-if="item.startsWith('<i>') || item.startsWith('<em>')"
>
{{ extractText(item) }}
</i>
<br
v-else-if="item === '<br>' || item === '\n'"
>
<span v-if="textOnly">
{{ dom.body.textContent }}
</span>
<template v-else>
<template
v-else
v-for="(item, index) in nodes || dom.body.childNodes"
:key="index"
>
{{ item }}
<a
v-if="item.tagName === 'A' && item.getAttribute('href') !== 'undefined'"
:href="sanitizeUrl(item.getAttribute('href'))"
rel="noopener noreferrer nofollow"
:class="item.getAttribute('class') || 'link'"
target="_blank"
>
<MarkdownContent :nodes="item.childNodes" />
</a>
<component
:is="item.tagName"
v-else-if="safeTags.includes(item.tagName)"
>
<MarkdownContent :nodes="item.childNodes" />
</component>
<br v-else-if="item.tagName === 'BR' || item.nodeValue === '\n'">
<template v-else>
{{ item.textContent }}
</template>
</template>
</template>
</span>
@@ -37,8 +34,7 @@
<script>
import snarkdown from 'snarkdown'
const htmlSplitRegexp = /(<a.+?<\/a>|<i>.+?<\/i>|<b>.+?<\/b>|<em>.+?<\/em>|<strong>.+?<\/strong>|<br>)/
import { sanitizeUrl } from '@braintree/sanitize-url'
export default {
name: 'MarkdownContent',
@@ -47,39 +43,30 @@ export default {
type: String,
required: false,
default: ''
},
nodes: {
type: [Array, Object],
require: false,
default: null
},
textOnly: {
type: Boolean,
required: false,
default: false
}
},
computed: {
items () {
const linkParts = this.string.split(/(https?:\/\/[^\s)]+)/g)
safeTags () {
return ['UL', 'I', 'EM', 'B', 'STRONG', 'P']
},
dom () {
const text = this.string.replace(/(?<!\(\s*)(https?:\/\/[^\s)]+)(?!\s*\))/g, (url) => `[${url}](${url})`)
const text = linkParts.map((part, index) => {
if (part.match(/^https?:\/\//) && !linkParts[index - 1]?.match(/\(\s*$/) && !linkParts[index + 1]?.match(/^\s*\)/)) {
return `[${part}](${part})`
} else {
return part
}
}).join('')
return snarkdown(text.replace(/\n/g, '<br>')).split(htmlSplitRegexp)
return new DOMParser().parseFromString(snarkdown(text.replace(/\n/g, '<br>')), 'text/html')
}
},
methods: {
sanitizeHref (href) {
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
return href.replace(/javascript:/g, '')
}
},
extractAttr (text, attr) {
if (text.includes(attr)) {
return text.split(attr).pop().split('"')[1]
}
},
extractText (text) {
if (text) {
return text.match(/>(.+?)</)?.[1]
}
}
sanitizeUrl
}
}
</script>
@@ -39,8 +39,10 @@
:id="field.uuid"
v-model="number"
type="number"
:step="field.validation?.step || 'any'"
:min="field.validation?.min"
:max="field.validation?.max"
class="base-input !text-2xl w-full"
step="any"
:required="field.required"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
:name="`values[${field.uuid}]`"
@@ -21,7 +21,7 @@
</label>
<div class="space-x-2 flex flex-none">
<span
v-if="isTextSignature && format !== 'typed' && format !== 'upload'"
v-if="isTextSignature && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
class="tooltip"
:data-tip="t('draw_signature')"
>
@@ -38,7 +38,7 @@
</a>
</span>
<span
v-else-if="withTypedSignature && format !== 'typed' && format !== 'drawn' && format !== 'upload'"
v-else-if="withTypedSignature && format !== 'drawn_or_upload' && format !== 'typed_or_upload' && format !== 'typed' && format !== 'drawn' && format !== 'upload'"
class="tooltip ml-2"
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
:data-tip="t('type_text')"
@@ -85,8 +85,8 @@
{{ t(format === 'upload' ? 'reupload' : 'redraw') }}
</a>
<span
v-if="withQrButton && !modelValue && !computedPreviousValue && format !== 'typed' && format !== 'upload'"
class=" tooltip"
v-if="withQrButton && !modelValue && !computedPreviousValue && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
class="tooltip before:translate-x-[-90%]"
:data-tip="t('drawn_signature_on_a_touchscreen_device')"
>
<a
@@ -201,7 +201,7 @@
</div>
</div>
<input
v-if="isTextSignature"
v-if="isTextSignature && !modelValue && !computedPreviousValue"
id="signature_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6"
@@ -391,11 +391,11 @@ export default {
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason'],
data () {
return {
isSignatureStarted: !!this.previousValue,
isSignatureStarted: false,
isShowQr: false,
isOtherReason: false,
isUsePreviousValue: true,
isTextSignature: this.field.preferences?.format === 'typed',
isTextSignature: this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload',
uploadImageInputKey: Math.random().toString()
}
},
@@ -422,6 +422,8 @@ export default {
}
},
created () {
this.isSignatureStarted = !!this.computedPreviousValue
if (this.requireSigningReason) {
this.field.preferences ||= {}
this.field.preferences.reason_field_uuid ||= v4()
@@ -716,11 +718,20 @@ export default {
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
formData.append('remember_signature', this.rememberSignature)
formData.append('type', 'signature')
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
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('update:model-value', attachment.uuid)
+24 -2
View File
@@ -43,8 +43,8 @@
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${field.uuid}]`"
@invalid="field.validation?.message ? $event.target.setCustomValidity(field.validation.message) : ''"
@input="field.validation?.message ? $event.target.setCustomValidity('') : ''"
@invalid="validationMessage ? $event.target.setCustomValidity(validationMessage) : ''"
@input="validationMessage ? $event.target.setCustomValidity('') : ''"
@focus="$emit('focus')"
>
<textarea
@@ -127,6 +127,28 @@ export default {
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: {
set (value) {
this.$emit('update:model-value', value)
@@ -149,7 +149,8 @@ export default {
return fetch(this.baseUrl + `/api/identity_verification/${this.field.uuid}`, {
method: 'PUT',
body: JSON.stringify({
submitter_slug: this.submitterSlug
submitter_slug: this.submitterSlug,
redirect_url: document.location.href
}),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
+155 -35
View File
@@ -72,11 +72,11 @@
@blur="onNameBlur"
>{{ optionIndexText }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || defaultName }}</span>
<div
v-if="isSettingsFocus || (isValueInput && field.type !== 'heading') || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
v-if="isSettingsFocus || isSelectInput || (isValueInput && field.type !== 'heading') || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
class="flex items-center ml-1.5"
>
<input
v-if="!isValueInput"
v-if="!isValueInput && !isSelectInput"
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
@@ -84,14 +84,14 @@
@mousedown.prevent
>
<label
v-if="!isValueInput"
v-if="!isValueInput && !isSelectInput"
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>{{ t('required') }}</label>
<input
v-if="isValueInput"
v-if="isValueInput || isSelectInput"
:id="`readonly-checkbox-${field.uuid}`"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@@ -100,7 +100,7 @@
@mousedown.prevent
>
<label
v-if="isValueInput"
v-if="isValueInput || isSelectInput"
:for="`readonly-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.readonly = !(field.readonly ?? true)"
@@ -140,6 +140,8 @@
:background-color="'white'"
:with-required="false"
:with-areas="false"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@@ -162,29 +164,39 @@
ref="touchValueTarget"
class="flex h-full w-full field-area"
dir="auto"
:class="[isValueInput ? 'bg-opacity-50' : 'bg-opacity-80', field.type === 'heading' ? 'bg-gray-50' : bgColors[submitterIndex % bgColors.length], isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas) ? fontClasses : 'justify-center items-center']"
:class="[isValueInput ? 'cursor-text' : '', isValueInput || isCheckboxInput || isSelectInput ? 'bg-opacity-50' : 'bg-opacity-80', field.type === 'heading' ? 'bg-gray-50' : bgColors[submitterIndex % bgColors.length], isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas) ? fontClasses : 'justify-center items-center']"
@click="focusValueInput"
>
<span
v-if="field"
class="flex justify-center items-center space-x-1"
:class="{ 'w-full': ['cells', 'checkbox'].includes(field.type), 'h-full': !isValueInput && !isDefaultValuePresent }"
:class="{ 'w-full': isWFullType, 'h-full': !isValueInput && !isDefaultValuePresent }"
>
<div
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 }"
v-if="isDefaultValuePresent || isValueInput || isSelectInput || (withFieldPlaceholder && field.areas && field.type !== 'checkbox')"
:class="{ 'w-full h-full': isWFullType }"
:style="fontStyle"
>
<div
ref="textContainer"
class="flex items-center px-0.5"
:style="{ color: field.preferences?.color }"
:class="{ 'w-full h-full': ['cells', 'checkbox'].includes(field.type) }"
:class="{ 'w-full h-full': isWFullType }"
>
<IconCheck
v-if="field.type == 'checkbox'"
class="aspect-square mx-auto"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
/>
<template
v-else-if="(field.type === 'radio' || field.type === 'multiple') && field?.areas?.length > 1"
>
<IconCheck
v-if="field.type === 'multiple' ? field.default_value.includes(buildAreaOptionValue(area)) : buildAreaOptionValue(area) === field.default_value"
class="aspect-square mx-auto"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
/>
</template>
<span
v-else-if="field.type === 'number' && !isValueInput && (field.default_value || field.default_value == 0)"
class="whitespace-pre-wrap"
@@ -207,14 +219,39 @@
{{ char }}
</div>
</div>
<select
v-else-if="isSelectInput"
ref="defaultValueSelect"
class="bg-transparent outline-none focus:outline-none w-full"
@change="[field.default_value = $event.target.value, field.readonly = !!field.default_value?.length, save()]"
@focus="selectedAreaRef.value = area"
@keydown.enter="onDefaultValueEnter"
>
<option
:disabled="!field.default_value?.length"
:selected="!field.default_value?.length"
:value="''"
>
{{ t(field.default_value?.length ? 'none' : 'select') }}
</option>
<option
v-for="(option, index) in field.options"
:key="index"
:selected="field.default_value === option.value"
:value="option.value"
>
{{ option.value }}
</option>
</select>
<span
v-else
ref="defaultValue"
:contenteditable="isValueInput"
class="whitespace-pre-wrap outline-none empty:before:content-[attr(placeholder)] before:text-gray-400"
class="whitespace-pre-wrap outline-none empty:before:content-[attr(placeholder)] before:text-base-content/30"
:class="{ 'cursor-text': isValueInput }"
:placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : t('type_value')"
:placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value'))"
@blur="onDefaultValueBlur"
@focus="selectedAreaRef.value = area"
@paste.prevent="onPaste"
@keydown.enter="onDefaultValueEnter"
>{{ field.default_value }}</span>
@@ -222,7 +259,7 @@
</div>
<component
:is="fieldIcons[field.type]"
v-else
v-else-if="!isCheckboxInput"
width="100%"
height="100%"
class="max-h-10 opacity-50"
@@ -230,12 +267,12 @@
</span>
</div>
<div
v-if="!isValueInput"
v-if="!isValueInput && !isSelectInput"
ref="touchTarget"
class="absolute top-0 bottom-0 right-0 left-0"
:class="isDragged ? 'cursor-grab' : 'cursor-pointer'"
@dblclick="maybeToggleDefaultValue"
@click="maybeToggleCheckboxValue"
@click="inputMode && maybeToggleCheckboxValue()"
/>
<span
v-if="field?.type && editable"
@@ -250,6 +287,7 @@
<FormulaModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@close="isShowFormulaModal = false"
/>
@@ -261,6 +299,7 @@
<FontModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@close="isShowFontModal = false"
/>
@@ -272,6 +311,7 @@
<ConditionsModal
:item="field"
:build-default-name="buildDefaultName"
:default-field="defaultField"
@close="isShowConditionsModal = false"
/>
</Teleport>
@@ -282,6 +322,7 @@
<DescriptionModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@close="isShowDescriptionModal = false"
/>
@@ -315,7 +356,7 @@ export default {
FieldSubmitter,
IconX
},
inject: ['template', 'selectedAreaRef', 'save', 't'],
inject: ['template', 'selectedAreaRef', 'save', 't', 'isInlineSize'],
props: {
area: {
type: Object,
@@ -346,6 +387,16 @@ export default {
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
withPrefillable: {
type: Boolean,
required: false,
default: false
},
defaultSubmitters: {
type: Array,
required: false,
@@ -385,15 +436,56 @@ export default {
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
fieldIcons: FieldType.computed.fieldIcons,
isDefaultValuePresent () {
if (this.field?.type === 'radio' && this.field?.areas?.length > 1) {
return false
isWFullType () {
return ['cells', 'checkbox', 'radio', 'multiple', 'select'].includes(this.field.type)
},
fontStyle () {
let fontSize = ''
if (this.isInlineSize) {
if (this.textOverflowChars) {
fontSize = `${this.fontSizePx / 1.5 / 10}cqmin`
} else {
fontSize = `${this.fontSizePx / 10}cqmin`
}
} else {
return this.field?.default_value || this.field?.default_value === 0
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})` }
},
optionsUuidIndex () {
return this.field.options.reduce((acc, option) => {
acc[option.uuid] = option
return acc
}, {})
},
fontSizePx () {
return parseInt(this.field?.preferences?.font_size || 11) * this.fontScale
},
lineHeight () {
return 1.3
},
fontScale () {
return 1040 / 612.0
},
isDefaultValuePresent () {
return this.field?.default_value || this.field?.default_value === 0
},
isSelectInput () {
return this.inputMode && (this.field.type === 'select' || (this.field.type === 'radio' && this.field.areas?.length < 2))
},
isCheckboxInput () {
return this.inputMode && (this.field.type === 'checkbox' || (['radio', 'multiple'].includes(this.field.type) && this.area.option_uuid))
},
isValueInput () {
return (this.field.type === 'heading' && this.isHeadingSelected) || this.isContenteditable || (this.inputMode && ['text', 'number', 'date'].includes(this.field.type))
return (this.field.type === 'heading' && this.isHeadingSelected) || this.isContenteditable ||
(this.inputMode && (['text', 'number'].includes(this.field.type) || (this.field.type === 'date' && this.field.default_value !== '{{date}}')))
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
@@ -413,8 +505,8 @@ export default {
'justify-center': this.field.preferences.align === 'center',
'justify-start': this.field.preferences.align === 'left',
'justify-end': this.field.preferences.align === 'right',
'font-mono': this.field.preferences.font === 'Courier',
'font-serif': this.field.preferences.font === 'Times',
'font-courier': this.field.preferences.font === 'Courier',
'font-times': this.field.preferences.font === 'Times',
'font-bold': ['bold_italic', 'bold'].includes(this.field.preferences.font_type),
italic: ['bold_italic', 'italic'].includes(this.field.preferences.font_type)
}
@@ -491,7 +583,7 @@ export default {
'field.default_value' () {
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)) {
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 +591,7 @@ export default {
mounted () {
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)) {
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
}
})
},
@@ -508,6 +600,11 @@ export default {
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
buildAreaOptionValue (area) {
const option = this.optionsUuidIndex[area.option_uuid]
return option.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}`
},
maybeToggleDefaultValue () {
if (!this.editable) {
return
@@ -517,22 +614,41 @@ export default {
this.isContenteditable = true
this.focusValueInput()
} else if (this.field.type === 'checkbox') {
this.field.readonly = !this.field.readonly
this.field.default_value === true ? delete this.field.default_value : this.field.default_value = true
this.save()
} else if (this.field.type === 'date') {
this.field.readonly = !this.field.readonly
this.field.default_value === '{{date}}' ? delete this.field.default_value : this.field.default_value = '{{date}}'
this.save()
} else {
this.maybeToggleCheckboxValue()
}
},
maybeToggleCheckboxValue () {
if (this.inputMode && this.field.type === 'checkbox') {
this.field.readonly = !this.field.readonly
if (this.field.type === 'checkbox') {
this.field.default_value === true ? delete this.field.default_value : this.field.default_value = true
this.field.readonly = this.field.default_value === true
this.save()
} else if (this.field.type === 'radio' && this.area.option_uuid) {
const value = this.buildAreaOptionValue(this.area)
this.field.default_value === value ? delete this.field.default_value : this.field.default_value = value
this.field.readonly = !!this.field.default_value?.length
this.save()
} else if (this.field.type === 'multiple' && this.area.option_uuid) {
const value = this.buildAreaOptionValue(this.area)
if (this.field.default_value?.includes(value)) {
this.field.default_value.splice(this.field.default_value.indexOf(value), 1)
if (!this.field.default_value?.length) delete this.field.default_value
} else {
Array.isArray(this.field.default_value) ? this.field.default_value.push(value) : this.field.default_value = [value]
}
this.field.readonly = !!this.field.default_value?.length
this.save()
}
@@ -708,7 +824,7 @@ export default {
}
},
drag (e) {
if (e.target.id === 'mask') {
if (e.target.id === 'mask' && this.editable) {
this.isDragged = true
this.area.x = (e.offsetX - this.dragFrom.x) / e.target.clientWidth
@@ -724,7 +840,9 @@ export default {
e.preventDefault()
this.isDragged = true
if (this.editable) {
this.isDragged = true
}
const rect = e.target.getBoundingClientRect()
@@ -777,7 +895,9 @@ export default {
e.preventDefault()
this.isDragged = true
if (this.editable) {
this.isDragged = true
}
const rect = e.target.getBoundingClientRect()
+53 -5
View File
@@ -330,6 +330,8 @@
:input-mode="inputMode"
:default-fields="[...defaultRequiredFields, ...defaultFields]"
:allow-draw="!onlyDefinedFields || drawField"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:data-document-uuid="document.uuid"
:default-submitters="defaultSubmitters"
:drag-field-placeholder="fieldsDragFieldRef.value || dragField"
@@ -436,6 +438,8 @@
:default-required-fields="defaultRequiredFields"
:field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:only-defined-fields="onlyDefinedFields"
:editable="editable"
:show-tour-start-form="showTourStartForm"
@@ -542,6 +546,7 @@ export default {
isPaymentConnected: this.isPaymentConnected,
withFormula: this.withFormula,
withConditions: this.withConditions,
isInlineSize: this.isInlineSize,
defaultDrawFieldType: this.defaultDrawFieldType,
selectedAreaRef: computed(() => this.selectedAreaRef),
fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef)
@@ -562,6 +567,11 @@ export default {
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
backgroundColor: {
type: String,
required: false,
@@ -650,7 +660,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
default: 'image/*, application/pdf, application/zip'
},
baseUrl: {
type: String,
@@ -793,6 +803,16 @@ export default {
language () {
return this.locale.split('-')[0].toLowerCase()
},
withPrefillable () {
if (this.template.fields) {
return this.template.fields.some((f) => f.prefillable)
} else {
return false
}
},
isInlineSize () {
return CSS.supports('container-type: size')
},
isMobile () {
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
@@ -1045,19 +1065,30 @@ export default {
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = [{ value: '', uuid: v4() }]
field.options = [{ value: '', uuid: v4() }, { value: '', uuid: v4() }]
}
if (type === 'stamp') {
field.readonly = true
}
if (type === 'datenow') {
field.type = 'date'
field.readonly = true
field.default_value = '{{date}}'
}
if (type === 'date') {
field.preferences = {
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.save()
@@ -1078,7 +1109,7 @@ export default {
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = [{ value: '', uuid: v4() }]
field.options = [{ value: '', uuid: v4() }, { value: '', uuid: v4() }]
}
if (type === 'stamp') {
@@ -1270,7 +1301,13 @@ export default {
this.copiedArea.option_uuid ||= field.options[0].uuid
area.option_uuid = v4()
field.options.push({ uuid: area.option_uuid })
const lastOption = field.options[field.options.length - 1]
if (!field.areas.find((a) => lastOption.uuid === a.option_uuid)) {
area.option_uuid = lastOption.uuid
} else {
field.options.push({ uuid: area.option_uuid })
}
field.areas.push(area)
} else {
@@ -1446,10 +1483,16 @@ export default {
if (this.dragField?.options?.length) {
field.options = this.dragField.options.map(option => ({ value: option, uuid: v4() }))
} else {
field.options = [{ value: '', uuid: v4() }]
field.options = [{ value: '', uuid: v4() }, { value: '', uuid: v4() }]
}
}
if (field.type === 'datenow') {
field.type = 'date'
field.readonly = true
field.default_value = '{{date}}'
}
if (['stamp', 'heading'].includes(field.type)) {
field.readonly = true
}
@@ -1458,6 +1501,11 @@ export default {
field.preferences ||= {}
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 = {
@@ -9,7 +9,7 @@
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
{{ t('condition') }} - {{ item.name || buildDefaultName(item, template.fields) }}
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item, template.fields) }}
</span>
<a
href="#"
@@ -160,6 +160,11 @@ export default {
type: Object,
required: true
},
defaultField: {
type: Object,
required: false,
default: null
},
buildDefaultName: {
type: Function,
required: true
+1 -1
View File
@@ -66,7 +66,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
default: 'image/*, application/pdf, application/zip'
},
withReplaceButton: {
type: Boolean,
@@ -9,7 +9,7 @@
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
{{ field.name || buildDefaultName(field, template.fields) }}
{{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field, template.fields) }}
</span>
<a
href="#"
@@ -73,6 +73,11 @@ export default {
type: Object,
required: true
},
defaultField: {
type: Object,
required: false,
default: null
},
editable: {
type: Boolean,
required: false,
+17 -4
View File
@@ -10,6 +10,8 @@
:data-page="index"
:areas="areasIndex[index]"
:allow-draw="allowDraw"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:is-drag="isDrag"
:with-field-placeholder="withFieldPlaceholder"
:default-fields="defaultFields"
@@ -29,6 +31,7 @@
</template>
<script>
import Page from './page'
import { reactive } from 'vue'
export default {
name: 'TemplateDocument',
@@ -65,6 +68,16 @@ export default {
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
withPrefillable: {
type: Boolean,
required: false,
default: false
},
drawFieldType: {
type: String,
required: false,
@@ -123,14 +136,14 @@ export default {
return this.document.metadata?.pdf?.number_of_pages || this.document.preview_images.length
},
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 this.previewImagesIndex[i] || {
metadata: lazyloadMetadata,
return this.previewImagesIndex[i] || reactive({
metadata: { ...lazyloadMetadata },
id: Math.random().toString(),
url: this.basePreviewUrl + `/preview/${this.document.signed_uuid || this.document.uuid}/${i}.jpg`
}
})
})
},
previewImagesIndex () {
+3 -3
View File
@@ -107,7 +107,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
default: 'image/*, application/pdf, application/zip'
}
},
emits: ['success', 'error', 'loading'],
@@ -131,7 +131,7 @@ export default {
message () {
if (this.isLoading) {
return this.t('uploading')
} else if (this.acceptFileTypes === 'image/*, application/pdf') {
} else if (this.acceptFileTypes === 'image/*, application/pdf, application/zip') {
return this.title || this.t('add_pdf_documents_or_images')
} else {
return this.title || this.t('add_documents_or_images')
@@ -146,7 +146,7 @@ export default {
methods: {
upload: Upload.methods.upload,
onDropFiles (e) {
if (this.acceptFileTypes !== 'image/*, application/pdf' || [...e.dataTransfer.files].every((f) => f.type.match(/(?:image\/)|(?:application\/pdf)/))) {
if (this.acceptFileTypes !== 'image/*, application/pdf, application/zip' || [...e.dataTransfer.files].every((f) => f.type.match(/(?:image\/)|(?:application\/pdf)|(?:application\/zip)/))) {
this.$refs.input.files = e.dataTransfer.files
this.upload()
+41 -3
View File
@@ -125,6 +125,8 @@
:field="field"
:default-field="defaultField"
:editable="editable"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:background-color="dropdownBgColor"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@@ -149,7 +151,7 @@
</div>
</div>
<div
v-if="field.options && withOptions"
v-if="field.options && withOptions && (isExpandOptions || field.options.length < 5)"
ref="options"
class="border-t border-base-300 mx-2 pt-2 space-y-1.5"
draggable="true"
@@ -214,12 +216,30 @@
/>
<button
v-else-if="field.options && editable && !defaultField"
class="text-center text-sm w-full pb-1"
class="field-add-option text-center text-sm w-full pb-1"
@click="addOption"
>
+ {{ t('add_option') }}
</button>
</div>
<div
v-else-if="field.options && withOptions && !isExpandOptions && field.options.length > 4"
class="border-t border-base-300 mx-2 space-y-1.5"
>
<button
class="field-expand-options text-center text-sm w-full py-1 flex space-x-0.5 justify-center items-center"
@click="isExpandOptions = true"
>
<span class="lowercase">
{{ field.options.length }} {{ t('options') }}
</span>
<IconChevronDown
class="ml-2 mr-2 mt-0.5"
width="15"
height="15"
/>
</button>
</div>
</div>
<Teleport
v-if="isShowFormulaModal"
@@ -228,6 +248,7 @@
<FormulaModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@close="isShowFormulaModal = false"
/>
@@ -239,6 +260,7 @@
<FontModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@close="isShowFontModal = false"
/>
@@ -249,6 +271,7 @@
>
<ConditionsModal
:item="field"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@close="isShowConditionsModal = false"
/>
@@ -260,6 +283,7 @@
<DescriptionModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@close="isShowDescriptionModal = false"
/>
@@ -276,7 +300,7 @@ import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconRouteAltLeft, IconMathFunction, IconNewSection, IconTrashX, IconSettings } from '@tabler/icons-vue'
import { IconRouteAltLeft, IconMathFunction, IconNewSection, IconTrashX, IconSettings, IconChevronDown } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
@@ -286,6 +310,7 @@ export default {
IconSettings,
FieldSettings,
PaymentSettings,
IconChevronDown,
IconNewSection,
FormulaModal,
FontModal,
@@ -302,6 +327,16 @@ export default {
type: Object,
required: true
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
withPrefillable: {
type: Boolean,
required: false,
default: false
},
withOptions: {
type: Boolean,
required: false,
@@ -321,6 +356,7 @@ export default {
emits: ['set-draw', 'remove', 'scroll-to'],
data () {
return {
isExpandOptions: false,
isNameFocus: false,
showPaymentModal: false,
isShowFormulaModal: false,
@@ -415,6 +451,8 @@ export default {
this.$el.getRootNode().activeElement.blur()
},
addOption () {
this.isExpandOptions = true
this.field.options.push({ value: '', uuid: v4() })
this.$nextTick(() => {
@@ -1,31 +1,4 @@
<template>
<div
v-if="field.type === 'number'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences ||= {}, field.preferences.format = $event.target.value, save()]"
>
<option
v-for="format in numberFormats"
:key="format"
:value="format"
:selected="format === field.preferences?.format || (format === 'none' && !field.preferences?.format)"
>
{{ formatNumber(123456789.567, format) }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('format') }}
</label>
</div>
<div
v-if="field.type === 'verification'"
class="py-1.5 px-1 relative"
@@ -153,13 +126,13 @@
<option
v-for="(key, value) in validations"
:key="key"
:selected="field.validation?.pattern ? value === field.validation.pattern : value === 'none'"
:value="value"
:selected="lengthValidation ? key == 'length' : (field.validation?.pattern ? value === field.validation.pattern : key === 'none')"
:value="key"
>
{{ t(key) }}
</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"
>
{{ t('custom') }}
@@ -174,7 +147,122 @@
</label>
</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="field.type === 'number'"
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="field.validation?.min"
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
@input="[field.validation ||= {}, $event.target.value ? field.validation.min = $event.target.value : delete field.validation.min]"
@blur="save"
>
<label
v-if="field.validation?.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="field.validation?.max"
@input="[field.validation ||= {}, $event.target.value ? field.validation.max = $event.target.value : delete field.validation.max]"
@blur="save"
>
<label
v-if="field.validation?.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="field.type === 'number'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences ||= {}, field.preferences.format = $event.target.value, save()]"
>
<option
v-for="format in numberFormats"
:key="format"
:value="format"
:selected="format === field.preferences?.format || (format === 'none' && !field.preferences?.format)"
>
{{ formatNumber(123456789.567, format) }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('format') }}
</label>
</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
>
@@ -195,6 +283,27 @@
{{ t('regexp_validation') }}
</label>
</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
v-if="field.type === 'date'"
class="py-1.5 px-1 relative"
@@ -239,7 +348,7 @@
{{ t('any') }}
</option>
<option
v-for="type in ['drawn', 'typed', 'drawn_or_typed', 'upload']"
v-for="type in ['drawn', 'typed', 'drawn_or_typed', 'drawn_or_upload', 'upload']"
:key="type"
:value="type"
:selected="field.preferences?.format === type"
@@ -255,6 +364,21 @@
{{ t('format') }}
</label>
</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
v-if="withRequired && field.type !== 'phone' && field.type !== 'stamp' && field.type !== 'verification'"
@click.stop
@@ -313,7 +437,7 @@
</label>
</li>
<li
v-if="['text', 'number'].includes(field.type) && !defaultField"
v-if="['text', 'number'].includes(field.type)"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -321,16 +445,32 @@
v-model="field.readonly"
type="checkbox"
class="toggle toggle-xs"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.readonly))"
@update:model-value="save"
>
<span class="label-text">{{ t('read_only') }}</span>
</label>
</li>
<li
v-if="withPrefillable && ['text', 'number', 'cells', 'date', 'checkbox', 'select', 'radio', 'phone'].includes(field['type'])"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.prefillable"
type="checkbox"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.prefillable))"
class="toggle toggle-xs"
@update:model-value="save"
>
<span class="label-text">{{ t('prefillable') }}</span>
</label>
</li>
<hr
v-if="field.type != 'stamp'"
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
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-font')"
@@ -460,6 +600,11 @@ export default {
type: Object,
required: true
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
backgroundColor: {
type: String,
required: false,
@@ -470,6 +615,11 @@ export default {
required: false,
default: null
},
withPrefillable: {
type: Boolean,
required: false,
default: false
},
withRequired: {
type: Boolean,
required: false,
@@ -489,6 +639,7 @@ export default {
emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition', 'click-font', 'remove-area'],
data () {
return {
selectedValidation: ''
}
},
computed: {
@@ -533,8 +684,16 @@ export default {
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 () {
return {
'.{0,}': 'length',
'^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'ssn',
'^[0-9]{2}-[0-9]{7}$': 'ein',
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'email',
@@ -553,13 +712,21 @@ export default {
methods: {
onChangeValidation (event) {
if (event.target.value === 'custom') {
this.field.validation = { pattern: '' }
this.selectedValidation = 'custom'
this.field.validation = { pattern: '', message: '' }
this.$nextTick(() => this.$refs.validationCustom.focus())
} else if (event.target.value) {
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 {
this.selectedValidation = ''
delete this.field.validation
}
@@ -51,7 +51,7 @@
</template>
<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 {
name: 'FiledTypeDropdown',
@@ -101,6 +101,7 @@ export default {
signature: this.t('signature'),
initials: this.t('initials'),
date: this.t('date'),
datenow: this.t('date_signed'),
number: this.t('number'),
image: this.t('image'),
file: this.t('file'),
@@ -142,6 +143,7 @@ export default {
signature: IconWritingSign,
initials: IconLetterCaseUpper,
date: IconCalendarEvent,
datenow: IconCalendarCheck,
number: IconSquareNumber1,
image: IconPhoto,
checkbox: IconCheckbox,
@@ -164,7 +166,7 @@ export default {
return acc
}, {})
} 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'))
}
}
},
+13 -1
View File
@@ -26,6 +26,8 @@
:field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
:editable="editable"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:default-field="defaultFieldsIndex[field.name]"
:draggable="editable"
@dragstart="[fieldsDragFieldRef.value = field, removeDragOverlay($event), setDragPlaceholder($event)]"
@@ -253,6 +255,16 @@ export default {
required: false,
default: null
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
withPrefillable: {
type: Boolean,
required: false,
default: false
},
template: {
type: Object,
required: true
@@ -347,7 +359,7 @@ export default {
return acc
}, {})
} 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 () {
+11 -6
View File
@@ -9,7 +9,7 @@
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
{{ t('font') }} - {{ field.name || buildDefaultName(field, template.fields) }}
{{ t('font') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field, template.fields) }}
</span>
<a
href="#"
@@ -163,7 +163,7 @@
class="flex border border-base-content/20 rounded-xl bg-white px-4 h-16 modal-field-font-preview"
:style="{
color: preferences.color || 'black',
fontSize: (preferences.font_size || 12) + 'pt',
fontSize: (preferences.font_size || 11) + 'pt',
}"
:class="textClasses"
>
@@ -202,6 +202,11 @@ export default {
type: Object,
required: true
},
defaultField: {
type: Object,
required: false,
default: null
},
editable: {
type: Boolean,
required: false,
@@ -222,8 +227,8 @@ export default {
fonts () {
return [
{ value: null, label: 'Default' },
{ value: 'Times', label: 'Times', class: 'font-serif' },
{ value: 'Courier', label: 'Courier', class: 'font-mono' }
{ value: 'Times', label: 'Times', class: 'font-times' },
{ value: 'Courier', label: 'Courier', class: 'font-courier' }
]
},
types () {
@@ -258,8 +263,8 @@ export default {
},
textClasses () {
return {
'font-mono': this.preferences.font === 'Courier',
'font-serif': this.preferences.font === 'Times',
'font-courier': this.preferences.font === 'Courier',
'font-times': this.preferences.font === 'Times',
'justify-center': this.preferences.align === 'center',
'justify-start': this.preferences.align === 'left',
'justify-end': this.preferences.align === 'right',
@@ -9,7 +9,7 @@
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
{{ t('formula') }} - {{ field.name || buildDefaultName(field, template.fields) }}
{{ t('formula') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field, template.fields) }}
</span>
<a
href="#"
@@ -28,14 +28,21 @@
class="link"
>{{ t('available_in_pro') }}</a>
</div>
<div class="flex-inline mb-2 gap-2 space-y-1">
<div class="flex flex-wrap mb-2 gap-y-1 pt-1">
<button
v-for="f in fields"
:key="f.uuid"
class="mr-1 btn btn-neutral btn-outline border-base-content/20 btn-sm normal-case font-normal bg-white !rounded-xl"
class="mr-1 flex btn btn-neutral btn-outline border-base-content/20 btn-sm normal-case font-normal bg-white !rounded-xl"
@click.prevent="insertTextUnderCursor(`{{${f.name || buildDefaultName(f, template.fields)}}}`)"
>
<IconMathFunction
v-if="f.preferences?.formula"
width="17"
height="17"
stroke-width="1.5"
/>
<IconCodePlus
v-else
width="20"
height="20"
stroke-width="1.5"
@@ -57,7 +64,7 @@
<div class="mb-3 mt-1">
<div
target="blank"
class="text-sm mb-2 inline space-x-2"
class="text-sm mb-2 inline space-x-2 font-mono"
>
<button
class="bg-base-200 px-2 rounded-xl"
@@ -83,12 +90,6 @@
>
/
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' % ')"
>
%
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor('^')"
@@ -122,12 +123,13 @@
</template>
<script>
import { IconCodePlus } from '@tabler/icons-vue'
import { IconCodePlus, IconMathFunction } from '@tabler/icons-vue'
export default {
name: 'FormulaModal',
components: {
IconCodePlus
IconCodePlus,
IconMathFunction
},
inject: ['t', 'save', 'template', 'withFormula'],
props: {
@@ -135,6 +137,11 @@ export default {
type: Object,
required: true
},
defaultField: {
type: Object,
required: false,
default: null
},
editable: {
type: Boolean,
required: false,
@@ -154,7 +161,7 @@ export default {
computed: {
fields () {
return this.template.fields.reduce((acc, f) => {
if (f !== this.field && ['number'].includes(f.type) && (!f.preferences?.formula || f.submitter_uuid !== this.field.submitter_uuid)) {
if (f !== this.field && ['number'].includes(f.type) && (!f.preferences?.formula || !f.preferences.formula.includes(this.field.uuid))) {
acc.push(f)
}
@@ -224,9 +231,11 @@ export default {
this.formula = newText
textarea.setSelectionRange(cursorPos + textToInsert.length, cursorPos + textToInsert.length)
this.$nextTick(() => {
textarea.setSelectionRange(cursorPos + textToInsert.length, cursorPos + textToInsert.length)
textarea.focus()
textarea.focus()
})
},
resizeTextarea () {
const textarea = this.$refs.textarea
@@ -2,6 +2,8 @@
<div
v-if="isDragging || isLoading"
class="modal modal-open"
@dragover.prevent
@drop.prevent="$emit('error')"
>
<div class="flex flex-col gap-2 p-4 items-center bg-base-100 h-full max-h-[85vh] max-w-6xl rounded-2xl w-full">
<Dropzone
@@ -76,7 +78,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
default: 'image/*, application/pdf, application/zip'
}
},
emits: ['add', 'replace', 'replace-and-clone', 'error'],
+54
View File
@@ -1,6 +1,13 @@
const en = {
prefillable: 'Prefillable',
signature_id: 'Signature ID',
error_message: 'Error message',
length: 'Length',
min: 'Min',
max: 'Max',
font: 'Font',
party: 'Party',
date_signed: 'Date Signed',
method: 'Method',
reorder_fields: 'Reorder fields',
verify_id: 'Verify ID',
@@ -53,6 +60,7 @@ const en = {
any: 'Any',
drawn: 'Drawn',
drawn_or_typed: 'Drawn or Typed',
drawn_or_upload: 'Drawn or Upload',
upload: 'Upload',
formula: 'Formula',
typed: 'Typed',
@@ -75,6 +83,7 @@ const en = {
copy_to_all_pages: 'Copy to All Pages',
add_option: 'Add option',
option: 'Option',
options: 'Options',
condition: 'Condition',
first_party: 'First Party',
second_party: 'Second Party',
@@ -167,6 +176,13 @@ const en = {
}
const es = {
prefillable: 'Rellenable',
signature_id: 'ID de Firma',
error_message: 'Mensaje de error',
length: 'Longitud',
min: 'Mín',
max: 'Máx',
date_signed: 'Fecha actual',
fuente: 'Fuente',
party: 'Parte',
method: 'Método',
@@ -240,6 +256,7 @@ const es = {
copy_to_all_pages: 'Copiar a todas las páginas',
add_option: 'Agregar opción',
option: 'Opción',
options: 'Opciones',
first_party: 'Primera Parte',
second_party: 'Segunda Parte',
third_party: 'Tercera Parte',
@@ -308,6 +325,7 @@ const es = {
any: 'Cualquier',
drawn: 'Dibujado',
drawn_or_typed: 'Dibujado o Escrito',
drawn_or_upload: 'Dibujado o Subido',
upload: 'Subir',
typed: 'Escrito',
none: 'Ninguno',
@@ -335,6 +353,13 @@ const es = {
}
const it = {
prefillable: 'Precompilabile',
signature_id: 'ID firma',
error_message: 'Messaggio di errore',
length: 'Lunghezza',
min: 'Min',
max: 'Max',
date_signed: 'Data attuale',
font: 'Carattere',
party: 'Parte',
method: 'Metodo',
@@ -389,6 +414,7 @@ const it = {
any: 'Qualsiasi',
drawn: 'Disegnato',
drawn_or_typed: 'Disegnato o Digitato',
drawn_or_upload: 'Disegnato o Caricato',
upload: 'Caricare',
formula: 'Formula',
typed: 'Digitato',
@@ -411,6 +437,7 @@ const it = {
copy_to_all_pages: 'Copia in tutte le pagine',
add_option: 'Aggiungi opzione',
option: 'Opzione',
options: 'Opzioni',
condition: 'Condizione',
first_party: 'Prima parte',
second_party: 'Seconda parte',
@@ -503,6 +530,13 @@ const it = {
}
const pt = {
prefillable: 'Pré-preenchível',
signature_id: 'ID da Assinatura',
error_message: 'Mensagem de erro',
length: 'Comprimento',
min: 'Mín',
max: 'Máx',
date_signed: 'Data atual',
fonte: 'Fonte',
party: 'Parte',
method: 'Método',
@@ -576,6 +610,7 @@ const pt = {
copy_to_all_pages: 'Copiar para todas as páginas',
add_option: 'Adicionar opção',
option: 'Opção',
options: 'Opções',
first_party: 'Primeira Parte',
second_party: 'Segunda Parte',
third_party: 'Terceira Parte',
@@ -644,6 +679,7 @@ const pt = {
any: 'Qualquer',
drawn: 'Desenhado',
drawn_or_typed: 'Desenhado ou Digitado',
drawn_or_upload: 'Desenhado ou Enviado',
upload: 'Carregar',
typed: 'Digitado',
none: 'Nenhum',
@@ -671,6 +707,13 @@ const pt = {
}
const fr = {
prefillable: 'Pré-remplissable',
signature_id: 'ID de signature',
error_message: 'Message d\'erreur',
length: 'Longueur',
min: 'Min',
max: 'Max',
date_signed: 'Date actuelle',
font: 'Police',
party: 'Partie',
method: 'Méthode',
@@ -743,6 +786,7 @@ const fr = {
copy_to_all_pages: 'Copier sur toutes les pages',
add_option: 'Ajouter une option',
option: 'Option',
options: 'Options',
first_party: 'Première partie',
second_party: 'Deuxième partie',
third_party: 'Troisième partie',
@@ -812,6 +856,7 @@ const fr = {
any: 'Tout',
drawn: 'Dessiné',
drawn_or_typed: 'Dessiné ou Tapé',
drawn_or_upload: 'Dessiné ou Téléchargé',
upload: 'Téléverser',
typed: 'Tapé',
none: 'Aucun',
@@ -839,6 +884,13 @@ const fr = {
}
const de = {
prefillable: 'Vorausfüllbar',
signature_id: 'Signatur-ID',
error_message: 'Fehlermeldung',
length: 'Länge',
min: 'Min',
max: 'Max',
date_now: 'Akt. Datum',
font: 'Schriftart',
party: 'Partei',
method: 'Verfahren',
@@ -911,6 +963,7 @@ const de = {
copy_to_all_pages: 'Auf alle Seiten kopieren',
add_option: 'Option hinzufügen',
option: 'Option',
options: 'Options',
first_party: 'Erste Partei',
second_party: 'Zweite Partei',
third_party: 'Dritte Partei',
@@ -980,6 +1033,7 @@ const de = {
any: 'Jede',
drawn: 'Gezeichnet',
drawn_or_typed: 'Gezeichnet oder getippt',
drawn_or_upload: 'Gezeichnet oder hochgeladen',
upload: 'Upload',
typed: 'Getippt',
none: 'Keine',
@@ -406,7 +406,7 @@ export default {
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 })
}
})
+19 -6
View File
@@ -1,8 +1,9 @@
<template>
<div
class="relative select-none"
:class="{ 'cursor-crosshair': allowDraw }"
:style="drawField ? 'touch-action: 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 && editable, 'touch-none': !!drawField }"
style="container-type: size"
:style="{ aspectRatio: `${width} / ${height}`}"
>
<img
ref="image"
@@ -10,7 +11,7 @@
:src="image.url"
:width="width"
:height="height"
class="border rounded mb-4"
class="rounded"
@load="onImageLoad"
>
<div
@@ -26,6 +27,8 @@
:field="item.field"
:editable="editable"
:with-field-placeholder="withFieldPlaceholder"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:default-field="defaultFieldsIndex[item.field.name]"
:default-submitters="defaultSubmitters"
:max-page="totalPages - 1"
@@ -77,6 +80,16 @@ export default {
required: false,
default: null
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
withPrefillable: {
type: Boolean,
required: false,
default: false
},
areas: {
type: Array,
required: false,
@@ -191,8 +204,8 @@ export default {
},
methods: {
onImageLoad (e) {
e.target.setAttribute('width', e.target.naturalWidth)
e.target.setAttribute('height', e.target.naturalHeight)
this.image.metadata.width = e.target.naturalWidth
this.image.metadata.height = e.target.naturalHeight
},
setAreaRefs (el) {
if (el) {
+1 -1
View File
@@ -157,7 +157,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
default: 'image/*, application/pdf, application/zip'
},
withReplaceButton: {
type: Boolean,

Some files were not shown because too many files have changed in this diff Show More