Compare commits

...

191 Commits

Author SHA1 Message Date
Alex Turchyn a389e8cc38 Merge from docusealco/wip 2026-02-11 16:49:32 +02:00
Pete Matsyburka 2056301f70 remember send tab selection 2026-02-11 13:14:09 +02:00
Pete Matsyburka 93babe3660 update logo 2026-02-11 10:01:12 +02:00
Pete Matsyburka 4aef2d8eb0 update deps 2026-02-10 09:02:37 +02:00
Pete Matsyburka c5ec2ed230 fix document condition 2026-02-05 11:56:22 +02:00
Pete Matsyburka a156d5f6cc increase webhook timeout 2026-02-05 08:42:53 +02:00
Pete Matsyburka f84674cddd adjust field focus 2026-02-04 17:05:12 +02:00
Pete Matsyburka b7b62c197a fix date field 2026-02-04 16:56:35 +02:00
Pete Matsyburka f5350eac36 prefill typed signature and initials from name 2026-02-04 16:48:40 +02:00
Pete Matsyburka ee6228db15 only required field prop 2026-02-04 10:51:11 +02:00
Pete Matsyburka 3e434c0878 fix 2026-02-04 00:47:34 +02:00
Pete Matsyburka 7c5372a8e2 fix pdf sign 2026-02-02 22:04:43 +02:00
Alex Turchyn 6ab68cc36b Merge from docusealco/wip 2026-02-02 14:13:54 +02:00
Pete Matsyburka ee2400a5c5 touch signature attachment 2026-02-02 12:46:34 +02:00
Pete Matsyburka 80be5d2cf5 sandbox 2026-02-01 08:10:02 +02:00
Pete Matsyburka 76cf44a31b fix 2026-01-31 22:17:11 +02:00
Pete Matsyburka eb63113227 use trilogy mysql 2026-01-31 22:15:17 +02:00
Pete Matsyburka b7045fb3d9 fix azure proxy 2026-01-31 13:07:42 +02:00
Pete Matsyburka 11a5b5842d starttls label 2026-01-31 11:41:52 +02:00
Pete Matsyburka 362994afd5 adjust i18n 2026-01-31 10:26:09 +02:00
Pete Matsyburka d7682f4ebd add number conditions 2026-01-31 09:00:24 +02:00
Pete Matsyburka 56d4fe7fa9 add white color 2026-01-31 07:58:12 +02:00
Pete Matsyburka 93a852ac14 fix save modal 2026-01-30 21:45:55 +02:00
Pete Matsyburka 964db0ab08 fix menu 2026-01-30 21:03:56 +02:00
Pete Matsyburka 8547295da7 border color 2026-01-30 20:53:47 +02:00
Pete Matsyburka c75050fa46 adjust kba 2026-01-30 18:17:47 +02:00
Pete Matsyburka 65372c52aa fix order 2026-01-30 17:42:50 +02:00
Alex Turchyn 12c767b7d3 add more options to context menu 2026-01-30 16:36:50 +02:00
Pete Matsyburka 2fc5e9cfb3 format cells 2026-01-30 11:21:47 +02:00
Pete Matsyburka bccc78ee65 optimize builder 2026-01-30 10:23:51 +02:00
Pete Matsyburka c0793f3ce7 cell font settings 2026-01-29 21:43:01 +02:00
Pete Matsyburka a8dd8699a3 adjust kba 2026-01-29 15:55:15 +02:00
Pete Matsyburka 91bb25e8b6 fix reload 2026-01-29 15:33:24 +02:00
Pete Matsyburka c3f4480cdc add submitter index 2026-01-29 13:01:44 +02:00
Pete Matsyburka 33e226861e add SMTP_SSL_VERIFY env 2026-01-29 11:34:37 +02:00
Pete Matsyburka 4f5acbec9d readonly radio 2026-01-28 21:36:48 +02:00
Pete Matsyburka d2c9bbf6ab optimize conditions 2026-01-28 13:24:05 +02:00
Pete Matsyburka b5e57e1f2f add include fields 2026-01-28 11:49:35 +02:00
Pete Matsyburka c82c8b2272 fix race condition 2026-01-28 08:50:44 +02:00
Pete Matsyburka 43f1b598a7 adjust smtp noverify 2026-01-28 08:27:25 +02:00
Pete Matsyburka 5081e387d0 fix undefined 2026-01-27 20:13:26 +02:00
Pete Matsyburka 1e5f55c1d6 fix Point is invalid 2026-01-27 20:07:57 +02:00
Alex Turchyn aa38773be3 Merge from docusealco/wip 2026-01-27 18:39:04 +02:00
Pete Matsyburka 96530bd809 fix race condition 2026-01-27 17:06:30 +02:00
Pete Matsyburka 127f95a9d0 fix typo 2026-01-27 12:31:06 +02:00
Pete Matsyburka cf31e12348 adjust smtp configs 2026-01-27 10:57:04 +02:00
Pete Matsyburka 4adaa531b7 fix pagination 2026-01-27 10:34:18 +02:00
Pete Matsyburka c7659563b8 fix puma warnings 2026-01-26 20:23:11 +02:00
Alex Turchyn 7b5494468f update rails to 8.1.2 2026-01-26 18:22:47 +02:00
Pete Matsyburka d12c67fd54 with force open 2026-01-26 18:00:46 +02:00
Pete Matsyburka e20eacb5ee center on click 2026-01-26 12:35:53 +02:00
Pete Matsyburka 98055f0762 add can parse polyfill 2026-01-26 12:17:57 +02:00
Pete Matsyburka a46c42c3a0 fix resize warning 2026-01-26 12:06:41 +02:00
Pete Matsyburka c3d1503361 cleanup 2026-01-26 11:44:12 +02:00
Pete Matsyburka 65b47d27cd adjust form 2026-01-26 11:38:33 +02:00
Pete Matsyburka 968c07f8be include idcode 2026-01-26 11:13:01 +02:00
Pete Matsyburka 91814ca105 custom fields 2026-01-26 11:12:55 +02:00
Pete Matsyburka 9128f24270 fix synology acl 2026-01-24 18:01:49 +02:00
Pete Matsyburka 264897bbd5 audit with sender 2026-01-23 12:12:37 +02:00
Pete Matsyburka 67d59ff1ba fix invite by order 2026-01-22 21:20:34 +02:00
Pete Matsyburka 81eee56e1e i18n 2026-01-21 20:14:53 +02:00
Pete Matsyburka 92ea8af786 fix options drag 2026-01-21 16:02:20 +02:00
Pete Matsyburka df0616f77e paste options 2026-01-21 15:42:38 +02:00
Pete Matsyburka a337dff85c change model 2026-01-21 10:23:26 +02:00
Pete Matsyburka 48e349c1d2 fix signature pad when resize 2026-01-20 17:24:05 +02:00
Pete Matsyburka df9fa0fec7 fix scroll into field 2026-01-20 15:27:35 +02:00
Alex Turchyn b3e72f0726 Merge from docusealco/wip 2026-01-19 15:40:56 +02:00
Pete Matsyburka 700ebc4727 render reason 2026-01-19 13:56:17 +02:00
Pete Matsyburka 745a18efca fix select mode draw 2026-01-19 11:57:09 +02:00
Pete Matsyburka b4a8147ff0 fix radio group paste 2026-01-19 11:51:00 +02:00
Pete Matsyburka 0543c25054 detect fields in context menu 2026-01-19 09:23:04 +02:00
Pete Matsyburka b1a0afea66 fix menu 2026-01-18 10:29:15 +02:00
Pete Matsyburka 43c41e2557 add select fields mode 2026-01-18 09:52:43 +02:00
Pete Matsyburka bc8eb33f05 Revert "hide area name menu on desktop"
This reverts commit 5212773d0a.
2026-01-17 19:42:27 +02:00
Pete Matsyburka d1cebc92f7 fix eslint 2026-01-16 12:38:29 +02:00
Pete Matsyburka bf02cf4058 fix paste 2026-01-16 12:25:50 +02:00
Pete Matsyburka 5212773d0a hide area name menu on desktop 2026-01-16 12:18:17 +02:00
Alex Turchyn bf53204a41 add context menu to template builder 2026-01-16 12:07:59 +02:00
Pete Matsyburka 092a19966c adjust condition 2026-01-15 17:34:05 +02:00
Pete Matsyburka 6060a2b798 fix icon 2026-01-14 21:58:30 +02:00
Pete Matsyburka ce12af68b2 fix required 2026-01-14 14:32:00 +02:00
Pete Matsyburka c05f8d47aa require 2fa 2026-01-14 12:44:27 +02:00
Pete Matsyburka f8be19c20e pg 18 2026-01-13 20:44:52 +02:00
Pete Matsyburka 2fe32a2145 show version 2026-01-13 12:25:08 +02:00
Pete Matsyburka 794ea4d0e8 remove expired from pending filter 2026-01-13 12:07:39 +02:00
Pete Matsyburka 307b0f5afd fix build 2026-01-13 09:44:58 +02:00
Alex Turchyn 9011e3f4b4 Merge from docusealco/wip 2026-01-12 13:25:54 +02:00
Pete Matsyburka 0b45ce5c0c update model 2026-01-12 12:05:54 +02:00
Pete Matsyburka a8ab93a4cc adjust sort 2026-01-12 11:27:19 +02:00
Pete Matsyburka d1768be636 secure env 2026-01-12 11:27:17 +02:00
Pete Matsyburka b2cd7c82a7 insert new field at page position 2026-01-11 14:45:42 +02:00
Pete Matsyburka b88d99afe0 merge preview docs 2026-01-11 11:09:00 +02:00
Pete Matsyburka 4523d38551 direct links option 2026-01-09 22:05:37 +02:00
Pete Matsyburka c4ba892559 refactor clone 2026-01-08 09:34:45 +02:00
Pete Matsyburka 40f8093e00 adjust onnx 2026-01-07 14:50:27 +02:00
Pete Matsyburka 4375459856 add nmm 2026-01-07 12:19:05 +02:00
Pete Matsyburka b3eb8a70bf fix submitter uuid 2026-01-07 00:01:47 +02:00
Pete Matsyburka 6245a487b6 add kba countdown 2026-01-06 11:24:30 +02:00
Pete Matsyburka a89f4c1b10 adjust trim 2026-01-06 10:22:34 +02:00
Pete Matsyburka 7df729b8b5 containment threshold 2026-01-06 09:04:25 +02:00
Pete Matsyburka f1db7c7e82 adjust trim 2026-01-06 08:54:29 +02:00
Alex Turchyn 00af69cbf2 Merge from docusealco/wip 2026-01-05 21:46:19 +02:00
Pete Matsyburka c6819a19bd adjust inference 2026-01-05 18:36:50 +02:00
Pete Matsyburka f5cbcb3d24 adjust kba 2026-01-05 13:45:41 +02:00
Pete Matsyburka 6ca75c53ef fix condition 2026-01-05 12:46:43 +02:00
Pete Matsyburka e0ec443e10 fix mysql migration 2026-01-05 12:34:50 +02:00
Pete Matsyburka 9eed2fc2ef fix 2026-01-05 12:21:34 +02:00
Pete Matsyburka b9c6d3ff9f model v2 2026-01-05 09:18:38 +02:00
Pete Matsyburka 03c25a89b2 fix save 2026-01-04 13:16:57 +02:00
Pete Matsyburka f5b4a0c5ab inference v2 2026-01-03 16:55:05 +02:00
Pete Matsyburka b10ed46ccc add kba 2026-01-02 17:02:45 +02:00
Pete Matsyburka c55036a1b1 update gem 2025-12-31 23:27:41 +02:00
Pete Matsyburka 962a5dade5 add preview url 2025-12-31 22:32:27 +02:00
Pete Matsyburka 369a09a6df fix upload 2025-12-26 12:13:30 +02:00
Pete Matsyburka e9a39e2571 add s3 endpoint variable 2025-12-25 14:03:57 +02:00
Pete Matsyburka 1cf9cc18f1 do not retry 2025-12-23 10:17:19 +02:00
Pete Matsyburka 1984d4ec8d fix sync 2025-12-22 20:31:00 +02:00
Alex Turchyn fa99b4ebdc Merge from docusealco/wip 2025-12-22 02:27:47 +02:00
Pete Matsyburka 83c793a2a5 reorder field options 2025-12-21 16:12:57 +02:00
Pete Matsyburka d078727070 recipient fields condition 2025-12-21 13:19:16 +02:00
Pete Matsyburka 0d8e2c5ff0 adjust signature size 2025-12-20 17:12:15 +02:00
Pete Matsyburka ea062d7494 update gem 2025-12-19 12:26:01 +02:00
Pete Matsyburka d525e0597a recipient fields config 2025-12-19 12:26:00 +02:00
Pete Matsyburka 964b5d4e74 validate webhook 2025-12-18 12:45:33 +02:00
Pete Matsyburka abf17eb09f localhost list 2025-12-18 12:12:40 +02:00
Pete Matsyburka 178809dad7 fix field render 2025-12-16 21:54:36 +02:00
Pete Matsyburka 881d189715 fix phone 2025-12-16 12:34:18 +02:00
Pete Matsyburka 48a8973910 add with field labels 2025-12-16 11:38:37 +02:00
Alex Turchyn ae891932c5 Merge from docusealco/wip 2025-12-15 13:19:09 +02:00
Alex Turchyn a8b5e00814 show field boxes on the submissions page 2025-12-15 12:05:37 +02:00
Alex Turchyn e98432d7e4 update docs 2025-12-15 11:11:00 +02:00
Pete Matsyburka 29ff58ed98 fix drawing 2025-12-15 10:22:27 +02:00
Pete Matsyburka a621575ffc 11k 2025-12-14 22:24:07 +02:00
Alex Turchyn d7c7efc203 update brakeman ignore rules 2025-12-12 21:44:54 +02:00
Pete Matsyburka 4f013ca927 do not combine with verification 2025-12-12 20:39:18 +02:00
Pete Matsyburka d9823feef7 fix option 2025-12-12 09:22:42 +02:00
Pete Matsyburka 676d775544 fix generation 2025-12-12 09:14:23 +02:00
Pete Matsyburka 840ad17258 fix locale 2025-12-11 11:11:46 +02:00
Alex Turchyn ce0cd09b70 add translations 2025-12-11 10:28:35 +02:00
Alex Turchyn bb752fbc00 update translations 2025-12-11 10:28:07 +02:00
Pete Matsyburka c71fb04404 fix mysql migration 2025-12-10 13:43:34 +02:00
Pete Matsyburka e856cf8787 validate redirect url 2025-12-10 09:13:55 +02:00
Pete Matsyburka 71aae65bc6 add require email 2fa param 2025-12-09 11:46:57 +02:00
Pete Matsyburka 7a33444f9b add invite_by 2025-12-09 11:39:15 +02:00
Pete Matsyburka e12ec9d986 fix build 2025-12-08 16:26:45 +02:00
Alex Turchyn 94d1fb7dcb Merge from docusealco/wip 2025-12-08 15:31:19 +02:00
Alex Turchyn 6e43a42274 add email 2FA 2025-12-08 14:51:49 +02:00
Pete Matsyburka 0e0aa0259e add validate prop 2025-12-07 15:31:20 +02:00
Pete Matsyburka 75ab6d39ef adjust unarchive 2025-12-07 14:57:21 +02:00
Pete Matsyburka 6f84d84f96 use newer model 2025-12-05 15:17:50 +02:00
Pete Matsyburka cf46593e9b fix required checbox 2025-12-04 21:41:36 +02:00
Pete Matsyburka 1808346348 fix build 2025-12-04 15:38:55 +02:00
Pete Matsyburka ea59a68499 adjust active job log 2025-12-04 14:54:55 +02:00
Pete Matsyburka cba2d02990 skip webhook not found 2025-12-03 13:20:52 +02:00
Pete Matsyburka e92fc0d3e1 adjust mobile field types 2025-12-03 10:33:22 +02:00
Pete Matsyburka f1eaa77092 password reset i18n 2025-12-02 14:25:45 +02:00
Alex Turchyn 5934bdec55 use set_reset_password_token 2025-12-02 14:00:51 +02:00
Alex Turchyn 7a7d3c6ee3 add translations for reset password instructions email 2025-12-02 14:00:51 +02:00
Pete Matsyburka cb2f58c04c fix i18n 2025-12-02 13:31:35 +02:00
Pete Matsyburka a1c117f1ca add i18n 2025-12-02 12:50:58 +02:00
Alex Turchyn 1aacd98460 Merge from docusealco/wip 2025-12-01 17:09:47 +02:00
Pete Matsyburka 840162c9ca fix sort 2025-12-01 10:37:58 +02:00
Pete Matsyburka 3519b0e009 fix typo 2025-12-01 10:15:23 +02:00
Pete Matsyburka 53fae56c51 fix regexp field types 2025-12-01 10:09:27 +02:00
Pete Matsyburka f4552dbaa8 skip reconfirmation 2025-11-30 16:30:56 +02:00
Pete Matsyburka dfab4c331c send confirmation 2025-11-30 16:04:30 +02:00
Pete Matsyburka d2a0937038 fix pdf text emoji 2025-11-30 14:37:05 +02:00
Pete Matsyburka fa1ef44b22 add tabs 2025-11-30 12:41:16 +02:00
Pete Matsyburka c1a5c91299 adjust detection 2025-11-30 11:11:08 +02:00
Pete Matsyburka e6280e8f5a optimize text nodes 2025-11-29 18:28:10 +02:00
Pete Matsyburka fd2ba57325 adjust invitation 2025-11-29 10:34:13 +02:00
Pete Matsyburka dc178ef03f adjust router 2025-11-28 16:15:57 +02:00
Alex Turchyn 68936e10c5 add confirmation columns 2025-11-28 15:57:36 +02:00
Pete Matsyburka 41f16e7d39 adjust detection 2025-11-27 17:00:26 +02:00
Pete Matsyburka 453ebbf90a adjust start form 2025-11-27 12:23:45 +02:00
Pete Matsyburka 7ca290c080 adjust detection 2025-11-25 13:43:35 +02:00
Alex Turchyn dd5b6a6aa6 Merge from docusealco/wip 2025-11-24 17:43:59 +02:00
Pete Matsyburka bf2ebb995b add tz info 2025-11-24 15:46:50 +02:00
Alex Turchyn 3e929758fb add the reports page 2025-11-24 15:46:04 +02:00
Pete Matsyburka 4a53116586 adjust links 2025-11-24 12:28:13 +02:00
Pete Matsyburka 2d1981acfa adjust billing link 2025-11-24 11:13:00 +02:00
Pete Matsyburka c610df269f adjust populate 2025-11-22 13:02:58 +02:00
Pete Matsyburka 96dac635f5 add us timezones 2025-11-21 17:50:04 +02:00
Pete Matsyburka abd579103d add index 2025-11-21 13:41:51 +02:00
Pete Matsyburka 7ed27bb413 populate columns 2025-11-21 12:33:43 +02:00
Pete Matsyburka 180c542a9f add index 2025-11-21 11:45:19 +02:00
Pete Matsyburka 89613113de set is first completed submitter 2025-11-21 11:31:02 +02:00
Pete Matsyburka 3102900776 add is first to completed submitters 2025-11-21 11:27:28 +02:00
Pete Matsyburka 1a68053f4e set submission event account id 2025-11-21 11:18:28 +02:00
Pete Matsyburka 5d4a032763 add submission event account id 2025-11-21 11:17:20 +02:00
Pete Matsyburka 7481d27822 add tsa timeout 2025-11-18 22:17:05 +02:00
238 changed files with 33920 additions and 28287 deletions
+6 -6
View File
@@ -12,7 +12,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.2
ruby-version: 4.0.1
- name: Cache gems
uses: actions/cache@v4
with:
@@ -37,7 +37,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.2
ruby-version: 4.0.1
- name: Cache gems
uses: actions/cache@v4
with:
@@ -62,7 +62,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v1
with:
node-version: 20.9.0
node-version: 20.19.0
- name: Cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
@@ -89,7 +89,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.2
ruby-version: 4.0.1
- name: Cache gems
uses: actions/cache@v4
with:
@@ -132,11 +132,11 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.2
ruby-version: 4.0.1
- name: Set up Node
uses: actions/setup-node@v1
with:
node-version: 20.9.0
node-version: 20.19.0
- name: Install Chrome
uses: browser-actions/setup-chrome@latest
with:
+7 -22
View File
@@ -1,27 +1,14 @@
name: Build Docker Images
on:
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
push:
tags:
- "*.*.*"
jobs:
build:
runs-on: ${{ inputs.os }}
timeout-minutes: 20
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
steps:
- name: Checkout code
@@ -34,18 +21,16 @@ jobs:
uses: docker/metadata-action@v4
with:
images: docuseal/docuseal
tags: latest,${{ inputs.version }}
tags: type=semver,pattern={{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 ${{ inputs.version }} > .version
run: echo ${{ github.ref_name }} > .version
- name: Login to Docker Hub
uses: docker/login-action@v3
+11 -2
View File
@@ -1,4 +1,4 @@
require:
plugins:
- rubocop-performance
- rubocop-rails
- rubocop-rspec
@@ -10,7 +10,7 @@ AllCops:
- node_modules/**/*
- bin/*
- vendor/**/*
TargetRubyVersion: '3.3'
TargetRubyVersion: '4.0'
SuggestExtensions: false
Metrics/BlockLength:
@@ -48,6 +48,9 @@ Style/MultipleComparison:
Style/NumericPredicate:
Enabled: false
Style/MinMaxComparison:
Enabled: false
Naming/PredicateMethod:
Enabled: false
@@ -97,3 +100,9 @@ Rails/ApplicationController:
Rails/Output:
Enabled: false
Rails/StrongParametersExpect:
Enabled: false
Rails/RedirectBackOrTo:
Enabled: false
+28 -26
View File
@@ -1,4 +1,4 @@
FROM ruby:3.4.2-alpine AS download
FROM ruby:4.0.1-alpine AS download
WORKDIR /fonts
@@ -9,14 +9,14 @@ RUN apk --no-cache add fontforge wget && \
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/1.0.0/model_704_int8.onnx" && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
FROM ruby:3.4.2-alpine AS webpack
FROM ruby:4.0.1-alpine AS webpack
ENV RAILS_ENV=production
ENV NODE_ENV=production
@@ -42,16 +42,18 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:3.4.2-alpine AS app
FROM ruby:4.0.1-alpine AS app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
ENV LD_PRELOAD=/lib/libgcompat.so.0
ENV OPENSSL_CONF=/app/openssl_legacy.cnf
ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN apk add --no-cache sqlite-dev libpq-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont onnxruntime && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\
@@ -63,36 +65,36 @@ legacy = legacy_sect\n\
activate = 1\n\
\n\
[legacy_sect]\n\
activate = 1' >> /app/openssl_legacy.cnf
activate = 1' >> /etc/openssl_legacy.cnf
COPY ./Gemfile ./Gemfile.lock ./
COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
RUN apk add --no-cache build-base git && bundle install && apk del --no-cache build-base git && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
RUN echo 'https://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories && echo 'https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache onnxruntime
COPY --chown=docuseal:docuseal ./bin ./bin
COPY --chown=docuseal:docuseal ./app ./app
COPY --chown=docuseal:docuseal ./config ./config
COPY --chown=docuseal:docuseal ./db/migrate ./db/migrate
COPY --chown=docuseal:docuseal ./log ./log
COPY --chown=docuseal:docuseal ./lib ./lib
COPY --chown=docuseal:docuseal ./public ./public
COPY --chown=docuseal:docuseal ./tmp ./tmp
COPY --chown=docuseal:docuseal LICENSE README.md Rakefile config.ru .version ./
COPY --chown=docuseal:docuseal .version ./public/version
COPY ./bin ./bin
COPY ./app ./app
COPY ./config ./config
COPY ./db/migrate ./db/migrate
COPY ./log ./log
COPY ./lib ./lib
COPY ./public ./public
COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./
COPY .version ./public/version
COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
COPY --from=download /model.onnx /app/tmp/model.onnx
COPY --from=webpack /app/public/packs ./public/packs
COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
RUN bundle exec bootsnap precompile --gemfile app/ lib/
RUN ln -s /fonts /app/public/fonts && \
bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \
chown -R docuseal:docuseal /app/tmp/cache
WORKDIR /data/docuseal
ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal
EXPOSE 3000
+11 -11
View File
@@ -2,16 +2,16 @@
source 'https://rubygems.org'
ruby '3.4.2'
ruby '4.0.1'
gem 'arabic-letter-connector', require: 'arabic-letter-connector/logic'
gem 'arabic-letter-connector', require: false
gem 'aws-sdk-s3', require: false
gem 'aws-sdk-secretsmanager', require: false
gem 'azure-storage-blob', require: false
gem 'azure-blob', require: false
gem 'bootsnap', require: false
gem 'cancancan'
gem 'csv'
gem 'csv-safe'
gem 'csv', require: false
gem 'csv-safe', require: false
gem 'devise'
gem 'devise-two-factor'
gem 'dotenv', require: false
@@ -21,12 +21,11 @@ gem 'faraday-follow_redirects'
gem 'google-cloud-storage', require: false
gem 'hexapdf'
gem 'image_processing'
gem 'jwt'
gem 'jwt', require: false
gem 'lograge'
gem 'mysql2', require: false
gem 'numo-narray'
gem 'numo-narray-alt', require: false
gem 'oj'
gem 'onnxruntime'
gem 'onnxruntime', require: false
gem 'pagy'
gem 'pg', require: false
gem 'premailer-rails'
@@ -38,13 +37,14 @@ gem 'rails_autolink'
gem 'rails-i18n'
gem 'rotp'
gem 'rouge', require: false
gem 'rqrcode'
gem 'rqrcode', require: false
gem 'ruby-vips'
gem 'rubyXL'
gem 'rubyXL', require: false
gem 'shakapacker'
gem 'sidekiq'
gem 'sqlite3', require: false
gem 'strip_attributes'
gem 'trilogy', github: 'trilogy-libraries/trilogy', glob: 'contrib/ruby/*.gemspec', require: false
gem 'turbo-rails'
gem 'twitter_cldr', require: false
gem 'tzinfo-data'
+292 -288
View File
@@ -1,29 +1,39 @@
GIT
remote: https://github.com/trilogy-libraries/trilogy.git
revision: 3963d490459df7a2b5bedb42424c3285f25eab22
glob: contrib/ruby/*.gemspec
specs:
trilogy (2.10.0)
bigdecimal
GEM
remote: https://rubygems.org/
specs:
actioncable (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
action_text-trix (2.1.16)
railties
actioncable (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
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)
actionmailbox (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
mail (>= 2.8.0)
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)
actionmailer (8.1.2)
actionpack (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activesupport (= 8.1.2)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.2.1)
actionview (= 8.0.2.1)
activesupport (= 8.0.2.1)
actionpack (8.1.2)
actionview (= 8.1.2)
activesupport (= 8.1.2)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -31,97 +41,97 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
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)
actiontext (8.1.2)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.2.1)
activesupport (= 8.0.2.1)
actionview (8.1.2)
activesupport (= 8.1.2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.0.2.1)
activesupport (= 8.0.2.1)
activejob (8.1.2)
activesupport (= 8.1.2)
globalid (>= 0.3.6)
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)
activemodel (8.1.2)
activesupport (= 8.1.2)
activerecord (8.1.2)
activemodel (= 8.1.2)
activesupport (= 8.1.2)
timeout (>= 0.4.0)
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)
activestorage (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activesupport (= 8.1.2)
marcel (~> 1.0)
activesupport (8.0.2.1)
activesupport (8.1.2)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
annotaterb (4.14.0)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
arabic-letter-connector (0.1.1)
ast (2.4.3)
aws-eventstream (1.3.0)
aws-partitions (1.1027.0)
aws-sdk-core (3.214.0)
aws-eventstream (1.4.0)
aws-partitions (1.1209.0)
aws-sdk-core (3.241.4)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.96.0)
aws-sdk-core (~> 3, >= 3.210.0)
logger
aws-sdk-kms (1.121.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.176.1)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-s3 (1.212.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sdk-secretsmanager (1.110.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-secretsmanager (1.128.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.10.1)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0)
nokogiri (~> 1, >= 1.10.8)
azure-storage-common (2.0.4)
faraday (~> 1.0)
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
azure-blob (0.8.0)
cgi
rexml
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.4.1)
better_html (2.1.1)
actionview (>= 6.0)
activesupport (>= 6.0)
bcrypt (3.1.21)
better_html (2.2.0)
actionview (>= 7.0)
activesupport (>= 7.0)
ast (~> 2.0)
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
bigdecimal (3.2.2)
bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.18.4)
bootsnap (1.21.1)
msgpack (~> 1.2)
brakeman (7.0.0)
brakeman (7.1.2)
racc
builder (3.3.0)
bullet (8.0.0)
bullet (8.1.0)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
camertron-eprun (1.1.1)
@@ -135,28 +145,29 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
chunky_png (1.4.0)
cldr-plurals-runtime-rb (1.1.0)
cmdparse (3.0.7)
coderay (1.1.3)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
crack (1.0.0)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
css_parser (1.21.0)
css_parser (1.21.1)
addressable
csv (3.3.2)
csv (3.3.5)
csv-safe (3.3.1)
csv (~> 3.0)
cuprite (0.15.1)
cuprite (0.17)
capybara (~> 3.0)
ferrum (~> 0.15.0)
date (3.4.1)
debug (1.10.0)
ferrum (~> 0.17.0)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
declarative (0.0.20)
@@ -166,20 +177,20 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-two-factor (6.1.0)
activesupport (>= 7.0, < 8.1)
devise (~> 4.0)
railties (>= 7.0, < 8.1)
devise-two-factor (6.3.1)
activesupport (>= 7.0, < 8.2)
devise (>= 4.0, < 5.0)
railties (>= 7.0, < 8.2)
rotp (~> 6.0)
diff-lcs (1.5.1)
digest-crc (0.6.5)
diff-lcs (1.6.2)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.1)
dotenv (3.1.7)
dotenv (3.2.0)
drb (2.2.3)
email_typo (0.2.3)
erb (5.0.2)
erb_lint (0.7.0)
erb (6.0.1)
erb_lint (0.9.0)
activesupport
better_html (>= 2.0.1)
parser (>= 2.7.1.4)
@@ -187,117 +198,101 @@ GEM
rubocop (>= 1)
smart_properties
erubi (1.13.1)
factory_bot (6.5.0)
activesupport (>= 5.0.0)
factory_bot_rails (6.4.4)
factory_bot (6.5.6)
activesupport (>= 6.1.0)
factory_bot_rails (6.5.1)
factory_bot (~> 6.5)
railties (>= 5.0.0)
faker (3.5.1)
railties (>= 6.1.0)
faker (3.6.0)
i18n (>= 1.8.11, < 2)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-follow_redirects (0.3.0)
faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
ferrum (0.15)
faraday-net_http (3.4.2)
net-http (~> 0.5)
ferrum (0.17.1)
addressable (~> 2.5)
base64 (~> 0.2)
concurrent-ruby (~> 1.1)
webrick (~> 1.7)
websocket-driver (~> 0.7)
ffi (1.17.1)
ffi (1.17.1-aarch64-linux-gnu)
ffi (1.17.1-aarch64-linux-musl)
ffi (1.17.1-arm64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
ffi (1.17.1-x86_64-linux-musl)
foreman (0.88.1)
ffi (1.17.3)
ffi (1.17.3-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-musl)
ffi (1.17.3-arm64-darwin)
ffi (1.17.3-x86_64-linux-gnu)
ffi (1.17.3-x86_64-linux-musl)
foreman (0.90.0)
thor (~> 1.4)
geom2d (0.4.1)
globalid (1.2.1)
globalid (1.3.0)
activesupport (>= 6.1)
google-apis-core (0.15.1)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
google-apis-core (1.0.2)
addressable (~> 2.8, >= 2.8.7)
faraday (~> 2.13)
faraday-follow_redirects (~> 0.3)
googleauth (~> 1.14)
mini_mime (~> 1.1)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.22.0)
retriable (~> 3.1)
google-apis-iamcredentials_v1 (0.26.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.49.0)
google-apis-storage_v1 (0.59.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.7.1)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.2.1)
google-cloud-env (2.3.1)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.4.0)
google-cloud-storage (1.54.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.58.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (~> 0.13)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (~> 0.38)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.1.0)
googleauth (1.12.2)
google-logging-utils (0.2.0)
googleauth (1.16.1)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 3.0)
jwt (>= 1.4, < 4.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
hashdiff (1.1.2)
hexapdf (1.4.0)
hashdiff (1.2.1)
hexapdf (1.5.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.7)
htmlentities (4.4.2)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.13.0)
mini_magick (>= 4.9.5, < 5)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.1)
irb (1.15.2)
io-console (0.8.2)
irb (1.16.0)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.15.0)
jwt (2.9.3)
json (2.18.1)
jwt (3.1.2)
base64
language_server-protocol (3.17.0.5)
launchy (3.0.1)
launchy (3.1.1)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
letter_opener_web (3.0.0)
@@ -312,29 +307,28 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.24.1)
loofah (2.25.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.0.4)
matrix (0.4.2)
marcel (1.1.0)
matrix (0.4.3)
method_source (1.1.0)
mini_magick (4.13.2)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
msgpack (1.7.5)
multi_json (1.15.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
mysql2 (0.5.6)
net-http-persistent (4.0.5)
connection_pool (~> 2.2)
net-imap (0.5.9)
minitest (6.0.1)
prism (~> 1.5)
msgpack (1.8.0)
multi_json (1.19.1)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.2)
date
net-protocol
net-pop (0.1.2)
@@ -343,22 +337,19 @@ GEM
timeout
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.9)
mini_portile2 (~> 2.8.2)
nio4r (2.7.5)
nokogiri (1.19.0-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.9-aarch64-linux-gnu)
nokogiri (1.19.0-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.9-aarch64-linux-musl)
nokogiri (1.19.0-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.9-arm64-darwin)
nokogiri (1.19.0-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-gnu)
nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-musl)
racc (~> 1.4)
numo-narray (0.9.2.1)
oj (3.16.11)
numo-narray-alt (0.9.13)
oj (3.16.13)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
onnxruntime (0.10.1)
@@ -369,18 +360,25 @@ GEM
ffi
onnxruntime (0.10.1-x86_64-linux)
ffi
openssl (3.3.0)
openssl (4.0.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.3)
package_json (0.1.0)
pagy (9.3.3)
package_json (0.2.0)
pagy (43.2.8)
json
yaml
parallel (1.27.0)
parser (3.3.9.0)
parser (3.3.10.1)
ast (~> 2.4.1)
racc
pg (1.5.9)
pp (0.6.2)
pg (1.6.3)
pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
pp (0.6.3)
prettyprint
premailer (1.27.0)
addressable
@@ -390,23 +388,24 @@ GEM
actionmailer (>= 3)
net-smtp
premailer (~> 1.7, >= 1.7.9)
pretender (0.5.0)
actionpack (>= 6.1)
pretender (0.6.0)
actionpack (>= 7.1)
prettyprint (0.2.0)
prism (1.5.1)
pry (0.15.0)
prism (1.8.0)
pry (0.16.0)
coderay (~> 1.1)
method_source (~> 1.0)
reline (>= 0.6.0)
pry-rails (0.3.11)
pry (>= 0.13.0)
psych (5.2.6)
psych (5.3.1)
date
stringio
public_suffix (6.0.1)
puma (6.5.0)
public_suffix (7.0.2)
puma (7.2.0)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.3)
rack (3.2.4)
rack-proxy (0.7.7)
rack
rack-session (2.1.1)
@@ -414,22 +413,22 @@ GEM
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rackup (2.3.1)
rack (>= 3)
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)
rails (8.1.2)
actioncable (= 8.1.2)
actionmailbox (= 8.1.2)
actionmailer (= 8.1.2)
actionpack (= 8.1.2)
actiontext (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activemodel (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
bundler (>= 1.15.0)
railties (= 8.0.2.1)
railties (= 8.1.2)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -437,30 +436,32 @@ GEM
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.0.1)
rails-i18n (8.1.0)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
rails_autolink (1.1.8)
actionview (> 3.1)
activesupport (> 3.1)
railties (> 3.1)
railties (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
railties (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.0)
rdoc (6.14.2)
rake (13.3.1)
rdoc (7.1.0)
erb
psych (>= 4.0.0)
redis-client (0.23.0)
tsort
redis-client (0.26.4)
connection_pool
regexp_parser (2.11.3)
reline (0.6.2)
reline (0.6.3)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
@@ -468,35 +469,35 @@ GEM
uber (< 0.2.0)
request_store (1.7.0)
rack (>= 1.4)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
responders (3.2.0)
actionpack (>= 7.0)
railties (>= 7.0)
retriable (3.1.2)
rexml (3.4.4)
rotp (6.3.0)
rouge (4.5.2)
rqrcode (2.2.0)
rouge (4.7.0)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
rqrcode_core (1.2.0)
rspec-core (3.13.2)
rqrcode_core (~> 2.0)
rqrcode_core (2.1.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.2)
rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.1.0)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
rspec-rails (8.0.2)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.2)
rubocop (1.81.1)
rspec-support (3.13.6)
rubocop (1.82.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -504,87 +505,90 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-ast (>= 1.48.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1)
rubocop-ast (1.49.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.23.0)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.27.0)
prism (~> 1.7)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (3.3.0)
rubocop (~> 1.61)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.9.0)
lint_roller (~> 1.1)
rubocop (~> 1.81)
ruby-progressbar (1.13.0)
ruby-vips (2.2.2)
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyXL (3.4.33)
rubyXL (3.4.35)
nokogiri (>= 1.10.8)
rubyzip (>= 1.3.0)
rubyzip (2.3.2)
rubyzip (>= 3.2.2)
rubyzip (3.2.2)
securerandom (0.4.1)
semantic_range (3.1.0)
shakapacker (8.0.2)
shakapacker (9.5.0)
activesupport (>= 5.2)
package_json
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
sidekiq (7.3.7)
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
signet (0.19.0)
sidekiq (8.1.0)
connection_pool (>= 3.0.0)
json (>= 2.16.0)
logger (>= 1.7.0)
rack (>= 3.2.0)
redis-client (>= 0.26.0)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sqlite3 (2.5.0)
mini_portile2 (~> 2.8.0)
sqlite3 (2.5.0-aarch64-linux-gnu)
sqlite3 (2.5.0-aarch64-linux-musl)
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.7)
strip_attributes (1.14.1)
sqlite3 (2.9.0-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl)
sqlite3 (2.9.0-arm64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl)
stringio (3.2.0)
strip_attributes (2.0.1)
activemodel (>= 3.0, < 9.0)
strscan (3.1.5)
thor (1.4.0)
timeout (0.4.3)
strscan (3.1.7)
thor (1.5.0)
timeout (0.6.0)
trailblazer-option (0.1.2)
turbo-rails (2.0.11)
actionpack (>= 6.0.0)
railties (>= 6.0.0)
twitter_cldr (6.12.1)
tsort (0.2.0)
turbo-rails (2.0.21)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
twitter_cldr (6.14.0)
base64
camertron-eprun
cldr-plurals-runtime-rb (~> 1.1)
tzinfo
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2024.2)
tzinfo-data (1.2025.3)
tzinfo (>= 1.0.0)
uber (0.1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uniform_notifier (1.16.0)
uri (1.0.3)
unicode-emoji (4.2.0)
uniform_notifier (1.18.0)
uri (1.1.1)
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
@@ -593,24 +597,24 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.24.0)
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1)
webrick (1.9.2)
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.3)
yaml (0.4.0)
zeitwerk (2.7.4)
PLATFORMS
aarch64-linux
aarch64-linux-musl
arm64-darwin
ruby
x86_64-linux
x86_64-linux-musl
@@ -619,7 +623,7 @@ DEPENDENCIES
arabic-letter-connector
aws-sdk-s3
aws-sdk-secretsmanager
azure-storage-blob
azure-blob
better_html
bootsnap
brakeman
@@ -646,8 +650,7 @@ DEPENDENCIES
jwt
letter_opener_web
lograge
mysql2
numo-narray
numo-narray-alt
oj
onnxruntime
pagy
@@ -675,6 +678,7 @@ DEPENDENCIES
simplecov
sqlite3
strip_attributes
trilogy!
turbo-rails
twitter_cldr
tzinfo-data
@@ -682,7 +686,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 3.4.2p28
ruby 4.0.1
BUNDLED WITH
2.5.3
4.0.3
+1 -1
View File
@@ -1,7 +1,7 @@
<h1 align="center" style="border-bottom: none">
<div>
<a href="https://www.docuseal.com">
<img alt="DocuSeal" src="https://github.com/docusealco/docuseal/assets/5418788/c12cd051-81cd-4402-bc3a-92f2cfdc1b06" width="80" />
<img alt="DocuSeal" src="https://github.com/user-attachments/assets/38b45682-ffa4-4919-abde-d2d422325c44" width="80" />
<br>
</a>
DocuSeal
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class AccountCustomFieldsController < ApplicationController
before_action :load_account_config, only: :create
def create
authorize!(:create, Template)
@account_config.update!(account_config_params)
render json: @account_config.value
end
private
def load_account_config
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY)
end
def account_config_params
params.permit(
value: [[:uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description,
{ preferences: {},
default_value: [],
options: [%i[value uuid]],
validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w option_uuid]] }]]
)
end
end
+1
View File
@@ -46,6 +46,7 @@ class AccountsController < ApplicationController
def destroy
authorize!(:manage, current_account)
true_user.skip_reconfirmation!
true_user.update!(locked_at: Time.current, email: true_user.email.sub('@', '+removed@'))
true_user.account.update!(archived_at: Time.current)
+1 -1
View File
@@ -3,7 +3,7 @@
module Api
class ApiBaseController < ActionController::API
include ActiveStorage::SetCurrent
include Pagy::Backend
include Pagy::Method
DEFAULT_LIMIT = 10
MAX_LIMIT = 100
@@ -37,7 +37,7 @@ module Api
rescue Submitters::MaliciousFileExtension => e
Rollbar.error(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_entity
render json: { error: e.message }, status: :unprocessable_content
end
def build_new_cookie_signatures_json(submitter, attachment)
@@ -5,34 +5,17 @@ module Api
load_and_authorize_resource :submission
def index
is_merge = params[:merge] == 'true' &&
(@submission.schema_documents || @submission.template.schema_documents).size > 1
documents =
if @submission.submitters.all?(&:completed_at?)
last_submitter = @submission.submitters.max_by(&:completed_at)
if last_submitter.documents_attachments.blank?
last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(last_submitter)
end
last_submitter.documents_attachments
build_completed_documents(@submission, merge: is_merge)
else
values_hash = Submissions::GeneratePreviewAttachments.build_values_hash(@submission)
if @submission.preview_documents.present? &&
@submission.preview_documents.all? { |s| s.metadata['values_hash'] == values_hash }
@submission.preview_documents
else
ApplicationRecord.no_touching do
@submission.preview_documents.each(&:destroy)
end
Submissions::GeneratePreviewAttachments.call(@submission, values_hash:)
end
build_preview_documents(@submission, merge: is_merge)
end
ActiveRecord::Associations::Preloader.new(
records: documents,
associations: [:blob]
).call
ActiveRecord::Associations::Preloader.new(records: documents, associations: [:blob]).call
expires_at = Accounts.link_expires_at(current_account)
@@ -43,5 +26,50 @@ module Api
end
}
end
private
def build_completed_documents(submission, merge: false)
last_submitter = submission.submitters.max_by(&:completed_at)
if merge
if submission.merged_document_attachment.blank?
submission.merged_document_attachment =
Submissions::GenerateCombinedAttachment.call(last_submitter, with_audit: false)
end
[submission.merged_document_attachment]
else
if last_submitter.documents_attachments.blank?
last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(last_submitter)
end
last_submitter.documents_attachments
end
end
def build_preview_documents(submission, merge: false)
values_hash = Submissions::GeneratePreviewAttachments.build_values_hash(submission)
if merge
if submission.preview_merged_document_attachment.present? &&
submission.preview_merged_document_attachment.metadata['values_hash'] == values_hash
[submission.preview_merged_document_attachment]
else
ApplicationRecord.no_touching { submission.preview_merged_document_attachment&.destroy }
Submissions::GeneratePreviewAttachments.call(submission, values_hash:, merge: true)
end
elsif submission.preview_documents.present? &&
submission.preview_documents.all? { |s| s.metadata['values_hash'] == values_hash }
submission.preview_documents
else
ApplicationRecord.no_touching do
submission.preview_documents.each(&:destroy)
end
Submissions::GeneratePreviewAttachments.call(submission, values_hash:)
end
end
end
end
@@ -182,13 +182,13 @@ module Api
def submissions_params
permitted_attrs = [
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last,
:require_phone_2fa, :expire_at, :name,
:require_phone_2fa, :require_email_2fa, :expire_at, :name,
{
variables: {},
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,
:require_phone_2fa, :require_email_2fa, :order, :index, :invite_by,
{ metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body],
fields: [:name, :uuid, :default_value, :value, :title, :description,
:readonly, :required, :validation_pattern, :invalid_message,
+1 -1
View File
@@ -84,7 +84,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, :require_phone_2fa,
:completed, :phone, :application_key, :external_id, :go_to_last, :require_phone_2fa, :require_email_2fa,
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
fields: [[:name, :uuid, :default_value, :value, :required,
:readonly, :validation_pattern, :invalid_message,
+8 -3
View File
@@ -4,7 +4,7 @@ class ApplicationController < ActionController::Base
BROWSER_LOCALE_REGEXP = /\A\w{2}(?:-\w{2})?/
include ActiveStorage::SetCurrent
include Pagy::Backend
include Pagy::Method
check_authorization unless: :devise_controller?
@@ -17,12 +17,13 @@ class ApplicationController < ActionController::Base
helper_method :button_title,
:current_account,
:true_ability,
:form_link_host,
:svg_icon
impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
rescue_from Pagy::OverflowError do
rescue_from Pagy::RangeError do
redirect_to request.path
end
@@ -59,7 +60,7 @@ class ApplicationController < ActionController::Base
def pagy_auto(collection, **keyword_args)
if current_ability.can?(:manage, :countless)
pagy_countless(collection, **keyword_args)
pagy(:countless, collection, **keyword_args)
else
pagy(collection, **keyword_args)
end
@@ -102,6 +103,10 @@ class ApplicationController < ActionController::Base
current_user&.account
end
def true_ability
@true_ability ||= Ability.new(true_user)
end
def maybe_redirect_to_setup
redirect_to setup_index_path unless User.exists?
end
@@ -1,4 +1,11 @@
# frozen_string_literal: true
class InvitationsController < Devise::PasswordsController
def update
super do |resource|
resource.confirmed_at ||= Time.current if resource.errors.empty?
PasswordsController::Current.user = resource
end
end
end
+2
View File
@@ -5,6 +5,8 @@ class PasswordsController < Devise::PasswordsController
skip_before_action :require_no_authentication, only: %i[edit update]
# rubocop:enable Rails/LexicallyScopedActionFilter
around_action :with_browser_locale
class Current < ActiveSupport::CurrentAttributes
attribute :user
end
+8 -1
View File
@@ -9,7 +9,14 @@ class ProfileController < ApplicationController
def update_contact
if current_user.update(contact_params)
redirect_to settings_profile_index_path, notice: I18n.t('contact_information_has_been_update')
if current_user.try(:pending_reconfirmation?) && current_user.previous_changes.key?(:unconfirmed_email)
SendConfirmationInstructionsJob.perform_async('user_id' => current_user.id)
redirect_to settings_profile_index_path,
notice: I18n.t('a_confirmation_email_has_been_sent_to_the_new_email_address')
else
redirect_to settings_profile_index_path, notice: I18n.t('contact_information_has_been_update')
end
else
render :index, status: :unprocessable_content
end
+4 -2
View File
@@ -16,7 +16,9 @@ class StartFormController < ApplicationController
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
def show
raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa']
if @template.preferences['require_phone_2fa'] || @template.preferences['require_email_2fa']
raise ActionController::RoutingError, I18n.t('not_found')
end
if @template.shared_link?
@submitter = @template.submissions.new(account_id: @template.account_id)
@@ -81,7 +83,7 @@ class StartFormController < ApplicationController
@submitter = Submitter.where(submission: @template.submissions)
.where.not(completed_at: nil)
.find_by!(required_params)
.find_by!(required_params.except('name'))
end
private
@@ -10,6 +10,7 @@ class SubmissionEventsController < ApplicationController
'api_complete_form' => 'check',
'send_reminder_email' => 'mail_forward',
'send_2fa_sms' => '2fa',
'send_2fa_email' => '2fa',
'send_sms' => 'send',
'phone_verified' => 'phone_check',
'email_verified' => 'email_check',
+11 -2
View File
@@ -12,6 +12,14 @@ class SubmissionsController < ApplicationController
authorize!(:create, Submission)
end
FIELD_ICONS = {
'text' => 'text_size', 'signature' => 'writing_sign', 'date' => 'calendar_event',
'number' => 'square_number_1', 'image' => 'photo', 'initials' => 'letter_case_upper',
'file' => 'paperclip', 'select' => 'select', 'checkbox' => 'checkbox', 'radio' => 'circle_dot',
'stamp' => 'rubber_stamp', 'cells' => 'columns_3', 'multiple' => 'checks', 'phone' => 'phone_check',
'payment' => 'credit_card', 'verification' => 'id', 'kba' => 'user_scan'
}.freeze
def show
@submission = Submissions.preload_with_pages(@submission)
@@ -45,14 +53,15 @@ class SubmissionsController < ApplicationController
else
submissions_attrs = submissions_params[:submission].to_h.values
submissions_attrs, =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, @template)
submissions_attrs, _, new_fields =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, @template, add_fields: true)
Submissions.create_from_submitters(template: @template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
submissions_attrs:,
new_fields:,
params: params.merge('send_completed_email' => true))
end
@@ -53,8 +53,11 @@ class SubmissionsPreviewController < ApplicationController
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.submitters.any? do |e|
e.preferences['require_phone_2fa'] || e.preferences['require_email_2fa']
end
return true if submission.template&.preferences&.dig('require_phone_2fa')
return true if submission.template&.preferences&.dig('require_email_2fa')
!submission_valid_ttl?(submission)
end
+16
View File
@@ -17,6 +17,7 @@ class SubmitFormController < ApplicationController
submission = @submitter.submission
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
return render :email_2fa if require_email_2fa?(@submitter)
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
@@ -47,6 +48,11 @@ class SubmitFormController < ApplicationController
end
def update
if require_email_2fa?(@submitter)
return render json: { error: I18n.t('verification_required_refresh_the_page_and_pass_2fa') },
status: :unprocessable_content
end
if @submitter.completed_at?
return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_content
end
@@ -77,6 +83,8 @@ class SubmitFormController < ApplicationController
def completed
raise ActionController::RoutingError, I18n.t('not_found') if @submitter.account.archived_at?
redirect_to submit_form_path(params[:submit_form_slug]) if require_email_2fa?(@submitter)
end
def success; end
@@ -109,4 +117,12 @@ class SubmitFormController < ApplicationController
ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments)
.preload(:blob).index_by(&:uuid)
end
def require_email_2fa?(submitter)
return false if submitter.submission.template&.preferences&.dig('require_email_2fa') != true &&
submitter.preferences['require_email_2fa'] != true
return false if cookies.encrypted[:email_2fa_slug] == submitter.slug
true
end
end
@@ -0,0 +1,52 @@
# frozen_string_literal: true
class SubmitFormEmail2fasController < ApplicationController
around_action :with_browser_locale
skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_submitter
COOKIES_TTL = 12.hours
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
def create
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
value = [@submitter.email.downcase.strip, @submitter.slug].join(':')
if EmailVerificationCodes.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value)
SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
cookies.encrypted[:email_2fa_slug] =
{ value: @submitter.slug, expires: COOKIES_TTL.from_now, **COOKIES_DEFAULTS }
redirect_to submit_form_path(@submitter.slug)
else
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:invalid_code)
end
rescue RateLimit::LimitApproached
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:too_many_attempts)
end
def update
if @submitter.submission_events.where(event_type: 'send_2fa_email').exists?(created_at: 15.seconds.ago..)
return redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:rate_limit_exceeded)
end
RateLimit.call("send-email-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
SendSubmitterVerificationEmailJob.perform_async('submitter_id' => @submitter.id, 'locale' => I18n.locale.to_s)
redir_params = params[:resend] ? { alert: I18n.t(:code_has_been_resent) } : {}
redirect_to submit_form_path(@submitter.slug, status: :sent), **redir_params
rescue RateLimit::LimitApproached
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:too_many_attempts)
end
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
end
end
@@ -0,0 +1,56 @@
# frozen_string_literal: true
class TemplatesCloneController < ApplicationController
load_and_authorize_resource :template, instance_name: :base_template
def new
authorize!(:create, Template)
@template = Template.new(name: "#{@base_template.name} (#{I18n.t('clone')})")
end
def create
ActiveRecord::Associations::Preloader.new(
records: [@base_template],
associations: [schema_documents: :preview_images_attachments]
).call
@template = Templates::Clone.call(@base_template, author: current_user,
name: params.dig(:template, :name),
folder_name: params[:folder_name])
authorize!(:create, @template)
if params[:account_id].present? && true_ability.authorize!(:manage, Account.find(params[:account_id]))
@template.account_id = params[:account_id]
@template.author = true_user if true_user.account_id == @template.account_id
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
else
@template.account = current_account
end
Templates.maybe_assign_access(@template)
if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template)
SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
maybe_redirect_to_template(@template)
else
render turbo_stream: turbo_stream.replace(:modal, partial: 'templates_clone/form'), status: :unprocessable_content
end
end
private
def maybe_redirect_to_template(template)
if template.account == current_account
redirect_to(edit_template_path(template))
else
redirect_back(fallback_location: root_path, notice: I18n.t('template_has_been_cloned'))
end
end
end
+5 -47
View File
@@ -3,8 +3,6 @@
class TemplatesController < ApplicationController
load_and_authorize_resource :template
before_action :load_base_template, only: %i[new create]
def show
submissions = @template.submissions.accessible_by(current_ability)
submissions = submissions.active if @template.archived_at.blank?
@@ -26,9 +24,7 @@ class TemplatesController < ApplicationController
redirect_to root_path
end
def new
@template.name = "#{@base_template.name} (#{I18n.t('clone')})" if @base_template
end
def new; end
def edit
ActiveRecord::Associations::Preloader.new(
@@ -48,37 +44,18 @@ class TemplatesController < ApplicationController
end
def create
if @base_template
ActiveRecord::Associations::Preloader.new(
records: [@base_template],
associations: [schema_documents: :preview_images_attachments]
).call
@template = Templates::Clone.call(@base_template, author: current_user,
name: params.dig(:template, :name),
folder_name: params[:folder_name])
else
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
end
if params[:account_id].present? && authorized_clone_account_id?(params[:account_id])
@template.account_id = params[:account_id]
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
else
@template.account = current_account
end
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
@template.account = current_account
Templates.maybe_assign_access(@template)
if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
maybe_redirect_to_template(@template)
redirect_to(edit_template_path(@template))
else
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_content
end
@@ -132,23 +109,4 @@ class TemplatesController < ApplicationController
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
)
end
def authorized_clone_account_id?(account_id)
true_user.account_id.to_s == account_id.to_s ||
true_user.account.linked_accounts.accessible_by(current_ability).exists?(id: account_id)
end
def maybe_redirect_to_template(template)
if template.account == current_account
redirect_to(edit_template_path(@template))
else
redirect_back(fallback_location: root_path, notice: I18n.t('template_has_been_cloned'))
end
end
def load_base_template
return if params[:base_template_id].blank?
@base_template = Template.accessible_by(current_ability).find_by(id: params[:base_template_id])
end
end
@@ -11,11 +11,14 @@ class TemplatesDetectFieldsController < ApplicationController
sse = SSE.new(response.stream)
documents = @template.schema_documents.preload(:blob)
documents = documents.where(uuid: params[:attachment_uuid]) if params[:attachment_uuid].present?
page_number = params[:page].presence&.to_i
documents.each do |document|
io = StringIO.new(document.download)
Templates::DetectFields.call(io, attachment: document) do |(attachment_uuid, page, fields)|
Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)|
sse.write({ attachment_uuid:, page:, fields: })
end
end
@@ -54,7 +54,7 @@ class TemplatesPreferencesController < ApplicationController
documents_copy_email_attach_documents documents_copy_email_reply_to
completed_notification_email_attach_documents
completed_redirect_url validate_unique_submitters
require_all_submitters submitters_order require_phone_2fa
require_all_submitters submitters_order require_phone_2fa require_email_2fa
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
completed_notification_email_subject completed_notification_email_body
completed_notification_email_enabled completed_notification_email_attach_audit] +
@@ -32,7 +32,7 @@ class TimestampServerController < ApplicationController
uri = Addressable::URI.parse(url)
conn = Faraday.new(uri.origin) do |c|
c.basic_auth(uri.user, uri.password) if uri.password.present?
c.request :authorization, :basic, uri.user, uri.password if uri.password.present?
end
response = conn.post(uri.path, req.to_der,
+10 -2
View File
@@ -54,7 +54,8 @@ class UsersController < ApplicationController
def update
return redirect_to settings_users_path, notice: I18n.t('unable_to_update_user') if Docuseal.demo?
attrs = user_params.compact_blank.merge(user_params.slice(:archived_at))
attrs = user_params.compact_blank
attrs = attrs.merge(user_params.slice(:archived_at)) if current_ability.can?(:create, @user)
if params.dig(:user, :account_id).present?
account = Account.accessible_by(current_ability).find(params.dig(:user, :account_id))
@@ -65,7 +66,14 @@ class UsersController < ApplicationController
end
if @user.update(attrs.except(*(current_user == @user ? %i[password otp_required_for_login role] : %i[password])))
redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_updated')
if @user.try(:pending_reconfirmation?) && @user.previous_changes.key?(:unconfirmed_email)
SendConfirmationInstructionsJob.perform_async('user_id' => @user.id)
redirect_back fallback_location: settings_users_path,
notice: I18n.t('a_confirmation_email_has_been_sent_to_the_new_email_address')
else
redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_updated')
end
else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_content
end
@@ -15,7 +15,7 @@ class WebhookSettingsController < ApplicationController
@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))
@pagy, @webhook_events = pagy(:countless, @webhook_events.order(id: :desc))
render :show
end
@@ -26,7 +26,7 @@ class WebhookSettingsController < ApplicationController
@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))
@pagy, @webhook_events = pagy(:countless, @webhook_events.order(id: :desc))
end
def new; end
+7
View File
@@ -51,6 +51,8 @@ import ToggleClasses from './elements/toggle_classes'
import AutosizeField from './elements/autosize_field'
import GoogleDriveFilePicker from './elements/google_drive_file_picker'
import OpenModal from './elements/open_modal'
import BarChart from './elements/bar_chart'
import FieldCondition from './elements/field_condition'
import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -140,6 +142,8 @@ safeRegisterElement('toggle-classes', ToggleClasses)
safeRegisterElement('autosize-field', AutosizeField)
safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
safeRegisterElement('open-modal', OpenModal)
safeRegisterElement('bar-chart', BarChart)
safeRegisterElement('field-condition', FieldCondition)
safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () {
@@ -151,14 +155,17 @@ safeRegisterElement('template-builder', class extends HTMLElement {
this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)),
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
backgroundColor: '#faf7f5',
locale: this.dataset.locale,
withPhone: this.dataset.withPhone === 'true',
withVerification: ['true', 'false'].includes(this.dataset.withVerification) ? this.dataset.withVerification === 'true' : null,
withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null,
withLogo: this.dataset.withLogo !== 'false',
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withCustomFields: true,
withPayment: this.dataset.withPayment === 'true',
isPaymentConnected: this.dataset.isPaymentConnected === 'true',
withFormula: this.dataset.withFormula === 'true',
+51 -6
View File
@@ -4,15 +4,26 @@ import { isValidSignatureCanvas } from './submission_form/validate_signature'
window.customElements.define('draw-signature', class extends HTMLElement {
connectedCallback () {
const scale = 3
this.canvas.width = this.canvas.parentNode.clientWidth * scale
this.canvas.height = this.canvas.parentNode.clientHeight * scale
this.canvas.getContext('2d').scale(scale, scale)
this.setCanvasSize()
this.pad = new SignaturePad(this.canvas)
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
const { width, height } = this.canvas
this.setCanvasSize()
if (this.canvas.width !== width || this.canvas.height !== height) {
this.redrawCanvas(width, height)
}
})
})
this.resizeObserver.observe(this.canvas.parentNode)
if (this.dataset.color) {
this.pad.penColor = this.dataset.color
}
@@ -57,6 +68,40 @@ window.customElements.define('draw-signature', class extends HTMLElement {
this.updateSubmitButtonVisibility()
}
disconnectedCallback () {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
}
setCanvasSize () {
const scale = 3
const width = this.canvas.parentNode.clientWidth
const height = this.canvas.parentNode.clientHeight
if (this.canvas.width !== width * scale || this.canvas.height !== height * scale) {
this.canvas.width = width * scale
this.canvas.height = height * scale
this.canvas.getContext('2d').scale(scale, scale)
}
}
redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight
const scaledData = this.pad.toData().map((stroke) => ({
...stroke,
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
}))
this.pad.fromData(scaledData)
}
}
updateSubmitButtonVisibility () {
if (this.pad.isEmpty()) {
this.submitButton.style.display = 'none'
+50
View File
@@ -0,0 +1,50 @@
export default class extends HTMLElement {
connectedCallback () {
this.chartLabels = JSON.parse(this.dataset.labels || '[]')
this.chartDatasets = JSON.parse(this.dataset.datasets || '[]')
this.initChart()
}
disconnectedCallback () {
if (this.chartInstance) {
this.chartInstance.destroy()
this.chartInstance = null
}
}
async initChart () {
const { default: Chart } = await import(/* webpackChunkName: "chartjs" */ 'chart.js/auto')
const canvas = this.querySelector('canvas')
const ctx = canvas.getContext('2d')
this.chartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: this.chartLabels,
datasets: this.chartDatasets
},
options: {
responsive: true,
maintainAspectRatio: true,
animation: false,
scales: {
y: {
beginAtZero: true,
grace: '20%',
ticks: {
precision: 0
}
}
},
plugins: {
legend: {
display: false
}
}
}
})
}
}
+167
View File
@@ -0,0 +1,167 @@
export default class extends HTMLElement {
connectedCallback () {
this.targetId = this.dataset.targetId
this.fieldId = this.dataset.fieldId
this.action = (this.dataset.action || '').trim()
this.expectedValue = this.dataset.value
this.targetEl = document.getElementById(this.targetId)
this.sourceEl = document.getElementById(this.fieldId)
this.bindListeners()
this.evaluateAndApply()
}
disconnectedCallback () {
this.unbindListeners()
}
bindListeners () {
this.eventsFor(this.sourceEl).forEach((ev) => {
this.sourceEl.addEventListener(ev, this.evaluateAndApply)
})
}
unbindListeners () {
this.eventsFor(this.sourceEl).forEach((ev) => {
this.sourceEl.removeEventListener(ev, this.evaluateAndApply)
})
}
eventsFor (el) {
if (!el) return []
const tag = el.tagName.toLowerCase()
if (tag === 'textarea') return ['input']
if (tag === 'input') return ['input', 'change']
return ['change']
}
evaluateAndApply = () => {
const fieldConditions = document.querySelectorAll(`field-condition[data-target-id="${this.targetId}"]`)
const result = [...fieldConditions].reduce((acc, cond) => {
if (cond.dataset.operation === 'or') {
acc.push(acc.pop() || cond.checkCondition())
} else {
acc.push(cond.checkCondition())
}
return acc
}, [])
this.apply(!result.includes(false))
}
checkCondition () {
const action = this.action
const actual = this.getSourceValue()
const expected = this.expectedValue
if (action === 'empty' || action === 'unchecked') return this.isEmpty(actual)
if (action === 'not_empty' || action === 'checked') return !this.isEmpty(actual)
if (['equal', 'not_equal', 'greater_than', 'less_than'].includes(action) && this.sourceEl?.getAttribute('type') === 'number') {
if (this.isEmpty(actual) || this.isEmpty(expected)) return false
const actualNumber = parseFloat(actual)
const expectedNumber = parseFloat(expected)
if (Number.isNaN(actualNumber) || Number.isNaN(expectedNumber)) return false
if (action === 'equal') return Math.abs(actualNumber - expectedNumber) < Number.EPSILON
if (action === 'not_equal') return Math.abs(actualNumber - expectedNumber) > Number.EPSILON
if (action === 'greater_than') return actualNumber > expectedNumber
if (action === 'less_than') return actualNumber < expectedNumber
return false
}
if (action === 'equal') {
const list = Array.isArray(actual) ? actual : [actual]
return list.filter((v) => v !== null && v !== undefined).map(String).includes(String(expected))
}
if (action === 'contains') return this.contains(actual, expected)
if (action === 'not_equal') {
const list = Array.isArray(actual) ? actual : [actual]
return !list.filter((v) => v !== null && v !== undefined).map(String).includes(String(expected))
}
if (action === 'does_not_contain') return !this.contains(actual, expected)
return true
}
getSourceValue () {
const el = this.sourceEl
if (!el) return
const tag = el.tagName.toLowerCase()
const type = (el.getAttribute('type') || '').toLowerCase()
if (tag === 'select') return el.value
if (tag === 'textarea') return el.value
if (tag === 'input' && type === 'checkbox') return el.checked ? (el.value || '1') : null
if (tag === 'input') return el.value
return el.value ?? null
}
isEmpty (obj) {
if (obj == null) return true
if (Array.isArray(obj)) {
return obj.length === 0
}
if (typeof obj === 'string') {
return obj.trim().length === 0
}
if (typeof obj === 'object') {
return Object.keys(obj).length === 0
}
if (obj === false) {
return true
}
return false
}
contains (actual, expected) {
if (expected === null || expected === undefined) return false
const exp = String(expected)
if (Array.isArray(actual)) return actual.filter((v) => v !== null && v !== undefined).map(String).includes(exp)
if (typeof actual === 'string') return actual.includes(exp)
return actual !== null && actual !== undefined && String(actual) === exp
}
apply (passed) {
const controls = this.targetEl.matches('input, select, textarea, button')
? [this.targetEl]
: Array.from(this.targetEl.querySelectorAll('input, select, textarea, button'))
if (passed) {
this.targetEl.style.display = ''
this.targetEl.labels.forEach((label) => { label.style.display = '' })
controls.forEach((c) => (c.disabled = false))
} else {
this.targetEl.style.display = 'none'
this.targetEl.labels.forEach((label) => { label.style.display = 'none' })
controls.forEach((c) => (c.disabled = true))
}
}
}
+52 -7
View File
@@ -5,17 +5,28 @@ export default targetable(class extends HTMLElement {
static [target.static] = ['canvas', 'input', 'clear', 'button']
async connectedCallback () {
const scale = 3
this.canvas.width = this.canvas.parentNode.clientWidth * scale
this.canvas.height = this.canvas.parentNode.clientHeight * scale
this.canvas.getContext('2d').scale(scale, scale)
const { default: SignaturePad } = await import('signature_pad')
this.setCanvasSize()
this.pad = new SignaturePad(this.canvas)
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
const { width, height } = this.canvas
this.setCanvasSize()
if (this.canvas.width !== width || this.canvas.height !== height) {
this.redrawCanvas(width, height)
}
})
})
this.resizeObserver.observe(this.canvas.parentNode)
this.clear.addEventListener('click', (e) => {
e.preventDefault()
@@ -47,4 +58,38 @@ export default targetable(class extends HTMLElement {
this.closest('form').requestSubmit()
}
disconnectedCallback () {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
}
setCanvasSize () {
const scale = 3
const width = this.canvas.parentNode.clientWidth
const height = this.canvas.parentNode.clientWidth / 2.5
if (this.canvas.width !== width * scale || this.canvas.height !== height * scale) {
this.canvas.width = width * scale
this.canvas.height = height * scale
this.canvas.getContext('2d').scale(scale, scale)
}
}
redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight
const scaledData = this.pad.toData().map((stroke) => ({
...stroke,
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
}))
this.pad.fromData(scaledData)
}
}
})
+1 -1
View File
@@ -12,6 +12,6 @@ export default class extends HTMLElement {
}
get button () {
return this.querySelector('button')
return this.querySelector('button, label')
}
}
+1
View File
@@ -37,6 +37,7 @@ safeRegisterElement('submission-form', class extends HTMLElement {
withSignatureId: this.dataset.withSignatureId === 'true',
requireSigningReason: this.dataset.requireSigningReason === 'true',
withConfetti: this.dataset.withConfetti !== 'false',
withFieldLabels: this.dataset.withFieldLabels !== 'false',
withDisclosure: this.dataset.withDisclosure === 'true',
reuseSignature: this.dataset.reuseSignature !== 'false',
withTypedSignature: this.dataset.withTypedSignature !== 'false',
+20 -6
View File
@@ -60,6 +60,11 @@
class="object-contain mx-auto"
:src="stamp.url"
>
<img
v-else-if="field.type === 'kba' && kba"
class="object-contain mx-auto"
:src="kba.url"
>
<div
v-else-if="field.type === 'signature' && signature"
class="flex justify-between h-full gap-1 overflow-hidden w-full"
@@ -177,8 +182,8 @@
</div>
<div
v-else-if="field.type === 'cells'"
class="w-full flex items-center"
:class="{ 'justify-end': field.preferences?.align === 'right', ...fontClasses }"
class="w-full flex"
:class="{ 'justify-end': field.preferences?.align === 'right', ...alignClasses, ...fontClasses }"
>
<div
v-for="(char, index) in modelValue"
@@ -272,7 +277,7 @@
<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'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconId, IconUserScan } from '@tabler/icons-vue'
export default {
name: 'FieldArea',
@@ -391,7 +396,8 @@ export default {
stamp: this.t('stamp'),
payment: this.t('payment'),
phone: this.t('phone'),
verification: this.t('verify_id')
verification: this.t('verify_id'),
kba: this.t('kba')
}
},
strikethroughWidth () {
@@ -454,7 +460,8 @@ export default {
multiple: IconChecks,
phone: IconPhoneCheck,
payment: IconCreditCard,
verification: IconId
verification: IconId,
kba: IconUserScan
}
},
image () {
@@ -471,6 +478,13 @@ export default {
return null
}
},
kba () {
if (this.field.type === 'kba') {
return this.attachmentsIndex[this.modelValue]
} else {
return null
}
},
signature () {
if (this.field.type === 'signature') {
return this.attachmentsIndex[this.modelValue]
@@ -560,7 +574,7 @@ export default {
return style
},
isNarrow () {
return this.area.h > 0 && (this.area.w / this.area.h) > 6
return this.area.h > 0 && ((this.area.w * this.pageWidth) / (this.area.h * this.pageHeight)) > 4.5
}
},
watch: {
+1 -1
View File
@@ -42,7 +42,7 @@
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" />
<div class="text-center">
<div class="text-center flex">
<input
:id="field.uuid"
ref="input"
+157 -42
View File
@@ -8,7 +8,7 @@
:scroll-el="scrollEl"
:with-signature-id="withSignatureId"
:attachments-index="attachmentsIndex"
:with-label="!isAnonymousChecboxes && showFieldNames"
:with-label="withFieldLabels && !isAnonymousChecboxes && showFieldNames"
:current-step="currentStepFields"
:scroll-padding="scrollPadding"
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
@@ -69,7 +69,7 @@
id="expand_form_button"
class="btn btn-neutral flex text-white absolute bottom-0 w-full mb-3 expand-form-button text-base"
style="width: 96%; margin-left: 2%"
@click.prevent="[isFormVisible = true, scrollIntoField(currentField)]"
@click.prevent="[isFormVisible = true, $nextTick(() => scrollIntoField(currentField))]"
>
<template v-if="['initials', 'signature'].includes(currentField.type)">
<IconWritingSign stroke-width="1.5" />
@@ -133,6 +133,12 @@
name="_method"
type="hidden"
>
<input
v-if="validate === false"
value="false"
name="validate"
type="hidden"
>
<div class="md:mt-4">
<div v-if="['cells', 'text'].includes(currentField.type)">
<TextStep
@@ -351,8 +357,6 @@
class="base-checkbox !h-7 !w-7"
:required="field.required"
:checked="!!values[field.uuid]"
@invalid="$event.target.setCustomValidity(t('please_check_the_box_to_continue'))"
@change="$event.target.setCustomValidity($event.target.validity.valueMissing ? t('please_check_the_box_to_continue') : '')"
@click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
>
<span
@@ -391,7 +395,9 @@
v-model="values[currentField.uuid]"
:reason="values[currentField.preferences?.reason_field_uuid]"
:field="currentField"
:values="values"
:previous-value="previousSignatureValueFor(currentField) || previousSignatureValue"
:touch-attachment-uuid="previousSignatureValue"
:with-typed-signature="withTypedSignature"
:remember-signature="rememberSignature"
:attachments-index="attachmentsIndex"
@@ -403,6 +409,7 @@
:submitter="submitter"
:show-field-names="showFieldNames"
@update:reason="values[currentField.preferences?.reason_field_uuid] = $event"
@touch-attachment="attachmentsIndex[previousSignatureValue] ? attachmentsIndex[previousSignatureValue].created_at = new Date() : null"
@attached="attachments.push($event)"
@start="scrollIntoField(currentField)"
@minimize="minimizeForm"
@@ -414,6 +421,7 @@
v-model="values[currentField.uuid]"
:field="currentField"
:dry-run="dryRun"
:submitter="submitter"
:previous-value="previousInitialsValue"
:attachments-index="attachmentsIndex"
:show-field-names="showFieldNames"
@@ -461,6 +469,17 @@
@focus="scrollIntoField(currentField)"
@submit="!isSubmitting && submitStep()"
/>
<KbaStep
v-else-if="currentField.type === 'kba'"
ref="currentStep"
:key="currentField.uuid"
:submitter="submitter"
:empty-value-required-step="emptyValueRequiredStep"
:field="currentField"
:submitter-slug="submitterSlug"
:values="values"
@submit="!isSubmitting && submitStep()"
/>
<VerificationStep
v-else-if="currentField.type === 'verification'"
ref="currentStep"
@@ -476,7 +495,7 @@
/>
</div>
<div
v-if="(currentField.type !== 'payment' && currentField.type !== 'verification') || submittedValues[currentField.uuid]"
v-if="(currentField.type !== 'payment' && currentField.type !== 'verification' && currentField.type !== 'kba') || submittedValues[currentField.uuid]"
:class="currentField.type === 'signature' ? 'mt-2' : 'mt-4 md:mt-6'"
>
<button
@@ -533,17 +552,21 @@
/>
<div
v-if="stepFields.length < 80"
class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1"
class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1 select-none"
>
<div class="flex items-center flex-wrap steps-progress">
<a
<template
v-for="(step, index) in stepFields"
:key="step[0].uuid"
href="#"
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1"
:class="{ 'bg-base-300 steps-progress-current': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(index, true)]"
/>
>
<a
v-if="!onlyRequiredFields || step.some((f) => f.required)"
href="#"
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1"
:class="{ 'bg-base-300 steps-progress-current': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(index, true)]"
/>
</template>
</div>
</div>
<div
@@ -565,6 +588,7 @@ import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step'
import PaymentStep from './payment_step'
import VerificationStep from './verification_step'
import KbaStep from './kba_step'
import TextStep from './text_step'
import NumberStep from './number_step'
import DateStep from './date_step'
@@ -576,6 +600,18 @@ import AppearsOn from './appears_on'
import i18n from './i18n'
import { sanitizeUrl } from '@braintree/sanitize-url'
if (typeof URL.canParse !== 'function') {
URL.canParse = function (url, base) {
try {
const parsed = new URL(url, base)
return !!parsed
} catch {
return false
}
}
}
const isEmpty = (obj) => {
if (obj == null) return true
@@ -609,6 +645,7 @@ export default {
AttachmentStep,
InitialsStep,
VerificationStep,
KbaStep,
InviteForm,
MultiSelectStep,
IconInnerShadowTop,
@@ -661,6 +698,11 @@ export default {
required: false,
default: false
},
onlyRequiredFields: {
type: Boolean,
required: false,
default: false
},
requireSigningReason: {
type: Boolean,
required: false,
@@ -718,6 +760,11 @@ export default {
required: false,
default: false
},
validate: {
type: Boolean,
required: false,
default: true
},
withDisclosure: {
type: Boolean,
required: false,
@@ -728,6 +775,11 @@ export default {
required: false,
default: true
},
withFieldLabels: {
type: Boolean,
required: false,
default: true
},
withConfetti: {
type: Boolean,
required: false,
@@ -887,10 +939,12 @@ export default {
}, {})
},
attachmentConditionsIndex () {
const cache = {}
return this.schema.reduce((acc, item) => {
if (item.conditions?.length) {
if (item.conditions.every((c) => this.fieldsUuidIndex[c.field_uuid])) {
acc[item.attachment_uuid] = this.checkFieldConditions(item)
acc[item.attachment_uuid] = this.checkFieldConditions(item, cache)
} else {
acc[item.attachment_uuid] = true
}
@@ -911,7 +965,13 @@ export default {
submitButtonText () {
if (this.alwaysMinimize) {
return this.t('submit')
} else if (this.stepFields.length === this.currentStep + 1) {
} else if (!this.onlyRequiredFields && this.stepFields.length === this.currentStep + 1) {
if (this.currentField.type === 'signature') {
return this.t('sign_and_complete')
} else {
return this.t('complete')
}
} else if (this.onlyRequiredFields && !this.findNextStep(this.currentStep)) {
if (this.currentField.type === 'signature') {
return this.t('sign_and_complete')
} else {
@@ -957,7 +1017,9 @@ export default {
},
previousInitialsValue () {
if (this.reuseSignature !== false) {
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
const initialsField = this.fields.findLast
? this.fields.findLast((field) => field.type === 'initials' && !!this.values[field.uuid])
: [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
return this.values[initialsField?.uuid]
} else {
@@ -984,13 +1046,15 @@ export default {
return this.readonlyFields.filter((f) => f.conditions?.length)
},
readonlyFields () {
return this.fields.filter((f) => f.readonly && this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f))
const cache = {}
return this.fields.filter((f) => f.readonly && this.checkFieldConditions(f, cache) && this.checkFieldDocumentsConditions(f))
},
stepFields () {
const verificationFields = []
const sortedFields = this.fields.reduce((acc, f) => {
if (f.type === 'verification') {
if (f.type === 'verification' || f.type === 'kba') {
verificationFields.push(f)
} else if (!f.readonly) {
acc.push(f)
@@ -1039,10 +1103,12 @@ export default {
sortedFields.push(verificationFields.pop())
}
const cache = {}
return sortedFields.reduce((acc, f) => {
const prevStep = acc[acc.length - 1]
if (this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f)) {
if (this.checkFieldConditions(f, cache) && this.checkFieldDocumentsConditions(f)) {
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox' && !f.description) {
prevStep.push(f)
} else {
@@ -1054,7 +1120,9 @@ export default {
}, [])
},
formulaFields () {
return this.fields.filter((f) => f.preferences?.formula && f.type !== 'payment' && this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f))
const cache = {}
return this.fields.filter((f) => f.preferences?.formula && f.type !== 'payment' && this.checkFieldConditions(f, cache) && this.checkFieldDocumentsConditions(f))
},
attachmentsIndex () {
return this.attachments.reduce((acc, a) => {
@@ -1116,15 +1184,17 @@ export default {
this.currentStep = Math.max(stepIndex, 0)
} else if (this.goToLast) {
const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.submittedValues[f.uuid])))
const lastFilledStepIndex = this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1
const lastFilledStepIndex = this.stepFields.indexOf(this.stepFields.findLast
? this.stepFields.findLast((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))
: [...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1
const indexesList = [this.stepFields.length - 1]
if (requiredEmptyStepIndex !== -1) {
if (requiredEmptyStepIndex !== -1 && (!this.onlyRequiredFields || this.stepFields[requiredEmptyStepIndex].some((f) => f.required))) {
indexesList.push(requiredEmptyStepIndex)
}
if (lastFilledStepIndex !== -1) {
if (lastFilledStepIndex !== -1 && (!this.onlyRequiredFields || this.stepFields[lastFilledStepIndex].some((f) => f.required))) {
indexesList.push(lastFilledStepIndex)
}
@@ -1184,27 +1254,35 @@ export default {
return true
}
},
checkFieldConditions (field) {
checkFieldConditions (field, cache = {}) {
const cacheKey = field.uuid || field.attachment_uuid
if (cache[cacheKey] !== undefined) {
return cache[cacheKey]
}
if (field.conditions?.length) {
const result = field.conditions.reduce((acc, cond) => {
if (cond.operation === 'or') {
acc.push(acc.pop() || this.checkFieldCondition(cond))
acc.push(acc.pop() || this.checkFieldCondition(cond, cache))
} else {
acc.push(this.checkFieldCondition(cond))
acc.push(this.checkFieldCondition(cond, cache))
}
return acc
}, [])
return !result.includes(false)
cache[cacheKey] = !result.includes(false)
} else {
return true
cache[cacheKey] = true
}
return cache[cacheKey]
},
checkFieldCondition (condition) {
checkFieldCondition (condition, cache = {}) {
const field = this.fieldsUuidIndex[condition.field_uuid]
if (['not_empty', 'checked', 'equal', 'contains'].includes(condition.action) && field && !this.checkFieldConditions(field)) {
if (['not_empty', 'checked', 'equal', 'contains', 'greater_than', 'less_than'].includes(condition.action) && field && !this.checkFieldConditions(field, cache)) {
return false
}
@@ -1214,20 +1292,50 @@ export default {
return isEmpty(this.values[condition.field_uuid] ?? defaultValue)
} else if (['not_empty', 'checked'].includes(condition.action)) {
return !isEmpty(this.values[condition.field_uuid] ?? defaultValue)
} else if (field?.type === 'number' && ['equal', 'not_equal', 'greater_than', 'less_than'].includes(condition.action)) {
const value = this.values[condition.field_uuid] ?? defaultValue
if (isEmpty(value) || isEmpty(condition.value)) return false
const actual = parseFloat(value)
const expected = parseFloat(condition.value)
if (Number.isNaN(actual) || Number.isNaN(expected)) return false
if (condition.action === 'equal') return Math.abs(actual - expected) < Number.EPSILON
if (condition.action === 'not_equal') return Math.abs(actual - expected) > Number.EPSILON
if (condition.action === 'greater_than') return actual > expected
if (condition.action === 'less_than') return actual < expected
return false
} else if (['equal', 'contains'].includes(condition.action) && field) {
if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value)
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
return values.includes(this.optionValue(option, field.options.indexOf(option)))
if (option) {
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
return values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return false
}
} else {
return [this.values[condition.field_uuid] ?? defaultValue].flat().includes(condition.value)
}
} else if (['not_equal', 'does_not_contain'].includes(condition.action) && field) {
const option = field.options.find((o) => o.uuid === condition.value)
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value)
return !values.includes(this.optionValue(option, field.options.indexOf(option)))
if (option) {
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
return !values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return false
}
} else {
return false
}
} else {
return true
}
@@ -1239,6 +1347,13 @@ export default {
return `${this.t('option')} ${index + 1}`
}
},
findNextStep (currentStepIndex) {
if (this.onlyRequiredFields) {
return this.stepFields.find((step, index) => index > currentStepIndex && step.some((f) => f.required))
} else {
return this.stepFields[currentStepIndex + 1]
}
},
maybeTrackEmailClick () {
const { queryParams } = this
@@ -1300,9 +1415,9 @@ export default {
},
previousSignatureValueFor (field) {
if (this.reuseSignature !== false) {
const signatureField = [...this.fields].reverse().find((f) =>
f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid]
)
const signatureField = this.fields.findLast
? this.fields.findLast((f) => f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid])
: [...this.fields].reverse().find((f) => f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid])
return this.values[signatureField?.uuid]
} else {
@@ -1385,15 +1500,15 @@ export default {
this.isSubmittingComplete = true
}
const submitStep = this.currentStep
const submitStepIndex = this.currentStep
const stepPromise = ['signature', 'phone', 'initials', 'payment', 'verification'].includes(this.currentField.type)
const stepPromise = ['signature', 'phone', 'initials', 'payment', 'verification', 'kba'].includes(this.currentField.type)
? this.$refs.currentStep.submit
: () => Promise.resolve({})
stepPromise().then(async () => {
const emptyRequiredField = this.stepFields.find((fields, index) => {
if (forceComplete ? index === submitStep : index >= submitStep) {
if (forceComplete ? index === submitStepIndex : index >= submitStepIndex) {
return false
}
@@ -1403,7 +1518,7 @@ export default {
})
const formData = new FormData(this.$refs.form)
const isLastStep = (submitStep === this.stepFields.length - 1) || forceComplete
const isLastStep = (this.onlyRequiredFields ? !this.findNextStep(submitStepIndex) : (submitStepIndex === this.stepFields.length - 1)) || forceComplete
if (isLastStep && !emptyRequiredField && !this.inviteSubmitters.length && !this.optionalInviteSubmitters.length) {
formData.append('completed', 'true')
@@ -1447,7 +1562,7 @@ export default {
return Promise.reject(new Error(data.error))
}
const nextStep = (isLastStep && emptyRequiredField) || (forceComplete ? null : this.stepFields[submitStep + 1])
const nextStep = (isLastStep && emptyRequiredField) || (forceComplete ? null : this.findNextStep(submitStepIndex))
if (nextStep) {
if (this.alwaysMinimize) {
+28 -14
View File
@@ -1,4 +1,5 @@
const en = {
kba: 'KBA',
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.',
@@ -13,7 +14,7 @@ const en = {
esignature_disclosure: 'eSignature Disclosure',
signature: 'Signature',
initials: 'Initials',
drawn_signature_on_a_touchscreen_device: 'Drawn signature on a touchscreen device',
sign_on_the_touchscreen: 'Sign on the touchscreen',
approved: 'Approved',
reviewed: 'Reviewed',
other: 'Other',
@@ -100,6 +101,7 @@ const en = {
}
const es = {
kba: 'KBA',
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.',
@@ -120,7 +122,7 @@ const es = {
select_a_reason: 'Selecciona una razón',
value_is_invalid: 'El valor no es válido',
verification_code_is_invalid: 'El código de verificación no es válido',
drawn_signature_on_a_touchscreen_device: 'Firma dibujada en un dispositivo con pantalla táctil',
sign_on_the_touchscreen: 'Firmar en pantalla táctil',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escanea el código QR con la aplicación de la cámara para abrir el formulario en el móvil y dibujar tu firma',
by_clicking_you_agree_to_the: 'Al hacer clic en "{button}", usted acepta el',
electronic_signature_disclosure: 'Divulgación de Firma Electrónica',
@@ -201,6 +203,7 @@ const es = {
}
const it = {
kba: 'KBA',
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à.",
@@ -221,7 +224,7 @@ const it = {
select_a_reason: 'Seleziona una ragione',
value_is_invalid: 'Il valore non è valido',
verification_code_is_invalid: 'Il codice di verifica non è valido',
drawn_signature_on_a_touchscreen_device: 'Firma disegnata su un dispositivo con schermo tattile',
sign_on_the_touchscreen: 'Firma su schermo tattile',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: "Scansiona il codice QR con l'app della fotocamera per aprire il modulo sul cellulare e disegnare la tua firma",
by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti il',
electronic_signature_disclosure: 'Divulgazione della Firma Elettronica',
@@ -302,6 +305,7 @@ const it = {
}
const de = {
kba: 'KBA',
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: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
@@ -316,7 +320,7 @@ const de = {
esignature_disclosure: 'Hinweis zur eSignatur',
signature: 'Unterschrift',
initials: 'Initialen',
drawn_signature_on_a_touchscreen_device: 'Auf einem Touchscreen-Gerät gezeichnete Unterschrift',
sign_on_the_touchscreen: 'Auf Touchscreen signieren',
approved: 'Genehmigt',
reviewed: 'Geprüft',
other: 'Sonstiges',
@@ -403,6 +407,7 @@ const de = {
}
const fr = {
kba: 'KBA',
please_upload_an_image_file: 'Veuillez téléverser un fichier image',
must_be_characters_length: 'Doit comporter {number} caractères',
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
@@ -417,7 +422,7 @@ const fr = {
esignature_disclosure: 'Déclaration eSignature',
signature: 'Signature',
initials: 'Initiales',
drawn_signature_on_a_touchscreen_device: 'Signature dessinée sur un appareil à écran tactile',
sign_on_the_touchscreen: 'Signer sur écran tactile',
approved: 'Approuvé',
reviewed: 'Révisé',
other: 'Autre',
@@ -504,6 +509,7 @@ const fr = {
}
const pl = {
kba: 'KBA',
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.',
@@ -524,7 +530,7 @@ const pl = {
select_a_reason: 'Wybierz powód',
value_is_invalid: 'Wartość jest nieprawidłowa',
verification_code_is_invalid: 'Kod weryfikacyjny jest nieprawidłowy',
drawn_signature_on_a_touchscreen_device: 'Podpis odręczny na urządzeniu z ekranem dotykowym',
sign_on_the_touchscreen: 'Podpisz na ekranie dotykowym',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Zeskanuj kod QR za pomocą aplikacji aparatu, aby otworzyć formularz na telefonie i narysować swój podpis',
by_clicking_you_agree_to_the: 'Klikając na "{button}", zgadzasz się na',
electronic_signature_disclosure: 'Ujawnienie Elektronicznej Sygnatury',
@@ -605,6 +611,7 @@ const pl = {
}
const uk = {
kba: 'KBA',
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
must_be_characters_length: 'Має містити {number} символів',
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
@@ -625,7 +632,7 @@ const uk = {
select_a_reason: 'Виберіть причину',
value_is_invalid: 'Значення є неправильним',
verification_code_is_invalid: 'Код підтвердження є неправильним',
drawn_signature_on_a_touchscreen_device: 'Підпис на сенсорному пристрої',
sign_on_the_touchscreen: 'Підписати на сенсорному екрані',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Скануйте QR-код за допомогою програми камери, щоб відкрити форму на мобільному пристрої та намалювати свій підпис',
by_clicking_you_agree_to_the: 'Натиснувши на "{button}", ви погоджуєтеся з',
electronic_signature_disclosure: 'Розголошення Електронного Підпису',
@@ -706,6 +713,7 @@ const uk = {
}
const cs = {
kba: 'KBA',
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.',
@@ -726,7 +734,7 @@ const cs = {
select_a_reason: 'Vyberte důvod',
value_is_invalid: 'Hodnota je neplatná',
verification_code_is_invalid: 'Ověřovací kód je neplatný',
drawn_signature_on_a_touchscreen_device: 'Namalovaný podpis na dotykovém zařízení',
sign_on_the_touchscreen: 'Podepsat na dotykové obrazovce',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Naskenujte QR kód pomocí aplikace fotoaparátu, abyste otevřeli formulář na mobilním zařízení a nakreslili svůj podpis',
by_clicking_you_agree_to_the: 'Kliknutím na "{button}" souhlasíte s',
electronic_signature_disclosure: 'Zveřejněním Elektronického Podpisu',
@@ -807,6 +815,7 @@ const cs = {
}
const pt = {
kba: 'KBA',
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.',
@@ -827,7 +836,7 @@ const pt = {
select_a_reason: 'Selecione um motivo',
value_is_invalid: 'Valor é inválido',
verification_code_is_invalid: 'Código de verificação é inválido',
drawn_signature_on_a_touchscreen_device: 'Assinatura desenhada em um dispositivo com tela sensível ao toque',
sign_on_the_touchscreen: 'Assinar na tela sensível',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escaneie o código QR com o aplicativo da câmera para abrir o formulário no celular e desenhar sua assinatura',
by_clicking_you_agree_to_the: 'Ao clicar em "{button}", você concorda com o',
electronic_signature_disclosure: 'Divulgação de Assinatura Eletrônica',
@@ -908,6 +917,7 @@ const pt = {
}
const he = {
kba: 'KBA',
please_upload_an_image_file: 'אנא העלה קובץ תמונה',
must_be_characters_length: 'חייב להיות באורך של {number} תווים',
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
@@ -928,7 +938,7 @@ const he = {
select_a_reason: 'בחר סיבה',
value_is_invalid: 'ערך לא תקין',
verification_code_is_invalid: 'קוד האימות אינו תקין',
drawn_signature_on_a_touchscreen_device: 'חתימה שנוצרה במכשיר עם מסך מגע',
sign_on_the_touchscreen: 'חתום על מסך המגע',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'סרוק את קוד ה-QR באמצעות אפליקציית המצלמה כדי לפתוח את הטופס במובייל ולצייר את החתימה שלך',
by_clicking_you_agree_to_the: 'על ידי לחיצה על "{button}", אתה מסכים ל',
electronic_signature_disclosure: 'חשיפת חתימה אלקטרונית',
@@ -1009,6 +1019,7 @@ const he = {
}
const nl = {
kba: 'KBA',
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.',
@@ -1029,7 +1040,7 @@ const nl = {
select_a_reason: 'Selecteer een reden',
value_is_invalid: 'Waarde is ongeldig',
verification_code_is_invalid: 'Verificatiecode is ongeldig',
drawn_signature_on_a_touchscreen_device: 'Getekende handtekening op een apparaat met een touchscreen',
sign_on_the_touchscreen: 'Onderteken op touchscreen',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scan de QR-code met de camera-app om het formulier op mobiel te openen en uw handtekening te tekenen',
by_clicking_you_agree_to_the: 'Door op "{button}" te klikken, gaat u akkoord met de',
electronic_signature_disclosure: 'Openbaarmaking van Elektronische Handtekening',
@@ -1110,6 +1121,7 @@ const nl = {
}
const ar = {
kba: 'KBA',
please_upload_an_image_file: 'يرجى تحميل ملف صورة',
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
@@ -1131,7 +1143,7 @@ const ar = {
value_is_invalid: 'القيمة غير صالحة',
verification_code_is_invalid: 'رمز التحقق غير صالح',
already_paid: 'تم الدفع بالفعل',
drawn_signature_on_a_touchscreen_device: 'توقيع مرسوم على جهاز بشاشة تعمل باللمس',
sign_on_the_touchscreen: 'وقع على شاشة اللمس',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'امسح رمز الاستجابة السريعة باستخدام تطبيق الكاميرا لفتح النموذج على الهاتف المحمول ورسم توقيعك',
by_clicking_you_agree_to_the: 'بالنقر فوق "{button}"، أنت توافق على',
electronic_signature_disclosure: 'كشف التوقيع الإلكتروني',
@@ -1211,6 +1223,7 @@ const ar = {
}
const ko = {
kba: 'KBA',
please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
must_be_characters_length: '{number}자여야 합니다',
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
@@ -1229,7 +1242,7 @@ const ko = {
reviewed_by: '검토자',
authored_by: '작성자',
select_a_reason: '이유 선택',
drawn_signature_on_a_touchscreen_device: '터치스크린 장치에서 그린 서명',
sign_on_the_touchscreen: '터치스크린에서 서명',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: '카메라 앱으로 QR 코드를 스캔하여 모바일에서 양식을 열고 서명을 그리세요',
by_clicking_you_agree_to_the: '"{button}"를 클릭함으로써, 다음에 동의하게 됩니다',
electronic_signature_disclosure: '전자 서명 공개',
@@ -1312,6 +1325,7 @@ const ko = {
}
const ja = {
kba: 'KBA',
please_upload_an_image_file: '画像ファイルをアップロードしてください',
must_be_characters_length: '{number}文字でなければなりません',
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
@@ -1326,7 +1340,7 @@ const ja = {
esignature_disclosure: '電子署名開示',
signature: '署名',
initials: 'イニシャル',
drawn_signature_on_a_touchscreen_device: 'タッチスクリーンデバイスで描かれた署名',
sign_on_the_touchscreen: 'タッチスクリーン署名',
approved: '承認済み',
reviewed: '確認済み',
other: 'その他',
@@ -19,7 +19,7 @@
<div class="space-x-2 flex flex-none">
<span
v-if="isDrawInitials"
class="tooltip"
class="md:tooltip"
:data-tip="t('type_initial')"
>
<a
@@ -36,7 +36,7 @@
</span>
<span
v-else
class="tooltip ml-2"
class="md:tooltip ml-2"
:data-tip="t('draw_initials')"
>
<a
@@ -52,7 +52,7 @@
</a>
</span>
<span
class="tooltip"
class="md:tooltip"
:data-tip="t('click_to_upload')"
>
<label class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button">
@@ -175,6 +175,10 @@ export default {
type: Object,
required: true
},
submitter: {
type: Object,
required: true
},
dryRun: {
type: Boolean,
required: false,
@@ -257,6 +261,14 @@ export default {
this.$refs.canvas.getContext('2d').scale(scale, scale)
if (!this.isDrawInitials) {
this.$nextTick(() => {
if (this.$refs.textInput) {
this.initTextInitial()
}
})
}
this.intersectionObserver?.disconnect()
}
})
@@ -332,12 +344,27 @@ export default {
if (!this.isDrawInitials) {
this.$nextTick(() => {
this.$refs.textInput.focus()
if (this.$refs.textInput) {
if (!this.submitter.name) {
this.$refs.textInput.focus()
}
this.$emit('start')
this.initTextInitial()
this.$emit('start')
}
})
}
},
initTextInitial () {
if (this.submitter.name) {
this.$refs.textInput.value = this.submitter.name.trim().split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase() || '').join('')
}
if (this.$refs.textInput.value) {
this.updateWrittenInitials({ target: this.$refs.textInput })
}
},
async submit () {
if (this.modelValue || this.computedPreviousValue) {
if (this.computedPreviousValue) {
+580
View File
@@ -0,0 +1,580 @@
<template>
<label
v-if="!error"
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
>
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name || 'Knowledge Based Authentication' }}</template>
<span
v-if="questions"
class="float-right font-normal text-neutral-500 mt-1 whitespace-nowrap text-right text-sm"
>
<span>Question {{ currentQuestionIndex + 1 }} / {{ questions.length }}</span>
<span
v-if="timeLeftSeconds !== null"
class="block"
>
Time left: {{ formattedTimeLeft }}
</span>
</span>
</label>
<div
v-if="field.description"
dir="auto"
class="mb-4 px-1 field-description-text"
>
<MarkdownContent :string="field.description" />
</div>
<div
v-if="isRequiredFieldEmpty"
class="px-1 field-description-text"
>
{{ t('complete_all_required_fields_to_proceed_with_identity_verification') }}
</div>
<div
v-if="error"
class="mb-4 text-center"
>
<div class="text-xl mb-4">
{{ error }}
</div>
<button
class="base-button w-full flex justify-center submit-form-button"
@click="restartKba"
>
{{ questions ? 'Restart KBA' : 'Retry' }}
</button>
</div>
<div
v-if="isLoading"
class="w-full flex space-x-2 justify-center mb-2"
>
<IconInnerShadowTop
width="40"
class="animate-spin h-10"
/>
</div>
<div v-else-if="questions && !error">
<form @submit.prevent="nextQuestion">
<div class="mb-6 px-1">
<p class="font-semibold mb-4 text-lg">
{{ currentQuestion.prompt }}
</p>
<div class="space-y-3.5 mx-auto">
<div
v-for="(answer, index) in currentQuestion.answers"
:key="`${currentQuestion.id}_${index}`"
>
<label
:for="`${currentQuestion.id}_${index}`"
class="flex items-center space-x-3 radio-label"
>
<input
:id="`${currentQuestion.id}_${index}`"
v-model="answers[currentQuestion.id]"
type="radio"
:name="currentQuestion.id"
:value="answer.text"
class="base-radio !h-7 !w-7"
required
>
<span class="text-xl">{{ answer.text }}</span>
</label>
</div>
</div>
</div>
<div class="mt-6">
<button
type="submit"
class="base-button w-full flex justify-center submit-form-button"
:disabled="isSubmitting || !answers[currentQuestion.id]"
>
<span class="flex">
<IconInnerShadowTop
v-if="isSubmitting"
class="mr-1 animate-spin"
/>
<span>
{{ isLastQuestion ? (isSubmitting ? t('submitting_') : t('complete')) : t('next') }}
</span><span
v-if="isSubmitting"
class="w-6 flex justify-start mr-1"
><span>...</span></span>
</span>
</button>
</div>
</form>
</div>
<div v-else-if="!error && !isRequiredFieldEmpty">
<form @submit.prevent="startKba">
<div class="grid grid-cols-6 gap-x-2 md:gap-x-4 md:gap-y-2 mb-4">
<div class="col-span-3">
<label
for="kba_fn"
class="label text-sm md:text-base"
>First Name</label>
<input
id="kba_fn"
v-model="form.fn"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-3">
<label
for="kba_ln"
class="label text-sm md:text-base"
>Last Name</label>
<input
id="kba_ln"
v-model="form.ln"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-6">
<label
for="kba_addr"
class="label text-sm md:text-base"
>Address</label>
<input
id="kba_addr"
v-model="form.addr"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-2">
<label
for="kba_city"
class="label text-sm md:text-base"
>City</label>
<input
id="kba_city"
v-model="form.city"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-2">
<label
for="kba_state"
class="label text-sm md:text-base"
>State</label>
<select
id="kba_state"
v-model="form.state"
class="select select-bordered !h-10 min-h-[2.5rem] w-full bg-white font-normal !text-base"
required
>
<option
value=""
disabled
>
Select State
</option>
<option
v-for="state in states"
:key="state.code"
:value="state.code"
>
{{ state.name }}
</option>
</select>
</div>
<div class="col-span-2">
<label
for="kba_zip"
class="label text-sm md:text-base"
>Zip</label>
<input
id="kba_zip"
v-model="form.zip"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-3">
<label
for="kba_phone"
class="label text-sm md:text-base"
>Phone</label>
<input
id="kba_phone"
v-model="form.phone"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-3">
<label
for="kba_email"
class="label text-sm md:text-base"
>Email</label>
<input
id="kba_email"
v-model="form.email"
type="email"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-3">
<label
for="kba_dob"
class="label text-sm md:text-base"
>DOB</label>
<input
id="kba_dob"
v-model="form.dob"
type="date"
class="input input-bordered !h-10 md:w-full bg-white"
required
>
</div>
<div class="col-span-3">
<label
for="kba_ssn"
class="label text-sm md:text-base"
>SSN</label>
<input
id="kba_ssn"
v-model="form.ssn"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
</div>
<div class="mt-6">
<button
type="submit"
class="base-button w-full flex justify-center submit-form-button"
:disabled="isLoading"
>
<span class="flex">
<IconInnerShadowTop
v-if="isLoading"
class="mr-1 animate-spin"
/>
<span>
{{ isLoading ? 'Loading...' : 'Start Verification' }}
</span><span
v-if="isLoading"
class="w-6 flex justify-start mr-1"
><span>...</span></span>
</span>
</button>
</div>
</form>
</div>
</template>
<script>
import MarkdownContent from './markdown_content'
import { IconInnerShadowTop } from '@tabler/icons-vue'
export default {
name: 'KbaStep',
components: {
MarkdownContent,
IconInnerShadowTop
},
inject: ['baseUrl', 't'],
props: {
field: {
type: Object,
required: true
},
submitter: {
type: Object,
required: true
},
submitterSlug: {
type: String,
required: true
},
emptyValueRequiredStep: {
type: Object,
required: false,
default: null
},
values: {
type: Object,
required: true
}
},
emits: ['submit'],
data () {
return {
isLoading: false,
isSubmitting: false,
questions: null,
currentQuestionIndex: 0,
token: null,
reference: null,
answers: {},
error: null,
timeLeftSeconds: null,
countdownIntervalId: null,
form: {
fn: '',
ln: '',
addr: '',
city: '',
state: '',
zip: '',
dob: '',
ssn: '',
phone: '',
email: ''
}
}
},
computed: {
currentQuestion () {
return this.questions ? this.questions[this.currentQuestionIndex] : null
},
isRequiredFieldEmpty () {
return this.emptyValueRequiredStep && this.emptyValueRequiredStep[0] !== this.field
},
kbaTimeLimitSeconds () {
return 90
},
states () {
return [
{ code: 'AL', name: 'Alabama' },
{ code: 'AK', name: 'Alaska' },
{ code: 'AZ', name: 'Arizona' },
{ code: 'AR', name: 'Arkansas' },
{ code: 'CA', name: 'California' },
{ code: 'CO', name: 'Colorado' },
{ code: 'CT', name: 'Connecticut' },
{ code: 'DE', name: 'Delaware' },
{ code: 'DC', name: 'District of Columbia' },
{ code: 'FL', name: 'Florida' },
{ code: 'GA', name: 'Georgia' },
{ code: 'HI', name: 'Hawaii' },
{ code: 'ID', name: 'Idaho' },
{ code: 'IL', name: 'Illinois' },
{ code: 'IN', name: 'Indiana' },
{ code: 'IA', name: 'Iowa' },
{ code: 'KS', name: 'Kansas' },
{ code: 'KY', name: 'Kentucky' },
{ code: 'LA', name: 'Louisiana' },
{ code: 'ME', name: 'Maine' },
{ code: 'MD', name: 'Maryland' },
{ code: 'MA', name: 'Massachusetts' },
{ code: 'MI', name: 'Michigan' },
{ code: 'MN', name: 'Minnesota' },
{ code: 'MS', name: 'Mississippi' },
{ code: 'MO', name: 'Missouri' },
{ code: 'MT', name: 'Montana' },
{ code: 'NE', name: 'Nebraska' },
{ code: 'NV', name: 'Nevada' },
{ code: 'NH', name: 'New Hampshire' },
{ code: 'NJ', name: 'New Jersey' },
{ code: 'NM', name: 'New Mexico' },
{ code: 'NY', name: 'New York' },
{ code: 'NC', name: 'North Carolina' },
{ code: 'ND', name: 'North Dakota' },
{ code: 'OH', name: 'Ohio' },
{ code: 'OK', name: 'Oklahoma' },
{ code: 'OR', name: 'Oregon' },
{ code: 'PA', name: 'Pennsylvania' },
{ code: 'RI', name: 'Rhode Island' },
{ code: 'SC', name: 'South Carolina' },
{ code: 'SD', name: 'South Dakota' },
{ code: 'TN', name: 'Tennessee' },
{ code: 'TX', name: 'Texas' },
{ code: 'UT', name: 'Utah' },
{ code: 'VT', name: 'Vermont' },
{ code: 'VA', name: 'Virginia' },
{ code: 'WA', name: 'Washington' },
{ code: 'WV', name: 'West Virginia' },
{ code: 'WI', name: 'Wisconsin' },
{ code: 'WY', name: 'Wyoming' }
]
},
isLastQuestion () {
return this.questions && this.currentQuestionIndex === this.questions.length - 1
},
formattedTimeLeft () {
if (this.timeLeftSeconds === null) return ''
const minutes = Math.floor(this.timeLeftSeconds / 60)
const seconds = this.timeLeftSeconds % 60
return `${minutes}:${String(seconds).padStart(2, '0')}`
}
},
beforeUnmount () {
this.clearCountdown()
},
methods: {
clearCountdown () {
if (this.countdownIntervalId) {
clearInterval(this.countdownIntervalId)
this.countdownIntervalId = null
}
this.timeLeftSeconds = null
},
startCountdown () {
this.clearCountdown()
this.timeLeftSeconds = this.kbaTimeLimitSeconds
this.countdownIntervalId = setInterval(() => {
if (this.timeLeftSeconds === null) return
this.timeLeftSeconds -= 1
if (this.timeLeftSeconds <= 0) {
this.handleTimeout()
}
}, 1000)
},
handleTimeout () {
this.clearCountdown()
this.questions = null
this.token = null
this.reference = null
this.answers = {}
this.currentQuestionIndex = 0
this.error = `Knowledge Based Authentication timed out. You only have ${this.kbaTimeLimitSeconds} seconds to complete the verification. Please retry.`
},
nextQuestion () {
if (this.isLastQuestion) {
this.$emit('submit')
} else {
this.currentQuestionIndex++
}
},
restartKba () {
this.clearCountdown()
this.questions = null
this.token = null
this.reference = null
this.answers = {}
this.currentQuestionIndex = 0
this.error = null
},
async startKba () {
this.isLoading = true
this.error = null
this.clearCountdown()
try {
const payload = { ...this.form, submitter_slug: this.submitterSlug }
if (payload.dob) {
payload.dob = payload.dob.replace(/-/g, '')
}
if (payload.ssn) {
payload.ssn = payload.ssn.replace(/\D/g, '')
}
if (payload.phone) {
payload.phone = payload.phone.replace(/^\+1/, '')
}
const resp = await fetch(this.baseUrl + '/api/kba', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const data = await resp.json()
if (!resp.ok) throw new Error(data.error || 'Failed to start KBA')
if (data.result && data.result.action === 'FAIL') {
if (data.result.detail === 'NO MATCH') {
throw new Error('Unfortunately, we were unable to start Knowledge Based Authentication with the details provided. Please review and confirm that all your personal details are correct.')
}
throw new Error(data.result.detail || 'KBA Start Failed')
}
if (data.output && data.output.questions && data.output.questions.questions) {
this.questions = data.output.questions.questions
this.token = data.continuations.questions.template.token
this.reference = data.meta.reference
this.questions.forEach(q => {
this.answers[q.id] = null
})
this.startCountdown()
} else {
throw new Error('Invalid KBA response')
}
} catch (e) {
this.error = e.message
} finally {
this.isLoading = false
}
},
async submit () {
this.clearCountdown()
this.isSubmitting = true
this.error = null
const formattedAnswers = Object.keys(this.answers).reduce((acc, key) => {
acc[key] = [this.answers[key]]
return acc
}, {})
try {
const resp = await fetch(this.baseUrl + `/api/kba/${this.field.uuid}`, {
method: 'PUT',
body: JSON.stringify({
token: this.token,
answers: formattedAnswers,
reference: this.reference,
submitter_slug: this.submitterSlug
}),
headers: { 'Content-Type': 'application/json' }
})
const data = await resp.json()
if (data.result?.action !== 'PASS') {
if (data.result?.issues?.length) {
this.error = `Knowledge Based Authentication Failed - make sure you provide correct details for the Knowledge Based authentication: ${data.result.issues.join(', ')}`
} else {
this.error = 'Knowledge Based Authentication Failed - make sure you provide correct answers for the Knowledge Based authentication.'
}
throw new Error('Knowledge Based Authentication Failed')
}
if (!resp.ok) {
this.error = 'Failed to submit answers'
throw new Error('Failed to submit answers')
}
return resp
} finally {
this.isSubmitting = false
}
}
}
}
</script>
@@ -100,7 +100,7 @@
<input
:id="field.uuid"
ref="phone"
:value="phoneValue"
:value="defaultValue && detectedPhoneValueDialCode ? phoneValue.split('+' + detectedPhoneValueDialCode).slice(-1).join('') : phoneValue"
:readonly="!!defaultValue"
class="base-input !text-2xl !rounded-l-none !border-l-0 !outline-none w-full"
autocomplete="tel"
+118 -31
View File
@@ -22,7 +22,7 @@
<div class="space-x-2 flex flex-none">
<span
v-if="isTextSignature && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
class="tooltip"
class="md:tooltip"
:data-tip="t('draw_signature')"
>
<a
@@ -39,7 +39,7 @@
</span>
<span
v-else-if="withTypedSignature && format !== 'drawn_or_upload' && format !== 'typed_or_upload' && format !== 'typed' && format !== 'drawn' && format !== 'upload'"
class="tooltip ml-2"
class="md:tooltip ml-2"
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
:data-tip="t('type_text')"
>
@@ -57,7 +57,7 @@
</span>
<span
v-if="format !== 'typed' && format !== 'drawn' && format !== 'upload' && format !== 'drawn_or_typed'"
class="tooltip"
class="md:tooltip"
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
:data-tip="t('take_photo')"
>
@@ -86,8 +86,8 @@
</a>
<span
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')"
class="md:tooltip before:translate-x-[-90%]"
:data-tip="t('sign_on_the_touchscreen')"
>
<a
href="#"
@@ -127,6 +127,12 @@
type="hidden"
:name="`values[${field.uuid}]`"
>
<input
v-if="isTouchAttachment"
:value="touchAttachmentUuid"
type="hidden"
name="touch_attachment_uuid"
>
<img
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
@@ -142,7 +148,7 @@
/>
<div
v-else
class="relative"
class="relative select-none"
>
<div
v-if="!modelValue && !computedPreviousValue && !isShowQr && !isTextSignature && isSignatureStarted"
@@ -175,9 +181,7 @@
v-if="isShowQr"
class="top-0 bottom-0 right-0 left-0 absolute bg-base-content/10 rounded-2xl"
>
<div
class="absolute top-1.5 right-1.5 tooltip"
>
<div class="absolute top-1.5 right-1.5">
<a
href="#"
class="btn btn-sm btn-circle btn-normal btn-outline"
@@ -281,7 +285,7 @@
<div
v-else-if="withDisclosure"
dir="auto"
class="text-base-content/60 text-xs text-center w-full mt-1"
class="text-base-content/60 text-xs text-center w-full mt-1 select-none"
>
{{ t('by_clicking_you_agree_to_the').replace('{button}', buttonText.charAt(0).toUpperCase() + buttonText.slice(1)) }} <a
href="https://www.docuseal.com/esign-disclosure"
@@ -312,7 +316,7 @@ import FileDropzone from './dropzone'
import MarkdownContent from './markdown_content'
import { v4 } from 'uuid'
let isFontLoaded = false
let fontLoadPromise = null
const scale = 3
@@ -336,6 +340,10 @@ export default {
type: Object,
required: true
},
values: {
type: Object,
required: true
},
requireSigningReason: {
type: Boolean,
required: false,
@@ -390,6 +398,11 @@ export default {
required: false,
default: ''
},
touchAttachmentUuid: {
type: String,
required: false,
default: ''
},
reason: {
type: String,
required: false,
@@ -401,13 +414,14 @@ export default {
default: ''
}
},
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason'],
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason', 'touch-attachment'],
data () {
return {
isSignatureStarted: false,
isShowQr: false,
isOtherReason: false,
isUsePreviousValue: true,
isTouchAttachment: false,
isTextSignature: this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload',
uploadImageInputKey: Math.random().toString()
}
@@ -448,14 +462,7 @@ export default {
}
},
async mounted () {
this.$nextTick(() => {
if (this.$refs.canvas) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
this.$refs.canvas.getContext('2d').scale(scale, scale)
}
})
this.$nextTick(() => this.setCanvasSize())
if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
@@ -470,13 +477,18 @@ export default {
this.$emit('start')
})
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
this.intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
this.setCanvasSize()
this.$refs.canvas.getContext('2d').scale(scale, scale)
if (this.isTextSignature) {
this.$nextTick(() => {
if (this.$refs.textInput) {
this.initTypedSignature()
}
})
}
this.intersectionObserver?.disconnect()
}
@@ -484,13 +496,66 @@ export default {
})
this.intersectionObserver.observe(this.$refs.canvas)
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.$refs.canvas) return
const { width, height } = this.$refs.canvas
this.setCanvasSize()
if (this.$refs.canvas.width !== width || this.$refs.canvas.height !== height) {
this.redrawCanvas(width, height)
}
})
})
this.resizeObserver.observe(this.$refs.canvas.parentNode)
if (this.isTextSignature) {
this.loadFont()
}
}
},
beforeUnmount () {
this.intersectionObserver?.disconnect()
this.resizeObserver?.disconnect()
this.stopCheckSignature()
},
methods: {
setCanvasSize () {
const canvas = this.$refs.canvas
if (canvas) {
const width = canvas.parentNode.clientWidth
const height = width / 3
if (canvas.width !== width * scale || canvas.height !== height * scale) {
canvas.width = width * scale
canvas.height = height * scale
canvas.getContext('2d').scale(scale, scale)
}
}
},
redrawCanvas (oldWidth, oldHeight) {
const canvas = this.$refs.canvas
if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
const sx = canvas.width / oldWidth
const sy = canvas.height / oldHeight
const scaledData = this.pad.toData().map((stroke) => ({
...stroke,
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
}))
this.pad.fromData(scaledData)
} else if (this.isTextSignature && this.$refs.textInput) {
this.updateWrittenSignature({ target: this.$refs.textInput })
}
},
remove () {
this.$emit('update:model-value', '')
@@ -498,17 +563,17 @@ export default {
this.isSignatureStarted = false
},
loadFont () {
if (!isFontLoaded) {
if (!fontLoadPromise) {
const font = new FontFace('Dancing Script', `url(${this.baseUrl}/fonts/DancingScript-Regular.otf) format("opentype")`)
font.load().then((loadedFont) => {
fontLoadPromise = font.load().then((loadedFont) => {
document.fonts.add(loadedFont)
isFontLoaded = true
}).catch((error) => {
console.error('Font loading failed:', error)
})
}
return fontLoadPromise
},
showQr () {
this.isShowQr = true
@@ -608,14 +673,29 @@ export default {
if (this.isTextSignature) {
this.$nextTick(() => {
this.$refs.textInput.focus()
if (this.$refs.textInput) {
if (!this.submitter.name) {
this.$refs.textInput.focus()
}
this.loadFont()
this.initTypedSignature()
this.$emit('start')
this.$emit('start')
}
})
}
},
async initTypedSignature () {
if (this.submitter.name) {
this.$refs.textInput.value = this.submitter.name
}
await this.loadFont()
if (this.$refs.textInput.value) {
this.updateWrittenSignature({ target: this.$refs.textInput })
}
},
drawImage (event) {
this.remove()
this.clear()
@@ -694,6 +774,13 @@ export default {
},
async submit () {
if (this.modelValue || this.computedPreviousValue) {
if (this.touchAttachmentUuid && this.computedPreviousValue === this.touchAttachmentUuid && !Object.values(this.values).includes(this.touchAttachmentUuid)) {
this.isTouchAttachment = true
this.$emit('touch-attachment', this.touchAttachmentUuid)
} else {
this.isTouchAttachment = false
}
if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue)
}
@@ -119,6 +119,7 @@ export default {
docId: this.eidEasyData.doc_id,
language: this.locale,
countryCode: this.countryCode,
sandbox: ['demo.docuseal.tech'].includes(location.host),
enabledMethods: {
signature: this.eidEasyData.available_methods
},
+80 -20
View File
@@ -8,7 +8,7 @@
@touchstart="startTouchDrag"
>
<div
v-if="isSelected || isDraw"
v-if="isSelected || isDraw || isInMultiSelection"
class="top-0 bottom-0 right-0 left-0 absolute border border-1.5 pointer-events-none"
:class="activeBorderClasses"
/>
@@ -24,7 +24,7 @@
:style="{ left: (cellW / area.w * 100) + '%' }"
>
<span
v-if="index === 0 && editable"
v-if="index === 0 && editable && !isInMultiSelection"
class="h-2.5 w-2.5 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-ew-resize z-10"
style="left: -4px"
@mousedown.stop="startResizeCell"
@@ -32,7 +32,7 @@
</div>
</div>
<div
v-if="field?.type && (isSelected || isNameFocus)"
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
style="top: -25px; height: 25px"
@mousedown.stop
@@ -48,7 +48,7 @@
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
:submitters="template.submitters"
@update:model-value="save"
@click="selectedAreaRef.value = area"
@click="selectedAreasRef.value = [area]"
/>
<FieldType
v-model="field.type"
@@ -57,7 +57,7 @@
:button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), save()]"
@click="selectedAreaRef.value = area"
@click="selectedAreasRef.value = [area]"
/>
<span
v-if="field.type !== 'checkbox' || field.name"
@@ -134,6 +134,7 @@
@focusout="maybeBlurSettings"
>
<FieldSettings
v-if="isMobile"
:field="field"
:default-field="defaultField"
:editable="editable"
@@ -145,9 +146,17 @@
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@add-custom-field="$emit('add-custom-field')"
@click-condition="isShowConditionsModal = true"
@scroll-to="[selectedAreaRef.value = $event, $emit('scroll-to', $event)]"
@save="save"
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
/>
<div
v-else
class="whitespace-normal"
>
The dots menu is retired in favor of the field context menu. Right-click the field to access field settings. Double-click the field to set a default value.
</div>
</ul>
</span>
</div>
@@ -250,7 +259,8 @@
</span>
<div
v-else-if="field.type === 'cells' && field.default_value"
class="w-full flex items-center"
class="w-full flex"
:class="fontClasses"
>
<div
v-for="(char, index) in field.default_value"
@@ -266,7 +276,7 @@
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"
@focus="selectedAreasRef.value = [area]"
@keydown.enter="onDefaultValueEnter"
>
<option
@@ -293,7 +303,7 @@
:class="{ 'cursor-text': isValueInput }"
: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"
@focus="selectedAreasRef.value = [area]"
@paste.prevent="onPaste"
@keydown.enter="onDefaultValueEnter"
>{{ field.default_value }}</span>
@@ -319,6 +329,7 @@
<span
v-if="field?.type && editable"
class="h-4 w-4 lg:h-2.5 lg:w-2.5 -right-1 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-nwse-resize"
:class="{ 'z-30': isInMultiSelection }"
@mousedown.stop="startResize"
@touchstart="startTouchResize"
/>
@@ -331,6 +342,7 @@
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="save"
@close="isShowFormulaModal = false"
/>
</Teleport>
@@ -343,6 +355,7 @@
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="save"
@close="isShowFontModal = false"
/>
</Teleport>
@@ -354,6 +367,7 @@
:item="field"
:build-default-name="buildDefaultName"
:default-field="defaultField"
@save="save"
@close="isShowConditionsModal = false"
/>
</Teleport>
@@ -366,6 +380,7 @@
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="save"
@close="isShowDescriptionModal = false"
/>
</Teleport>
@@ -398,7 +413,7 @@ export default {
FieldSubmitter,
IconX
},
inject: ['template', 'selectedAreaRef', 'save', 't', 'isInlineSize'],
inject: ['template', 'save', 't', 'isInlineSize', 'selectedAreasRef', 'isCmdKeyRef', 'getFieldTypeIndex'],
props: {
area: {
type: Object,
@@ -463,9 +478,19 @@ export default {
type: Object,
required: false,
default: null
},
isMobile: {
type: Boolean,
required: false,
default: false
},
isSelectMode: {
type: Boolean,
required: false,
default: false
}
},
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to'],
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field'],
data () {
return {
isShowFormulaModal: false,
@@ -571,7 +596,7 @@ export default {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
defaultName () {
return this.buildDefaultName(this.field, this.template.fields)
return this.buildDefaultName(this.field)
},
fontClasses () {
if (!this.field.preferences) {
@@ -646,7 +671,10 @@ export default {
]
},
isSelected () {
return this.selectedAreaRef.value === this.area
return this.selectedAreasRef.value.includes(this.area)
},
isInMultiSelection () {
return this.selectedAreasRef.value.length >= 2 && this.isSelected
},
positionStyle () {
const { x, y, w, h } = this.area
@@ -683,10 +711,10 @@ export default {
buildAreaOptionValue (area) {
const option = this.optionsUuidIndex[area.option_uuid]
return option.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}`
return option?.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}`
},
maybeToggleDefaultValue () {
if (!this.editable) {
if (!this.editable || this.isCmdKeyRef.value) {
return
}
@@ -770,7 +798,7 @@ export default {
}
},
onNameFocus (e) {
this.selectedAreaRef.value = this.area
this.selectedAreasRef.value = [this.area]
this.isNameFocus = true
this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px'
@@ -906,6 +934,15 @@ export default {
if (e.target.id === 'mask') {
this.area.w = e.offsetX / e.target.clientWidth - this.area.x
this.area.h = e.offsetY / e.target.clientHeight - this.area.y
if (this.isInMultiSelection) {
this.selectedAreasRef.value.forEach((area) => {
if (area !== this.area) {
area.w = this.area.w
area.h = this.area.h
}
})
}
}
},
drag (e) {
@@ -931,7 +968,7 @@ export default {
const rect = e.target.getBoundingClientRect()
this.selectedAreaRef.value = this.area
this.selectedAreasRef.value = [this.area]
this.dragFrom = { x: rect.left - e.touches[0].clientX, y: rect.top - e.touches[0].clientY }
@@ -980,13 +1017,23 @@ export default {
e.preventDefault()
if (e.metaKey || e.ctrlKey) {
if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value.push(this.area)
} else {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(this.area), 1)
}
return
}
if (this.editable) {
this.isDragged = true
}
const rect = e.target.getBoundingClientRect()
this.selectedAreaRef.value = this.area
this.selectedAreasRef.value = [this.area]
this.dragFrom = { x: rect.left - e.clientX, y: rect.top - e.clientY }
@@ -1055,7 +1102,9 @@ export default {
this.$emit('stop-drag')
},
startResize () {
this.selectedAreaRef.value = this.area
if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value = [this.area]
}
this.$el.getRootNode().addEventListener('mousemove', this.resize)
this.$el.getRootNode().addEventListener('mouseup', this.stopResize)
@@ -1071,7 +1120,9 @@ export default {
this.save()
},
startTouchResize (e) {
this.selectedAreaRef.value = this.area
if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value = [this.area]
}
this.$refs?.name?.blur()
@@ -1088,6 +1139,15 @@ export default {
this.area.w = (e.touches[0].clientX - rect.left) / rect.width - this.area.x
this.area.h = (e.touches[0].clientY - rect.top) / rect.height - this.area.y
if (this.isInMultiSelection) {
this.selectedAreasRef.value.forEach((area) => {
if (area !== this.area) {
area.w = this.area.w
area.h = this.area.h
}
})
}
},
stopTouchResize () {
this.$el.getRootNode().removeEventListener('touchmove', this.touchResize)
File diff suppressed because it is too large Load Diff
@@ -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') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item, template.fields) }}
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item) }}
</span>
<a
href="#"
@@ -83,7 +83,7 @@
class="text-base-content"
:selected="condition.field_uuid === f.uuid"
>
{{ f.name || buildDefaultName(f, template.fields) }}
{{ f.name || buildDefaultName(f) }}
</option>
</select>
<select
@@ -124,6 +124,16 @@
{{ option.value || `${t('option')} ${index + 1}` }}
</option>
</select>
<input
v-else-if="conditionField(condition)?.type === 'number' && ['equal', 'not_equal', 'greater_than', 'less_than'].includes(condition.action)"
v-model="condition.value"
type="number"
step="any"
class="input input-bordered input-sm w-full bg-white h-11 pl-4 text-base font-normal"
:class="{ 'text-gray-300': !condition.value }"
:placeholder="t('type_value')"
required
>
</div>
</div>
<a
@@ -154,7 +164,7 @@
<script>
export default {
name: 'ConditionModal',
inject: ['t', 'save', 'template', 'withConditions'],
inject: ['t', 'template', 'withConditions'],
props: {
item: {
type: Object,
@@ -168,9 +178,14 @@ export default {
buildDefaultName: {
type: Function,
required: true
},
excludeFieldUuids: {
type: Array,
required: false,
default: () => []
}
},
emits: ['close'],
emits: ['close', 'save'],
data () {
return {
conditions: this.item.conditions?.[0] ? JSON.parse(JSON.stringify(this.item.conditions)) : [{}]
@@ -183,14 +198,14 @@ export default {
fields () {
if (this.item.submitter_uuid) {
return this.template.fields.reduce((acc, f) => {
if (f !== this.item && !this.excludeTypes.includes(f.type) && (!f.conditions?.length || !f.conditions.find((c) => c.field_uuid === this.item.uuid))) {
if (f !== this.item && !this.excludeTypes.includes(f.type) && !this.excludeFieldUuids.includes(f.uuid) && (!f.conditions?.length || !f.conditions.find((c) => c.field_uuid === this.item.uuid))) {
acc.push(f)
}
return acc
}, [])
} else {
return this.template.fields
return this.template.fields.filter((f) => !this.excludeFieldUuids.includes(f.uuid))
}
}
},
@@ -217,6 +232,8 @@ export default {
actions.push('equal', 'not_equal')
} else if (['multiple'].includes(field.type)) {
actions.push('contains', 'does_not_contain')
} else if (field.type === 'number') {
actions.push('not_empty', 'empty', 'equal', 'not_equal', 'greater_than', 'less_than')
} else {
actions.push('not_empty', 'empty')
}
@@ -234,7 +251,7 @@ export default {
delete this.item.conditions
}
this.save()
this.$emit('save')
this.$emit('close')
}
}
@@ -6,18 +6,24 @@
<span
ref="contenteditable"
dir="auto"
:contenteditable="editable"
style="min-width: 2px"
:class="[iconInline ? 'inline' : 'block', hideIcon ? 'focus:block' : '']"
class="peer outline-none"
:contenteditable="editable && (!editableOnButton || isEditable)"
:data-placeholder="placeholder"
:data-empty="isEmpty"
:style="{ minWidth }"
:class="[iconInline ? (isEmpty ? 'inline-block' : 'inline') : 'block', hideIcon ? 'focus:block' : '']"
class="peer relative inline-block outline-none before:pointer-events-none before:absolute before:left-0 before:top-0 before:select-none before:whitespace-pre before:text-neutral-400 before:content-[attr(data-placeholder)] before:opacity-0 data-[empty=true]:before:opacity-100"
@paste.prevent="onPaste"
@keydown.enter.prevent="blurContenteditable"
@input="updateInputValue"
@cut="updateInputValue"
@focus="$emit('focus', $event)"
@blur="onBlur"
@click="editable && (!editableOnButton || isEditable) ? '' : $emit('click-contenteditable')"
>
{{ value }}
</span>
<span
v-if="withButton"
class="relative inline"
:class="{ 'peer-focus:hidden': hideIcon, 'peer-focus:invisible': !hideIcon }"
>
@@ -28,7 +34,7 @@
:class="{ invisible: !editable, 'absolute top-1/2 -translate-y-1/2': !iconInline || floatIcon, 'inline align-bottom': iconInline, 'left-0': floatIcon }"
:width="iconWidth + 4"
:stroke-width="iconStrokeWidth"
@click="[focusContenteditable(), selectOnEditClick && selectContent()]"
@click="clickEdit"
/>
</span>
</div>
@@ -49,6 +55,16 @@ export default {
required: false,
default: ''
},
placeholder: {
type: String,
required: false,
default: ''
},
withButton: {
type: Boolean,
required: false,
default: true
},
iconInline: {
type: Boolean,
required: false,
@@ -74,6 +90,16 @@ export default {
required: false,
default: false
},
editableOnButton: {
type: Boolean,
required: false,
default: false
},
minWidth: {
type: String,
required: false,
default: '2px'
},
editable: {
type: Boolean,
required: false,
@@ -85,21 +111,34 @@ export default {
default: 2
}
},
emits: ['update:model-value', 'focus', 'blur'],
emits: ['update:model-value', 'focus', 'blur', 'click-contenteditable'],
data () {
return {
isEditable: false,
inputValue: '',
value: ''
}
},
computed: {
isEmpty () {
return !this.inputValue.replace(/\u200B/g, '').replace(/\u00A0/g, ' ').trim()
}
},
watch: {
modelValue: {
handler (value) {
this.value = value
this.value = value || ''
},
immediate: true
}
},
mounted () {
this.updateInputValue()
},
methods: {
updateInputValue () {
this.inputValue = this.$refs.contenteditable?.textContent || ''
},
onPaste (e) {
const text = (e.clipboardData || window.clipboardData).getData('text/plain')
@@ -110,6 +149,20 @@ export default {
selection.getRangeAt(0).insertNode(document.createTextNode(text))
selection.collapseToEnd()
}
this.updateInputValue()
},
clickEdit (e) {
this.focusContenteditable()
if (this.selectOnEditClick) {
this.selectContent()
}
},
setText (text) {
this.$refs.contenteditable.innerText = text
this.updateInputValue()
},
selectContent () {
const el = this.$refs.contenteditable
@@ -126,13 +179,24 @@ export default {
},
onBlur (e) {
setTimeout(() => {
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
this.$emit('update:model-value', this.value)
if (this.$refs.contenteditable) {
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
this.$emit('update:model-value', this.value)
}
this.$emit('blur', e)
this.isEditable = false
}, 1)
},
focusContenteditable () {
this.$refs.contenteditable.focus()
this.isEditable = true
this.$nextTick(() => {
this.$refs.contenteditable.focus()
this.updateInputValue()
})
},
blurContenteditable () {
this.$refs.contenteditable.blur()
@@ -0,0 +1,271 @@
<template>
<div
class="list-field group"
>
<div
class="border border-dashed border-base-300 hover:border-base-content/20 rounded relative group fields-list-item transition-colors"
:style="{ backgroundColor: backgroundColor }"
>
<div class="flex items-center justify-between relative group/contenteditable-container">
<div
v-if="!isNew"
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
@click="$emit('click', field)"
/>
<div
class="absolute top-0 bottom-0 left-0 flex items-center transition-all cursor-grab group-hover:bg-base-200/50"
@click="$emit('click', field)"
>
<IconDrag style="margin-left: 1px" />
</div>
<div class="flex items-center p-1 pl-6 space-x-1">
<FieldType
v-model="field.type"
:editable="false"
:button-width="20"
@click="$emit('click', field)"
/>
<Contenteditable
ref="name"
:model-value="field.name"
:placeholder="'Field Name'"
:icon-inline="true"
:icon-width="18"
:min-width="isNew ? '100px' : '2px'"
:icon-stroke-width="1.6"
:editable-on-button="!isNew"
:with-button="!isNew"
:class="{ 'cursor-pointer': !isNew }"
@click-contenteditable="$emit('click', field)"
@focus="onNameFocus"
@blur="onNameBlur"
/>
</div>
<div
class="flex items-center space-x-1"
>
<PaymentSettings
v-if="field.type === 'payment' && !isNew"
:field="field"
:with-condition="false"
:with-force-open="false"
@click-description="isShowDescriptionModal = true"
@click-formula="isShowFormulaModal = true"
/>
<span
v-else-if="!isNew"
class="dropdown dropdown-end field-settings-dropdown"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
tabindex="0"
:title="t('settings')"
class="cursor-pointer text-transparent group-hover:text-base-content"
>
<IconSettings
:width="18"
:stroke-width="1.6"
/>
</label>
<ul
v-if="renderDropdown"
tabindex="0"
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10"
:style="{ backgroundColor: dropdownBgColor }"
draggable="true"
@dragstart.prevent.stop
@click="closeDropdown"
>
<FieldSettings
:field="field"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:background-color="dropdownBgColor"
:with-areas="false"
:with-copy-to-all-pages="false"
:with-condition="false"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@save="$emit('save')"
/>
</ul>
</span>
<button
v-if="isNew && !$refs.name"
class="relative text-base-content pr-1 field-save-button"
:title="t('save')"
@click="field.name ? $emit('save', field) : focusName()"
>
<IconCheck
:width="18"
:stroke-width="2"
/>
</button>
<button
class="relative group-hover:text-base-content pr-1 field-remove-button"
:class="isNew ? 'text-base-content' : 'text-transparent group-hover:text-base-content'"
:title="t('remove')"
@click="onRemoveClick"
>
<IconTrashX
:width="18"
:stroke-width="1.6"
/>
</button>
</div>
</div>
</div>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="field"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowFontModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div>
</template>
<script>
import Contenteditable from './contenteditable'
import FieldType from './field_type'
import PaymentSettings from './payment_settings'
import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import DescriptionModal from './description_modal'
import { IconTrashX, IconSettings, IconCheck } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
export default {
name: 'CustomField',
components: {
Contenteditable,
IconSettings,
IconCheck,
FieldSettings,
PaymentSettings,
IconDrag,
FormulaModal,
FontModal,
DescriptionModal,
IconTrashX,
FieldType
},
inject: ['backgroundColor', 't'],
props: {
field: {
type: Object,
required: true
},
isNew: {
type: Boolean,
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
withPrefillable: {
type: Boolean,
required: false,
default: false
}
},
emits: ['remove', 'save', 'click'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowDescriptionModal: false,
renderDropdown: false
}
},
computed: {
dropdownBgColor () {
return ['', null, 'transparent'].includes(this.backgroundColor) ? 'white' : this.backgroundColor
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
}
},
created () {
this.field.preferences ||= {}
},
mounted () {
if (this.isNew) {
this.focusName()
}
},
methods: {
buildDefaultName () {
return this.t('custom')
},
focusName () {
setTimeout(() => {
this.$refs.name.clickEdit()
}, 1)
},
onNameFocus (e) {
if (!this.field.name) {
setTimeout(() => {
this.$refs.name.$refs.contenteditable.innerText = ' '
}, 1)
}
},
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
onRemoveClick () {
if (this.isNew || window.confirm(this.t('are_you_sure_'))) {
this.$emit('remove', this.field)
}
},
onNameBlur (e) {
const text = this.$refs.name.$refs.contenteditable.innerText.trim()
if (text) {
this.field.name = text
} else {
this.$refs.name.setText(this.field.name)
}
if (this.field.name) {
this.$emit('save')
}
}
}
}
</script>
@@ -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">
{{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field, template.fields) }}
{{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
</span>
<a
href="#"
@@ -67,7 +67,7 @@
<script>
export default {
name: 'DescriptionModal',
inject: ['t', 'save', 'template'],
inject: ['t', 'template'],
props: {
field: {
type: Object,
@@ -88,7 +88,7 @@ export default {
required: true
}
},
emits: ['close'],
emits: ['close', 'save'],
data () {
return {
description: this.field.description,
@@ -103,7 +103,7 @@ export default {
this.field.description = this.description
this.field.title = this.title
this.save()
this.$emit('save')
this.$emit('close')
},
resizeTextarea () {
+37 -3
View File
@@ -13,17 +13,28 @@
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:is-drag="isDrag"
:is-mobile="isMobile"
:with-field-placeholder="withFieldPlaceholder"
:default-fields="defaultFields"
:drag-field-placeholder="dragFieldPlaceholder"
:default-submitters="defaultSubmitters"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField"
:selected-submitter="selectedSubmitter"
:total-pages="sortedPreviewImages.length"
:image="image"
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
:attachment-uuid="document.uuid"
:with-fields-detection="withFieldsDetection"
@drop-field="$emit('drop-field', { ...$event, attachment_uuid: document.uuid })"
@remove-area="$emit('remove-area', $event)"
@copy-field="$emit('copy-field', $event)"
@paste-field="$emit('paste-field', { ...$event, attachment_uuid: document.uuid })"
@add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)"
@copy-selected-areas="$emit('copy-selected-areas')"
@delete-selected-areas="$emit('delete-selected-areas')"
@autodetect-fields="$emit('autodetect-fields', $event)"
@scroll-to="scrollToArea"
@draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })"
/>
@@ -88,6 +99,11 @@ export default {
required: false,
default: () => []
},
isMobile: {
type: Boolean,
required: false,
default: false
},
allowDraw: {
type: Boolean,
required: false,
@@ -107,6 +123,11 @@ export default {
required: false,
default: null
},
drawCustomField: {
type: Object,
required: false,
default: null
},
baseUrl: {
type: String,
required: false,
@@ -116,9 +137,14 @@ export default {
type: Boolean,
required: false,
default: false
},
withFieldsDetection: {
type: Boolean,
required: false,
default: false
}
},
emits: ['draw', 'drop-field', 'remove-area'],
emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field', 'copy-selected-areas', 'delete-selected-areas', 'autodetect-fields', 'add-custom-field', 'set-draw'],
data () {
return {
pageRefs: []
@@ -160,7 +186,15 @@ export default {
methods: {
scrollToArea (area) {
this.$nextTick(() => {
this.pageRefs[area.page].areaRefs.find((e) => e.area === area).$el.scrollIntoView({ behavior: 'smooth', block: 'center' })
const pageRef = this.pageRefs[area.page]
if (pageRef && pageRef.areaRefs) {
const areaRef = pageRef.areaRefs.find((e) => e.area === area)
if (areaRef && areaRef.$el) {
areaRef.$el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
})
},
setPageRefs (el) {
@@ -8,6 +8,13 @@
class="fixed z-20 pointer-events-none"
:editable="false"
/>
<CustomField
v-else-if="dragPlaceholder && isCustom && !isMask && field"
ref="dragPlaceholder"
:style="dragPlaceholderStyle"
:field="field"
class="fixed z-20 pointer-events-none opacity-90"
/>
<div
v-else-if="dragPlaceholder && (isDefault || isRequired) && !isMask && field"
ref="dragPlaceholder"
@@ -57,6 +64,7 @@
<script>
import Field from './field'
import CustomField from './custom_field'
import IconDrag from './icon_drag'
import FieldType from './field_type'
@@ -64,6 +72,7 @@ export default {
name: 'DragPlaceholder',
components: {
Field,
CustomField,
IconDrag
},
inject: ['t', 'backgroundColor'],
@@ -87,6 +96,11 @@ export default {
type: Boolean,
required: false,
default: false
},
isCustom: {
type: Boolean,
required: false,
default: false
}
},
data () {
+144 -21
View File
@@ -1,6 +1,6 @@
<template>
<div
class="list-field group mb-2"
class="list-field group"
>
<div
class="border border-base-300 rounded relative group fields-list-item"
@@ -18,7 +18,7 @@
:button-width="20"
:menu-classes="'mt-1.5'"
:menu-style="{ backgroundColor: dropdownBgColor }"
@update:model-value="[maybeUpdateOptions(), save()]"
@update:model-value="[maybeUpdateOptions(), $emit('save')]"
@click="scrollToFirstArea"
/>
<Contenteditable
@@ -92,9 +92,12 @@
<PaymentSettings
v-if="field.type === 'payment'"
:field="field"
:with-custom-fields="withCustomFields"
@click-condition="isShowConditionsModal = true"
@click-description="isShowDescriptionModal = true"
@add-custom-field="$emit('add-custom-field', $event)"
@click-formula="isShowFormulaModal = true"
@save="$emit('save')"
/>
<span
v-else
@@ -128,12 +131,15 @@
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:background-color="dropdownBgColor"
:with-custom-fields="withCustomFields"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@click-condition="isShowConditionsModal = true"
@set-draw="$emit('set-draw', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@remove-area="removeArea"
@save="$emit('save')"
@scroll-to="$emit('scroll-to', $event)"
/>
</ul>
@@ -155,14 +161,23 @@
ref="options"
class="border-t border-base-300 mx-2 pt-2 space-y-1.5"
draggable="true"
@dragover="onOptionDragover"
@drop="reorderOptions"
@dragstart.prevent.stop
>
<div
v-for="(option, index) in field.options"
:key="option.uuid"
class="flex space-x-1.5 items-center"
:data-option-uuid="option.uuid"
>
<span class="text-sm w-3.5">
<span
class="text-sm w-3.5 cursor-grab select-none"
:draggable="editable && !defaultField"
@dragstart.stop="onOptionDragstart($event, option)"
@dragend.stop="optionDragRef = null"
@dragover.prevent.stop="onOptionDragover"
>
{{ index + 1 }}.
</span>
<div
@@ -176,7 +191,9 @@
dir="auto"
required
:placeholder="`${t('option')} ${index + 1}`"
@blur="save"
@keydown.enter="option.value ? addOptionAt(index + 1) : null"
@blur="$emit('save')"
@paste="onOptionPaste($event, index)"
>
<button
:title="t('draw')"
@@ -198,8 +215,10 @@
:readonly="!editable || defaultField"
required
dir="auto"
@keydown.enter="option.value ? addOptionAt(index + 1) : null"
@focus="maybeFocusOnOptionArea(option)"
@blur="save"
@blur="$emit('save')"
@paste="onOptionPaste($event, index)"
>
<button
v-if="editable && !defaultField"
@@ -217,7 +236,7 @@
<button
v-else-if="field.options && editable && !defaultField"
class="field-add-option text-center text-sm w-full pb-1"
@click="addOption"
@click="addOptionAt(field.options.length)"
>
+ {{ t('add_option') }}
</button>
@@ -250,6 +269,7 @@
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowFormulaModal = false"
/>
</Teleport>
@@ -262,6 +282,7 @@
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowFontModal = false"
/>
</Teleport>
@@ -273,6 +294,7 @@
:item="field"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowConditionsModal = false"
/>
</Teleport>
@@ -285,6 +307,7 @@
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowDescriptionModal = false"
/>
</Teleport>
@@ -321,7 +344,7 @@ export default {
IconMathFunction,
FieldType
},
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't', 'locale'],
inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex'],
props: {
field: {
type: Object,
@@ -332,6 +355,11 @@ export default {
required: false,
default: null
},
withCustomFields: {
type: Boolean,
required: false,
default: false
},
withPrefillable: {
type: Boolean,
required: false,
@@ -353,17 +381,17 @@ export default {
default: true
}
},
emits: ['set-draw', 'remove', 'scroll-to'],
emits: ['set-draw', 'remove', 'scroll-to', 'save', 'add-custom-field'],
data () {
return {
isExpandOptions: false,
isNameFocus: false,
showPaymentModal: false,
isShowFormulaModal: false,
isShowFontModal: false,
isShowConditionsModal: false,
isShowDescriptionModal: false,
renderDropdown: false
renderDropdown: false,
optionDragRef: null
}
},
computed: {
@@ -388,7 +416,7 @@ export default {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
defaultName () {
return this.buildDefaultName(this.field, this.template.fields)
return this.buildDefaultName(this.field)
},
areas () {
return this.field.areas || []
@@ -406,9 +434,9 @@ export default {
removeArea (area) {
this.field.areas.splice(this.field.areas.indexOf(area), 1)
this.save()
this.$emit('save')
},
buildDefaultName (field, fields) {
buildDefaultName (field) {
if (field.type === 'payment' && field.preferences?.price && !field.preferences?.formula) {
const { price, currency } = field.preferences || {}
@@ -419,7 +447,7 @@ export default {
return `${this.fieldNames[field.type]} ${formattedPrice}`
} else {
const typeIndex = fields.filter((f) => f.type === field.type).indexOf(field)
const typeIndex = this.getFieldTypeIndex(field)
if (field.type === 'heading' || field.type === 'strikethrough') {
return `${this.fieldNames[field.type]} ${typeIndex + 1}`
@@ -441,7 +469,7 @@ export default {
const area = this.field.areas.find((a) => a.option_uuid === option.uuid)
if (area) {
this.selectedAreaRef.value = area
this.selectedAreasRef.value = [area]
}
},
scrollToFirstArea () {
@@ -450,18 +478,49 @@ export default {
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
addOption () {
onOptionPaste (e, index) {
const text = e.clipboardData.getData('text')
if (text.includes('\n')) {
e.preventDefault()
this.isExpandOptions = true
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l)
if (lines.length > 0) {
const currentOption = this.field.options[index]
currentOption.value = (currentOption.value + lines[0]).trim()
const newOptions = lines.slice(1).map((line) => ({ value: line, uuid: v4() }))
this.field.options.splice(index + 1, 0, ...newOptions)
this.$nextTick(() => {
const inputs = this.$refs.options.querySelectorAll('input')
inputs[index + newOptions.length]?.focus()
})
this.$emit('save')
}
}
},
addOptionAt (index) {
this.isExpandOptions = true
this.field.options.push({ value: '', uuid: v4() })
const insertAt = index ?? this.field.options.length
this.field.options.splice(insertAt, 0, { value: '', uuid: v4() })
this.$nextTick(() => {
const inputs = this.$refs.options.querySelectorAll('input')
inputs[inputs.length - 1]?.focus()
inputs[insertAt]?.focus()
})
this.save()
this.$emit('save')
},
removeOption (option) {
this.field.options.splice(this.field.options.indexOf(option), 1)
@@ -472,7 +531,7 @@ export default {
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
}
this.save()
this.$emit('save')
},
maybeUpdateOptions () {
delete this.field.default_value
@@ -514,7 +573,71 @@ export default {
this.isNameFocus = false
this.save()
this.$emit('save')
},
onOptionDragstart (event, option) {
this.optionDragRef = option
const root = this.$el.getRootNode()
const hiddenEl = document.createElement('div')
hiddenEl.style.width = '1px'
hiddenEl.style.height = '1px'
hiddenEl.style.opacity = '0'
hiddenEl.style.position = 'fixed'
root.querySelector('#docuseal_modal_container')?.appendChild(hiddenEl)
event.dataTransfer?.setDragImage(hiddenEl, 0, 0)
setTimeout(() => { hiddenEl.remove() }, 1000)
event.dataTransfer.effectAllowed = 'move'
},
onOptionDragover (e) {
if (!this.optionDragRef) return
e.preventDefault()
e.stopPropagation()
const targetRow = e.target.closest('[data-option-uuid]')
if (!targetRow) return
const dragRow = this.$refs.options?.querySelector(`[data-option-uuid="${this.optionDragRef.uuid}"]`)
if (!dragRow) return
if (targetRow === dragRow) return
const rows = Array.from(this.$refs.options.querySelectorAll('[data-option-uuid]'))
const currentIndex = rows.indexOf(dragRow)
const targetIndex = rows.indexOf(targetRow)
if (currentIndex < targetIndex) {
targetRow.after(dragRow)
} else {
targetRow.before(dragRow)
}
},
reorderOptions (e) {
if (!this.optionDragRef) return
e.preventDefault()
e.stopPropagation()
const rows = Array.from(this.$refs.options.querySelectorAll('[data-option-uuid]'))
const newOrder = rows
.map((el) => this.field.options.find((opt) => opt.uuid === el.dataset.optionUuid))
.filter(Boolean)
if (newOrder.length === this.field.options.length) {
this.field.options.splice(0, this.field.options.length, ...newOrder)
this.$emit('save')
}
this.optionDragRef = null
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
<template>
<Teleport :to="modalContainerEl">
<div class="modal modal-open items-start !animate-none overflow-y-auto">
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
{{ title }}
</span>
<a
href="#"
class="text-xl modal-close-button"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<form @submit.prevent="$emit('save')">
<slot />
<button
class="base-button w-full mt-4 modal-save-button"
type="submit"
>
{{ t('save') }}
</button>
</form>
</div>
</div>
</Teleport>
</template>
<script>
export default {
name: 'ContextModal',
inject: ['t'],
props: {
title: {
type: String,
required: true
},
modalContainerEl: {
type: Element,
required: true
}
},
emits: ['close', 'save']
}
</script>
@@ -0,0 +1,147 @@
<template>
<div
class="relative"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="isOpen ? close() : open()"
>
<span class="flex items-center space-x-2">
<component
:is="icon || 'span'"
class="w-4 h-4"
/>
<span>{{ label }}</span>
</span>
<IconChevronRight class="w-4 h-4" />
</button>
<div
v-if="isOpen"
ref="submenu"
class="absolute p-1 z-50 left-full bg-white shadow-lg rounded-lg border border-neutral-200 cursor-default"
style="min-width: 170px"
:style="submenuStyle"
:class="menuClass"
@click.stop
>
<slot>
<button
v-for="option in options"
:key="option.value"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between space-x-2 text-sm cursor-pointer"
@click="handleSelect(option.value)"
>
<span class="whitespace-nowrap">{{ option.label }}</span>
<IconCheck
v-if="modelValue === option.value"
class="w-4 h-4"
/>
</button>
</slot>
</div>
</div>
</template>
<script>
import { IconChevronRight, IconCheck } from '@tabler/icons-vue'
export default {
name: 'ContextSubmenu',
components: {
IconChevronRight,
IconCheck
},
props: {
icon: {
type: [Function],
required: false,
default: null
},
label: {
type: String,
required: true
},
options: {
type: Array,
default: () => []
},
modelValue: {
type: [String, Number],
default: null
},
menuClass: {
type: String,
default: ''
}
},
emits: ['select', 'update:modelValue'],
data () {
return {
isOpen: false,
topOffset: 0
}
},
computed: {
submenuStyle () {
return {
top: this.topOffset + 'px'
}
}
},
beforeUnmount () {
this.clearTimeout()
},
methods: {
handleMouseEnter () {
clearTimeout(this.closeTimeout)
this.openTimeout = setTimeout(() => this.open(), 200)
},
handleMouseLeave () {
clearTimeout(this.openTimeout)
this.closeTimeout = setTimeout(() => this.close(), 200)
},
open () {
this.clearTimeout()
this.isOpen = true
this.topOffset = 0
this.$nextTick(() => setTimeout(() => this.adjustPosition(), 0))
},
clearTimeout () {
if (this.openTimeout) {
clearTimeout(this.openTimeout)
}
if (this.closeTimeout) {
clearTimeout(this.closeTimeout)
}
},
close () {
this.clearTimeout()
this.isOpen = false
},
handleSelect (value) {
this.$emit('select', value)
this.$emit('update:modelValue', value)
},
adjustPosition () {
if (!this.$refs.submenu) return
const rect = this.$refs.submenu.getBoundingClientRect()
const overflow = rect.bottom - window.innerHeight
if (overflow > 0) {
this.topOffset = -overflow - 4
} else {
this.topOffset = 0
}
}
}
}
</script>
@@ -7,10 +7,10 @@
<select
:placeholder="t('method')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences ||= {}, field.preferences.method = $event.target.value, save()]"
@change="[field.preferences ||= {}, field.preferences.method = $event.target.value, $emit('save')]"
>
<option
v-for="method in ['QeS', 'AeS']"
v-for="method in verificationMethods"
:key="method"
:value="method.toLowerCase()"
:selected="method.toLowerCase() === field.preferences?.method || (method === 'QeS' && !field.preferences?.method)"
@@ -26,32 +26,6 @@
{{ t('method') }}
</label>
</div>
<div
v-if="['cells'].includes(field.type)"
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent"
@change="[field.preferences ||= {}, field.preferences.align = $event.target.value, save()]"
>
<option
v-for="value in ['left', 'right', field.type === 'cells' ? null : 'center'].filter(Boolean)"
:key="value"
:selected="field.preferences?.align ? value === field.preferences.align : value === 'left'"
:value="value"
>
{{ t(value) }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('align') }}
</label>
</div>
<div
v-if="['select', 'radio'].includes(field.type) && !defaultField"
class="py-1.5 px-1 relative"
@@ -61,7 +35,7 @@
:placeholder="t('default_value')"
dir="auto"
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent"
@change="[field.default_value = $event.target.value, !field.default_value && delete field.default_value, save()]"
@change="[field.default_value = $event.target.value, !field.default_value && delete field.default_value, $emit('save')]"
>
<option
value=""
@@ -97,7 +71,7 @@
dir="auto"
:type="field.type"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
@blur="$emit('save')"
>
<label
v-if="field.default_value"
@@ -159,7 +133,7 @@
: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"
@blur="$emit('save')"
>
<label
v-if="lengthValidation.min"
@@ -178,7 +152,7 @@
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"
@blur="$emit('save')"
>
<label
v-if="lengthValidation.max"
@@ -203,7 +177,7 @@
: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"
@blur="$emit('save')"
>
<label
v-if="field.validation?.min"
@@ -222,7 +196,7 @@
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"
@blur="$emit('save')"
>
<label
v-if="field.validation?.max"
@@ -242,7 +216,7 @@
<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()]"
@change="[field.preferences ||= {}, field.preferences.format = $event.target.value, $emit('save')]"
>
<option
v-for="format in numberFormats"
@@ -272,7 +246,7 @@
:placeholder="t('regexp_validation')"
dir="auto"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
@blur="$emit('save')"
>
<label
v-if="field.validation.pattern"
@@ -293,7 +267,7 @@
: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"
@blur="$emit('save')"
>
<label
v-if="field.validation.message"
@@ -313,7 +287,7 @@
v-model="field.preferences.format"
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="save"
@change="$emit('save')"
>
<option
v-for="format in dateFormats"
@@ -339,7 +313,7 @@
<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.format = $event.target.value, save()]"
@change="[field.preferences.format = $event.target.value, $emit('save')]"
>
<option
value="any"
@@ -348,7 +322,7 @@
{{ t('any') }}
</option>
<option
v-for="type in ['drawn', 'typed', 'drawn_or_typed', 'drawn_or_upload', 'upload']"
v-for="type in signatureFormats"
:key="type"
:value="type"
:selected="field.preferences?.format === type"
@@ -374,7 +348,7 @@
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()]"
@change="[field.preferences ||= {}, field.preferences.with_signature_id = $event.target.checked, $emit('save')]"
>
<span class="label-text">{{ t('signature_id') }}</span>
</label>
@@ -389,7 +363,7 @@
type="checkbox"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.required))"
class="toggle toggle-xs"
@update:model-value="save"
@update:model-value="$emit('save')"
>
<span class="label-text">{{ t('required') }}</span>
</label>
@@ -403,7 +377,7 @@
:checked="field.preferences?.with_logo != false"
type="checkbox"
class="toggle toggle-xs"
@change="[field.preferences ||= {}, field.preferences.with_logo = field.preferences.with_logo == false, save()]"
@change="[field.preferences ||= {}, field.preferences.with_logo = field.preferences.with_logo == false, $emit('save')]"
>
<span class="label-text">{{ t('with_logo') }}</span>
</label>
@@ -417,7 +391,7 @@
v-model="field.default_value"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="[field.default_value = $event, field.readonly = $event, save()]"
@update:model-value="[field.default_value = $event, field.readonly = $event, $emit('save')]"
>
<span class="label-text">{{ t('checked') }}</span>
</label>
@@ -431,13 +405,13 @@
v-model="field.readonly"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="[field.default_value = $event ? '{{date}}' : null, field.readonly = $event, save()]"
@update:model-value="[field.default_value = $event ? '{{date}}' : null, field.readonly = $event, $emit('save')]"
>
<span class="label-text">{{ t('set_signing_date') }}</span>
</label>
</li>
<li
v-if="['text', 'number'].includes(field.type)"
v-if="['text', 'number', 'radio', 'multiple', 'select'].includes(field.type)"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -446,13 +420,13 @@
type="checkbox"
class="toggle toggle-xs"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.readonly))"
@update:model-value="save"
@update:model-value="$emit('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'])"
v-if="withPrefillable && prefillableFieldTypes.includes(field['type'])"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -461,7 +435,7 @@
type="checkbox"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.prefillable))"
class="toggle toggle-xs"
@update:model-value="save"
@update:model-value="$emit('save')"
>
<span class="label-text">{{ t('prefillable') }}</span>
</label>
@@ -470,7 +444,7 @@
v-if="field.type != 'stamp'"
class="pb-0.5 mt-0.5"
>
<li v-if="['text', 'number', 'date', 'select', 'heading'].includes(field.type)">
<li v-if="['text', 'number', 'date', 'select', 'heading', 'cells'].includes(field.type)">
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-font')"
@@ -499,7 +473,7 @@
</label>
</li>
<li
v-if="field.type != 'stamp' && field.type != 'heading'"
v-if="withCondition && field.type != 'stamp' && field.type != 'heading'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@@ -526,7 +500,10 @@
</span>
</label>
</li>
<hr class="pb-0.5 mt-0.5">
<hr
v-if="withCopyToAllPages || withAreas || withCustomFields"
class="pb-0.5 mt-0.5"
>
<template v-if="withAreas">
<li
v-for="(area, index) in sortedAreas"
@@ -564,7 +541,7 @@
</a>
</li>
</template>
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)">
<li v-if="withCopyToAllPages && field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@@ -577,10 +554,23 @@
{{ t('copy_to_all_pages') }}
</a>
</li>
<li v-if="withCustomFields">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('add-custom-field', field)"
>
<IconForms
:width="20"
:stroke-width="1.6"
/>
{{ t('save_as_custom_field') }}
</a>
</li>
</template>
<script>
import { IconRouteAltLeft, IconTypography, IconShape, IconX, IconMathFunction, IconNewSection, IconInfoCircle, IconCopy } from '@tabler/icons-vue'
import { IconRouteAltLeft, IconTypography, IconShape, IconX, IconMathFunction, IconNewSection, IconInfoCircle, IconCopy, IconForms } from '@tabler/icons-vue'
export default {
name: 'FieldSettings',
@@ -589,17 +579,33 @@ export default {
IconInfoCircle,
IconMathFunction,
IconRouteAltLeft,
IconForms,
IconCopy,
IconNewSection,
IconTypography,
IconX
},
inject: ['template', 'save', 't'],
inject: ['template', 't'],
props: {
field: {
type: Object,
required: true
},
withCondition: {
type: Boolean,
required: false,
default: true
},
withCustomFields: {
type: Boolean,
required: false,
default: false
},
withCopyToAllPages: {
type: Boolean,
required: false,
default: true
},
withSignatureId: {
type: Boolean,
required: false,
@@ -636,7 +642,7 @@ export default {
default: true
}
},
emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition', 'click-font', 'remove-area'],
emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition', 'click-font', 'remove-area', 'save', 'add-custom-field'],
data () {
return {
selectedValidation: ''
@@ -686,7 +692,7 @@ export default {
},
lengthValidation () {
if (this.field.validation?.pattern && this.selectedValidation !== 'custom') {
return this.field.validation.pattern.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups
return this.parseLengthPattern(this.field.validation.pattern)
} else {
return null
}
@@ -703,6 +709,15 @@ export default {
'^[a-zA-Z]+$': 'letters_only'
}
},
signatureFormats () {
return ['drawn', 'typed', 'drawn_or_typed', 'drawn_or_upload', 'upload']
},
verificationMethods () {
return ['QeS', 'AeS']
},
prefillableFieldTypes () {
return ['text', 'number', 'cells', 'date', 'checkbox', 'select', 'radio', 'phone']
},
sortedAreas () {
return (this.field.areas || []).sort((a, b) => {
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
@@ -730,7 +745,7 @@ export default {
delete this.field.validation
}
this.save()
this.$emit('save')
},
copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0])
@@ -747,7 +762,7 @@ export default {
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
this.save()
this.$emit('save')
},
formatNumber (number, format) {
if (format === 'comma') {
@@ -766,6 +781,9 @@ export default {
return number
}
},
parseLengthPattern (pattern) {
return pattern?.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups || null
},
formatDate (date, format) {
const monthFormats = {
M: 'numeric',
@@ -30,7 +30,7 @@
v-for="(icon, type) in fieldIconsSorted"
:key="type"
>
<li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))">
<li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification') && (withKba || type != 'kba'))">
<a
href="#"
class="text-sm py-1 px-2"
@@ -51,11 +51,11 @@
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconHeading, IconId, IconCalendarCheck, IconStrikethrough } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconHeading, IconId, IconCalendarCheck, IconStrikethrough, IconUserScan } from '@tabler/icons-vue'
export default {
name: 'FiledTypeDropdown',
inject: ['withPhone', 'withPayment', 'withVerification', 't', 'fieldTypes'],
inject: ['withPhone', 'withPayment', 'withVerification', 'withKba', 't', 'fieldTypes'],
props: {
modelValue: {
type: String,
@@ -114,7 +114,8 @@ export default {
stamp: this.t('stamp'),
payment: this.t('payment'),
phone: this.t('phone'),
verification: this.t('verify_id')
verification: this.t('verify_id'),
kba: this.t('kba')
}
},
fieldLabels () {
@@ -134,7 +135,8 @@ export default {
stamp: this.t('stamp_field'),
payment: this.t('payment_field'),
phone: this.t('phone_field'),
verification: this.t('verify_id')
verification: this.t('verify_id'),
kba: this.t('kba')
}
},
fieldIcons () {
@@ -157,7 +159,8 @@ export default {
stamp: IconRubberStamp,
payment: IconCreditCard,
phone: IconPhoneCheck,
verification: IconId
verification: IconId,
kba: IconUserScan
}
},
skipTypes () {
+237 -12
View File
@@ -15,22 +15,27 @@
</div>
<div
ref="fields"
class="fields mb-1 mt-2"
class="fields mt-2"
:class="{ 'mb-1': !withCustomFields || !customFields.length }"
@dragover.prevent="onFieldDragover"
@drop="reorderFields"
@drop="fieldsDragFieldRef.value ? reorderFields() : null"
>
<Field
v-for="field in submitterFields"
:key="field.uuid"
:data-uuid="field.uuid"
:field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
:type-index="getFieldTypeIndex(field)"
:editable="editable"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:default-field="defaultFieldsIndex[field.name]"
:draggable="editable"
:with-custom-fields="withCustomFields"
class="mb-1.5"
@add-custom-field="addCustomField"
@dragstart="[fieldsDragFieldRef.value = field, removeDragOverlay($event), setDragPlaceholder($event)]"
@save="save"
@dragend="[fieldsDragFieldRef.value = null, $emit('set-drag-placeholder', null)]"
@remove="removeField"
@scroll-to="$emit('scroll-to-area', $event)"
@@ -105,7 +110,92 @@
</div>
</div>
<div
v-if="editable && !onlyDefinedFields"
v-if="editable && withCustomFields && (customFields.length || newCustomField)"
class="tabs w-full mb-1.5"
>
<a
class="tab tab-bordered w-1/2 border-base-300"
:class="{ 'tab-active': !showCustomTab }"
:style="{ '--tab-border': showCustomTab ? '0px' : '0.5px' }"
@click="setFieldsTab('default')"
>{{ t('default') }}</a>
<a
class="tab tab-bordered w-1/2 border-base-300"
:class="{ 'tab-active': showCustomTab }"
:style="{ '--tab-border': showCustomTab ? '0.5px' : '0px' }"
@click="setFieldsTab('custom')"
>{{ t('custom') }}</a>
</div>
<div
v-if="showCustomTab && editable && (customFields.length || newCustomField)"
ref="customFields"
class="custom-fields"
@dragover.prevent="onCustomFieldDragover"
@drop="customDragFieldRef.value ? reorderCustomFields() : null"
>
<template v-if="isShowCustomFieldSearch">
<input
v-model="customFieldsSearch"
:placeholder="t('search_field')"
class="input input-ghost input-xs px-0 text-base mb-1 !outline-0 !rounded bg-transparent w-full"
>
<hr class="mb-2">
</template>
<div
ref="customFieldsList"
class="overflow-auto relative"
:style="{
maxHeight: isShowCustomFieldSearch ? '320px' : '',
minHeight: '320px'
}"
>
<div
v-if="!filteredCustomFields.length && customFieldsSearch"
class="top-0 bottom-0 text-center absolute flex items-center justify-center w-full flex-col"
>
<div>
{{ t('field_not_found') }}
</div>
<a
href="#"
class="link"
@click.prevent="customFieldsSearch = ''"
>
{{ t('clear') }}
</a>
</div>
<CustomField
v-if="newCustomField"
:key="newCustomField.uuid"
ref="newCustomField"
:data-uuid="newCustomField.uuid"
:is-new="true"
:field="newCustomField"
:draggable="false"
class="mb-1.5"
@save="onNewCustomFieldSave"
@remove="newCustomField = null"
/>
<CustomField
v-for="field in filteredCustomFields"
:key="field.uuid"
:data-uuid="field.uuid"
:field="field"
:draggable="true"
class="mb-1.5"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
@save="saveCustomFields"
@click="$emit('set-draw-custom-field', field)"
@dragstart="[customDragFieldRef.value = field, removeDragOverlay($event), setDragPlaceholder($event)]"
@dragend="[customDragFieldRef.value = null, $emit('set-drag-placeholder', null)]"
@remove="removeCustomField"
@set-draw="$emit('set-draw', $event)"
/>
</div>
</div>
<div
v-if="editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField))"
id="field-types-grid"
class="grid grid-cols-3 gap-1 pb-2 fields-grid"
>
@@ -114,7 +204,7 @@
:key="type"
>
<button
v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))"
v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification') && (withKba || type != 'kba'))"
:id="`${type}_type_field_button`"
draggable="true"
class="field-type-button group flex items-center justify-center border border-dashed w-full rounded relative fields-grid-item"
@@ -122,7 +212,7 @@
:class="drawFieldType === type ? 'border-base-content/40' : 'border-base-300 hover:border-base-content/20'"
@dragstart="onDragstart($event, { type: type })"
@dragend="$emit('drag-end')"
@click="['file', 'payment', 'verification'].includes(type) ? $emit('add-field', type) : $emit('set-draw-type', type)"
@click="['file', 'payment', 'verification', 'kba'].includes(type) ? $emit('add-field', type) : $emit('set-draw-type', type)"
>
<div
class="flex items-console transition-all cursor-grab h-full absolute left-0"
@@ -266,15 +356,18 @@
<script>
import Field from './field'
import CustomField from './custom_field'
import FieldType from './field_type'
import FieldSubmitter from './field_submitter'
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
import { v4 } from 'uuid'
export default {
name: 'TemplateFields',
components: {
Field,
CustomField,
FieldType,
IconCirclePlus,
IconSparkles,
@@ -283,12 +376,22 @@ export default {
IconDrag,
IconLock
},
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withPayment', 't', 'fieldsDragFieldRef', 'baseFetch'],
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'baseFetch', 'selectedAreasRef', 'getFieldTypeIndex'],
props: {
fields: {
type: Array,
required: true
},
customFields: {
type: Array,
required: false,
default: () => []
},
withCustomFields: {
type: Boolean,
required: false,
default: false
},
withFieldsSearch: {
type: Boolean,
required: false,
@@ -372,12 +475,15 @@ export default {
default: false
}
},
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter'],
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter'],
data () {
return {
fieldPagesLoaded: null,
analyzingProgress: 0,
defaultFieldsSearch: ''
newCustomField: null,
showCustomTab: false,
defaultFieldsSearch: '',
customFieldsSearch: ''
}
},
computed: {
@@ -430,6 +536,23 @@ export default {
} else {
return this.submitterDefaultFields
}
},
isShowCustomFieldSearch () {
return this.customFields.length > 8
},
filteredCustomFields () {
if (this.customFieldsSearch) {
return this.customFields.filter((f) => f.name.toLowerCase().includes(this.customFieldsSearch.toLowerCase()))
} else {
return this.customFields
}
}
},
mounted () {
try {
this.showCustomTab = localStorage.getItem('docuseal_builder_tab') === 'custom'
} catch (e) {
console.error(e)
}
},
methods: {
@@ -440,6 +563,61 @@ export default {
this.$emit('set-drag', field)
},
onNewCustomFieldSave () {
if (this.newCustomField.name) {
this.customFields.unshift(this.newCustomField)
this.newCustomField = null
this.saveCustomFields()
} else {
this.newCustomField = null
}
},
addCustomField (field) {
const customField = JSON.parse(JSON.stringify(field))
customField.uuid = v4()
delete customField.submitter_uuid
delete customField.prefillable
delete customField.conditions
customField.areas?.forEach((area) => {
delete area.attachment_uuid
delete area.page
})
if (customField.name) {
this.customFields.unshift(customField)
this.saveCustomFields()
} else {
this.newCustomField = customField
}
this.setFieldsTab('custom')
},
setFieldsTab (type) {
try {
localStorage.setItem('docuseal_builder_tab', type)
} catch (e) {
console.error(e)
}
this.showCustomTab = type === 'custom'
},
saveCustomFields () {
return this.baseFetch('/account_custom_fields', {
method: 'POST',
body: JSON.stringify({
value: this.customFields
}),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
const fields = await resp.json()
this.customFields.splice(0, this.customFields.length, ...fields)
})
},
detectFields () {
const fields = []
@@ -579,7 +757,7 @@ export default {
removeSubmitter (submitter) {
[...this.fields].forEach((field) => {
if (field.submitter_uuid === submitter.uuid) {
this.removeField(field)
this.removeField(field, false)
}
})
@@ -591,7 +769,7 @@ export default {
this.save()
},
removeField (field) {
removeField (field, save = true) {
this.fields.splice(this.fields.indexOf(field), 1)
this.fields.forEach((f) => {
@@ -610,7 +788,54 @@ export default {
})
})
this.save()
field.areas?.forEach((area) => {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(area), 1)
})
if (save) {
this.save()
}
},
removeCustomField (field) {
this.customFields.splice(this.customFields.indexOf(field), 1)
if (!this.customFields.length) {
this.setFieldsTab('default')
}
this.saveCustomFields()
},
onCustomFieldDragover (e) {
if (this.customDragFieldRef.value && this.customFields.includes(this.customDragFieldRef.value)) {
const container = this.$refs.customFieldsList
const targetField = e.target.closest('[data-uuid]')
const dragField = container.querySelector(`[data-uuid="${this.customDragFieldRef.value.uuid}"]`)
if (dragField && targetField && targetField !== dragField) {
const fields = Array.from(container.children)
const currentIndex = fields.indexOf(dragField)
const targetIndex = fields.indexOf(targetField)
if (currentIndex < targetIndex) {
targetField.after(dragField)
} else {
targetField.before(dragField)
}
}
}
},
reorderCustomFields () {
if (!this.customFields.includes(this.customDragFieldRef.value)) {
return
}
const reorderedFields = Array.from(this.$refs.customFieldsList.children).map((el) => {
return this.customFields.find((f) => f.uuid === el.dataset.uuid)
}).filter(Boolean)
this.customFields.splice(0, this.customFields.length, ...reorderedFields)
this.saveCustomFields()
}
}
}
@@ -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') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field, template.fields) }}
{{ t('font') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
</span>
<a
href="#"
@@ -171,7 +171,7 @@
contenteditable="true"
class="outline-none whitespace-nowrap truncate"
>
{{ field.default_value || field.name || buildDefaultName(field, template.fields) }}
{{ field.default_value || field.name || buildDefaultName(field) }}
</span>
</div>
</div>
@@ -196,7 +196,7 @@ export default {
components: {
IconChevronDown
},
inject: ['t', 'save', 'template'],
inject: ['t', 'template'],
props: {
field: {
type: Object,
@@ -217,7 +217,7 @@ export default {
required: true
}
},
emits: ['close'],
emits: ['close', 'save'],
data () {
return {
preferences: {}
@@ -257,6 +257,7 @@ export default {
colors () {
return [
{ label: '⬛', value: 'black' },
{ label: '⬜', value: 'white' },
{ label: '🟦', value: 'blue' },
{ label: '🟥', value: 'red' }
]
@@ -271,6 +272,7 @@ export default {
'items-center': !this.preferences.valign || this.preferences.valign === 'center',
'items-start': this.preferences.valign === 'top',
'items-end': this.preferences.valign === 'bottom',
'bg-black': this.preferences.color === 'white',
'font-bold': ['bold_italic', 'bold'].includes(this.preferences.font_type),
italic: ['bold_italic', 'italic'].includes(this.preferences.font_type)
}
@@ -322,8 +324,7 @@ export default {
Object.assign(this.field.preferences, this.preferences)
this.save()
this.$emit('save')
this.$emit('close')
}
}
@@ -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') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field, template.fields) }}
{{ t('formula') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
</span>
<a
href="#"
@@ -33,7 +33,7 @@
v-for="f in fields"
:key="f.uuid"
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)}}}`)"
@click.prevent="insertTextUnderCursor(`{{${f.name || buildDefaultName(f)}}}`)"
>
<IconMathFunction
v-if="f.preferences?.formula"
@@ -47,7 +47,7 @@
height="20"
stroke-width="1.5"
/>
{{ f.name || buildDefaultName(f, template.fields) }}
{{ f.name || buildDefaultName(f) }}
</button>
</div>
<div>
@@ -131,7 +131,7 @@ export default {
IconCodePlus,
IconMathFunction
},
inject: ['t', 'save', 'template', 'withFormula'],
inject: ['t', 'template', 'withFormula'],
props: {
field: {
type: Object,
@@ -152,7 +152,7 @@ export default {
required: true
}
},
emits: ['close'],
emits: ['close', 'save'],
data () {
return {
formula: ''
@@ -181,7 +181,7 @@ export default {
const foundField = this.fields.find((f) => f.uuid === uuid)
if (foundField) {
return `{{${foundField.name || this.buildDefaultName(foundField, this.template.fields)}}}`
return `{{${foundField.name || this.buildDefaultName(foundField)}}}`
} else {
return '{{FIELD NOT FOUND}}'
}
@@ -190,7 +190,7 @@ export default {
normalizeFormula (text) {
return text.replace(/{{(.*?)}}/g, (match, name) => {
const foundField = this.fields.find((f) => {
return (f.name || this.buildDefaultName(f, this.template.fields)).trim() === name.trim()
return (f.name || this.buildDefaultName(f)).trim() === name.trim()
})
if (foundField) {
@@ -212,11 +212,14 @@ export default {
} else {
this.field.preferences.formula = normalizedFormula
if (this.field.type !== 'payment') {
if (this.field.type === 'payment') {
delete this.field.preferences.price
delete this.field.preferences.payment_link_id
} else {
this.field.readonly = !!normalizedFormula
}
this.save()
this.$emit('save')
this.$emit('close')
}
@@ -27,7 +27,7 @@
</li>
<li>
<button
form="sync_form"
:form="`sync_form_${inputId}`"
type="submit"
:disabled="isLoading"
>
@@ -40,7 +40,7 @@
</li>
</ul>
<form
id="sync_form"
:id="`sync_form_${inputId}`"
ref="form"
class="hidden"
@submit.prevent="upload({ path: uploadUrl })"
+178 -10
View File
@@ -1,9 +1,13 @@
const en = {
fixed: 'Fixed',
default: 'Default',
save_as_custom_field: 'Save as custom field',
kba: 'KBA',
analyzing_: 'Analyzing...',
download: 'Download',
downloading_: 'Downloading...',
view: 'View',
autodetect_fields: 'Autodetect fields',
autodetect_fields: 'Autodetect Fields',
payment_link: 'Payment link',
strikeout: 'Strikeout',
draw_strikethrough_the_document: 'Draw strikethrough the document',
@@ -28,6 +32,9 @@ const en = {
field_not_found: 'Field not found',
clear: 'Clear',
align: 'Align',
resize: 'Resize',
width: 'Width',
height: 'Height',
add_all_required_fields_to_continue: 'Add all required fields to continue',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Uploaded PDF contains form fields. Keep or remove them?',
keep: 'Keep',
@@ -45,6 +52,8 @@ const en = {
type_value: 'Type value',
equal: 'Equal',
not_equal: 'Not equal',
greater_than: 'Greater than',
less_than: 'Less than',
contains: 'Contains',
does_not_contain: 'Does not contain',
not_empty: 'Not empty',
@@ -88,8 +97,9 @@ const en = {
format: 'Format',
read_only: 'Read-only',
page: 'Page',
draw_new_area: 'Draw New Area',
copy_to_all_pages: 'Copy to All Pages',
draw_new_area: 'Draw new area',
copy_to_all_pages: 'Copy to all pages',
more: 'More',
add_option: 'Add option',
option: 'Option',
options: 'Options',
@@ -170,6 +180,9 @@ const en = {
numbers_only: 'Numbers only',
letters_only: 'Letters only',
regexp_validation: 'Regexp validation',
custom_validation: 'Custom Validation',
length_validation: 'Length Validation',
number_range: 'Number Range',
enter_pdf_password: 'Enter PDF password',
wrong_password: 'Wrong password',
currency: 'Currency',
@@ -184,10 +197,25 @@ const en = {
start_tour: 'Start Tour',
or_add_from: 'Or add from',
sync: 'Sync',
syncing: 'Syncing...'
syncing: 'Syncing...',
copy: 'Copy',
paste: 'Paste',
select_fields: 'Select Fields',
draw_fields: 'Draw Fields',
align_left: 'Align Left',
align_right: 'Align Right',
align_top: 'Align Top',
align_bottom: 'Align Bottom',
fields_selected: '{count} Fields Selected',
field_added: '{count} Field Added',
fields_added: '{count} Fields Added'
}
const es = {
fixed: 'Fijo',
default: 'Predeterminado',
save_as_custom_field: 'Guardar como personalizado',
kba: 'KBA',
autodetect_fields: 'Autodetectar campos',
analyzing_: 'Analizando...',
download: 'Descargar',
@@ -218,6 +246,9 @@ const es = {
clear: 'Borrar',
type_value: 'Escriba valor',
align: 'Alinear',
resize: 'Redimensionar',
width: 'Ancho',
height: 'Alto',
add_all_required_fields_to_continue: 'Agregar todos los campos requeridos para continuar',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'El PDF cargado tiene campos. ¿Mantenerlos o eliminarlos?',
keep: 'Mantener',
@@ -235,6 +266,8 @@ const es = {
price: 'Precio',
equal: 'Igual',
not_equal: 'No es igual',
greater_than: 'Mayor que',
less_than: 'Menor que',
contains: 'Contiene',
does_not_contain: 'No contiene',
not_empty: 'No vacío',
@@ -275,6 +308,7 @@ const es = {
page: 'Página',
draw_new_area: 'Dibujar nueva área',
copy_to_all_pages: 'Copiar a todas las páginas',
more: 'Más',
add_option: 'Agregar opción',
option: 'Opción',
options: 'Opciones',
@@ -359,6 +393,9 @@ const es = {
numbers_only: 'Solo números',
letters_only: 'Solo letras',
regexp_validation: 'Validación de expresión regular',
custom_validation: 'Validación Personalizada',
length_validation: 'Validación de Longitud',
number_range: 'Rango de Números',
enter_pdf_password: 'Ingrese la contraseña del PDF',
wrong_password: 'Contraseña incorrecta',
currency: 'Moneda',
@@ -373,10 +410,25 @@ const es = {
start_tour: 'Iniciar guía',
or_add_from: 'O agregar desde',
sync: 'Sincronizar',
syncing: 'Sincronizando...'
syncing: 'Sincronizando...',
copy: 'Copiar',
paste: 'Pegar',
select_fields: 'Seleccionar Campos',
draw_fields: 'Dibujar Campos',
align_left: 'Alinear a la izquierda',
align_right: 'Alinear a la derecha',
align_top: 'Alinear arriba',
align_bottom: 'Alinear abajo',
fields_selected: '{count} Campos Seleccionados',
field_added: '{count} Campo Añadido',
fields_added: '{count} Campos Añadidos'
}
const it = {
fixed: 'Fisso',
default: 'Predefinito',
save_as_custom_field: 'Salva come personalizzato',
kba: 'KBA',
autodetect_fields: 'Rileva campi',
analyzing_: 'Analisi...',
download: 'Scarica',
@@ -406,6 +458,9 @@ const it = {
field_not_found: 'Campo non trovato',
clear: 'Cancella',
align: 'Allinea',
resize: 'Ridimensiona',
width: 'Larghezza',
height: 'Altezza',
add_all_required_fields_to_continue: 'Aggiungi tutti i campi obbligatori per continuare',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Il PDF caricato contiene campi del modulo. Mantenerli o rimuoverli?',
keep: 'Mantieni',
@@ -423,6 +478,8 @@ const it = {
type_value: 'Inserisci valore',
equal: 'Uguale',
not_equal: 'Non uguale',
greater_than: 'Maggiore di',
less_than: 'Minore di',
contains: 'Contiene',
does_not_contain: 'Non contiene',
not_empty: 'Non vuoto',
@@ -468,6 +525,7 @@ const it = {
page: 'Pagina',
draw_new_area: 'Disegna nuova area',
copy_to_all_pages: 'Copia in tutte le pagine',
more: 'Altro',
add_option: 'Aggiungi opzione',
option: 'Opzione',
options: 'Opzioni',
@@ -548,6 +606,9 @@ const it = {
numbers_only: 'Solo numeri',
letters_only: 'Solo lettere',
regexp_validation: 'Validazione regexp',
custom_validation: 'Validazione Personalizzata',
length_validation: 'Validazione Lunghezza',
number_range: 'Intervallo Numerico',
enter_pdf_password: 'Inserisci password PDF',
wrong_password: 'Password errata',
currency: 'Valuta',
@@ -562,10 +623,25 @@ const it = {
start_tour: 'Inizia il tour',
or_add_from: 'O aggiungi da',
sync: 'Sincronizza',
syncing: 'Sincronizzazione...'
syncing: 'Sincronizzazione...',
copy: 'Copia',
paste: 'Incolla',
select_fields: 'Seleziona Campi',
draw_fields: 'Disegna Campi',
align_left: 'Allinea a sinistra',
align_right: 'Allinea a destra',
align_top: 'Allinea in alto',
align_bottom: 'Allinea in basso',
fields_selected: '{count} Campi Selezionati',
field_added: '{count} Campo Aggiunto',
fields_added: '{count} Campi Aggiunti'
}
const pt = {
fixed: 'Fixo',
default: 'Padrão',
save_as_custom_field: 'Salvar como personalizado',
kba: 'KBA',
autodetect_fields: 'Detectar campos',
analyzing_: 'Analisando...',
download: 'Baixar',
@@ -599,6 +675,9 @@ const pt = {
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'O PDF carregado contém campos. Manter ou removê-los?',
keep: 'Manter',
align: 'Alinhar',
resize: 'Redimensionar',
width: 'Largura',
height: 'Altura',
left: 'Esquerda',
heading: 'Cabeçalho',
validation: 'Validação',
@@ -613,6 +692,8 @@ const pt = {
price: 'Preço',
equal: 'Igual',
not_equal: 'Não é igual',
greater_than: 'Maior que',
less_than: 'Menor que',
contains: 'Contém',
does_not_contain: 'Não contém',
not_empty: 'Não vazio',
@@ -653,6 +734,7 @@ const pt = {
page: 'Página',
draw_new_area: 'Desenhar nova área',
copy_to_all_pages: 'Copiar para todas as páginas',
more: 'Mais',
add_option: 'Adicionar opção',
option: 'Opção',
options: 'Opções',
@@ -737,6 +819,9 @@ const pt = {
numbers_only: 'Somente números',
letters_only: 'Somente letras',
regexp_validation: 'Validação de expressão regular',
custom_validation: 'Validação Personalizada',
length_validation: 'Validação de Comprimento',
number_range: 'Intervalo de Números',
enter_pdf_password: 'Digite a senha do PDF',
wrong_password: 'Senha incorreta',
currency: 'Moeda',
@@ -751,10 +836,25 @@ const pt = {
start_tour: 'Iniciar tour',
or_add_from: 'Ou adicionar de',
sync: 'Sincronizar',
syncing: 'Sincronizando...'
syncing: 'Sincronizando...',
copy: 'Copiar',
paste: 'Colar',
select_fields: 'Selecionar Campos',
draw_fields: 'Desenhar Campos',
align_left: 'Alinhar à esquerda',
align_right: 'Alinhar à direita',
align_top: 'Alinhar ao topo',
align_bottom: 'Alinhar à parte inferior',
fields_selected: '{count} Campos Selecionados',
field_added: '{count} Campo Adicionado',
fields_added: '{count} Campos Adicionados'
}
const fr = {
fixed: 'Fixe',
default: 'Par défaut',
save_as_custom_field: 'Enregistrer comme personnalisé',
kba: 'KBA',
autodetect_fields: 'Détecter les champs',
analyzing_: 'Analyse...',
download: 'Télécharger',
@@ -784,6 +884,9 @@ const fr = {
field_not_found: 'Champ introuvable',
clear: 'Effacer',
align: 'Aligner',
resize: 'Redimensionner',
width: 'Largeur',
height: 'Hauteur',
add_all_required_fields_to_continue: 'Ajoutez tous les champs obligatoires pour continuer',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Le PDF téléversé contient des champs de formulaire. Les conserver ou les supprimer ?',
keep: 'Conserver',
@@ -801,6 +904,8 @@ const fr = {
type_value: 'Saisir une valeur',
equal: 'Égal',
not_equal: 'Différent',
greater_than: 'Supérieur à',
less_than: 'Inférieur à',
contains: 'Contient',
does_not_contain: 'Ne contient pas',
not_empty: 'Non vide',
@@ -846,6 +951,7 @@ const fr = {
page: 'Page',
draw_new_area: 'Dessiner une zone',
copy_to_all_pages: 'Copier sur toutes les pages',
more: 'Plus',
add_option: 'Ajouter une option',
option: 'Option',
options: 'Options',
@@ -926,6 +1032,9 @@ const fr = {
numbers_only: 'Chiffres uniquement',
letters_only: 'Lettres uniquement',
regexp_validation: 'Validation par expression régulière',
custom_validation: 'Validation Personnalisée',
length_validation: 'Validation de Longueur',
number_range: 'Plage de Nombres',
enter_pdf_password: 'Saisir le mot de passe du PDF',
wrong_password: 'Mot de passe incorrect',
currency: 'Devise',
@@ -940,10 +1049,25 @@ const fr = {
start_tour: 'Démarrer',
or_add_from: 'Ou ajouter depuis',
sync: 'Synchroniser',
syncing: 'Synchronisation...'
syncing: 'Synchronisation...',
copy: 'Copier',
paste: 'Coller',
select_fields: 'Sélectionner Champs',
draw_fields: 'Dessiner Champs',
align_left: 'Aligner à gauche',
align_right: 'Aligner à droite',
align_top: 'Aligner en haut',
align_bottom: 'Aligner en bas',
fields_selected: '{count} Champs Sélectionnés',
field_added: '{count} Champ Ajouté',
fields_added: '{count} Champs Ajoutés'
}
const de = {
fixed: 'Fest',
default: 'Standard',
save_as_custom_field: 'Als benutzerdefiniert speichern',
kba: 'KBA',
autodetect_fields: 'Felder erkennen',
analyzing_: 'Analysiere...',
download: 'Download',
@@ -973,6 +1097,9 @@ const de = {
field_not_found: 'Feld nicht gefunden',
clear: 'Leeren',
align: 'Ausrichten',
resize: 'Größe ändern',
width: 'Breite',
height: 'Höhe',
add_all_required_fields_to_continue: 'Fügen Sie alle erforderlichen Felder hinzu, um fortzufahren',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Das hochgeladene PDF enthält Formularfelder. Beibehalten oder entfernen?',
keep: 'Beibehalten',
@@ -990,6 +1117,8 @@ const de = {
type_value: 'Wert eingeben',
equal: 'Gleich',
not_equal: 'Ungleich',
greater_than: 'Größer als',
less_than: 'Kleiner als',
contains: 'Enthält',
does_not_contain: 'Enthält nicht',
not_empty: 'Nicht leer',
@@ -1035,6 +1164,7 @@ const de = {
page: 'Seite',
draw_new_area: 'Bereich zeichnen',
copy_to_all_pages: 'Auf alle Seiten kopieren',
more: 'Mehr',
add_option: 'Option hinzufügen',
option: 'Option',
options: 'Optionen',
@@ -1115,6 +1245,9 @@ const de = {
numbers_only: 'Nur Zahlen',
letters_only: 'Nur Buchstaben',
regexp_validation: 'RegExp-Validierung',
custom_validation: 'Benutzerdefinierte Validierung',
length_validation: 'Längenvalidierung',
number_range: 'Zahlenbereich',
enter_pdf_password: 'PDF-Passwort eingeben',
wrong_password: 'Falsches Passwort',
currency: 'Währung',
@@ -1129,10 +1262,25 @@ const de = {
start_tour: 'Tour starten',
or_add_from: 'Oder hinzufügen aus',
sync: 'Synchronisieren',
syncing: 'Synchronisiere...'
syncing: 'Synchronisiere...',
copy: 'Kopieren',
paste: 'Einfügen',
select_fields: 'Felder Auswählen',
draw_fields: 'Felder Zeichnen',
align_left: 'Links ausrichten',
align_right: 'Rechts ausrichten',
align_top: 'Oben ausrichten',
align_bottom: 'Unten ausrichten',
fields_selected: '{count} Felder Ausgewählt',
field_added: '{count} Feld Hinzugefügt',
fields_added: '{count} Felder Hinzugefügt'
}
const nl = {
fixed: 'Vast',
default: 'Standaard',
save_as_custom_field: 'Opslaan als aangepast',
kba: 'KBA',
autodetect_fields: 'Velden detecteren',
analyzing_: 'Analyseren...',
download: 'Downloaden',
@@ -1162,6 +1310,9 @@ const nl = {
field_not_found: 'Veld niet gevonden',
clear: 'Wissen',
align: 'Uitlijnen',
resize: 'Formaat wijzigen',
width: 'Breedte',
height: 'Hoogte',
add_all_required_fields_to_continue: 'Voeg alle vereiste velden toe om door te gaan',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Geüploade PDF bevat formuliervelden. Behouden of verwijderen?',
keep: 'Behouden',
@@ -1179,6 +1330,8 @@ const nl = {
type_value: 'Typ waarde',
equal: 'Gelijk aan',
not_equal: 'Niet gelijk aan',
greater_than: 'Groter dan',
less_than: 'Kleiner dan',
contains: 'Bevat',
does_not_contain: 'Bevat niet',
not_empty: 'Niet leeg',
@@ -1224,6 +1377,7 @@ const nl = {
page: 'Pagina',
draw_new_area: 'Nieuw gebied tekenen',
copy_to_all_pages: 'Kopieer naar alle pag.',
more: 'Meer',
add_option: 'Optie toevoegen',
option: 'Optie',
options: 'Opties',
@@ -1304,6 +1458,9 @@ const nl = {
numbers_only: 'Alleen cijfers',
letters_only: 'Alleen letters',
regexp_validation: 'Regex validatie',
custom_validation: 'Aangepaste Validatie',
length_validation: 'Lengte Validatie',
number_range: 'Getalbereik',
enter_pdf_password: 'Voer PDF-wachtwoord in',
wrong_password: 'Onjuist wachtwoord',
currency: 'Valuta',
@@ -1318,7 +1475,18 @@ const nl = {
start_tour: 'Rondleiding starten',
or_add_from: 'Of toevoegen van',
sync: 'Synchroniseren',
syncing: 'Synchroniseren...'
syncing: 'Synchroniseren...',
copy: 'Kopiëren',
paste: 'Plakken',
select_fields: 'Velden Selecteren',
draw_fields: 'Velden Tekenen',
align_left: 'Links uitlijnen',
align_right: 'Rechts uitlijnen',
align_top: 'Boven uitlijnen',
align_bottom: 'Onder uitlijnen',
fields_selected: '{count} Velden Geselecteerd',
field_added: '{count} Veld Toegevoegd',
fields_added: '{count} Velden Toegevoegd'
}
export { en, es, it, pt, fr, de, nl }
+8 -75
View File
@@ -2,87 +2,20 @@
<svg
height="40"
width="40"
version="1.1"
style="color: #e0753f"
viewBox="0 0 180 180"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 511.998 511.998"
xml:space="preserve"
>
<path
style="fill:#AA968C;"
d="M503.999,247.999c0,128.13-111.033,240-248,240S8,376.129,8,247.999s111.033-224,248-224
S503.999,119.869,503.999,247.999z"
/>
<path
style="fill:#AA968C;"
d="M255.999,23.999C119.033,23.999,8,119.868,8,247.999c0,24.631,4.138,48.647,11.74,71.397
c25.518,34.29,66.232,56.602,112.26,56.602c53.893,0,100.6-30.495,124-75.13c23.4,44.636,70.107,75.13,124,75.13
c46.028,0,86.743-22.312,112.26-56.602c7.602-22.75,11.74-46.767,11.74-71.397C503.999,119.868,392.966,23.999,255.999,23.999z"
fill="currentColor"
d="M 178.224 72.09 c -0.296 -1.463 -0.627 -2.919 -0.996 -4.364 -0.293 -1.151 -0.616 -2.293 -0.956 -3.433 -0.301 -1.008 -0.612 -2.014 -0.95 -3.012 -0.531 -1.578 -1.113 -3.142 -1.735 -4.694 -0.216 -0.54 -0.433 -1.082 -0.661 -1.618 -0.195 -0.462 -0.399 -0.917 -0.601 -1.375 -0.262 -0.591 -0.53 -1.177 -0.804 -1.762 -0.074 -0.159 -0.151 -0.315 -0.226 -0.474 -0.209 -0.441 -0.422 -0.881 -0.638 -1.318 -0.076 -0.154 -0.153 -0.306 -0.229 -0.459 -0.236 -0.471 -0.477 -0.939 -0.721 -1.406 -0.053 -0.101 -0.105 -0.201 -0.158 -0.302 -1.143 -2.16 -2.367 -4.269 -3.68 -6.322 -0.116 -0.181 -0.237 -0.359 -0.355 -0.539 -0.094 -0.144 -0.189 -0.288 -0.284 -0.432 -0.284 -0.431 -0.57 -0.861 -0.862 -1.287 -0.112 -0.164 -0.225 -0.326 -0.338 -0.489 -0.193 -0.279 -0.382 -0.56 -0.579 -0.836 -0.089 -0.125 -0.182 -0.249 -0.273 -0.374 -0.13 -0.182 -0.264 -0.362 -0.395 -0.542 -0.277 -0.38 -0.556 -0.76 -0.838 -1.135 -0.15 -0.199 -0.303 -0.395 -0.454 -0.593 -0.21 -0.274 -0.417 -0.552 -0.63 -0.823 -0.055 -0.069 -0.111 -0.136 -0.166 -0.205 -0.482 -0.61 -0.971 -1.216 -1.47 -1.814 -0.129 -0.155 -0.262 -0.306 -0.392 -0.461 -0.402 -0.476 -0.808 -0.95 -1.22 -1.417 -0.186 -0.212 -0.375 -0.422 -0.563 -0.631 -0.384 -0.428 -0.773 -0.854 -1.167 -1.276 -0.176 -0.189 -0.351 -0.379 -0.529 -0.567 -0.564 -0.595 -1.134 -1.186 -1.716 -1.768 -1.091 -1.091 -2.207 -2.15 -3.346 -3.178 -1.016 -0.919 -2.05 -1.815 -3.103 -2.684 -0.772 -0.636 -1.557 -1.255 -2.348 -1.864 -3.465 -2.67 -7.112 -5.075 -10.927 -7.209 -2.869 -1.604 -5.83 -3.06 -8.883 -4.351 -2.443 -1.033 -4.922 -1.948 -7.428 -2.756 -8.879 -2.863 -18.13 -4.318 -27.605 -4.318 -3.19 0 -6.354 0.169 -9.488 0.496 -4.036 0.421 -8.019 1.114 -11.94 2.073 -1.732 0.423 -3.452 0.892 -5.157 1.42 -2.856 0.883 -5.673 1.912 -8.447 3.085 -2.645 1.118 -5.222 2.357 -7.729 3.711 -2.574 1.39 -5.073 2.901 -7.494 4.533 -1.195 0.805 -2.37 1.64 -3.527 2.503 -1.156 0.864 -2.292 1.756 -3.408 2.676 -0.553 0.456 -1.1 0.919 -1.643 1.389 -1.649 1.427 -3.252 2.92 -4.806 4.473 -2.582 2.582 -4.991 5.299 -7.222 8.138 -0.892 1.135 -1.756 2.292 -2.59 3.467 -0.417 0.588 -0.827 1.18 -1.23 1.778 -0.403 0.597 -0.798 1.199 -1.186 1.806 -0.388 0.607 -0.769 1.218 -1.143 1.835 -2.241 3.697 -4.216 7.562 -5.916 11.582 -1.095 2.589 -2.059 5.217 -2.901 7.877 -0.153 0.482 -0.3 0.965 -0.444 1.449 -0.339 1.14 -0.663 2.282 -0.956 3.433 -0.369 1.446 -0.7 2.901 -0.996 4.364 -1.034 5.121 -1.618 10.343 -1.749 15.637 -0.018 0.757 -0.028 1.514 -0.028 2.274 0 1.123 0.02 2.244 0.062 3.361 0.285 7.82 1.568 15.475 3.825 22.879 0.044 0.147 0.088 0.295 0.133 0.441 0.877 2.823 1.894 5.608 3.054 8.35 0.85 2.009 1.769 3.98 2.755 5.912 0.539 1.057 1.105 2.099 1.685 3.132 4.013 7.142 8.98 13.698 14.846 19.564 7.713 7.713 16.611 13.878 26.477 18.352 0.705 0.32 1.415 0.632 2.131 0.935 2.081 0.88 4.185 1.679 6.313 2.396 9.217 3.106 18.85 4.677 28.719 4.677 8.031 0 15.902 -1.047 23.522 -3.107 0.633 -0.172 1.266 -0.35 1.895 -0.535 0.757 -0.222 1.509 -0.456 2.26 -0.698 0.717 -0.232 1.431 -0.474 2.145 -0.723 1.752 -0.616 3.49 -1.281 5.211 -2.009 0.755 -0.319 1.503 -0.651 2.247 -0.989 1.237 -0.563 2.459 -1.15 3.664 -1.766 0.644 -0.328 1.283 -0.665 1.917 -1.009 1.654 -0.896 3.274 -1.848 4.865 -2.844 5.736 -3.591 11.06 -7.827 15.912 -12.679 0.775 -0.775 1.534 -1.562 2.278 -2.36 5.204 -5.59 9.636 -11.754 13.246 -18.417 0.343 -0.634 0.68 -1.274 1.009 -1.917 0.482 -0.944 0.943 -1.9 1.392 -2.863 0.471 -1.007 0.928 -2.021 1.364 -3.049 1.22 -2.886 2.281 -5.82 3.187 -8.793 0.559 -1.833 1.056 -3.68 1.494 -5.542 0.108 -0.458 0.211 -0.916 0.312 -1.376 0.194 -0.883 0.373 -1.77 0.539 -2.659 1.02 -5.455 1.542 -11.02 1.542 -16.663 0 -6.074 -0.595 -12.058 -1.776 -17.911 z m -161.733 19.614 c -1.118 -56.662 44.604 -74.877 60.998 -67.647 2.187 0.965 4.732 2.431 7.042 2.96 5.295 1.213 13.432 -3.113 13.521 6.273 0.078 8.156 -3.389 13.108 -10.797 16.177 -7.539 3.124 -14.777 9.181 -19.95 15.493 -21.487 26.216 -31.231 68.556 -7.565 94.296 -13.679 -5.545 -42.418 -25.467 -43.248 -67.552 z m 91.109 72.619 c -0.053 0.008 -4.171 0.775 -4.171 0.775 0 0 -15.862 -22.957 -23.509 -21.719 11.291 16.04 12.649 22.625 12.649 22.625 -0.053 0.001 -0.107 0.001 -0.161 0.003 -51.831 2.131 -42.785 -64.026 -28.246 -86.502 -1.555 13.073 8.878 39.992 39.034 44.1 9.495 1.293 32.302 -3.275 41.015 -11.38 0.098 1.825 0.163 3.85 0.159 6.013 -0.046 23.538 -13.47 42.743 -36.77 46.085 z m 30.575 -15.708 c 9.647 -9.263 12.869 -27.779 9.103 -44.137 -4.608 -20.011 -28.861 -32.383 -40.744 -35.564 5.766 -8.089 27.908 -14.274 39.567 5.363 -5.172 -10.519 -13.556 -23.023 -1.732 -33.128 12.411 13.329 19.411 29.94 20.161 48.7 0.75 18.753 -6.64 41.768 -26.355 58.765 z"
/>
<circle
style="fill:#C8AF9B;"
cx="256"
cy="351.999"
r="136"
fill="currentColor"
cx="71.927"
cy="32.004"
r="2.829"
/>
<g>
<circle
style="fill:#464655;"
cx="132"
cy="203.999"
r="28"
/>
<circle
style="fill:#464655;"
cx="380"
cy="203.999"
r="28"
/>
<path
style="fill:#464655;"
d="M269.949,284.516c-7.672,10.741-20.227,10.741-27.899,0l-12.101-16.941
c-7.672-10.741-3.15-19.53,10.05-19.53h32c13.2,0,17.723,8.788,10.05,19.53L269.949,284.516z"
/>
</g>
<path
style="fill:#AA968C;"
d="M 350.964 399.998 C 316.628 399.998 299.021 351.998 255.882 351.998 C 212.742 351.998 195.135 399.998 160.801 399.998 C 145.395 399.998 131.723 394.147 120.621 374.798 C 131.595 439.03 187.893 487.998 255.881 487.998 C 323.868 487.998 380.168 439.03 391.14 374.798 C 380.04 394.148 366.368 399.998 350.964 399.998 Z"
/>
<g>
<path
style="fill:#8C7873;"
d="M32,423.998c-3.172,0-6.18-1.906-7.43-5.031c-1.641-4.105,0.359-8.758,4.461-10.402l160.008-64
c4.062-1.617,8.758,0.352,10.398,4.457s-0.359,8.758-4.461,10.402l-160.008,64C34,423.811,32.992,423.998,32,423.998z"
/>
<path
style="fill:#8C7873;"
d="M15.992,375.995c-3.547,0-6.781-2.375-7.727-5.965c-1.125-4.273,1.422-8.648,5.695-9.773l152-40
c4.289-1.121,8.648,1.426,9.773,5.703c1.125,4.273-1.422,8.648-5.695,9.773l-152,40C17.351,375.913,16.672,375.995,15.992,375.995z
"
/>
<path
style="fill:#8C7873;"
d="M7.992,335.995c-3.812,0-7.187-2.73-7.867-6.609c-0.773-4.352,2.133-8.5,6.484-9.27l136-24
c4.328-0.77,8.508,2.141,9.266,6.488c0.773,4.352-2.133,8.5-6.484,9.27l-136,24C8.922,335.956,8.453,335.995,7.992,335.995z"
/>
<path
style="fill:#8C7873;"
d="M480,423.998c3.172,0,6.18-1.906,7.43-5.031c1.641-4.105-0.359-8.758-4.461-10.402l-160.008-64
c-4.063-1.617-8.758,0.352-10.398,4.457s0.359,8.758,4.461,10.402l160.008,64C478,423.811,479.007,423.998,480,423.998z"
/>
<path
style="fill:#8C7873;"
d="M496.007,375.995c3.547,0,6.781-2.375,7.727-5.965c1.125-4.273-1.422-8.648-5.695-9.773l-152-40
c-4.289-1.121-8.648,1.426-9.773,5.703c-1.125,4.273,1.422,8.648,5.695,9.773l152,40
C494.648,375.913,495.328,375.995,496.007,375.995z"
/>
<path
style="fill:#8C7873;"
d="M504.007,335.995c3.813,0,7.188-2.73,7.867-6.609c0.773-4.352-2.133-8.5-6.484-9.27l-136-24
c-4.328-0.77-8.508,2.141-9.266,6.488c-0.773,4.352,2.133,8.5,6.484,9.27l136,24C503.078,335.956,503.546,335.995,504.007,335.995z
"
/>
</g>
</svg>
</template>
@@ -77,7 +77,7 @@ export default {
removeSubmitter (submitter) {
[...this.fields].forEach((field) => {
if (field.submitter_uuid === submitter.uuid) {
this.removeField(field)
this.removeField(field, false)
}
})
@@ -89,10 +89,12 @@ export default {
this.save()
},
removeField (field) {
removeField (field, save = true) {
this.fields.splice(this.fields.indexOf(field), 1)
this.save()
if (save) {
this.save()
}
}
}
}
@@ -49,12 +49,17 @@
</li>
</template>
</template>
<template v-else>
<div
v-if="fieldTypes.length && submitterDefaultFields.length"
class="bg-base-300"
style="height: 1px; margin-top: 1px; margin-bottom: 1px"
/>
<template v-if="fieldTypes.length || !submitterDefaultFields.length">
<template
v-for="(icon, type) in fieldIconsSorted"
:key="type"
>
<li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))">
<li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification') && (withKba || type != 'kba'))">
<a
href="#"
class="text-sm py-1 px-2"
@@ -84,7 +89,7 @@ export default {
IconPlus,
IconX
},
inject: ['withPhone', 'withPayment', 'withVerification', 'backgroundColor', 't'],
inject: ['withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor', 't'],
props: {
modelValue: {
type: String,
+475 -58
View File
@@ -1,7 +1,7 @@
<template>
<div
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 }"
:class="{ 'cursor-crosshair': allowDraw && editable && !isSelectMode, 'touch-none': !!drawField }"
style="container-type: size"
:style="{ aspectRatio: `${width} / ${height}`}"
>
@@ -17,7 +17,20 @@
<div
class="top-0 bottom-0 left-0 right-0 absolute"
@pointerdown="onStartDraw"
@contextmenu="openContextMenu"
>
<SelectionBox
v-if="showSelectionBox"
:selection-box="selectionBox"
:page-width="width"
:page-height="height"
:is-resizing="!!resizeDirection"
:is-drawing="!!drawFieldType"
:is-drag="isDrag"
@move="onSelectionBoxMove"
@contextmenu="openSelectionContextMenu"
@close-context-menu="closeContextMenu"
/>
<FieldArea
v-for="(item, i) in areas"
:key="i"
@@ -34,31 +47,81 @@
:default-field="defaultFieldsIndex[item.field.name]"
:default-submitters="defaultSubmitters"
:max-page="totalPages - 1"
:is-select-mode="isSelectMode"
:is-mobile="isMobile"
@start-resize="resizeDirection = $event"
@stop-resize="resizeDirection = null"
@remove="$emit('remove-area', item.area)"
@scroll-to="$emit('scroll-to', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@contextmenu="openAreaContextMenu($event, item.area, item.field)"
/>
<FieldArea
v-if="newArea"
v-for="(area, index) in newAreas"
:key="index"
:is-draw="true"
:page-width="width"
:page-height="height"
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || dragFieldPlaceholder?.type || defaultFieldType }"
:area="newArea"
:field="{ submitter_uuid: selectedSubmitter.uuid, type: newAreaFieldType }"
:area="area"
/>
<div
v-if="newAreas.length > 1"
class="absolute outline-dashed outline-gray-400 pointer-events-none z-20"
:style="newAreasBoxStyle"
/>
<div
v-if="selectionRect"
class="absolute outline-dashed outline-gray-400 pointer-events-none z-20"
:style="selectionRectStyle"
/>
<FieldContextMenu
v-if="contextMenu && contextMenu.field"
:context-menu="contextMenu"
:field="contextMenu.field"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:editable="editable"
:default-field="defaultFieldsIndex[contextMenu.field.name]"
@copy="handleCopy"
@delete="handleDelete"
@close="closeContextMenu"
@set-draw="$emit('set-draw', $event)"
@scroll-to="$emit('scroll-to', $event)"
@save="save"
@add-custom-field="$emit('add-custom-field', $event)"
/>
<SelectionContextMenu
v-else-if="contextMenu && contextMenu.areas"
:context-menu="contextMenu"
:editable="editable"
:template="template"
@copy="handleSelectionCopy"
@delete="handleSelectionDelete"
@close="closeContextMenu"
/>
<PageContextMenu
v-else-if="contextMenu && !contextMenu.field && !contextMenu.areas"
:context-menu="contextMenu"
:editable="editable"
:with-fields-detection="withFieldsDetection"
@paste="handlePaste"
@autodetect-fields="handleAutodetectFields"
@close="closeContextMenu"
/>
</div>
<div
v-show="resizeDirection || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value"
v-show="resizeDirection || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value || customDragFieldRef?.value || selectionRect"
id="mask"
ref="mask"
class="top-0 bottom-0 left-0 right-0 absolute"
:class="{ 'z-10': !isMobile, 'cursor-grab': isDrag, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
:class="{ 'z-10': !isMobile, 'cursor-grab': isDrag, 'cursor-nwse-resize': drawField && !isSelectMode, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
@pointermove="onPointermove"
@pointerdown="onStartDraw"
@contextmenu="openContextMenu"
@dragover.prevent="onDragover"
@dragenter="onDragenter"
@dragleave="newArea = null"
@dragleave="newAreas = []"
@drop="onDrop"
@pointerup="onPointerup"
/>
@@ -67,13 +130,21 @@
<script>
import FieldArea from './area'
import FieldContextMenu from './field_context_menu'
import SelectionContextMenu from './selection_context_menu'
import PageContextMenu from './page_context_menu'
import SelectionBox from './selection_box'
export default {
name: 'TemplatePage',
components: {
FieldArea
FieldArea,
FieldContextMenu,
SelectionContextMenu,
PageContextMenu,
SelectionBox
},
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'assignDropAreaSize'],
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'customDragFieldRef', 'assignDropAreaSize', 'selectedAreasRef', 'template', 'isSelectModeRef', 'save'],
props: {
image: {
type: Object,
@@ -84,6 +155,11 @@ export default {
required: false,
default: null
},
isMobile: {
type: Boolean,
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
@@ -142,6 +218,11 @@ export default {
required: false,
default: null
},
drawCustomField: {
type: Object,
required: false,
default: null
},
editable: {
type: Boolean,
required: false,
@@ -155,18 +236,68 @@ export default {
number: {
type: Number,
required: true
},
attachmentUuid: {
type: String,
required: false,
default: ''
},
withFieldsDetection: {
type: Boolean,
required: false,
default: false
}
},
emits: ['draw', 'drop-field', 'remove-area', 'scroll-to'],
emits: ['draw', 'drop-field', 'remove-area', 'copy-field', 'paste-field', 'scroll-to', 'copy-selected-areas', 'delete-selected-areas', 'autodetect-fields', 'add-custom-field', 'set-draw'],
data () {
return {
areaRefs: [],
showMask: false,
resizeDirection: null,
newArea: null
newAreas: [],
contextMenu: null,
selectionRect: null
}
},
computed: {
isSelectMode () {
return this.isSelectModeRef.value && !this.drawFieldType && this.editable && !this.drawField
},
pageSelectedAreas () {
if (!this.selectedAreasRef.value) return []
return this.selectedAreasRef.value.filter((a) =>
a.attachment_uuid === this.attachmentUuid && a.page === this.number
)
},
showSelectionBox () {
return this.pageSelectedAreas.length >= 2 && this.editable
},
minSelectionBoxHeight () {
const ys = this.pageSelectedAreas.map((a) => a.y)
return Math.max(...ys) - Math.min(...ys)
},
minSelectionBoxWidth () {
const xs = this.pageSelectedAreas.map((a) => a.x)
return Math.max(...xs) - Math.min(...xs)
},
selectionBox () {
if (!this.pageSelectedAreas.length) return null
const minX = Math.min(...this.pageSelectedAreas.map((a) => a.x))
const minY = Math.min(...this.pageSelectedAreas.map((a) => a.y))
const maxX = Math.max(...this.pageSelectedAreas.map((a) => a.x + a.w))
const maxY = Math.max(...this.pageSelectedAreas.map((a) => a.y + a.h))
return {
x: minX,
y: minY,
w: Math.max(maxX - minX, this.minSelectionBoxWidth),
h: Math.max(maxY - minY, this.minSelectionBoxHeight)
}
},
defaultFieldsIndex () {
return this.defaultFields.reduce((acc, field) => {
acc[field.name] = field
@@ -174,6 +305,14 @@ export default {
return acc
}, {})
},
newAreaFieldType () {
if (this.drawField?.type) return this.drawField.type
if (this.drawCustomField?.type) return this.drawCustomField.type
if (this.dragFieldPlaceholder?.type) return this.dragFieldPlaceholder.type
if (this.customDragFieldRef?.value?.type) return this.customDragFieldRef.value.type
return this.defaultFieldType
},
defaultFieldType () {
if (this.drawFieldType) {
return this.drawFieldType
@@ -185,11 +324,6 @@ export default {
return 'text'
}
},
isMobile () {
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
return isMobileSafariIos || /android|iphone|ipad/i.test(navigator.userAgent)
},
resizeDirectionClasses () {
return {
nwse: 'cursor-nwse-resize',
@@ -201,6 +335,31 @@ export default {
},
height () {
return this.image.metadata.height
},
newAreasBoxStyle () {
if (this.newAreas.length < 2) return {}
const minX = Math.min(...this.newAreas.map(a => a.x))
const minY = Math.min(...this.newAreas.map(a => a.y))
const maxX = Math.max(...this.newAreas.map(a => a.x + a.w))
const maxY = Math.max(...this.newAreas.map(a => a.y + a.h))
return {
left: minX * 100 + '%',
top: minY * 100 + '%',
width: (maxX - minX) * 100 + '%',
height: (maxY - minY) * 100 + '%'
}
},
selectionRectStyle () {
if (!this.selectionRect) return {}
return {
left: this.selectionRect.x * 100 + '%',
top: this.selectionRect.y * 100 + '%',
width: this.selectionRect.w * 100 + '%',
height: this.selectionRect.h * 100 + '%'
}
}
},
beforeUpdate () {
@@ -211,28 +370,183 @@ export default {
this.image.metadata.width = e.target.naturalWidth
this.image.metadata.height = e.target.naturalHeight
},
openContextMenu (event) {
if (!this.editable) {
return
}
event.preventDefault()
event.stopPropagation()
const rect = this.$refs.image.getBoundingClientRect()
this.newAreas = []
this.showMask = false
this.contextMenu = {
x: event.clientX,
y: event.clientY,
relativeX: (event.clientX - rect.left) / rect.width,
relativeY: (event.clientY - rect.top) / rect.height
}
},
openAreaContextMenu (event, area, field) {
if (!this.editable) {
return
}
event.preventDefault()
event.stopPropagation()
const rect = this.$refs.image.getBoundingClientRect()
this.newAreas = []
this.showMask = false
this.contextMenu = {
x: event.clientX,
y: event.clientY,
relativeX: (event.clientX - rect.left) / rect.width,
relativeY: (event.clientY - rect.top) / rect.height,
area,
field
}
},
openSelectionContextMenu (event) {
if (!this.editable) {
return
}
event.preventDefault()
event.stopPropagation()
const rect = this.$el.getBoundingClientRect()
this.contextMenu = {
x: event.clientX,
y: event.clientY,
relativeX: (event.clientX - rect.left) / rect.width,
relativeY: (event.clientY - rect.top) / rect.height,
areas: this.selectedAreasRef.value
}
},
handleSelectionCopy () {
this.$emit('copy-selected-areas')
this.closeContextMenu()
},
handleSelectionDelete () {
this.$emit('delete-selected-areas')
this.closeContextMenu()
},
closeContextMenu () {
this.contextMenu = null
this.newAreas = []
this.showMask = false
},
handleCopy () {
if (this.contextMenu.area) {
this.selectedAreasRef.value = [this.contextMenu.area]
this.$emit('copy-field')
}
this.closeContextMenu()
},
handleDelete () {
if (this.contextMenu.area) {
this.$emit('remove-area', this.contextMenu.area)
}
this.closeContextMenu()
},
handlePaste () {
this.newAreas = []
this.showMask = false
this.$emit('paste-field', {
page: this.number,
x: this.contextMenu.relativeX,
y: this.contextMenu.relativeY
})
this.closeContextMenu()
},
handleAutodetectFields () {
this.$emit('autodetect-fields', {
page: this.number,
attachmentUuid: this.attachmentUuid
})
this.closeContextMenu()
},
setAreaRefs (el) {
if (el) {
this.areaRefs.push(el)
}
},
onDragenter (e) {
this.newArea = {}
const customField = this.customDragFieldRef?.value
const customAreas = customField?.areas || []
const dropX = (e.offsetX - 6) / this.$refs.mask.clientWidth
const dropY = e.offsetY / this.$refs.mask.clientHeight
this.assignDropAreaSize(this.newArea, this.dragFieldPlaceholder, {
maskW: this.$refs.mask.clientWidth,
maskH: this.$refs.mask.clientHeight
})
if (customAreas.length > 1) {
const refArea = customAreas[0]
this.newArea.x = (e.offsetX - 6) / this.$refs.mask.clientWidth
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight - this.newArea.h / 2
this.newAreas = customAreas.map((customArea) => ({
x: dropX + (customArea.x - refArea.x),
y: dropY + (customArea.y - refArea.y) - (customArea.h / 2),
w: customArea.w,
h: customArea.h
}))
} else {
const newArea = {}
if (customAreas.length === 1) {
newArea.w = customAreas[0].w
newArea.h = customAreas[0].h
} else if (customField) {
this.assignDropAreaSize(newArea, customField, {
maskW: this.$refs.mask.clientWidth,
maskH: this.$refs.mask.clientHeight
})
} else {
this.assignDropAreaSize(newArea, this.dragFieldPlaceholder, {
maskW: this.$refs.mask.clientWidth,
maskH: this.$refs.mask.clientHeight
})
}
newArea.x = dropX
newArea.y = dropY - newArea.h / 2
this.newAreas = [newArea]
}
},
onDragover (e) {
this.newArea.x = (e.offsetX - 6) / this.$refs.mask.clientWidth
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight - this.newArea.h / 2
const customField = this.customDragFieldRef?.value
const customAreas = customField?.areas || []
const dropX = (e.offsetX - 6) / this.$refs.mask.clientWidth
const dropY = e.offsetY / this.$refs.mask.clientHeight
if (customAreas.length > 1) {
const refArea = customAreas[0]
this.newAreas.forEach((newArea, index) => {
const customArea = customAreas[index]
newArea.x = dropX + (customArea.x - refArea.x)
newArea.y = dropY + (customArea.y - refArea.y) - (customArea.h / 2)
})
} else if (this.newAreas.length) {
this.newAreas[0].x = dropX
this.newAreas[0].y = dropY - this.newAreas[0].h / 2
}
},
onDrop (e) {
this.newArea = null
this.newAreas = []
this.$emit('drop-field', {
x: e.offsetX,
@@ -243,11 +557,25 @@ export default {
})
},
onStartDraw (e) {
if (e.button === 2) {
return
}
if (this.selectedAreasRef.value.length >= 2) {
this.selectedAreasRef.value = []
}
if (this.isSelectMode) {
this.startSelectionRect(e)
return
}
if (!this.allowDraw) {
return
}
if (this.isMobile && !this.drawField) {
if (this.isMobile && !this.drawField && !this.drawCustomField) {
return
}
@@ -258,65 +586,154 @@ export default {
this.showMask = true
this.$nextTick(() => {
this.newArea = {
this.newAreas = [{
initialX: e.offsetX / this.$refs.mask.clientWidth,
initialY: e.offsetY / this.$refs.mask.clientHeight,
x: e.offsetX / this.$refs.mask.clientWidth,
y: e.offsetY / this.$refs.mask.clientHeight,
w: 0,
h: 0
}]
})
},
startSelectionRect (e) {
this.selectedAreasRef.value = []
this.showMask = true
this.$nextTick(() => {
const x = e.offsetX / this.$refs.mask.clientWidth
const y = e.offsetY / this.$refs.mask.clientHeight
this.selectionRect = {
initialX: x,
initialY: y,
x,
y,
w: 0,
h: 0
}
})
},
onSelectionBoxMove (dx, dy) {
let clampedDx = dx
let clampedDy = dy
this.pageSelectedAreas.forEach((area) => {
const maxDxLeft = -area.x
const maxDxRight = 1 - area.w - area.x
const maxDyTop = -area.y
const maxDyBottom = 1 - area.h - area.y
if (dx < maxDxLeft) clampedDx = Math.max(clampedDx, maxDxLeft)
if (dx > maxDxRight) clampedDx = Math.min(clampedDx, maxDxRight)
if (dy < maxDyTop) clampedDy = Math.max(clampedDy, maxDyTop)
if (dy > maxDyBottom) clampedDy = Math.min(clampedDy, maxDyBottom)
})
this.pageSelectedAreas.forEach((area) => {
area.x += clampedDx
area.y += clampedDy
})
},
onPointermove (e) {
if (this.newArea) {
const dx = e.offsetX / this.$refs.mask.clientWidth - this.newArea.initialX
const dy = e.offsetY / this.$refs.mask.clientHeight - this.newArea.initialY
if (this.selectionRect) {
const dx = e.offsetX / this.$refs.mask.clientWidth - this.selectionRect.initialX
const dy = e.offsetY / this.$refs.mask.clientHeight - this.selectionRect.initialY
if (dx > 0) {
this.newArea.x = this.newArea.initialX
this.selectionRect.x = this.selectionRect.initialX
} else {
this.newArea.x = e.offsetX / this.$refs.mask.clientWidth
this.selectionRect.x = e.offsetX / this.$refs.mask.clientWidth
}
if (dy > 0) {
this.newArea.y = this.newArea.initialY
this.selectionRect.y = this.selectionRect.initialY
} else {
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight
this.selectionRect.y = e.offsetY / this.$refs.mask.clientHeight
}
if ((this.drawField?.type || this.drawFieldType) === 'cells') {
this.newArea.cell_w = this.newArea.h * (this.$refs.mask.clientHeight / this.$refs.mask.clientWidth)
this.selectionRect.w = Math.abs(dx)
this.selectionRect.h = Math.abs(dy)
return
}
const drawArea = this.newAreas[0]
if (drawArea?.initialX !== undefined) {
const dx = e.offsetX / this.$refs.mask.clientWidth - drawArea.initialX
const dy = e.offsetY / this.$refs.mask.clientHeight - drawArea.initialY
if (dx > 0) {
drawArea.x = drawArea.initialX
} else {
drawArea.x = e.offsetX / this.$refs.mask.clientWidth
}
this.newArea.w = Math.abs(dx)
this.newArea.h = Math.abs(dy)
if (dy > 0) {
drawArea.y = drawArea.initialY
} else {
drawArea.y = e.offsetY / this.$refs.mask.clientHeight
}
if ((this.drawField?.type || this.drawCustomField?.type || this.drawFieldType) === 'cells') {
drawArea.cell_w = drawArea.h * (this.$refs.mask.clientHeight / this.$refs.mask.clientWidth)
}
drawArea.w = Math.abs(dx)
drawArea.h = Math.abs(dy)
}
},
onPointerup (e) {
if (this.newArea) {
const area = {
x: this.newArea.x,
y: this.newArea.y,
w: this.newArea.w,
h: this.newArea.h,
page: this.number
if (this.selectionRect) {
const selRect = this.selectionRect
const areasToSelect = this.areas || []
areasToSelect.forEach((item) => {
const area = item.area
if (this.rectsOverlap(selRect, area)) {
this.selectedAreasRef.value.push(area)
}
})
this.selectionRect = null
} else {
const drawArea = this.newAreas[0]
if (drawArea?.initialX !== undefined) {
const area = {
x: drawArea.x,
y: drawArea.y,
w: drawArea.w,
h: drawArea.h,
page: this.number
}
if ('cell_w' in drawArea) {
area.cell_w = drawArea.cell_w
}
const dx = Math.abs(e.offsetX - this.$refs.mask.clientWidth * drawArea.initialX)
const dy = Math.abs(e.offsetY - this.$refs.mask.clientHeight * drawArea.initialY)
const isTooSmall = dx < 8 && dy < 8
this.$emit('draw', { area, isTooSmall })
}
if ('cell_w' in this.newArea) {
area.cell_w = this.newArea.cell_w
}
const dx = Math.abs(e.offsetX - this.$refs.mask.clientWidth * this.newArea.initialX)
const dy = Math.abs(e.offsetY - this.$refs.mask.clientHeight * this.newArea.initialY)
const isTooSmall = dx < 8 && dy < 8
this.$emit('draw', { area, isTooSmall })
}
this.showMask = false
this.newArea = null
this.newAreas = []
},
rectsOverlap (r1, r2) {
return !(
r1.x + r1.w < r2.x ||
r2.x + r2.w < r1.x ||
r1.y + r1.h < r2.y ||
r2.y + r2.h < r1.y
)
}
}
}
@@ -0,0 +1,159 @@
<template>
<div
ref="menu"
class="fixed z-50 p-1 bg-white shadow-lg rounded-lg border border-neutral-200 cursor-default"
style="min-width: 170px"
:style="menuStyle"
@mousedown.stop
@pointerdown.stop
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
:class="!hasClipboardData ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-100'"
:disabled="!hasClipboardData"
@click.stop="!hasClipboardData ? null : $emit('paste')"
>
<span class="flex items-center space-x-2">
<IconClipboard class="w-4 h-4" />
<span>{{ t('paste') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘V' : 'Ctrl+V' }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="handleToggleSelectMode"
>
<span class="flex items-center space-x-2">
<IconClick
v-if="!isSelectModeRef.value"
class="w-4 h-4"
/>
<IconNewSection
v-else
class="w-4 h-4"
/>
<span>{{ isSelectModeRef.value ? t('draw_fields') : t('select_fields') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Tab</span>
</button>
<hr
v-if="showAutodetectFields"
class="my-1 border-neutral-200"
>
<button
v-if="showAutodetectFields"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('autodetect-fields')"
>
<IconSparkles class="w-4 h-4" />
<span>{{ t('autodetect_fields') }}</span>
</button>
</div>
</template>
<script>
import { IconClipboard, IconClick, IconNewSection, IconSparkles } from '@tabler/icons-vue'
export default {
name: 'PageContextMenu',
components: {
IconClipboard,
IconClick,
IconNewSection,
IconSparkles
},
inject: ['t', 'isSelectModeRef'],
props: {
contextMenu: {
type: Object,
default: null,
required: true
},
editable: {
type: Boolean,
default: true
},
withFieldsDetection: {
type: Boolean,
default: false
}
},
emits: ['paste', 'close', 'autodetect-fields'],
computed: {
isMac () {
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
},
hasClipboardData () {
try {
const clipboard = localStorage.getItem('docuseal_clipboard')
if (clipboard) {
const data = JSON.parse(clipboard)
return Date.now() - data.timestamp < 3600000
}
return false
} catch {
return false
}
},
menuStyle () {
return {
left: this.contextMenu.x + 'px',
top: this.contextMenu.y + 'px'
}
},
showAutodetectFields () {
return this.withFieldsDetection && this.editable
}
},
mounted () {
document.addEventListener('keydown', this.onKeyDown)
document.addEventListener('mousedown', this.handleClickOutside)
this.$nextTick(() => {
this.checkMenuPosition()
})
},
beforeUnmount () {
document.removeEventListener('keydown', this.onKeyDown)
document.removeEventListener('mousedown', this.handleClickOutside)
},
methods: {
checkMenuPosition () {
if (this.$refs.menu) {
const rect = this.$refs.menu.getBoundingClientRect()
const overflow = rect.bottom - window.innerHeight
if (overflow > 0) {
this.contextMenu.y = this.contextMenu.y - overflow - 4
}
}
},
onKeyDown (event) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
this.$emit('close')
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v' && this.hasClipboardData) {
event.preventDefault()
event.stopPropagation()
this.$emit('paste')
}
},
handleClickOutside (event) {
if (this.$refs.menu && !this.$refs.menu.contains(event.target)) {
this.$emit('close')
}
},
handleToggleSelectMode () {
this.isSelectModeRef.value = !this.isSelectModeRef.value
this.$emit('close')
}
}
}
</script>
@@ -1,7 +1,7 @@
<template>
<span
class="dropdown dropdown-end field-settings-dropdown"
:class="{ 'dropdown-open': ((!field.preferences?.price && !field.preferences?.formula && !field.preferences?.price_id && !field.preferences?.payment_link_id) || !isConnected) && !isLoading }"
:class="{ 'dropdown-open': withForceOpen && ((!field.preferences?.price && !field.preferences?.formula && !field.preferences?.price_id && !field.preferences?.payment_link_id) || !isConnected) && !isLoading }"
>
<label
tabindex="0"
@@ -231,7 +231,10 @@
</span>
</label>
</li>
<li class="mt-1">
<li
v-if="withCondition"
class="mt-1"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-condition')"
@@ -244,12 +247,29 @@
</span>
</label>
</li>
<hr
v-if="withCustomFields"
class="pb-0.5 mt-0.5"
>
<li v-if="withCustomFields">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('add-custom-field', field)"
>
<IconForms
:width="20"
:stroke-width="1.6"
/>
{{ t('save_as_custom_field') }}
</a>
</li>
</ul>
</span>
</template>
<script>
import { IconMathFunction, IconSettings, IconCircleCheck, IconInfoCircle, IconBrandStripe, IconInnerShadowTop, IconRouteAltLeft } from '@tabler/icons-vue'
import { IconMathFunction, IconSettings, IconCircleCheck, IconInfoCircle, IconBrandStripe, IconInnerShadowTop, IconRouteAltLeft, IconForms } from '@tabler/icons-vue'
import { ref } from 'vue'
const isConnected = ref(false)
@@ -261,6 +281,7 @@ export default {
IconCircleCheck,
IconRouteAltLeft,
IconInfoCircle,
IconForms,
IconMathFunction,
IconInnerShadowTop,
IconBrandStripe
@@ -270,9 +291,24 @@ export default {
field: {
type: Object,
required: true
},
withForceOpen: {
type: Boolean,
required: false,
default: true
},
withCustomFields: {
type: Boolean,
required: false,
default: false
},
withCondition: {
type: Boolean,
required: false,
default: true
}
},
emits: ['click-condition', 'click-description', 'click-formula'],
emits: ['click-condition', 'click-description', 'click-formula', 'add-custom-field'],
data () {
return {
isLoading: false
+2 -1
View File
@@ -119,6 +119,7 @@
<ConditionsModal
:item="item"
:build-default-name="buildDefaultName"
@save="$emit('change')"
@close="isShowConditionsModal = false"
/>
</Teleport>
@@ -145,7 +146,7 @@ export default {
GoogleDriveDocumentSettings,
IconSortDescending2
},
inject: ['t'],
inject: ['t', 'getFieldTypeIndex'],
props: {
item: {
type: Object,
@@ -0,0 +1,100 @@
<template>
<div
class="absolute outline-dashed outline-gray-400 cursor-move"
:class="[isResizing || isCmdKeyRef.value ? 'z-0' : 'z-20', { 'pointer-events-none': isDrawing || fieldsDragFieldRef.value || isDrag }]"
:style="positionStyle"
@pointerdown.stop.prevent="onPointerDown"
@contextmenu.stop.prevent="openContextMenu"
/>
</template>
<script>
export default {
name: 'SelectionBox',
inject: ['save', 'selectedAreasRef', 'isCmdKeyRef', 'fieldsDragFieldRef'],
props: {
selectionBox: {
type: Object,
required: true
},
pageWidth: {
type: Number,
required: true
},
pageHeight: {
type: Number,
required: true
},
isResizing: {
type: Boolean,
default: false
},
isDrag: {
type: Boolean,
default: false
},
isDrawing: {
type: Boolean,
default: false
}
},
emits: ['move', 'contextmenu', 'close-context-menu'],
data () {
return {
isDragging: false,
dragStart: { x: 0, y: 0 }
}
},
computed: {
positionStyle () {
const { x, y, w, h } = this.selectionBox
return {
top: y * 100 + '%',
left: x * 100 + '%',
width: w * 100 + '%',
height: h * 100 + '%'
}
}
},
methods: {
openContextMenu (e) {
this.$emit('contextmenu', e)
},
onPointerDown (e) {
this.$emit('close-context-menu')
this.startDrag(e)
},
startDrag (e) {
this.isDragging = true
this.dragStart = { x: e.clientX, y: e.clientY }
document.addEventListener('pointermove', this.onDrag)
document.addEventListener('pointerup', this.stopDrag)
},
onDrag (e) {
if (!this.isDragging) return
const parent = this.$el.parentElement
const rect = parent.getBoundingClientRect()
const dx = (e.clientX - this.dragStart.x) / rect.width
const dy = (e.clientY - this.dragStart.y) / rect.height
this.$emit('move', dx, dy)
this.dragStart = { x: e.clientX, y: e.clientY }
},
stopDrag () {
this.isDragging = false
document.removeEventListener('pointermove', this.onDrag)
document.removeEventListener('pointerup', this.stopDrag)
this.save()
}
}
}
</script>
@@ -0,0 +1,348 @@
<template>
<div>
<div
v-if="!isShowFontModal && !isShowConditionsModal"
ref="menu"
class="fixed z-50 p-1 bg-white shadow-lg rounded-lg border border-neutral-200 cursor-default"
style="min-width: 170px"
:style="menuStyle"
@mousedown.stop
@pointerdown.stop
>
<ContextSubmenu
:icon="IconLayoutAlignMiddle"
:label="t('align')"
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="alignSelectedAreas('left')"
>
<IconLayoutAlignLeft class="w-4 h-4" />
<span>{{ t('align_left') }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="alignSelectedAreas('right')"
>
<IconLayoutAlignRight class="w-4 h-4" />
<span>{{ t('align_right') }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="alignSelectedAreas('top')"
>
<IconLayoutAlignTop class="w-4 h-4" />
<span>{{ t('align_top') }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="alignSelectedAreas('bottom')"
>
<IconLayoutAlignBottom class="w-4 h-4" />
<span>{{ t('align_bottom') }}</span>
</button>
</ContextSubmenu>
<ContextSubmenu
:icon="IconAspectRatio"
:label="t('resize')"
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="resizeSelectedAreas('width')"
>
<IconArrowsHorizontal class="w-4 h-4" />
<span>{{ t('width') }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="resizeSelectedAreas('height')"
>
<IconArrowsVertical class="w-4 h-4" />
<span>{{ t('height') }}</span>
</button>
</ContextSubmenu>
<hr
v-if="showFont || showCondition"
class="my-1 border-neutral-200"
>
<button
v-if="showFont"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showCondition"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal"
>
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</button>
<hr class="my-1 border-neutral-200">
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="$emit('copy')"
>
<span class="flex items-center space-x-2">
<IconCopy class="w-4 h-4" />
<span>{{ t('copy') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘C' : 'Ctrl+C' }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm text-red-600"
@click.stop="$emit('delete')"
>
<span class="flex items-center space-x-2">
<IconTrashX class="w-4 h-4" />
<span>{{ t('remove') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Del</span>
</button>
</div>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="multiSelectField"
:area="contextMenu.area"
:editable="editable"
:build-default-name="buildDefaultName"
@save="handleSaveMultiSelectFontModal"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="multiSelectField"
:build-default-name="buildDefaultName"
:exclude-field-uuids="selectedFields.map(f => f.uuid)"
@save="handleSaveMultiSelectConditionsModal"
@close="closeModal"
/>
</Teleport>
</div>
</template>
<script>
import { IconCopy, IconTrashX, IconTypography, IconRouteAltLeft, IconLayoutAlignLeft, IconLayoutAlignRight, IconLayoutAlignTop, IconLayoutAlignBottom, IconLayoutAlignMiddle, IconAspectRatio, IconArrowsHorizontal, IconArrowsVertical } from '@tabler/icons-vue'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import ContextSubmenu from './field_context_submenu'
import Field from './field'
import FieldType from './field_type'
export default {
name: 'SelectionContextMenu',
components: {
IconCopy,
IconTrashX,
IconTypography,
IconRouteAltLeft,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconLayoutAlignTop,
IconLayoutAlignBottom,
FontModal,
IconArrowsHorizontal,
IconArrowsVertical,
ConditionsModal,
ContextSubmenu
},
inject: ['t', 'save', 'selectedAreasRef', 'getFieldTypeIndex'],
props: {
contextMenu: {
type: Object,
required: true
},
editable: {
type: Boolean,
default: true
},
template: {
type: Object,
required: true
},
withCondition: {
type: Boolean,
default: true
}
},
emits: ['copy', 'delete', 'close'],
data () {
return {
isShowFontModal: false,
isShowConditionsModal: false,
multiSelectField: null
}
},
computed: {
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
selectedFields () {
return this.selectedAreasRef.value.map((area) => {
return this.template.fields.find((f) => f.areas?.includes(area))
}).filter(Boolean)
},
isMac () {
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
},
menuStyle () {
return {
left: this.contextMenu.x + 'px',
top: this.contextMenu.y + 'px'
}
},
showFont () {
return true
},
showCondition () {
return this.withCondition
},
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels
},
mounted () {
document.addEventListener('keydown', this.onKeyDown)
document.addEventListener('mousedown', this.handleClickOutside)
this.$nextTick(() => this.checkMenuPosition())
},
beforeUnmount () {
document.removeEventListener('keydown', this.onKeyDown)
document.removeEventListener('mousedown', this.handleClickOutside)
},
methods: {
IconLayoutAlignMiddle,
IconAspectRatio,
buildDefaultName: Field.methods.buildDefaultName,
checkMenuPosition () {
if (this.$refs.menu) {
const rect = this.$refs.menu.getBoundingClientRect()
const overflow = rect.bottom - window.innerHeight
if (overflow > 0) {
this.contextMenu.y = this.contextMenu.y - overflow - 4
}
}
},
onKeyDown (event) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
this.$emit('close')
}
},
handleClickOutside (event) {
if (this.$refs.menu && !this.$refs.menu.contains(event.target)) {
this.$emit('close')
}
},
openFontModal () {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
preferences: {}
}
const preferencesStrings = this.selectedFields.map((f) => JSON.stringify(f.preferences || {}))
if (preferencesStrings.every((s) => s === preferencesStrings[0])) {
this.multiSelectField.preferences = JSON.parse(preferencesStrings[0])
}
this.isShowFontModal = true
},
openConditionModal () {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
conditions: []
}
const conditionStrings = this.selectedFields.map((f) => JSON.stringify(f.conditions || []))
if (conditionStrings.every((s) => s === conditionStrings[0])) {
this.multiSelectField.conditions = JSON.parse(conditionStrings[0])
}
this.isShowConditionsModal = true
},
closeModal () {
this.isShowFontModal = false
this.isShowConditionsModal = false
this.multiSelectField = null
this.$emit('close')
},
alignSelectedAreas (direction) {
const areas = this.selectedAreasRef.value
let targetValue
if (direction === 'left') {
targetValue = Math.min(...areas.map(a => a.x))
areas.forEach((area) => { area.x = targetValue })
} else if (direction === 'right') {
targetValue = Math.max(...areas.map(a => a.x + a.w))
areas.forEach((area) => { area.x = targetValue - area.w })
} else if (direction === 'top') {
targetValue = Math.min(...areas.map(a => a.y))
areas.forEach((area) => { area.y = targetValue })
} else if (direction === 'bottom') {
targetValue = Math.max(...areas.map(a => a.y + a.h))
areas.forEach((area) => { area.y = targetValue - area.h })
}
this.save()
this.$emit('close')
},
resizeSelectedAreas (dimension) {
const areas = this.selectedAreasRef.value
const values = areas.map(a => dimension === 'width' ? a.w : a.h).sort((a, b) => a - b)
const medianValue = values[Math.floor(values.length / 2)]
if (dimension === 'width') {
areas.forEach((area) => { area.w = medianValue })
} else if (dimension === 'height') {
areas.forEach((area) => {
const diff = medianValue - area.h
area.y = area.y - diff
area.h = medianValue
})
}
this.save()
this.$emit('close')
},
handleSaveMultiSelectFontModal () {
this.selectedFields.forEach((field) => {
field.preferences = { ...field.preferences, ...this.multiSelectField.preferences }
})
this.save()
this.closeModal()
},
handleSaveMultiSelectConditionsModal () {
this.selectedFields.forEach((field) => {
field.conditions = JSON.parse(JSON.stringify(this.multiSelectField.conditions))
})
this.save()
this.closeModal()
}
}
}
</script>
+9 -2
View File
@@ -216,6 +216,9 @@ export default {
queryParams () {
return new URLSearchParams(window.location.search)
},
uploadUrl () {
return `/templates/${this.templateId}/documents`
},
googleDriveOauthPath () {
const params = {
access_type: 'offline',
@@ -279,7 +282,7 @@ export default {
async upload ({ path } = {}) {
this.isLoading = true
return this.baseFetch(path || `/templates/${this.templateId}/documents`, {
return this.baseFetch(path || this.uploadUrl, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(this.$refs.form)
@@ -287,7 +290,11 @@ export default {
if (resp.ok) {
resp.json().then((data) => {
this.$emit('success', data)
this.$refs.input.value = ''
if (this.$refs.input) {
this.$refs.input.value = ''
}
this.isLoading = false
})
} else if (resp.status === 422) {
+3 -1
View File
@@ -4,7 +4,9 @@ class ProcessSubmissionExpiredJob
include Sidekiq::Job
def perform(params = {})
submission = Submission.find(params['submission_id'])
submission = Submission.find_by(id: params['submission_id'])
return unless submission
return if submission.archived_at?
return if submission.template&.archived_at?
+16 -6
View File
@@ -39,18 +39,26 @@ class ProcessSubmitterCompletionJob
submission = submitter.submission
complete_verification_events, sms_events =
submitter.submission_events.where(event_type: %i[send_sms send_2fa_sms complete_verification])
.partition { |e| e.event_type == 'complete_verification' }
submitter.submission_events.where(event_type: %i[send_sms send_2fa_sms complete_verification complete_kba])
.partition { |e| e.event_type == 'complete_verification' || e.event_type == 'complete_kba' }
complete_verification_event = complete_verification_events.first
verification_method =
if complete_verification_event&.event_type == 'complete_kba'
'kba'
elsif complete_verification_event
complete_verification_event.data['method']
end
completed_submitter.assign_attributes(
submission_id: submitter.submission_id,
account_id: submission.account_id,
is_first: !CompletedSubmitter.exists?(submission: submitter.submission_id, is_first: true),
template_id: submission.template_id,
source: submission.source,
sms_count: sms_events.sum { |e| e.data['segments'] || 1 },
verification_method: complete_verification_event&.data&.dig('method'),
verification_method:,
completed_at: submitter.completed_at
)
@@ -156,11 +164,13 @@ class ProcessSubmitterCompletionJob
next_submitter_items =
if submission.template_submitters.any? { |s| s['order'] }
submitter_groups =
submission.template_submitters.group_by.with_index { |s, index| s['order'] || index }
submission.template_submitters
.group_by.with_index { |s, index| s['order'] || index }
.sort_by(&:first).pluck(1)
current_group_index = submitter_groups.find { |_, group| group.any? { |s| s['uuid'] == submitter.uuid } }&.first
current_group_index = submitter_groups.index { |group| group.any? { |s| s['uuid'] == submitter.uuid } }
if submitter_groups[current_group_index + 1] &&
if current_group_index && submitter_groups[current_group_index + 1] &&
submitters_index.values_at(*submitter_groups[current_group_index].pluck('uuid'))
.compact.all?(&:completed_at?)
submitter_groups[current_group_index + 1]
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class SendConfirmationInstructionsJob
include Sidekiq::Job
def perform(params = {})
user = User.find(params['user_id'])
user.send_confirmation_instructions
end
end
@@ -8,8 +8,13 @@ class SendFormCompletedWebhookRequestJob
MAX_ATTEMPTS = 12
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
submitter = Submitter.find_by(id: params['submitter_id'])
return unless submitter
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
return unless webhook_url
attempt = params['attempt'].to_i
@@ -8,8 +8,13 @@ class SendFormDeclinedWebhookRequestJob
MAX_ATTEMPTS = 10
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
submitter = Submitter.find_by(id: params['submitter_id'])
return unless submitter
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
return unless webhook_url
attempt = params['attempt'].to_i
@@ -8,8 +8,13 @@ class SendFormStartedWebhookRequestJob
MAX_ATTEMPTS = 10
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
submitter = Submitter.find_by(id: params['submitter_id'])
return unless submitter
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
return unless webhook_url
attempt = params['attempt'].to_i
@@ -8,8 +8,13 @@ class SendFormViewedWebhookRequestJob
MAX_ATTEMPTS = 10
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
submitter = Submitter.find_by(id: params['submitter_id'])
return unless submitter
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
return unless webhook_url
attempt = params['attempt'].to_i
@@ -8,8 +8,13 @@ class SendSubmissionArchivedWebhookRequestJob
MAX_ATTEMPTS = 10
def perform(params = {})
submission = Submission.find(params['submission_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
submission = Submission.find_by(id: params['submission_id'])
return unless submission
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
return unless webhook_url
attempt = params['attempt'].to_i
@@ -8,8 +8,13 @@ class SendSubmissionCompletedWebhookRequestJob
MAX_ATTEMPTS = 10
def perform(params = {})
submission = Submission.find(params['submission_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
submission = Submission.find_by(id: params['submission_id'])
return unless submission
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
return unless webhook_url
attempt = params['attempt'].to_i
@@ -8,8 +8,13 @@ class SendSubmissionCreatedWebhookRequestJob
MAX_ATTEMPTS = 10
def perform(params = {})
submission = Submission.find(params['submission_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
submission = Submission.find_by(id: params['submission_id'])
return unless submission
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
return unless webhook_url
attempt = params['attempt'].to_i
@@ -8,8 +8,13 @@ class SendSubmissionExpiredWebhookRequestJob
MAX_ATTEMPTS = 10
def perform(params = {})
submission = Submission.find(params['submission_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
submission = Submission.find_by(id: params['submission_id'])
return unless submission
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
return unless webhook_url
attempt = params['attempt'].to_i
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class SendSubmitterVerificationEmailJob
include Sidekiq::Job
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
locale = params['locale'].presence || submitter.account.locale
SubmitterMailer.otp_verification_email(submitter, locale:).deliver_now!
SubmissionEvent.create!(submitter_id: params['submitter_id'],
event_type: 'send_2fa_email',
data: { email: submitter.email })
end
end
@@ -8,8 +8,13 @@ class SendTemplateCreatedWebhookRequestJob
MAX_ATTEMPTS = 10
def perform(params = {})
template = Template.find(params['template_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
template = Template.find_by(id: params['template_id'])
return unless template
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
return unless webhook_url
attempt = params['attempt'].to_i
@@ -8,8 +8,13 @@ class SendTemplateUpdatedWebhookRequestJob
MAX_ATTEMPTS = 10
def perform(params = {})
template = Template.find(params['template_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
template = Template.find_by(id: params['template_id'])
return unless template
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
return unless webhook_url
attempt = params['attempt'].to_i
+21 -4
View File
@@ -7,11 +7,28 @@ class SendTestWebhookRequestJob
USER_AGENT = 'DocuSeal.com Webhook'
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
HttpsError = Class.new(StandardError)
LocalhostError = Class.new(StandardError)
return unless webhook_url && submitter
def perform(params = {})
submitter = Submitter.find_by(id: params['submitter_id'])
return unless submitter
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
return unless webhook_url
if Docuseal.multitenant?
uri = begin
URI(webhook_url.url)
rescue URI::Error
Addressable::URI.parse(webhook_url.url).normalize
end
raise HttpsError, 'Only HTTPS is allowed.' if uri.scheme != 'https'
raise LocalhostError, "Can't send to localhost." if uri.host.in?(SendWebhookRequest::LOCALHOSTS)
end
Faraday.post(webhook_url.url,
{
+11
View File
@@ -144,6 +144,17 @@ class SubmitterMailer < ApplicationMailer
end
end
def otp_verification_email(submitter, locale: nil)
@submitter = submitter
@otp_code = EmailVerificationCodes.generate([submitter.email.downcase.strip, submitter.slug].join(':'))
assign_message_metadata('otp_verification_email', submitter)
I18n.with_locale(locale || submitter.account.locale) do
mail(to: submitter.email, subject: I18n.t('email_verification'))
end
end
private
def build_submitter_reply_to(submitter, email_config: nil, documents_copy_email: nil)
+4
View File
@@ -60,6 +60,10 @@ class Account < ApplicationRecord
linked_account_account&.testing?
end
def tz_info
@tz_info ||= TZInfo::Timezone.get(ActiveSupport::TimeZone::MAPPING[timezone] || timezone)
end
def default_template_folder
super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME,
author_id: users.minimum(:id)).tap(&:save!)
+4
View File
@@ -45,12 +45,16 @@ class AccountConfig < ApplicationRecord
WITH_SIGNATURE_ID = 'with_signature_id'
WITH_FILE_LINKS_KEY = 'with_file_links'
WITH_SIGNATURE_ID_REASON_KEY = 'with_signature_id_reason'
RECIPIENT_FORM_FIELDS_KEY = 'recipient_form_fields'
WITH_AUDIT_VALUES_KEY = 'with_audit_values'
WITH_AUDIT_SENDER_KEY = 'with_audit_sender'
WITH_SUBMITTER_TIMEZONE_KEY = 'with_submitter_timezone'
REQUIRE_SIGNING_REASON_KEY = 'require_signing_reason'
REUSE_SIGNATURE_KEY = 'reuse_signature'
WITH_FIELD_LABELS_KEY = 'with_field_labels'
COMBINE_PDF_RESULT_KEY = 'combine_pdf_result_key'
DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format'
TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields'
POLICY_LINKS_KEY = 'policy_links'
DEFAULT_VALUES = {
+5 -2
View File
@@ -6,6 +6,7 @@
#
# id :bigint not null, primary key
# completed_at :datetime not null
# is_first :boolean
# sms_count :integer not null
# source :string not null
# verification_method :string
@@ -18,8 +19,10 @@
#
# Indexes
#
# index_completed_submitters_on_account_id (account_id)
# index_completed_submitters_on_submitter_id (submitter_id) UNIQUE
# index_completed_submitters_account_id_completed_at_is_first (account_id,completed_at) WHERE (is_first = true)
# index_completed_submitters_on_account_id_and_completed_at (account_id,completed_at)
# index_completed_submitters_on_submission_id (submission_id) UNIQUE WHERE (is_first = true)
# index_completed_submitters_on_submitter_id (submitter_id) UNIQUE
#
class CompletedSubmitter < ApplicationRecord
belongs_to :submitter
+5 -2
View File
@@ -63,6 +63,8 @@ class Submission < ApplicationRecord
has_one_attached :audit_trail
has_one_attached :combined_document
has_one_attached :merged_document
has_one_attached :preview_merged_document
has_many_attached :preview_documents
has_many_attached :documents
@@ -76,8 +78,9 @@ class Submission < ApplicationRecord
scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
scope :pending, lambda {
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
where(expire_at: nil).or(where(expire_at: Time.current..))
.where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
}
scope :completed, lambda {
where.not(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
+16 -3
View File
@@ -10,17 +10,21 @@
# event_type :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint
# submission_id :bigint not null
# submitter_id :bigint
#
# Indexes
#
# index_submission_events_on_created_at (created_at)
# index_submission_events_on_submission_id (submission_id)
# index_submission_events_on_submitter_id (submitter_id)
# index_submission_events_on_account_id (account_id)
# index_submission_events_on_created_at (created_at)
# index_submission_events_on_submission_id (submission_id)
# index_submission_events_on_submitter_id (submitter_id)
# index_submissions_events_on_sms_event_types (account_id,created_at) WHERE ((event_type)::text = ANY ((ARRAY['send_sms'::character varying, 'send_2fa_sms'::character varying])::text[]))
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (submission_id => submissions.id)
# fk_rails_... (submitter_id => submitters.id)
#
@@ -35,6 +39,7 @@ class SubmissionEvent < ApplicationRecord
serialize :data, coder: JSON
before_validation :set_submission_id, on: :create
before_validation :set_account_id, on: :create
enum :event_type, {
send_email: 'send_email',
@@ -43,6 +48,7 @@ class SubmissionEvent < ApplicationRecord
send_reminder_email: 'send_reminder_email',
send_sms: 'send_sms',
send_2fa_sms: 'send_2fa_sms',
send_2fa_email: 'send_2fa_email',
open_email: 'open_email',
click_email: 'click_email',
click_sms: 'click_sms',
@@ -51,6 +57,9 @@ class SubmissionEvent < ApplicationRecord
start_form: 'start_form',
start_verification: 'start_verification',
complete_verification: 'complete_verification',
start_kba: 'start_kba',
complete_kba: 'complete_kba',
fail_kba: 'fail_kba',
view_form: 'view_form',
invite_party: 'invite_party',
complete_form: 'complete_form',
@@ -63,4 +72,8 @@ class SubmissionEvent < ApplicationRecord
def set_submission_id
self.submission_id = submitter&.submission_id
end
def set_account_id
self.account_id = submitter&.account_id
end
end
+5 -1
View File
@@ -6,6 +6,9 @@
#
# id :bigint not null, primary key
# archived_at :datetime
# confirmation_sent_at :datetime
# confirmation_token :string
# confirmed_at :datetime
# consumed_timestep :integer
# current_sign_in_at :datetime
# current_sign_in_ip :string
@@ -24,8 +27,9 @@
# reset_password_token :string
# role :string not null
# sign_in_count :integer default(0), not null
# unconfirmed_email :string
# unlock_token :string
# uuid :text not null
# uuid :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
+1 -1
View File
@@ -235,7 +235,7 @@
<% end %>
<% end %>
<% end %>
<% if can?(:manage, :personalization_advanced) %>
<% if !Docuseal.multitenant? || can?(:manage, :personalization_advanced) %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::WITH_FILE_LINKS_KEY) %>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
@@ -1,5 +1,10 @@
<p>Hello <%= @resource.email %>!</p>
<p>Someone has requested a link to change your password. You can do this through the link below.</p>
<p><%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %></p>
<p>If you didn't request this, please ignore this email.</p>
<p>Your password won't change until you access the link above and create a new one.</p>
<p><%= @resource.first_name.present? ? t('hello_name', name: @resource.first_name) : t('hi_there') %>,</p>
<p><%= t('you_requested_to_reset_your_password_use_the_link_below_to_continue') %>:</p>
<p><%= link_to t('change_my_password'), edit_password_url(@resource, reset_password_token: @token) %></p>
<p><%= t('your_password_wont_change_until_you_open_the_link_above_and_set_a_new_one') %></p>
<p><%= t('if_you_didnt_request_this_you_can_ignore_this_email') %></p>
<p>
<%= t('thanks') %>,<br>
<%= Docuseal.product_name %>
</p>
<% content_for(:remove_attribution, true) %>

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