mirror of
https://github.com/docusealco/docuseal.git
synced 2026-08-07 15:25:16 +00:00
Compare commits
231 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6785b0afd | |||
| 009033a411 | |||
| fdc3d7742a | |||
| f9ccca1376 | |||
| 0a00fe6ab8 | |||
| 6253e80a70 | |||
| 3dcb7c813f | |||
| 39cdc41a67 | |||
| d3000487f4 | |||
| 80254da1c1 | |||
| 73fc2d92f7 | |||
| f69aa33f3d | |||
| e16202c9f7 | |||
| 7453d0d274 | |||
| c102828c93 | |||
| afeb6dd318 | |||
| 6156623f0e | |||
| ccc06fcc3d | |||
| bbe3942056 | |||
| 49cea59b94 | |||
| ce1323e849 | |||
| fc3c1e65e3 | |||
| f1ebd8f912 | |||
| 98f5bba832 | |||
| e864e8a47f | |||
| a573545ead | |||
| 15ff4386e5 | |||
| 7a4aa3bf87 | |||
| c1ba6288c8 | |||
| d0ba180729 | |||
| 2edc76414b | |||
| 452db5dc39 | |||
| 21c4c53feb | |||
| 6292789cac | |||
| d445bbc02d | |||
| 8627f58cbc | |||
| 66ab0059bb | |||
| 3f06932277 | |||
| 59cf1b435e | |||
| c9a5b0e66e | |||
| bcd5e1da98 | |||
| f74ad050b4 | |||
| b6f43c9b23 | |||
| 298c64a119 | |||
| 8b80679b3a | |||
| 82a2bc561a | |||
| e019542d3f | |||
| 9356247ef0 | |||
| 4db77c18e3 | |||
| 0d0b248988 | |||
| 44f3910266 | |||
| 35c6798a80 | |||
| 0995c7ee63 | |||
| 0a25f78329 | |||
| bb8fffc491 | |||
| 6f6c51fb32 | |||
| 39d14c15d6 | |||
| 17de28965b | |||
| ae7865e508 | |||
| dc8e8cc5c9 | |||
| 5208470c38 | |||
| e7ef7b552f | |||
| a478112b07 | |||
| 7d0f32e1f9 | |||
| d35d8bead3 | |||
| ab9cc52bcc | |||
| 7dc342a6f9 | |||
| 80a3ef9193 | |||
| bb723d20cb | |||
| 0c24a2016c | |||
| 2124965996 | |||
| 6f1e104067 | |||
| b20f40b5a3 | |||
| 1093ca5fc4 | |||
| 0d99c55d3f | |||
| 86dc1a88ed | |||
| 968c20d8ca | |||
| 1b2d978449 | |||
| 30582b2d75 | |||
| 38a8037a4d | |||
| 67de45f787 | |||
| f358f24abb | |||
| fd4e15003a | |||
| 81df249d5c | |||
| ba60997992 | |||
| 8961294a29 | |||
| bc2e0a000a | |||
| 1a693fba2d | |||
| 05b7acf6aa | |||
| de13d8d397 | |||
| 6d7d8ae87c | |||
| cfb1bdcd52 | |||
| 48d41833f1 | |||
| 01535f1afc | |||
| fe845bfa36 | |||
| b94dbc75b7 | |||
| bdf40640d0 | |||
| 496f6c706b | |||
| 79f97d5555 | |||
| 82f679c84c | |||
| 9b789889fa | |||
| dd8dd118da | |||
| 8222a4b6a4 | |||
| 31f8057bfa | |||
| 1d8e0417bd | |||
| 8100ca46c2 | |||
| 94d745cf1c | |||
| a92bd0ad0f | |||
| 94a13ffca2 | |||
| 64c8707957 | |||
| d421a73988 | |||
| f7eef1f8d6 | |||
| 08c780d309 | |||
| baaf1f510f | |||
| 8898dcc054 | |||
| 33521c8aeb | |||
| 9db03bd7b9 | |||
| bd600c1fba | |||
| 82b18b08f1 | |||
| 0aefe616b3 | |||
| 59a66bbd16 | |||
| 155c708e4f | |||
| 5502c2ab1b | |||
| e89b42de40 | |||
| 2562c4f432 | |||
| 17323074ca | |||
| 8a4c5c5e09 | |||
| 3033faff0d | |||
| 0e6aab0e8d | |||
| 75e3dd839a | |||
| 830e6eb88d | |||
| 3c8fae78d1 | |||
| 441c1d7044 | |||
| 203f4633f7 | |||
| e558f4d9fb | |||
| 990e4020e9 | |||
| 1ad3e45519 | |||
| 442ced8381 | |||
| ba46202a4d | |||
| 1c77a1ee90 | |||
| ea74b59535 | |||
| 0e9343d9e2 | |||
| af4cf7257f | |||
| c8d4ee58f8 | |||
| 65e57c22e5 | |||
| 7351ae0a77 | |||
| f1e0d5d137 | |||
| 8a1b71688c | |||
| 8ef1c309e0 | |||
| a32dcc0dc5 | |||
| 16a2fde4b7 | |||
| 7856f7a134 | |||
| c545924fe9 | |||
| b1510bb88c | |||
| 31d2024ce5 | |||
| 23c49f8706 | |||
| 62eb0cda6b | |||
| 9f48c6e625 | |||
| 83ce401cf2 | |||
| 541b025ad0 | |||
| 5fd018df46 | |||
| 2822b0086a | |||
| e27606f443 | |||
| 6c95cd5945 | |||
| d723146060 | |||
| 3b03e61ae3 | |||
| 172513b96c | |||
| 68be42a48d | |||
| 0370e363bc | |||
| ced88476cc | |||
| c5d71505ef | |||
| 489f9859be | |||
| a87ca65839 | |||
| fa78d0976d | |||
| 4aaab2563b | |||
| e073cf2c27 | |||
| 173d406b1f | |||
| 3bcbad6f04 | |||
| e67854fe56 | |||
| 7510e0cb6b | |||
| 30c913b54f | |||
| 2cda9cd736 | |||
| 342d29f272 | |||
| 13f76c9944 | |||
| ce4e55f387 | |||
| 712107ba5a | |||
| 1aa867893e | |||
| 4f6142f9c0 | |||
| c303349666 | |||
| e85df170f2 | |||
| 01333e5b2e | |||
| 514987138a | |||
| 0814c6d065 | |||
| 542a443fb8 | |||
| f2c6bb1c3d | |||
| b85c9cfdb7 | |||
| e07b50c22d | |||
| 4dde9890e0 | |||
| 66d1ae2f64 | |||
| d130835902 | |||
| 52f66368a3 | |||
| 89d0810625 | |||
| 06d3d1202b | |||
| 0698aad8e1 | |||
| 3575a25935 | |||
| cdd034e72d | |||
| 26f7d5cf0d | |||
| 3c69430b94 | |||
| c0162bccc8 | |||
| 1adb800f94 | |||
| 333a5d6b7b | |||
| 9aa68fe955 | |||
| 46af87d89a | |||
| 1066131143 | |||
| a25022532e | |||
| 5162f9a9dc | |||
| cc5694de43 | |||
| f734ba280d | |||
| 500d0da563 | |||
| 6626a2b602 | |||
| 07e66a6e53 | |||
| deef9c7d3b | |||
| 2ec8954dc8 | |||
| c1af803e5b | |||
| 10abdbeba4 | |||
| 1dbde7a6d8 | |||
| a27eb18670 | |||
| 44a49a4533 | |||
| fc11078cae | |||
| a42ae0b39e | |||
| 3163cdc761 |
@@ -8,7 +8,8 @@
|
||||
},
|
||||
"rules": {
|
||||
"vue/no-deprecated-html-element-is": 0,
|
||||
"vue/no-mutating-props": 0
|
||||
"vue/no-mutating-props": 0,
|
||||
"vue/no-v-html": 0
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
|
||||
@@ -30,6 +30,9 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Create .version file
|
||||
run: echo ${{ github.ref_name }} > .version
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
|
||||
+14
-5
@@ -1,4 +1,12 @@
|
||||
FROM ruby:3.2.2-alpine as webpack
|
||||
FROM ruby:3.2.2-alpine3.18 as fonts
|
||||
|
||||
WORKDIR /fonts
|
||||
|
||||
RUN apk --no-cache add fontforge wget ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf . && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansArabic/hinted/ttf/NotoSansArabic-Regular.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
|
||||
|
||||
RUN fontforge -lang=py -c 'font1 = fontforge.open("LiberationSans-Regular.ttf"); font2 = fontforge.open("NotoSansArabic-Regular.ttf"); font1.mergeFonts(font2); font1.generate("LiberationSans-Regular.ttf")'
|
||||
|
||||
FROM ruby:3.2.2-alpine3.18 as webpack
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV NODE_ENV=production
|
||||
@@ -24,18 +32,18 @@ COPY ./app/views ./app/views
|
||||
|
||||
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
|
||||
|
||||
FROM ruby:3.2.2-alpine as app
|
||||
FROM ruby:3.2.2-alpine3.18 as app
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV BUNDLE_WITHOUT="development:test"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont ttf-liberation && mkdir /fonts && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf /fonts && apk del ttf-liberation && wget -O /fonts/DancingScript.otf "https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf" && wget -O /fonts/DancingScript-License.txt https://github.com/impallari/DancingScript/blob/master/OFL.txt
|
||||
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts
|
||||
|
||||
COPY ./Gemfile ./Gemfile.lock ./
|
||||
|
||||
RUN bundle update --bundler && bundle install && rm -rf ~/.bundle
|
||||
RUN bundle update --bundler && bundle install && 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
|
||||
|
||||
COPY ./bin ./bin
|
||||
COPY ./app ./app
|
||||
@@ -45,8 +53,9 @@ COPY ./log ./log
|
||||
COPY ./lib ./lib
|
||||
COPY ./public ./public
|
||||
COPY ./tmp ./tmp
|
||||
COPY LICENSE README.md Rakefile config.ru ./
|
||||
COPY LICENSE README.md Rakefile config.ru .version ./
|
||||
|
||||
COPY --from=fonts /fonts/LiberationSans-Regular.ttf /fonts/LiberationSans-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
|
||||
COPY --from=webpack /app/public/packs ./public/packs
|
||||
|
||||
RUN ln -s /fonts /app/public/fonts
|
||||
|
||||
@@ -4,6 +4,7 @@ source 'https://rubygems.org'
|
||||
|
||||
ruby '3.2.2'
|
||||
|
||||
gem 'arabic-letter-connector', require: 'arabic-letter-connector/logic'
|
||||
gem 'aws-sdk-s3', require: false
|
||||
gem 'azure-storage-blob', require: false
|
||||
gem 'bootsnap', require: false
|
||||
@@ -21,13 +22,13 @@ gem 'jwt'
|
||||
gem 'lograge'
|
||||
gem 'mysql2', require: false
|
||||
gem 'oj'
|
||||
gem 'omniauth-google-oauth2'
|
||||
gem 'omniauth-rails_csrf_protection'
|
||||
gem 'pagy'
|
||||
gem 'pdf-reader'
|
||||
gem 'pg', require: false
|
||||
gem 'premailer-rails'
|
||||
gem 'pretender'
|
||||
gem 'puma'
|
||||
gem 'rack'
|
||||
gem 'rails'
|
||||
gem 'rails_autolink'
|
||||
gem 'rails-i18n'
|
||||
@@ -41,6 +42,7 @@ gem 'sidekiq', require: ENV.key?('REDIS_URL')
|
||||
gem 'sqlite3', require: false
|
||||
gem 'strip_attributes'
|
||||
gem 'turbo-rails'
|
||||
gem 'twitter_cldr', require: false
|
||||
gem 'tzinfo-data'
|
||||
|
||||
group :development, :test do
|
||||
|
||||
+200
-194
@@ -2,93 +2,103 @@ GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
Ascii85 (1.1.0)
|
||||
actioncable (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
actioncable (7.1.2)
|
||||
actionpack (= 7.1.2)
|
||||
activesupport (= 7.1.2)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
activejob (= 7.0.7)
|
||||
activerecord (= 7.0.7)
|
||||
activestorage (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.1.2)
|
||||
actionpack (= 7.1.2)
|
||||
activejob (= 7.1.2)
|
||||
activerecord (= 7.1.2)
|
||||
activestorage (= 7.1.2)
|
||||
activesupport (= 7.1.2)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
actionview (= 7.0.7)
|
||||
activejob (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
actionmailer (7.1.2)
|
||||
actionpack (= 7.1.2)
|
||||
actionview (= 7.1.2)
|
||||
activejob (= 7.1.2)
|
||||
activesupport (= 7.1.2)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (7.0.7)
|
||||
actionview (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
rack (~> 2.0, >= 2.2.4)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.1.2)
|
||||
actionview (= 7.1.2)
|
||||
activesupport (= 7.1.2)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
activerecord (= 7.0.7)
|
||||
activestorage (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
actiontext (7.1.2)
|
||||
actionpack (= 7.1.2)
|
||||
activerecord (= 7.1.2)
|
||||
activestorage (= 7.1.2)
|
||||
activesupport (= 7.1.2)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
actionview (7.1.2)
|
||||
activesupport (= 7.1.2)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
activejob (7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (7.1.2)
|
||||
activesupport (= 7.1.2)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
activerecord (7.0.7)
|
||||
activemodel (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
activestorage (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
activejob (= 7.0.7)
|
||||
activerecord (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
activemodel (7.1.2)
|
||||
activesupport (= 7.1.2)
|
||||
activerecord (7.1.2)
|
||||
activemodel (= 7.1.2)
|
||||
activesupport (= 7.1.2)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.1.2)
|
||||
actionpack (= 7.1.2)
|
||||
activejob (= 7.1.2)
|
||||
activerecord (= 7.1.2)
|
||||
activesupport (= 7.1.2)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (7.0.7)
|
||||
activesupport (7.1.2)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
mutex_m
|
||||
tzinfo (~> 2.0)
|
||||
addressable (2.8.5)
|
||||
addressable (2.8.6)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
afm (0.2.2)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
arabic-letter-connector (0.1.1)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.2.0)
|
||||
aws-partitions (1.807.0)
|
||||
aws-sdk-core (3.180.3)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.874.0)
|
||||
aws-sdk-core (3.190.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (~> 1.8)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.71.0)
|
||||
aws-sdk-core (~> 3, >= 3.177.0)
|
||||
aws-sdk-kms (1.75.0)
|
||||
aws-sdk-core (~> 3, >= 3.188.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.132.1)
|
||||
aws-sdk-core (~> 3, >= 3.179.0)
|
||||
aws-sdk-s3 (1.142.0)
|
||||
aws-sdk-core (~> 3, >= 3.189.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.6)
|
||||
aws-sigv4 (1.6.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (1.8.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
azure-storage-blob (2.0.3)
|
||||
azure-storage-common (~> 2.0)
|
||||
@@ -98,7 +108,8 @@ GEM
|
||||
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
|
||||
net-http-persistent (~> 4.0)
|
||||
nokogiri (~> 1, >= 1.10.8)
|
||||
bcrypt (3.1.19)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
better_html (2.0.2)
|
||||
actionview (>= 6.0)
|
||||
activesupport (>= 6.0)
|
||||
@@ -106,13 +117,15 @@ GEM
|
||||
erubi (~> 1.4)
|
||||
parser (>= 2.4)
|
||||
smart_properties
|
||||
bigdecimal (3.1.5)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
bootsnap (1.17.0)
|
||||
msgpack (~> 1.2)
|
||||
builder (3.2.4)
|
||||
bullet (7.0.7)
|
||||
bullet (7.1.4)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
camertron-eprun (1.1.1)
|
||||
cancancan (3.5.0)
|
||||
capybara (3.39.2)
|
||||
addressable
|
||||
@@ -124,6 +137,7 @@ GEM
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
chunky_png (1.4.0)
|
||||
cldr-plurals-runtime-rb (1.1.0)
|
||||
cmdparse (3.0.7)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.2.2)
|
||||
@@ -131,17 +145,17 @@ GEM
|
||||
crack (0.4.5)
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
css_parser (1.14.0)
|
||||
css_parser (1.16.0)
|
||||
addressable
|
||||
cuprite (0.14.3)
|
||||
cuprite (0.15)
|
||||
capybara (~> 3.0)
|
||||
ferrum (~> 0.13.0)
|
||||
date (3.3.3)
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
reline (>= 0.3.1)
|
||||
ferrum (~> 0.14.0)
|
||||
date (3.3.4)
|
||||
debug (1.9.1)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
declarative (0.0.20)
|
||||
devise (4.9.2)
|
||||
devise (4.9.3)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 4.1.0)
|
||||
@@ -157,8 +171,10 @@ GEM
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
docile (1.4.0)
|
||||
dotenv (2.8.1)
|
||||
drb (2.2.0)
|
||||
ruby2_keywords
|
||||
email_typo (0.2.3)
|
||||
erb_lint (0.4.0)
|
||||
erb_lint (0.5.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
parser (>= 2.7.1.4)
|
||||
@@ -166,12 +182,12 @@ GEM
|
||||
rubocop
|
||||
smart_properties
|
||||
erubi (1.12.0)
|
||||
factory_bot (6.2.1)
|
||||
factory_bot (6.4.4)
|
||||
activesupport (>= 5.0.0)
|
||||
factory_bot_rails (6.2.0)
|
||||
factory_bot (~> 6.2.0)
|
||||
factory_bot_rails (6.4.2)
|
||||
factory_bot (~> 6.4)
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.1)
|
||||
faker (3.2.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (1.10.3)
|
||||
faraday-em_http (~> 1.0)
|
||||
@@ -200,16 +216,16 @@ GEM
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.0)
|
||||
faraday (~> 1.0)
|
||||
ferrum (0.13)
|
||||
ferrum (0.14)
|
||||
addressable (~> 2.5)
|
||||
concurrent-ruby (~> 1.1)
|
||||
webrick (~> 1.7)
|
||||
websocket-driver (>= 0.6, < 0.8)
|
||||
ffi (1.15.5)
|
||||
ffi (1.16.3)
|
||||
geom2d (0.4.1)
|
||||
globalid (1.1.0)
|
||||
activesupport (>= 5.0)
|
||||
google-apis-core (0.11.1)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
google-apis-core (0.11.2)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
@@ -220,33 +236,32 @@ GEM
|
||||
webrick
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.19.0)
|
||||
google-apis-core (>= 0.9.0, < 2.a)
|
||||
google-cloud-core (1.6.0)
|
||||
google-cloud-env (~> 1.0)
|
||||
google-apis-storage_v1 (0.29.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.6.1)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-env (2.1.0)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-storage (1.44.0)
|
||||
google-cloud-storage (1.45.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.19.0)
|
||||
google-apis-storage_v1 (~> 0.29.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.7.0)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
googleauth (1.9.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.1)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
memoist (~> 0.16)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
hashdiff (1.0.1)
|
||||
hashdiff (1.1.0)
|
||||
hashery (2.1.2)
|
||||
hashie (5.0.0)
|
||||
hexapdf (0.33.0)
|
||||
hexapdf (0.34.1)
|
||||
cmdparse (~> 3.0, >= 3.0.3)
|
||||
geom2d (~> 0.4, >= 0.4.1)
|
||||
openssl (>= 2.2.1)
|
||||
@@ -257,11 +272,12 @@ GEM
|
||||
image_processing (1.12.2)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
io-console (0.6.0)
|
||||
irb (1.7.4)
|
||||
reline (>= 0.3.6)
|
||||
io-console (0.7.1)
|
||||
irb (1.11.0)
|
||||
rdoc
|
||||
reline (>= 0.3.8)
|
||||
jmespath (1.6.2)
|
||||
json (2.6.3)
|
||||
json (2.7.1)
|
||||
jwt (2.7.1)
|
||||
language_server-protocol (3.17.0.3)
|
||||
launchy (2.5.2)
|
||||
@@ -273,12 +289,12 @@ GEM
|
||||
letter_opener (~> 1.7)
|
||||
railties (>= 5.2)
|
||||
rexml
|
||||
lograge (0.13.0)
|
||||
lograge (0.14.0)
|
||||
actionpack (>= 4)
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.21.3)
|
||||
loofah (2.22.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
@@ -288,72 +304,48 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.2)
|
||||
matrix (0.4.2)
|
||||
memoist (0.16.2)
|
||||
method_source (1.0.0)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.4)
|
||||
mini_portile2 (2.8.5)
|
||||
minitest (5.20.0)
|
||||
msgpack (1.7.2)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.6.0)
|
||||
multipart-post (2.3.0)
|
||||
mutex_m (0.2.0)
|
||||
mysql2 (0.5.5)
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.3.7)
|
||||
net-imap (0.4.9)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.1)
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-smtp (0.3.3)
|
||||
net-smtp (0.4.0)
|
||||
net-protocol
|
||||
nio4r (2.5.9)
|
||||
nokogiri (1.15.4)
|
||||
nio4r (2.7.0)
|
||||
nokogiri (1.15.5)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.15.4-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
oauth2 (2.0.9)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
jwt (>= 1.0, < 3.0)
|
||||
multi_xml (~> 0.5)
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
oj (3.16.0)
|
||||
omniauth (2.1.1)
|
||||
hashie (>= 3.4.6)
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-google-oauth2 (1.1.1)
|
||||
jwt (>= 2.0)
|
||||
oauth2 (~> 2.0.6)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-oauth2 (~> 1.8.0)
|
||||
omniauth-oauth2 (1.8.0)
|
||||
oauth2 (>= 1.4, < 3)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-rails_csrf_protection (1.0.1)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
openssl (3.1.0)
|
||||
oj (3.16.3)
|
||||
bigdecimal (>= 3.0)
|
||||
openssl (3.2.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
pagy (6.0.4)
|
||||
parallel (1.23.0)
|
||||
pagy (6.2.0)
|
||||
parallel (1.24.0)
|
||||
parser (3.2.2.4)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pdf-reader (2.11.0)
|
||||
pdf-reader (2.12.0)
|
||||
Ascii85 (~> 1.0)
|
||||
afm (~> 0.2.1)
|
||||
hashery (~> 2.0)
|
||||
ruby-rc4
|
||||
ttfunk
|
||||
pg (1.5.3)
|
||||
pg (1.5.4)
|
||||
premailer (1.21.0)
|
||||
addressable
|
||||
css_parser (>= 1.12.0)
|
||||
@@ -362,36 +354,43 @@ GEM
|
||||
actionmailer (>= 3)
|
||||
net-smtp
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
pretender (0.5.0)
|
||||
actionpack (>= 6.1)
|
||||
pry (0.14.2)
|
||||
coderay (~> 1.1)
|
||||
method_source (~> 1.0)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (5.0.3)
|
||||
puma (6.3.1)
|
||||
psych (5.1.2)
|
||||
stringio
|
||||
public_suffix (5.0.4)
|
||||
puma (6.4.0)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.7.1)
|
||||
racc (1.7.3)
|
||||
rack (2.2.8)
|
||||
rack-protection (3.1.0)
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
rack-proxy (0.7.6)
|
||||
rack-proxy (0.7.7)
|
||||
rack
|
||||
rack-session (1.0.2)
|
||||
rack (< 3)
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rails (7.0.7)
|
||||
actioncable (= 7.0.7)
|
||||
actionmailbox (= 7.0.7)
|
||||
actionmailer (= 7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
actiontext (= 7.0.7)
|
||||
actionview (= 7.0.7)
|
||||
activejob (= 7.0.7)
|
||||
activemodel (= 7.0.7)
|
||||
activerecord (= 7.0.7)
|
||||
activestorage (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
rackup (1.0.0)
|
||||
rack (< 3)
|
||||
webrick
|
||||
rails (7.1.2)
|
||||
actioncable (= 7.1.2)
|
||||
actionmailbox (= 7.1.2)
|
||||
actionmailer (= 7.1.2)
|
||||
actionpack (= 7.1.2)
|
||||
actiontext (= 7.1.2)
|
||||
actionview (= 7.1.2)
|
||||
activejob (= 7.1.2)
|
||||
activemodel (= 7.1.2)
|
||||
activerecord (= 7.1.2)
|
||||
activestorage (= 7.1.2)
|
||||
activesupport (= 7.1.2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.0.7)
|
||||
railties (= 7.1.2)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -406,19 +405,22 @@ GEM
|
||||
actionview (> 3.1)
|
||||
activesupport (> 3.1)
|
||||
railties (> 3.1)
|
||||
railties (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
method_source
|
||||
railties (7.1.2)
|
||||
actionpack (= 7.1.2)
|
||||
activesupport (= 7.1.2)
|
||||
irb
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
zeitwerk (~> 2.5)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.0.6)
|
||||
redis-client (0.16.0)
|
||||
rake (13.1.0)
|
||||
rdoc (6.6.2)
|
||||
psych (>= 4.0.0)
|
||||
redis-client (0.19.1)
|
||||
connection_pool
|
||||
regexp_parser (2.8.2)
|
||||
reline (0.3.8)
|
||||
regexp_parser (2.8.3)
|
||||
reline (0.4.1)
|
||||
io-console (~> 0.5)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
@@ -426,12 +428,12 @@ GEM
|
||||
uber (< 0.2.0)
|
||||
request_store (1.5.1)
|
||||
rack (>= 1.4)
|
||||
responders (3.1.0)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.6)
|
||||
rollbar (3.4.0)
|
||||
rollbar (3.4.2)
|
||||
rotp (6.3.0)
|
||||
rqrcode (2.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
@@ -445,7 +447,7 @@ GEM
|
||||
rspec-mocks (3.12.6)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-rails (6.0.3)
|
||||
rspec-rails (6.1.0)
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
railties (>= 6.1)
|
||||
@@ -454,7 +456,7 @@ GEM
|
||||
rspec-mocks (~> 3.12)
|
||||
rspec-support (~> 3.12)
|
||||
rspec-support (3.12.1)
|
||||
rubocop (1.57.2)
|
||||
rubocop (1.59.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
@@ -462,7 +464,7 @@ GEM
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.28.1, < 2.0)
|
||||
rubocop-ast (>= 1.30.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.30.0)
|
||||
@@ -471,20 +473,21 @@ GEM
|
||||
rubocop (~> 1.41)
|
||||
rubocop-factory_bot (2.24.0)
|
||||
rubocop (~> 1.33)
|
||||
rubocop-performance (1.19.1)
|
||||
rubocop (>= 1.7.0, < 2.0)
|
||||
rubocop-ast (>= 0.4.0)
|
||||
rubocop-rails (2.22.1)
|
||||
rubocop-performance (1.20.1)
|
||||
rubocop (>= 1.48.1, < 2.0)
|
||||
rubocop-ast (>= 1.30.0, < 2.0)
|
||||
rubocop-rails (2.23.1)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.33.0, < 2.0)
|
||||
rubocop-ast (>= 1.30.0, < 2.0)
|
||||
rubocop-rspec (2.25.0)
|
||||
rubocop (~> 1.40)
|
||||
rubocop-capybara (~> 2.17)
|
||||
rubocop-factory_bot (~> 2.22)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-rc4 (0.1.5)
|
||||
ruby-vips (2.1.4)
|
||||
ruby-vips (2.2.0)
|
||||
ffi (~> 1.12)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyXL (3.4.25)
|
||||
@@ -492,17 +495,17 @@ GEM
|
||||
rubyzip (>= 1.3.0)
|
||||
rubyzip (2.3.2)
|
||||
semantic_range (3.0.0)
|
||||
shakapacker (7.0.3)
|
||||
shakapacker (7.1.0)
|
||||
activesupport (>= 5.2)
|
||||
rack-proxy (>= 0.6.1)
|
||||
railties (>= 5.2)
|
||||
semantic_range (>= 2.3.0)
|
||||
sidekiq (7.1.2)
|
||||
sidekiq (7.2.0)
|
||||
concurrent-ruby (< 2)
|
||||
connection_pool (>= 2.3.0)
|
||||
rack (>= 2.2.4)
|
||||
redis-client (>= 0.14.0)
|
||||
signet (0.17.0)
|
||||
signet (0.18.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
@@ -514,38 +517,39 @@ GEM
|
||||
simplecov-html (0.12.3)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
snaky_hash (2.0.1)
|
||||
hashie
|
||||
version_gem (~> 1.1, >= 1.1.1)
|
||||
sqlite3 (1.6.3)
|
||||
sqlite3 (1.7.0)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
sqlite3 (1.6.3-arm64-darwin)
|
||||
sqlite3 (1.7.0-arm64-darwin)
|
||||
stringio (3.1.0)
|
||||
strip_attributes (1.13.0)
|
||||
activemodel (>= 3.0, < 8.0)
|
||||
thor (1.2.2)
|
||||
timeout (0.4.0)
|
||||
thor (1.3.0)
|
||||
timeout (0.4.1)
|
||||
trailblazer-option (0.1.2)
|
||||
ttfunk (1.7.0)
|
||||
turbo-rails (1.4.0)
|
||||
turbo-rails (1.5.0)
|
||||
actionpack (>= 6.0.0)
|
||||
activejob (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
twitter_cldr (6.12.0)
|
||||
camertron-eprun
|
||||
cldr-plurals-runtime-rb (~> 1.1)
|
||||
tzinfo
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
tzinfo-data (1.2023.3)
|
||||
tzinfo-data (1.2023.4)
|
||||
tzinfo (>= 1.0.0)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (2.5.0)
|
||||
uniform_notifier (1.16.0)
|
||||
version_gem (1.1.3)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
web-console (4.2.0)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webmock (3.18.1)
|
||||
webmock (3.19.1)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
@@ -555,7 +559,7 @@ GEM
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.6.11)
|
||||
zeitwerk (2.6.12)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-22
|
||||
@@ -563,6 +567,7 @@ PLATFORMS
|
||||
|
||||
DEPENDENCIES
|
||||
annotate
|
||||
arabic-letter-connector
|
||||
aws-sdk-s3
|
||||
azure-storage-blob
|
||||
better_html
|
||||
@@ -589,14 +594,14 @@ DEPENDENCIES
|
||||
lograge
|
||||
mysql2
|
||||
oj
|
||||
omniauth-google-oauth2
|
||||
omniauth-rails_csrf_protection
|
||||
pagy
|
||||
pdf-reader
|
||||
pg
|
||||
premailer-rails
|
||||
pretender
|
||||
pry-rails
|
||||
puma
|
||||
rack
|
||||
rails
|
||||
rails-i18n
|
||||
rails_autolink
|
||||
@@ -616,6 +621,7 @@ DEPENDENCIES
|
||||
sqlite3
|
||||
strip_attributes
|
||||
turbo-rails
|
||||
twitter_cldr
|
||||
tzinfo-data
|
||||
web-console
|
||||
webmock
|
||||
@@ -624,4 +630,4 @@ RUBY VERSION
|
||||
ruby 3.2.2p53
|
||||
|
||||
BUNDLED WITH
|
||||
2.4.10
|
||||
2.5.3
|
||||
|
||||
@@ -34,10 +34,10 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
|
||||
## Features
|
||||
- [x] PDF form fields builder (WYSIWYG)
|
||||
- [x] 10 field types available (Signature, Date, File, Checkbox etc.)
|
||||
- [x] 11 field types available (Signature, Date, File, Checkbox etc.)
|
||||
- [x] Multiple submitters per document
|
||||
- [x] Automated emails via SMTP
|
||||
- [x] Files storage on AWS S3, Google Storage, or Azure
|
||||
- [x] Files storage on disk or AWS S3, Google Storage, Azure Cloud
|
||||
- [x] Automatic PDF eSignature
|
||||
- [x] PDF signature verification
|
||||
- [x] Users management
|
||||
@@ -73,10 +73,10 @@ curl https://raw.githubusercontent.com/docusealco/docuseal/master/docker-compose
|
||||
|
||||
Run the app under a custom domain over https using docker compose (make sure your DNS points to the server to automatically issue ssl certs with Caddy):
|
||||
```sh
|
||||
HOST=your-domain-name.com docker-compose up
|
||||
sudo HOST=your-domain-name.com docker compose up
|
||||
```
|
||||
|
||||
## For Companies
|
||||
## For Businesses
|
||||
### Integrate seamless document signing into your web or mobile apps with DocuSeal!
|
||||
|
||||
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AccountConfigsController < ApplicationController
|
||||
before_action :load_account_config
|
||||
authorize_resource :account_config
|
||||
|
||||
ALLOWED_KEYS = [
|
||||
AccountConfig::ALLOW_TYPED_SIGNATURE,
|
||||
AccountConfig::FORCE_MFA,
|
||||
AccountConfig::ESIGNING_PREFERENCE_KEY
|
||||
].freeze
|
||||
|
||||
def create
|
||||
@account_config.update!(account_config_params)
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_account_config
|
||||
return head :not_found unless ALLOWED_KEYS.include?(account_config_params[:key])
|
||||
|
||||
@account_config =
|
||||
AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key])
|
||||
end
|
||||
|
||||
def account_config_params
|
||||
params.required(:account_config).permit!.tap do |attrs|
|
||||
attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,7 @@ class AccountsController < ApplicationController
|
||||
LOCALE_OPTIONS = {
|
||||
'en-US' => 'English (United States)',
|
||||
'en-GB' => 'English (United Kingdom)',
|
||||
'fr-FR' => 'French (France)',
|
||||
'es-ES' => 'Spanish (Spain)',
|
||||
'pt-PT' => 'Portuguese (Portugal)',
|
||||
'de-DE' => 'German (Germany)'
|
||||
|
||||
@@ -8,17 +8,29 @@ module Api
|
||||
DEFAULT_LIMIT = 10
|
||||
MAX_LIMIT = 100
|
||||
|
||||
impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
|
||||
|
||||
wrap_parameters false
|
||||
|
||||
before_action :authenticate_user!
|
||||
check_authorization
|
||||
|
||||
rescue_from Params::BaseValidator::InvalidParameterError do |e|
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if Rails.env.production?
|
||||
rescue_from CanCan::AccessDenied do |e|
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :forbidden
|
||||
end
|
||||
|
||||
rescue_from JSON::ParserError do |e|
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: "JSON parse error: #{e.message}" }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
@@ -27,12 +39,23 @@ module Api
|
||||
result = relation.order(id: :desc)
|
||||
.limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min)
|
||||
|
||||
result = result.where('id < ?', params[:after]) if params[:after].present?
|
||||
result = result.where('id > ?', params[:before]) if params[:before].present?
|
||||
result = result.where(relation.arel_table[:id].lt(params[:after])) if params[:after].present?
|
||||
result = result.where(relation.arel_table[:id].gt(params[:before])) if params[:before].present?
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def authenticate_user!
|
||||
@current_user ||=
|
||||
if request.headers['X-Auth-Token'].present?
|
||||
sha256 = Digest::SHA256.hexdigest(request.headers['X-Auth-Token'])
|
||||
|
||||
User.joins(:access_token).active.find_by(access_token: { sha256: })
|
||||
end
|
||||
|
||||
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
|
||||
end
|
||||
|
||||
def current_account
|
||||
current_user&.account
|
||||
end
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
module Api
|
||||
class SubmissionsController < ApiBaseController
|
||||
load_and_authorize_resource :template, only: :create
|
||||
load_and_authorize_resource :submission, only: %i[show index]
|
||||
load_and_authorize_resource :submission, only: %i[show index destroy]
|
||||
|
||||
before_action only: :create do
|
||||
authorize!(:create, Submission)
|
||||
@@ -13,6 +13,10 @@ module Api
|
||||
submissions = Submissions.search(@submissions, params[:q])
|
||||
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
|
||||
|
||||
if params[:template_folder].present?
|
||||
submissions = submissions.joins(template: :folder).where(folder: { name: params[:template_folder] })
|
||||
end
|
||||
|
||||
submissions = paginate(submissions.preload(:created_by_user, :template, :submitters,
|
||||
audit_trail_attachment: :blob))
|
||||
|
||||
@@ -27,73 +31,102 @@ module Api
|
||||
end
|
||||
|
||||
def show
|
||||
serialized_subbmitters =
|
||||
@submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob).map do |submitter|
|
||||
Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at?
|
||||
submitters = @submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob)
|
||||
|
||||
Submitters::SerializeForApi.call(submitter)
|
||||
end
|
||||
serialized_submitters = submitters.map do |submitter|
|
||||
Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at?
|
||||
|
||||
Submitters::SerializeForApi.call(submitter)
|
||||
end
|
||||
|
||||
json = @submission.as_json(
|
||||
serialize_params.deep_merge(
|
||||
include: {
|
||||
submission_events: {
|
||||
only: %i[id submitter_id event_type event_timestamp]
|
||||
}
|
||||
}
|
||||
include: { submission_events: { only: %i[id submitter_id event_type event_timestamp] } }
|
||||
)
|
||||
)
|
||||
|
||||
json[:submitters] = serialized_subbmitters
|
||||
if submitters.all?(&:completed_at?)
|
||||
last_submitter = submitters.max_by(&:completed_at)
|
||||
|
||||
json[:documents] = serialized_submitters.find { |e| e['id'] == last_submitter.id }['documents']
|
||||
json[:status] = 'completed'
|
||||
json[:completed_at] = last_submitter.completed_at
|
||||
else
|
||||
json[:documents] = []
|
||||
json[:status] = 'pending'
|
||||
json[:completed_at] = nil
|
||||
end
|
||||
|
||||
json[:submitters] = serialized_submitters
|
||||
|
||||
render json:
|
||||
end
|
||||
|
||||
def create
|
||||
is_send_email = !params[:send_email].in?(['false', false])
|
||||
Params::SubmissionCreateValidator.call(params, dry_run: true)
|
||||
|
||||
submissions =
|
||||
if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
|
||||
Submissions.create_from_emails(template: @template,
|
||||
user: current_user,
|
||||
source: :api,
|
||||
mark_as_sent: is_send_email,
|
||||
emails:)
|
||||
else
|
||||
submissions_attrs, attachments = normalize_submissions_params!(submissions_params[:submission], @template)
|
||||
return render json: { error: 'Template not found' }, status: :unprocessable_entity if @template.nil?
|
||||
|
||||
Submissions.create_from_submitters(
|
||||
template: @template,
|
||||
user: current_user,
|
||||
source: :api,
|
||||
mark_as_sent: is_send_email,
|
||||
submitters_order: params[:submitters_order] || 'preserved',
|
||||
submissions_attrs:
|
||||
)
|
||||
end
|
||||
if @template.fields.blank?
|
||||
Rollbar.error("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
|
||||
|
||||
Submissions.send_signature_requests(submissions, send_email: is_send_email)
|
||||
return render json: { error: 'Template does not contain fields' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
submitters = submissions.flat_map(&:submitters)
|
||||
params[:send_email] = true unless params.key?(:send_email)
|
||||
params[:send_sms] = false unless params.key?(:send_sms)
|
||||
|
||||
save_default_value_attachments!(attachments, submitters)
|
||||
submissions = create_submissions(@template, params)
|
||||
|
||||
render json: submitters
|
||||
Submissions.send_signature_requests(submissions)
|
||||
|
||||
render json: submissions.flat_map(&:submitters)
|
||||
rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
@submission.update!(deleted_at: Time.current)
|
||||
@submission.update!(archived_at: Time.current)
|
||||
|
||||
render json: @submission.as_json(only: %i[id deleted_at])
|
||||
render json: @submission.as_json(only: %i[id], methods: %i[archived_at])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_submissions(template, params)
|
||||
is_send_email = !params[:send_email].in?(['false', false])
|
||||
|
||||
if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
|
||||
Submissions.create_from_emails(template:,
|
||||
user: current_user,
|
||||
source: :api,
|
||||
mark_as_sent: is_send_email,
|
||||
emails:,
|
||||
params:)
|
||||
else
|
||||
submissions_attrs, attachments =
|
||||
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template)
|
||||
|
||||
submissions = Submissions.create_from_submitters(
|
||||
template:,
|
||||
user: current_user,
|
||||
source: :api,
|
||||
mark_as_sent: is_send_email,
|
||||
submitters_order: params[:submitters_order] || params[:order] || 'preserved',
|
||||
submissions_attrs:,
|
||||
params:
|
||||
)
|
||||
|
||||
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments,
|
||||
submissions.flat_map(&:submitters))
|
||||
|
||||
submissions
|
||||
end
|
||||
end
|
||||
|
||||
def serialize_params
|
||||
{
|
||||
only: %i[id source submitters_order created_at updated_at],
|
||||
only: %i[id source submitters_order created_at updated_at archived_at],
|
||||
methods: %i[audit_log_url],
|
||||
include: {
|
||||
submitters: { only: %i[id slug uuid name email phone
|
||||
@@ -107,54 +140,20 @@ module Api
|
||||
end
|
||||
|
||||
def submissions_params
|
||||
params.permit(submission: [{
|
||||
submitters: [[:uuid, :name, :email, :role, :completed, :phone, :application_key,
|
||||
{ values: {}, readonly_fields: [],
|
||||
fields: [%i[name default_value readonly validation_pattern invalid_message]] }]]
|
||||
}])
|
||||
end
|
||||
key = params.key?(:submission) ? :submission : :submissions
|
||||
|
||||
def normalize_submissions_params!(submissions_params, template)
|
||||
attachments = []
|
||||
|
||||
Array.wrap(submissions_params).each do |submission|
|
||||
submission[:submitters].each_with_index do |submitter, index|
|
||||
default_values = submitter[:values] || {}
|
||||
|
||||
submitter[:fields]&.each { |f| default_values[f[:name]] = f[:default_value] if f[:default_value].present? }
|
||||
|
||||
next if default_values.blank?
|
||||
|
||||
values, new_attachments =
|
||||
Submitters::NormalizeValues.call(template,
|
||||
default_values,
|
||||
submitter[:role] || template.submitters[index]['name'])
|
||||
|
||||
attachments.push(*new_attachments)
|
||||
|
||||
submitter[:values] = values
|
||||
end
|
||||
end
|
||||
|
||||
[submissions_params, attachments]
|
||||
end
|
||||
|
||||
def save_default_value_attachments!(attachments, submitters)
|
||||
return if attachments.blank?
|
||||
|
||||
attachments_index = attachments.index_by(&:uuid)
|
||||
|
||||
submitters.each do |submitter|
|
||||
submitter.values.to_a.each do |_, value|
|
||||
attachment = attachments_index[value]
|
||||
|
||||
next unless attachment
|
||||
|
||||
attachment.record = submitter
|
||||
|
||||
attachment.save!
|
||||
end
|
||||
end
|
||||
params.permit(
|
||||
key => [
|
||||
[:send_email, :send_sms, :bcc_completed, :completed_redirect_url, {
|
||||
message: %i[subject body],
|
||||
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
|
||||
:completed, :phone, :application_key,
|
||||
{ values: {}, readonly_fields: [], message: %i[subject body],
|
||||
fields: [%i[name default_value title description
|
||||
readonly validation_pattern invalid_message]] }]]
|
||||
}]
|
||||
]
|
||||
).fetch(key, [])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,5 +30,87 @@ module Api
|
||||
|
||||
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true)
|
||||
end
|
||||
|
||||
def update
|
||||
if @submitter.completed_at?
|
||||
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
role = @submitter.submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
|
||||
|
||||
normalized_params, new_attachments =
|
||||
Submissions::NormalizeParamUtils.normalize_submitter_params!(submitter_params.merge(role:), @submitter.template,
|
||||
for_submitter: @submitter)
|
||||
|
||||
Submissions::CreateFromSubmitters.maybe_set_template_fields(@submitter.submission,
|
||||
[normalized_params],
|
||||
submitter_uuid: @submitter.uuid)
|
||||
|
||||
assign_submitter_attrs(@submitter, normalized_params)
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
Submissions::NormalizeParamUtils.save_default_value_attachments!(new_attachments, [@submitter])
|
||||
|
||||
@submitter.save!
|
||||
|
||||
@submitter.submission.save!
|
||||
end
|
||||
|
||||
if @submitter.completed_at?
|
||||
ProcessSubmitterCompletionJob.perform_later(@submitter)
|
||||
elsif normalized_params[:send_email] || normalized_params[:send_sms]
|
||||
Submitters.send_signature_requests([@submitter])
|
||||
end
|
||||
|
||||
render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_events: false)
|
||||
end
|
||||
|
||||
def submitter_params
|
||||
submitter_params = params.key?(:submitter) ? params.require(:submitter) : params
|
||||
|
||||
submitter_params.permit(
|
||||
:send_email, :send_sms, :uuid, :name, :email, :role, :completed, :phone, :application_key,
|
||||
{ values: {}, readonly_fields: [], message: %i[subject body],
|
||||
fields: [%i[name default_value readonly validation_pattern invalid_message]] }
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assign_submitter_attrs(submitter, attrs)
|
||||
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
|
||||
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
|
||||
submitter.values = submitter.values.merge(attrs[:values].to_unsafe_h) if attrs[:values].present?
|
||||
submitter.completed_at = attrs[:completed] ? Time.current : submitter.completed_at
|
||||
submitter.application_key = attrs[:application_key] if attrs.key?(:application_key)
|
||||
|
||||
assign_submission_fields(submitter.submission)
|
||||
assign_preferences(submitter, attrs)
|
||||
|
||||
submitter
|
||||
end
|
||||
|
||||
def assign_submission_fields(submission)
|
||||
submission.template_fields ||= submission.template.fields
|
||||
submission.template_schema ||= submission.template.schema
|
||||
end
|
||||
|
||||
def assign_preferences(submitter, attrs)
|
||||
submitter_preferences = Submitters.normalize_preferences(submitter.account, current_user, attrs)
|
||||
|
||||
submitter.preferences['default_values'] = attrs[:values].to_unsafe_h if attrs[:values].present?
|
||||
|
||||
if submitter_preferences.key?('send_email')
|
||||
submitter.preferences['send_email'] = submitter_preferences['send_email']
|
||||
end
|
||||
|
||||
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
|
||||
|
||||
return unless submitter_preferences.key?('email_message_uuid')
|
||||
|
||||
submitter.preferences['email_message_uuid'] = submitter_preferences['email_message_uuid']
|
||||
|
||||
submitter
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,10 +7,10 @@ module Api
|
||||
LIMIT = 100
|
||||
|
||||
def index
|
||||
template_folders = @template_folders.joins(:templates).where(templates: { deleted_at: nil }).distinct
|
||||
template_folders = @template_folders.joins(:templates).where(templates: { archived_at: nil }).distinct
|
||||
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT)
|
||||
|
||||
render json: template_folders.as_json(only: %i[name deleted_at])
|
||||
render json: template_folders.as_json(only: %i[name archived_at])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class TemplatesCloneController < ApiBaseController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
authorize!(:manage, @template)
|
||||
|
||||
cloned_template = Templates::Clone.call(@template,
|
||||
author: current_user,
|
||||
name: params[:name],
|
||||
application_key: params[:application_key],
|
||||
folder_name: params[:folder_name])
|
||||
|
||||
cloned_template.source = :api
|
||||
cloned_template.save!
|
||||
|
||||
Templates::CloneAttachments.call(template: cloned_template, original_template: @template)
|
||||
|
||||
render json: cloned_template.as_json(serialize_params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialize_params
|
||||
{
|
||||
include: { author: { only: %i[id email first_name last_name] },
|
||||
documents: { only: %i[id uuid], methods: %i[url filename] } }
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,6 +9,7 @@ module Api
|
||||
|
||||
templates = params[:archived] ? templates.archived : templates.active
|
||||
templates = templates.where(application_key: params[:application_key]) if params[:application_key].present?
|
||||
templates = templates.joins(:folder).where(folder: { name: params[:folder] }) if params[:folder].present?
|
||||
|
||||
templates = paginate(templates.preload(:author, documents_attachments: :blob))
|
||||
|
||||
@@ -37,9 +38,13 @@ module Api
|
||||
end
|
||||
|
||||
def destroy
|
||||
@template.update!(deleted_at: Time.current)
|
||||
if params[:permanently] == 'true' && !Docuseal.multitenant?
|
||||
@template.destroy!
|
||||
else
|
||||
@template.update!(archived_at: Time.current)
|
||||
end
|
||||
|
||||
render json: @template.as_json(only: %i[id deleted_at])
|
||||
render json: @template.as_json(only: %i[id archived_at])
|
||||
end
|
||||
|
||||
private
|
||||
@@ -57,7 +62,9 @@ module Api
|
||||
schema: [%i[attachment_uuid name]],
|
||||
submitters: [%i[name uuid]],
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
|
||||
{ options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]]
|
||||
{ preferences: {},
|
||||
options: [%i[value uuid]],
|
||||
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,6 +22,8 @@ module Api
|
||||
}
|
||||
)
|
||||
}
|
||||
rescue Templates::CreateAttachments::PdfEncrypted
|
||||
render json: { error: 'PDF encrypted' }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,4 +4,14 @@ class ApiSettingsController < ApplicationController
|
||||
def index
|
||||
authorize!(:read, current_user.access_token)
|
||||
end
|
||||
|
||||
def create
|
||||
authorize!(:manage, current_user.access_token)
|
||||
|
||||
current_user.access_token.token = SecureRandom.base58(AccessToken::TOKEN_LENGTH)
|
||||
|
||||
current_user.access_token.save!
|
||||
|
||||
redirect_back(fallback_location: settings_api_index_path, notice: 'API token as been updated.')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ApplicationController < ActionController::Base
|
||||
BROWSER_LOCALE_REGEXP = /\A\w{2}(?:-\w{2})?/
|
||||
|
||||
include ActiveStorage::SetCurrent
|
||||
include Pagy::Backend
|
||||
|
||||
@@ -14,6 +16,8 @@ class ApplicationController < ActionController::Base
|
||||
:current_account,
|
||||
:svg_icon
|
||||
|
||||
impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
|
||||
|
||||
rescue_from Pagy::OverflowError do
|
||||
redirect_to request.path
|
||||
end
|
||||
@@ -22,7 +26,7 @@ class ApplicationController < ActionController::Base
|
||||
rescue_from CanCan::AccessDenied do |e|
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
redirect_back(fallback_location: root_path, alert: e.message)
|
||||
redirect_to root_path, alert: e.message
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,8 +34,32 @@ class ApplicationController < ActionController::Base
|
||||
Docuseal.default_url_options
|
||||
end
|
||||
|
||||
def impersonate_user(user)
|
||||
raise ArgumentError unless user
|
||||
raise Pretender::Error unless true_user
|
||||
|
||||
@impersonated_user = user
|
||||
|
||||
request.session[:impersonated_user_id] = user.uuid
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def with_browser_locale(&)
|
||||
locale = request.env['HTTP_ACCEPT_LANGUAGE'].to_s[BROWSER_LOCALE_REGEXP].to_s
|
||||
|
||||
locale =
|
||||
if locale.starts_with?('en-') && locale != 'en-US'
|
||||
'en-GB'
|
||||
else
|
||||
locale.split('-').first.presence || 'en-GB'
|
||||
end
|
||||
|
||||
locale = 'en-GB' unless I18n.locale_available?(locale)
|
||||
|
||||
I18n.with_locale(locale, &)
|
||||
end
|
||||
|
||||
def sign_in_for_demo
|
||||
sign_in(User.active.order('random()').take) unless signed_in?
|
||||
end
|
||||
@@ -44,8 +72,9 @@ class ApplicationController < ActionController::Base
|
||||
redirect_to setup_index_path unless User.exists?
|
||||
end
|
||||
|
||||
def button_title(title: 'Submit', disabled_with: 'Submitting', icon: nil, icon_disabled: nil)
|
||||
render_to_string(partial: 'shared/button_title', locals: { title:, disabled_with:, icon:, icon_disabled: })
|
||||
def button_title(title: 'Submit', disabled_with: 'Submitting', title_class: '', icon: nil, icon_disabled: nil)
|
||||
render_to_string(partial: 'shared/button_title',
|
||||
locals: { title:, disabled_with:, title_class:, icon:, icon_disabled: })
|
||||
end
|
||||
|
||||
def svg_icon(icon_name, class: '')
|
||||
|
||||
@@ -5,9 +5,9 @@ class ConsoleRedirectController < ApplicationController
|
||||
skip_authorization_check
|
||||
|
||||
def index
|
||||
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if current_user.blank?
|
||||
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank?
|
||||
|
||||
auth = JsonWebToken.encode(uuid: current_user.uuid,
|
||||
auth = JsonWebToken.encode(uuid: true_user.uuid,
|
||||
scope: :console,
|
||||
exp: 1.minute.from_now.to_i)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ class DashboardController < ApplicationController
|
||||
|
||||
before_action :maybe_redirect_product_url
|
||||
before_action :maybe_render_landing
|
||||
before_action :maybe_redirect_mfa_setup
|
||||
|
||||
load_and_authorize_resource :template_folder, parent: false
|
||||
load_and_authorize_resource :template, parent: false
|
||||
@@ -62,6 +63,17 @@ class DashboardController < ApplicationController
|
||||
redirect_to Docuseal::PRODUCT_URL, allow_other_host: true
|
||||
end
|
||||
|
||||
def maybe_redirect_mfa_setup
|
||||
return unless signed_in?
|
||||
return if current_user.otp_required_for_login
|
||||
|
||||
return if !current_user.otp_required_for_login && !AccountConfig.exists?(value: true,
|
||||
account_id: current_user.account_id,
|
||||
key: AccountConfig::FORCE_MFA)
|
||||
|
||||
redirect_to mfa_setup_path, notice: 'Setup 2FA to continue'
|
||||
end
|
||||
|
||||
def maybe_render_landing
|
||||
return if signed_in?
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ErrorsController < ActionController::Base
|
||||
ENTERPRISE_FEATURE_MESSAGE =
|
||||
'This feature is available in Pro Edition: https://www.docuseal.co/pricing'
|
||||
|
||||
ENTERPRISE_PATHS = [
|
||||
'/templates/html',
|
||||
'/api/templates/html',
|
||||
'/templates/pdf',
|
||||
'/api/templates/pdf',
|
||||
'/templates/doc',
|
||||
'/api/templates/doc',
|
||||
'/templates/docx',
|
||||
'/api/templates/docx'
|
||||
].freeze
|
||||
|
||||
def show
|
||||
if request.original_fullpath.in?(ENTERPRISE_PATHS) && error_status_code == 404
|
||||
return render json: { status: 404, message: ENTERPRISE_FEATURE_MESSAGE }, status: :not_found
|
||||
end
|
||||
|
||||
respond_to do |f|
|
||||
f.json do
|
||||
render json: { status: error_status_code }, status: error_status_code
|
||||
end
|
||||
|
||||
f.html { render error_status_code.to_s, status: error_status_code }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def error_status_code
|
||||
@error_status_code ||=
|
||||
ActionDispatch::ExceptionWrapper.new(request.env,
|
||||
request.env['action_dispatch.exception']).status_code
|
||||
end
|
||||
end
|
||||
@@ -5,13 +5,11 @@ class MfaSetupController < ApplicationController
|
||||
authorize!(:update, current_user)
|
||||
end
|
||||
|
||||
def new
|
||||
current_user.otp_secret ||= User.generate_otp_secret
|
||||
before_action :set_provision_url, only: %i[show new]
|
||||
|
||||
current_user.save!
|
||||
def show; end
|
||||
|
||||
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
|
||||
end
|
||||
def new; end
|
||||
|
||||
def edit; end
|
||||
|
||||
@@ -26,7 +24,7 @@ class MfaSetupController < ApplicationController
|
||||
|
||||
@error_message = 'Code is invalid'
|
||||
|
||||
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/new'), status: :unprocessable_entity
|
||||
render turbo_stream: turbo_stream.replace(:mfa_form, partial: 'mfa_setup/form'), status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -41,4 +39,16 @@ class MfaSetupController < ApplicationController
|
||||
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_provision_url
|
||||
return redirect_to root_path, alert: '2FA has been set up already' if current_user.otp_required_for_login
|
||||
|
||||
current_user.otp_secret ||= User.generate_otp_secret
|
||||
|
||||
current_user.save!
|
||||
|
||||
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ class NotificationsSettingsController < ApplicationController
|
||||
def index; end
|
||||
|
||||
def create
|
||||
if @account_config.save
|
||||
if @account_config.value.present? ? @account_config.save : @account_config.delete
|
||||
redirect_back fallback_location: settings_notifications_path, notice: 'Changes have been saved'
|
||||
else
|
||||
redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save'
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
||||
def google_oauth2
|
||||
@user = Users.from_omniauth(request.env['omniauth.auth'])
|
||||
|
||||
if @user.persisted?
|
||||
flash[:notice] = I18n.t('devise.omniauth_callbacks.success', kind: 'Google')
|
||||
|
||||
sign_in_and_redirect @user, event: :authentication
|
||||
else
|
||||
redirect_to new_registration_path(oauth_callback: true, user: @user.slice(:email, :first_name, :last_name)),
|
||||
notice: 'Please complete registration with Google auth'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,18 +7,28 @@ class PersonalizationSettingsController < ApplicationController
|
||||
|
||||
def create
|
||||
account_config =
|
||||
current_account.account_configs.find_or_initialize_by(key: encrypted_config_params[:key])
|
||||
current_account.account_configs.find_or_initialize_by(key: account_config_params[:key])
|
||||
|
||||
authorize!(:create, account_config)
|
||||
|
||||
account_config.update!(encrypted_config_params)
|
||||
account_config.update!(account_config_params)
|
||||
|
||||
redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def encrypted_config_params
|
||||
params.require(:account_config).permit!
|
||||
def account_config_params
|
||||
attrs = params.require(:account_config).permit!
|
||||
|
||||
attrs[:value]&.transform_values! do |value|
|
||||
if value.in?(%w[true false])
|
||||
value == 'true'
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
attrs
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,7 +23,7 @@ class PreviewDocumentPageController < ActionController::API
|
||||
|
||||
io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
|
||||
|
||||
render plain: io.tap(&:rewind).read
|
||||
render plain: io.tap(&:rewind).read, content_type: 'image/jpeg'
|
||||
end
|
||||
|
||||
def find_or_create_document_tempfile_path(attachment)
|
||||
|
||||
@@ -6,6 +6,7 @@ class StartFormController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
around_action :with_browser_locale, only: %i[show completed]
|
||||
before_action :load_template
|
||||
|
||||
def show
|
||||
@@ -13,22 +14,21 @@ class StartFormController < ApplicationController
|
||||
end
|
||||
|
||||
def update
|
||||
@submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil))
|
||||
@submitter = Submitter.where(submission: @template.submissions.where(archived_at: nil))
|
||||
.order(id: :desc)
|
||||
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
|
||||
.find_or_initialize_by(**submitter_params.compact_blank)
|
||||
|
||||
if @submitter.completed_at?
|
||||
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
|
||||
else
|
||||
@submitter.assign_attributes(
|
||||
uuid: @template.submitters.first['uuid'],
|
||||
ip: request.remote_ip,
|
||||
ua: request.user_agent
|
||||
)
|
||||
if @template.submitters.to_a.size > 1 && @submitter.new_record?
|
||||
@error_message = 'Not found'
|
||||
|
||||
@submitter.submission ||= Submission.new(template: @template,
|
||||
template_submitters: @template.submitters,
|
||||
source: :link)
|
||||
return render :show
|
||||
end
|
||||
|
||||
assign_submission_attributes(@submitter, @template) if @submitter.new_record?
|
||||
|
||||
if @submitter.save
|
||||
redirect_to submit_form_path(@submitter.slug)
|
||||
@@ -46,6 +46,33 @@ class StartFormController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def assign_submission_attributes(submitter, template)
|
||||
resubmit_submitter =
|
||||
if params[:resubmit].present?
|
||||
Submitter.where(submission: @template.submissions).find_by(slug: params[:resubmit])
|
||||
end
|
||||
|
||||
submitter.assign_attributes(
|
||||
uuid: template.submitters.first['uuid'],
|
||||
ip: request.remote_ip,
|
||||
ua: request.user_agent,
|
||||
values: resubmit_submitter&.preferences&.fetch('default_values', nil) || {},
|
||||
preferences: resubmit_submitter&.preferences.presence || { 'send_email' => true }
|
||||
)
|
||||
|
||||
if submitter.values.present?
|
||||
resubmit_submitter.attachments.each do |attachment|
|
||||
submitter.attachments << attachment.dup if submitter.values.value?(attachment.uuid)
|
||||
end
|
||||
end
|
||||
|
||||
submitter.submission ||= Submission.new(template:,
|
||||
template_submitters: template.submitters,
|
||||
source: :link)
|
||||
|
||||
submitter
|
||||
end
|
||||
|
||||
def submitter_params
|
||||
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
|
||||
attrs[:email] = Submissions.normalize_email(attrs[:email])
|
||||
|
||||
@@ -34,29 +34,36 @@ class SubmissionsController < ApplicationController
|
||||
def create
|
||||
authorize!(:create, Submission)
|
||||
|
||||
if params[:is_custom_message] != '1'
|
||||
params.delete(:subject)
|
||||
params.delete(:body)
|
||||
end
|
||||
|
||||
submissions =
|
||||
if params[:emails].present?
|
||||
Submissions.create_from_emails(template: @template,
|
||||
user: current_user,
|
||||
source: :invite,
|
||||
mark_as_sent: params[:send_email] == '1',
|
||||
emails: params[:emails])
|
||||
emails: params[:emails],
|
||||
params: params.merge('send_completed_email' => true))
|
||||
else
|
||||
Submissions.create_from_submitters(template: @template,
|
||||
user: current_user,
|
||||
source: :invite,
|
||||
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
|
||||
mark_as_sent: params[:send_email] == '1',
|
||||
submissions_attrs: submissions_params[:submission].to_h.values)
|
||||
submissions_attrs: submissions_params[:submission].to_h.values,
|
||||
params: params.merge('send_completed_email' => true))
|
||||
end
|
||||
|
||||
Submissions.send_signature_requests(submissions, params)
|
||||
Submissions.send_signature_requests(submissions)
|
||||
|
||||
redirect_to template_path(@template), notice: 'New recipients have been added'
|
||||
end
|
||||
|
||||
def destroy
|
||||
@submission.update!(deleted_at: Time.current)
|
||||
@submission.update!(archived_at: Time.current)
|
||||
|
||||
redirect_back(fallback_location: template_path(@submission.template), notice: 'Submission has been archived')
|
||||
end
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmissionsPreviewController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
PRELOAD_ALL_PAGES_AMOUNT = 200
|
||||
|
||||
def show
|
||||
@submission = Submission.find_by!(slug: params[:slug])
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@submission],
|
||||
associations: [:template, { template_schema_documents: :blob }]
|
||||
).call
|
||||
|
||||
total_pages =
|
||||
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
|
||||
|
||||
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: @submission.template_schema_documents,
|
||||
associations: [:blob, { preview_images_attachments: :blob }]
|
||||
).call
|
||||
end
|
||||
|
||||
render 'submissions/show', layout: 'plain'
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@
|
||||
class SubmitFormController < ApplicationController
|
||||
layout 'form'
|
||||
|
||||
around_action :with_browser_locale, only: %i[show completed success]
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
@@ -32,12 +33,22 @@ class SubmitFormController < ApplicationController
|
||||
|
||||
cookies[:submitter_sid] = @submitter.signed_id
|
||||
|
||||
render @submitter.submission.template.deleted_at? ? :archived : :show
|
||||
render(@submitter.submission.template.archived_at? || @submitter.submission.archived_at? ? :archived : :show)
|
||||
end
|
||||
|
||||
def update
|
||||
submitter = Submitter.find_by!(slug: params[:slug])
|
||||
|
||||
if submitter.completed_at?
|
||||
return render json: { error: 'Form has been completed already.' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if submitter.template.archived_at? || submitter.submission.archived_at?
|
||||
Rollbar.info("Archived template: #{submitter.template.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: 'Form has been archived.' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
Submitters::SubmitValues.call(submitter, params, request)
|
||||
|
||||
head :ok
|
||||
@@ -48,4 +59,6 @@ class SubmitFormController < ApplicationController
|
||||
def completed
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
end
|
||||
|
||||
def success; end
|
||||
end
|
||||
|
||||
@@ -4,9 +4,14 @@ class SubmittersSendEmailController < ApplicationController
|
||||
load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug
|
||||
|
||||
def create
|
||||
SubmitterMailer.invitation_email(@submitter).deliver_later!
|
||||
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
|
||||
event_type: 'send_email',
|
||||
created_at: 24.hours.ago..Time.current)
|
||||
return redirect_back(fallback_location: submission_path(@submitter.submission),
|
||||
alert: 'Email has been sent already.')
|
||||
end
|
||||
|
||||
SubmissionEvent.create!(submitter: @submitter, event_type: 'send_email')
|
||||
SendSubmitterInvitationEmailJob.perform_later('submitter_id' => @submitter.id)
|
||||
|
||||
@submitter.sent_at ||= Time.current
|
||||
@submitter.save!
|
||||
|
||||
@@ -4,7 +4,7 @@ class TemplatesArchivedController < ApplicationController
|
||||
load_and_authorize_resource :template, parent: false
|
||||
|
||||
def index
|
||||
@templates = @templates.where.not(deleted_at: nil).preload(:author, :folder).order(id: :desc)
|
||||
@templates = @templates.where.not(archived_at: nil).preload(:author, :folder).order(id: :desc)
|
||||
@templates = Templates.search(@templates, params[:q])
|
||||
|
||||
@pagy, @templates = pagy(@templates, items: 12)
|
||||
|
||||
@@ -5,7 +5,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController
|
||||
load_and_authorize_resource :submission, through: :template, parent: false
|
||||
|
||||
def index
|
||||
@submissions = @submissions.where.not(deleted_at: nil)
|
||||
@submissions = @submissions.where.not(archived_at: nil)
|
||||
@submissions = Submissions.search(@submissions, params[:q])
|
||||
|
||||
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
|
||||
|
||||
@@ -7,7 +7,7 @@ class TemplatesController < ApplicationController
|
||||
|
||||
def show
|
||||
submissions = @template.submissions
|
||||
submissions = submissions.active if @template.deleted_at.blank?
|
||||
submissions = submissions.active if @template.archived_at.blank?
|
||||
submissions = Submissions.search(submissions, params[:q])
|
||||
|
||||
@base_submissions = submissions
|
||||
@@ -42,10 +42,15 @@ class TemplatesController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
@template.account = current_account
|
||||
@template.author = current_user
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
||||
@template.assign_attributes(@base_template.slice(:fields, :schema, :submitters)) if @base_template
|
||||
if @base_template
|
||||
@template = Templates::Clone.call(@base_template, author: current_user,
|
||||
name: params.dig(:template, :name),
|
||||
folder_name: params[:folder_name])
|
||||
else
|
||||
@template.account = current_account
|
||||
@template.author = current_user
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
||||
end
|
||||
|
||||
if @template.save
|
||||
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
|
||||
@@ -58,12 +63,14 @@ class TemplatesController < ApplicationController
|
||||
|
||||
def destroy
|
||||
notice =
|
||||
if !Docuseal.multitenant? && params[:permanently].present?
|
||||
if params[:permanently].present?
|
||||
@template.destroy!
|
||||
|
||||
Rollbar.info("Remove template: #{@template.id}") if defined?(Rollbar)
|
||||
|
||||
'Template has been removed.'
|
||||
else
|
||||
@template.update!(deleted_at: Time.current)
|
||||
@template.update!(archived_at: Time.current)
|
||||
|
||||
'Template has been archived.'
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ class TemplatesRestoreController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
@template.update!(deleted_at: nil)
|
||||
@template.update!(archived_at: nil)
|
||||
|
||||
redirect_to template_path(@template), notice: 'Template has been unarchived'
|
||||
end
|
||||
|
||||
@@ -10,12 +10,7 @@ class TemplatesUploadsController < ApplicationController
|
||||
def create
|
||||
url_params = create_file_params_from_url if params[:url].present?
|
||||
|
||||
@template.account = current_account
|
||||
@template.author = current_user
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
||||
@template.name = File.basename((url_params || params)[:files].first.original_filename, '.*')
|
||||
|
||||
@template.save!
|
||||
save_template!(@template, url_params)
|
||||
|
||||
documents = Templates::CreateAttachments.call(@template, url_params || params)
|
||||
|
||||
@@ -24,6 +19,8 @@ class TemplatesUploadsController < ApplicationController
|
||||
@template.update!(schema:)
|
||||
|
||||
redirect_to edit_template_path(@template)
|
||||
rescue Templates::CreateAttachments::PdfEncrypted
|
||||
render turbo_stream: turbo_stream.append(params[:form_id], html: helpers.tag.prompt_password)
|
||||
rescue StandardError => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
@@ -32,10 +29,21 @@ class TemplatesUploadsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def save_template!(template, url_params)
|
||||
template.account = current_account
|
||||
template.author = current_user
|
||||
template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
||||
template.name = File.basename((url_params || params)[:files].first.original_filename, '.*')
|
||||
|
||||
template.save!
|
||||
|
||||
template
|
||||
end
|
||||
|
||||
def create_file_params_from_url
|
||||
tempfile = Tempfile.new
|
||||
tempfile.binmode
|
||||
tempfile.write(conn.get(params[:url]).body)
|
||||
tempfile.write(conn.get(Addressable::URI.parse(params[:url]).display_uri.to_s).body)
|
||||
tempfile.rewind
|
||||
|
||||
file = ActionDispatch::Http::UploadedFile.new(
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TestingAccountsController < ApplicationController
|
||||
skip_authorization_check only: :destroy
|
||||
|
||||
def show
|
||||
authorize!(:manage, current_account)
|
||||
authorize!(:manage, current_user)
|
||||
|
||||
impersonate_user(Accounts.find_or_create_testing_user(current_account))
|
||||
|
||||
redirect_back(fallback_location: root_path)
|
||||
end
|
||||
|
||||
def destroy
|
||||
stop_impersonating_user
|
||||
|
||||
redirect_back(fallback_location: root_path)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TestingApiSettingsController < ApplicationController
|
||||
def index
|
||||
authorize!(:manage, current_user.access_token)
|
||||
|
||||
@webhook_config =
|
||||
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
|
||||
|
||||
authorize!(:manage, @webhook_config)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TimestampServerController < ApplicationController
|
||||
before_action :build_encrypted_config
|
||||
authorize_resource :encrypted_config
|
||||
|
||||
def create
|
||||
return head :not_found if Docuseal.multitenant?
|
||||
|
||||
test_timeserver_url(@encrypted_config.value) if @encrypted_config.value.present?
|
||||
|
||||
if @encrypted_config.value.present? ? @encrypted_config.save : @encrypted_config.delete
|
||||
redirect_back fallback_location: settings_notifications_path, notice: 'Changes have been saved'
|
||||
else
|
||||
redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save'
|
||||
end
|
||||
rescue HexaPDF::Error, SocketError, Submissions::TimestampHandler::TimestampError, OpenSSL::Timestamp::TimestampError
|
||||
redirect_back fallback_location: settings_notifications_path, alert: 'Invalid Timeserver'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def test_timeserver_url(url)
|
||||
pdf = HexaPDF::Document.new
|
||||
pdf.pages.add
|
||||
|
||||
pkcs = Accounts.load_signing_pkcs(current_account)
|
||||
|
||||
pdf.sign(StringIO.new,
|
||||
reason: 'Test',
|
||||
certificate: pkcs.certificate,
|
||||
key: pkcs.key,
|
||||
signature_size: 10_000,
|
||||
certificate_chain: pkcs.ca_certs || [],
|
||||
timestamp_handler: Submissions::TimestampHandler.new(tsa_url: url))
|
||||
end
|
||||
|
||||
def load_encrypted_config
|
||||
@encrypted_config
|
||||
end
|
||||
|
||||
def build_encrypted_config
|
||||
@encrypted_config =
|
||||
EncryptedConfig.find_or_initialize_by(account: current_account,
|
||||
key: EncryptedConfig::TIMESTAMP_SERVER_URL_KEY)
|
||||
|
||||
@encrypted_config.assign_attributes(encrypted_config_params)
|
||||
end
|
||||
|
||||
def encrypted_config_params
|
||||
params.require(:encrypted_config).permit(:value)
|
||||
end
|
||||
end
|
||||
@@ -28,6 +28,12 @@ class UserSignaturesController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@user_config.destroy
|
||||
|
||||
redirect_to settings_profile_index_path, notice: 'Signature has been removed'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_user_config
|
||||
|
||||
@@ -28,9 +28,9 @@ class UsersController < ApplicationController
|
||||
return redirect_to settings_users_path, notice: 'Unable to update user.' if Docuseal.demo?
|
||||
|
||||
attrs = user_params.compact_blank
|
||||
attrs.delete(:role) if User::ROLES.exclude?(attrs[:role])
|
||||
attrs.delete(:role) if !role_valid?(attrs[:role]) || current_user == @user
|
||||
|
||||
if @user.update(attrs.except(current_user == @user ? :role : nil))
|
||||
if @user.update(attrs)
|
||||
redirect_to settings_users_path, notice: 'User has been updated'
|
||||
else
|
||||
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity
|
||||
@@ -42,17 +42,21 @@ class UsersController < ApplicationController
|
||||
return redirect_to settings_users_path, notice: 'Unable to remove user'
|
||||
end
|
||||
|
||||
@user.update!(deleted_at: Time.current)
|
||||
@user.update!(archived_at: Time.current)
|
||||
|
||||
redirect_to settings_users_path, notice: 'User has been removed'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def role_valid?(role)
|
||||
User::ROLES.include?(role)
|
||||
end
|
||||
|
||||
def build_user
|
||||
@user = current_account.users.find_by(email: user_params[:email])&.tap do |user|
|
||||
user.assign_attributes(user_params)
|
||||
user.deleted_at = nil
|
||||
user.archived_at = nil
|
||||
end
|
||||
|
||||
@user ||= current_account.users.new(user_params)
|
||||
|
||||
@@ -7,7 +7,9 @@ class WebhookSettingsController < ApplicationController
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@encrypted_config.update!(encrypted_config_params)
|
||||
@encrypted_config.assign_attributes(encrypted_config_params)
|
||||
|
||||
@encrypted_config.value.present? ? @encrypted_config.save! : @encrypted_config.delete
|
||||
|
||||
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook URL has been saved.')
|
||||
end
|
||||
|
||||
@@ -19,6 +19,7 @@ import SubmittersAutocomplete from './elements/submitter_autocomplete'
|
||||
import FolderAutocomplete from './elements/folder_autocomplete'
|
||||
import SignatureForm from './elements/signature_form'
|
||||
import SubmitForm from './elements/submit_form'
|
||||
import PromptPassword from './elements/prompt_password'
|
||||
|
||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||
|
||||
@@ -51,6 +52,7 @@ window.customElements.define('submitters-autocomplete', SubmittersAutocomplete)
|
||||
window.customElements.define('folder-autocomplete', FolderAutocomplete)
|
||||
window.customElements.define('signature-form', SignatureForm)
|
||||
window.customElements.define('submit-form', SubmitForm)
|
||||
window.customElements.define('prompt-password', PromptPassword)
|
||||
|
||||
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
|
||||
document.addEventListener('turbo:submit-end', async (event) => {
|
||||
@@ -79,11 +81,14 @@ window.customElements.define('template-builder', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.appElem = document.createElement('div')
|
||||
|
||||
this.appElem.classList.add('md:h-screen')
|
||||
|
||||
this.app = createApp(TemplateBuilder, {
|
||||
template: reactive(JSON.parse(this.dataset.template)),
|
||||
backgroundColor: '#faf7f5',
|
||||
withPhone: this.dataset.withPhone === 'true',
|
||||
withLogo: this.dataset.withLogo !== 'false',
|
||||
withPayment: this.dataset.withPayment === 'true',
|
||||
acceptFileTypes: this.dataset.acceptFileTypes,
|
||||
isDirectUpload: this.dataset.isDirectUpload === 'true'
|
||||
})
|
||||
|
||||
@@ -75,16 +75,16 @@ button[disabled] .enabled {
|
||||
@apply select base-input w-full font-normal;
|
||||
}
|
||||
|
||||
.tooltip-bottom-end:before {
|
||||
transform: translateX(-95%);
|
||||
.tooltip-bottom-start:before {
|
||||
transform: translateX(-30%);
|
||||
top: var(--tooltip-offset);
|
||||
left: 100%;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.tooltip-bottom-end:after {
|
||||
transform: translateX(-25%);
|
||||
.tooltip-bottom-start:after {
|
||||
transform: translateX(-75%);
|
||||
border-color: transparent transparent var(--tooltip-color) transparent;
|
||||
top: var(--tooltip-tail-offset);
|
||||
left: 50%;
|
||||
|
||||
@@ -18,27 +18,64 @@ export default targetable(class extends HTMLElement {
|
||||
this.toggleState()
|
||||
|
||||
fetch(this.dataset.src).then((response) => response.json()).then((urls) => {
|
||||
const fileRequests = urls.map((url) => {
|
||||
return () => {
|
||||
return fetch(url).then(async (resp) => {
|
||||
const blobUrl = URL.createObjectURL(await resp.blob())
|
||||
const link = document.createElement('a')
|
||||
const isSafariIos = /iPhone|iPad|iPod/i.test(navigator.userAgent)
|
||||
|
||||
link.href = blobUrl
|
||||
link.setAttribute('download', decodeURI(url.split('/').pop()))
|
||||
if (isSafariIos && urls.length > 1) {
|
||||
this.downloadSafariIos(urls)
|
||||
} else {
|
||||
this.downloadUrls(urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
link.click()
|
||||
downloadUrls (urls) {
|
||||
const fileRequests = urls.map((url) => {
|
||||
return () => {
|
||||
return fetch(url).then(async (resp) => {
|
||||
const blobUrl = URL.createObjectURL(await resp.blob())
|
||||
const link = document.createElement('a')
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
link.href = blobUrl
|
||||
link.setAttribute('download', decodeURI(url.split('/').pop()))
|
||||
|
||||
link.click()
|
||||
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
fileRequests.reduce(
|
||||
(prevPromise, request) => prevPromise.then(() => request()),
|
||||
Promise.resolve()
|
||||
)
|
||||
|
||||
this.toggleState()
|
||||
}
|
||||
|
||||
downloadSafariIos (urls) {
|
||||
const fileRequests = urls.map((url) => {
|
||||
return fetch(url).then(async (resp) => {
|
||||
const blob = await resp.blob()
|
||||
const blobUrl = URL.createObjectURL(blob.slice(0, blob.size, 'application/octet-stream'))
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = blobUrl
|
||||
link.setAttribute('download', decodeURI(url.split('/').pop()))
|
||||
|
||||
return link
|
||||
})
|
||||
})
|
||||
|
||||
fileRequests.reduce(
|
||||
(prevPromise, request) => prevPromise.then(() => request()),
|
||||
Promise.resolve()
|
||||
)
|
||||
Promise.all(fileRequests).then((links) => {
|
||||
links.forEach((link, index) => {
|
||||
setTimeout(() => {
|
||||
link.click()
|
||||
|
||||
URL.revokeObjectURL(link.href)
|
||||
}, index * 50)
|
||||
})
|
||||
}).finally(() => {
|
||||
this.toggleState()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export default class extends HTMLElement {
|
||||
render = (item) => {
|
||||
const div = document.createElement('div')
|
||||
|
||||
div.setAttribute('dir', 'auto')
|
||||
|
||||
div.textContent = item.name
|
||||
|
||||
return div
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const input = document.createElement('input')
|
||||
|
||||
input.type = 'hidden'
|
||||
input.name = 'password'
|
||||
input.value = prompt('Enter PDF password')
|
||||
|
||||
this.form.append(input)
|
||||
|
||||
this.form.requestSubmit()
|
||||
|
||||
this.remove()
|
||||
}
|
||||
|
||||
get form () {
|
||||
return this.closest('form')
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,21 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
if (this.dataset.inputId) {
|
||||
document.getElementById(this.dataset.inputId).value = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
|
||||
if (this.dataset.params === 'true') {
|
||||
const params = new URLSearchParams(this.input.value)
|
||||
|
||||
params.set('timezone', timezone)
|
||||
|
||||
this.input.value = params.toString()
|
||||
} else {
|
||||
this.input.value = timezone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get input () {
|
||||
return document.getElementById(this.dataset.inputId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export default class extends HTMLElement {
|
||||
render = (item) => {
|
||||
const div = document.createElement('div')
|
||||
|
||||
div.setAttribute('dir', 'auto')
|
||||
|
||||
div.textContent = item[this.dataset.field]
|
||||
|
||||
return div
|
||||
|
||||
@@ -10,15 +10,16 @@ window.customElements.define('submission-form', class extends HTMLElement {
|
||||
|
||||
this.app = createApp(Form, {
|
||||
submitter: JSON.parse(this.dataset.submitter),
|
||||
authenticityToken: this.dataset.authenticityToken,
|
||||
canSendEmail: this.dataset.canSendEmail === 'true',
|
||||
isDirectUpload: this.dataset.isDirectUpload === 'true',
|
||||
goToLast: this.dataset.goToLast === 'true',
|
||||
isDemo: this.dataset.isDemo === 'true',
|
||||
attribution: this.dataset.attribution !== 'false',
|
||||
withConfetti: true,
|
||||
withTypedSignature: this.dataset.withTypedSignature !== 'false',
|
||||
values: reactive(JSON.parse(this.dataset.values)),
|
||||
completedButton: JSON.parse(this.dataset.completedButton),
|
||||
completedRedirectUrl: this.dataset.completedRedirectUrl,
|
||||
attachments: reactive(JSON.parse(this.dataset.attachments)),
|
||||
fields: JSON.parse(this.dataset.fields)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="uniqueAreas.length > 1"
|
||||
dir="auto"
|
||||
class="mb-2"
|
||||
>
|
||||
<div class="flex space-x-1 text-sm ml-1 -mt-3">
|
||||
<span>
|
||||
{{ t('appears_on') }}:
|
||||
</span>
|
||||
<a
|
||||
v-for="area in uniqueAreas"
|
||||
:key="area.uuid"
|
||||
href="#"
|
||||
class="link pr-0.5"
|
||||
@click.prevent="scrollIntoArea(area)"
|
||||
>
|
||||
{{ t('page') }} <template v-if="isMultipleDocs">{{ attachmentUuids.indexOf(area.attachment_uuid) + 1 }}-</template>{{ area.page + 1 }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'AppearsOn',
|
||||
inject: ['t', 'scrollIntoArea'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isMultipleDocs () {
|
||||
return this.attachmentUuids.length > 1
|
||||
},
|
||||
attachmentUuids () {
|
||||
return [...new Set(this.uniqueAreas.map((e) => e.attachment_uuid))]
|
||||
},
|
||||
uniqueAreas () {
|
||||
const areas = {}
|
||||
|
||||
this.field.areas?.forEach((area) => {
|
||||
areas[area.attachment_uuid + area.page] ||= area
|
||||
})
|
||||
|
||||
return Object.values(areas).slice(0, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex absolute text-[1.5vw] lg:text-base"
|
||||
class="flex absolute lg:text-base"
|
||||
dir="auto"
|
||||
:style="computedStyle"
|
||||
:class="{ 'cursor-default': !submittable, 'bg-red-100 border cursor-pointer ': submittable, 'border-red-100': !isActive && submittable, 'bg-opacity-70': !isActive && !isValueSet && submittable, 'border-red-500 border-dashed z-10': isActive && submittable, 'bg-opacity-30': (isActive || isValueSet) && submittable }"
|
||||
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'bg-red-100 border cursor-pointer ': submittable, 'border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'border-red-500 border-dashed border-2 z-10': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
|
||||
>
|
||||
<div
|
||||
v-if="!isActive && !isValueSet && field.type !== 'checkbox' && submittable"
|
||||
v-if="!isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
|
||||
class="absolute top-0 bottom-0 right-0 left-0 items-center justify-center h-full w-full"
|
||||
>
|
||||
<span
|
||||
@@ -21,15 +22,20 @@
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isActive && withLabel"
|
||||
class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap"
|
||||
v-if="isActive && withLabel && (!area.option_uuid || !option.value)"
|
||||
class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap pointer-events-none"
|
||||
>
|
||||
{{ field.name || fieldNames[field.type] }}
|
||||
<template v-if="field.type === 'checkbox' && !field.name">
|
||||
{{ fieldIndex + 1 }}
|
||||
<template v-if="area.option_uuid && !option.value">
|
||||
{{ optionValue(option) }}
|
||||
</template>
|
||||
<template v-else-if="!field.required && field.type !== 'checkbox'">
|
||||
(optional)
|
||||
<template v-else>
|
||||
{{ field.name || fieldNames[field.type] }}
|
||||
<template v-if="field.type === 'checkbox' && !field.name">
|
||||
{{ fieldIndex + 1 }}
|
||||
</template>
|
||||
<template v-else-if="!field.required && field.type !== 'checkbox'">
|
||||
(optional)
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
@@ -42,6 +48,11 @@
|
||||
class="object-contain mx-auto"
|
||||
:src="image.url"
|
||||
>
|
||||
<img
|
||||
v-else-if="field.type === 'stamp' && stamp"
|
||||
class="object-contain mx-auto"
|
||||
:src="stamp.url"
|
||||
>
|
||||
<img
|
||||
v-else-if="field.type === 'signature' && signature"
|
||||
class="object-contain mx-auto"
|
||||
@@ -53,7 +64,7 @@
|
||||
:src="initials.url"
|
||||
>
|
||||
<div
|
||||
v-else-if="field.type === 'file'"
|
||||
v-else-if="field.type === 'file' || field.type === 'payment'"
|
||||
class="px-0.5 flex flex-col justify-center"
|
||||
>
|
||||
<a
|
||||
@@ -87,6 +98,44 @@
|
||||
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="field.type === 'radio' && area.option_uuid"
|
||||
class="w-full p-[0.2vw] flex items-center justify-center"
|
||||
>
|
||||
<input
|
||||
v-if="submittable"
|
||||
type="radio"
|
||||
:value="false"
|
||||
class="aspect-square base-radio"
|
||||
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
:checked="!!modelValue && modelValue === optionValue(option)"
|
||||
@click="$emit('update:model-value', optionValue(option))"
|
||||
>
|
||||
<IconCheck
|
||||
v-else-if="!!modelValue && modelValue === optionValue(option)"
|
||||
class="aspect-square"
|
||||
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="field.type === 'multiple' && area.option_uuid"
|
||||
class="w-full p-[0.2vw] flex items-center justify-center"
|
||||
>
|
||||
<input
|
||||
v-if="submittable"
|
||||
type="checkbox"
|
||||
:value="false"
|
||||
class="aspect-square base-checkbox"
|
||||
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
:checked="!!modelValue && modelValue.includes(optionValue(option))"
|
||||
@change="updateMultipleSelectValue(optionValue(option))"
|
||||
>
|
||||
<IconCheck
|
||||
v-else-if="!!modelValue && modelValue.includes(optionValue(option))"
|
||||
class="aspect-square"
|
||||
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="field.type === 'cells'"
|
||||
class="w-full flex items-center"
|
||||
@@ -102,7 +151,9 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center px-0.5"
|
||||
ref="textContainer"
|
||||
dir="auto"
|
||||
class="flex items-center px-0.5 w-full"
|
||||
>
|
||||
<span v-if="Array.isArray(modelValue)">
|
||||
{{ modelValue.join(', ') }}
|
||||
@@ -119,7 +170,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue'
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FieldArea',
|
||||
@@ -127,6 +178,7 @@ export default {
|
||||
IconPaperclip,
|
||||
IconCheck
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -173,6 +225,11 @@ export default {
|
||||
}
|
||||
},
|
||||
emits: ['update:model-value'],
|
||||
data () {
|
||||
return {
|
||||
textOverflowChars: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames () {
|
||||
return {
|
||||
@@ -181,15 +238,20 @@ export default {
|
||||
signature: 'Signature',
|
||||
date: 'Date',
|
||||
image: 'Image',
|
||||
stamp: 'Stamp',
|
||||
initials: 'Initials',
|
||||
file: 'File',
|
||||
select: 'Select',
|
||||
checkbox: 'Checkbox',
|
||||
radio: 'Radio',
|
||||
multiple: 'Multiple Select',
|
||||
phone: 'Phone'
|
||||
phone: 'Phone',
|
||||
payment: 'Payment'
|
||||
}
|
||||
},
|
||||
option () {
|
||||
return this.field.options.find((o) => o.uuid === this.area.option_uuid)
|
||||
},
|
||||
fieldIcons () {
|
||||
return {
|
||||
text: IconTextSize,
|
||||
@@ -201,9 +263,11 @@ export default {
|
||||
select: IconSelect,
|
||||
checkbox: IconCheckbox,
|
||||
radio: IconCircleDot,
|
||||
stamp: IconRubberStamp,
|
||||
cells: IconColumns3,
|
||||
multiple: IconChecks,
|
||||
phone: IconPhoneCheck
|
||||
phone: IconPhoneCheck,
|
||||
payment: IconCreditCard
|
||||
}
|
||||
},
|
||||
image () {
|
||||
@@ -213,6 +277,13 @@ export default {
|
||||
return null
|
||||
}
|
||||
},
|
||||
stamp () {
|
||||
if (this.field.type === 'stamp') {
|
||||
return this.attachmentsIndex[this.modelValue]
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
signature () {
|
||||
if (this.field.type === 'signature') {
|
||||
return this.attachmentsIndex[this.modelValue]
|
||||
@@ -227,9 +298,15 @@ export default {
|
||||
return null
|
||||
}
|
||||
},
|
||||
locale () {
|
||||
return Intl.DateTimeFormat().resolvedOptions()?.locale
|
||||
},
|
||||
formattedDate () {
|
||||
if (this.field.type === 'date' && this.modelValue) {
|
||||
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(this.modelValue))
|
||||
return this.formatDate(
|
||||
new Date(this.modelValue),
|
||||
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
|
||||
)
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
@@ -237,6 +314,8 @@ export default {
|
||||
attachments () {
|
||||
if (this.field.type === 'file') {
|
||||
return (this.modelValue || []).map((uuid) => this.attachmentsIndex[uuid])
|
||||
} else if (this.field.type === 'payment') {
|
||||
return [this.attachmentsIndex[this.modelValue]].filter(Boolean)
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
@@ -251,6 +330,78 @@ export default {
|
||||
height: h * 100 + '%'
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
modelValue () {
|
||||
if (this.field.type === 'text' && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.modelValue.length)) {
|
||||
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.field.type === 'text' && this.$refs.textContainer) {
|
||||
this.$nextTick(() => {
|
||||
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
optionValue (option) {
|
||||
if (option) {
|
||||
if (option.value) {
|
||||
return option.value
|
||||
} else {
|
||||
const index = this.field.options.indexOf(option)
|
||||
|
||||
return `${this.t('option')} ${index + 1}`
|
||||
}
|
||||
}
|
||||
},
|
||||
formatDate (date, format) {
|
||||
const monthFormats = {
|
||||
M: 'numeric',
|
||||
MM: '2-digit',
|
||||
MMM: 'short',
|
||||
MMMM: 'long'
|
||||
}
|
||||
|
||||
const dayFormats = {
|
||||
D: 'numeric',
|
||||
DD: '2-digit'
|
||||
}
|
||||
|
||||
const yearFormats = {
|
||||
YYYY: 'numeric',
|
||||
YY: '2-digit'
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat([], {
|
||||
day: dayFormats[format.match(/D+/)],
|
||||
month: monthFormats[format.match(/M+/)],
|
||||
year: yearFormats[format.match(/Y+/)],
|
||||
timeZone: 'UTC'
|
||||
}).formatToParts(date)
|
||||
|
||||
return format
|
||||
.replace(/D+/, parts.find((p) => p.type === 'day').value)
|
||||
.replace(/M+/, parts.find((p) => p.type === 'month').value)
|
||||
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
|
||||
},
|
||||
updateMultipleSelectValue (value) {
|
||||
if (this.modelValue?.includes(value)) {
|
||||
const newValue = [...this.modelValue]
|
||||
|
||||
newValue.splice(newValue.indexOf(value), 1)
|
||||
|
||||
this.$emit('update:model-value', newValue)
|
||||
} else {
|
||||
const newValue = this.modelValue ? [...this.modelValue] : []
|
||||
|
||||
newValue.push(value)
|
||||
|
||||
this.$emit('update:model-value', newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
:key="areaIndex"
|
||||
>
|
||||
<Teleport
|
||||
v-if="findPageElementForArea(area)"
|
||||
:to="findPageElementForArea(area)"
|
||||
>
|
||||
<FieldArea
|
||||
@@ -82,22 +83,25 @@ export default {
|
||||
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${area.attachment_uuid}-${area.page}`)
|
||||
},
|
||||
scrollIntoField (field) {
|
||||
this.areaRefs.find((area) => {
|
||||
if (area.field === field) {
|
||||
const root = this.$root.$el.parentNode.getRootNode()
|
||||
const container = root.body || root.querySelector('div')
|
||||
if (field?.areas) {
|
||||
this.scrollIntoArea(field.areas[0])
|
||||
}
|
||||
},
|
||||
scrollIntoArea (area) {
|
||||
const areaRef = this.areaRefs.find((a) => a.area === area)
|
||||
|
||||
if (container.style.overflow === 'hidden') {
|
||||
this.scrollInContainer(area.$el)
|
||||
} else {
|
||||
area.$refs.scrollToElem.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
if (areaRef) {
|
||||
const root = this.$root.$el.parentNode.getRootNode()
|
||||
const container = root.body || root.querySelector('div')
|
||||
|
||||
return true
|
||||
if (container.style.overflow === 'hidden') {
|
||||
this.scrollInContainer(areaRef.$el)
|
||||
} else {
|
||||
return null
|
||||
areaRef.$refs.scrollToElem.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
scrollInContainer (target) {
|
||||
const root = this.$root.$el.parentNode.getRootNode()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="modelValue.length">
|
||||
<div v-if="value.length">
|
||||
<div
|
||||
v-for="(val, index) in modelValue"
|
||||
v-for="(val, index) in value"
|
||||
:key="index"
|
||||
class="flex mb-2"
|
||||
>
|
||||
@@ -26,10 +26,7 @@
|
||||
{{ attachmentsIndex[val].filename }}
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
v-if="modelValue"
|
||||
@click.prevent="removeAttachment(val)"
|
||||
>
|
||||
<button @click.prevent="removeAttachment(val)">
|
||||
<IconTrashX
|
||||
:width="18"
|
||||
:heigh="19"
|
||||
@@ -91,18 +88,28 @@ export default {
|
||||
}
|
||||
},
|
||||
emits: ['attached', 'update:model-value'],
|
||||
computed: {
|
||||
value: {
|
||||
set (value) {
|
||||
this.$emit('update:model-value', this.modelValue || [])
|
||||
},
|
||||
get () {
|
||||
return this.modelValue || []
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
removeAttachment (uuid) {
|
||||
this.modelValue.splice(this.modelValue.indexOf(uuid), 1)
|
||||
this.value.splice(this.value.indexOf(uuid), 1)
|
||||
|
||||
this.$emit('update:model-value', this.modelValue)
|
||||
this.$emit('update:model-value', this.value)
|
||||
},
|
||||
onUpload (attachments) {
|
||||
attachments.forEach((attachment) => {
|
||||
this.$emit('attached', attachment)
|
||||
})
|
||||
|
||||
this.$emit('update:model-value', [...this.modelValue, ...attachments.map(a => a.uuid)])
|
||||
this.$emit('update:model-value', [...this.value, ...attachments.map(a => a.uuid)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="mx-auto max-w-md flex flex-col">
|
||||
<div
|
||||
class="mx-auto max-w-md flex flex-col"
|
||||
dir="auto"
|
||||
>
|
||||
<p class="font-medium text-2xl flex items-center space-x-1.5 mx-auto">
|
||||
<IconCircleCheck
|
||||
class="inline text-green-600"
|
||||
@@ -21,7 +24,7 @@
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
v-if="canSendEmail && !isDemo"
|
||||
v-if="canSendEmail && !isDemo && withSendCopyButton"
|
||||
class="white-button !h-auto flex items-center space-x-1 w-full"
|
||||
:disabled="isSendingCopy"
|
||||
@click.prevent="sendCopyToEmail"
|
||||
@@ -36,7 +39,7 @@
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="!isWebView"
|
||||
v-if="!isWebView && withDownloadButton"
|
||||
class="base-button flex items-center space-x-1 w-full"
|
||||
:disabled="isDownloading"
|
||||
@click.prevent="download"
|
||||
@@ -115,6 +118,16 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withDownloadButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withSendCopyButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withConfetti: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -169,27 +182,62 @@ export default {
|
||||
this.isDownloading = true
|
||||
|
||||
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`).then((response) => response.json()).then((urls) => {
|
||||
const fileRequests = urls.map((url) => {
|
||||
return () => {
|
||||
return fetch(url).then(async (resp) => {
|
||||
const blobUrl = URL.createObjectURL(await resp.blob())
|
||||
const link = document.createElement('a')
|
||||
const isSafariIos = /iPhone|iPad|iPod/i.test(navigator.userAgent)
|
||||
|
||||
link.href = blobUrl
|
||||
link.setAttribute('download', decodeURI(url.split('/').pop()))
|
||||
if (isSafariIos && urls.length > 1) {
|
||||
this.downloadSafariIos(urls)
|
||||
} else {
|
||||
this.downloadUrls(urls)
|
||||
}
|
||||
})
|
||||
},
|
||||
downloadUrls (urls) {
|
||||
const fileRequests = urls.map((url) => {
|
||||
return () => {
|
||||
return fetch(url).then(async (resp) => {
|
||||
const blobUrl = URL.createObjectURL(await resp.blob())
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.click()
|
||||
link.href = blobUrl
|
||||
link.setAttribute('download', decodeURI(url.split('/').pop()))
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
link.click()
|
||||
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
fileRequests.reduce(
|
||||
(prevPromise, request) => prevPromise.then(() => request()),
|
||||
Promise.resolve()
|
||||
)
|
||||
|
||||
this.isDownloading = false
|
||||
},
|
||||
downloadSafariIos (urls) {
|
||||
const fileRequests = urls.map((url) => {
|
||||
return fetch(url).then(async (resp) => {
|
||||
const blob = await resp.blob()
|
||||
const blobUrl = URL.createObjectURL(blob.slice(0, blob.size, 'application/octet-stream'))
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = blobUrl
|
||||
link.setAttribute('download', decodeURI(url.split('/').pop()))
|
||||
|
||||
return link
|
||||
})
|
||||
})
|
||||
|
||||
fileRequests.reduce(
|
||||
(prevPromise, request) => prevPromise.then(() => request()),
|
||||
Promise.resolve()
|
||||
)
|
||||
Promise.all(fileRequests).then((links) => {
|
||||
links.forEach((link, index) => {
|
||||
setTimeout(() => {
|
||||
link.click()
|
||||
|
||||
URL.revokeObjectURL(link.href)
|
||||
}, index * 50)
|
||||
})
|
||||
}).finally(() => {
|
||||
this.isDownloading = false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div dir="auto">
|
||||
<div class="flex justify-between items-center w-full mb-2">
|
||||
<label
|
||||
:for="field.uuid"
|
||||
@@ -15,6 +15,7 @@
|
||||
{{ t('set_today') }}
|
||||
</button>
|
||||
</div>
|
||||
<AppearsOn :field="field" />
|
||||
<div class="text-center">
|
||||
<input
|
||||
ref="input"
|
||||
@@ -31,11 +32,13 @@
|
||||
|
||||
<script>
|
||||
import { IconCalendarCheck } from '@tabler/icons-vue'
|
||||
import AppearsOn from './appears_on'
|
||||
|
||||
export default {
|
||||
name: 'DateStep',
|
||||
components: {
|
||||
IconCalendarCheck
|
||||
IconCalendarCheck,
|
||||
AppearsOn
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
/>
|
||||
<button
|
||||
v-if="!isFormVisible"
|
||||
id="expand_form_button"
|
||||
class="btn btn-neutral flex text-white absolute rounded-none border-x-0 md:border md:rounded-full bottom-0 w-full md:mb-4 text-base"
|
||||
@click.prevent="isFormVisible = true"
|
||||
>
|
||||
@@ -23,11 +24,13 @@
|
||||
<div
|
||||
v-show="isFormVisible"
|
||||
id="form_container"
|
||||
class="shadow-md bg-base-100 absolute bottom-0 md:bottom-4 w-full border-base-200 border p-4 rounded"
|
||||
class="shadow-md bg-base-100 absolute bottom-0 w-full border-base-200 border p-4 rounded"
|
||||
:class="{ 'md:bottom-4': isBreakpointMd }"
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
>
|
||||
<button
|
||||
v-if="!isCompleted"
|
||||
id="minimize_form_button"
|
||||
class="absolute right-0 mr-2 mt-2 top-0 hidden md:block"
|
||||
title="Minimize"
|
||||
@click.prevent="isFormVisible = false"
|
||||
@@ -43,7 +46,7 @@
|
||||
ref="form"
|
||||
:action="submitPath"
|
||||
method="post"
|
||||
class="md:mx-16"
|
||||
:class="{ 'md:mx-16': isBreakpointMd }"
|
||||
@submit.prevent="submitStep"
|
||||
>
|
||||
<input
|
||||
@@ -62,7 +65,7 @@
|
||||
:key="currentField.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
:field="currentField"
|
||||
@focus="$refs.areas.scrollIntoField(currentField)"
|
||||
@focus="scrollIntoField(currentField)"
|
||||
/>
|
||||
</div>
|
||||
<DateStep
|
||||
@@ -70,12 +73,13 @@
|
||||
:key="currentField.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
:field="currentField"
|
||||
@focus="$refs.areas.scrollIntoField(currentField)"
|
||||
@focus="scrollIntoField(currentField)"
|
||||
/>
|
||||
<div v-else-if="currentField.type === 'select'">
|
||||
<label
|
||||
v-if="currentField.name"
|
||||
:for="currentField.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl mb-2"
|
||||
>{{ currentField.name }}
|
||||
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
||||
@@ -84,13 +88,15 @@
|
||||
v-else
|
||||
class="py-1"
|
||||
/>
|
||||
<AppearsOn :field="currentField" />
|
||||
<select
|
||||
:id="currentField.uuid"
|
||||
dir="auto"
|
||||
:required="currentField.required"
|
||||
class="select base-input !text-2xl w-full text-center font-normal"
|
||||
:name="`values[${currentField.uuid}]`"
|
||||
@change="values[currentField.uuid] = $event.target.value"
|
||||
@focus="$refs.areas.scrollIntoField(currentField)"
|
||||
@focus="scrollIntoField(currentField)"
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
@@ -99,12 +105,12 @@
|
||||
{{ t('select_your_option') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="(option, index) in currentField.options"
|
||||
:key="index"
|
||||
:selected="values[currentField.uuid] == option"
|
||||
:value="option"
|
||||
v-for="option in currentField.options"
|
||||
:key="option.uuid"
|
||||
:selected="values[currentField.uuid] == option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option }}
|
||||
{{ option.value }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -112,6 +118,7 @@
|
||||
<label
|
||||
v-if="currentField.name"
|
||||
:for="currentField.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl mb-2"
|
||||
>{{ currentField.name }}
|
||||
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
||||
@@ -120,23 +127,23 @@
|
||||
<div class="space-y-3.5 mx-auto">
|
||||
<div
|
||||
v-for="(option, index) in currentField.options"
|
||||
:key="index"
|
||||
:key="option.uuid"
|
||||
>
|
||||
<label
|
||||
:for="currentField.uuid + option"
|
||||
:for="option.uuid"
|
||||
class="flex items-center space-x-3"
|
||||
>
|
||||
<input
|
||||
:id="currentField.uuid + option"
|
||||
:id="option.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
type="radio"
|
||||
class="base-radio !h-7 !w-7"
|
||||
:name="`values[${currentField.uuid}]`"
|
||||
:value="option"
|
||||
:value="option.value || `${t('option')} ${index + 1}`"
|
||||
:required="currentField.required"
|
||||
>
|
||||
<span class="text-xl">
|
||||
{{ option }}
|
||||
{{ option.value || `${t('option')} ${index + 1}` }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -195,9 +202,17 @@
|
||||
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
|
||||
:required="field.required"
|
||||
:checked="!!values[field.uuid]"
|
||||
@click="[$refs.areas.scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
|
||||
@click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
|
||||
>
|
||||
<span
|
||||
v-if="field.title"
|
||||
class="text-xl"
|
||||
v-html="field.title"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="text-xl"
|
||||
>
|
||||
<span class="text-xl">
|
||||
{{ field.name || field.type + ' ' + (index + 1) }}
|
||||
</span>
|
||||
</label>
|
||||
@@ -206,14 +221,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<ImageStep
|
||||
v-else-if="currentField.type === 'image'"
|
||||
v-else-if="currentField.type === 'image' || currentField.type === 'stamp'"
|
||||
:key="currentField.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
:field="currentField"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:submitter-slug="submitterSlug"
|
||||
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
|
||||
@attached="[attachments.push($event), scrollIntoField(currentField)]"
|
||||
/>
|
||||
<SignatureStep
|
||||
v-else-if="currentField.type === 'signature'"
|
||||
@@ -223,10 +238,11 @@
|
||||
:field="currentField"
|
||||
:previous-value="previousSignatureValue"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:with-typed-signature="withTypedSignature"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:submitter-slug="submitterSlug"
|
||||
@attached="attachments.push($event)"
|
||||
@start="$refs.areas.scrollIntoField(currentField)"
|
||||
@start="scrollIntoField(currentField)"
|
||||
@minimize="isFormVisible = false"
|
||||
/>
|
||||
<InitialsStep
|
||||
@@ -240,8 +256,8 @@
|
||||
:attachments-index="attachmentsIndex"
|
||||
:submitter-slug="submitterSlug"
|
||||
@attached="attachments.push($event)"
|
||||
@start="$refs.areas.scrollIntoField(currentField)"
|
||||
@focus="$refs.areas.scrollIntoField(currentField)"
|
||||
@start="scrollIntoField(currentField)"
|
||||
@focus="scrollIntoField(currentField)"
|
||||
@minimize="isFormVisible = false"
|
||||
/>
|
||||
<AttachmentStep
|
||||
@@ -252,7 +268,7 @@
|
||||
:field="currentField"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:submitter-slug="submitterSlug"
|
||||
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
|
||||
@attached="[attachments.push($event), scrollIntoField(currentField)]"
|
||||
/>
|
||||
<PhoneStep
|
||||
v-else-if="currentField.type === 'phone'"
|
||||
@@ -262,12 +278,27 @@
|
||||
:field="currentField"
|
||||
:default-value="submitter.phone"
|
||||
:submitter-slug="submitterSlug"
|
||||
@focus="$refs.areas.scrollIntoField(currentField)"
|
||||
@focus="scrollIntoField(currentField)"
|
||||
@submit="submitStep"
|
||||
/>
|
||||
<PaymentStep
|
||||
v-else-if="currentField.type === 'payment'"
|
||||
ref="currentStep"
|
||||
:key="currentField.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
:field="currentField"
|
||||
:submitter-slug="submitterSlug"
|
||||
@attached="attachments.push($event)"
|
||||
@focus="scrollIntoField(currentField)"
|
||||
@submit="submitStep"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 md:mt-8">
|
||||
<div
|
||||
v-if="currentField.type !== 'payment' || submittedValues[currentField.uuid]"
|
||||
class="mt-6 md:mt-8"
|
||||
>
|
||||
<button
|
||||
id="submit_form_button"
|
||||
ref="submitButton"
|
||||
type="submit"
|
||||
class="base-button w-full flex justify-center"
|
||||
@@ -301,23 +332,32 @@
|
||||
v-else
|
||||
:is-demo="isDemo"
|
||||
:attribution="attribution"
|
||||
:completed-button="completedButton"
|
||||
:completed-button="completedRedirectUrl ? {} : completedButton"
|
||||
:with-send-copy-button="withSendCopyButton && !completedRedirectUrl"
|
||||
:with-download-button="withDownloadButton && !completedRedirectUrl"
|
||||
:with-confetti="withConfetti"
|
||||
:can-send-email="canSendEmail && !!submitter.email"
|
||||
:submitter-slug="submitterSlug"
|
||||
/>
|
||||
<div class="flex justify-center">
|
||||
<div class="flex items-center mt-5 mb-1">
|
||||
<div
|
||||
v-if="stepFields.length < 80"
|
||||
class="flex justify-center"
|
||||
>
|
||||
<div class="flex items-center mt-4 mb-1 flex-wrap">
|
||||
<a
|
||||
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"
|
||||
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1"
|
||||
:class="{ 'bg-base-300': 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(step, true)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="mt-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -330,10 +370,12 @@ import InitialsStep from './initials_step'
|
||||
import AttachmentStep from './attachment_step'
|
||||
import MultiSelectStep from './multi_select_step'
|
||||
import PhoneStep from './phone_step'
|
||||
import PaymentStep from './payment_step'
|
||||
import TextStep from './text_step'
|
||||
import DateStep from './date_step'
|
||||
import FormCompleted from './completed'
|
||||
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||
import AppearsOn from './appears_on'
|
||||
import { t } from './i18n'
|
||||
|
||||
export default {
|
||||
@@ -342,6 +384,7 @@ export default {
|
||||
FieldAreas,
|
||||
ImageStep,
|
||||
SignatureStep,
|
||||
AppearsOn,
|
||||
AttachmentStep,
|
||||
InitialsStep,
|
||||
MultiSelectStep,
|
||||
@@ -350,12 +393,14 @@ export default {
|
||||
IconArrowsDiagonal,
|
||||
TextStep,
|
||||
PhoneStep,
|
||||
PaymentStep,
|
||||
IconArrowsDiagonalMinimize2,
|
||||
FormCompleted
|
||||
},
|
||||
provide () {
|
||||
return {
|
||||
baseUrl: this.baseUrl,
|
||||
scrollIntoArea: this.scrollIntoArea,
|
||||
t: this.t
|
||||
}
|
||||
},
|
||||
@@ -386,6 +431,16 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withTypedSignature: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
isBreakpointMd: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
baseUrl: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -396,11 +451,6 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
authenticityToken: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
backgroundColor: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -436,6 +486,21 @@ export default {
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
withSendCopyButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withDownloadButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
completedRedirectUrl: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
completedButton: {
|
||||
type: Object,
|
||||
required: false,
|
||||
@@ -457,6 +522,12 @@ export default {
|
||||
currentStepFields () {
|
||||
return this.stepFields[this.currentStep]
|
||||
},
|
||||
queryParams () {
|
||||
return new URLSearchParams(window.location.search)
|
||||
},
|
||||
authenticityToken () {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content
|
||||
},
|
||||
submitterSlug () {
|
||||
return this.submitter.slug
|
||||
},
|
||||
@@ -476,7 +547,7 @@ export default {
|
||||
isButtonDisabled () {
|
||||
if (this.recalculateButtonDisabledKey) {
|
||||
return this.isSubmitting ||
|
||||
(this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) ||
|
||||
(this.currentField.required && ['image', 'file', 'multiple'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) ||
|
||||
(this.currentField.required && this.currentField.type === 'signature' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isSignatureStarted) ||
|
||||
(this.currentField.required && this.currentField.type === 'initials' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isInitialsStarted)
|
||||
} else {
|
||||
@@ -519,7 +590,13 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
if (this.goToLast) {
|
||||
if (this.queryParams.get('field_uuid')) {
|
||||
const stepIndex = this.stepFields.findIndex((fields) => {
|
||||
return fields.some((f) => f.uuid === this.queryParams.get('field_uuid'))
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
@@ -564,7 +641,7 @@ export default {
|
||||
methods: {
|
||||
t,
|
||||
maybeTrackEmailClick () {
|
||||
const queryParams = new URLSearchParams(window.location.search)
|
||||
const { queryParams } = this
|
||||
|
||||
if (queryParams.has('t')) {
|
||||
const t = queryParams.get('t')
|
||||
@@ -588,7 +665,7 @@ export default {
|
||||
}
|
||||
},
|
||||
maybeTrackSmsClick () {
|
||||
const queryParams = new URLSearchParams(window.location.search)
|
||||
const { queryParams } = this
|
||||
|
||||
if (queryParams.has('c')) {
|
||||
const c = queryParams.get('c')
|
||||
@@ -630,7 +707,7 @@ export default {
|
||||
this.recalculateButtonDisabledKey = Math.random()
|
||||
|
||||
if (scrollToArea) {
|
||||
this.$refs.areas.scrollIntoField(step[0])
|
||||
this.scrollIntoField(step[0])
|
||||
}
|
||||
|
||||
this.$refs.form.querySelector('input[type="date"], input[type="text"], select')?.focus()
|
||||
@@ -641,25 +718,39 @@ export default {
|
||||
})
|
||||
},
|
||||
saveStep (formData) {
|
||||
const currentFieldUuid = this.currentField.uuid
|
||||
|
||||
if (this.isCompleted) {
|
||||
return Promise.resolve({})
|
||||
} else {
|
||||
return fetch(this.baseUrl + this.submitPath, {
|
||||
method: 'POST',
|
||||
body: formData || new FormData(this.$refs.form)
|
||||
}).then((response) => {
|
||||
if (response.status === 200) {
|
||||
this.submittedValues[currentFieldUuid] = this.values[currentFieldUuid]
|
||||
}
|
||||
|
||||
return response
|
||||
})
|
||||
}
|
||||
},
|
||||
scrollIntoField (field) {
|
||||
return this.$refs.areas.scrollIntoField(field)
|
||||
},
|
||||
scrollIntoArea (area) {
|
||||
return this.$refs.areas.scrollIntoArea(area)
|
||||
},
|
||||
async submitStep () {
|
||||
this.isSubmitting = true
|
||||
|
||||
const stepPromise = ['signature', 'phone', 'initials'].includes(this.currentField.type)
|
||||
const stepPromise = ['signature', 'phone', 'initials', 'payment'].includes(this.currentField.type)
|
||||
? this.$refs.currentStep.submit
|
||||
: () => Promise.resolve({})
|
||||
|
||||
stepPromise().then(async () => {
|
||||
const emptyRequiredField = this.stepFields.find((fields, index) => {
|
||||
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && !this.submittedValues[fields[0].uuid]
|
||||
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && (this.submittedValues[fields[0].uuid] === true ? false : !this.submittedValues[fields[0].uuid]?.length)
|
||||
})
|
||||
|
||||
const formData = new FormData(this.$refs.form)
|
||||
@@ -670,7 +761,7 @@ export default {
|
||||
}
|
||||
|
||||
await this.saveStep(formData).then(async (response) => {
|
||||
if (response.status === 422) {
|
||||
if (response.status === 422 || response.status === 500) {
|
||||
const data = await response.json()
|
||||
|
||||
alert(data.error || 'Value is invalid')
|
||||
@@ -678,8 +769,6 @@ export default {
|
||||
return Promise.reject(new Error(data.error))
|
||||
}
|
||||
|
||||
this.submittedValues[this.currentField.uuid] = this.values[this.currentField.uuid]
|
||||
|
||||
if (isLastStep) {
|
||||
this.isSecondWalkthrough = true
|
||||
}
|
||||
@@ -700,9 +789,13 @@ export default {
|
||||
if (respData) {
|
||||
this.onComplete(JSON.parse(respData))
|
||||
}
|
||||
|
||||
if (this.completedRedirectUrl) {
|
||||
window.location.href = this.completedRedirectUrl
|
||||
}
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Error submitting form:', error)
|
||||
console.error(error)
|
||||
}).finally(() => {
|
||||
this.isSubmitting = false
|
||||
})
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
const en = {
|
||||
submit_form: 'Submit Form',
|
||||
type_here: 'Type here',
|
||||
type_here_: 'Type here...',
|
||||
optional: 'optional',
|
||||
option: 'Option',
|
||||
appears_on: 'Appears on',
|
||||
page: 'Page',
|
||||
select_your_option: 'Select your option',
|
||||
complete_hightlighted_checkboxes_and_click: 'Complete hightlighted checkboxes and click',
|
||||
submit: 'submit',
|
||||
@@ -32,14 +35,23 @@ const en = {
|
||||
please_fill_all_required_fields: 'Please fill all required fields',
|
||||
set_today: 'Set Today',
|
||||
toggle_multiline_text: 'Toggle Multiline Text',
|
||||
draw_signature: 'Draw signature',
|
||||
type_initial: 'Type initials',
|
||||
draw: 'Draw',
|
||||
type: 'Type',
|
||||
type_text: 'Type text',
|
||||
date: 'Date',
|
||||
email_has_been_sent: 'Email has been sent'
|
||||
email_has_been_sent: 'Email has been sent',
|
||||
processing: 'Processing',
|
||||
pay_with_strip: 'Pay with Stripe'
|
||||
}
|
||||
|
||||
const es = {
|
||||
submit_form: 'Enviar Formulario',
|
||||
type_here: 'Escribe aquí',
|
||||
type_here_: 'Escribe aquí...',
|
||||
optional: 'opcional',
|
||||
appears_on: 'Aparece en',
|
||||
page: 'Página',
|
||||
select_your_option: 'Selecciona tu opción',
|
||||
complete_hightlighted_checkboxes_and_click: 'Completa las casillas resaltadas y haz clic',
|
||||
submit: 'enviar',
|
||||
@@ -71,13 +83,22 @@ const es = {
|
||||
set_today: 'Establecer Hoy',
|
||||
date: 'Fecha',
|
||||
toggle_multiline_text: 'Alternar Texto Multilínea',
|
||||
email_has_been_sent: 'El correo electrónico ha sido enviado'
|
||||
draw_signature: 'Dibujar firma',
|
||||
type_initial: 'Escribir iniciales',
|
||||
draw: 'Dibujar',
|
||||
type: 'Escribir',
|
||||
type_text: 'Escribir texto',
|
||||
email_has_been_sent: 'El correo electrónico ha sido enviado',
|
||||
processing: 'Procesando',
|
||||
pay_with_strip: 'Pagar con Stripe'
|
||||
}
|
||||
|
||||
const it = {
|
||||
submit_form: 'Invia Modulo',
|
||||
type_here: 'Digita qui',
|
||||
type_here_: 'Digita qui...',
|
||||
optional: 'opzionale',
|
||||
appears_on: 'Compare su',
|
||||
page: 'Pagina',
|
||||
select_your_option: 'Seleziona la tua opzione',
|
||||
complete_hightlighted_checkboxes_and_click: 'Completa le caselle evidenziate e fai clic',
|
||||
submit: 'invia',
|
||||
@@ -108,14 +129,23 @@ const it = {
|
||||
please_fill_all_required_fields: 'Si prega di compilare tutti i campi obbligatori',
|
||||
set_today: 'Imposta Oggi',
|
||||
date: 'Data',
|
||||
draw_signature: 'Disegna firma',
|
||||
type_initial: 'Inserisci iniziali',
|
||||
draw: 'Disegna',
|
||||
type: 'Inserisci',
|
||||
type_text: 'Inserisci testo',
|
||||
toggle_multiline_text: 'Attiva Testo Multilinea',
|
||||
email_has_been_sent: "L'email è stata inviata"
|
||||
email_has_been_sent: "L'email è stata inviata",
|
||||
processing: 'Elaborazione',
|
||||
pay_with_strip: 'Paga con Stripe'
|
||||
}
|
||||
|
||||
const de = {
|
||||
submit_form: 'Formular absenden',
|
||||
type_here: 'Hier eingeben',
|
||||
type_here_: 'Hier eingeben...',
|
||||
optional: 'optional',
|
||||
appears_on: 'Erscheint auf',
|
||||
page: 'Seite',
|
||||
select_your_option: 'Wähle deine Option',
|
||||
complete_hightlighted_checkboxes_and_click: 'Markierte Kontrollkästchen ausfüllen und klicken',
|
||||
submit: 'absenden',
|
||||
@@ -146,14 +176,23 @@ const de = {
|
||||
please_fill_all_required_fields: 'Bitte füllen Sie alle erforderlichen Felder aus',
|
||||
set_today: 'Heute einstellen',
|
||||
date: 'Datum',
|
||||
draw_signature: 'Unterschrift zeichnen',
|
||||
type_initial: 'Initialen eingeben',
|
||||
draw: 'Zeichnen',
|
||||
type: 'Eingeben',
|
||||
type_text: 'Text eingeben',
|
||||
toggle_multiline_text: 'Mehrzeiligen Text umschalten',
|
||||
email_has_been_sent: 'Die E-Mail wurde gesendet'
|
||||
email_has_been_sent: 'Die E-Mail wurde gesendet',
|
||||
processing: 'Verarbeitung',
|
||||
pay_with_strip: 'Mit Stripe bezahlen'
|
||||
}
|
||||
|
||||
const fr = {
|
||||
submit_form: 'Envoyer le Formulaire',
|
||||
type_here: 'Tapez ici',
|
||||
type_here_: 'Tapez ici...',
|
||||
optional: 'facultatif',
|
||||
appears_on: 'Apparaît sur',
|
||||
page: 'Page',
|
||||
select_your_option: 'Sélectionnez votre option',
|
||||
complete_hightlighted_checkboxes_and_click: 'Complétez les cases à cocher en surbrillance et cliquez',
|
||||
submit: 'envoyer',
|
||||
@@ -184,14 +223,23 @@ const fr = {
|
||||
please_fill_all_required_fields: 'Veuillez remplir tous les champs obligatoires',
|
||||
set_today: "Définir Aujourd'hui",
|
||||
date: 'Date',
|
||||
draw_signature: 'Dessiner une signature',
|
||||
type_initial: 'Saisir les initiales',
|
||||
draw: 'Dessiner',
|
||||
type: 'Saisir',
|
||||
type_text: 'Saisir du texte',
|
||||
toggle_multiline_text: 'Basculer le Texte Multiligne',
|
||||
email_has_been_sent: "L'email a été envoyé"
|
||||
email_has_been_sent: "L'email a été envoyé",
|
||||
processing: 'Traitement',
|
||||
pay_with_strip: 'Paiement avec Stripe'
|
||||
}
|
||||
|
||||
const pl = {
|
||||
submit_form: 'Wyślij Formularz',
|
||||
type_here: 'Wpisz tutaj',
|
||||
type_here_: 'Wpisz tutaj...',
|
||||
optional: 'opcjonalny',
|
||||
appears_on: 'Pojawia się na',
|
||||
page: 'Strona',
|
||||
select_your_option: 'Wybierz swoją opcję',
|
||||
complete_hightlighted_checkboxes_and_click: 'Wypełnij zaznaczone pola wyboru i kliknij',
|
||||
submit: 'wyślij',
|
||||
@@ -222,14 +270,23 @@ const pl = {
|
||||
please_fill_all_required_fields: 'Proszę wypełnić wszystkie wymagane pola',
|
||||
set_today: 'Ustaw Dziś',
|
||||
date: 'Data',
|
||||
draw_signature: 'Rysuj podpis',
|
||||
type_initial: 'Wprowadź inicjały',
|
||||
draw: 'Rysuj',
|
||||
type: 'Wprowadź',
|
||||
type_text: 'Wprowadź tekst',
|
||||
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
|
||||
email_has_been_sent: 'E-mail został wysłany'
|
||||
email_has_been_sent: 'E-mail został wysłany',
|
||||
processing: 'Przetwarzanie',
|
||||
pay_with_strip: 'Płatność za pomocą Stripe'
|
||||
}
|
||||
|
||||
const uk = {
|
||||
submit_form: 'Надіслати Форму',
|
||||
type_here: 'Введіть тут',
|
||||
type_here_: 'Введіть тут',
|
||||
optional: 'необов’язково',
|
||||
appears_on: "З'являється на",
|
||||
page: 'Сторінка',
|
||||
select_your_option: 'Виберіть варіант',
|
||||
complete_hightlighted_checkboxes_and_click: 'Заповніть позначені прапорці та натисніть',
|
||||
submit: 'надіслати',
|
||||
@@ -260,14 +317,23 @@ const uk = {
|
||||
please_fill_all_required_fields: "Будь ласка, заповніть всі обов'язкові поля",
|
||||
set_today: 'Задати Сьогодні',
|
||||
date: 'Дата',
|
||||
draw_signature: 'Намалюйте підпис',
|
||||
type_initial: 'Введіть ініціали',
|
||||
draw: 'Підпис',
|
||||
type: 'Текст',
|
||||
type_text: 'Введіть текст',
|
||||
toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
|
||||
email_has_been_sent: 'Електронний лист був відправлений'
|
||||
email_has_been_sent: 'Електронний лист був відправлений',
|
||||
processing: 'Обробка',
|
||||
pay_with_strip: 'Сплатити за допомогою Stripe'
|
||||
}
|
||||
|
||||
const cs = {
|
||||
submit_form: 'Odeslat formulář',
|
||||
type_here: 'Zadejte zde',
|
||||
type_here_: 'Zadejte zde',
|
||||
optional: 'volitelné',
|
||||
appears_on: 'Zobrazuje se na',
|
||||
page: 'Stránka',
|
||||
select_your_option: 'Vyberte svou volbu',
|
||||
complete_hightlighted_checkboxes_and_click: 'Označte zvýrazněné zaškrtávací políčka a klikněte na',
|
||||
submit: 'odeslat',
|
||||
@@ -298,14 +364,23 @@ const cs = {
|
||||
please_fill_all_required_fields: 'Prosím vyplňte všechny povinné položky',
|
||||
set_today: 'Nastavit Dnes',
|
||||
date: 'Datum',
|
||||
draw_signature: 'Nakreslit podpis',
|
||||
type_initial: 'Zadat iniciály',
|
||||
draw: 'Kreslit',
|
||||
type: 'Zadat',
|
||||
type_text: 'Zadat text',
|
||||
toggle_multiline_text: 'Přepnout Víceřádkový Text',
|
||||
email_has_been_sent: 'E-mail byl odeslán'
|
||||
email_has_been_sent: 'E-mail byl odeslán',
|
||||
processing: 'Zpracování',
|
||||
pay_with_strip: 'Zaplacení přes Stripe'
|
||||
}
|
||||
|
||||
const pt = {
|
||||
submit_form: 'Enviar Formulário',
|
||||
type_here: 'Digite aqui',
|
||||
type_here_: 'Digite aqui',
|
||||
optional: 'opcional',
|
||||
appears_on: 'Aparece em',
|
||||
page: 'Página',
|
||||
select_your_option: 'Selecione sua opção',
|
||||
complete_hightlighted_checkboxes_and_click: 'Complete as caixas de seleção destacadas e clique',
|
||||
submit: 'enviar',
|
||||
@@ -336,11 +411,66 @@ const pt = {
|
||||
please_fill_all_required_fields: 'Por favor, preencha todos os campos obrigatórios',
|
||||
set_today: 'Definir Hoje',
|
||||
date: 'Data',
|
||||
draw_signature: 'Desenhar assinatura',
|
||||
type_initial: 'Inserir iniciais',
|
||||
draw: 'Desenhar',
|
||||
type: 'Inserir',
|
||||
type_text: 'Inserir texto',
|
||||
toggle_multiline_text: 'Alternar Texto Multilinha',
|
||||
email_has_been_sent: 'Email enviado'
|
||||
email_has_been_sent: 'Email enviado',
|
||||
processing: 'Processamento',
|
||||
pay_with_strip: 'Pagar com Stripe'
|
||||
}
|
||||
|
||||
const i18n = { en, es, it, de, fr, pl, uk, cs, pt }
|
||||
const he = {
|
||||
submit_form: 'שלח טופס',
|
||||
type_here_: 'הקלד כאן',
|
||||
optional: 'אופציונלי',
|
||||
option: 'אפשרות',
|
||||
appears_on: 'מופיע ב',
|
||||
page: 'עמוד',
|
||||
select_your_option: 'בחר את האפשרות שלך',
|
||||
complete_hightlighted_checkboxes_and_click: 'השלם את תיבות הסימון המודגשות ולחץ',
|
||||
submit: 'שליחה',
|
||||
next: 'הבא',
|
||||
click_to_upload: 'לחץ להעלאה',
|
||||
or_drag_and_drop_files: 'או גרור ושחרר קבצים',
|
||||
send_copy_via_email: 'שלח עותק באמצעות אימייל',
|
||||
download: 'הורדה',
|
||||
signature: 'חתימה',
|
||||
initials: 'ציוני ראשי תיבות',
|
||||
clear: 'נקה',
|
||||
redraw: 'צייר מחדש',
|
||||
draw_initials: 'צייר ציוני ראשי תיבות',
|
||||
type_signature_here: 'הקלד חתימה כאן',
|
||||
type_initial_here: 'הקלד ציוני ראשי תיבות כאן',
|
||||
form_has_been_completed: 'הטופס הושלם',
|
||||
create_a_free_account: 'צור חשבון חינם',
|
||||
signed_with: 'חתום באמצעות',
|
||||
please_check_the_box_to_continue: 'אנא סמן את התיבה כדי להמשיך',
|
||||
open_source_documents_software: 'תוכנה פתוחה למסמכים',
|
||||
verified_phone_number: 'אימות מספר טלפון',
|
||||
use_international_format: 'השתמש בפורמט בינלאומי: +1xxx',
|
||||
six_digits_code: 'קוד משתמש שש ספרות',
|
||||
change_phone_number: 'שינוי מספר טלפון',
|
||||
sending: 'שולח',
|
||||
resend_code: 'שלח מחדש קוד',
|
||||
verification_code_has_been_resent: 'קוד האימות נשלח מחדש בSMS',
|
||||
please_fill_all_required_fields: 'אנא מלא את כל השדות הדרושים',
|
||||
set_today: 'קבע היום',
|
||||
toggle_multiline_text: 'שנה בין טקסט במספר שורות לטקסט בשורה אחת',
|
||||
draw_signature: 'צייר חתימה',
|
||||
type_initial: 'הקלד ציוני ראשי תיבות',
|
||||
draw: 'צייר',
|
||||
type: 'הקלד',
|
||||
type_text: 'הקלד טקסט',
|
||||
date: 'תאריך',
|
||||
email_has_been_sent: 'האימייל נשלח',
|
||||
processing: 'מעבד',
|
||||
pay_with_strip: 'שלם עם סטרייפ'
|
||||
}
|
||||
|
||||
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he }
|
||||
|
||||
const browserLanguage = (navigator.language || navigator.userLanguage || 'en').split('-')[0]
|
||||
|
||||
|
||||
@@ -1,28 +1,48 @@
|
||||
<template>
|
||||
<div>
|
||||
<div dir="auto">
|
||||
<div class="flex justify-between items-center w-full mb-2">
|
||||
<label
|
||||
class="label text-2xl"
|
||||
>{{ field.name || t('initials') }}</label>
|
||||
<div class="space-x-2 flex">
|
||||
<span
|
||||
v-if="isDrawInitials"
|
||||
class="tooltip"
|
||||
:data-tip="t('type_initials')"
|
||||
>
|
||||
<a
|
||||
id="type_text_button"
|
||||
href="#"
|
||||
class="btn btn-outline font-medium btn-sm"
|
||||
@click.prevent="toggleTextInput"
|
||||
>
|
||||
<IconTextSize :width="16" />
|
||||
<span class="hidden sm:inline">
|
||||
{{ t('type') }}
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="tooltip ml-2"
|
||||
:data-tip="t('draw_initials')"
|
||||
>
|
||||
<a
|
||||
id="type_text_button"
|
||||
href="#"
|
||||
class="btn btn-sm btn-circle"
|
||||
:class="{ 'btn-neutral': isDrawInitials, 'btn-outline': !isDrawInitials }"
|
||||
class="btn btn-outline font-medium btn-sm"
|
||||
@click.prevent="toggleTextInput"
|
||||
>
|
||||
<IconSignature :width="16" />
|
||||
<span class="hidden sm:inline">
|
||||
{{ t('draw') }}
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
<a
|
||||
v-if="modelValue || computedPreviousValue"
|
||||
href="#"
|
||||
class="btn btn-outline btn-sm"
|
||||
class="btn font-medium btn-outline btn-sm"
|
||||
@click.prevent="remove"
|
||||
>
|
||||
<IconReload :width="16" />
|
||||
@@ -31,7 +51,7 @@
|
||||
<a
|
||||
v-else
|
||||
href="#"
|
||||
class="btn btn-outline btn-sm"
|
||||
class="btn font-medium btn-outline btn-sm"
|
||||
@click.prevent="clear"
|
||||
>
|
||||
<IconReload :width="16" />
|
||||
@@ -50,6 +70,7 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<AppearsOn :field="field" />
|
||||
<input
|
||||
:value="modelValue || computedPreviousValue"
|
||||
type="hidden"
|
||||
@@ -63,7 +84,7 @@
|
||||
<canvas
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
ref="canvas"
|
||||
class="bg-white border border-base-300 rounded"
|
||||
class="bg-white border border-base-300 rounded-2xl"
|
||||
/>
|
||||
<input
|
||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
|
||||
@@ -81,13 +102,16 @@
|
||||
|
||||
<script>
|
||||
import { cropCanvasAndExportToPNG } from './crop_canvas'
|
||||
import { IconReload, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||
import { IconReload, IconTextSize, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||
import SignaturePad from 'signature_pad'
|
||||
import AppearsOn from './appears_on'
|
||||
|
||||
export default {
|
||||
name: 'InitialsStep',
|
||||
components: {
|
||||
AppearsOn,
|
||||
IconReload,
|
||||
IconTextSize,
|
||||
IconSignature,
|
||||
IconArrowsDiagonalMinimize2
|
||||
},
|
||||
|
||||
@@ -2,30 +2,38 @@
|
||||
<label
|
||||
v-if="field.name"
|
||||
:for="field.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl mb-2"
|
||||
>{{ field.name }}</label>
|
||||
<div class="flex w-full">
|
||||
<input
|
||||
v-if="modelValue.length === 0"
|
||||
type="text"
|
||||
:name="`values[${field.uuid}][]`"
|
||||
:value="''"
|
||||
class="hidden"
|
||||
>
|
||||
<div class="space-y-3.5 mx-auto">
|
||||
<div
|
||||
v-for="(option, index) in field.options"
|
||||
:key="index"
|
||||
:key="option.uuid"
|
||||
>
|
||||
<label
|
||||
:for="field.uuid + option"
|
||||
:for="option.uuid"
|
||||
class="flex items-center space-x-3"
|
||||
>
|
||||
<input
|
||||
:id="field.uuid + option"
|
||||
:id="option.uuid"
|
||||
:ref="setInputRef"
|
||||
type="checkbox"
|
||||
:name="`values[${field.uuid}][]`"
|
||||
:value="option"
|
||||
:value="optionValue(option, index)"
|
||||
class="base-checkbox !h-7 !w-7"
|
||||
:checked="(modelValue || []).includes(option)"
|
||||
:checked="(modelValue || []).includes(optionValue(option, index))"
|
||||
@change="onChange"
|
||||
>
|
||||
<span class="text-xl">
|
||||
{{ option }}
|
||||
{{ optionValue(option, index) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -36,6 +44,7 @@
|
||||
<script>
|
||||
export default {
|
||||
name: 'MultiSelectStep',
|
||||
inject: ['t'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -62,8 +71,15 @@ export default {
|
||||
this.inputRefs.push(el)
|
||||
}
|
||||
},
|
||||
optionValue (option, index) {
|
||||
if (option.value) {
|
||||
return option.value
|
||||
} else {
|
||||
return `${this.t('option')} ${index + 1}`
|
||||
}
|
||||
},
|
||||
onChange () {
|
||||
this.$emit('update:model-value', this.inputRefs.filter(e => e.checked).map(e => e.value))
|
||||
this.$emit('update:model-value', this.inputRefs.filter(e => e.checked).map((e, index) => this.optionValue(e, index)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<label
|
||||
v-if="!modelValue && !sessionId"
|
||||
:for="field.uuid"
|
||||
class="label text-2xl mb-2"
|
||||
>{{ field.name || defaultName }}
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
:value="modelValue"
|
||||
hidden
|
||||
:name="`values[${field.uuid}]`"
|
||||
class="hidden"
|
||||
>
|
||||
<div
|
||||
v-if="modelValue && !sessionId"
|
||||
class=" text-2xl mb-2"
|
||||
>
|
||||
Already paid
|
||||
</div>
|
||||
<div v-else>
|
||||
<button
|
||||
v-if="sessionId"
|
||||
disabled
|
||||
class="base-button w-full"
|
||||
>
|
||||
<IconLoader
|
||||
width="22"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<span>
|
||||
{{ t('processing') }}...
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
:id="field.uuid"
|
||||
class="btn bg-[#7B73FF] text-white hover:bg-[#0A2540] text-lg w-full"
|
||||
:class="{ disabled: isCreatingCheckout }"
|
||||
:disabled="isCreatingCheckout"
|
||||
@click.prevent="startCheckout"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
v-if="isCreatingCheckout"
|
||||
width="22"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<IconBrandStripe
|
||||
v-else
|
||||
width="22"
|
||||
/>
|
||||
<span>
|
||||
{{ t('pay_with_strip') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconBrandStripe, IconInnerShadowTop, IconLoader } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'PaymentStep',
|
||||
components: {
|
||||
IconBrandStripe,
|
||||
IconInnerShadowTop,
|
||||
IconLoader
|
||||
},
|
||||
inject: ['baseUrl', 't'],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
submitterSlug: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['focus', 'submit', 'update:model-value', 'attached'],
|
||||
data () {
|
||||
return {
|
||||
isCreatingCheckout: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
queryParams () {
|
||||
return new URLSearchParams(window.location.search)
|
||||
},
|
||||
sessionId () {
|
||||
return this.queryParams.get('stripe_session_id')
|
||||
},
|
||||
defaultName () {
|
||||
const { price, currency } = this.field.preferences || {}
|
||||
|
||||
const formattedPrice = new Intl.NumberFormat([], {
|
||||
style: 'currency',
|
||||
currency
|
||||
}).format(price)
|
||||
|
||||
return `Pay ${formattedPrice}`
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.sessionId) {
|
||||
this.$emit('submit')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submit () {
|
||||
if (this.sessionId) {
|
||||
return fetch((this.baseUrl || '/embed').replace('/embed', '/api/stripe_payments/' + this.sessionId), {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then(async (resp) => {
|
||||
if (resp.status === 422 || resp.status === 500) {
|
||||
const data = await resp.json()
|
||||
|
||||
alert(data.error || 'Unexpected error')
|
||||
|
||||
return Promise.reject(new Error(data.error))
|
||||
}
|
||||
|
||||
const attachment = await resp.json()
|
||||
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
this.$emit('attached', attachment)
|
||||
|
||||
return resp
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
},
|
||||
startCheckout () {
|
||||
this.isCreatingCheckout = true
|
||||
|
||||
fetch((this.baseUrl || '/embed').replace('/embed', '/api/stripe_payments'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug,
|
||||
field_uuid: this.field.uuid
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then(async (resp) => {
|
||||
if (resp.status === 422 || resp.status === 500) {
|
||||
const data = await resp.json()
|
||||
|
||||
alert(data.message || 'Unexpected error')
|
||||
|
||||
return Promise.reject(new Error(data.message))
|
||||
}
|
||||
|
||||
const { url } = await resp.json()
|
||||
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = url
|
||||
|
||||
if (url) {
|
||||
link.click()
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isCreatingCheckout = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,22 +1,42 @@
|
||||
<template>
|
||||
<div>
|
||||
<div dir="auto">
|
||||
<div class="flex justify-between items-center w-full mb-2">
|
||||
<label
|
||||
class="label text-2xl"
|
||||
>{{ field.name || t('signature') }}</label>
|
||||
<div class="space-x-2 flex">
|
||||
<span
|
||||
v-if="isTextSignature"
|
||||
class="tooltip"
|
||||
data-tip="Type text"
|
||||
:data-tip="t('draw_signature')"
|
||||
>
|
||||
<a
|
||||
id="type_text_button"
|
||||
href="#"
|
||||
class="btn btn-sm btn-circle"
|
||||
:class="{ 'btn-neutral': isTextSignature, 'btn-outline': !isTextSignature }"
|
||||
class="btn btn-outline btn-sm font-medium"
|
||||
@click.prevent="toggleTextInput"
|
||||
>
|
||||
<IconSignature :width="16" />
|
||||
<span class="hidden sm:inline">
|
||||
{{ t('draw') }}
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="withTypedSignature"
|
||||
class="tooltip ml-2"
|
||||
:data-tip="t('type_text')"
|
||||
>
|
||||
<a
|
||||
id="type_text_button"
|
||||
href="#"
|
||||
class="btn btn-outline btn-sm font-medium"
|
||||
@click.prevent="toggleTextInput"
|
||||
>
|
||||
<IconTextSize :width="16" />
|
||||
<span class="hidden sm:inline">
|
||||
{{ t('type') }}
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
<span
|
||||
@@ -24,7 +44,7 @@
|
||||
data-tip="Take photo"
|
||||
>
|
||||
<label
|
||||
class="btn btn-outline btn-sm btn-circle"
|
||||
class="btn btn-outline btn-sm font-medium"
|
||||
>
|
||||
<IconCamera :width="16" />
|
||||
<input
|
||||
@@ -33,12 +53,15 @@
|
||||
accept="image/*"
|
||||
@change="drawImage"
|
||||
>
|
||||
<span class="hidden sm:inline">
|
||||
{{ t('upload') }}
|
||||
</span>
|
||||
</label>
|
||||
</span>
|
||||
<a
|
||||
v-if="modelValue || computedPreviousValue"
|
||||
href="#"
|
||||
class="btn btn-outline btn-sm"
|
||||
class="btn btn-outline btn-sm font-medium"
|
||||
@click.prevent="remove"
|
||||
>
|
||||
<IconReload :width="16" />
|
||||
@@ -47,7 +70,7 @@
|
||||
<a
|
||||
v-else
|
||||
href="#"
|
||||
class="btn btn-outline btn-sm"
|
||||
class="btn btn-outline btn-sm font-medium"
|
||||
@click.prevent="clear"
|
||||
>
|
||||
<IconReload :width="16" />
|
||||
@@ -66,6 +89,7 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<AppearsOn :field="field" />
|
||||
<input
|
||||
:value="modelValue || computedPreviousValue"
|
||||
type="hidden"
|
||||
@@ -79,7 +103,8 @@
|
||||
<canvas
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
ref="canvas"
|
||||
class="bg-white border border-base-300 rounded"
|
||||
style="padding: 1px; 0"
|
||||
class="bg-white border border-base-300 rounded-2xl"
|
||||
/>
|
||||
<input
|
||||
v-if="isTextSignature"
|
||||
@@ -95,18 +120,21 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||
import { cropCanvasAndExportToPNG } from './crop_canvas'
|
||||
import SignaturePad from 'signature_pad'
|
||||
import AppearsOn from './appears_on'
|
||||
|
||||
let isFontLoaded = false
|
||||
|
||||
export default {
|
||||
name: 'SignatureStep',
|
||||
components: {
|
||||
AppearsOn,
|
||||
IconReload,
|
||||
IconCamera,
|
||||
IconTextSize,
|
||||
IconSignature,
|
||||
IconArrowsDiagonalMinimize2
|
||||
},
|
||||
inject: ['baseUrl', 't'],
|
||||
@@ -124,6 +152,11 @@ export default {
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
withTypedSignature: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
attachmentsIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
|
||||
@@ -2,25 +2,36 @@
|
||||
<label
|
||||
v-if="field.name"
|
||||
:for="field.uuid"
|
||||
class="label text-2xl mb-2"
|
||||
>{{ field.name }}
|
||||
dir="auto"
|
||||
class="label text-2xl"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
><template v-if="field.title"><span v-html="field.title" /></template>
|
||||
<template v-else>{{ field.name }}</template>
|
||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||
</label>
|
||||
<div
|
||||
v-else
|
||||
class="py-1"
|
||||
/>
|
||||
<div
|
||||
v-if="field.description"
|
||||
class="mb-3 px-1 text-lg"
|
||||
v-html="field.description"
|
||||
/>
|
||||
<AppearsOn :field="field" />
|
||||
<div class="items-center flex">
|
||||
<input
|
||||
v-if="!isTextArea"
|
||||
:id="field.uuid"
|
||||
v-model="text"
|
||||
:maxlength="cellsMaxLegth"
|
||||
dir="auto"
|
||||
class="base-input !text-2xl w-full !pr-11 -mr-10"
|
||||
:required="field.required"
|
||||
:pattern="field.validation?.pattern"
|
||||
:oninvalid="field.validation?.message ? `this.setCustomValidity(${JSON.stringify(field.validation.message)})` : ''"
|
||||
:oninput="field.validation?.message ? `this.setCustomValidity('')` : ''"
|
||||
:placeholder="`${t('type_here')}...${field.required ? '' : ` (${t('optional')})`}`"
|
||||
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
type="text"
|
||||
:name="`values[${field.uuid}]`"
|
||||
@focus="$emit('focus')"
|
||||
@@ -30,15 +41,16 @@
|
||||
:id="field.uuid"
|
||||
ref="textarea"
|
||||
v-model="text"
|
||||
dir="auto"
|
||||
class="base-textarea !text-2xl w-full"
|
||||
:placeholder="`${t('type_here')}...${field.required ? '' : ` (${t('optional')})`}`"
|
||||
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
:required="field.required"
|
||||
:name="`values[${field.uuid}]`"
|
||||
@input="resizeTextarea"
|
||||
@focus="$emit('focus')"
|
||||
/>
|
||||
<div
|
||||
v-if="!isTextArea"
|
||||
v-if="!isTextArea && field.type !== 'cells'"
|
||||
class="tooltip"
|
||||
:data-tip="t('toggle_multiline_text')"
|
||||
>
|
||||
@@ -55,11 +67,13 @@
|
||||
|
||||
<script>
|
||||
import { IconAlignBoxLeftTop } from '@tabler/icons-vue'
|
||||
import AppearsOn from './appears_on'
|
||||
|
||||
export default {
|
||||
name: 'TextStep',
|
||||
components: {
|
||||
IconAlignBoxLeftTop
|
||||
IconAlignBoxLeftTop,
|
||||
AppearsOn
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
@@ -80,6 +94,19 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
cellsMaxLegth () {
|
||||
if (this.field.type === 'cells') {
|
||||
const area = this.field.areas?.[0]
|
||||
|
||||
if (area) {
|
||||
return parseInt(area.w / area.cell_w) + 1
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
text: {
|
||||
set (value) {
|
||||
this.$emit('update:model-value', value)
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
v-model="field.submitter_uuid"
|
||||
class="border-r"
|
||||
:compact="true"
|
||||
:editable="editable"
|
||||
:editable="editable && !defaultField"
|
||||
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px]'"
|
||||
:submitters="template.submitters"
|
||||
@update:model-value="save"
|
||||
@@ -51,7 +51,7 @@
|
||||
<FieldType
|
||||
v-model="field.type"
|
||||
:button-width="27"
|
||||
:editable="editable"
|
||||
:editable="editable && !defaultField"
|
||||
:button-classes="'px-1'"
|
||||
:menu-classes="'bg-white rounded-t-none'"
|
||||
@update:model-value="[maybeUpdateOptions(), save()]"
|
||||
@@ -60,15 +60,16 @@
|
||||
<span
|
||||
v-if="field.type !== 'checkbox' || field.name"
|
||||
ref="name"
|
||||
:contenteditable="editable"
|
||||
:contenteditable="editable && !defaultField"
|
||||
dir="auto"
|
||||
class="pr-1 cursor-text outline-none block"
|
||||
style="min-width: 2px"
|
||||
@keydown.enter.prevent="onNameEnter"
|
||||
@focus="onNameFocus"
|
||||
@blur="onNameBlur"
|
||||
>{{ field.name || defaultName }}</span>
|
||||
>{{ optionIndexText }} {{ field.name || defaultName }}</span>
|
||||
<div
|
||||
v-if="isNameFocus && field.type !== 'checkbox'"
|
||||
v-if="isNameFocus && !['checkbox', 'phone'].includes(field.type)"
|
||||
class="flex items-center ml-1.5"
|
||||
>
|
||||
<input
|
||||
@@ -83,7 +84,7 @@
|
||||
class="label text-xs"
|
||||
@click.prevent="field.required = !field.required"
|
||||
@mousedown.prevent
|
||||
>Required</label>
|
||||
>{{ t('required') }}</label>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="editable"
|
||||
@@ -104,9 +105,12 @@
|
||||
>
|
||||
<div
|
||||
v-if="field?.default_value"
|
||||
class="text-[1.5vw] lg:text-base"
|
||||
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars }"
|
||||
>
|
||||
<div class="flex items-center px-0.5">
|
||||
<div
|
||||
ref="textContainer"
|
||||
class="flex items-center px-0.5"
|
||||
>
|
||||
<span class="whitespace-pre-wrap">{{ field.default_value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,6 +141,7 @@ import FieldSubmitter from './field_submitter'
|
||||
import FieldType from './field_type'
|
||||
import Field from './field'
|
||||
import { IconX } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
export default {
|
||||
name: 'FieldArea',
|
||||
@@ -145,7 +150,7 @@ export default {
|
||||
FieldSubmitter,
|
||||
IconX
|
||||
},
|
||||
inject: ['template', 'selectedAreaRef', 'save'],
|
||||
inject: ['template', 'selectedAreaRef', 'save', 't'],
|
||||
props: {
|
||||
area: {
|
||||
type: Object,
|
||||
@@ -156,6 +161,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
defaultField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -173,6 +183,7 @@ export default {
|
||||
isResize: false,
|
||||
isDragged: false,
|
||||
isNameFocus: false,
|
||||
textOverflowChars: 0,
|
||||
dragFrom: { x: 0, y: 0 }
|
||||
}
|
||||
},
|
||||
@@ -180,6 +191,13 @@ export default {
|
||||
defaultName: Field.computed.defaultName,
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldIcons: FieldType.computed.fieldIcons,
|
||||
optionIndexText () {
|
||||
if (this.area.option_uuid && this.field.options) {
|
||||
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
cells () {
|
||||
const cells = []
|
||||
|
||||
@@ -201,30 +219,30 @@ export default {
|
||||
},
|
||||
borderColors () {
|
||||
return [
|
||||
'border-red-500/50',
|
||||
'border-sky-500/50',
|
||||
'border-emerald-500/50',
|
||||
'border-yellow-300/50',
|
||||
'border-purple-600/50',
|
||||
'border-pink-500/50',
|
||||
'border-cyan-500/50',
|
||||
'border-orange-500/50',
|
||||
'border-lime-500/50',
|
||||
'border-indigo-500/50'
|
||||
'border-red-500/80',
|
||||
'border-sky-500/80',
|
||||
'border-emerald-500/80',
|
||||
'border-yellow-300/80',
|
||||
'border-purple-600/80',
|
||||
'border-pink-500/80',
|
||||
'border-cyan-500/80',
|
||||
'border-orange-500/80',
|
||||
'border-lime-500/80',
|
||||
'border-indigo-500/80'
|
||||
]
|
||||
},
|
||||
bgColors () {
|
||||
return [
|
||||
'bg-red-100/50',
|
||||
'bg-sky-100/50',
|
||||
'bg-emerald-100/50',
|
||||
'bg-yellow-100/50',
|
||||
'bg-purple-100/50',
|
||||
'bg-pink-100/50',
|
||||
'bg-cyan-100/50',
|
||||
'bg-orange-100/50',
|
||||
'bg-lime-100/50',
|
||||
'bg-indigo-100/50'
|
||||
'bg-red-100/80',
|
||||
'bg-sky-100/80',
|
||||
'bg-emerald-100/80',
|
||||
'bg-yellow-100/80',
|
||||
'bg-purple-100/80',
|
||||
'bg-pink-100/80',
|
||||
'bg-cyan-100/80',
|
||||
'bg-orange-100/80',
|
||||
'bg-lime-100/80',
|
||||
'bg-indigo-100/80'
|
||||
]
|
||||
},
|
||||
isSelected () {
|
||||
@@ -241,6 +259,20 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'field.default_value' () {
|
||||
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value.length)) {
|
||||
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value)) {
|
||||
this.$nextTick(() => {
|
||||
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onNameFocus (e) {
|
||||
this.selectedAreaRef.value = this.area
|
||||
@@ -285,7 +317,7 @@ export default {
|
||||
}
|
||||
|
||||
if (['select', 'multiple', 'radio'].includes(this.field.type)) {
|
||||
this.field.options ||= ['']
|
||||
this.field.options ||= [{ value: '', uuid: v4() }]
|
||||
}
|
||||
|
||||
(this.field.areas || []).forEach((area) => {
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
class="mx-auto pl-3 md:pl-4 h-full"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between py-1.5 items-center pr-4 sticky top-0 z-10"
|
||||
v-if="$slots.buttons || withTitle"
|
||||
class="flex justify-between py-1.5 items-center pr-4 top-0 z-10"
|
||||
:class="{ sticky: withStickySubmitters || isBreakpointLg }"
|
||||
:style="{ backgroundColor }"
|
||||
>
|
||||
<div class="flex space-x-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<a
|
||||
v-if="withLogo"
|
||||
href="/"
|
||||
@@ -15,9 +17,10 @@
|
||||
<Logo />
|
||||
</a>
|
||||
<Contenteditable
|
||||
v-if="withTitle"
|
||||
:model-value="template.name"
|
||||
:editable="editable"
|
||||
class="text-3xl font-semibold focus:text-clip"
|
||||
class="text-xl md:text-3xl font-semibold focus:text-clip"
|
||||
:icon-stroke-width="2.3"
|
||||
@update:model-value="updateName"
|
||||
/>
|
||||
@@ -29,9 +32,24 @@
|
||||
/>
|
||||
<template v-else>
|
||||
<a
|
||||
:href="`/templates/${template.id}/submissions/new`"
|
||||
:href="template.submitters.length > 1 ? `/templates/${template.id}/submissions/new?selfsign=true` : `/d/${template.slug}`"
|
||||
class="btn btn-primary btn-ghost text-base hidden md:flex"
|
||||
:target="template.submitters.length > 1 ? '' : '_blank'"
|
||||
:data-turbo-frame="template.submitters.length > 1 ? 'modal' : ''"
|
||||
@click="maybeShowEmptyTemplateAlert"
|
||||
>
|
||||
<IconWritingSign
|
||||
width="20"
|
||||
class="inline"
|
||||
/>
|
||||
<span class="hidden md:inline">
|
||||
{{ t('sign_yourself') }}
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
:href="`/templates/${template.id}/submissions/new?with_link=true`"
|
||||
data-turbo-frame="modal"
|
||||
class="btn btn-primary text-base"
|
||||
class="white-button md:!px-6"
|
||||
@click="maybeShowEmptyTemplateAlert"
|
||||
>
|
||||
<IconUsersPlus
|
||||
@@ -39,7 +57,7 @@
|
||||
class="inline"
|
||||
/>
|
||||
<span class="hidden md:inline">
|
||||
Recipients
|
||||
{{ t('send') }}
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
@@ -58,14 +76,18 @@
|
||||
width="22"
|
||||
/>
|
||||
<span class="hidden md:inline">
|
||||
Save
|
||||
{{ t('save') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex md:max-h-[calc(100vh-60px)]">
|
||||
<div
|
||||
class="flex"
|
||||
:class="$slots.buttons || withTitle ? 'md:max-h-[calc(100%_-_60px)]' : 'md:max-h-[100%]'"
|
||||
>
|
||||
<div
|
||||
v-if="withDocumentsList"
|
||||
ref="previews"
|
||||
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
|
||||
class="overflow-y-auto overflow-x-hidden w-52 flex-none pr-3 mt-0.5 pt-0.5 hidden lg:block"
|
||||
@@ -77,6 +99,7 @@
|
||||
:item="item"
|
||||
:document="sortedDocuments[index]"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:with-replace-button="withUploadButton"
|
||||
:editable="editable"
|
||||
:template="template"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
@@ -89,10 +112,10 @@
|
||||
/>
|
||||
<div
|
||||
class="sticky bottom-0 py-2"
|
||||
:class="{ 'bg-base-100': withStickySubmitters }"
|
||||
:style="{ backgroundColor }"
|
||||
>
|
||||
<Upload
|
||||
v-if="sortedDocuments.length && editable"
|
||||
v-if="sortedDocuments.length && editable && withUploadButton"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:template-id="template.id"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
@@ -106,7 +129,7 @@
|
||||
class="pr-3.5 pl-0.5"
|
||||
>
|
||||
<Dropzone
|
||||
v-if="!sortedDocuments.length"
|
||||
v-if="!sortedDocuments.length && withUploadButton"
|
||||
:template-id="template.id"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
@@ -122,9 +145,12 @@
|
||||
:areas-index="fieldAreasIndex[document.uuid]"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
:document="document"
|
||||
:is-drag="!!dragFieldType"
|
||||
:is-drag="!!dragField"
|
||||
:default-fields="defaultFields"
|
||||
:allow-draw="!onlyDefinedFields"
|
||||
:draw-field="drawField"
|
||||
:editable="editable"
|
||||
:base-url="baseUrl"
|
||||
@draw="onDraw"
|
||||
@drop-field="onDropfield"
|
||||
@remove-area="removeArea"
|
||||
@@ -133,6 +159,7 @@
|
||||
v-if="isBreakpointLg && editable"
|
||||
:with-arrows="template.schema.length > 1"
|
||||
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
|
||||
:with-replace-button="withUploadButton"
|
||||
:document="document"
|
||||
:template="template"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
@@ -149,8 +176,10 @@
|
||||
class="pb-4"
|
||||
>
|
||||
<Upload
|
||||
v-if="withUploadButton"
|
||||
:template-id="template.id"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
@success="updateFromUpload"
|
||||
/>
|
||||
</div>
|
||||
@@ -164,52 +193,48 @@
|
||||
:selected-submitter="selectedSubmitter"
|
||||
class="md:hidden"
|
||||
:editable="editable"
|
||||
@cancel="drawField = null"
|
||||
@cancel="[drawField = null, drawOption = null]"
|
||||
@change-submitter="[selectedSubmitter = $event, drawField.submitter_uuid = $event.uuid]"
|
||||
/>
|
||||
<FieldType
|
||||
<MobileFields
|
||||
v-if="sortedDocuments.length && !drawField && editable"
|
||||
class="dropdown-top dropdown-end fixed bottom-4 right-4 z-10 md:hidden"
|
||||
:model-value="''"
|
||||
@update:model-value="startFieldDraw($event)"
|
||||
>
|
||||
<label
|
||||
class="btn btn-neutral text-white btn-circle btn-lg group"
|
||||
tabindex="0"
|
||||
>
|
||||
<IconPlus
|
||||
class="group-focus:hidden"
|
||||
width="28"
|
||||
height="28"
|
||||
/>
|
||||
<IconX
|
||||
class="hidden group-focus:inline"
|
||||
width="28"
|
||||
height="28"
|
||||
/>
|
||||
</label>
|
||||
</FieldType>
|
||||
:fields="template.fields"
|
||||
:default-fields="defaultFields"
|
||||
:field-types="fieldTypes"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
@select="startFieldDraw($event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="withFieldsList"
|
||||
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
|
||||
:class="drawField ? 'overflow-hidden' : 'overflow-auto'"
|
||||
:class="drawField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'"
|
||||
>
|
||||
<div
|
||||
v-if="drawField"
|
||||
class="sticky inset-0 bg-base-100 h-full"
|
||||
class="sticky inset-0 h-full z-20"
|
||||
:style="{ backgroundColor }"
|
||||
>
|
||||
<div class="bg-base-300 rounded-lg p-5 text-center space-y-4">
|
||||
<p>
|
||||
Draw {{ drawField.name }} field on the document
|
||||
{{ t('draw_field_on_the_document').replace('{field}', drawField.name) }}
|
||||
</p>
|
||||
<p>
|
||||
<div>
|
||||
<button
|
||||
class="base-button"
|
||||
@click="drawField = null"
|
||||
@click="clearDrawField"
|
||||
>
|
||||
Cancel
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
</p>
|
||||
<a
|
||||
v-if="!drawOption && !drawField.areas.length && !['stamp', 'signature', 'initials'].includes(drawField.type)"
|
||||
href="#"
|
||||
class="link block mt-3 text-sm"
|
||||
@click.prevent="[drawField = null, drawOption = null]"
|
||||
>
|
||||
{{ t('or_add_field_without_drawing') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -218,12 +243,17 @@
|
||||
:fields="template.fields"
|
||||
:submitters="template.submitters"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
:with-help="withHelp"
|
||||
:default-submitters="defaultSubmitters"
|
||||
:default-fields="defaultFields"
|
||||
:field-types="fieldTypes"
|
||||
:with-sticky-submitters="withStickySubmitters"
|
||||
:only-defined-fields="onlyDefinedFields"
|
||||
:editable="editable"
|
||||
@set-draw="drawField = $event"
|
||||
@set-drag="dragFieldType = $event"
|
||||
@set-draw="[drawField = $event.field, drawOption = $event.option]"
|
||||
@set-drag="dragField = $event"
|
||||
@change-submitter="selectedSubmitter = $event"
|
||||
@drag-end="dragFieldType = null"
|
||||
@drag-end="dragField = null"
|
||||
@scroll-to-area="scrollToArea"
|
||||
/>
|
||||
</div>
|
||||
@@ -242,10 +272,11 @@ import Logo from './logo'
|
||||
import Contenteditable from './contenteditable'
|
||||
import DocumentPreview from './preview'
|
||||
import DocumentControls from './controls'
|
||||
import FieldType from './field_type'
|
||||
import { IconUsersPlus, IconDeviceFloppy, IconInnerShadowTop, IconPlus, IconX } from '@tabler/icons-vue'
|
||||
import MobileFields from './mobile_fields'
|
||||
import { IconUsersPlus, IconDeviceFloppy, IconWritingSign, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
import { ref, computed } from 'vue'
|
||||
import { en as i18nEn } from './i18n'
|
||||
|
||||
export default {
|
||||
name: 'TemplateBuilder',
|
||||
@@ -254,9 +285,8 @@ export default {
|
||||
Document,
|
||||
Fields,
|
||||
MobileDrawField,
|
||||
IconPlus,
|
||||
FieldType,
|
||||
IconX,
|
||||
IconWritingSign,
|
||||
MobileFields,
|
||||
Logo,
|
||||
Dropzone,
|
||||
DocumentPreview,
|
||||
@@ -270,9 +300,12 @@ export default {
|
||||
return {
|
||||
template: this.template,
|
||||
save: this.save,
|
||||
t: this.t,
|
||||
baseFetch: this.baseFetch,
|
||||
fieldTypes: this.fieldTypes,
|
||||
backgroundColor: this.backgroundColor,
|
||||
withPhone: this.withPhone,
|
||||
withPayment: this.withPayment,
|
||||
selectedAreaRef: computed(() => this.selectedAreaRef)
|
||||
}
|
||||
},
|
||||
@@ -286,6 +319,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
i18n: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
backgroundColor: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -296,6 +334,31 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withHelp: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
autosave: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
defaultFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
fieldTypes: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
defaultSubmitters: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
acceptFileTypes: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -311,16 +374,60 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
onUpload: {
|
||||
type: Function,
|
||||
required: false,
|
||||
default () {
|
||||
return () => {}
|
||||
}
|
||||
},
|
||||
onSave: {
|
||||
type: Function,
|
||||
required: false,
|
||||
default () {
|
||||
return () => {}
|
||||
}
|
||||
},
|
||||
withStickySubmitters: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withUploadButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withTitle: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withFieldsList: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withDocumentsList: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withPhone: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withPayment: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
onlyDefinedFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
fetchOptions: {
|
||||
type: Object,
|
||||
required: false,
|
||||
@@ -334,12 +441,11 @@ export default {
|
||||
isSaving: false,
|
||||
selectedSubmitter: null,
|
||||
drawField: null,
|
||||
dragFieldType: null
|
||||
drawOption: null,
|
||||
dragField: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldIcons: FieldType.computed.fieldIcons,
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
selectedAreaRef: () => ref(),
|
||||
fieldAreasIndex () {
|
||||
const areas = {}
|
||||
@@ -366,6 +472,22 @@ export default {
|
||||
}
|
||||
},
|
||||
created () {
|
||||
const existingSubmittersUuids = this.defaultSubmitters.map((name) => {
|
||||
return this.template.submitters.find(e => e.name === name)?.uuid
|
||||
})
|
||||
|
||||
this.defaultSubmitters.forEach((name, index) => {
|
||||
const submitter = (this.template.submitters[index] ||= {})
|
||||
|
||||
submitter.name = name
|
||||
|
||||
if (existingSubmittersUuids.filter(Boolean).length) {
|
||||
submitter.uuid = existingSubmittersUuids[index] || v4()
|
||||
} else {
|
||||
submitter.uuid ||= v4()
|
||||
}
|
||||
})
|
||||
|
||||
this.selectedSubmitter = this.template.submitters[0]
|
||||
},
|
||||
mounted () {
|
||||
@@ -380,6 +502,12 @@ export default {
|
||||
window.addEventListener('keydown', this.onKeyDown)
|
||||
|
||||
window.addEventListener('resize', this.onWindowResize)
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (document.location.search?.includes('stripe_connect_success')) {
|
||||
document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
|
||||
}
|
||||
})
|
||||
},
|
||||
unmounted () {
|
||||
document.removeEventListener('keyup', this.onKeyUp)
|
||||
@@ -391,21 +519,42 @@ export default {
|
||||
this.documentRefs = []
|
||||
},
|
||||
methods: {
|
||||
startFieldDraw (type) {
|
||||
const field = {
|
||||
name: '',
|
||||
uuid: v4(),
|
||||
required: type !== 'checkbox',
|
||||
areas: [],
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
type
|
||||
t (key) {
|
||||
return this.i18n[key] || i18nEn[key] || key
|
||||
},
|
||||
startFieldDraw ({ name, type }) {
|
||||
const existingField = this.template.fields?.find((f) => f.submitter_uuid === this.selectedSubmitter.uuid && name && name === f.name)
|
||||
|
||||
if (existingField) {
|
||||
this.drawField = existingField
|
||||
} else {
|
||||
const field = {
|
||||
name: name || '',
|
||||
uuid: v4(),
|
||||
required: type !== 'checkbox',
|
||||
areas: [],
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
type
|
||||
}
|
||||
|
||||
if (['select', 'multiple', 'radio'].includes(type)) {
|
||||
field.options = [{ value: '', uuid: v4() }]
|
||||
}
|
||||
|
||||
if (type === 'stamp') {
|
||||
field.readonly = true
|
||||
}
|
||||
|
||||
if (type === 'date') {
|
||||
field.preferences = {
|
||||
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
|
||||
}
|
||||
}
|
||||
|
||||
this.drawField = field
|
||||
}
|
||||
|
||||
if (['select', 'multiple', 'radio'].includes(type)) {
|
||||
field.options = ['']
|
||||
}
|
||||
|
||||
this.drawField = field
|
||||
this.drawOption = null
|
||||
},
|
||||
undo () {
|
||||
if (this.undoStack.length > 1) {
|
||||
@@ -452,13 +601,25 @@ export default {
|
||||
|
||||
ref.$el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
},
|
||||
clearDrawField () {
|
||||
if (this.drawField && !this.drawOption && this.drawField.areas.length === 0) {
|
||||
const fieldIndex = this.template.fields.indexOf(this.drawField)
|
||||
|
||||
if (fieldIndex !== -1) {
|
||||
this.template.fields.splice(fieldIndex, 1)
|
||||
}
|
||||
}
|
||||
this.drawField = null
|
||||
this.drawOption = null
|
||||
},
|
||||
onKeyUp (e) {
|
||||
if (e.code === 'Escape') {
|
||||
this.drawField = null
|
||||
this.clearDrawField()
|
||||
|
||||
this.selectedAreaRef.value = null
|
||||
}
|
||||
|
||||
if (['Backspace', 'Delete'].includes(e.key) && this.selectedAreaRef.value && document.activeElement === document.body) {
|
||||
if (this.editable && ['Backspace', 'Delete'].includes(e.key) && this.selectedAreaRef.value && document.activeElement === document.body) {
|
||||
this.removeArea(this.selectedAreaRef.value)
|
||||
|
||||
this.selectedAreaRef.value = null
|
||||
@@ -501,14 +662,69 @@ export default {
|
||||
},
|
||||
onDraw (area) {
|
||||
if (this.drawField) {
|
||||
if (this.drawOption) {
|
||||
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
|
||||
|
||||
if (areaWithoutOption && !this.drawField.areas.find((a) => a.option_uuid === this.drawField.options[0].uuid)) {
|
||||
areaWithoutOption.option_uuid = this.drawField.options[0].uuid
|
||||
}
|
||||
|
||||
area.option_uuid = this.drawOption.uuid
|
||||
}
|
||||
|
||||
if (area.w === 0 || area.h === 0) {
|
||||
const previousArea = this.drawField.areas?.[this.drawField.areas.length - 1]
|
||||
|
||||
if (this.selectedField?.type === this.drawField.type) {
|
||||
area.w = this.selectedAreaRef.value.w
|
||||
area.h = this.selectedAreaRef.value.h
|
||||
} else if (previousArea) {
|
||||
area.w = previousArea.w
|
||||
area.h = previousArea.h
|
||||
} else {
|
||||
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
|
||||
const pageMask = documentRef.pageRefs[area.page].$refs.mask
|
||||
|
||||
if (this.drawField.type === 'checkbox' || this.drawOption) {
|
||||
area.w = pageMask.clientWidth / 30 / pageMask.clientWidth
|
||||
area.h = (pageMask.clientWidth / 30 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
|
||||
} else if (this.drawField.type === 'image') {
|
||||
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
|
||||
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
|
||||
} else if (this.drawField.type === 'signature' || this.drawField.type === 'stamp') {
|
||||
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
|
||||
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight) / 2
|
||||
} else if (this.drawField.type === 'initials') {
|
||||
area.w = pageMask.clientWidth / 10 / pageMask.clientWidth
|
||||
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
|
||||
} else {
|
||||
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
|
||||
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
|
||||
}
|
||||
}
|
||||
|
||||
area.x -= area.w / 2
|
||||
area.y -= area.h / 2
|
||||
}
|
||||
|
||||
this.drawField.areas ||= []
|
||||
this.drawField.areas.push(area)
|
||||
|
||||
const insertBeforeAreaIndex = this.drawField.areas.findIndex((a) => {
|
||||
return a.attachment_uuid === area.attachment_uuid && a.page > area.page
|
||||
})
|
||||
|
||||
if (insertBeforeAreaIndex !== -1) {
|
||||
this.drawField.areas.splice(insertBeforeAreaIndex, 0, area)
|
||||
} else {
|
||||
this.drawField.areas.push(area)
|
||||
}
|
||||
|
||||
if (this.template.fields.indexOf(this.drawField) === -1) {
|
||||
this.template.fields.push(this.drawField)
|
||||
}
|
||||
|
||||
this.drawField = null
|
||||
this.drawOption = null
|
||||
|
||||
this.selectedAreaRef.value = area
|
||||
|
||||
@@ -517,7 +733,11 @@ export default {
|
||||
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
|
||||
const pageMask = documentRef.pageRefs[area.page].$refs.mask
|
||||
|
||||
const type = (pageMask.clientWidth * area.w) < 35 ? 'checkbox' : 'text'
|
||||
let type = (pageMask.clientWidth * area.w) < 35 ? 'checkbox' : 'text'
|
||||
|
||||
if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) {
|
||||
type = this.fieldTypes[0]
|
||||
}
|
||||
|
||||
if (type === 'checkbox') {
|
||||
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
|
||||
@@ -558,14 +778,24 @@ export default {
|
||||
onDropfield (area) {
|
||||
const field = {
|
||||
name: '',
|
||||
type: this.dragFieldType,
|
||||
uuid: v4(),
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
required: this.dragFieldType !== 'checkbox'
|
||||
required: this.dragField.type !== 'checkbox',
|
||||
...this.dragField
|
||||
}
|
||||
|
||||
if (['select', 'multiple', 'radio'].includes(this.dragFieldType)) {
|
||||
field.options = ['']
|
||||
if (['select', 'multiple', 'radio'].includes(field.type)) {
|
||||
field.options = [{ value: '', uuid: v4() }]
|
||||
}
|
||||
|
||||
if (field.type === 'stamp') {
|
||||
field.readonly = true
|
||||
}
|
||||
|
||||
if (field.type === 'date') {
|
||||
field.preferences = {
|
||||
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
|
||||
}
|
||||
}
|
||||
|
||||
const fieldArea = {
|
||||
@@ -579,27 +809,27 @@ export default {
|
||||
|
||||
let baseArea
|
||||
|
||||
if (this.selectedField?.type === this.dragFieldType) {
|
||||
if (this.selectedField?.type === field.type) {
|
||||
baseArea = this.selectedAreaRef.value
|
||||
} else if (previousField?.areas?.length) {
|
||||
baseArea = previousField.areas[previousField.areas.length - 1]
|
||||
} else {
|
||||
if (['checkbox'].includes(this.dragFieldType)) {
|
||||
if (['checkbox'].includes(field.type)) {
|
||||
baseArea = {
|
||||
w: area.maskW / 30 / area.maskW,
|
||||
h: area.maskW / 30 / area.maskW * (area.maskW / area.maskH)
|
||||
}
|
||||
} else if (this.dragFieldType === 'image') {
|
||||
} else if (field.type === 'image') {
|
||||
baseArea = {
|
||||
w: area.maskW / 5 / area.maskW,
|
||||
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH)
|
||||
}
|
||||
} else if (this.dragFieldType === 'signature') {
|
||||
} else if (field.type === 'signature' || field.type === 'stamp') {
|
||||
baseArea = {
|
||||
w: area.maskW / 5 / area.maskW,
|
||||
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
|
||||
}
|
||||
} else if (this.dragFieldType === 'initials') {
|
||||
} else if (field.type === 'initials') {
|
||||
baseArea = {
|
||||
w: area.maskW / 10 / area.maskW,
|
||||
h: area.maskW / 35 / area.maskW
|
||||
@@ -616,7 +846,7 @@ export default {
|
||||
fieldArea.h = baseArea.h
|
||||
fieldArea.y = fieldArea.y - baseArea.h / 2
|
||||
|
||||
if (this.dragFieldType === 'cells') {
|
||||
if (field.type === 'cells') {
|
||||
fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5)
|
||||
}
|
||||
|
||||
@@ -638,6 +868,14 @@ export default {
|
||||
this.scrollIntoDocument(schema[0])
|
||||
})
|
||||
|
||||
if (this.template.name === 'New Document') {
|
||||
this.template.name = this.template.schema[0].name
|
||||
}
|
||||
|
||||
if (this.onUpload) {
|
||||
this.onUpload(this.template)
|
||||
}
|
||||
|
||||
this.save()
|
||||
},
|
||||
updateName (value) {
|
||||
@@ -646,7 +884,7 @@ export default {
|
||||
this.save()
|
||||
},
|
||||
onDocumentRemove (item) {
|
||||
if (window.confirm('Are you sure?')) {
|
||||
if (window.confirm(this.t('are_you_sure'))) {
|
||||
this.template.schema.splice(this.template.schema.indexOf(item), 1)
|
||||
}
|
||||
|
||||
@@ -671,6 +909,10 @@ export default {
|
||||
})
|
||||
})
|
||||
|
||||
if (this.onUpload) {
|
||||
this.onUpload(this.template)
|
||||
}
|
||||
|
||||
this.save()
|
||||
},
|
||||
moveDocument (item, direction) {
|
||||
@@ -721,11 +963,17 @@ export default {
|
||||
headers: { ...this.fetchOptions.headers, ...options.headers }
|
||||
})
|
||||
},
|
||||
save () {
|
||||
if (this.$el.closest('template-builder')) {
|
||||
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
|
||||
save ({ force } = { force: false }) {
|
||||
if (!this.autosave && !force) {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.$el.closest('template-builder')) {
|
||||
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
|
||||
}
|
||||
})
|
||||
|
||||
this.pushUndo()
|
||||
|
||||
return this.baseFetch(`/api/templates/${this.template.id}`, {
|
||||
@@ -739,6 +987,10 @@ export default {
|
||||
}
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then(() => {
|
||||
if (this.onSave) {
|
||||
this.onSave(this.template)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
>
|
||||
<span
|
||||
ref="contenteditable"
|
||||
dir="auto"
|
||||
:contenteditable="editable"
|
||||
style="min-width: 2px"
|
||||
:class="iconInline ? 'inline' : 'block'"
|
||||
@@ -24,11 +25,10 @@
|
||||
*
|
||||
</span>
|
||||
<IconPencil
|
||||
v-if="editable"
|
||||
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
|
||||
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
|
||||
title="Edit"
|
||||
:class="{ 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
|
||||
:class="{ invisible: !editable, 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
|
||||
:width="iconWidth"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
@click="[focusContenteditable(), selectOnEditClick && selectContent()]"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
@update:model-value="onUpdateName"
|
||||
/>
|
||||
<ReplaceButton
|
||||
v-if="withReplaceButton"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:template-id="template.id"
|
||||
@click.stop
|
||||
@@ -67,6 +68,11 @@ export default {
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
withReplaceButton: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: true
|
||||
},
|
||||
withArrows: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
:number="index"
|
||||
:editable="editable"
|
||||
:areas="areasIndex[index]"
|
||||
:allow-draw="allowDraw"
|
||||
:is-drag="isDrag"
|
||||
:default-fields="defaultFields"
|
||||
:draw-field="drawField"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
:image="image"
|
||||
@@ -35,6 +37,16 @@ export default {
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
defaultFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
allowDraw: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
selectedSubmitter: {
|
||||
type: Object,
|
||||
required: true
|
||||
@@ -49,6 +61,11 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
baseUrl: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
isDrag: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -62,6 +79,13 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
basePreviewUrl () {
|
||||
if (this.baseUrl) {
|
||||
return new URL(this.baseUrl).origin
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
numberOfPages () {
|
||||
return this.document.metadata?.pdf?.number_of_pages || this.document.preview_images.length
|
||||
},
|
||||
@@ -72,7 +96,7 @@ export default {
|
||||
return this.previewImagesIndex[i] || {
|
||||
metadata: lazyloadMetadata,
|
||||
id: Math.random().toString(),
|
||||
url: `/preview/${this.document.uuid}/${i}.jpg`
|
||||
url: this.basePreviewUrl + `/preview/${this.document.uuid}/${i}.jpg`
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
{{ message }}
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span class="font-medium">Click to upload</span> or drag and drop files
|
||||
<span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,7 +61,7 @@ export default {
|
||||
IconCloudUpload,
|
||||
IconInnerShadowTop
|
||||
},
|
||||
inject: ['baseFetch'],
|
||||
inject: ['baseFetch', 't'],
|
||||
props: {
|
||||
templateId: {
|
||||
type: [Number, String],
|
||||
@@ -91,13 +91,13 @@ export default {
|
||||
},
|
||||
message () {
|
||||
if (this.isLoading) {
|
||||
return 'Uploading...'
|
||||
return this.t('uploading')
|
||||
} else if (this.isProcessing) {
|
||||
return 'Processing...'
|
||||
return this.t('processing_')
|
||||
} else if (this.acceptFileTypes === 'image/*, application/pdf') {
|
||||
return 'Add PDF documents or images'
|
||||
return this.t('add_pdf_documents_or_images')
|
||||
} else {
|
||||
return 'Add documents or images'
|
||||
return this.t('add_documents_or_images')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="flex items-center p-1 space-x-1">
|
||||
<FieldType
|
||||
v-model="field.type"
|
||||
:editable="editable"
|
||||
:editable="editable && !defaultField"
|
||||
:button-width="20"
|
||||
@update:model-value="[maybeUpdateOptions(), save()]"
|
||||
@click="scrollToFirstArea"
|
||||
@@ -22,7 +22,7 @@
|
||||
<Contenteditable
|
||||
ref="name"
|
||||
:model-value="field.name || defaultName"
|
||||
:editable="editable"
|
||||
:editable="editable && !defaultField"
|
||||
:icon-inline="true"
|
||||
:icon-width="18"
|
||||
:icon-stroke-width="1.6"
|
||||
@@ -34,19 +34,21 @@
|
||||
v-if="isNameFocus"
|
||||
class="flex items-center relative"
|
||||
>
|
||||
<input
|
||||
:id="`required-checkbox-${field.uuid}`"
|
||||
v-model="field.required"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-xs no-animation rounded"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<label
|
||||
:for="`required-checkbox-${field.uuid}`"
|
||||
class="label text-xs"
|
||||
@click.prevent="field.required = !field.required"
|
||||
@mousedown.prevent
|
||||
>Required</label>
|
||||
<template v-if="field.type != 'phone'">
|
||||
<input
|
||||
:id="`required-checkbox-${field.uuid}`"
|
||||
v-model="field.required"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-xs no-animation rounded"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<label
|
||||
:for="`required-checkbox-${field.uuid}`"
|
||||
class="label text-xs"
|
||||
@click.prevent="field.required = !field.required"
|
||||
@mousedown.prevent
|
||||
>{{ t('required') }}</label>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="editable"
|
||||
@@ -56,15 +58,22 @@
|
||||
v-if="field && !field.areas.length"
|
||||
title="Draw"
|
||||
class="relative cursor-pointer text-transparent group-hover:text-base-content"
|
||||
@click="$emit('set-draw', field)"
|
||||
@click="$emit('set-draw', { field })"
|
||||
>
|
||||
<IconNewSection
|
||||
:width="18"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
<PaymentSettings
|
||||
v-if="field.type === 'payment'"
|
||||
:field="field"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="dropdown dropdown-end"
|
||||
@mouseenter="renderDropdown = true"
|
||||
@touchstart="renderDropdown = true"
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
@@ -77,6 +86,7 @@
|
||||
/>
|
||||
</label>
|
||||
<ul
|
||||
v-if="renderDropdown"
|
||||
tabindex="0"
|
||||
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow bg-base-100 rounded-box w-52 z-10"
|
||||
draggable="true"
|
||||
@@ -84,14 +94,15 @@
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<div
|
||||
v-if="field.type === 'text'"
|
||||
v-if="field.type === 'text' && !defaultField"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
v-model="field.default_value"
|
||||
type="text"
|
||||
placeholder="Default value"
|
||||
:placeholder="t('default_value')"
|
||||
dir="auto"
|
||||
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
|
||||
@blur="save"
|
||||
>
|
||||
@@ -101,10 +112,40 @@
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>
|
||||
Default value
|
||||
{{ t('default_value') }}
|
||||
</label>
|
||||
</div>
|
||||
<li @click.stop>
|
||||
<div
|
||||
v-if="field.type === 'date'"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<select
|
||||
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"
|
||||
@change="save"
|
||||
>
|
||||
<option
|
||||
v-for="format in dateFormats"
|
||||
:key="format"
|
||||
:value="format"
|
||||
>
|
||||
{{ formatDate(new Date(), format) }}
|
||||
</option>
|
||||
</select>
|
||||
<label
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>
|
||||
{{ t('format') }}
|
||||
</label>
|
||||
</div>
|
||||
<li
|
||||
v-if="field.type != 'phone'"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
<input
|
||||
v-model="field.required"
|
||||
@@ -112,11 +153,11 @@
|
||||
class="toggle toggle-xs"
|
||||
@update:model-value="save"
|
||||
>
|
||||
<span class="label-text">Required</span>
|
||||
<span class="label-text">{{ t('required') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type === 'text'"
|
||||
v-if="field.type === 'text' && !defaultField"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
@@ -126,7 +167,7 @@
|
||||
class="toggle toggle-xs"
|
||||
@update:model-value="save"
|
||||
>
|
||||
<span class="label-text">Read-only</span>
|
||||
<span class="label-text">{{ t('read_only') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<hr class="pb-0.5 mt-0.5">
|
||||
@@ -143,20 +184,20 @@
|
||||
:width="20"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
Page {{ area.page + 1 }}
|
||||
{{ t('page') }} {{ area.page + 1 }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<li v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)">
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm py-1 px-2"
|
||||
@click.prevent="$emit('set-draw', field)"
|
||||
@click.prevent="$emit('set-draw', { field })"
|
||||
>
|
||||
<IconNewSection
|
||||
:width="20"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
Draw New Area
|
||||
{{ t('draw_new_area') }}
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells'].includes(field.type)">
|
||||
@@ -169,7 +210,7 @@
|
||||
:width="20"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
Copy to All Pages
|
||||
{{ t('copy_to_all_pages') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -188,26 +229,58 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="field.options"
|
||||
ref="options"
|
||||
class="border-t border-base-300 mx-2 pt-2 space-y-1.5"
|
||||
draggable="true"
|
||||
@dragstart.prevent.stop
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in field.options"
|
||||
:key="index"
|
||||
:key="option.uuid"
|
||||
class="flex space-x-1.5 items-center"
|
||||
>
|
||||
<span class="text-sm w-3.5">
|
||||
{{ index + 1 }}.
|
||||
</span>
|
||||
<div
|
||||
v-if="['radio', 'multiple'].includes(field.type) && (index > 0 || field.areas.find((a) => a.option_uuid) || !field.areas.length) && !field.areas.find((a) => a.option_uuid === option.uuid)"
|
||||
class="items-center flex w-full"
|
||||
>
|
||||
<input
|
||||
v-model="option.value"
|
||||
class="w-full input input-primary input-xs text-sm bg-transparent !pr-7 -mr-6"
|
||||
type="text"
|
||||
dir="auto"
|
||||
required
|
||||
:placeholder="`${t('option')} ${index + 1}`"
|
||||
@blur="save"
|
||||
>
|
||||
<button
|
||||
title="Draw"
|
||||
tabindex="-1"
|
||||
@click.prevent="$emit('set-draw', { field, option })"
|
||||
>
|
||||
<IconNewSection
|
||||
:width="18"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="field.options[index]"
|
||||
v-else
|
||||
v-model="option.value"
|
||||
class="w-full input input-primary input-xs text-sm bg-transparent"
|
||||
:placeholder="`${t('option')} ${index + 1}`"
|
||||
type="text"
|
||||
required
|
||||
dir="auto"
|
||||
@focus="maybeFocusOnOptionArea(option)"
|
||||
@blur="save"
|
||||
>
|
||||
<button
|
||||
class="text-sm w-3.5"
|
||||
@click="[field.options.splice(index, 1), save()]"
|
||||
tabindex="-1"
|
||||
@click="removeOption(option)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -215,9 +288,9 @@
|
||||
<button
|
||||
v-if="field.options"
|
||||
class="text-center text-sm w-full pb-1"
|
||||
@click="[field.options.push(''), save()]"
|
||||
@click="addOption"
|
||||
>
|
||||
+ Add option
|
||||
+ {{ t('add_option') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,7 +300,9 @@
|
||||
<script>
|
||||
import Contenteditable from './contenteditable'
|
||||
import FieldType from './field_type'
|
||||
import PaymentSettings from './payment_settings'
|
||||
import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
export default {
|
||||
name: 'TemplateField',
|
||||
@@ -235,17 +310,23 @@ export default {
|
||||
Contenteditable,
|
||||
IconSettings,
|
||||
IconShape,
|
||||
PaymentSettings,
|
||||
IconNewSection,
|
||||
IconTrashX,
|
||||
IconCopy,
|
||||
FieldType
|
||||
},
|
||||
inject: ['template', 'save', 'backgroundColor'],
|
||||
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
defaultField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -255,30 +336,93 @@ export default {
|
||||
emits: ['set-draw', 'remove', 'scroll-to'],
|
||||
data () {
|
||||
return {
|
||||
isNameFocus: false
|
||||
isNameFocus: false,
|
||||
showPaymentModal: false,
|
||||
renderDropdown: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
dateFormats () {
|
||||
return [
|
||||
'MM/DD/YYYY',
|
||||
'DD/MM/YYYY',
|
||||
'YYYY-MM-DD',
|
||||
'DD-MM-YYYY',
|
||||
'DD.MM.YYYY',
|
||||
'MMM D, YYYY',
|
||||
'MMMM D, YYYY',
|
||||
'D MMM YYYY',
|
||||
'D MMMM YYYY'
|
||||
]
|
||||
},
|
||||
defaultName () {
|
||||
const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field)
|
||||
if (this.field.type === 'payment' && this.field.preferences?.price) {
|
||||
const { price, currency } = this.field.preferences || {}
|
||||
|
||||
const suffix = { multiple: 'Select', radio: 'Group' }[this.field.type] || 'Field'
|
||||
const formattedPrice = new Intl.NumberFormat([], {
|
||||
style: 'currency',
|
||||
currency
|
||||
}).format(price)
|
||||
|
||||
return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}`
|
||||
return `${this.fieldNames[this.field.type]} ${formattedPrice}`
|
||||
} else {
|
||||
const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field)
|
||||
|
||||
const suffix = { multiple: this.t('select'), radio: this.t('group') }[this.field.type] || this.t('field')
|
||||
|
||||
return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}`
|
||||
}
|
||||
},
|
||||
areas () {
|
||||
return this.field.areas || []
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.field.preferences ||= {}
|
||||
|
||||
if (this.field.type === 'date') {
|
||||
this.field.preferences.format ||=
|
||||
(Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatDate (date, format) {
|
||||
const monthFormats = {
|
||||
M: 'numeric',
|
||||
MM: '2-digit',
|
||||
MMM: 'short',
|
||||
MMMM: 'long'
|
||||
}
|
||||
|
||||
const dayFormats = {
|
||||
D: 'numeric',
|
||||
DD: '2-digit'
|
||||
}
|
||||
|
||||
const yearFormats = {
|
||||
YYYY: 'numeric',
|
||||
YY: '2-digit'
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat([], {
|
||||
day: dayFormats[format.match(/D+/)],
|
||||
month: monthFormats[format.match(/M+/)],
|
||||
year: yearFormats[format.match(/Y+/)]
|
||||
}).formatToParts(date)
|
||||
|
||||
return format
|
||||
.replace(/D+/, parts.find((p) => p.type === 'day').value)
|
||||
.replace(/M+/, parts.find((p) => p.type === 'month').value)
|
||||
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
|
||||
},
|
||||
copyToAllPages (field) {
|
||||
const areaString = JSON.stringify(field.areas[0])
|
||||
|
||||
this.template.documents.forEach((attachment) => {
|
||||
attachment.preview_images.forEach((page) => {
|
||||
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === parseInt(page.filename))) {
|
||||
field.areas.push({ ...JSON.parse(areaString), page: parseInt(page.filename) })
|
||||
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page: parseInt(page.filename) })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -298,12 +442,36 @@ export default {
|
||||
}, 1)
|
||||
}
|
||||
},
|
||||
maybeFocusOnOptionArea (option) {
|
||||
const area = this.field.areas.find((a) => a.option_uuid === option.uuid)
|
||||
|
||||
if (area) {
|
||||
this.selectedAreaRef.value = area
|
||||
}
|
||||
},
|
||||
scrollToFirstArea () {
|
||||
return this.field.areas?.[0] && this.$emit('scroll-to', this.field.areas[0])
|
||||
},
|
||||
closeDropdown () {
|
||||
document.activeElement.blur()
|
||||
},
|
||||
addOption () {
|
||||
this.field.options.push({ value: '', uuid: v4() })
|
||||
|
||||
this.$nextTick(() => {
|
||||
const inputs = this.$refs.options.querySelectorAll('input')
|
||||
|
||||
inputs[inputs.length - 1]?.focus()
|
||||
})
|
||||
|
||||
this.save()
|
||||
},
|
||||
removeOption (option) {
|
||||
this.field.options.splice(this.field.options.indexOf(option), 1)
|
||||
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
|
||||
|
||||
this.save()
|
||||
},
|
||||
maybeUpdateOptions () {
|
||||
delete this.field.default_value
|
||||
|
||||
@@ -312,7 +480,7 @@ export default {
|
||||
}
|
||||
|
||||
if (['radio', 'multiple', 'select'].includes(this.field.type)) {
|
||||
this.field.options ||= ['']
|
||||
this.field.options ||= [{ value: '', uuid: v4() }]
|
||||
}
|
||||
|
||||
(this.field.areas || []).forEach((area) => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div v-if="mobileView">
|
||||
<div
|
||||
v-if="mobileView"
|
||||
@mouseenter="renderDropdown = true"
|
||||
@touchstart="renderDropdown = true"
|
||||
>
|
||||
<div class="flex space-x-2 items-end">
|
||||
<div class="group/contenteditable-container bg-base-100 rounded-md p-2 border border-base-300 w-full flex justify-between items-end">
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -29,7 +33,7 @@
|
||||
/>
|
||||
</label>
|
||||
<ul
|
||||
v-if="editable"
|
||||
v-if="editable && renderDropdown"
|
||||
tabindex="0"
|
||||
class="rounded-md min-w-max mb-2"
|
||||
:class="menuClasses"
|
||||
@@ -57,7 +61,7 @@
|
||||
<button
|
||||
v-if="submitters.length > 1 && editable"
|
||||
class="px-2"
|
||||
@click.stop="remove(submitter)"
|
||||
@click.prevent.stop="remove(submitter)"
|
||||
>
|
||||
<IconTrashX :width="18" />
|
||||
</button>
|
||||
@@ -74,7 +78,7 @@
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<span class="py-1">
|
||||
Add Submitter
|
||||
{{ t('add') }} {{ names[submitters.length] }}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -85,6 +89,8 @@
|
||||
<div
|
||||
v-else
|
||||
class="dropdown"
|
||||
@mouseenter="renderDropdown = true"
|
||||
@touchstart="renderDropdown = true"
|
||||
>
|
||||
<label
|
||||
v-if="compact"
|
||||
@@ -99,8 +105,9 @@
|
||||
</label>
|
||||
<label
|
||||
v-else
|
||||
ref="label"
|
||||
tabindex="0"
|
||||
class="cursor-pointer group/contenteditable-container rounded-md p-2 border border-base-300 w-full flex justify-between"
|
||||
class="group cursor-pointer group/contenteditable-container rounded-md p-2 border border-base-300 hover:border-content w-full flex justify-between"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
@@ -117,7 +124,7 @@
|
||||
@update:model-value="$emit('name-change', selectedSubmitter)"
|
||||
/>
|
||||
</div>
|
||||
<span class="flex items-center">
|
||||
<span class="flex items-center transition-all duration-75 group-hover:border border-base-content/20 border-dashed w-6 h-6 flex justify-center items-center rounded">
|
||||
<IconPlus
|
||||
width="18"
|
||||
height="18"
|
||||
@@ -125,7 +132,7 @@
|
||||
</span>
|
||||
</label>
|
||||
<ul
|
||||
v-if="editable || !compact"
|
||||
v-if="(editable || !compact) && renderDropdown"
|
||||
tabindex="0"
|
||||
:class="menuClasses"
|
||||
@click="closeDropdown"
|
||||
@@ -149,13 +156,36 @@
|
||||
{{ submitter.name }}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
<div
|
||||
v-if="!compact && submitters.length > 1 && editable"
|
||||
class="hidden group-hover:block px-2"
|
||||
@click.stop="remove(submitter)"
|
||||
class="flex"
|
||||
>
|
||||
<IconTrashX :width="18" />
|
||||
</button>
|
||||
<div class="flex-col pr-1 hidden group-hover:flex -mt-1 h-0">
|
||||
<button
|
||||
title="Up"
|
||||
class="relative w-2"
|
||||
style="font-size: 10px; margin-bottom: -4px"
|
||||
@click.prevent.stop="[move(submitter, -1), $refs.label.focus()] "
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
title="Down"
|
||||
class="relative w-2"
|
||||
style="font-size: 10px; margin-top: -4px"
|
||||
@click.prevent.stop="[move(submitter, 1), $refs.label.focus()] "
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="!compact && submitters.length > 1 && editable"
|
||||
class="hidden group-hover:block px-2"
|
||||
@click.prevent.stop="remove(submitter)"
|
||||
>
|
||||
<IconTrashX :width="18" />
|
||||
</button>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="submitters.length < 10 && editable">
|
||||
@@ -169,7 +199,7 @@
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<span class="py-1">
|
||||
Add Submitter
|
||||
{{ t('add') }} {{ names[submitters.length] }}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -191,6 +221,7 @@ export default {
|
||||
IconTrashX,
|
||||
IconChevronUp
|
||||
},
|
||||
inject: ['t', 'save'],
|
||||
props: {
|
||||
submitters: {
|
||||
type: Array,
|
||||
@@ -222,6 +253,11 @@ export default {
|
||||
}
|
||||
},
|
||||
emits: ['update:model-value', 'remove', 'new-submitter', 'name-change'],
|
||||
data () {
|
||||
return {
|
||||
renderDropdown: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
colors () {
|
||||
return [
|
||||
@@ -239,16 +275,16 @@ export default {
|
||||
},
|
||||
names () {
|
||||
return [
|
||||
'First Submitter',
|
||||
'Second Submitter',
|
||||
'Third Submitter',
|
||||
'Fourth Submitter',
|
||||
'Fifth Submitter',
|
||||
'Sixth Submitter',
|
||||
'Seventh Submitter',
|
||||
'Eighth Submitter',
|
||||
'Ninth Submitter',
|
||||
'Tenth Submitter'
|
||||
this.t('first_party'),
|
||||
this.t('second_party'),
|
||||
this.t('third_party'),
|
||||
this.t('fourth_party'),
|
||||
this.t('fifth_party'),
|
||||
this.t('sixth_party'),
|
||||
this.t('seventh_party'),
|
||||
this.t('eighth_party'),
|
||||
this.t('ninth_party'),
|
||||
this.t('tenth_party')
|
||||
]
|
||||
},
|
||||
selectedSubmitter () {
|
||||
@@ -260,10 +296,27 @@ export default {
|
||||
this.$emit('update:model-value', submitter.uuid)
|
||||
},
|
||||
remove (submitter) {
|
||||
if (window.confirm('Are you sure?')) {
|
||||
if (window.confirm(this.t('are_you_sure'))) {
|
||||
this.$emit('remove', submitter)
|
||||
}
|
||||
},
|
||||
move (submitter, direction) {
|
||||
const currentIndex = this.submitters.indexOf(submitter)
|
||||
|
||||
this.submitters.splice(currentIndex, 1)
|
||||
|
||||
if (currentIndex + direction > this.submitters.length) {
|
||||
this.submitters.unshift(submitter)
|
||||
} else if (currentIndex + direction < 0) {
|
||||
this.submitters.push(submitter)
|
||||
} else {
|
||||
this.submitters.splice(currentIndex + direction, 0, submitter)
|
||||
}
|
||||
|
||||
this.selectSubmitter(submitter)
|
||||
|
||||
this.save()
|
||||
},
|
||||
addSubmitter () {
|
||||
const newSubmitter = {
|
||||
name: this.names[this.submitters.length],
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<span class="dropdown">
|
||||
<span
|
||||
class="dropdown"
|
||||
@mouseenter="renderDropdown = true"
|
||||
@touchstart="renderDropdown = true"
|
||||
>
|
||||
<slot>
|
||||
<label
|
||||
tabindex="0"
|
||||
@@ -15,7 +19,7 @@
|
||||
</label>
|
||||
</slot>
|
||||
<ul
|
||||
v-if="editable"
|
||||
v-if="editable && renderDropdown"
|
||||
tabindex="0"
|
||||
class="dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10 mb-3"
|
||||
:class="menuClasses"
|
||||
@@ -25,7 +29,7 @@
|
||||
v-for="(icon, type) in fieldIcons"
|
||||
:key="type"
|
||||
>
|
||||
<li v-if="withPhone || type !== 'phone'">
|
||||
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')">
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm py-1 px-2"
|
||||
@@ -46,11 +50,11 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue'
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FiledTypeDropdown',
|
||||
inject: ['withPhone'],
|
||||
inject: ['withPhone', 'withPayment', 't', 'fieldTypes'],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
@@ -78,21 +82,28 @@ export default {
|
||||
}
|
||||
},
|
||||
emits: ['update:model-value'],
|
||||
data () {
|
||||
return {
|
||||
renderDropdown: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames () {
|
||||
return {
|
||||
text: 'Text',
|
||||
signature: 'Signature',
|
||||
initials: 'Initials',
|
||||
date: 'Date',
|
||||
image: 'Image',
|
||||
file: 'File',
|
||||
select: 'Select',
|
||||
checkbox: 'Checkbox',
|
||||
multiple: 'Multiple',
|
||||
radio: 'Radio',
|
||||
cells: 'Cells',
|
||||
phone: 'Phone'
|
||||
text: this.t('text'),
|
||||
signature: this.t('signature'),
|
||||
initials: this.t('initials'),
|
||||
date: this.t('date'),
|
||||
image: this.t('image'),
|
||||
file: this.t('file'),
|
||||
select: this.t('select'),
|
||||
checkbox: this.t('checkbox'),
|
||||
multiple: this.t('multiple'),
|
||||
radio: this.t('radio'),
|
||||
cells: this.t('cells'),
|
||||
stamp: this.t('stamp'),
|
||||
payment: this.t('payment'),
|
||||
phone: this.t('phone')
|
||||
}
|
||||
},
|
||||
fieldIcons () {
|
||||
@@ -103,11 +114,13 @@ export default {
|
||||
date: IconCalendarEvent,
|
||||
image: IconPhoto,
|
||||
file: IconPaperclip,
|
||||
select: IconSelect,
|
||||
checkbox: IconCheckbox,
|
||||
radio: IconCircleDot,
|
||||
select: IconSelect,
|
||||
multiple: IconChecks,
|
||||
cells: IconColumns3,
|
||||
stamp: IconRubberStamp,
|
||||
payment: IconCreditCard,
|
||||
phone: IconPhoneCheck
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
class="w-full rounded-lg"
|
||||
:class="{ 'bg-base-100': withStickySubmitters }"
|
||||
:submitters="submitters"
|
||||
:editable="editable"
|
||||
:editable="editable && !defaultSubmitters.length"
|
||||
@new-submitter="save"
|
||||
@remove="removeSubmitter"
|
||||
@name-change="save"
|
||||
@@ -13,9 +13,10 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref="fields"
|
||||
class="mb-1 mt-2"
|
||||
@dragover.prevent="onFieldDragover"
|
||||
@drop="save"
|
||||
@drop="reorderFields"
|
||||
>
|
||||
<Field
|
||||
v-for="field in submitterFields"
|
||||
@@ -23,7 +24,8 @@
|
||||
:data-uuid="field.uuid"
|
||||
:field="field"
|
||||
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
|
||||
:editable="editable && !dragField"
|
||||
:editable="editable && (!dragField || dragField !== field)"
|
||||
:default-field="defaultFields.find((f) => f.name === field.name)"
|
||||
:draggable="editable"
|
||||
@dragstart="dragField = field"
|
||||
@dragend="dragField = null"
|
||||
@@ -32,8 +34,37 @@
|
||||
@set-draw="$emit('set-draw', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="submitterDefaultFields.length">
|
||||
<hr class="mb-2">
|
||||
<template
|
||||
v-for="field in submitterDefaultFields"
|
||||
:key="field.name"
|
||||
>
|
||||
<div
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
draggable="true"
|
||||
class="border border-base-300 rounded rounded-tr-none relative group mb-2"
|
||||
@dragstart="onDragstart({ type: 'text', ...field })"
|
||||
@dragend="$emit('drag-end')"
|
||||
>
|
||||
<div class="flex items-center justify-between relative cursor-grab">
|
||||
<div class="flex items-center p-1 space-x-1">
|
||||
<IconDrag />
|
||||
<FieldType
|
||||
:model-value="field.type || 'text'"
|
||||
:editable="false"
|
||||
:button-width="20"
|
||||
/>
|
||||
<span class="block pl-0.5">
|
||||
{{ field.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="editable"
|
||||
v-if="editable && !onlyDefinedFields"
|
||||
class="grid grid-cols-3 gap-1 pb-2"
|
||||
>
|
||||
<template
|
||||
@@ -41,39 +72,16 @@
|
||||
:key="type"
|
||||
>
|
||||
<button
|
||||
v-if="withPhone || type != 'phone'"
|
||||
v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')"
|
||||
draggable="true"
|
||||
class="flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
|
||||
class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
@dragstart="onDragstart(type)"
|
||||
@dragstart="onDragstart({ type: type })"
|
||||
@dragend="$emit('drag-end')"
|
||||
@click="addField(type)"
|
||||
>
|
||||
<div class="w-0 absolute left-0">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="cursor-grab"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
stroke="none"
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
/>
|
||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
</svg>
|
||||
<div class="flex items-console group-hover:bg-base-200/50 transition-all cursor-grab h-full absolute left-0">
|
||||
<IconDrag class=" my-auto" />
|
||||
</div>
|
||||
<div class="flex items-center flex-col px-2 py-2">
|
||||
<component :is="icon" />
|
||||
@@ -83,8 +91,9 @@
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
class="tooltip tooltip-bottom-end flex"
|
||||
v-else-if="type == 'phone' && (fieldTypes.length === 0 || fieldTypes.includes(type))"
|
||||
class="tooltip tooltip-bottom flex"
|
||||
:class="{'tooltip-bottom-start': !withPayment, 'tooltip-bottom': withPayment }"
|
||||
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
|
||||
>
|
||||
<a
|
||||
@@ -111,7 +120,7 @@
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="fields.length < 4 && editable"
|
||||
v-if="fields.length < 4 && editable && withHelp"
|
||||
class="text-xs p-2 border border-base-200 rounded"
|
||||
>
|
||||
<ul class="list-disc list-outside ml-3">
|
||||
@@ -122,7 +131,7 @@
|
||||
Drag & drop any other field type on the page
|
||||
</li>
|
||||
<li>
|
||||
Click on the field type above to add it to the form without drawing it on the document
|
||||
Click on the field type above to start drawing it
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -134,30 +143,58 @@ import { v4 } from 'uuid'
|
||||
import FieldType from './field_type'
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import { IconLock } from '@tabler/icons-vue'
|
||||
import IconDrag from './icon_drag'
|
||||
|
||||
export default {
|
||||
name: 'TemplateFields',
|
||||
components: {
|
||||
Field,
|
||||
FieldType,
|
||||
FieldSubmitter,
|
||||
IconDrag,
|
||||
IconLock
|
||||
},
|
||||
inject: ['save', 'backgroundColor', 'withPhone'],
|
||||
inject: ['save', 'backgroundColor', 'withPhone', 'withPayment', 't'],
|
||||
props: {
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
withHelp: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
defaultFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
onlyDefinedFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
defaultSubmitters: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
withStickySubmitters: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
fieldTypes: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
submitters: {
|
||||
type: Array,
|
||||
required: true
|
||||
@@ -178,30 +215,45 @@ export default {
|
||||
fieldIcons: FieldType.computed.fieldIcons,
|
||||
submitterFields () {
|
||||
return this.fields.filter((f) => f.submitter_uuid === this.selectedSubmitter.uuid)
|
||||
},
|
||||
submitterDefaultFields () {
|
||||
return this.defaultFields.filter((f) => {
|
||||
return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name)
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onDragstart (fieldType) {
|
||||
this.$emit('set-drag', fieldType)
|
||||
onDragstart (field) {
|
||||
this.$emit('set-drag', field)
|
||||
},
|
||||
onFieldDragover (e) {
|
||||
const targetFieldUuid = e.target.closest('[data-uuid]')?.dataset?.uuid
|
||||
const targetField = e.target.closest('[data-uuid]')
|
||||
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.dragField.uuid}"]`)
|
||||
|
||||
if (this.dragField && targetFieldUuid && this.dragField.uuid !== targetFieldUuid) {
|
||||
const field = this.fields.find((f) => f.uuid === targetFieldUuid)
|
||||
|
||||
const currentIndex = this.fields.indexOf(this.dragField)
|
||||
const targetIndex = this.fields.indexOf(field)
|
||||
if (dragField && targetField && targetField !== dragField) {
|
||||
const fields = Array.from(this.$refs.fields.children)
|
||||
const currentIndex = fields.indexOf(dragField)
|
||||
const targetIndex = fields.indexOf(targetField)
|
||||
|
||||
if (currentIndex < targetIndex) {
|
||||
this.fields.splice(targetIndex + 1, 0, this.dragField)
|
||||
this.fields.splice(currentIndex, 1)
|
||||
targetField.after(dragField)
|
||||
} else {
|
||||
this.fields.splice(targetIndex, 0, this.dragField)
|
||||
this.fields.splice(currentIndex + 1, 1)
|
||||
targetField.before(dragField)
|
||||
}
|
||||
}
|
||||
},
|
||||
reorderFields () {
|
||||
Array.from(this.$refs.fields.children).forEach((el, index) => {
|
||||
if (el.dataset.uuid !== this.fields[index].uuid) {
|
||||
const field = this.fields.find((f) => f.uuid === el.dataset.uuid)
|
||||
|
||||
this.fields.splice(this.fields.indexOf(field), 1)
|
||||
this.fields.splice(index, 0, field)
|
||||
}
|
||||
})
|
||||
|
||||
this.save()
|
||||
},
|
||||
removeSubmitter (submitter) {
|
||||
[...this.fields].forEach((field) => {
|
||||
if (field.submitter_uuid === submitter.uuid) {
|
||||
@@ -233,11 +285,25 @@ export default {
|
||||
}
|
||||
|
||||
if (['select', 'multiple', 'radio'].includes(type)) {
|
||||
field.options = ['']
|
||||
field.options = [{ value: '', uuid: v4() }]
|
||||
}
|
||||
|
||||
if (type === 'stamp') {
|
||||
field.readonly = true
|
||||
}
|
||||
|
||||
if (type === 'date') {
|
||||
field.preferences = {
|
||||
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
|
||||
}
|
||||
}
|
||||
|
||||
this.fields.push(field)
|
||||
|
||||
if (!['payment', 'file'].includes(type)) {
|
||||
this.$emit('set-draw', { field })
|
||||
}
|
||||
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
const en = {
|
||||
are_you_sure: 'Are you sure?',
|
||||
sign_yourself: 'Sign Yourself',
|
||||
send: 'Send',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
draw_field_on_the_document: 'Draw {field} field on the document',
|
||||
click_to_upload: 'Click to upload',
|
||||
or_drag_and_drop_files: 'or drag and drop files',
|
||||
uploading: 'Uploading',
|
||||
processing_: 'Processing...',
|
||||
add_pdf_documents_or_images: 'Add PDF documents or images',
|
||||
add_documents_or_images: 'Add documents or images',
|
||||
required: 'Required',
|
||||
default_value: 'Default value',
|
||||
format: 'Format',
|
||||
read_only: 'Read-only',
|
||||
page: 'Page',
|
||||
draw_new_area: 'Draw New Area',
|
||||
copy_to_all_pages: 'Copy to All Pages',
|
||||
add_option: 'Add option',
|
||||
option: 'Option',
|
||||
first_party: 'First Party',
|
||||
second_party: 'Second Party',
|
||||
third_party: 'Third Party',
|
||||
fourth_party: 'Fourth Party',
|
||||
fifth_party: 'Fifth Party',
|
||||
sixth_party: 'Sixth Party',
|
||||
seventh_party: 'Seventh Party',
|
||||
eighth_party: 'Eighth Party',
|
||||
ninth_party: 'Ninth Party',
|
||||
tenth_party: 'Tenth Party',
|
||||
add: 'Add',
|
||||
or_add_field_without_drawing: 'Or add field without drawing',
|
||||
text: 'Text',
|
||||
signature: 'Signature',
|
||||
initials: 'Initials',
|
||||
date: 'Date',
|
||||
image: 'Image',
|
||||
file: 'File',
|
||||
select: 'Select',
|
||||
checkbox: 'Checkbox',
|
||||
multiple: 'Multiple',
|
||||
radio: 'Radio',
|
||||
cells: 'Cells',
|
||||
stamp: 'Stamp',
|
||||
payment: 'Payment',
|
||||
phone: 'Phone',
|
||||
field: 'Field',
|
||||
group: 'Group',
|
||||
draw_a_text_field_on_the_page_with_a_mouse: 'Draw a text field on the page with a mouse',
|
||||
draw_field: 'Draw {field} Field',
|
||||
replace: 'Replace',
|
||||
uploading_: 'Uploading...',
|
||||
add_document: 'Add Document'
|
||||
}
|
||||
|
||||
export { en }
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
stroke="none"
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
/>
|
||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DragIcon'
|
||||
}
|
||||
</script>
|
||||
@@ -9,14 +9,14 @@
|
||||
class="inline"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<span> Draw {{ fieldNames[drawField.type] }} Field </span>
|
||||
<span>{{ t('draw_field').replace('{field}', fieldNames[drawField.type]) }}</span>
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
class="link block text-center"
|
||||
@click.prevent="$emit('cancel')"
|
||||
>
|
||||
Cancel
|
||||
{{ t('cancel') }}
|
||||
</a>
|
||||
</span>
|
||||
<FieldSubmitter
|
||||
@@ -43,7 +43,7 @@ export default {
|
||||
Field,
|
||||
FieldSubmitter
|
||||
},
|
||||
inject: ['save'],
|
||||
inject: ['save', 't'],
|
||||
props: {
|
||||
drawField: {
|
||||
type: Object,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<span
|
||||
class="dropdown dropdown-top dropdown-end fixed bottom-4 right-4 z-10 md:hidden"
|
||||
>
|
||||
<label
|
||||
class="btn btn-neutral text-white btn-circle btn-lg group"
|
||||
tabindex="0"
|
||||
>
|
||||
<IconPlus
|
||||
class="group-focus:hidden"
|
||||
width="28"
|
||||
height="28"
|
||||
/>
|
||||
<IconX
|
||||
class="hidden group-focus:inline"
|
||||
width="28"
|
||||
height="28"
|
||||
/>
|
||||
</label>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10 mb-3 mt-1.5 bg-base-100"
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<template v-if="submitterDefaultFields.length">
|
||||
<template
|
||||
v-for="field in submitterDefaultFields"
|
||||
:key="field.name"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm py-1 px-2"
|
||||
@click.prevent="$emit('select', { name: field.name || '', type: field.type || 'text' })"
|
||||
>
|
||||
<component
|
||||
:is="fieldIcons[field.type || 'text']"
|
||||
:stroke-width="1.6"
|
||||
:width="20"
|
||||
/>
|
||||
{{ field.name }}
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template
|
||||
v-for="(icon, type) in fieldIcons"
|
||||
:key="type"
|
||||
>
|
||||
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')">
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm py-1 px-2"
|
||||
:class="{ 'active': type === modelValue }"
|
||||
@click.prevent="$emit('select', { type })"
|
||||
>
|
||||
<component
|
||||
:is="icon"
|
||||
:stroke-width="1.6"
|
||||
:width="20"
|
||||
/>
|
||||
{{ fieldNames[type] }}
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
</ul>
|
||||
</span>
|
||||
</template>
|
||||
<script>
|
||||
import { IconPlus, IconX } from '@tabler/icons-vue'
|
||||
import FieldType from './field_type'
|
||||
|
||||
export default {
|
||||
name: 'MobileFields',
|
||||
components: {
|
||||
IconPlus,
|
||||
IconX
|
||||
},
|
||||
inject: ['withPhone', 'withPayment', 'backgroundColor', 't'],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
selectedSubmitter: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
fieldTypes: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
defaultFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
emits: ['select'],
|
||||
computed: {
|
||||
...FieldType.computed,
|
||||
submitterFields () {
|
||||
return this.fields.filter((f) => f.submitter_uuid === this.selectedSubmitter.uuid)
|
||||
},
|
||||
submitterDefaultFields () {
|
||||
return this.defaultFields.filter((f) => {
|
||||
return (!f.role || f.role === this.selectedSubmitter.name)
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeDropdown () {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -23,6 +23,7 @@
|
||||
:area="item.area"
|
||||
:field="item.field"
|
||||
:editable="editable"
|
||||
:default-field="defaultFields.find((f) => f.name === item.field.name)"
|
||||
@start-resize="resizeDirection = $event"
|
||||
@stop-resize="resizeDirection = null"
|
||||
@start-drag="isMove = true"
|
||||
@@ -32,7 +33,7 @@
|
||||
<FieldArea
|
||||
v-if="newArea"
|
||||
:is-draw="true"
|
||||
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || 'text' }"
|
||||
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || defaultFieldType }"
|
||||
:area="newArea"
|
||||
/>
|
||||
</div>
|
||||
@@ -40,8 +41,8 @@
|
||||
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile)"
|
||||
id="mask"
|
||||
ref="mask"
|
||||
class="top-0 bottom-0 left-0 right-0 absolute z-10"
|
||||
:class="{ 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
|
||||
class="top-0 bottom-0 left-0 right-0 absolute"
|
||||
:class="{ 'z-10': !isMobile, 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
|
||||
@pointermove="onPointermove"
|
||||
@pointerdown="onStartDraw"
|
||||
@dragover.prevent
|
||||
@@ -59,6 +60,7 @@ export default {
|
||||
components: {
|
||||
FieldArea
|
||||
},
|
||||
inject: ['fieldTypes'],
|
||||
props: {
|
||||
image: {
|
||||
type: Object,
|
||||
@@ -69,6 +71,16 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
defaultFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
allowDraw: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
selectedSubmitter: {
|
||||
type: Object,
|
||||
required: true
|
||||
@@ -104,6 +116,13 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
defaultFieldType () {
|
||||
if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes('text')) {
|
||||
return this.fieldTypes[0]
|
||||
} else {
|
||||
return 'text'
|
||||
}
|
||||
},
|
||||
isMobile () {
|
||||
return /android|iphone|ipad/i.test(navigator.userAgent)
|
||||
},
|
||||
@@ -143,6 +162,10 @@ export default {
|
||||
})
|
||||
},
|
||||
onStartDraw (e) {
|
||||
if (!this.allowDraw) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isMobile && !this.drawField) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<span
|
||||
class="dropdown dropdown-end"
|
||||
:class="{ 'dropdown-open': (!field.preferences?.price || !isConnected) && !isLoading }"
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
title="Settings"
|
||||
class="cursor-pointer text-transparent group-hover:text-base-content"
|
||||
>
|
||||
<IconSettings
|
||||
:width="18"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</label>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow bg-base-100 rounded-box w-52 z-10"
|
||||
draggable="true"
|
||||
@dragstart.prevent.stop
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<div
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<select
|
||||
v-model="field.preferences.currency"
|
||||
placeholder="Price"
|
||||
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0"
|
||||
@change="save"
|
||||
>
|
||||
<option
|
||||
v-for="currency in currencies"
|
||||
:key="currency"
|
||||
:value="currency"
|
||||
>
|
||||
{{ currency }}
|
||||
</option>
|
||||
</select>
|
||||
<label
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>
|
||||
Currency
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
v-model="field.preferences.price"
|
||||
type="number"
|
||||
placeholder="Price"
|
||||
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
|
||||
@blur="save"
|
||||
>
|
||||
<label
|
||||
v-if="field.preferences.price"
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>
|
||||
Price
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isConnected || isOauthSuccess"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
v-if="isConnected && isOauthSuccess"
|
||||
class="text-sm text-center"
|
||||
>
|
||||
<IconCircleCheck
|
||||
class="inline text-green-600 w-4 h-4"
|
||||
/>
|
||||
Stripe Connected
|
||||
</div>
|
||||
<form
|
||||
v-if="!isConnected"
|
||||
data-turbo="false"
|
||||
action="/auth/stripe_connect"
|
||||
accept-charset="UTF-8"
|
||||
target="_blank"
|
||||
method="post"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="state"
|
||||
:value="oauthState"
|
||||
autocomplete="off"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="redirect_uri"
|
||||
:value="redirectUri"
|
||||
autocomplete="off"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="scope"
|
||||
value="read_write"
|
||||
autocomplete="off"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="authenticity_token"
|
||||
:value="authenticityToken"
|
||||
autocomplete="off"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading"
|
||||
class="btn bg-[#7B73FF] hover:bg-[#0A2540] btn-sm text-white w-full"
|
||||
>
|
||||
<span
|
||||
v-if="isLoading"
|
||||
class="flex items-center space-x-1"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
class="w-4 h-4 animate-spin inline"
|
||||
/>
|
||||
<span>
|
||||
Connect Stripe
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center space-x-1"
|
||||
>
|
||||
<IconBrandStripe
|
||||
class="w-4 h-4 inline"
|
||||
/>
|
||||
<span>
|
||||
Connect Stripe
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
<a
|
||||
v-if="!isConnected"
|
||||
class="block link text-center mt-1"
|
||||
href="https://www.docuseal.co/blog/accept-payments-and-request-signatures-with-ease"
|
||||
target="_blank"
|
||||
data-turbo="false"
|
||||
>Learn more</a>
|
||||
</div>
|
||||
</ul>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconSettings, IconCircleCheck, IconBrandStripe, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isConnected = ref(false)
|
||||
|
||||
export default {
|
||||
name: 'PaymentSettings',
|
||||
components: {
|
||||
IconSettings,
|
||||
IconCircleCheck,
|
||||
IconInnerShadowTop,
|
||||
IconBrandStripe
|
||||
},
|
||||
inject: ['backgroundColor', 'save'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isConnected: () => isConnected.value,
|
||||
isOauthSuccess () {
|
||||
return document.location.search?.includes('stripe_connect_success')
|
||||
},
|
||||
redirectUri () {
|
||||
return document.location.origin + '/auth/stripe_connect/callback'
|
||||
},
|
||||
currencies () {
|
||||
return ['USD', 'EUR', 'GBP']
|
||||
},
|
||||
authenticityToken () {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content
|
||||
},
|
||||
oauthState () {
|
||||
const params = new URLSearchParams('')
|
||||
|
||||
params.set('redir', document.location.href)
|
||||
|
||||
return params.toString()
|
||||
},
|
||||
defaultCurrency () {
|
||||
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
|
||||
if (userTimezone.startsWith('Europe')) {
|
||||
return 'EUR'
|
||||
} else if (userTimezone.includes('London') || userTimezone.includes('Belfast')) {
|
||||
return 'GBP'
|
||||
} else {
|
||||
return 'USD'
|
||||
}
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.field.preferences ||= {}
|
||||
},
|
||||
mounted () {
|
||||
this.field.preferences.currency ||= this.defaultCurrency
|
||||
|
||||
if (!this.isConnected) {
|
||||
this.checkStatus()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkStatus () {
|
||||
this.isLoading = true
|
||||
|
||||
fetch('/api/stripe_connect').then(async (resp) => {
|
||||
const { status } = await resp.json()
|
||||
|
||||
if (status === 'connected') {
|
||||
isConnected.value = true
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
},
|
||||
closeDropdown () {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -19,6 +19,7 @@
|
||||
<div style="width: 26px" />
|
||||
<div class="">
|
||||
<ReplaceButton
|
||||
v-if="withReplaceButton"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:template-id="template.id"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
@@ -66,6 +67,7 @@
|
||||
<Contenteditable
|
||||
:model-value="item.name"
|
||||
:icon-width="16"
|
||||
:editable="editable"
|
||||
style="max-width: 95%"
|
||||
class="mx-auto"
|
||||
@update:model-value="onUpdateName"
|
||||
@@ -108,6 +110,11 @@ export default {
|
||||
required: false,
|
||||
default: 'image/*, application/pdf'
|
||||
},
|
||||
withReplaceButton: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: true
|
||||
},
|
||||
isDirectUpload: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
|
||||
@@ -26,7 +26,7 @@ import Upload from './upload'
|
||||
|
||||
export default {
|
||||
name: 'ReplaceDocument',
|
||||
inject: ['baseFetch'],
|
||||
inject: ['baseFetch', 't'],
|
||||
props: {
|
||||
templateId: {
|
||||
type: [Number, String],
|
||||
@@ -56,11 +56,11 @@ export default {
|
||||
},
|
||||
message () {
|
||||
if (this.isLoading) {
|
||||
return 'Uploading...'
|
||||
return this.t('uploading_')
|
||||
} else if (this.isProcessing) {
|
||||
return 'Processing...'
|
||||
return this.t('processing_')
|
||||
} else {
|
||||
return 'Replace'
|
||||
return this.t('replace')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
width="20"
|
||||
/>
|
||||
<span v-if="isLoading">
|
||||
Uploading...
|
||||
{{ t('uploading_') }}
|
||||
</span>
|
||||
<span v-else-if="isProcessing">
|
||||
Processing...
|
||||
{{ t('processing_') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
Add Document
|
||||
{{ t('add_document') }}
|
||||
</span>
|
||||
</label>
|
||||
<form
|
||||
@@ -50,7 +50,7 @@ export default {
|
||||
IconUpload,
|
||||
IconInnerShadowTop
|
||||
},
|
||||
inject: ['baseFetch'],
|
||||
inject: ['baseFetch', 't'],
|
||||
props: {
|
||||
templateId: {
|
||||
type: [Number, String],
|
||||
@@ -123,9 +123,30 @@ export default {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ blobs }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then(resp => resp.json()).then((data) => {
|
||||
this.$emit('success', data)
|
||||
this.$refs.input.value = ''
|
||||
}).then((resp) => {
|
||||
if (resp.ok) {
|
||||
resp.json().then((data) => {
|
||||
this.$emit('success', data)
|
||||
this.$refs.input.value = ''
|
||||
})
|
||||
} else if (resp.status === 422) {
|
||||
resp.json().then((data) => {
|
||||
if (data.error === 'PDF encrypted') {
|
||||
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ blobs, password: prompt('Enter PDF password') }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then(async (resp) => {
|
||||
if (resp.ok) {
|
||||
this.$emit('success', await resp.json())
|
||||
this.$refs.input.value = ''
|
||||
} else {
|
||||
alert('Wrong password')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isProcessing = false
|
||||
})
|
||||
@@ -133,9 +154,33 @@ export default {
|
||||
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
|
||||
method: 'POST',
|
||||
body: new FormData(this.$refs.form)
|
||||
}).then(resp => resp.json()).then((data) => {
|
||||
this.$emit('success', data)
|
||||
this.$refs.input.value = ''
|
||||
}).then((resp) => {
|
||||
if (resp.ok) {
|
||||
resp.json().then((data) => {
|
||||
this.$emit('success', data)
|
||||
this.$refs.input.value = ''
|
||||
})
|
||||
} else if (resp.status === 422) {
|
||||
resp.json().then((data) => {
|
||||
if (data.error === 'PDF encrypted') {
|
||||
const formData = new FormData(this.$refs.form)
|
||||
|
||||
formData.append('password', prompt('Enter PDF password'))
|
||||
|
||||
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(async (resp) => {
|
||||
if (resp.ok) {
|
||||
this.$emit('success', await resp.json())
|
||||
this.$refs.input.value = ''
|
||||
} else {
|
||||
alert('Wrong password')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
|
||||
@@ -22,20 +22,33 @@ class ProcessSubmitterCompletionJob < ApplicationJob
|
||||
end
|
||||
|
||||
def enqueue_completed_emails(submitter)
|
||||
user = submitter.submission.created_by_user || submitter.template.author
|
||||
submission = submitter.submission
|
||||
|
||||
if submitter.template.account.users.exists?(id: user.id)
|
||||
bcc = submitter.submission.template.account.account_configs
|
||||
.find_by(key: AccountConfig::BCC_EMAILS)&.value
|
||||
user = submission.created_by_user || submitter.template.author
|
||||
|
||||
SubmitterMailer.completed_email(submitter, user, bcc:).deliver_later!
|
||||
if submitter.template.account.users.exists?(id: user.id) &&
|
||||
submission.preferences['send_email'] != false
|
||||
SubmitterMailer.completed_email(submitter, user).deliver_later!
|
||||
|
||||
bcc = submission.preferences['bcc_completed'].presence ||
|
||||
submission.template.account.account_configs
|
||||
.find_by(key: AccountConfig::BCC_EMAILS)&.value.presence
|
||||
|
||||
bcc.to_s.scan(User::EMAIL_REGEXP).each do |to|
|
||||
SubmitterMailer.completed_email(submitter, user, to:).deliver_later!
|
||||
end
|
||||
end
|
||||
|
||||
to = submitter.submission.submitters.sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ')
|
||||
to = build_to_addresses(submitter)
|
||||
|
||||
SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! if to.present?
|
||||
end
|
||||
|
||||
def build_to_addresses(submitter)
|
||||
submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
|
||||
.sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ')
|
||||
end
|
||||
|
||||
def enqueue_next_submitter_request_notification(submitter)
|
||||
next_submitter_item =
|
||||
submitter.submission.template_submitters.find do |e|
|
||||
@@ -48,6 +61,6 @@ class ProcessSubmitterCompletionJob < ApplicationJob
|
||||
|
||||
next_submitter = submitter.submission.submitters.find { |s| s.uuid == next_submitter_item['uuid'] }
|
||||
|
||||
Submitters.send_signature_requests([next_submitter], send_email: true)
|
||||
Submitters.send_signature_requests([next_submitter])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,11 @@ class SendSubmitterInvitationEmailJob < ApplicationJob
|
||||
def perform(params = {})
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
|
||||
SubmitterMailer.invitation_email(submitter, subject: params['subject'], body: params['body']).deliver_now!
|
||||
mail = SubmitterMailer.invitation_email(submitter)
|
||||
|
||||
Submitters::ValidateSending.call(submitter, mail)
|
||||
|
||||
mail.deliver_now!
|
||||
|
||||
SubmissionEvent.create!(submitter:, event_type: 'send_email')
|
||||
|
||||
|
||||
@@ -3,27 +3,37 @@
|
||||
class SubmitterMailer < ApplicationMailer
|
||||
MAX_ATTACHMENTS_SIZE = 10.megabytes
|
||||
|
||||
def invitation_email(submitter, body: nil, subject: nil)
|
||||
DEFAULT_INVITATION_SUBJECT = 'You are invited to submit a form'
|
||||
|
||||
def invitation_email(submitter)
|
||||
@current_account = submitter.submission.template.account
|
||||
@submitter = submitter
|
||||
@body = body.presence
|
||||
|
||||
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
|
||||
if submitter.preferences['email_message_uuid']
|
||||
@email_message = submitter.account.email_messages.find_by(uuid: submitter.preferences['email_message_uuid'])
|
||||
end
|
||||
|
||||
@body = @email_message&.body.presence
|
||||
@subject = @email_message&.subject.presence
|
||||
|
||||
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
|
||||
|
||||
subject =
|
||||
if @email_config || subject.present?
|
||||
ReplaceEmailVariables.call(subject.presence || @email_config.value['subject'], submitter:)
|
||||
if @email_config || @subject
|
||||
ReplaceEmailVariables.call(@subject || @email_config.value['subject'], submitter:)
|
||||
else
|
||||
'You are invited to submit a form'
|
||||
DEFAULT_INVITATION_SUBJECT
|
||||
end
|
||||
|
||||
mail(to: @submitter.friendly_name,
|
||||
from: from_address_for_submitter(submitter),
|
||||
subject:,
|
||||
reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name)
|
||||
mail(
|
||||
to: @submitter.friendly_name,
|
||||
from: from_address_for_submitter(submitter),
|
||||
subject:,
|
||||
reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@')
|
||||
)
|
||||
end
|
||||
|
||||
def completed_email(submitter, user, bcc: nil)
|
||||
def completed_email(submitter, user, to: nil)
|
||||
@current_account = submitter.submission.template.account
|
||||
@submitter = submitter
|
||||
@submission = submitter.submission
|
||||
@@ -31,9 +41,11 @@ class SubmitterMailer < ApplicationMailer
|
||||
|
||||
Submissions::EnsureResultGenerated.call(submitter)
|
||||
|
||||
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
|
||||
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
|
||||
|
||||
add_completed_email_attachments!(submitter)
|
||||
add_completed_email_attachments!(
|
||||
submitter, with_audit_log: @email_config.nil? || @email_config.value['attach_audit_log'] != false
|
||||
)
|
||||
|
||||
subject =
|
||||
if @email_config
|
||||
@@ -45,8 +57,7 @@ class SubmitterMailer < ApplicationMailer
|
||||
end
|
||||
|
||||
mail(from: from_address_for_submitter(submitter),
|
||||
to: user.friendly_name,
|
||||
bcc:,
|
||||
to: to || (user.role == 'integration' ? user.friendly_name.sub(/\+\w+@/, '@') : user.friendly_name),
|
||||
subject:)
|
||||
end
|
||||
|
||||
@@ -56,9 +67,11 @@ class SubmitterMailer < ApplicationMailer
|
||||
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
|
||||
@documents = add_completed_email_attachments!(submitter)
|
||||
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
|
||||
|
||||
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
|
||||
@documents = add_completed_email_attachments!(
|
||||
submitter, with_audit_log: @email_config.nil? || @email_config.value['attach_audit_log'] != false
|
||||
)
|
||||
|
||||
subject =
|
||||
if @email_config
|
||||
@@ -74,19 +87,38 @@ class SubmitterMailer < ApplicationMailer
|
||||
|
||||
private
|
||||
|
||||
def add_completed_email_attachments!(submitter)
|
||||
def add_completed_email_attachments!(submitter, with_audit_log: true)
|
||||
documents = Submitters.select_attachments_for_download(submitter)
|
||||
|
||||
total_size = 0
|
||||
audit_trail_data = nil
|
||||
|
||||
if submitter.submission.audit_trail.present?
|
||||
if with_audit_log && submitter.submission.audit_trail.present?
|
||||
audit_trail_data = submitter.submission.audit_trail.download
|
||||
|
||||
total_size = audit_trail_data.size
|
||||
end
|
||||
|
||||
documents.each do |attachment|
|
||||
total_size = add_attachments_with_size_limit(documents, total_size)
|
||||
|
||||
attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data
|
||||
|
||||
file_fields = submitter.submission.template_fields.select { |e| e['type'].in?(%w[file payment]) }
|
||||
|
||||
if file_fields.pluck('submitter_uuid').uniq.size == 1
|
||||
storage_attachments =
|
||||
submitter.attachments.where(uuid: submitter.values.values_at(*file_fields.pluck('uuid')).flatten)
|
||||
|
||||
add_attachments_with_size_limit(storage_attachments, total_size)
|
||||
end
|
||||
|
||||
documents
|
||||
end
|
||||
|
||||
def add_attachments_with_size_limit(storage_attachments, current_size)
|
||||
total_size = current_size
|
||||
|
||||
storage_attachments.each do |attachment|
|
||||
total_size += attachment.byte_size
|
||||
|
||||
break if total_size >= MAX_ATTACHMENTS_SIZE
|
||||
@@ -94,12 +126,15 @@ class SubmitterMailer < ApplicationMailer
|
||||
attachments[attachment.filename.to_s] = attachment.download
|
||||
end
|
||||
|
||||
attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data
|
||||
|
||||
documents
|
||||
total_size
|
||||
end
|
||||
|
||||
def from_address_for_submitter(submitter)
|
||||
submitter.submission.created_by_user&.friendly_name || submitter.submission.template.author.friendly_name
|
||||
if submitter.submission.source.in?(%w[api embed]) &&
|
||||
(from_email = AccountConfig.find_by(account: submitter.account, key: 'integration_from_email')&.value.presence)
|
||||
from_email
|
||||
else
|
||||
(submitter.submission.created_by_user || submitter.submission.template.author).friendly_name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,7 @@ class AccessToken < ApplicationRecord
|
||||
|
||||
belongs_to :user
|
||||
|
||||
before_validation :set_sha256, on: :create
|
||||
before_validation :set_sha256
|
||||
|
||||
attribute :token, :string, default: -> { SecureRandom.base58(TOKEN_LENGTH) }
|
||||
|
||||
|
||||
@@ -15,18 +15,33 @@ class Account < ApplicationRecord
|
||||
has_many :users, dependent: :destroy
|
||||
has_many :encrypted_configs, dependent: :destroy
|
||||
has_many :account_configs, dependent: :destroy
|
||||
has_many :email_messages, dependent: :destroy
|
||||
has_many :templates, dependent: :destroy
|
||||
has_many :template_folders, dependent: :destroy
|
||||
has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) },
|
||||
class_name: 'TemplateFolder', dependent: :destroy, inverse_of: :account
|
||||
has_many :submissions, through: :templates
|
||||
has_many :submitters, through: :submissions
|
||||
has_many :account_linked_accounts, dependent: :destroy
|
||||
has_many :account_testing_accounts, -> { testing }, dependent: :destroy,
|
||||
class_name: 'AccountLinkedAccount',
|
||||
inverse_of: :account
|
||||
has_one :linked_account_account, dependent: :destroy,
|
||||
foreign_key: :linked_account_id,
|
||||
class_name: 'AccountLinkedAccount',
|
||||
inverse_of: :linked_account
|
||||
has_many :linked_accounts, through: :account_linked_accounts
|
||||
has_many :testing_accounts, through: :account_testing_accounts, source: :linked_account
|
||||
has_many :active_users, -> { active }, dependent: :destroy,
|
||||
inverse_of: :account, class_name: 'User'
|
||||
|
||||
attribute :timezone, :string, default: 'UTC'
|
||||
attribute :locale, :string, default: 'en-US'
|
||||
|
||||
def testing?
|
||||
linked_account_account&.testing?
|
||||
end
|
||||
|
||||
def default_template_folder
|
||||
super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME,
|
||||
author_id: users.minimum(:id)).tap(&:save!)
|
||||
|
||||
@@ -25,8 +25,11 @@ class AccountConfig < ApplicationRecord
|
||||
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
|
||||
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
|
||||
BCC_EMAILS = 'bcc_emails'
|
||||
FORCE_MFA = 'force_mfa'
|
||||
ALLOW_TYPED_SIGNATURE = 'allow_typed_signature'
|
||||
SUBMITTER_REMAILERS = 'submitter_reminders'
|
||||
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
|
||||
ESIGNING_PREFERENCE_KEY = 'esigning_preference'
|
||||
|
||||
DEFAULT_VALUES = {
|
||||
SUBMITTER_INVITATION_EMAIL_KEY => {
|
||||
@@ -49,7 +52,7 @@ class AccountConfig < ApplicationRecord
|
||||
'body' => "Hi there,\n\n" \
|
||||
"Please check the copy of your \"{{template.name}}\" submission in the email attachments.\n" \
|
||||
"Alternatively, you can download your copy using:\n\n" \
|
||||
"{{documents.links}}\n\n" \
|
||||
"{{documents.link}}\n\n" \
|
||||
"Thanks,\n" \
|
||||
'{{account.name}}'
|
||||
}
|
||||
@@ -57,5 +60,5 @@ class AccountConfig < ApplicationRecord
|
||||
|
||||
belongs_to :account
|
||||
|
||||
serialize :value, JSON
|
||||
serialize :value, coder: JSON
|
||||
end
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_linked_accounts
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# account_type :text not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# linked_account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_on_account_id_linked_account_id_48ab9f79d2 (account_id,linked_account_id) UNIQUE
|
||||
# index_account_linked_accounts_on_account_id (account_id)
|
||||
# index_account_linked_accounts_on_linked_account_id (linked_account_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
# fk_rails_... (linked_account_id => accounts.id)
|
||||
#
|
||||
class AccountLinkedAccount < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :linked_account, class_name: 'Account'
|
||||
|
||||
attribute :account_type, :string, default: 'testing'
|
||||
|
||||
scope :testing, -> { where(account_type: :testing) }
|
||||
|
||||
def testing?
|
||||
account_type == 'testing'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: email_messages
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# body :text not null
|
||||
# sha1 :string not null
|
||||
# subject :text not null
|
||||
# uuid :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# author_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_email_messages_on_account_id (account_id)
|
||||
# index_email_messages_on_sha1 (sha1)
|
||||
# index_email_messages_on_uuid (uuid)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
# fk_rails_... (author_id => users.id)
|
||||
#
|
||||
class EmailMessage < ApplicationRecord
|
||||
belongs_to :author, class_name: 'User'
|
||||
belongs_to :account
|
||||
|
||||
attribute :uuid, :string, default: -> { SecureRandom.uuid }
|
||||
|
||||
before_validation :set_sha1, on: :create
|
||||
|
||||
def set_sha1
|
||||
self.sha1 = Digest::SHA1.hexdigest({ subject:, body: }.to_json)
|
||||
end
|
||||
end
|
||||
@@ -25,6 +25,7 @@ class EncryptedConfig < ApplicationRecord
|
||||
FILES_STORAGE_KEY = 'active_storage',
|
||||
EMAIL_SMTP_KEY = 'action_mailer_smtp',
|
||||
ESIGN_CERTS_KEY = 'esign_certs',
|
||||
TIMESTAMP_SERVER_URL_KEY = 'timestamp_server_url',
|
||||
APP_URL_KEY = 'app_url',
|
||||
WEBHOOK_URL_KEY = 'webhook_url'
|
||||
].freeze
|
||||
@@ -33,5 +34,5 @@ class EncryptedConfig < ApplicationRecord
|
||||
|
||||
encrypts :value
|
||||
|
||||
serialize :value, JSON
|
||||
serialize :value, coder: JSON
|
||||
end
|
||||
|
||||
@@ -25,5 +25,5 @@ class EncryptedUserConfig < ApplicationRecord
|
||||
|
||||
encrypts :value
|
||||
|
||||
serialize :value, JSON
|
||||
serialize :value, coder: JSON
|
||||
end
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
# Table name: submissions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# deleted_at :datetime
|
||||
# archived_at :datetime
|
||||
# preferences :text not null
|
||||
# slug :string not null
|
||||
# source :text not null
|
||||
# submitters_order :string not null
|
||||
# template_fields :text
|
||||
@@ -19,6 +21,7 @@
|
||||
# Indexes
|
||||
#
|
||||
# index_submissions_on_created_by_user_id (created_by_user_id)
|
||||
# index_submissions_on_slug (slug) UNIQUE
|
||||
# index_submissions_on_template_id (template_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
@@ -34,20 +37,25 @@ class Submission < ApplicationRecord
|
||||
has_many :submitters, dependent: :destroy
|
||||
has_many :submission_events, dependent: :destroy
|
||||
|
||||
serialize :template_fields, JSON
|
||||
serialize :template_schema, JSON
|
||||
serialize :template_submitters, JSON
|
||||
attribute :preferences, :string, default: -> { {} }
|
||||
|
||||
serialize :template_fields, coder: JSON
|
||||
serialize :template_schema, coder: JSON
|
||||
serialize :template_submitters, coder: JSON
|
||||
serialize :preferences, coder: JSON
|
||||
|
||||
attribute :source, :string, default: 'link'
|
||||
attribute :submitters_order, :string, default: 'random'
|
||||
|
||||
attribute :slug, :string, default: -> { SecureRandom.base58(14) }
|
||||
|
||||
has_one_attached :audit_trail
|
||||
|
||||
has_many :template_schema_documents,
|
||||
->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) },
|
||||
through: :template, source: :documents_attachments
|
||||
|
||||
scope :active, -> { where(deleted_at: nil) }
|
||||
scope :active, -> { where(archived_at: nil) }
|
||||
scope :pending, -> { joins(:submitters).where(submitters: { completed_at: nil }).distinct }
|
||||
scope :completed, -> { where.not(id: pending.select(:submission_id)) }
|
||||
|
||||
@@ -64,7 +72,9 @@ class Submission < ApplicationRecord
|
||||
}, scope: false, prefix: true
|
||||
|
||||
def audit_trail_url
|
||||
audit_trail&.url
|
||||
return if audit_trail.blank?
|
||||
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(audit_trail, **Docuseal.default_url_options)
|
||||
end
|
||||
alias audit_log_url audit_trail_url
|
||||
end
|
||||
|
||||
@@ -31,7 +31,7 @@ class SubmissionEvent < ApplicationRecord
|
||||
attribute :data, :string, default: -> { {} }
|
||||
attribute :event_timestamp, :datetime, default: -> { Time.current }
|
||||
|
||||
serialize :data, JSON
|
||||
serialize :data, coder: JSON
|
||||
|
||||
before_validation :set_submission_id, on: :create
|
||||
|
||||
@@ -39,6 +39,7 @@ class SubmissionEvent < ApplicationRecord
|
||||
send_email: 'send_email',
|
||||
send_reminder_email: 'send_reminder_email',
|
||||
send_sms: 'send_sms',
|
||||
send_2fa_sms: 'send_2fa_sms',
|
||||
open_email: 'open_email',
|
||||
click_email: 'click_email',
|
||||
click_sms: 'click_sms',
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
# name :string
|
||||
# opened_at :datetime
|
||||
# phone :string
|
||||
# preferences :text not null
|
||||
# sent_at :datetime
|
||||
# slug :string not null
|
||||
# ua :string
|
||||
@@ -37,9 +38,11 @@ class Submitter < ApplicationRecord
|
||||
has_one :account, through: :template
|
||||
|
||||
attribute :values, :string, default: -> { {} }
|
||||
attribute :preferences, :string, default: -> { {} }
|
||||
attribute :slug, :string, default: -> { SecureRandom.base58(14) }
|
||||
|
||||
serialize :values, JSON
|
||||
serialize :values, coder: JSON
|
||||
serialize :preferences, coder: JSON
|
||||
|
||||
has_many_attached :documents
|
||||
has_many_attached :attachments
|
||||
@@ -61,7 +64,7 @@ class Submitter < ApplicationRecord
|
||||
|
||||
def friendly_name
|
||||
if name.present? && email.present? && email.exclude?(',')
|
||||
"#{name} <#{email}>"
|
||||
%("#{name.delete('"')}" <#{email}>)
|
||||
else
|
||||
email
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# application_key :string
|
||||
# deleted_at :datetime
|
||||
# archived_at :datetime
|
||||
# fields :text not null
|
||||
# name :string not null
|
||||
# schema :text not null
|
||||
@@ -33,7 +33,7 @@
|
||||
# fk_rails_... (folder_id => template_folders.id)
|
||||
#
|
||||
class Template < ApplicationRecord
|
||||
DEFAULT_SUBMITTER_NAME = 'First Submitter'
|
||||
DEFAULT_SUBMITTER_NAME = 'First Party'
|
||||
|
||||
belongs_to :author, class_name: 'User'
|
||||
belongs_to :account
|
||||
@@ -47,9 +47,9 @@ class Template < ApplicationRecord
|
||||
attribute :slug, :string, default: -> { SecureRandom.base58(14) }
|
||||
attribute :source, :string, default: 'native'
|
||||
|
||||
serialize :fields, JSON
|
||||
serialize :schema, JSON
|
||||
serialize :submitters, JSON
|
||||
serialize :fields, coder: JSON
|
||||
serialize :schema, coder: JSON
|
||||
serialize :submitters, coder: JSON
|
||||
|
||||
has_many_attached :documents
|
||||
|
||||
@@ -58,8 +58,8 @@ class Template < ApplicationRecord
|
||||
|
||||
has_many :submissions, dependent: :destroy
|
||||
|
||||
scope :active, -> { where(deleted_at: nil) }
|
||||
scope :archived, -> { where.not(deleted_at: nil) }
|
||||
scope :active, -> { where(archived_at: nil) }
|
||||
scope :archived, -> { where.not(archived_at: nil) }
|
||||
|
||||
private
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
#
|
||||
# Table name: template_folders
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# deleted_at :datetime
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# author_id :bigint not null
|
||||
# id :bigint not null, primary key
|
||||
# archived_at :datetime
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# author_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
@@ -29,10 +29,10 @@ class TemplateFolder < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
has_many :templates, dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder
|
||||
has_many :active_templates, -> { where(deleted_at: nil) },
|
||||
has_many :active_templates, -> { where(archived_at: nil) },
|
||||
class_name: 'Template', dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder
|
||||
|
||||
scope :active, -> { where(deleted_at: nil) }
|
||||
scope :active, -> { where(archived_at: nil) }
|
||||
|
||||
def default?
|
||||
name == DEFAULT_NAME
|
||||
|
||||
+9
-6
@@ -5,10 +5,10 @@
|
||||
# Table name: users
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# archived_at :datetime
|
||||
# consumed_timestep :integer
|
||||
# current_sign_in_at :datetime
|
||||
# current_sign_in_ip :string
|
||||
# deleted_at :datetime
|
||||
# email :string not null
|
||||
# encrypted_password :string not null
|
||||
# failed_attempts :integer default(0), not null
|
||||
@@ -49,19 +49,22 @@ class User < ApplicationRecord
|
||||
|
||||
EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/
|
||||
|
||||
has_one_attached :signature
|
||||
|
||||
belongs_to :account
|
||||
has_one :access_token, dependent: :destroy
|
||||
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
|
||||
has_many :template_folders, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
|
||||
has_many :user_configs, dependent: :destroy
|
||||
has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig'
|
||||
has_many :email_messages, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
|
||||
|
||||
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
|
||||
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
|
||||
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable, :lockable
|
||||
|
||||
attribute :role, :string, default: ADMIN_ROLE
|
||||
attribute :uuid, :string, default: -> { SecureRandom.uuid }
|
||||
|
||||
scope :active, -> { where(deleted_at: nil) }
|
||||
scope :active, -> { where(archived_at: nil) }
|
||||
scope :admins, -> { where(role: ADMIN_ROLE) }
|
||||
|
||||
def access_token
|
||||
@@ -69,7 +72,7 @@ class User < ApplicationRecord
|
||||
end
|
||||
|
||||
def active_for_authentication?
|
||||
!deleted_at?
|
||||
super && !archived_at?
|
||||
end
|
||||
|
||||
def remember_me
|
||||
@@ -94,7 +97,7 @@ class User < ApplicationRecord
|
||||
|
||||
def friendly_name
|
||||
if full_name.present?
|
||||
"#{full_name} <#{email}>"
|
||||
%("#{full_name.delete('"')}" <#{email}>)
|
||||
else
|
||||
email
|
||||
end
|
||||
|
||||
@@ -25,5 +25,5 @@ class UserConfig < ApplicationRecord
|
||||
|
||||
belongs_to :user
|
||||
|
||||
serialize :value, JSON
|
||||
serialize :value, coder: JSON
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<%= f.fields_for current_account do |ff| %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :name, 'Company Name', class: 'label' %>
|
||||
<%= ff.text_field :name, required: true, class: 'base-input' %>
|
||||
<%= ff.text_field :name, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<% encrypted_config = @encrypted_config || EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::APP_URL_KEY) %>
|
||||
<% if !Docuseal.multitenant? && can?(:manage, encrypted_config) %>
|
||||
<% if !Docuseal.multitenant? && can?(:manage, encrypted_config) && !current_account.testing? %>
|
||||
<%= f.fields_for encrypted_config do |ff| %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :value, 'App URL', class: 'label' %>
|
||||
@@ -36,6 +36,35 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if can?(:manage, AccountConfig) %>
|
||||
<div class="px-1 mt-8">
|
||||
<h2 class="text-2xl font-bold mb-2">Preferences</h2>
|
||||
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::FORCE_MFA) %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between py-2.5">
|
||||
<span>
|
||||
Force 2FA with Authenticator App
|
||||
</span>
|
||||
<%= f.check_box :value, class: 'toggle', checked: account_config.value, onchange: 'this.form.requestSubmit()' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TYPED_SIGNATURE) %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between py-2.5">
|
||||
<span>
|
||||
Allow typed text signatures
|
||||
</span>
|
||||
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false, onchange: 'this.form.requestSubmit()' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="w-0 md:w-52"></div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user