mirror of
https://github.com/docusealco/docuseal.git
synced 2026-08-07 15:25:16 +00:00
Compare commits
252 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9ae79dd5b | |||
| cadb78ca35 | |||
| 36a6b7ff1d | |||
| efee46f113 | |||
| b6639738c3 | |||
| aab07c5f49 | |||
| 33c0784bc6 | |||
| 6f5ebfc8a6 | |||
| 811091ff14 | |||
| 6666f39928 | |||
| f67db9fe5f | |||
| 45f002803e | |||
| 8c19f90e88 | |||
| a8f5d5cea7 | |||
| 6788071eec | |||
| 0c690b3033 | |||
| dcb963b623 | |||
| 04e30dbdd6 | |||
| ac6d5a7aed | |||
| 3166a31b67 | |||
| fc3283eb3a | |||
| d6a96d4aa3 | |||
| cda349d71c | |||
| 87345a3b3d | |||
| b791d0699c | |||
| 2cb15f3282 | |||
| 8b823ca0d0 | |||
| f3f08797f7 | |||
| cab9672d25 | |||
| c8d3b1bace | |||
| 5ee51b2b57 | |||
| 525dc503a6 | |||
| 12cf28792b | |||
| 33466c1d07 | |||
| 226ae2172e | |||
| 8e2700b8b8 | |||
| 753cbd4d27 | |||
| f16405ca70 | |||
| 23f052c400 | |||
| 90a0a03e15 | |||
| 2dba8ae366 | |||
| 5bb7ad571c | |||
| 0cdb7c2c4f | |||
| 11bdc522cd | |||
| 014835f90d | |||
| 05e66b60ee | |||
| 505b185cf2 | |||
| 50c32ac6ff | |||
| 878ab25fd4 | |||
| 1b31772ff6 | |||
| d4940234b7 | |||
| 9bdc5d4863 | |||
| a73fd7929c | |||
| 8269a61751 | |||
| de072d1437 | |||
| 5b24793f2b | |||
| 660f7ab8f3 | |||
| 6e3fea5fa0 | |||
| 29ecbecbc2 | |||
| ad3bbe9892 | |||
| b570ebee69 | |||
| 207780fa3f | |||
| 8aa219433b | |||
| cb1e9f2be4 | |||
| 202943f58d | |||
| 6dd15b2091 | |||
| 1c55e4f18b | |||
| 6cc36b1728 | |||
| 37751cd5c1 | |||
| 80d882cd20 | |||
| 8bef75e570 | |||
| 077eab7005 | |||
| 715c70d422 | |||
| a3c23f9d53 | |||
| c1e67a8af0 | |||
| 2fdc294293 | |||
| 1f49ba069e | |||
| 43ef0fc0d0 | |||
| a1a920e536 | |||
| dec33b4331 | |||
| 432f290633 | |||
| cf98616a27 | |||
| 1a720cf1ad | |||
| 557c6c34f2 | |||
| 943c76505d | |||
| 66eb83e6a7 | |||
| 0844427357 | |||
| 72e9d9ea2d | |||
| 3db7d47746 | |||
| 95899336b8 | |||
| f6691c110e | |||
| 749722a9df | |||
| 265b668cb4 | |||
| 0527e6a5c1 | |||
| d989b61d19 | |||
| c1f3e6ff13 | |||
| 3c0fff2d82 | |||
| bd599c264f | |||
| 0274093c89 | |||
| 5845b5bdfd | |||
| 3dd0ba6b0b | |||
| e79306f8a3 | |||
| 0171b0c2dd | |||
| a963ef53d3 | |||
| 344f302c76 | |||
| 17ded282b2 | |||
| 2ce57df485 | |||
| 849289287e | |||
| 704fbc2393 | |||
| 168fc8bd03 | |||
| c5598d9efe | |||
| f02315726d | |||
| e56dcb59c0 | |||
| c74f350ea2 | |||
| af9d015cf2 | |||
| 633e5ac5e4 | |||
| 2d0f323854 | |||
| cfe5610ec9 | |||
| 55616ac321 | |||
| 8f2d1deb38 | |||
| e2763ceb80 | |||
| 10462f3140 | |||
| 3aef92486b | |||
| c1a9ee0bbb | |||
| 398fae82cb | |||
| a16443f961 | |||
| ad4e0e1d27 | |||
| 493a2108e9 | |||
| c2c9bb64b1 | |||
| bb1426c0af | |||
| 2505768e4d | |||
| 23a1b70534 | |||
| b34616ecbc | |||
| 0111252f6f | |||
| a561cc5f73 | |||
| 519e6f1f79 | |||
| da871b5d3a | |||
| ae3f939d23 | |||
| fb6ede564e | |||
| 1c701d4fe8 | |||
| c743cc689d | |||
| 8432bec3d6 | |||
| 8e72b0b0e4 | |||
| 19e4bbc01e | |||
| 56a9e412c8 | |||
| 4b75623b86 | |||
| e1ea42a997 | |||
| ee37710e57 | |||
| ae99218789 | |||
| 9fe8214ca0 | |||
| 6ca44a5cc3 | |||
| 801abd197b | |||
| 970977e469 | |||
| f83674b934 | |||
| 57c7dc1b5e | |||
| e9277f4eeb | |||
| 4052b6fe13 | |||
| 608347b1e6 | |||
| 345fe674d4 | |||
| 92f8901a67 | |||
| fcd40308ee | |||
| 2bd023e5d0 | |||
| a697d6330e | |||
| ab56e8be20 | |||
| b9a906b569 | |||
| 02adbae758 | |||
| 866aab7fac | |||
| c81b4d855b | |||
| 4ad58fc285 | |||
| 8227dd4f6a | |||
| beb677734f | |||
| 03db636d51 | |||
| b83abc9499 | |||
| 9738cdcf8f | |||
| 179ae01551 | |||
| 295860a9de | |||
| 0a6c15b6b8 | |||
| 18f80020ee | |||
| 3768fb94bf | |||
| 7b2cadcb91 | |||
| 4eb4e8c1f8 | |||
| 984df15949 | |||
| aa3bd24332 | |||
| 250889623d | |||
| 862fa55986 | |||
| 0f2889904a | |||
| 49f14c7929 | |||
| c3f3c5d5df | |||
| 274259f305 | |||
| a15d7a9cf3 | |||
| 7d154fc28d | |||
| 6727d3be83 | |||
| 4bbd5c3bcb | |||
| 5995f2e427 | |||
| 0910c8e100 | |||
| 8df76dfa90 | |||
| 78e9e66cb0 | |||
| 71c61fc4b3 | |||
| a728aa8689 | |||
| 5a3174a720 | |||
| 652072d660 | |||
| 323f5c0c15 | |||
| 229d78c78b | |||
| 9d9a955149 | |||
| b40a047190 | |||
| ff9e415823 | |||
| 2f7a4fb2b3 | |||
| f8a654a5b6 | |||
| 4449475467 | |||
| bcbbf0dd42 | |||
| 19b9ba52d4 | |||
| bdc30db2dc | |||
| 74634ba96b | |||
| 77feb05bad | |||
| a1f05d006e | |||
| cc3dae1335 | |||
| d9debcfb0d | |||
| 8770d16d52 | |||
| 84953481e4 | |||
| df7aa59f6f | |||
| 008b83119d | |||
| 15dafcb6ff | |||
| 7d291ed888 | |||
| a7221f0750 | |||
| 9a5b2a5d30 | |||
| 7361a00d42 | |||
| e07d402782 | |||
| a34354bee1 | |||
| 84b762b529 | |||
| 2a839951b4 | |||
| 26d7c2f047 | |||
| 151a5b85f3 | |||
| 3f506f53b4 | |||
| 95f734e4b8 | |||
| ff74574e84 | |||
| 843f6c405f | |||
| f8b24ebfce | |||
| d1d26b5424 | |||
| 8aa6f45da8 | |||
| 48d5bee95b | |||
| 98c79d2dec | |||
| 0f51238f62 | |||
| b7323b929f | |||
| 05eb1537f7 | |||
| bc9f27cfed | |||
| 8f457be719 | |||
| 6d590e13c9 | |||
| c987ec172a | |||
| 49f1637c47 | |||
| 13379bd699 | |||
| 5ee762c08c | |||
| edb6d561f1 |
@@ -9,7 +9,7 @@
|
||||
"rules": {
|
||||
"vue/no-deprecated-html-element-is": 0,
|
||||
"vue/no-mutating-props": 0,
|
||||
"vue/no-v-html": 0
|
||||
"vue/one-component-per-file": 0
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16.13.1
|
||||
node-version: 20.9.0
|
||||
- name: Cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16.13.1
|
||||
node-version: 20.9.0
|
||||
- name: Install Chrome
|
||||
uses: browser-actions/setup-chrome@latest
|
||||
- name: Cache node_modules
|
||||
|
||||
+2
-2
@@ -30,7 +30,7 @@ Metrics/ParameterLists:
|
||||
Max: 10
|
||||
|
||||
Metrics/MethodLength:
|
||||
Max: 25
|
||||
Max: 30
|
||||
Exclude:
|
||||
- 'db/migrate/**'
|
||||
|
||||
@@ -44,7 +44,7 @@ Layout/LineLength:
|
||||
AllowedPatterns: ['\A\s*#']
|
||||
|
||||
Metrics/AbcSize:
|
||||
Max: 40
|
||||
Max: 45
|
||||
|
||||
Metrics/ModuleLength:
|
||||
Max: 500
|
||||
|
||||
+11
-10
@@ -2,9 +2,9 @@ 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 apk --no-cache add fontforge wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
|
||||
|
||||
RUN fontforge -lang=py -c 'font1 = fontforge.open("LiberationSans-Regular.ttf"); font2 = fontforge.open("NotoSansArabic-Regular.ttf"); font1.mergeFonts(font2); font1.generate("LiberationSans-Regular.ttf")'
|
||||
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
|
||||
|
||||
FROM ruby:3.2.2-alpine3.18 as webpack
|
||||
|
||||
@@ -23,10 +23,10 @@ RUN yarn install --network-timeout 1000000
|
||||
COPY ./bin/shakapacker ./bin/shakapacker
|
||||
COPY ./config/webpack ./config/webpack
|
||||
COPY ./config/shakapacker.yml ./config/shakapacker.yml
|
||||
COPY ./postcss.config.js ./postcss.config.js ./
|
||||
COPY ./tailwind.config.js ./tailwind.config.js ./
|
||||
COPY ./tailwind.form.config.js ./tailwind.form.config.js ./
|
||||
COPY ./tailwind.application.config.js ./tailwind.application.config.js ./
|
||||
COPY ./postcss.config.js ./postcss.config.js
|
||||
COPY ./tailwind.config.js ./tailwind.config.js
|
||||
COPY ./tailwind.form.config.js ./tailwind.form.config.js
|
||||
COPY ./tailwind.application.config.js ./tailwind.application.config.js
|
||||
COPY ./app/javascript ./app/javascript
|
||||
COPY ./app/views ./app/views
|
||||
|
||||
@@ -39,11 +39,11 @@ 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 && mkdir /fonts
|
||||
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
|
||||
|
||||
COPY ./Gemfile ./Gemfile.lock ./
|
||||
|
||||
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
|
||||
RUN apk add --no-cache build-base && bundle update --bundler && bundle install && apk del build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
|
||||
|
||||
COPY ./bin ./bin
|
||||
COPY ./app ./app
|
||||
@@ -55,7 +55,8 @@ COPY ./public ./public
|
||||
COPY ./tmp ./tmp
|
||||
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=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
|
||||
COPY --from=fonts /fonts/FreeSans.ttf /usr/share/fonts/freefont
|
||||
COPY --from=webpack /app/public/packs ./public/packs
|
||||
|
||||
RUN ln -s /fonts /app/public/fonts
|
||||
@@ -65,4 +66,4 @@ WORKDIR /data/docuseal
|
||||
ENV WORKDIR=/data/docuseal
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["/app/bin/rails", "server"]
|
||||
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
|
||||
|
||||
@@ -32,7 +32,6 @@ gem 'rack'
|
||||
gem 'rails'
|
||||
gem 'rails_autolink'
|
||||
gem 'rails-i18n'
|
||||
gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
|
||||
gem 'rotp'
|
||||
gem 'rqrcode'
|
||||
gem 'ruby-vips'
|
||||
|
||||
+1
-3
@@ -261,7 +261,7 @@ GEM
|
||||
signet (>= 0.16, < 2.a)
|
||||
hashdiff (1.1.0)
|
||||
hashery (2.1.2)
|
||||
hexapdf (0.34.1)
|
||||
hexapdf (0.37.1)
|
||||
cmdparse (~> 3.0, >= 3.0.3)
|
||||
geom2d (~> 0.4, >= 0.4.1)
|
||||
openssl (>= 2.2.1)
|
||||
@@ -433,7 +433,6 @@ GEM
|
||||
railties (>= 5.2)
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.6)
|
||||
rollbar (3.4.2)
|
||||
rotp (6.3.0)
|
||||
rqrcode (2.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
@@ -605,7 +604,6 @@ DEPENDENCIES
|
||||
rails
|
||||
rails-i18n
|
||||
rails_autolink
|
||||
rollbar
|
||||
rotp
|
||||
rqrcode
|
||||
rspec-rails
|
||||
|
||||
@@ -33,17 +33,32 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
[](https://demo.docuseal.co)
|
||||
|
||||
## Features
|
||||
- [x] PDF form fields builder (WYSIWYG)
|
||||
- [x] 11 field types available (Signature, Date, File, Checkbox etc.)
|
||||
- [x] Multiple submitters per document
|
||||
- [x] Automated emails via SMTP
|
||||
- [x] Files storage on disk or AWS S3, Google Storage, Azure Cloud
|
||||
- [x] Automatic PDF eSignature
|
||||
- [x] PDF signature verification
|
||||
- [x] Users management
|
||||
- [x] Mobile-optimized
|
||||
- [x] API and Webhooks for integrations
|
||||
- [x] Easy to deploy in minutes
|
||||
- PDF form fields builder (WYSIWYG)
|
||||
- 12 field types available (Signature, Date, File, Checkbox etc.)
|
||||
- Multiple submitters per document
|
||||
- Automated emails via SMTP
|
||||
- Files storage on disk or AWS S3, Google Storage, Azure Cloud
|
||||
- Automatic PDF eSignature
|
||||
- PDF signature verification
|
||||
- Users management
|
||||
- Mobile-optimized
|
||||
- Signing available in 13 languages
|
||||
- API and Webhooks for integrations
|
||||
- Easy to deploy in minutes
|
||||
|
||||
## Pro Features
|
||||
- Company logo and white-label
|
||||
- User roles
|
||||
- Automated reminders
|
||||
- Invitation and identify verification via SMS
|
||||
- Conditional fields and formulas
|
||||
- Bulk send with CSV, XLSX spreadsheet import
|
||||
- SSO / SAML
|
||||
- Template creation with HTML API ([Guide](https://www.docuseal.co/guides/create-pdf-document-fillable-form-with-html-api))
|
||||
- Template creation with PDF or DOCX and field tags API ([Guide](https://www.docuseal.co/guides/use-embedded-text-field-tags-in-the-pdf-to-create-a-fillable-form))
|
||||
- Embedded signing form ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue), [Angular](https://github.com/docusealco/docuseal-angular) or [JavaScript](https://www.docuseal.co/docs/embedded))
|
||||
- Embedded document form builder ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue), [Angular](https://github.com/docusealco/docuseal-angular) or [JavaScript](https://www.docuseal.co/docs/embedded))
|
||||
- [Learn more](https://www.docuseal.co/pricing)
|
||||
|
||||
## Deploy
|
||||
|
||||
@@ -52,9 +67,6 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|
||||
|**DigitalOcean**|**Render**|
|
||||
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
|
||||
|**Koyeb**|**Elestio**|
|
||||
| [<img alt="Deploy on Koyeb" src="https://www.koyeb.com/static/images/deploy/button.svg" height="40">](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | [<img alt="Deploy on Elestio" src="https://pub-da36157c854648669813f3f76c526c2b.r2.dev/deploy-on-elestio-black.png">](https://dash.elest.io/deploy?soft=DocuSeal&id=339) |
|
||||
|
||||
|
||||
#### Docker
|
||||
|
||||
@@ -77,7 +89,7 @@ sudo HOST=your-domain-name.com docker compose up
|
||||
```
|
||||
|
||||
## For Businesses
|
||||
### Integrate seamless document signing into your web or mobile apps with DocuSeal!
|
||||
### 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.
|
||||
|
||||
@@ -87,3 +99,9 @@ At DocuSeal we have expertise and technologies to make documents creation, filli
|
||||
|
||||
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information.
|
||||
Unless otherwise noted, all files © 2023 DocuSeal LLC.
|
||||
|
||||
## Tools
|
||||
|
||||
- [Signature Maker](https://www.docuseal.co/online-signature)
|
||||
- [Sign Document Online](https://www.docuseal.co/sign-documents-online)
|
||||
- [Fill PDF Online](https://www.docuseal.co/fill-pdf)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Reporting a Vulnerability
|
||||
|
||||
If you come across any security concern or vulnarability, please report the information via email to security@docuseal.co instead of opening a GitHub issue. We will promptly respond and will collaborate with you to validate the issue, and resolve it ASAP.
|
||||
|
||||
**We have a bug bounty program to reward security researchers.**
|
||||
@@ -8,9 +8,12 @@ class AccountConfigsController < ApplicationController
|
||||
AccountConfig::ALLOW_TYPED_SIGNATURE,
|
||||
AccountConfig::FORCE_MFA,
|
||||
AccountConfig::ALLOW_TO_RESUBMIT,
|
||||
AccountConfig::ESIGNING_PREFERENCE_KEY
|
||||
AccountConfig::ESIGNING_PREFERENCE_KEY,
|
||||
AccountConfig::FORM_WITH_CONFETTI_KEY
|
||||
].freeze
|
||||
|
||||
InvalidKey = Class.new(StandardError)
|
||||
|
||||
def create
|
||||
@account_config.update!(account_config_params)
|
||||
|
||||
@@ -20,7 +23,7 @@ class AccountConfigsController < ApplicationController
|
||||
private
|
||||
|
||||
def load_account_config
|
||||
return head :not_found unless ALLOWED_KEYS.include?(account_config_params[:key])
|
||||
raise InvalidKey unless ALLOWED_KEYS.include?(account_config_params[:key])
|
||||
|
||||
@account_config =
|
||||
AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key])
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class ActiveStorageBlobsProxyController < ApiBaseController
|
||||
include ActiveStorage::Streaming
|
||||
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
before_action :set_cors_headers
|
||||
|
||||
def show
|
||||
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
|
||||
|
||||
if blob_uuid.blank? || (purp.present? && purp != 'blob') || (exp && exp < Time.current.to_i)
|
||||
Rollbar.error('Blob not found') if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
|
||||
|
||||
if request.headers['Range'].present?
|
||||
send_blob_byte_range_data blob, request.headers['Range']
|
||||
else
|
||||
http_cache_forever public: true do
|
||||
response.headers['Accept-Ranges'] = 'bytes'
|
||||
response.headers['Content-Length'] = blob.byte_size.to_s
|
||||
|
||||
send_blob_stream blob, disposition: params[:disposition]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class ActiveStorageBlobsProxyLegacyController < ApiBaseController
|
||||
include ActiveStorage::Streaming
|
||||
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def show
|
||||
Rollbar.info('Blob legacy') if defined?(Rollbar)
|
||||
|
||||
blob = ActiveStorage::Blob.find_signed(params[:signed_blob_id] || params[:signed_id])
|
||||
|
||||
return head :not_found unless blob
|
||||
|
||||
is_permitted = blob.attachments.any? do |a|
|
||||
(current_user && a.record.account.id == current_user.account_id) ||
|
||||
a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } ||
|
||||
a.name == 'logo'
|
||||
end
|
||||
|
||||
unless is_permitted
|
||||
Rollbar.error("Blob account not found: #{blob.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: 'Not authenticated' }, status: :unauthorized
|
||||
end
|
||||
|
||||
if request.headers['Range'].present?
|
||||
send_blob_byte_range_data blob, request.headers['Range']
|
||||
else
|
||||
http_cache_forever public: true do
|
||||
response.headers['Accept-Ranges'] = 'bytes'
|
||||
response.headers['Content-Length'] = blob.byte_size.to_s
|
||||
|
||||
send_blob_stream blob, disposition: params[:disposition]
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
end
|
||||
end
|
||||
@@ -19,15 +19,19 @@ module Api
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
rescue_from RateLimit::LimitApproached do |e|
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: 'Too many requests' }, status: :too_many_requests
|
||||
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)
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: "JSON parse error: #{e.message}" }, status: :unprocessable_entity
|
||||
end
|
||||
@@ -39,8 +43,8 @@ module Api
|
||||
result = relation.order(id: :desc)
|
||||
.limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min)
|
||||
|
||||
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 = result.where(id: ...params[:after].to_i) if params[:after].present?
|
||||
result = result.where(id: (params[:before].to_i + 1)...) if params[:before].present?
|
||||
|
||||
result
|
||||
end
|
||||
@@ -59,5 +63,13 @@ module Api
|
||||
def current_account
|
||||
current_user&.account
|
||||
end
|
||||
|
||||
def set_cors_headers
|
||||
headers['Access-Control-Allow-Origin'] = '*'
|
||||
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
|
||||
headers['Access-Control-Allow-Headers'] = '*'
|
||||
headers['Access-Control-Max-Age'] = '1728000'
|
||||
headers['Access-Control-Allow-Credentials'] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,7 +17,8 @@ module Api
|
||||
submissions = submissions.joins(template: :folder).where(folder: { name: params[:template_folder] })
|
||||
end
|
||||
|
||||
submissions = paginate(submissions.preload(:created_by_user, :template, :submitters,
|
||||
submissions = paginate(submissions.preload(:created_by_user, :submitters,
|
||||
template: :folder,
|
||||
audit_trail_attachment: :blob))
|
||||
|
||||
render json: {
|
||||
@@ -33,12 +34,18 @@ module Api
|
||||
def show
|
||||
submitters = @submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob)
|
||||
|
||||
serialized_submitters = submitters.map do |submitter|
|
||||
Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at?
|
||||
|
||||
Submitters::SerializeForApi.call(submitter)
|
||||
submitters.each do |submitter|
|
||||
if submitter.completed_at? && submitter.documents_attachments.blank?
|
||||
submitter.documents_attachments = Submissions::EnsureResultGenerated.call(submitter)
|
||||
end
|
||||
end
|
||||
|
||||
if @submission.audit_trail_attachment.blank? && submitters.all?(&:completed_at?)
|
||||
@submission.audit_trail_attachment = Submissions::GenerateAuditTrail.call(@submission)
|
||||
end
|
||||
|
||||
serialized_submitters = submitters.map { |submitter| Submitters::SerializeForApi.call(submitter) }
|
||||
|
||||
json = @submission.as_json(
|
||||
serialize_params.deep_merge(
|
||||
include: { submission_events: { only: %i[id submitter_id event_type event_timestamp] } }
|
||||
@@ -68,7 +75,7 @@ module Api
|
||||
return render json: { error: 'Template not found' }, status: :unprocessable_entity if @template.nil?
|
||||
|
||||
if @template.fields.blank?
|
||||
Rollbar.error("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
|
||||
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: 'Template does not contain fields' }, status: :unprocessable_entity
|
||||
end
|
||||
@@ -80,14 +87,24 @@ module Api
|
||||
|
||||
Submissions.send_signature_requests(submissions)
|
||||
|
||||
submissions.each do |submission|
|
||||
if submission.submitters.all?(&:completed_at?) && submission.submitters.last
|
||||
ProcessSubmitterCompletionJob.perform_later(submission.submitters.last)
|
||||
end
|
||||
end
|
||||
|
||||
render json: submissions.flat_map(&:submitters)
|
||||
rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e
|
||||
rescue Submitters::NormalizeValues::BaseError => e
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
@submission.update!(archived_at: Time.current)
|
||||
|
||||
SendSubmissionArchivedWebhookRequestJob.perform_later(@submission)
|
||||
|
||||
render json: @submission.as_json(only: %i[id], methods: %i[archived_at])
|
||||
end
|
||||
|
||||
@@ -118,8 +135,13 @@ module Api
|
||||
params:
|
||||
)
|
||||
|
||||
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments,
|
||||
submissions.flat_map(&:submitters))
|
||||
submitters = submissions.flat_map(&:submitters)
|
||||
|
||||
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments, submitters)
|
||||
|
||||
submitters.each do |submitter|
|
||||
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request) if submitter.completed_at?
|
||||
end
|
||||
|
||||
submissions
|
||||
end
|
||||
@@ -134,7 +156,8 @@ module Api
|
||||
completed_at opened_at sent_at
|
||||
created_at updated_at external_id metadata],
|
||||
methods: %i[status application_key] },
|
||||
template: { only: %i[id name created_at updated_at] },
|
||||
template: { only: %i[id name external_id created_at updated_at],
|
||||
methods: %i[folder_name] },
|
||||
created_by_user: { only: %i[id email first_name last_name] }
|
||||
}
|
||||
}
|
||||
@@ -142,14 +165,15 @@ module Api
|
||||
|
||||
def submissions_params
|
||||
permitted_attrs = [
|
||||
:send_email, :send_sms, :bcc_completed, :completed_redirect_url,
|
||||
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last,
|
||||
{
|
||||
message: %i[subject body],
|
||||
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
|
||||
:completed, :phone, :application_key, :external_id,
|
||||
:completed, :phone, :application_key, :external_id, :reply_to, :go_to_last,
|
||||
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
|
||||
fields: [%i[name default_value title description
|
||||
readonly validation_pattern invalid_message]] }]]
|
||||
fields: [:name, :uuid, :default_value, :title, :description,
|
||||
:readonly, :redacted, :validation_pattern, :invalid_message,
|
||||
{ default_value: [] }] }]]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ module Api
|
||||
def create
|
||||
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
submitter.opened_at = Time.current
|
||||
submitter.save
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request)
|
||||
|
||||
SendFormViewedWebhookRequestJob.perform_later(submitter)
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class SubmittersAutocompleteController < ApiBaseController
|
||||
load_and_authorize_resource :submitter, parent: false
|
||||
|
||||
SELECT_COLUMNS = %w[email phone name].freeze
|
||||
LIMIT = 100
|
||||
|
||||
def index
|
||||
submitters = search_submitters(@submitters)
|
||||
|
||||
values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', '))
|
||||
|
||||
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
|
||||
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
|
||||
|
||||
render json: attrs
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_submitters(submitters)
|
||||
if SELECT_COLUMNS.include?(params[:field])
|
||||
column = Submitter.arel_table[params[:field].to_sym]
|
||||
|
||||
term = "%#{params[:q].downcase}%"
|
||||
|
||||
submitters.where(column.lower.matches(term))
|
||||
else
|
||||
Submitters.search(submitters, params[:q])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -10,6 +10,7 @@ module Api
|
||||
submitters = submitters.where(external_id: params[:application_key]) if params[:application_key].present?
|
||||
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
|
||||
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
|
||||
submitters = maybe_filder_by_completed_at(submitters, params)
|
||||
|
||||
submitters = paginate(
|
||||
submitters.preload(:template, :submission, :submission_events,
|
||||
@@ -55,6 +56,8 @@ module Api
|
||||
@submitter.save!
|
||||
|
||||
@submitter.submission.save!
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request) if @submitter.completed_at?
|
||||
end
|
||||
|
||||
if @submitter.completed_at?
|
||||
@@ -70,15 +73,28 @@ module Api
|
||||
submitter_params = params.key?(:submitter) ? params.require(:submitter) : params
|
||||
|
||||
submitter_params.permit(
|
||||
:send_email, :send_sms, :uuid, :name, :email, :role,
|
||||
:completed, :phone, :application_key, :external_id,
|
||||
:send_email, :send_sms, :reply_to, :completed_redirect_url, :uuid, :name, :email, :role,
|
||||
:completed, :phone, :application_key, :external_id, :go_to_last,
|
||||
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
|
||||
fields: [%i[name default_value readonly validation_pattern invalid_message]] }
|
||||
fields: [[:name, :uuid, :default_value, :readonly, :redacted, :validation_pattern, :invalid_message,
|
||||
{ default_value: [] }]] }
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def maybe_filder_by_completed_at(submitters, params)
|
||||
if params[:completed_after].present?
|
||||
submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..)
|
||||
end
|
||||
|
||||
if params[:completed_before].present?
|
||||
submitters = submitters.where(completed_at: ..Time.zone.parse(params[:completed_before]))
|
||||
end
|
||||
|
||||
submitters
|
||||
end
|
||||
|
||||
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)
|
||||
@@ -109,6 +125,14 @@ module Api
|
||||
end
|
||||
|
||||
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
|
||||
submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to')
|
||||
if submitter_preferences.key?('go_to_last')
|
||||
submitter.preferences['go_to_last'] = submitter_preferences['go_to_last']
|
||||
end
|
||||
|
||||
if submitter_preferences.key?('completed_redirect_url')
|
||||
submitter.preferences['completed_redirect_url'] = submitter_preferences['completed_redirect_url']
|
||||
end
|
||||
|
||||
return unless submitter_preferences.key?('email_message_uuid')
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class TemplateFoldersAutocompleteController < ApiBaseController
|
||||
load_and_authorize_resource :template_folder, parent: false
|
||||
|
||||
LIMIT = 100
|
||||
|
||||
def index
|
||||
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 archived_at])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -20,16 +20,7 @@ module Api
|
||||
|
||||
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] } }
|
||||
}
|
||||
render json: Templates::SerializeForApi.call(cloned_template)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,10 +7,31 @@ module Api
|
||||
def index
|
||||
templates = filter_templates(@templates, params)
|
||||
|
||||
templates = paginate(templates.preload(:author, documents_attachments: :blob))
|
||||
templates = paginate(templates.preload(:author, :folder))
|
||||
|
||||
schema_documents =
|
||||
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
|
||||
record_type: 'Template',
|
||||
name: :documents,
|
||||
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
|
||||
.preload(:blob)
|
||||
|
||||
preview_image_attachments =
|
||||
ActiveStorage::Attachment.joins(:blob)
|
||||
.where(blob: { filename: '0.jpg' })
|
||||
.where(record_id: schema_documents.map(&:id),
|
||||
record_type: 'ActiveStorage::Attachment',
|
||||
name: :preview_images)
|
||||
.preload(:blob)
|
||||
|
||||
render json: {
|
||||
data: templates.as_json(serialize_params),
|
||||
data: templates.map do |t|
|
||||
Templates::SerializeForApi.call(
|
||||
t,
|
||||
schema_documents.select { |e| e.record_id == t.id },
|
||||
preview_image_attachments
|
||||
)
|
||||
end,
|
||||
pagination: {
|
||||
count: templates.size,
|
||||
next: templates.last&.id,
|
||||
@@ -20,14 +41,28 @@ module Api
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @template.as_json(serialize_params)
|
||||
render json: Templates::SerializeForApi.call(@template)
|
||||
end
|
||||
|
||||
def update
|
||||
if (folder_name = params.dig(:template, :folder_name))
|
||||
if (folder_name = params[:folder_name] || params.dig(:template, :folder_name))
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
|
||||
end
|
||||
|
||||
Array.wrap(params[:roles].presence || params.dig(:template, :roles).presence).each_with_index do |role, index|
|
||||
if (item = @template.submitters[index])
|
||||
item['name'] = role
|
||||
else
|
||||
@template.submitters << { 'name' => role, 'uuid' => SecureRandom.uuid }
|
||||
end
|
||||
end
|
||||
|
||||
archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived)
|
||||
|
||||
if archived.in?([true, false])
|
||||
@template.archived_at = archived == true ? Time.current : nil
|
||||
end
|
||||
|
||||
@template.update!(template_params)
|
||||
|
||||
render json: @template.as_json(only: %i[id updated_at])
|
||||
@@ -55,24 +90,12 @@ module Api
|
||||
templates
|
||||
end
|
||||
|
||||
def serialize_params
|
||||
{
|
||||
methods: %i[application_key],
|
||||
include: { author: { only: %i[id email first_name last_name] },
|
||||
documents: { only: %i[id uuid], methods: %i[url preview_image_url filename] } }
|
||||
}
|
||||
end
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(
|
||||
:name,
|
||||
schema: [%i[attachment_uuid name]],
|
||||
submitters: [%i[name uuid]],
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
|
||||
{ preferences: {},
|
||||
options: [%i[value uuid]],
|
||||
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
|
||||
)
|
||||
if params.key?(:template)
|
||||
params.require(:template).permit(:name, submitters: [%i[name uuid]])
|
||||
else
|
||||
params.permit(:name, submitters: [%i[name uuid]])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class TemplatesDocumentsController < ApiBaseController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
|
||||
|
||||
documents = Templates::CreateAttachments.call(@template, params)
|
||||
|
||||
schema = documents.map do |doc|
|
||||
{ attachment_uuid: doc.uuid, name: doc.filename.base }
|
||||
end
|
||||
|
||||
render json: {
|
||||
schema:,
|
||||
documents: documents.as_json(
|
||||
methods: [:metadata],
|
||||
include: {
|
||||
preview_images: { methods: %i[url metadata filename] }
|
||||
}
|
||||
)
|
||||
}
|
||||
rescue Templates::CreateAttachments::PdfEncrypted
|
||||
render json: { error: 'PDF encrypted' }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -22,9 +22,15 @@ class ApplicationController < ActionController::Base
|
||||
redirect_to request.path
|
||||
end
|
||||
|
||||
rescue_from RateLimit::LimitApproached do |e|
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
redirect_to request.referer, alert: 'Too many requests', status: :too_many_requests
|
||||
end
|
||||
|
||||
if Rails.env.production?
|
||||
rescue_from CanCan::AccessDenied do |e|
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
redirect_to root_path, alert: e.message
|
||||
end
|
||||
|
||||
@@ -5,6 +5,10 @@ class ConsoleRedirectController < ApplicationController
|
||||
skip_authorization_check
|
||||
|
||||
def index
|
||||
if request.path == '/upgrade'
|
||||
params[:redir] = Docuseal.multitenant? ? "#{Docuseal::CONSOLE_URL}/plans" : "#{Docuseal::CONSOLE_URL}/on_premise"
|
||||
end
|
||||
|
||||
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank?
|
||||
|
||||
auth = JsonWebToken.encode(uuid: true_user.uuid,
|
||||
|
||||
@@ -7,62 +7,18 @@ class DashboardController < ApplicationController
|
||||
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
|
||||
|
||||
SHOW_TEMPLATES_FOLDERS_THRESHOLD = 9
|
||||
TEMPLATES_PER_PAGE = 12
|
||||
FOLDERS_PER_PAGE = 18
|
||||
skip_authorization_check
|
||||
|
||||
def index
|
||||
@template_folders = filter_template_folders(@template_folders)
|
||||
|
||||
@pagy, @template_folders = pagy(
|
||||
@template_folders,
|
||||
items: FOLDERS_PER_PAGE,
|
||||
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
|
||||
)
|
||||
|
||||
if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD
|
||||
@templates = @templates.none
|
||||
if cookies.permanent[:dashboard_view] == 'submissions'
|
||||
SubmissionsDashboardController.dispatch(:index, request, response)
|
||||
else
|
||||
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
|
||||
@templates = filter_templates(@templates)
|
||||
|
||||
items =
|
||||
if @template_folders.size < 4
|
||||
TEMPLATES_PER_PAGE
|
||||
else
|
||||
(@template_folders.size < 7 ? 9 : 6)
|
||||
end
|
||||
|
||||
@pagy, @templates = pagy(@templates, items:)
|
||||
TemplatesDashboardController.dispatch(:index, request, response)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filter_template_folders(template_folders)
|
||||
rel = template_folders.joins(:active_templates)
|
||||
.order(id: :desc)
|
||||
.distinct
|
||||
|
||||
TemplateFolders.search(rel, params[:q])
|
||||
end
|
||||
|
||||
def filter_templates(templates)
|
||||
rel = templates.active.preload(:author).order(id: :desc)
|
||||
|
||||
if params[:q].blank?
|
||||
shared_template_ids =
|
||||
TemplateSharing.where(account_id: [current_account.id, TemplateSharing::ALL_ID]).select(:template_id)
|
||||
|
||||
rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids))
|
||||
end
|
||||
|
||||
Templates.search(rel, params[:q])
|
||||
end
|
||||
|
||||
def maybe_redirect_product_url
|
||||
return if !Docuseal.multitenant? || signed_in?
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class EncryptedUserConfigsController < ApplicationController
|
||||
load_and_authorize_resource :encrypted_user_config
|
||||
|
||||
def destroy
|
||||
@encrypted_user_config.destroy!
|
||||
|
||||
redirect_back(fallback_location: root_path)
|
||||
end
|
||||
end
|
||||
@@ -22,6 +22,8 @@ class ErrorsController < ActionController::Base
|
||||
|
||||
respond_to do |f|
|
||||
f.json do
|
||||
set_cors_headers
|
||||
|
||||
render json: { status: error_status_code }, status: error_status_code
|
||||
end
|
||||
|
||||
@@ -31,6 +33,14 @@ class ErrorsController < ActionController::Base
|
||||
|
||||
private
|
||||
|
||||
def set_cors_headers
|
||||
headers['Access-Control-Allow-Origin'] = '*'
|
||||
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
|
||||
headers['Access-Control-Allow-Headers'] = '*'
|
||||
headers['Access-Control-Max-Age'] = '1728000'
|
||||
headers['Access-Control-Allow-Credentials'] = true
|
||||
end
|
||||
|
||||
def error_status_code
|
||||
@error_status_code ||=
|
||||
ActionDispatch::ExceptionWrapper.new(request.env,
|
||||
|
||||
@@ -35,12 +35,12 @@ class NotificationsSettingsController < ApplicationController
|
||||
|
||||
def load_reminder_config
|
||||
@reminder_config =
|
||||
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::SUBMITTER_REMAILERS)
|
||||
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::SUBMITTER_REMINDERS)
|
||||
end
|
||||
|
||||
def email_config_params
|
||||
params.require(:account_config).permit!.tap do |attrs|
|
||||
attrs[:key] = nil unless attrs[:key].in?([AccountConfig::BCC_EMAILS, AccountConfig::SUBMITTER_REMAILERS])
|
||||
attrs[:key] = nil unless attrs[:key].in?([AccountConfig::BCC_EMAILS, AccountConfig::SUBMITTER_REMINDERS])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,12 @@ class PasswordsController < Devise::PasswordsController
|
||||
attribute :user
|
||||
end
|
||||
|
||||
def create
|
||||
super do |resource|
|
||||
resource.errors.clear unless Docuseal.multitenant?
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
super do |resource|
|
||||
Current.user = resource
|
||||
|
||||
@@ -1,26 +1,52 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PersonalizationSettingsController < ApplicationController
|
||||
ALLOWED_KEYS = [
|
||||
AccountConfig::FORM_COMPLETED_BUTTON_KEY,
|
||||
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY,
|
||||
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY,
|
||||
AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY,
|
||||
AccountConfig::FORM_COMPLETED_MESSAGE_KEY
|
||||
].freeze
|
||||
|
||||
InvalidKey = Class.new(StandardError)
|
||||
|
||||
before_action :load_and_authorize_account_config, only: :create
|
||||
|
||||
def show
|
||||
authorize!(:read, AccountConfig)
|
||||
end
|
||||
|
||||
def create
|
||||
account_config =
|
||||
current_account.account_configs.find_or_initialize_by(key: account_config_params[:key])
|
||||
|
||||
authorize!(:create, account_config)
|
||||
|
||||
account_config.update!(account_config_params)
|
||||
if @account_config.value != false && @account_config.value.blank?
|
||||
@account_config.destroy!
|
||||
else
|
||||
@account_config.save!
|
||||
end
|
||||
|
||||
redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_and_authorize_account_config
|
||||
@account_config =
|
||||
current_account.account_configs.find_or_initialize_by(key: account_config_params[:key])
|
||||
|
||||
@account_config.assign_attributes(account_config_params)
|
||||
|
||||
authorize!(:create, @account_config)
|
||||
|
||||
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
|
||||
|
||||
@account_config
|
||||
end
|
||||
|
||||
def account_config_params
|
||||
attrs = params.require(:account_config).permit!
|
||||
|
||||
return attrs if attrs[:value].is_a?(String)
|
||||
|
||||
attrs[:value]&.transform_values! do |value|
|
||||
if value.in?(%w[true false])
|
||||
value == 'true'
|
||||
|
||||
@@ -6,7 +6,9 @@ class PreviewDocumentPageController < ActionController::API
|
||||
FORMAT = Templates::ProcessDocument::FORMAT
|
||||
|
||||
def show
|
||||
attachment = ActiveStorage::Attachment.find_by(uuid: params[:attachment_uuid])
|
||||
attachment_uuid = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid], purpose: :attachment)
|
||||
|
||||
attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid) if attachment_uuid
|
||||
|
||||
return head :not_found unless attachment
|
||||
|
||||
@@ -21,9 +23,10 @@ class PreviewDocumentPageController < ActionController::API
|
||||
find_or_create_document_tempfile_path(attachment)
|
||||
end
|
||||
|
||||
io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
|
||||
preview_image =
|
||||
Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
|
||||
|
||||
render plain: io.tap(&:rewind).read, content_type: 'image/jpeg'
|
||||
redirect_to preview_image.url, allow_other_host: true
|
||||
end
|
||||
|
||||
def find_or_create_document_tempfile_path(attachment)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RegistrationsController < Devise::RegistrationsController
|
||||
prepend_before_action :require_no_authentication, only: [:show]
|
||||
prepend_before_action :maybe_redirect_if_signed_in, only: [:show]
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
super
|
||||
|
||||
Accounts.create_default_template(resource.account) if resource.account.persisted?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def after_sign_up_path_for(...)
|
||||
if params[:redir].present?
|
||||
return console_redirect_index_path(redir: params[:redir]) if params[:redir].starts_with?(Docuseal::CONSOLE_URL)
|
||||
|
||||
return params[:redir]
|
||||
end
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def maybe_redirect_if_signed_in
|
||||
return unless signed_in?
|
||||
return if params[:redir].blank?
|
||||
|
||||
redirect_to after_sign_up_path_for(current_user), allow_other_host: true
|
||||
end
|
||||
|
||||
def set_flash_message(key, kind, options = {})
|
||||
return if key == :alert && kind == 'already_authenticated'
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def build_resource(_hash = {})
|
||||
account = Account.new(account_params)
|
||||
account.timezone = Accounts.normalize_timezone(account.timezone)
|
||||
|
||||
self.resource = account.users.new(user_params)
|
||||
|
||||
account.name ||= resource.full_name if params[:action] == 'create'
|
||||
end
|
||||
|
||||
def user_params
|
||||
return {} if params[:user].blank?
|
||||
|
||||
params.require(:user).permit(:first_name, :last_name, :email, :password).compact_blank.tap do |attrs|
|
||||
attrs[:password] ||= SecureRandom.hex if params[:action] == 'create'
|
||||
end
|
||||
end
|
||||
|
||||
def account_params
|
||||
return {} if params[:account].blank?
|
||||
|
||||
params.require(:account).permit(:name, :timezone).compact_blank
|
||||
end
|
||||
end
|
||||
@@ -12,13 +12,18 @@ class SendSubmissionEmailController < ApplicationController
|
||||
def create
|
||||
@submitter =
|
||||
if params[:template_slug]
|
||||
Submitter.joins(submission: :template).find_by!(email: params[:email],
|
||||
Submitter.joins(submission: :template).find_by!(email: params[:email].to_s.downcase,
|
||||
template: { slug: params[:template_slug] })
|
||||
elsif params[:submission_slug]
|
||||
Submitter.joins(:submission).find_by!(email: params[:email].to_s.downcase,
|
||||
submission: { slug: params[:submission_slug] })
|
||||
else
|
||||
Submitter.find_by!(slug: params[:submitter_slug])
|
||||
end
|
||||
|
||||
SubmitterMailer.documents_copy_email(@submitter).deliver_later!
|
||||
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
|
||||
|
||||
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later!
|
||||
|
||||
respond_to do |f|
|
||||
f.html { redirect_to success_send_submission_email_index_path }
|
||||
|
||||
@@ -4,12 +4,14 @@ class SessionsController < Devise::SessionsController
|
||||
before_action :configure_permitted_parameters
|
||||
|
||||
def create
|
||||
if Docuseal.multitenant? && !User.exists?(email: sign_in_params[:email])
|
||||
email = sign_in_params[:email].to_s.downcase
|
||||
|
||||
if Docuseal.multitenant? && !User.exists?(email:)
|
||||
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
|
||||
notice: 'Create a new account'
|
||||
end
|
||||
|
||||
if User.exists?(email: sign_in_params[:email], otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
|
||||
if User.exists?(email:, otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
|
||||
return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
class SsoSettingsController < ApplicationController
|
||||
before_action :load_encrypted_config
|
||||
authorize_resource :encrypted_config, only: :index
|
||||
authorize_resource :encrypted_config, parent: false, except: :index
|
||||
|
||||
def index; end
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ class StartFormController < ApplicationController
|
||||
end
|
||||
|
||||
def update
|
||||
return redirect_to start_form_path(@template.slug) if @template.archived_at?
|
||||
|
||||
@submitter = Submitter.where(submission: @template.submissions.where(archived_at: nil))
|
||||
.order(id: :desc)
|
||||
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmissionsArchivedController < ApplicationController
|
||||
load_and_authorize_resource :submission, parent: false
|
||||
|
||||
def index
|
||||
@submissions = @submissions.joins(:template)
|
||||
@submissions = @submissions.where.not(archived_at: nil)
|
||||
.or(@submissions.where.not(templates: { archived_at: nil }))
|
||||
.preload(:created_by_user, template: :author)
|
||||
@submissions = Submissions.search(@submissions, params[:q], search_template: true)
|
||||
|
||||
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
|
||||
end
|
||||
end
|
||||
@@ -6,23 +6,8 @@ class SubmissionsController < ApplicationController
|
||||
|
||||
load_and_authorize_resource :submission, only: %i[show destroy]
|
||||
|
||||
PRELOAD_ALL_PAGES_AMOUNT = 200
|
||||
|
||||
def show
|
||||
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
|
||||
@submission = Submissions.preload_with_pages(@submission)
|
||||
|
||||
render :show, layout: 'plain'
|
||||
end
|
||||
@@ -65,6 +50,8 @@ class SubmissionsController < ApplicationController
|
||||
def destroy
|
||||
@submission.update!(archived_at: Time.current)
|
||||
|
||||
SendSubmissionArchivedWebhookRequestJob.perform_later(@submission)
|
||||
|
||||
redirect_back(fallback_location: template_path(@submission.template), notice: 'Submission has been archived')
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmissionsDashboardController < ApplicationController
|
||||
load_and_authorize_resource :submission, parent: false
|
||||
|
||||
def index
|
||||
@submissions = @submissions.joins(:template)
|
||||
|
||||
@submissions = @submissions.where(archived_at: nil)
|
||||
.where(templates: { archived_at: nil })
|
||||
.preload(:created_by_user, template: :author)
|
||||
|
||||
@submissions = Submissions.search(@submissions, params[:q], search_template: true)
|
||||
|
||||
@submissions = @submissions.pending if params[:status] == 'pending'
|
||||
@submissions = @submissions.completed if params[:status] == 'completed'
|
||||
|
||||
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
|
||||
end
|
||||
end
|
||||
@@ -4,8 +4,20 @@ class SubmissionsDownloadController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
TTL = 40.minutes
|
||||
FILES_TTL = 5.minutes
|
||||
|
||||
def index
|
||||
submitter = Submitter.find_by(slug: params[:submitter_slug])
|
||||
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
|
||||
signature_valid =
|
||||
if submitter&.slug == params[:submitter_slug]
|
||||
true
|
||||
else
|
||||
submitter = nil
|
||||
end
|
||||
|
||||
submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
Submissions::EnsureResultGenerated.call(submitter)
|
||||
|
||||
@@ -13,11 +25,26 @@ class SubmissionsDownloadController < ApplicationController
|
||||
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
urls =
|
||||
Submitters.select_attachments_for_download(last_submitter).map do |attachment|
|
||||
helpers.rails_blob_url(attachment)
|
||||
end
|
||||
return head :not_found unless last_submitter.completed_at?
|
||||
|
||||
render json: urls
|
||||
if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter)
|
||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
render json: build_urls(last_submitter)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def current_user_submitter?(submitter)
|
||||
current_user && current_user.account.submitters.exists?(id: submitter.id)
|
||||
end
|
||||
|
||||
def build_urls(submitter)
|
||||
Submitters.select_attachments_for_download(submitter).map do |attachment|
|
||||
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.minutes.from_now.to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,26 +4,48 @@ class SubmissionsPreviewController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
PRELOAD_ALL_PAGES_AMOUNT = 200
|
||||
TTL = 40.minutes
|
||||
|
||||
def show
|
||||
@submission = Submission.find_by!(slug: params[:slug])
|
||||
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@submission],
|
||||
associations: [:template, { template_schema_documents: :blob }]
|
||||
).call
|
||||
signature_valid =
|
||||
if submitter && submitter.submission.slug == params[:slug]
|
||||
@submission = submitter.submission
|
||||
|
||||
total_pages =
|
||||
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
|
||||
true
|
||||
end
|
||||
|
||||
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: @submission.template_schema_documents,
|
||||
associations: [:blob, { preview_images_attachments: :blob }]
|
||||
).call
|
||||
@submission ||= Submission.find_by!(slug: params[:slug])
|
||||
|
||||
if !@submission.submitters.all?(&:completed_at?) && current_user.blank?
|
||||
raise ActionController::RoutingError, 'Not Found'
|
||||
end
|
||||
|
||||
if !submission_valid_ttl?(@submission) && !signature_valid
|
||||
Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar)
|
||||
|
||||
return redirect_to submissions_preview_completed_path(@submission.slug)
|
||||
end
|
||||
|
||||
@submission = Submissions.preload_with_pages(@submission)
|
||||
|
||||
render 'submissions/show', layout: 'plain'
|
||||
end
|
||||
|
||||
def completed
|
||||
@submission = Submission.find_by!(slug: params[:submissions_preview_slug])
|
||||
|
||||
render :completed, layout: 'form'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def submission_valid_ttl?(submission)
|
||||
return true if current_user && current_user.account.submissions.exists?(id: submission.id)
|
||||
|
||||
last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at)
|
||||
|
||||
last_submitter && last_submitter.completed_at > TTL.ago
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,8 +31,6 @@ class SubmitFormController < ApplicationController
|
||||
|
||||
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
|
||||
|
||||
cookies[:submitter_sid] = @submitter.signed_id
|
||||
|
||||
render(@submitter.submission.template.archived_at? || @submitter.submission.archived_at? ? :archived : :show)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmittersAutocompleteController < ApplicationController
|
||||
load_and_authorize_resource :submitter, parent: false
|
||||
|
||||
SELECT_COLUMNS = %w[email phone name].freeze
|
||||
LIMIT = 100
|
||||
|
||||
def index
|
||||
submitters = search_submitters(@submitters)
|
||||
|
||||
values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', '))
|
||||
|
||||
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
|
||||
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
|
||||
|
||||
render json: attrs
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_submitters(submitters)
|
||||
if SELECT_COLUMNS.include?(params[:field])
|
||||
column = Submitter.arel_table[params[:field].to_sym]
|
||||
|
||||
term = "#{params[:q].downcase}%"
|
||||
|
||||
submitters.where(column.matches(term, false, true))
|
||||
else
|
||||
Submitters.search(submitters, params[:q])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplateDocumentsController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
|
||||
|
||||
old_fields_hash = @template.fields.hash
|
||||
|
||||
documents = Templates::CreateAttachments.call(@template, params)
|
||||
|
||||
schema = documents.map do |doc|
|
||||
{ attachment_uuid: doc.uuid, name: doc.filename.base }
|
||||
end
|
||||
|
||||
render json: {
|
||||
schema:,
|
||||
fields: old_fields_hash == @template.fields.hash ? nil : @template.fields,
|
||||
submitters: old_fields_hash == @template.fields.hash ? nil : @template.submitters,
|
||||
documents: documents.as_json(
|
||||
methods: %i[metadata signed_uuid],
|
||||
include: {
|
||||
preview_images: { methods: %i[url metadata filename] }
|
||||
}
|
||||
)
|
||||
}
|
||||
rescue Templates::CreateAttachments::PdfEncrypted
|
||||
render json: { error: 'PDF encrypted' }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplateFoldersAutocompleteController < ApplicationController
|
||||
load_and_authorize_resource :template_folder, parent: false
|
||||
|
||||
LIMIT = 100
|
||||
|
||||
def index
|
||||
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 archived_at])
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController
|
||||
|
||||
def index
|
||||
@submissions = @submissions.where.not(archived_at: nil)
|
||||
@submissions = Submissions.search(@submissions, params[:q])
|
||||
@submissions = Submissions.search(@submissions, params[:q], search_values: true)
|
||||
|
||||
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
|
||||
@@ -8,7 +8,7 @@ class TemplatesController < ApplicationController
|
||||
def show
|
||||
submissions = @template.submissions.accessible_by(current_ability)
|
||||
submissions = submissions.active if @template.archived_at.blank?
|
||||
submissions = Submissions.search(submissions, params[:q])
|
||||
submissions = Submissions.search(submissions, params[:q], search_values: true)
|
||||
|
||||
@base_submissions = submissions
|
||||
|
||||
@@ -33,7 +33,7 @@ class TemplatesController < ApplicationController
|
||||
@template_data =
|
||||
@template.as_json.merge(
|
||||
documents: @template.schema_documents.as_json(
|
||||
methods: [:metadata],
|
||||
methods: %i[metadata signed_uuid],
|
||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||
)
|
||||
).to_json
|
||||
@@ -51,17 +51,28 @@ class TemplatesController < ApplicationController
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
||||
end
|
||||
|
||||
@template.account = current_account
|
||||
if params[:account_id].present? && authorized_clone_account_id?(params[:account_id])
|
||||
@template.account_id = params[:account_id]
|
||||
@template.folder = @template.account.default_template_folder
|
||||
else
|
||||
@template.account = current_account
|
||||
end
|
||||
|
||||
if @template.save
|
||||
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
|
||||
|
||||
redirect_to edit_template_path(@template)
|
||||
maybe_redirect_to_template(@template)
|
||||
else
|
||||
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@template.update!(template_params)
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
def destroy
|
||||
notice =
|
||||
if params[:permanently].present?
|
||||
@@ -82,7 +93,31 @@ class TemplatesController < ApplicationController
|
||||
private
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(:name)
|
||||
params.require(:template).permit(
|
||||
:name,
|
||||
{ schema: [%i[attachment_uuid name]],
|
||||
submitters: [%i[name uuid]],
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type,
|
||||
:required, :readonly, :default_value,
|
||||
:title, :description,
|
||||
{ preferences: {},
|
||||
conditions: [%i[field_uuid value action]],
|
||||
options: [%i[value uuid]],
|
||||
validation: %i[message pattern],
|
||||
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
|
||||
)
|
||||
end
|
||||
|
||||
def authorized_clone_account_id?(account_id)
|
||||
true_user.account_id.to_s == account_id.to_s || true_user.account.linked_accounts.exists?(id: account_id)
|
||||
end
|
||||
|
||||
def maybe_redirect_to_template(template)
|
||||
if template.account == current_account
|
||||
redirect_to(edit_template_path(@template))
|
||||
else
|
||||
redirect_back(fallback_location: root_path, notice: 'Template has been clonned')
|
||||
end
|
||||
end
|
||||
|
||||
def load_base_template
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesDashboardController < ApplicationController
|
||||
load_and_authorize_resource :template_folder, parent: false
|
||||
load_and_authorize_resource :template, parent: false
|
||||
|
||||
SHOW_TEMPLATES_FOLDERS_THRESHOLD = 9
|
||||
TEMPLATES_PER_PAGE = 12
|
||||
FOLDERS_PER_PAGE = 18
|
||||
|
||||
def index
|
||||
@template_folders = filter_template_folders(@template_folders)
|
||||
|
||||
@pagy, @template_folders = pagy(
|
||||
@template_folders,
|
||||
items: FOLDERS_PER_PAGE,
|
||||
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
|
||||
)
|
||||
|
||||
if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD
|
||||
@templates = @templates.none
|
||||
else
|
||||
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
|
||||
@templates = filter_templates(@templates)
|
||||
|
||||
items =
|
||||
if @template_folders.size < 4
|
||||
TEMPLATES_PER_PAGE
|
||||
else
|
||||
(@template_folders.size < 7 ? 9 : 6)
|
||||
end
|
||||
|
||||
@pagy, @templates = pagy(@templates, items:)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filter_template_folders(template_folders)
|
||||
rel = template_folders.joins(:active_templates)
|
||||
.order(id: :desc)
|
||||
.distinct
|
||||
|
||||
TemplateFolders.search(rel, params[:q])
|
||||
end
|
||||
|
||||
def filter_templates(templates)
|
||||
rel = templates.active.preload(:author).order(id: :desc)
|
||||
|
||||
if params[:q].blank?
|
||||
if Docuseal.multitenant? && !current_account.testing?
|
||||
rel = rel.where(folder_id: current_account.default_template_folder.id)
|
||||
else
|
||||
shared_template_ids =
|
||||
TemplateSharing.where(account_id: [current_account.id, TemplateSharing::ALL_ID]).select(:template_id)
|
||||
|
||||
rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids))
|
||||
end
|
||||
end
|
||||
|
||||
Templates.search(rel, params[:q])
|
||||
end
|
||||
end
|
||||
@@ -12,7 +12,7 @@ class TemplatesPreviewController < ApplicationController
|
||||
@template_data =
|
||||
@template.as_json.merge(
|
||||
documents: @template.schema_documents.as_json(
|
||||
methods: [:metadata],
|
||||
methods: %i[metadata signed_uuid],
|
||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||
)
|
||||
).to_json
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UserConfigsController < ApplicationController
|
||||
before_action :load_user_config
|
||||
authorize_resource :user_config
|
||||
|
||||
ALLOWED_KEYS = [
|
||||
UserConfig::RECEIVE_COMPLETED_EMAIL
|
||||
].freeze
|
||||
|
||||
InvalidKey = Class.new(StandardError)
|
||||
|
||||
def create
|
||||
@user_config.update!(user_config_params)
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_user_config
|
||||
raise InvalidKey unless ALLOWED_KEYS.include?(user_config_params[:key])
|
||||
|
||||
@user_config =
|
||||
UserConfig.find_or_initialize_by(user: current_user, key: user_config_params[:key])
|
||||
end
|
||||
|
||||
def user_config_params
|
||||
params.required(:user_config).permit!.tap do |attrs|
|
||||
attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,11 +9,14 @@ class VerifyPdfSignatureController < ApplicationController
|
||||
HexaPDF::Document.new(io: file.open)
|
||||
end
|
||||
|
||||
cert_data = if Docuseal.multitenant?
|
||||
Docuseal::CERTS
|
||||
else
|
||||
EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
|
||||
end
|
||||
cert_data =
|
||||
if Docuseal.multitenant?
|
||||
value = EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
|
||||
|
||||
Docuseal::CERTS.merge(value)
|
||||
else
|
||||
EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
|
||||
end
|
||||
|
||||
default_pkcs = GenerateCertificate.load_pkcs(cert_data)
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class WebhookPreferencesController < ApplicationController
|
||||
EVENTS = %w[
|
||||
form.viewed
|
||||
form.started
|
||||
form.completed
|
||||
submission.archived
|
||||
].freeze
|
||||
|
||||
before_action :load_account_config
|
||||
authorize_resource :account_config, parent: false
|
||||
|
||||
def create
|
||||
@account_config.value[account_config_params[:event]] = account_config_params[:value] == '1'
|
||||
|
||||
@account_config.save!
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_account_config
|
||||
@account_config =
|
||||
current_account.account_configs.find_or_initialize_by(key: AccountConfig::WEBHOOK_PREFERENCES_KEY)
|
||||
@account_config.value ||= {}
|
||||
end
|
||||
|
||||
def account_config_params
|
||||
params.permit(:event, :value)
|
||||
end
|
||||
end
|
||||
@@ -3,8 +3,10 @@ import { encodeMethodIntoRequestBody } from '@hotwired/turbo-rails/app/javascrip
|
||||
|
||||
import { createApp, reactive } from 'vue'
|
||||
import TemplateBuilder from './template_builder/builder'
|
||||
import ImportList from './template_builder/import_list'
|
||||
|
||||
import ToggleVisible from './elements/toggle_visible'
|
||||
import ToggleCookies from './elements/toggle_cookies'
|
||||
import DisableHidden from './elements/disable_hidden'
|
||||
import TurboModal from './elements/turbo_modal'
|
||||
import FileDropzone from './elements/file_dropzone'
|
||||
@@ -20,6 +22,7 @@ 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 EmailsTextarea from './elements/emails_textarea'
|
||||
|
||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||
|
||||
@@ -53,6 +56,8 @@ window.customElements.define('folder-autocomplete', FolderAutocomplete)
|
||||
window.customElements.define('signature-form', SignatureForm)
|
||||
window.customElements.define('submit-form', SubmitForm)
|
||||
window.customElements.define('prompt-password', PromptPassword)
|
||||
window.customElements.define('emails-textarea', EmailsTextarea)
|
||||
window.customElements.define('toggle-cookies', ToggleCookies)
|
||||
|
||||
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
|
||||
document.addEventListener('turbo:submit-end', async (event) => {
|
||||
@@ -89,10 +94,33 @@ window.customElements.define('template-builder', class extends HTMLElement {
|
||||
withPhone: this.dataset.withPhone === 'true',
|
||||
withLogo: this.dataset.withLogo !== 'false',
|
||||
editable: this.dataset.editable !== 'false',
|
||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||
withPayment: this.dataset.withPayment === 'true',
|
||||
isPaymentConnected: this.dataset.isPaymentConnected === 'true',
|
||||
withFormula: this.dataset.withFormula === 'true',
|
||||
withConditions: this.dataset.withConditions === 'true',
|
||||
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
|
||||
acceptFileTypes: this.dataset.acceptFileTypes,
|
||||
isDirectUpload: this.dataset.isDirectUpload === 'true'
|
||||
acceptFileTypes: this.dataset.acceptFileTypes
|
||||
})
|
||||
|
||||
this.app.mount(this.appElem)
|
||||
|
||||
this.appendChild(this.appElem)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.app?.unmount()
|
||||
this.appElem?.remove()
|
||||
}
|
||||
})
|
||||
|
||||
window.customElements.define('import-list', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.appElem = document.createElement('div')
|
||||
|
||||
this.app = createApp(ImportList, {
|
||||
template: JSON.parse(this.dataset.template),
|
||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content
|
||||
})
|
||||
|
||||
this.app.mount(this.appElem)
|
||||
|
||||
@@ -75,16 +75,21 @@ button[disabled] .enabled {
|
||||
@apply select base-input w-full font-normal;
|
||||
}
|
||||
|
||||
.tooltip-bottom-start:before {
|
||||
transform: translateX(-30%);
|
||||
.tooltip-bottom-end:before {
|
||||
transform: translateX(-95%);
|
||||
top: var(--tooltip-offset);
|
||||
left: 100%;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.tooltip-bottom-start:after {
|
||||
transform: translateX(-75%);
|
||||
.tooltip-pre:before {
|
||||
white-space: pre;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tooltip-bottom-end:after {
|
||||
transform: translateX(-25%);
|
||||
border-color: transparent transparent var(--tooltip-color) transparent;
|
||||
top: var(--tooltip-tail-offset);
|
||||
left: 50%;
|
||||
@@ -125,3 +130,7 @@ button[disabled] .enabled {
|
||||
outline-offset: 3px;
|
||||
outline-color: hsl(var(--bc) / 0.2);
|
||||
}
|
||||
|
||||
select:required:invalid {
|
||||
color: gray !important;
|
||||
}
|
||||
|
||||
@@ -48,9 +48,9 @@ export default targetable(class extends HTMLElement {
|
||||
fileRequests.reduce(
|
||||
(prevPromise, request) => prevPromise.then(() => request()),
|
||||
Promise.resolve()
|
||||
)
|
||||
|
||||
this.toggleState()
|
||||
).finally(() => {
|
||||
this.toggleState()
|
||||
})
|
||||
}
|
||||
|
||||
downloadSafariIos (urls) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
const emailRegexp = /([^@;,<>\s]+@[^@;,<>\s]+)/g
|
||||
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
if (this.dataset.limit) {
|
||||
this.textarea.addEventListener('input', () => {
|
||||
const emails = this.textarea.value.match(emailRegexp) || []
|
||||
|
||||
this.updateCounter(emails.length)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
updateCounter (count) {
|
||||
let counter = document.getElementById('emails_counter')
|
||||
let bulkMessage = document.getElementById('bulk_message')
|
||||
|
||||
if (count < 2) {
|
||||
counter?.remove()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if ((count + 10) > this.dataset.limit) {
|
||||
if (!counter) {
|
||||
counter = document.createElement('span')
|
||||
|
||||
counter.id = 'emails_counter'
|
||||
counter.classList.add('text-xs', 'right-0', 'absolute')
|
||||
counter.style.bottom = '-15px'
|
||||
|
||||
this.textarea.parentNode.append(counter)
|
||||
}
|
||||
|
||||
counter.innerText = `${count} / ${this.dataset.limit}`
|
||||
}
|
||||
|
||||
if (this.dataset.bulkEnabled !== 'true') {
|
||||
if (!bulkMessage) {
|
||||
bulkMessage = document.createElement('span')
|
||||
|
||||
bulkMessage.id = 'bulk_message'
|
||||
bulkMessage.classList.add('text-xs', 'left-0', 'absolute')
|
||||
bulkMessage.style.bottom = '-15px'
|
||||
|
||||
this.textarea.parentNode.append(bulkMessage)
|
||||
}
|
||||
|
||||
bulkMessage.innerHTML = '<a class="link" data-turbo="false" href="/upgrade">Upgrade</a> to bulk send multiple recipients'
|
||||
}
|
||||
}
|
||||
|
||||
get textarea () {
|
||||
return this.querySelector('textarea')
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,6 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
]
|
||||
|
||||
connectedCallback () {
|
||||
if (this.dataset.isDirectUpload === 'true') {
|
||||
import('@rails/activestorage')
|
||||
}
|
||||
|
||||
this.addEventListener('drop', this.onDrop)
|
||||
|
||||
this.addEventListener('dragover', (e) => e.preventDefault())
|
||||
@@ -48,61 +44,11 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
this.classList.toggle('opacity-50')
|
||||
}
|
||||
|
||||
async uploadFiles (files) {
|
||||
uploadFiles () {
|
||||
this.toggleLoading()
|
||||
|
||||
if (this.dataset.isDirectUpload === 'true') {
|
||||
const { DirectUpload } = await import('@rails/activestorage')
|
||||
|
||||
await Promise.all(
|
||||
Array.from(files).map(async (file) => {
|
||||
const upload = new DirectUpload(
|
||||
file,
|
||||
'/direct_uploads',
|
||||
this.input
|
||||
)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
upload.create((error, blob) => {
|
||||
if (error) {
|
||||
console.error(error)
|
||||
|
||||
return reject(error)
|
||||
} else {
|
||||
return resolve(blob)
|
||||
}
|
||||
})
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
})
|
||||
).then((blobs) => {
|
||||
if (this.dataset.submitOnUpload) {
|
||||
this.querySelectorAll('[name="blob_signed_ids[]"]').forEach((e) => e.remove())
|
||||
}
|
||||
|
||||
blobs.forEach((blob) => {
|
||||
const input = document.createElement('input')
|
||||
|
||||
input.type = 'hidden'
|
||||
input.name = 'blob_signed_ids[]'
|
||||
input.value = blob.signed_id
|
||||
|
||||
this.append(input)
|
||||
})
|
||||
|
||||
if (this.dataset.submitOnUpload === 'true') {
|
||||
this.closest('form').querySelector('button[type="submit"]').click()
|
||||
}
|
||||
}).finally(() => {
|
||||
if (this.dataset.submitOnUpload !== 'true') {
|
||||
this.toggleLoading()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
if (this.dataset.submitOnUpload) {
|
||||
this.closest('form').querySelector('button[type="submit"]').click()
|
||||
}
|
||||
if (this.dataset.submitOnUpload) {
|
||||
this.closest('form').querySelector('button[type="submit"]').click()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -20,7 +20,7 @@ export default class extends HTMLElement {
|
||||
fetch = (text, resolve) => {
|
||||
const queryParams = new URLSearchParams({ q: text })
|
||||
|
||||
fetch('/api/template_folders_autocomplete?' + queryParams).then(async (resp) => {
|
||||
fetch('/template_folders_autocomplete?' + queryParams).then(async (resp) => {
|
||||
const items = await resp.json()
|
||||
|
||||
resolve(items)
|
||||
|
||||
@@ -7,6 +7,7 @@ export default class extends HTMLElement {
|
||||
preventSubmit: 1,
|
||||
minLength: 1,
|
||||
showOnFocus: true,
|
||||
debounceWaitMs: 200,
|
||||
onSelect: this.onSelect,
|
||||
render: this.render,
|
||||
fetch: this.fetch
|
||||
@@ -27,6 +28,8 @@ export default class extends HTMLElement {
|
||||
|
||||
if (textarea && item[field]) {
|
||||
textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ')
|
||||
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -37,7 +40,9 @@ export default class extends HTMLElement {
|
||||
if (q) {
|
||||
const queryParams = new URLSearchParams({ q, field: this.dataset.field })
|
||||
|
||||
fetch('/api/submitters_autocomplete?' + queryParams).then(async (resp) => {
|
||||
this.currentFetch ||= fetch('/submitters_autocomplete?' + queryParams)
|
||||
|
||||
this.currentFetch.then(async (resp) => {
|
||||
const items = await resp.json()
|
||||
|
||||
if (q.length < 3) {
|
||||
@@ -47,6 +52,8 @@ export default class extends HTMLElement {
|
||||
}
|
||||
}).catch(() => {
|
||||
resolve([])
|
||||
}).finally(() => {
|
||||
this.currentFetch = null
|
||||
})
|
||||
} else {
|
||||
resolve([])
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.button.addEventListener('click', () => {
|
||||
const expirationDate = new Date()
|
||||
|
||||
expirationDate.setFullYear(expirationDate.getFullYear() + 10)
|
||||
|
||||
const expires = expirationDate.toUTCString()
|
||||
|
||||
document.cookie = this.dataset.key + '=' + this.dataset.value + '; expires=' + expires + '; path=/'
|
||||
})
|
||||
}
|
||||
|
||||
get button () {
|
||||
return this.querySelector('button')
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,15 @@ window.customElements.define('submission-form', class extends HTMLElement {
|
||||
this.app = createApp(Form, {
|
||||
submitter: JSON.parse(this.dataset.submitter),
|
||||
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: this.dataset.withConfetti !== 'false',
|
||||
withTypedSignature: this.dataset.withTypedSignature !== 'false',
|
||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||
values: reactive(JSON.parse(this.dataset.values)),
|
||||
completedButton: JSON.parse(this.dataset.completedButton),
|
||||
completedButton: JSON.parse(this.dataset.completedButton || '{}'),
|
||||
completedMessage: JSON.parse(this.dataset.completedMessage || '{}'),
|
||||
completedRedirectUrl: this.dataset.completedRedirectUrl,
|
||||
attachments: reactive(JSON.parse(this.dataset.attachments)),
|
||||
fields: JSON.parse(this.dataset.fields)
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
ref="textContainer"
|
||||
dir="auto"
|
||||
class="flex items-center px-0.5 w-full"
|
||||
:class="alignClasses[field.preferences?.align]"
|
||||
>
|
||||
<span v-if="Array.isArray(modelValue)">
|
||||
{{ modelValue.join(', ') }}
|
||||
@@ -167,13 +168,14 @@
|
||||
<span
|
||||
v-else
|
||||
class="whitespace-pre-wrap"
|
||||
:class="{ 'w-full': field.preferences?.align }"
|
||||
>{{ modelValue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp } from '@tabler/icons-vue'
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1 } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FieldArea',
|
||||
@@ -240,6 +242,7 @@ export default {
|
||||
signature: this.t('signature'),
|
||||
initials: this.t('initials'),
|
||||
date: this.t('date'),
|
||||
number: this.t('number'),
|
||||
image: this.t('image'),
|
||||
file: this.t('file'),
|
||||
select: this.t('select'),
|
||||
@@ -252,6 +255,13 @@ export default {
|
||||
phone: this.t('phone')
|
||||
}
|
||||
},
|
||||
alignClasses () {
|
||||
return {
|
||||
center: 'text-center',
|
||||
left: 'text-left',
|
||||
right: 'text-right'
|
||||
}
|
||||
},
|
||||
option () {
|
||||
return this.field.options.find((o) => o.uuid === this.area.option_uuid)
|
||||
},
|
||||
@@ -260,6 +270,7 @@ export default {
|
||||
text: IconTextSize,
|
||||
signature: IconWritingSign,
|
||||
date: IconCalendarEvent,
|
||||
number: IconSquareNumber1,
|
||||
image: IconPhoto,
|
||||
initials: IconLetterCaseUpper,
|
||||
file: IconPaperclip,
|
||||
@@ -307,7 +318,7 @@ export default {
|
||||
formattedDate () {
|
||||
if (this.field.type === 'date' && this.modelValue) {
|
||||
return this.formatDate(
|
||||
new Date(this.modelValue),
|
||||
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
|
||||
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
|
||||
)
|
||||
} else {
|
||||
@@ -336,17 +347,19 @@ export default {
|
||||
},
|
||||
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
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.modelValue}`.length)) {
|
||||
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue}`.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
|
||||
})
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer) {
|
||||
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue}`.length : 0
|
||||
}
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
optionValue (option) {
|
||||
|
||||
@@ -41,10 +41,16 @@
|
||||
:name="`values[${field.uuid}][]`"
|
||||
>
|
||||
</template>
|
||||
<div
|
||||
v-if="field.description && !modelValue.length"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<FileDropzone
|
||||
:message="`${t('upload')} ${field.name || t('files')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
:submitter-slug="submitterSlug"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:multiple="true"
|
||||
@upload="onUpload"
|
||||
/>
|
||||
@@ -53,12 +59,14 @@
|
||||
|
||||
<script>
|
||||
import FileDropzone from './dropzone'
|
||||
import MarkdownContent from './markdown_content'
|
||||
import { IconPaperclip, IconTrashX } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'AttachmentStep',
|
||||
components: {
|
||||
FileDropzone,
|
||||
MarkdownContent,
|
||||
IconPaperclip,
|
||||
IconTrashX
|
||||
},
|
||||
@@ -77,11 +85,6 @@ export default {
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
isDirectUpload: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
modelValue: {
|
||||
type: Array,
|
||||
required: false,
|
||||
|
||||
@@ -3,20 +3,29 @@
|
||||
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">
|
||||
<div class="font-medium text-2xl flex items-center space-x-1.5 mx-auto">
|
||||
<IconCircleCheck
|
||||
class="inline text-green-600"
|
||||
:width="30"
|
||||
:height="30"
|
||||
/>
|
||||
<span>
|
||||
{{ t('form_has_been_completed') }}
|
||||
{{ completedMessage.title || t('form_has_been_completed') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="completedMessage.body"
|
||||
class="mt-2"
|
||||
>
|
||||
<MarkdownContent
|
||||
:string="completedMessage.body"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-3 mt-5">
|
||||
<a
|
||||
v-if="completedButton.url"
|
||||
:href="completedButton.url"
|
||||
:href="sanitizeHref(completedButton.url)"
|
||||
rel="noopener noreferrer nofollow"
|
||||
class="white-button flex items-center w-full"
|
||||
>
|
||||
<span>
|
||||
@@ -91,10 +100,12 @@
|
||||
|
||||
<script>
|
||||
import { IconCircleCheck, IconBrandGithub, IconMail, IconDownload, IconInnerShadowTop, IconLogin } from '@tabler/icons-vue'
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
export default {
|
||||
name: 'FormCompleted',
|
||||
components: {
|
||||
MarkdownContent,
|
||||
IconCircleCheck,
|
||||
IconInnerShadowTop,
|
||||
IconBrandGithub,
|
||||
@@ -142,6 +153,11 @@ export default {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
completedMessage: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
@@ -211,9 +227,14 @@ export default {
|
||||
fileRequests.reduce(
|
||||
(prevPromise, request) => prevPromise.then(() => request()),
|
||||
Promise.resolve()
|
||||
)
|
||||
|
||||
this.isDownloading = false
|
||||
).finally(() => {
|
||||
this.isDownloading = false
|
||||
})
|
||||
},
|
||||
sanitizeHref (href) {
|
||||
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
|
||||
return href.replace(/javascript:/g, '')
|
||||
}
|
||||
},
|
||||
downloadSafariIos (urls) {
|
||||
const fileRequests = urls.map((url) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
function cropCanvasAndExportToPNG (canvas) {
|
||||
function cropCanvasAndExportToPNG (canvas, { errorOnTooSmall } = { errorOnTooSmall: false }) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const width = canvas.width
|
||||
@@ -33,6 +33,10 @@ function cropCanvasAndExportToPNG (canvas) {
|
||||
croppedCanvas.height = croppedHeight
|
||||
const croppedCtx = croppedCanvas.getContext('2d')
|
||||
|
||||
if (errorOnTooSmall && (croppedWidth < 20 || croppedHeight < 20)) {
|
||||
return Promise.reject(new Error('Image too small'))
|
||||
}
|
||||
|
||||
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
<template>
|
||||
<div dir="auto">
|
||||
<div class="flex justify-between items-center w-full mb-2">
|
||||
<div
|
||||
class="flex justify-between items-center w-full"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
>
|
||||
<label
|
||||
:for="field.uuid"
|
||||
class="label text-2xl"
|
||||
>{{ field.name && showFieldNames ? field.name : t('date') }}
|
||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||
>
|
||||
<MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ field.name && showFieldNames ? field.name : t('date') }}
|
||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||
</template>
|
||||
</label>
|
||||
<button
|
||||
class="btn btn-outline btn-sm !normal-case font-normal"
|
||||
@click.prevent="setCurrentDate"
|
||||
@click.prevent="[setCurrentDate(), $emit('focus')]"
|
||||
>
|
||||
<IconCalendarCheck :width="16" />
|
||||
{{ t('set_today') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="field.description"
|
||||
class="mb-3 px-1"
|
||||
dir="auto"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<AppearsOn :field="field" />
|
||||
<div class="text-center">
|
||||
<input
|
||||
@@ -33,11 +50,13 @@
|
||||
<script>
|
||||
import { IconCalendarCheck } from '@tabler/icons-vue'
|
||||
import AppearsOn from './appears_on'
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
export default {
|
||||
name: 'DateStep',
|
||||
components: {
|
||||
IconCalendarCheck,
|
||||
MarkdownContent,
|
||||
AppearsOn
|
||||
},
|
||||
inject: ['t'],
|
||||
|
||||
@@ -70,11 +70,6 @@ export default {
|
||||
required: false,
|
||||
default: '*/*'
|
||||
},
|
||||
isDirectUpload: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -92,11 +87,6 @@ export default {
|
||||
return 'el' + Math.random().toString(32).split('.')[1]
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.isDirectUpload) {
|
||||
import('@rails/activestorage')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onDropFiles (e) {
|
||||
this.uploadFiles(e.dataTransfer.files)
|
||||
@@ -113,72 +103,25 @@ export default {
|
||||
async uploadFiles (files) {
|
||||
this.isLoading = true
|
||||
|
||||
if (this.isDirectUpload) {
|
||||
const { DirectUpload } = await import('@rails/activestorage')
|
||||
return await Promise.all(
|
||||
Array.from(files).map((file) => {
|
||||
const formData = new FormData()
|
||||
|
||||
const blobs = await Promise.all(
|
||||
Array.from(files).map(async (file) => {
|
||||
const upload = new DirectUpload(
|
||||
file,
|
||||
'/direct_uploads',
|
||||
this.$refs.input
|
||||
)
|
||||
formData.append('file', file)
|
||||
formData.append('submitter_slug', this.submitterSlug)
|
||||
formData.append('name', 'attachments')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
upload.create((error, blob) => {
|
||||
if (error) {
|
||||
console.error(error)
|
||||
|
||||
return reject(error)
|
||||
} else {
|
||||
return resolve(blob)
|
||||
}
|
||||
})
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(resp => resp.json()).then((data) => {
|
||||
return data
|
||||
})
|
||||
)
|
||||
|
||||
return await Promise.all(
|
||||
blobs.map((blob) => {
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: 'attachments',
|
||||
blob_signed_id: blob.signed_id,
|
||||
submitter_slug: this.submitterSlug
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then(resp => resp.json()).then((data) => {
|
||||
return data
|
||||
})
|
||||
})).then((result) => {
|
||||
this.$emit('upload', result)
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
} else {
|
||||
return await Promise.all(
|
||||
Array.from(files).map((file) => {
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('file', file)
|
||||
formData.append('submitter_slug', this.submitterSlug)
|
||||
formData.append('name', 'attachments')
|
||||
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(resp => resp.json()).then((data) => {
|
||||
return data
|
||||
})
|
||||
})).then((result) => {
|
||||
this.$emit('upload', result)
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
}
|
||||
})).then((result) => {
|
||||
this.$emit('upload', result)
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,24 @@
|
||||
:current-step="currentStepFields"
|
||||
@focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']"
|
||||
/>
|
||||
<FormulaFieldAreas
|
||||
v-if="formulaFields.length"
|
||||
:fields="formulaFields"
|
||||
:values="values"
|
||||
/>
|
||||
<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"
|
||||
@click.prevent="[isFormVisible = true, scrollIntoField(currentField)]"
|
||||
>
|
||||
{{ t('submit_form') }}
|
||||
<template v-if="['initials', 'signature'].includes(currentField.type)">
|
||||
<IconWritingSign stroke-width="1.5" />
|
||||
{{ t('sign_now') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('submit_form') }}
|
||||
</template>
|
||||
<IconArrowsDiagonal
|
||||
class="absolute right-0 mr-4"
|
||||
:width="20"
|
||||
@@ -32,7 +43,7 @@
|
||||
v-if="!isCompleted"
|
||||
id="minimize_form_button"
|
||||
class="absolute right-0 mr-2 mt-2 top-0 hidden md:block"
|
||||
title="Minimize"
|
||||
:title="t('minimize')"
|
||||
@click.prevent="isFormVisible = false"
|
||||
>
|
||||
<IconArrowsDiagonalMinimize2
|
||||
@@ -40,13 +51,16 @@
|
||||
:height="20"
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<div
|
||||
:class="{ 'md:px-4': isBreakpointMd }"
|
||||
>
|
||||
<form
|
||||
v-if="!isCompleted"
|
||||
ref="form"
|
||||
:action="submitPath"
|
||||
method="post"
|
||||
:class="{ 'md:mx-16': isBreakpointMd }"
|
||||
class="mx-auto"
|
||||
:style="{ maxWidth: isBreakpointMd ? '582px' : '' }"
|
||||
@submit.prevent="submitStep"
|
||||
>
|
||||
<input
|
||||
@@ -69,6 +83,14 @@
|
||||
@focus="scrollIntoField(currentField)"
|
||||
/>
|
||||
</div>
|
||||
<NumberStep
|
||||
v-else-if="currentField.type === 'number'"
|
||||
:key="currentField.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
:show-field-names="showFieldNames"
|
||||
:field="currentField"
|
||||
@focus="scrollIntoField(currentField)"
|
||||
/>
|
||||
<DateStep
|
||||
v-else-if="currentField.type === 'date'"
|
||||
:key="currentField.uuid"
|
||||
@@ -79,17 +101,32 @@
|
||||
/>
|
||||
<div v-else-if="currentField.type === 'select'">
|
||||
<label
|
||||
v-if="showFieldNames && currentField.name"
|
||||
v-if="showFieldNames && (currentField.name || currentField.title)"
|
||||
:for="currentField.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl mb-2"
|
||||
>{{ currentField.name }}
|
||||
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
||||
class="label text-2xl"
|
||||
:class="{ 'mb-2': !currentField.description }"
|
||||
>
|
||||
<MarkdownContent
|
||||
v-if="currentField.title"
|
||||
:string="currentField.title"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ currentField.name }}
|
||||
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
||||
</template>
|
||||
</label>
|
||||
<div
|
||||
v-else
|
||||
class="py-1"
|
||||
/>
|
||||
<div
|
||||
v-if="currentField.description"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="currentField.description" />
|
||||
</div>
|
||||
<AppearsOn :field="currentField" />
|
||||
<select
|
||||
:id="currentField.uuid"
|
||||
@@ -118,13 +155,28 @@
|
||||
</div>
|
||||
<div v-else-if="currentField.type === 'radio'">
|
||||
<label
|
||||
v-if="showFieldNames && currentField.name"
|
||||
v-if="showFieldNames && (currentField.name || currentField.title)"
|
||||
:for="currentField.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl mb-2"
|
||||
>{{ currentField.name }}
|
||||
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
||||
class="label text-2xl"
|
||||
:class="{ 'mb-2': !currentField.description }"
|
||||
>
|
||||
<MarkdownContent
|
||||
v-if="currentField.title"
|
||||
:string="currentField.title"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ currentField.name }}
|
||||
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
||||
</template>
|
||||
</label>
|
||||
<div
|
||||
v-if="currentField.description"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="currentField.description" />
|
||||
</div>
|
||||
<div class="flex w-full max-h-44 overflow-y-auto">
|
||||
<div
|
||||
v-if="!showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4)"
|
||||
@@ -156,6 +208,7 @@
|
||||
:name="`values[${currentField.uuid}]`"
|
||||
:value="option.value || `${t('option')} ${index + 1}`"
|
||||
:required="currentField.required"
|
||||
@click="scrollIntoField(currentField)"
|
||||
>
|
||||
<span class="text-xl">
|
||||
{{ option.value || `${t('option')} ${index + 1}` }}
|
||||
@@ -175,66 +228,77 @@
|
||||
/>
|
||||
<div
|
||||
v-else-if="currentField.type === 'checkbox'"
|
||||
class="flex w-full max-h-44 overflow-y-auto"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="cast_boolean"
|
||||
value="true"
|
||||
>
|
||||
<div
|
||||
class="space-y-3.5 mx-auto"
|
||||
v-if="currentField.description"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<template v-if="isAnonymousChecboxes || !showFieldNames">
|
||||
<span class="text-xl">
|
||||
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
|
||||
</span>
|
||||
<input
|
||||
v-for="field in currentStepFields"
|
||||
:key="field.uuid"
|
||||
type="hidden"
|
||||
:name="`values[${field.uuid}]`"
|
||||
:value="!!values[field.uuid]"
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(field, index) in currentStepFields"
|
||||
:key="field.uuid"
|
||||
>
|
||||
<label
|
||||
:for="field.uuid"
|
||||
class="flex items-center space-x-3"
|
||||
<MarkdownContent :string="currentField.description" />
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full max-h-44 overflow-y-auto"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="cast_boolean"
|
||||
value="true"
|
||||
>
|
||||
<div
|
||||
class="space-y-3.5 mx-auto"
|
||||
>
|
||||
<template v-if="isAnonymousChecboxes || !showFieldNames">
|
||||
<span class="text-xl">
|
||||
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
|
||||
</span>
|
||||
<input
|
||||
v-for="field in currentStepFields"
|
||||
:key="field.uuid"
|
||||
type="hidden"
|
||||
:name="`values[${field.uuid}]`"
|
||||
:value="!!values[field.uuid]"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
:name="`values[${field.uuid}]`"
|
||||
:value="!!values[field.uuid]"
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(field, index) in currentStepFields"
|
||||
:key="field.uuid"
|
||||
>
|
||||
<label
|
||||
:for="field.uuid"
|
||||
class="flex items-center space-x-3"
|
||||
>
|
||||
<input
|
||||
:id="field.uuid"
|
||||
type="checkbox"
|
||||
class="base-checkbox !h-7 !w-7"
|
||||
:oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`"
|
||||
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
|
||||
:required="field.required"
|
||||
:checked="!!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"
|
||||
>
|
||||
{{ field.name || field.type + ' ' + (index + 1) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<input
|
||||
type="hidden"
|
||||
:name="`values[${field.uuid}]`"
|
||||
:value="!!values[field.uuid]"
|
||||
>
|
||||
<input
|
||||
:id="field.uuid"
|
||||
type="checkbox"
|
||||
class="base-checkbox !h-7 !w-7"
|
||||
:oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`"
|
||||
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
|
||||
:required="field.required"
|
||||
:checked="!!values[field.uuid]"
|
||||
@click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
|
||||
>
|
||||
<span
|
||||
v-if="field.title"
|
||||
class="text-xl"
|
||||
>
|
||||
<MarkdownContent :string="field.title" />
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-xl"
|
||||
>
|
||||
{{ field.name || field.type + ' ' + (index + 1) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ImageStep
|
||||
@@ -242,7 +306,6 @@
|
||||
:key="currentField.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
:field="currentField"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:submitter-slug="submitterSlug"
|
||||
:show-field-names="showFieldNames"
|
||||
@@ -254,8 +317,7 @@
|
||||
:key="currentField.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
:field="currentField"
|
||||
:previous-value="previousSignatureValue"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:previous-value="previousSignatureValueFor(currentField)"
|
||||
:with-typed-signature="withTypedSignature"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:submitter-slug="submitterSlug"
|
||||
@@ -271,7 +333,6 @@
|
||||
v-model="values[currentField.uuid]"
|
||||
:field="currentField"
|
||||
:previous-value="previousInitialsValue"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:show-field-names="showFieldNames"
|
||||
:submitter-slug="submitterSlug"
|
||||
@@ -284,7 +345,6 @@
|
||||
v-else-if="currentField.type === 'file'"
|
||||
:key="currentField.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:field="currentField"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:submitter-slug="submitterSlug"
|
||||
@@ -354,6 +414,7 @@
|
||||
:is-demo="isDemo"
|
||||
:attribution="attribution"
|
||||
:completed-button="completedRedirectUrl ? {} : completedButton"
|
||||
:completed-message="completedRedirectUrl ? {} : completedMessage"
|
||||
:with-send-copy-button="withSendCopyButton && !completedRedirectUrl"
|
||||
:with-download-button="withDownloadButton && !completedRedirectUrl"
|
||||
:with-confetti="withConfetti"
|
||||
@@ -385,6 +446,7 @@
|
||||
|
||||
<script>
|
||||
import FieldAreas from './areas'
|
||||
import FormulaFieldAreas from './formula_areas'
|
||||
import ImageStep from './image_step'
|
||||
import SignatureStep from './signature_step'
|
||||
import InitialsStep from './initials_step'
|
||||
@@ -393,12 +455,32 @@ import MultiSelectStep from './multi_select_step'
|
||||
import PhoneStep from './phone_step'
|
||||
import PaymentStep from './payment_step'
|
||||
import TextStep from './text_step'
|
||||
import NumberStep from './number_step'
|
||||
import DateStep from './date_step'
|
||||
import MarkdownContent from './markdown_content'
|
||||
import FormCompleted from './completed'
|
||||
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||
import { IconInnerShadowTop, IconArrowsDiagonal, IconWritingSign, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||
import AppearsOn from './appears_on'
|
||||
import i18n from './i18n'
|
||||
|
||||
const isEmpty = (obj) => {
|
||||
if (obj == null) return true
|
||||
|
||||
if (Array.isArray(obj) || typeof obj === 'string') {
|
||||
return obj.length === 0
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
return Object.keys(obj).length === 0
|
||||
}
|
||||
|
||||
if (obj === false) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'SubmissionForm',
|
||||
components: {
|
||||
@@ -406,6 +488,7 @@ export default {
|
||||
ImageStep,
|
||||
SignatureStep,
|
||||
AppearsOn,
|
||||
IconWritingSign,
|
||||
AttachmentStep,
|
||||
InitialsStep,
|
||||
MultiSelectStep,
|
||||
@@ -413,7 +496,10 @@ export default {
|
||||
DateStep,
|
||||
IconArrowsDiagonal,
|
||||
TextStep,
|
||||
NumberStep,
|
||||
FormulaFieldAreas,
|
||||
PhoneStep,
|
||||
MarkdownContent,
|
||||
PaymentStep,
|
||||
IconArrowsDiagonalMinimize2,
|
||||
FormCompleted
|
||||
@@ -448,11 +534,21 @@ export default {
|
||||
return () => {}
|
||||
}
|
||||
},
|
||||
expand: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withConfetti: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
autoscrollFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
showFieldNames: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -483,11 +579,6 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
isDirectUpload: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
allowToSkip: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -518,6 +609,16 @@ export default {
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
authenticityToken: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
i18n: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
withSendCopyButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -537,12 +638,17 @@ export default {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
completedMessage: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isCompleted: false,
|
||||
isFormVisible: true,
|
||||
isFormVisible: this.expand !== false,
|
||||
showFillAllRequiredFields: false,
|
||||
currentStep: 0,
|
||||
isSubmitting: false,
|
||||
@@ -560,16 +666,15 @@ export default {
|
||||
queryParams () {
|
||||
return new URLSearchParams(window.location.search)
|
||||
},
|
||||
authenticityToken () {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content
|
||||
},
|
||||
submitterSlug () {
|
||||
return this.submitter.slug
|
||||
},
|
||||
previousSignatureValue () {
|
||||
const signatureField = [...this.fields].reverse().find((field) => field.type === 'signature' && !!this.values[field.uuid])
|
||||
fieldsUuidIndex () {
|
||||
return this.fields.reduce((acc, f) => {
|
||||
acc[f.uuid] = f
|
||||
|
||||
return this.values[signatureField?.uuid]
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
previousInitialsValue () {
|
||||
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
|
||||
@@ -577,7 +682,7 @@ export default {
|
||||
return this.values[initialsField?.uuid]
|
||||
},
|
||||
isAnonymousChecboxes () {
|
||||
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4
|
||||
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4
|
||||
},
|
||||
isButtonDisabled () {
|
||||
if (this.recalculateButtonDisabledKey) {
|
||||
@@ -596,15 +701,20 @@ export default {
|
||||
return this.fields.filter((f) => !f.readonly).reduce((acc, f) => {
|
||||
const prevStep = acc[acc.length - 1]
|
||||
|
||||
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox') {
|
||||
prevStep.push(f)
|
||||
} else {
|
||||
acc.push([f])
|
||||
if (this.checkFieldConditions(f)) {
|
||||
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox' && !f.description) {
|
||||
prevStep.push(f)
|
||||
} else {
|
||||
acc.push([f])
|
||||
}
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
},
|
||||
formulaFields () {
|
||||
return this.fields.filter((f) => f.preferences?.formula)
|
||||
},
|
||||
attachmentsIndex () {
|
||||
return this.attachments.reduce((acc, a) => {
|
||||
acc[a.uuid] = a
|
||||
@@ -616,12 +726,17 @@ export default {
|
||||
return `/s/${this.submitterSlug}`
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
expand (value) {
|
||||
this.isFormVisible = value
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.submittedValues = JSON.parse(JSON.stringify(this.values))
|
||||
|
||||
this.fields.forEach((field) => {
|
||||
if (field.default_value && !field.readonly) {
|
||||
this.values[field.uuid] = field.default_value
|
||||
this.values[field.uuid] ||= field.default_value
|
||||
}
|
||||
})
|
||||
|
||||
@@ -648,6 +763,10 @@ export default {
|
||||
this.currentStep = Math.min(...indexesList)
|
||||
}
|
||||
|
||||
if (document.body?.clientWidth >= 768 && this.expand !== true && ['signature', 'initials', 'file', 'image'].includes(this.currentField?.type)) {
|
||||
this.isFormVisible = false
|
||||
}
|
||||
|
||||
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
||||
this.$nextTick(() => {
|
||||
const root = this.$root.$el.parentNode.getRootNode()
|
||||
@@ -675,7 +794,41 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
t (key) {
|
||||
return i18n[this.language?.toLowerCase()]?.[key] || i18n[this.browserLanguage]?.[key] || i18n.en[key] || key
|
||||
return this.i18n[key] || i18n[this.language?.toLowerCase()]?.[key] || i18n[this.browserLanguage]?.[key] || i18n.en[key] || key
|
||||
},
|
||||
checkFieldConditions (field) {
|
||||
if (field.conditions?.length) {
|
||||
return field.conditions.reduce((acc, c) => {
|
||||
if (['empty', 'unchecked'].includes(c.action)) {
|
||||
return acc && isEmpty(this.values[c.field_uuid])
|
||||
} else if (['not_empty', 'checked'].includes(c.action)) {
|
||||
return acc && !isEmpty(this.values[c.field_uuid])
|
||||
} else if (['equal', 'contains'].includes(c.action)) {
|
||||
const field = this.fieldsUuidIndex[c.field_uuid]
|
||||
const option = field.options.find((o) => o.uuid === c.value)
|
||||
const values = [this.values[c.field_uuid]].flat()
|
||||
|
||||
return acc && values.includes(this.optionValue(option, field.options.indexOf(option)))
|
||||
} else if (['not_equal', 'does_not_contain'].includes(c.action)) {
|
||||
const field = this.fieldsUuidIndex[c.field_uuid]
|
||||
const option = field.options.find((o) => o.uuid === c.value)
|
||||
const values = [this.values[c.field_uuid]].flat()
|
||||
|
||||
return acc && !values.includes(this.optionValue(option, field.options.indexOf(option)))
|
||||
} else {
|
||||
return acc
|
||||
}
|
||||
}, true)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
},
|
||||
optionValue (option, index) {
|
||||
if (option.value) {
|
||||
return option.value
|
||||
} else {
|
||||
return `${this.t('option')} ${index + 1}`
|
||||
}
|
||||
},
|
||||
maybeTrackEmailClick () {
|
||||
const { queryParams } = this
|
||||
@@ -736,6 +889,13 @@ export default {
|
||||
})
|
||||
})
|
||||
},
|
||||
previousSignatureValueFor (field) {
|
||||
const signatureField = [...this.fields].reverse().find((f) =>
|
||||
f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid]
|
||||
)
|
||||
|
||||
return this.values[signatureField?.uuid]
|
||||
},
|
||||
goToStep (step, scrollToArea = false, clickUpload = false) {
|
||||
this.currentStep = this.stepFields.indexOf(step)
|
||||
this.showFillAllRequiredFields = false
|
||||
@@ -745,9 +905,9 @@ export default {
|
||||
|
||||
if (scrollToArea) {
|
||||
this.scrollIntoField(step[0])
|
||||
}
|
||||
|
||||
this.$refs.form.querySelector('input[type="date"], input[type="text"], select')?.focus()
|
||||
this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], select')?.focus()
|
||||
}
|
||||
|
||||
if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) {
|
||||
this.$refs.form.querySelector('input[type="file"]')?.click()
|
||||
@@ -755,7 +915,7 @@ export default {
|
||||
})
|
||||
},
|
||||
saveStep (formData) {
|
||||
const currentFieldUuid = this.currentField.uuid
|
||||
const currentFieldUuids = this.currentStepFields.map((f) => f.uuid)
|
||||
|
||||
if (this.isCompleted) {
|
||||
return Promise.resolve({})
|
||||
@@ -765,7 +925,9 @@ export default {
|
||||
body: formData || new FormData(this.$refs.form)
|
||||
}).then((response) => {
|
||||
if (response.status === 200) {
|
||||
this.submittedValues[currentFieldUuid] = this.values[currentFieldUuid]
|
||||
currentFieldUuids.forEach((fieldUuid) => {
|
||||
this.submittedValues[fieldUuid] = this.values[fieldUuid]
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
@@ -787,7 +949,13 @@ export default {
|
||||
|
||||
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] === true ? false : !this.submittedValues[fields[0].uuid]?.length)
|
||||
if (index >= this.currentStep) {
|
||||
return false
|
||||
}
|
||||
|
||||
return fields.some((f) => {
|
||||
return f.required && (f.type === 'phone' || !this.allowToSkip) && isEmpty(this.submittedValues[f.uuid])
|
||||
})
|
||||
})
|
||||
|
||||
const formData = new FormData(this.$refs.form)
|
||||
@@ -813,7 +981,7 @@ export default {
|
||||
const nextStep = (isLastStep && emptyRequiredField) || this.stepFields[this.currentStep + 1]
|
||||
|
||||
if (nextStep) {
|
||||
this.goToStep(nextStep, true)
|
||||
this.goToStep(nextStep, this.autoscrollFields)
|
||||
|
||||
if (emptyRequiredField === nextStep) {
|
||||
this.showFillAllRequiredFields = true
|
||||
@@ -837,7 +1005,11 @@ export default {
|
||||
this.isSubmitting = false
|
||||
})
|
||||
}).catch(error => {
|
||||
console.log(error)
|
||||
if (error?.message === 'Image too small') {
|
||||
alert('Signature is too small - please redraw.')
|
||||
} else {
|
||||
console.log(error)
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isSubmitting = false
|
||||
})
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<template
|
||||
v-for="(field, fieldIndex) in fields"
|
||||
:key="field.uuid"
|
||||
>
|
||||
<template
|
||||
v-for="(area, areaIndex) in field.areas"
|
||||
:key="areaIndex"
|
||||
>
|
||||
<Teleport
|
||||
v-if="findPageElementForArea(area)"
|
||||
:to="findPageElementForArea(area)"
|
||||
>
|
||||
<FieldArea
|
||||
v-if="isMathLoaded"
|
||||
:model-value="calculateFormula(field)"
|
||||
:field="field"
|
||||
:area="area"
|
||||
:submittable="false"
|
||||
:field-index="fieldIndex"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FieldArea from './area'
|
||||
|
||||
export default {
|
||||
name: 'FormulaFieldAreas',
|
||||
components: {
|
||||
FieldArea
|
||||
},
|
||||
props: {
|
||||
fields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isMathLoaded: false
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
const {
|
||||
create,
|
||||
evaluateDependencies,
|
||||
addDependencies,
|
||||
subtractDependencies,
|
||||
divideDependencies,
|
||||
multiplyDependencies,
|
||||
powDependencies,
|
||||
roundDependencies,
|
||||
absDependencies,
|
||||
sinDependencies,
|
||||
tanDependencies,
|
||||
cosDependencies
|
||||
} = await import('mathjs')
|
||||
|
||||
this.math = create({
|
||||
evaluateDependencies,
|
||||
addDependencies,
|
||||
subtractDependencies,
|
||||
divideDependencies,
|
||||
multiplyDependencies,
|
||||
powDependencies,
|
||||
roundDependencies,
|
||||
absDependencies,
|
||||
sinDependencies,
|
||||
tanDependencies,
|
||||
cosDependencies
|
||||
})
|
||||
|
||||
this.isMathLoaded = true
|
||||
},
|
||||
methods: {
|
||||
findPageElementForArea (area) {
|
||||
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${area.attachment_uuid}-${area.page}`)
|
||||
},
|
||||
calculateFormula (field) {
|
||||
const transformedFormula = field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
return this.values[uuid] || 0.0
|
||||
})
|
||||
|
||||
return this.math.evaluate(transformedFormula.toLowerCase())
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -3,6 +3,7 @@ const en = {
|
||||
signature: 'Signature',
|
||||
initials: 'Initials',
|
||||
date: 'Date',
|
||||
number: 'Number',
|
||||
image: 'Image',
|
||||
take_photo: 'Take photo',
|
||||
file: 'File',
|
||||
@@ -12,16 +13,18 @@ const en = {
|
||||
radio: 'Radio',
|
||||
cells: 'Cells',
|
||||
stamp: 'Stamp',
|
||||
minimize: 'Minimize',
|
||||
payment: 'Payment',
|
||||
phone: 'Phone',
|
||||
submit_form: 'Submit Form',
|
||||
sign_now: 'Sign Now',
|
||||
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',
|
||||
complete_hightlighted_checkboxes_and_click: 'Complete highlighted checkboxes and click',
|
||||
submit: 'submit',
|
||||
next: 'next',
|
||||
click_to_upload: 'Click to upload',
|
||||
@@ -39,7 +42,7 @@ const en = {
|
||||
please_check_the_box_to_continue: 'Please check the box to continue',
|
||||
open_source_documents_software: 'open source documents software',
|
||||
verified_phone_number: 'Verify Phone Number',
|
||||
use_international_format: 'Use internatioanl format: +1xxx',
|
||||
use_international_format: 'Use international format: +1xxx',
|
||||
six_digits_code: '6-digit code',
|
||||
change_phone_number: 'Change phone number',
|
||||
sending: 'Sending...',
|
||||
@@ -62,10 +65,12 @@ const en = {
|
||||
}
|
||||
|
||||
const es = {
|
||||
minimize: 'Minimizar',
|
||||
text: 'Texto',
|
||||
signature: 'Firma',
|
||||
initials: 'Iniciales',
|
||||
date: 'Fecha',
|
||||
number: 'Número',
|
||||
image: 'Imagen',
|
||||
file: 'Archivo',
|
||||
select: 'Seleccionar',
|
||||
@@ -78,6 +83,7 @@ const es = {
|
||||
phone: 'Teléfono',
|
||||
take_photo: 'Tomar foto',
|
||||
submit_form: 'Enviar Formulario',
|
||||
sign_now: 'Firmar ahora',
|
||||
type_here_: 'Escribe aquí...',
|
||||
optional: 'opcional',
|
||||
appears_on: 'Aparece en',
|
||||
@@ -124,10 +130,12 @@ const es = {
|
||||
}
|
||||
|
||||
const it = {
|
||||
minimize: 'Minimizza',
|
||||
text: 'Testo',
|
||||
signature: 'Firma',
|
||||
initials: 'Iniziali',
|
||||
date: 'Data',
|
||||
number: 'Numero',
|
||||
image: 'Immagine',
|
||||
file: 'File',
|
||||
select: 'Seleziona',
|
||||
@@ -139,6 +147,7 @@ const it = {
|
||||
payment: 'Pagamento',
|
||||
phone: 'Telefono',
|
||||
submit_form: 'Invia Modulo',
|
||||
sign_now: 'Firma ora',
|
||||
type_here_: 'Digita qui...',
|
||||
optional: 'opzionale',
|
||||
appears_on: 'Compare su',
|
||||
@@ -186,10 +195,12 @@ const it = {
|
||||
}
|
||||
|
||||
const de = {
|
||||
minimize: 'Minimieren',
|
||||
text: 'Text',
|
||||
signature: 'Unterschrift',
|
||||
initials: 'Initialen',
|
||||
date: 'Datum',
|
||||
number: 'Nummer',
|
||||
image: 'Bild',
|
||||
file: 'Datei',
|
||||
select: 'Auswählen',
|
||||
@@ -201,6 +212,7 @@ const de = {
|
||||
payment: 'Zahlung',
|
||||
phone: 'Telefon',
|
||||
submit_form: 'Formular absenden',
|
||||
sign_now: 'Jetzt unterschreiben',
|
||||
type_here_: 'Hier eingeben...',
|
||||
optional: 'optional',
|
||||
appears_on: 'Erscheint auf',
|
||||
@@ -248,10 +260,12 @@ const de = {
|
||||
}
|
||||
|
||||
const fr = {
|
||||
minimize: 'Réduire',
|
||||
text: 'Texte',
|
||||
signature: 'Signature',
|
||||
initials: 'Initiales',
|
||||
date: 'Date',
|
||||
number: 'Numéro',
|
||||
image: 'Image',
|
||||
file: 'Fichier',
|
||||
select: 'Choisir',
|
||||
@@ -263,6 +277,7 @@ const fr = {
|
||||
payment: 'Paiement',
|
||||
phone: 'Téléphone',
|
||||
submit_form: 'Envoyer le Formulaire',
|
||||
sign_now: 'Signer maintenant',
|
||||
type_here_: 'Tapez ici...',
|
||||
optional: 'facultatif',
|
||||
appears_on: 'Apparaît sur',
|
||||
@@ -310,10 +325,12 @@ const fr = {
|
||||
}
|
||||
|
||||
const pl = {
|
||||
minimize: 'Zminimalizuj',
|
||||
text: 'Tekst',
|
||||
signature: 'Podpis',
|
||||
initials: 'Inicjały',
|
||||
date: 'Data',
|
||||
number: 'Numer',
|
||||
image: 'Obraz',
|
||||
file: 'Plik',
|
||||
select: 'Wybierz',
|
||||
@@ -325,6 +342,7 @@ const pl = {
|
||||
payment: 'Płatność',
|
||||
phone: 'Telefon',
|
||||
submit_form: 'Wyślij Formularz',
|
||||
sign_now: 'Podpisz teraz',
|
||||
type_here_: 'Wpisz tutaj...',
|
||||
optional: 'opcjonalny',
|
||||
appears_on: 'Pojawia się na',
|
||||
@@ -372,10 +390,12 @@ const pl = {
|
||||
}
|
||||
|
||||
const uk = {
|
||||
minimize: 'Зменшити',
|
||||
text: 'Текст',
|
||||
signature: 'Підпис',
|
||||
initials: 'Ініціали',
|
||||
date: 'Дата',
|
||||
number: 'Число',
|
||||
image: 'Зображення',
|
||||
file: 'Файл',
|
||||
select: 'Вибрати',
|
||||
@@ -387,6 +407,7 @@ const uk = {
|
||||
payment: 'Платіж',
|
||||
phone: 'Телефон',
|
||||
submit_form: 'Надіслати Форму',
|
||||
sign_now: 'Підписати зараз',
|
||||
type_here_: 'Введіть тут',
|
||||
optional: 'необов’язково',
|
||||
appears_on: "З'являється на",
|
||||
@@ -434,10 +455,12 @@ const uk = {
|
||||
}
|
||||
|
||||
const cs = {
|
||||
minimize: 'Minimalizovat',
|
||||
text: 'Text',
|
||||
signature: 'Podpis',
|
||||
initials: 'Iniciály',
|
||||
date: 'Datum',
|
||||
number: 'Číslo',
|
||||
image: 'Obrázek',
|
||||
file: 'Soubor',
|
||||
select: 'Vybrat',
|
||||
@@ -449,6 +472,7 @@ const cs = {
|
||||
payment: 'Platba',
|
||||
phone: 'Telefon',
|
||||
submit_form: 'Odeslat formulář',
|
||||
sign_now: 'Podepsat nyní',
|
||||
type_here_: 'Zadejte zde',
|
||||
optional: 'volitelné',
|
||||
appears_on: 'Zobrazuje se na',
|
||||
@@ -496,10 +520,12 @@ const cs = {
|
||||
}
|
||||
|
||||
const pt = {
|
||||
minimize: 'Minimizar',
|
||||
text: 'Texto',
|
||||
signature: 'Assinatura',
|
||||
initials: 'Iniciais',
|
||||
date: 'Data',
|
||||
number: 'Número',
|
||||
image: 'Imagem',
|
||||
file: 'Arquivo',
|
||||
select: 'Selecionar',
|
||||
@@ -511,6 +537,7 @@ const pt = {
|
||||
payment: 'Pagamento',
|
||||
phone: 'Telefone',
|
||||
submit_form: 'Enviar Formulário',
|
||||
sign_now: 'Assinar agora',
|
||||
type_here_: 'Digite aqui',
|
||||
optional: 'opcional',
|
||||
appears_on: 'Aparece em',
|
||||
@@ -558,10 +585,12 @@ const pt = {
|
||||
}
|
||||
|
||||
const he = {
|
||||
minimize: 'לקטן',
|
||||
text: 'טקסט',
|
||||
signature: 'חתימה',
|
||||
initials: 'ראשי תיקיות',
|
||||
initials: 'ראשי תיבות',
|
||||
date: 'תאריך',
|
||||
number: 'מספר',
|
||||
image: 'תמונה',
|
||||
file: 'קובץ',
|
||||
select: 'בחר',
|
||||
@@ -573,6 +602,7 @@ const he = {
|
||||
payment: 'תשלום',
|
||||
phone: 'טלפון',
|
||||
submit_form: 'שלח טופס',
|
||||
sign_now: 'חתום כעת',
|
||||
type_here_: 'הקלד כאן',
|
||||
optional: 'אופציונלי',
|
||||
option: 'אפשרות',
|
||||
@@ -621,10 +651,12 @@ const he = {
|
||||
}
|
||||
|
||||
const nl = {
|
||||
minimize: 'Minimaliseren',
|
||||
text: 'Tekst',
|
||||
signature: 'Handtekening',
|
||||
initials: 'Initialen',
|
||||
date: 'Datum',
|
||||
number: 'Nummer',
|
||||
image: 'Afbeelding',
|
||||
take_photo: 'Maak een foto',
|
||||
file: 'Bestand',
|
||||
@@ -637,6 +669,7 @@ const nl = {
|
||||
payment: 'Betaling',
|
||||
phone: 'Telefoon',
|
||||
submit_form: 'Formulier verzenden',
|
||||
sign_now: 'Nu ondertekenen',
|
||||
type_here_: 'Typ hier...',
|
||||
optional: 'Optioneel',
|
||||
option: 'Optie',
|
||||
@@ -683,6 +716,138 @@ const nl = {
|
||||
files: 'Bestanden'
|
||||
}
|
||||
|
||||
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl }
|
||||
const ar = {
|
||||
text: 'نص',
|
||||
signature: 'توقيع',
|
||||
initials: 'الاختصارات',
|
||||
date: 'تاريخ',
|
||||
number: 'رقم',
|
||||
image: 'صورة',
|
||||
take_photo: 'التقاط صورة',
|
||||
file: 'ملف',
|
||||
select: 'اختيار',
|
||||
checkbox: 'خانة اختيار',
|
||||
multiple: 'متعدد',
|
||||
radio: 'راديو',
|
||||
cells: 'خلايا',
|
||||
stamp: 'ختم',
|
||||
minimize: 'تصغير',
|
||||
payment: 'الدفع',
|
||||
phone: 'هاتف',
|
||||
submit_form: 'إرسال النموذج',
|
||||
sign_now: 'وقع الآن',
|
||||
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: 'تحميل',
|
||||
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: 'رمز مكون من 6 أرقام',
|
||||
change_phone_number: 'تغيير رقم الهاتف',
|
||||
sending: 'جارٍ الإرسال',
|
||||
resend_code: 'إعادة إرسال الرمز',
|
||||
verification_code_has_been_resent: 'تم إعادة إرسال رمز التحقق عبر الرسائل القصيرة',
|
||||
please_fill_all_required_fields: 'الرجاء ملء جميع الحقول المطلوبة',
|
||||
set_today: 'تعيين اليوم',
|
||||
toggle_multiline_text: 'تبديل النصوص متعددة الأسطر',
|
||||
draw_signature: 'ارسم التوقيع',
|
||||
type_initial: 'اكتب الاختصارات',
|
||||
draw: 'ارسم',
|
||||
type: 'اكتب',
|
||||
type_text: 'اكتب نصًا',
|
||||
email_has_been_sent: 'تم إرسال البريد الإلكتروني',
|
||||
processing: 'جارٍ المعالجة',
|
||||
pay_with_strip: 'الدفع بواسطة Stripe',
|
||||
reupload: 'إعادة التحميل',
|
||||
upload: 'تحميل',
|
||||
files: 'الملفات'
|
||||
}
|
||||
|
||||
const ko = {
|
||||
text: '텍스트',
|
||||
signature: '서명',
|
||||
initials: '이니셜',
|
||||
date: '날짜',
|
||||
number: '숫자',
|
||||
image: '이미지',
|
||||
take_photo: '사진 찍기',
|
||||
file: '파일',
|
||||
select: '선택',
|
||||
checkbox: '체크박스',
|
||||
multiple: '다중',
|
||||
radio: '라디오',
|
||||
cells: '셀',
|
||||
stamp: '스탬프',
|
||||
minimize: '최소화',
|
||||
payment: '결제',
|
||||
phone: '전화',
|
||||
submit_form: '양식 제출',
|
||||
sign_now: '지금 서명',
|
||||
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: '다운로드',
|
||||
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: '6자리 코드',
|
||||
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: '텍스트 입력',
|
||||
email_has_been_sent: '이메일이 전송되었습니다',
|
||||
processing: '처리 중',
|
||||
pay_with_strip: '스트라이프로 결제',
|
||||
reupload: '다시 업로드',
|
||||
upload: '업로드',
|
||||
files: '파일'
|
||||
}
|
||||
|
||||
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko }
|
||||
|
||||
export default i18n
|
||||
|
||||
@@ -24,13 +24,20 @@
|
||||
:name="`values[${field.uuid}]`"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-if="!modelValue"
|
||||
>
|
||||
<div
|
||||
v-if="field.description"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<FileDropzone
|
||||
v-if="!modelValue"
|
||||
:message="`${t('upload')} ${field.name || t('image')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
:submitter-slug="submitterSlug"
|
||||
:accept="'image/*'"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
@upload="onImageUpload"
|
||||
/>
|
||||
</div>
|
||||
@@ -39,12 +46,14 @@
|
||||
<script>
|
||||
import FileDropzone from './dropzone'
|
||||
import { IconReload } from '@tabler/icons-vue'
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
export default {
|
||||
name: 'ImageStep',
|
||||
components: {
|
||||
FileDropzone,
|
||||
IconReload
|
||||
IconReload,
|
||||
MarkdownContent
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
@@ -52,11 +61,6 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isDirectUpload: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
showFieldNames: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
<template>
|
||||
<div dir="auto">
|
||||
<div class="flex justify-between items-center w-full mb-2">
|
||||
<div
|
||||
class="flex justify-between items-center w-full"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
>
|
||||
<label
|
||||
class="label text-2xl"
|
||||
>{{ showFieldNames && field.name ? field.name : t('initials') }}</label>
|
||||
>
|
||||
<MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ showFieldNames && field.name ? field.name : t('initials') }}
|
||||
</template>
|
||||
</label>
|
||||
<div class="space-x-2 flex">
|
||||
<span
|
||||
v-if="isDrawInitials"
|
||||
class="tooltip"
|
||||
:data-tip="t('type_initials')"
|
||||
:data-tip="t('type_initial')"
|
||||
>
|
||||
<a
|
||||
id="type_text_button"
|
||||
@@ -58,7 +69,7 @@
|
||||
{{ t('clear') }}
|
||||
</a>
|
||||
<a
|
||||
title="Minimize"
|
||||
:title="t('minimize')"
|
||||
href="#"
|
||||
class="py-1.5 inline md:hidden"
|
||||
@click.prevent="$emit('minimize')"
|
||||
@@ -70,6 +81,13 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="field.description"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<AppearsOn :field="field" />
|
||||
<input
|
||||
:value="modelValue || computedPreviousValue"
|
||||
@@ -84,7 +102,7 @@
|
||||
<canvas
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
ref="canvas"
|
||||
class="bg-white border border-base-300 rounded-2xl"
|
||||
class="bg-white border border-base-300 rounded-2xl w-full"
|
||||
/>
|
||||
<input
|
||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
|
||||
@@ -105,6 +123,9 @@ import { cropCanvasAndExportToPNG } from './crop_canvas'
|
||||
import { IconReload, IconTextSize, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||
import SignaturePad from 'signature_pad'
|
||||
import AppearsOn from './appears_on'
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
const scale = 3
|
||||
|
||||
export default {
|
||||
name: 'InitialsStep',
|
||||
@@ -113,6 +134,7 @@ export default {
|
||||
IconReload,
|
||||
IconTextSize,
|
||||
IconSignature,
|
||||
MarkdownContent,
|
||||
IconArrowsDiagonalMinimize2
|
||||
},
|
||||
inject: ['baseUrl', 't'],
|
||||
@@ -130,11 +152,6 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
isDirectUpload: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
attachmentsIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
@@ -171,17 +188,13 @@ export default {
|
||||
async mounted () {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.canvas) {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 5
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
|
||||
this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 3) * scale
|
||||
|
||||
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||
}
|
||||
|
||||
this.$refs.textInput?.focus()
|
||||
})
|
||||
|
||||
if (this.isDirectUpload) {
|
||||
import('@rails/activestorage')
|
||||
}
|
||||
|
||||
if (this.$refs.canvas) {
|
||||
this.pad = new SignaturePad(this.$refs.canvas)
|
||||
|
||||
@@ -190,8 +203,26 @@ export default {
|
||||
|
||||
this.$emit('start')
|
||||
})
|
||||
|
||||
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
|
||||
this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 3) * scale
|
||||
|
||||
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||
|
||||
this.intersectionObserver?.disconnect()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.intersectionObserver.observe(this.$refs.canvas)
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.intersectionObserver?.disconnect()
|
||||
},
|
||||
methods: {
|
||||
remove () {
|
||||
this.$emit('update:model-value', '')
|
||||
@@ -209,7 +240,7 @@ export default {
|
||||
}
|
||||
},
|
||||
updateWrittenInitials (e) {
|
||||
this.isInitialsStarted = true
|
||||
this.isInitialsStarted = !!e.target.value
|
||||
|
||||
const canvas = this.$refs.canvas
|
||||
const context = canvas.getContext('2d')
|
||||
@@ -222,8 +253,8 @@ export default {
|
||||
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
|
||||
context.textAlign = 'center'
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11)
|
||||
context.clearRect(0, 0, canvas.width / scale, canvas.height / scale)
|
||||
context.fillText(e.target.value, canvas.width / 2 / scale, canvas.height / 2 / scale + 11)
|
||||
},
|
||||
toggleTextInput () {
|
||||
this.remove()
|
||||
@@ -251,45 +282,21 @@ export default {
|
||||
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
|
||||
const file = new File([blob], 'initials.png', { type: 'image/png' })
|
||||
|
||||
if (this.isDirectUpload) {
|
||||
const { DirectUpload } = await import('@rails/activestorage')
|
||||
const formData = new FormData()
|
||||
|
||||
new DirectUpload(
|
||||
file,
|
||||
'/direct_uploads'
|
||||
).create((_error, data) => {
|
||||
fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug,
|
||||
blob_signed_id: data.signed_id,
|
||||
name: 'attachments'
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then((resp) => resp.json()).then((attachment) => {
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
this.$emit('attached', attachment)
|
||||
formData.append('file', file)
|
||||
formData.append('submitter_slug', this.submitterSlug)
|
||||
formData.append('name', 'attachments')
|
||||
|
||||
return resolve(attachment)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
const formData = new FormData()
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then((resp) => resp.json()).then((attachment) => {
|
||||
this.$emit('attached', attachment)
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
|
||||
formData.append('file', file)
|
||||
formData.append('submitter_slug', this.submitterSlug)
|
||||
formData.append('name', 'attachments')
|
||||
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then((resp) => resp.json()).then((attachment) => {
|
||||
this.$emit('attached', attachment)
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
|
||||
return resolve(attachment)
|
||||
})
|
||||
}
|
||||
return resolve(attachment)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<span>
|
||||
<template
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
>
|
||||
<a
|
||||
v-if="item.startsWith('<a') && item.endsWith('</a>')"
|
||||
:href="sanitizeHref(extractAttr(item, 'href'))"
|
||||
rel="noopener noreferrer nofollow"
|
||||
:class="extractAttr(item, 'class') || 'link'"
|
||||
target="_blank"
|
||||
>
|
||||
{{ extractText(item) }}
|
||||
</a>
|
||||
<b
|
||||
v-else-if="item.startsWith('<b>') || item.startsWith('<strong>')"
|
||||
>
|
||||
{{ extractText(item) }}
|
||||
</b>
|
||||
<i
|
||||
v-else-if="item.startsWith('<i>') || item.startsWith('<em>')"
|
||||
>
|
||||
{{ extractText(item) }}
|
||||
</i>
|
||||
<br
|
||||
v-else-if="item === '<br>' || item === '\n'"
|
||||
>
|
||||
<template
|
||||
v-else
|
||||
>
|
||||
{{ item }}
|
||||
</template>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import snarkdown from 'snarkdown'
|
||||
|
||||
const htmlSplitRegexp = /(<a.+?<\/a>|<i>.+?<\/i>|<b>.+?<\/b>|<em>.+?<\/em>|<strong>.+?<\/strong>|<br>)/
|
||||
|
||||
export default {
|
||||
name: 'MarkdownContent',
|
||||
props: {
|
||||
string: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
items () {
|
||||
return snarkdown(this.string.replace(/\n/g, '<br>')).split(htmlSplitRegexp)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
sanitizeHref (href) {
|
||||
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
|
||||
return href.replace(/javascript:/g, '')
|
||||
}
|
||||
},
|
||||
extractAttr (text, attr) {
|
||||
if (text.includes(attr)) {
|
||||
return text.split(attr).pop().split('"')[1]
|
||||
}
|
||||
},
|
||||
extractText (text) {
|
||||
if (text) {
|
||||
return text.match(/>(.+?)</)?.[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,10 +1,23 @@
|
||||
<template>
|
||||
<label
|
||||
v-if="showFieldNames && field.name"
|
||||
v-if="showFieldNames && (field.name || field.title)"
|
||||
:for="field.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl mb-2"
|
||||
>{{ field.name }}</label>
|
||||
class="label text-2xl"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
><MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>{{ field.name }}</template>
|
||||
</label>
|
||||
<div
|
||||
v-if="field.description"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<div class="flex w-full max-h-44 overflow-y-auto">
|
||||
<input
|
||||
v-if="modelValue.length === 0"
|
||||
@@ -32,6 +45,7 @@
|
||||
<label
|
||||
:for="option.uuid"
|
||||
class="flex items-center space-x-3"
|
||||
@click="scrollIntoField(field)"
|
||||
>
|
||||
<input
|
||||
:id="option.uuid"
|
||||
@@ -53,8 +67,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
export default {
|
||||
name: 'MultiSelectStep',
|
||||
components: {
|
||||
MarkdownContent
|
||||
},
|
||||
inject: ['t', 'scrollIntoField'],
|
||||
props: {
|
||||
field: {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<label
|
||||
v-if="showFieldNames && (field.name || field.title)"
|
||||
:for="field.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
><MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<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"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<AppearsOn :field="field" />
|
||||
<div class="items-center flex">
|
||||
<input
|
||||
type="hidden"
|
||||
name="cast_number"
|
||||
value="true"
|
||||
>
|
||||
<input
|
||||
:id="field.uuid"
|
||||
v-model="number"
|
||||
type="number"
|
||||
class="base-input !text-2xl w-full"
|
||||
step="any"
|
||||
:required="field.required"
|
||||
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
:name="`values[${field.uuid}]`"
|
||||
@focus="$emit('focus')"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppearsOn from './appears_on'
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
export default {
|
||||
name: 'TextStep',
|
||||
components: {
|
||||
AppearsOn,
|
||||
MarkdownContent
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
showFieldNames: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['update:model-value', 'focus'],
|
||||
computed: {
|
||||
number: {
|
||||
set (value) {
|
||||
this.$emit('update:model-value', value)
|
||||
},
|
||||
get () {
|
||||
return this.modelValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,10 +2,25 @@
|
||||
<div>
|
||||
<label
|
||||
:for="isCodeSent ? 'one_time_code' : field.uuid"
|
||||
class="label text-2xl mb-2"
|
||||
>{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }}
|
||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||
class="label text-2xl"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
>
|
||||
<MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }}
|
||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||
</template>
|
||||
</label>
|
||||
<div
|
||||
v-if="field.description"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="hidden"
|
||||
@@ -53,14 +68,13 @@
|
||||
class="base-input !text-2xl w-full"
|
||||
autocomplete="tel"
|
||||
pattern="^\+[0-9\s\-]+$"
|
||||
:oninvalid="`this.value ? this.setCustomValidity('${t('use_international_format')}...') : ''`"
|
||||
oninput="this.setCustomValidity('')"
|
||||
type="tel"
|
||||
inputmode="tel"
|
||||
:required="field.required"
|
||||
placeholder="+1 234 567-8900"
|
||||
:name="`values[${field.uuid}]`"
|
||||
@input="$emit('update:model-value', $event.target.value)"
|
||||
@invalid="$event.target.value ? $event.target.setCustomValidity(`${t('use_international_format')}...`) : ''"
|
||||
@input="[$event.target.setCustomValidity(''), $emit('update:model-value', $event.target.value)]"
|
||||
@focus="$emit('focus')"
|
||||
>
|
||||
</div>
|
||||
@@ -68,6 +82,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
function throttle (func, delay) {
|
||||
let lastCallTime = 0
|
||||
|
||||
@@ -83,6 +99,9 @@ function throttle (func, delay) {
|
||||
|
||||
export default {
|
||||
name: 'PhoneStep',
|
||||
components: {
|
||||
MarkdownContent
|
||||
},
|
||||
inject: ['t', 'baseUrl'],
|
||||
props: {
|
||||
field: {
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
<template>
|
||||
<div dir="auto">
|
||||
<div class="flex justify-between items-center w-full mb-2">
|
||||
<div
|
||||
class="flex justify-between items-center w-full"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
>
|
||||
<label
|
||||
class="label text-2xl"
|
||||
>{{ showFieldNames && field.name ? field.name : t('signature') }}</label>
|
||||
>
|
||||
<MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ showFieldNames && field.name ? field.name : t('signature') }}
|
||||
</template>
|
||||
</label>
|
||||
<div class="space-x-2 flex">
|
||||
<span
|
||||
v-if="isTextSignature"
|
||||
v-if="isTextSignature && field.preferences?.format !== 'typed'"
|
||||
class="tooltip"
|
||||
:data-tip="t('draw_signature')"
|
||||
>
|
||||
@@ -23,14 +34,14 @@
|
||||
</a>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="withTypedSignature"
|
||||
v-else-if="withTypedSignature && field.preferences?.format !== 'typed' && field.preferences?.format !== 'drawn'"
|
||||
class="tooltip ml-2"
|
||||
:data-tip="t('type_text')"
|
||||
>
|
||||
<a
|
||||
id="type_text_button"
|
||||
href="#"
|
||||
class="btn btn-outline btn-sm font-medium"
|
||||
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
|
||||
@click.prevent="toggleTextInput"
|
||||
>
|
||||
<IconTextSize :width="16" />
|
||||
@@ -40,14 +51,16 @@
|
||||
</a>
|
||||
</span>
|
||||
<span
|
||||
v-if="field.preferences?.format !== 'typed'"
|
||||
class="tooltip"
|
||||
:data-tip="t('take_photo')"
|
||||
>
|
||||
<label
|
||||
class="btn btn-outline btn-sm font-medium"
|
||||
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
|
||||
>
|
||||
<IconCamera :width="16" />
|
||||
<input
|
||||
:key="uploadImageInputKey"
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/*"
|
||||
@@ -78,7 +91,7 @@
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
title="Minimize"
|
||||
:title="t('minimize')"
|
||||
class="py-1.5 inline md:hidden"
|
||||
@click.prevent="$emit('minimize')"
|
||||
>
|
||||
@@ -89,6 +102,13 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="field.description"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<AppearsOn :field="field" />
|
||||
<input
|
||||
:value="modelValue || computedPreviousValue"
|
||||
@@ -104,14 +124,14 @@
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
ref="canvas"
|
||||
style="padding: 1px; 0"
|
||||
class="bg-white border border-base-300 rounded-2xl"
|
||||
class="bg-white border border-base-300 rounded-2xl w-full"
|
||||
/>
|
||||
<input
|
||||
v-if="isTextSignature"
|
||||
id="signature_text_input"
|
||||
ref="textInput"
|
||||
class="base-input !text-2xl w-full mt-6"
|
||||
:required="field.required"
|
||||
:required="field.required && !isSignatureStarted"
|
||||
:placeholder="`${t('type_signature_here')}...`"
|
||||
type="text"
|
||||
@input="updateWrittenSignature"
|
||||
@@ -124,15 +144,19 @@ import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonal
|
||||
import { cropCanvasAndExportToPNG } from './crop_canvas'
|
||||
import SignaturePad from 'signature_pad'
|
||||
import AppearsOn from './appears_on'
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
let isFontLoaded = false
|
||||
|
||||
const scale = 3
|
||||
|
||||
export default {
|
||||
name: 'SignatureStep',
|
||||
components: {
|
||||
AppearsOn,
|
||||
IconReload,
|
||||
IconCamera,
|
||||
MarkdownContent,
|
||||
IconTextSize,
|
||||
IconSignature,
|
||||
IconArrowsDiagonalMinimize2
|
||||
@@ -152,11 +176,6 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
isDirectUpload: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
withTypedSignature: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -183,7 +202,8 @@ export default {
|
||||
return {
|
||||
isSignatureStarted: !!this.previousValue,
|
||||
isUsePreviousValue: true,
|
||||
isTextSignature: false
|
||||
isTextSignature: this.field.preferences?.format === 'typed',
|
||||
uploadImageInputKey: Math.random().toString()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -198,15 +218,13 @@ export default {
|
||||
async mounted () {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.canvas) {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
|
||||
|
||||
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||
}
|
||||
})
|
||||
|
||||
if (this.isDirectUpload) {
|
||||
import('@rails/activestorage')
|
||||
}
|
||||
|
||||
if (this.$refs.canvas) {
|
||||
this.pad = new SignaturePad(this.$refs.canvas)
|
||||
|
||||
@@ -215,8 +233,26 @@ export default {
|
||||
|
||||
this.$emit('start')
|
||||
})
|
||||
|
||||
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
|
||||
|
||||
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||
|
||||
this.intersectionObserver?.disconnect()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.intersectionObserver.observe(this.$refs.canvas)
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.intersectionObserver?.disconnect()
|
||||
},
|
||||
methods: {
|
||||
remove () {
|
||||
this.$emit('update:model-value', '')
|
||||
@@ -247,7 +283,7 @@ export default {
|
||||
}
|
||||
},
|
||||
updateWrittenSignature (e) {
|
||||
this.isSignatureStarted = true
|
||||
this.isSignatureStarted = !!e.target.value
|
||||
|
||||
const canvas = this.$refs.canvas
|
||||
const context = canvas.getContext('2d')
|
||||
@@ -260,8 +296,8 @@ export default {
|
||||
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
|
||||
context.textAlign = 'center'
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11)
|
||||
context.clearRect(0, 0, canvas.width / scale, canvas.height / scale)
|
||||
context.fillText(e.target.value, canvas.width / 2 / scale, canvas.height / 2 / scale + 11)
|
||||
},
|
||||
toggleTextInput () {
|
||||
this.remove()
|
||||
@@ -297,14 +333,16 @@ export default {
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
const aspectRatio = img.width / img.height
|
||||
const canvasWidth = canvas.width / scale
|
||||
const canvasHeight = canvas.height / scale
|
||||
|
||||
let targetWidth = canvas.width
|
||||
let targetHeight = canvas.height
|
||||
let targetWidth = canvasWidth
|
||||
let targetHeight = canvasHeight
|
||||
|
||||
if (canvas.width / canvas.height > aspectRatio) {
|
||||
targetWidth = canvas.height * aspectRatio
|
||||
if (canvasWidth / canvasHeight > aspectRatio) {
|
||||
targetWidth = canvasHeight * aspectRatio
|
||||
} else {
|
||||
targetHeight = canvas.width / aspectRatio
|
||||
targetHeight = canvasWidth / aspectRatio
|
||||
}
|
||||
|
||||
if (targetHeight > targetWidth) {
|
||||
@@ -313,17 +351,21 @@ export default {
|
||||
targetHeight = targetHeight * scale
|
||||
}
|
||||
|
||||
const x = (canvas.width - targetWidth) / 2
|
||||
const y = (canvas.height - targetHeight) / 2
|
||||
const x = (canvasWidth - targetWidth) / 2
|
||||
const y = (canvasHeight - targetHeight) / 2
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
context.drawImage(img, x, y, targetWidth, targetHeight)
|
||||
setTimeout(() => {
|
||||
context.clearRect(0, 0, canvasWidth, canvasHeight)
|
||||
context.drawImage(img, x, y, targetWidth, targetHeight)
|
||||
|
||||
this.$emit('start')
|
||||
this.$emit('start')
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
reader.readAsDataURL(file)
|
||||
|
||||
this.uploadImageInputKey = Math.random().toString()
|
||||
}
|
||||
},
|
||||
async submit () {
|
||||
@@ -335,49 +377,27 @@ export default {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => {
|
||||
const file = new File([blob], 'signature.png', { type: 'image/png' })
|
||||
|
||||
if (this.isDirectUpload) {
|
||||
const { DirectUpload } = await import('@rails/activestorage')
|
||||
const formData = new FormData()
|
||||
|
||||
new DirectUpload(
|
||||
file,
|
||||
'/direct_uploads'
|
||||
).create((_error, data) => {
|
||||
fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug,
|
||||
blob_signed_id: data.signed_id,
|
||||
name: 'attachments'
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then((resp) => resp.json()).then((attachment) => {
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
this.$emit('attached', attachment)
|
||||
formData.append('file', file)
|
||||
formData.append('submitter_slug', this.submitterSlug)
|
||||
formData.append('name', 'attachments')
|
||||
|
||||
return resolve(attachment)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
const formData = new FormData()
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then((resp) => resp.json()).then((attachment) => {
|
||||
this.$emit('attached', attachment)
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
|
||||
formData.append('file', file)
|
||||
formData.append('submitter_slug', this.submitterSlug)
|
||||
formData.append('name', 'attachments')
|
||||
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then((resp) => resp.json()).then((attachment) => {
|
||||
this.$emit('attached', attachment)
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
|
||||
return resolve(attachment)
|
||||
})
|
||||
}
|
||||
return resolve(attachment)
|
||||
})
|
||||
}).catch((error) => {
|
||||
return reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<template>
|
||||
<label
|
||||
v-if="showFieldNames && field.name"
|
||||
v-if="showFieldNames && (field.name || field.title)"
|
||||
:for="field.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
><template v-if="field.title"><span v-html="field.title" /></template>
|
||||
><MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>{{ field.name }}</template>
|
||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||
</label>
|
||||
@@ -15,9 +18,11 @@
|
||||
/>
|
||||
<div
|
||||
v-if="field.description"
|
||||
class="mb-3 px-1 text-lg"
|
||||
v-html="field.description"
|
||||
/>
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<AppearsOn :field="field" />
|
||||
<div class="items-center flex">
|
||||
<input
|
||||
@@ -30,11 +35,11 @@
|
||||
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
|
||||
: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')})`}`"
|
||||
type="text"
|
||||
:name="`values[${field.uuid}]`"
|
||||
@invalid="field.validation?.message ? $event.target.setCustomValidity(field.validation.message) : ''"
|
||||
@input="field.validation?.message ? $event.target.setCustomValidity('') : ''"
|
||||
@focus="$emit('focus')"
|
||||
>
|
||||
<textarea
|
||||
@@ -69,11 +74,13 @@
|
||||
<script>
|
||||
import { IconAlignBoxLeftTop } from '@tabler/icons-vue'
|
||||
import AppearsOn from './appears_on'
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
export default {
|
||||
name: 'TextStep',
|
||||
components: {
|
||||
IconAlignBoxLeftTop,
|
||||
MarkdownContent,
|
||||
AppearsOn
|
||||
},
|
||||
inject: ['t'],
|
||||
@@ -123,12 +130,14 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.isTextArea = this.modelValue?.includes('\n')
|
||||
if (this.modelValue) {
|
||||
this.isTextArea = this.modelValue.toString().includes('\n')
|
||||
|
||||
if (this.isTextArea) {
|
||||
this.$nextTick(() => {
|
||||
this.resizeTextarea()
|
||||
})
|
||||
if (this.isTextArea) {
|
||||
this.$nextTick(() => {
|
||||
this.resizeTextarea()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
v-model="field.submitter_uuid"
|
||||
class="border-r"
|
||||
:compact="true"
|
||||
:editable="editable && !defaultField"
|
||||
:editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
|
||||
:allow-add-new="!defaultSubmitters.length"
|
||||
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
|
||||
:submitters="template.submitters"
|
||||
@@ -90,7 +90,7 @@
|
||||
<button
|
||||
v-else-if="editable"
|
||||
class="pr-1"
|
||||
title="Remove"
|
||||
:title="t('remove')"
|
||||
@click.prevent="$emit('remove')"
|
||||
>
|
||||
<IconX width="14" />
|
||||
@@ -98,21 +98,32 @@
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center h-full w-full"
|
||||
:class="[bgColors[submitterIndex], field?.default_value ? '' : 'justify-center']"
|
||||
dir="auto"
|
||||
:class="[bgColors[submitterIndex], field?.default_value ? (alignClasses[field.preferences?.align] || '') : 'justify-center']"
|
||||
>
|
||||
<span
|
||||
v-if="field"
|
||||
class="flex justify-center items-center space-x-1 h-full"
|
||||
:class="{'w-full h-full': field.type == 'checkbox'}"
|
||||
>
|
||||
<div
|
||||
v-if="field?.default_value"
|
||||
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars }"
|
||||
:class="{ 'w-full h-full': field.type == 'checkbox', 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars }"
|
||||
>
|
||||
<div
|
||||
ref="textContainer"
|
||||
class="flex items-center px-0.5"
|
||||
:class="{'w-full h-full': field.type == 'checkbox'}"
|
||||
>
|
||||
<span class="whitespace-pre-wrap">{{ field.default_value }}</span>
|
||||
<IconCheck
|
||||
v-if="field.type == 'checkbox'"
|
||||
class="aspect-square mx-auto"
|
||||
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="whitespace-pre-wrap"
|
||||
>{{ field.default_value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<component
|
||||
@@ -141,13 +152,14 @@
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import FieldType from './field_type'
|
||||
import Field from './field'
|
||||
import { IconX } from '@tabler/icons-vue'
|
||||
import { IconX, IconCheck } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
export default {
|
||||
name: 'FieldArea',
|
||||
components: {
|
||||
FieldType,
|
||||
IconCheck,
|
||||
FieldSubmitter,
|
||||
IconX
|
||||
},
|
||||
@@ -194,9 +206,18 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
defaultName: Field.computed.defaultName,
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldIcons: FieldType.computed.fieldIcons,
|
||||
defaultName () {
|
||||
return this.buildDefaultName(this.field, this.template.fields)
|
||||
},
|
||||
alignClasses () {
|
||||
return {
|
||||
center: 'justify-center',
|
||||
left: 'justify-start',
|
||||
right: 'justify-end'
|
||||
}
|
||||
},
|
||||
optionIndexText () {
|
||||
if (this.area.option_uuid && this.field.options) {
|
||||
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
|
||||
@@ -225,6 +246,16 @@ export default {
|
||||
},
|
||||
borderColors () {
|
||||
return [
|
||||
'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',
|
||||
'border-red-500/80',
|
||||
'border-sky-500/80',
|
||||
'border-emerald-500/80',
|
||||
@@ -239,6 +270,16 @@ export default {
|
||||
},
|
||||
bgColors () {
|
||||
return [
|
||||
'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',
|
||||
'bg-red-100/80',
|
||||
'bg-sky-100/80',
|
||||
'bg-emerald-100/80',
|
||||
@@ -267,19 +308,22 @@ 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
|
||||
if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
|
||||
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.$nextTick(() => {
|
||||
if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
|
||||
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
|
||||
}
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
buildDefaultName: Field.methods.buildDefaultName,
|
||||
onNameFocus (e) {
|
||||
this.selectedAreaRef.value = this.area
|
||||
|
||||
@@ -308,7 +352,7 @@ export default {
|
||||
},
|
||||
onResizeCell (e) {
|
||||
if (e.target.id === 'mask') {
|
||||
const positionX = e.layerX / (e.target.clientWidth - 1)
|
||||
const positionX = e.offsetX / (e.target.clientWidth - 1)
|
||||
|
||||
if (positionX > this.area.x) {
|
||||
this.area.cell_w = positionX - this.area.x
|
||||
@@ -354,16 +398,16 @@ export default {
|
||||
},
|
||||
resize (e) {
|
||||
if (e.target.id === 'mask') {
|
||||
this.area.w = e.layerX / e.target.clientWidth - this.area.x
|
||||
this.area.h = e.layerY / e.target.clientHeight - this.area.y
|
||||
this.area.w = e.offsetX / e.target.clientWidth - this.area.x
|
||||
this.area.h = e.offsetY / e.target.clientHeight - this.area.y
|
||||
}
|
||||
},
|
||||
drag (e) {
|
||||
if (e.target.id === 'mask') {
|
||||
this.isDragged = true
|
||||
|
||||
this.area.x = (e.layerX - this.dragFrom.x) / e.target.clientWidth
|
||||
this.area.y = (e.layerY - this.dragFrom.y) / e.target.clientHeight
|
||||
this.area.x = (e.offsetX - this.dragFrom.x) / e.target.clientWidth
|
||||
this.area.y = (e.offsetY - this.dragFrom.y) / e.target.clientHeight
|
||||
}
|
||||
},
|
||||
startDrag (e) {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
@update:model-value="updateName"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-x-3 flex items-center">
|
||||
<div class="space-x-3 flex items-center flex-shrink-0">
|
||||
<slot
|
||||
v-if="$slots.buttons"
|
||||
name="buttons"
|
||||
@@ -40,7 +40,7 @@
|
||||
@click="maybeShowEmptyTemplateAlert"
|
||||
>
|
||||
<IconWritingSign
|
||||
width="20"
|
||||
width="22"
|
||||
class="inline"
|
||||
/>
|
||||
<span class="hidden md:inline">
|
||||
@@ -94,11 +94,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="main_container"
|
||||
class="flex"
|
||||
:class="$slots.buttons || withTitle ? 'md:max-h-[calc(100%_-_60px)]' : 'md:max-h-[100%]'"
|
||||
>
|
||||
<div
|
||||
v-if="withDocumentsList"
|
||||
id="documents_container"
|
||||
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"
|
||||
@@ -113,7 +115,6 @@
|
||||
:with-replace-button="withUploadButton"
|
||||
:editable="editable"
|
||||
:template="template"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
@scroll-to="scrollIntoDocument(item)"
|
||||
@remove="onDocumentRemove"
|
||||
@replace="onDocumentReplace"
|
||||
@@ -129,12 +130,14 @@
|
||||
v-if="sortedDocuments.length && editable && withUploadButton"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:template-id="template.id"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
@success="updateFromUpload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
|
||||
<div
|
||||
id="pages_container"
|
||||
class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5"
|
||||
>
|
||||
<div
|
||||
ref="documents"
|
||||
class="pr-3.5 pl-0.5"
|
||||
@@ -143,7 +146,6 @@
|
||||
v-if="!sortedDocuments.length && withUploadButton"
|
||||
:template-id="template.id"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
@success="updateFromUpload"
|
||||
/>
|
||||
<template v-else>
|
||||
@@ -161,9 +163,10 @@
|
||||
:allow-draw="!onlyDefinedFields"
|
||||
:default-submitters="defaultSubmitters"
|
||||
:draw-field="drawField"
|
||||
:draw-field-type="drawFieldType"
|
||||
:editable="editable"
|
||||
:base-url="baseUrl"
|
||||
@draw="onDraw"
|
||||
@draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
|
||||
@drop-field="onDropfield"
|
||||
@remove-area="removeArea"
|
||||
/>
|
||||
@@ -172,9 +175,9 @@
|
||||
:with-arrows="template.schema.length > 1"
|
||||
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
|
||||
:with-replace-button="withUploadButton"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:document="document"
|
||||
:template="template"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
class="pb-2 mb-2 border-b border-base-300 border-dashed"
|
||||
@remove="onDocumentRemove"
|
||||
@replace="onDocumentReplace"
|
||||
@@ -190,7 +193,6 @@
|
||||
<Upload
|
||||
v-if="withUploadButton"
|
||||
:template-id="template.id"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
@success="updateFromUpload"
|
||||
/>
|
||||
@@ -200,17 +202,18 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="withFieldsList"
|
||||
id="fields_list_container"
|
||||
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
|
||||
:class="drawField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'"
|
||||
>
|
||||
<div
|
||||
v-if="drawField"
|
||||
v-if="showDrawField || drawField"
|
||||
class="sticky inset-0 h-full z-20"
|
||||
:style="{ backgroundColor }"
|
||||
>
|
||||
<div class="bg-base-300 rounded-lg p-5 text-center space-y-4">
|
||||
<div class="bg-base-200 rounded-lg p-5 text-center space-y-4">
|
||||
<p>
|
||||
{{ t('draw_field_on_the_document').replace('{field}', drawField.name) }}
|
||||
{{ t('draw_field_on_the_document').replace('{field}', drawField?.name || '') }}
|
||||
</p>
|
||||
<div>
|
||||
<button
|
||||
@@ -220,10 +223,10 @@
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<a
|
||||
v-if="!drawOption && !drawField.areas.length && !['stamp', 'signature', 'initials'].includes(drawField.type)"
|
||||
v-if="!drawField && !drawOption && !['stamp', 'signature', 'initials'].includes(drawField?.type || drawFieldType)"
|
||||
href="#"
|
||||
class="link block mt-3 text-sm"
|
||||
@click.prevent="[drawField = null, drawOption = null]"
|
||||
@click.prevent="[addField(drawFieldType), drawField = null, drawOption = null, withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
|
||||
>
|
||||
{{ t('or_add_field_without_drawing') }}
|
||||
</a>
|
||||
@@ -238,12 +241,15 @@
|
||||
:selected-submitter="selectedSubmitter"
|
||||
:with-help="withHelp"
|
||||
:default-submitters="defaultSubmitters"
|
||||
:draw-field-type="drawFieldType"
|
||||
:default-fields="defaultFields"
|
||||
:field-types="fieldTypes"
|
||||
:with-sticky-submitters="withStickySubmitters"
|
||||
:only-defined-fields="onlyDefinedFields"
|
||||
:editable="editable"
|
||||
@add-field="addField"
|
||||
@set-draw="[drawField = $event.field, drawOption = $event.option]"
|
||||
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
|
||||
@set-drag="dragField = $event"
|
||||
@change-submitter="selectedSubmitter = $event"
|
||||
@drag-end="dragField = null"
|
||||
@@ -273,6 +279,7 @@
|
||||
@select="startFieldDraw($event)"
|
||||
/>
|
||||
</div>
|
||||
<div id="docuseal_modal_container" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -321,7 +328,12 @@ export default {
|
||||
backgroundColor: this.backgroundColor,
|
||||
withPhone: this.withPhone,
|
||||
withPayment: this.withPayment,
|
||||
selectedAreaRef: computed(() => this.selectedAreaRef)
|
||||
isPaymentConnected: this.isPaymentConnected,
|
||||
withFormula: this.withFormula,
|
||||
withConditions: this.withConditions,
|
||||
defaultDrawFieldType: this.defaultDrawFieldType,
|
||||
selectedAreaRef: computed(() => this.selectedAreaRef),
|
||||
fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef)
|
||||
}
|
||||
},
|
||||
props: {
|
||||
@@ -329,11 +341,6 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isDirectUpload: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
i18n: {
|
||||
type: Object,
|
||||
required: false,
|
||||
@@ -364,6 +371,16 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
withSelectedFieldType: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
defaultDrawFieldType: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'text'
|
||||
},
|
||||
currencies: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -428,6 +445,11 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
authenticityToken: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
withDocumentsList: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -443,6 +465,21 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
isPaymentConnected: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withFormula: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withConditions: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
onlyDefinedFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -460,13 +497,16 @@ export default {
|
||||
isBreakpointLg: false,
|
||||
isSaving: false,
|
||||
selectedSubmitter: null,
|
||||
showDrawField: false,
|
||||
drawField: null,
|
||||
drawFieldType: null,
|
||||
drawOption: null,
|
||||
dragField: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selectedAreaRef: () => ref(),
|
||||
fieldsDragFieldRef: () => ref(),
|
||||
fieldAreasIndex () {
|
||||
const areas = {}
|
||||
|
||||
@@ -492,6 +532,12 @@ export default {
|
||||
}
|
||||
},
|
||||
created () {
|
||||
if (!this.template.fields?.length && this.template.submitters?.length === 1) {
|
||||
if (this.template.submitters[0]?.name === 'First Party') {
|
||||
this.template.submitters[0].name = this.t('first_party')
|
||||
}
|
||||
}
|
||||
|
||||
const existingSubmittersUuids = this.defaultSubmitters.map((name) => {
|
||||
return this.template.submitters.find(e => e.name === name)?.uuid
|
||||
})
|
||||
@@ -502,7 +548,7 @@ export default {
|
||||
submitter.name = name
|
||||
|
||||
if (existingSubmittersUuids.filter(Boolean).length) {
|
||||
submitter.uuid = existingSubmittersUuids[index] || v4()
|
||||
submitter.uuid = existingSubmittersUuids[index] || submitter.uuid || v4()
|
||||
} else {
|
||||
submitter.uuid ||= v4()
|
||||
}
|
||||
@@ -542,6 +588,34 @@ export default {
|
||||
t (key) {
|
||||
return this.i18n[key] || i18nEn[key] || key
|
||||
},
|
||||
addField (type, area = null) {
|
||||
const field = {
|
||||
name: '',
|
||||
uuid: v4(),
|
||||
required: type !== 'checkbox',
|
||||
areas: area ? [area] : [],
|
||||
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.template.fields.push(field)
|
||||
|
||||
this.save()
|
||||
},
|
||||
startFieldDraw ({ name, type }) {
|
||||
const existingField = this.template.fields?.find((f) => f.submitter_uuid === this.selectedSubmitter.uuid && name && name === f.name)
|
||||
|
||||
@@ -622,15 +696,13 @@ 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
|
||||
this.showDrawField = false
|
||||
|
||||
if (!this.withSelectedFieldType) {
|
||||
this.drawFieldType = ''
|
||||
}
|
||||
},
|
||||
onKeyUp (e) {
|
||||
if (e.code === 'Escape') {
|
||||
@@ -680,6 +752,27 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
setDefaultAreaSize (area, type) {
|
||||
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
|
||||
const pageMask = documentRef.pageRefs[area.page].$refs.mask
|
||||
|
||||
if (type === 'checkbox') {
|
||||
area.w = pageMask.clientWidth / 30 / pageMask.clientWidth
|
||||
area.h = (pageMask.clientWidth / 30 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
|
||||
} else if (type === 'image') {
|
||||
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
|
||||
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
|
||||
} else if (type === 'signature' || type === 'stamp') {
|
||||
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
|
||||
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight) / 2
|
||||
} else if (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)
|
||||
}
|
||||
},
|
||||
onDraw (area) {
|
||||
if (this.drawField) {
|
||||
if (this.drawOption) {
|
||||
@@ -702,25 +795,7 @@ export default {
|
||||
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)
|
||||
}
|
||||
this.setDefaultAreaSize(area, this.drawOption ? 'checkbox' : this.drawField?.type)
|
||||
}
|
||||
|
||||
area.x -= area.w / 2
|
||||
@@ -755,11 +830,15 @@ export default {
|
||||
|
||||
let type = (pageMask.clientWidth * area.w) < 35 ? 'checkbox' : 'text'
|
||||
|
||||
if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) {
|
||||
if (this.drawFieldType) {
|
||||
type = this.drawFieldType
|
||||
} else if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
|
||||
type = this.defaultDrawFieldType
|
||||
} else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) {
|
||||
type = this.fieldTypes[0]
|
||||
}
|
||||
|
||||
if (type === 'checkbox') {
|
||||
if (type === 'checkbox' && !this.drawFieldType) {
|
||||
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
|
||||
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
|
||||
|
||||
@@ -777,26 +856,27 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
if (area.w) {
|
||||
const field = {
|
||||
name: '',
|
||||
uuid: v4(),
|
||||
required: type !== 'checkbox',
|
||||
type,
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
areas: [area]
|
||||
if (this.drawFieldType && (area.w === 0 || area.h === 0)) {
|
||||
if (this.selectedField?.type === this.drawFieldType) {
|
||||
area.w = this.selectedAreaRef.value.w
|
||||
area.h = this.selectedAreaRef.value.h
|
||||
} else {
|
||||
this.setDefaultAreaSize(area, this.drawFieldType)
|
||||
}
|
||||
|
||||
this.template.fields.push(field)
|
||||
area.x -= area.w / 2
|
||||
area.y -= area.h / 2
|
||||
}
|
||||
|
||||
if (area.w) {
|
||||
this.addField(type, area)
|
||||
|
||||
this.selectedAreaRef.value = area
|
||||
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
},
|
||||
onDropfield (area) {
|
||||
const field = {
|
||||
const field = this.fieldsDragFieldRef.value || {
|
||||
name: '',
|
||||
uuid: v4(),
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
@@ -804,17 +884,19 @@ export default {
|
||||
...this.dragField
|
||||
}
|
||||
|
||||
if (['select', 'multiple', 'radio'].includes(field.type)) {
|
||||
field.options = [{ value: '', uuid: v4() }]
|
||||
}
|
||||
if (!this.fieldsDragFieldRef.value) {
|
||||
if (['select', 'multiple', 'radio'].includes(field.type)) {
|
||||
field.options = [{ value: '', uuid: v4() }]
|
||||
}
|
||||
|
||||
if (field.type === 'stamp') {
|
||||
field.readonly = true
|
||||
}
|
||||
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'
|
||||
if (field.type === 'date') {
|
||||
field.preferences = {
|
||||
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -870,22 +952,46 @@ export default {
|
||||
fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5)
|
||||
}
|
||||
|
||||
field.areas = [fieldArea]
|
||||
field.areas ||= []
|
||||
|
||||
const lastArea = field.areas[field.areas.length - 1]
|
||||
|
||||
if (lastArea) {
|
||||
fieldArea.x -= lastArea.w / 2
|
||||
fieldArea.w = lastArea.w
|
||||
fieldArea.h = lastArea.h
|
||||
}
|
||||
|
||||
field.areas.push(fieldArea)
|
||||
|
||||
this.selectedAreaRef.value = fieldArea
|
||||
|
||||
this.template.fields.push(field)
|
||||
if (this.template.fields.indexOf(field) === -1) {
|
||||
this.template.fields.push(field)
|
||||
}
|
||||
|
||||
this.save()
|
||||
},
|
||||
updateFromUpload ({ schema, documents }) {
|
||||
this.template.schema.push(...schema)
|
||||
this.template.documents.push(...documents)
|
||||
updateFromUpload (data) {
|
||||
this.template.schema.push(...data.schema)
|
||||
this.template.documents.push(...data.documents)
|
||||
|
||||
if (data.fields) {
|
||||
this.template.fields = data.fields
|
||||
}
|
||||
|
||||
if (data.submitters) {
|
||||
this.template.submitters = data.submitters
|
||||
|
||||
if (!this.template.submitters.find((s) => s.uuid === this.selectedSubmitter?.uuid)) {
|
||||
this.selectedSubmitter = this.template.submitters[0]
|
||||
}
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.previews.scrollTop = this.$refs.previews.scrollHeight
|
||||
|
||||
this.scrollIntoDocument(schema[0])
|
||||
this.scrollIntoDocument(data.schema[0])
|
||||
})
|
||||
|
||||
if (this.template.name === 'New Document') {
|
||||
@@ -908,19 +1014,56 @@ export default {
|
||||
this.template.schema.splice(this.template.schema.indexOf(item), 1)
|
||||
}
|
||||
|
||||
const removedFieldUuids = []
|
||||
|
||||
this.template.fields.forEach((field) => {
|
||||
[...(field.areas || [])].forEach((area) => {
|
||||
if (area.attachment_uuid === item.attachment_uuid) {
|
||||
field.areas.splice(field.areas.indexOf(area), 1)
|
||||
|
||||
removedFieldUuids.push(field.uuid)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.template.fields =
|
||||
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
|
||||
|
||||
this.save()
|
||||
},
|
||||
onDocumentReplace ({ replaceSchemaItem, schema, documents }) {
|
||||
onDocumentReplace (data) {
|
||||
const { replaceSchemaItem, schema, documents } = data
|
||||
|
||||
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, schema[0])
|
||||
this.template.documents.push(...documents)
|
||||
|
||||
if (data.fields) {
|
||||
this.template.fields = data.fields
|
||||
|
||||
const removedFieldUuids = []
|
||||
|
||||
this.template.fields.forEach((field) => {
|
||||
[...(field.areas || [])].forEach((area) => {
|
||||
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
|
||||
field.areas.splice(field.areas.indexOf(area), 1)
|
||||
|
||||
removedFieldUuids.push(field.uuid)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.template.fields =
|
||||
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
|
||||
}
|
||||
|
||||
if (data.submitters) {
|
||||
this.template.submitters = data.submitters
|
||||
|
||||
if (!this.template.submitters.find((s) => s.uuid === this.selectedSubmitter?.uuid)) {
|
||||
this.selectedSubmitter = this.template.submitters[0]
|
||||
}
|
||||
}
|
||||
|
||||
this.template.fields.forEach((field) => {
|
||||
(field.areas || []).forEach((area) => {
|
||||
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
|
||||
@@ -980,7 +1123,11 @@ export default {
|
||||
baseFetch (path, options = {}) {
|
||||
return fetch(this.baseUrl + path, {
|
||||
...options,
|
||||
headers: { ...this.fetchOptions.headers, ...options.headers }
|
||||
headers: {
|
||||
'X-CSRF-Token': this.authenticityToken,
|
||||
...this.fetchOptions.headers,
|
||||
...options.headers
|
||||
}
|
||||
})
|
||||
},
|
||||
save ({ force } = { force: false }) {
|
||||
@@ -996,7 +1143,7 @@ export default {
|
||||
|
||||
this.pushUndo()
|
||||
|
||||
return this.baseFetch(`/api/templates/${this.template.id}`, {
|
||||
return this.baseFetch(`/templates/${this.template.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
template: {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div
|
||||
class="modal modal-open items-start !animate-none overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<span>
|
||||
{{ t('condition') }} - {{ field.name || buildDefaultName(field, template.fields) }}
|
||||
</span>
|
||||
<a
|
||||
href="#"
|
||||
class="text-xl"
|
||||
@click.prevent="$emit('close')"
|
||||
>×</a>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-if="!withConditions"
|
||||
class="bg-base-300 rounded-xl py-2 px-3 text-center"
|
||||
>
|
||||
<a
|
||||
href="https://www.docuseal.co/pricing"
|
||||
target="_blank"
|
||||
class="link"
|
||||
>Available in Pro</a>
|
||||
</div>
|
||||
<form @submit.prevent="validateSaveAndClose">
|
||||
<div class="my-4">
|
||||
<div class="space-y-4">
|
||||
<select
|
||||
class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal"
|
||||
required
|
||||
@change="[
|
||||
newCondition.field_uuid = $event.target.value,
|
||||
delete newCondition.value,
|
||||
(conditionActions.includes(newCondition.action) ? '' : newCondition.action = conditionActions[0])
|
||||
]"
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
disabled
|
||||
:selected="!newCondition.field_uuid"
|
||||
>
|
||||
{{ t('select_field_') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="f in fields"
|
||||
:key="f.uuid"
|
||||
:value="f.uuid"
|
||||
:selected="newCondition.field_uuid === f.uuid"
|
||||
>
|
||||
{{ f.name || buildDefaultName(f, template.fields) }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-model="newCondition.action"
|
||||
class="select select-bordered select-sm w-full h-11 pl-4 text-base font-normal"
|
||||
:class="{ 'bg-white': newCondition.field_uuid, 'bg-base-300': !newCondition.field_uuid }"
|
||||
:required="newCondition.field_uuid"
|
||||
>
|
||||
<option
|
||||
v-for="action in conditionActions"
|
||||
:key="action"
|
||||
:value="action"
|
||||
>
|
||||
{{ t(action) }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="conditionField?.options?.length"
|
||||
class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal"
|
||||
required
|
||||
@change="newCondition.value = $event.target.value"
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
disabled
|
||||
selected
|
||||
>
|
||||
{{ t('select_value_') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="(option, index) in conditionField.options"
|
||||
:key="option.uuid"
|
||||
:value="option.uuid"
|
||||
:selected="newCondition.value === option.uuid"
|
||||
>
|
||||
{{ option.value || `${t('option')} ${index + 1}` }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="base-button w-full mt-2"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
v-if="field.conditions?.[0]?.field_uuid"
|
||||
class="text-center w-full mt-4"
|
||||
>
|
||||
<button
|
||||
class="link"
|
||||
@click="[newCondition.field_uuid = null, delete field.conditions, validateSaveAndClose()]"
|
||||
>
|
||||
{{ t('remove_condition') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ConditionModal',
|
||||
inject: ['t', 'save', 'template', 'withConditions'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
buildDefaultName: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['close'],
|
||||
data () {
|
||||
return {
|
||||
newCondition: this.field.conditions?.[0] || {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
conditionField () {
|
||||
return this.fields.find((f) => f.uuid === this.newCondition.field_uuid)
|
||||
},
|
||||
conditionActions () {
|
||||
return this.fieldActions(this.conditionField)
|
||||
},
|
||||
fields () {
|
||||
return this.template.fields.reduce((acc, f) => {
|
||||
if (f !== this.field && f.submitter_uuid === this.field.submitter_uuid) {
|
||||
acc.push(f)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.field.conditions ||= []
|
||||
},
|
||||
methods: {
|
||||
fieldActions (field) {
|
||||
const actions = []
|
||||
|
||||
if (!field) {
|
||||
return actions
|
||||
}
|
||||
|
||||
if (field.type === 'checkbox') {
|
||||
actions.push('checked', 'unchecked')
|
||||
} else if (['radio', 'select'].includes(field.type)) {
|
||||
actions.push('equal', 'not_equal')
|
||||
} else if (['multiple'].includes(field.type)) {
|
||||
actions.push('contains', 'does_not_contain')
|
||||
} else {
|
||||
actions.push('not_empty', 'empty')
|
||||
}
|
||||
|
||||
return actions
|
||||
},
|
||||
validateSaveAndClose () {
|
||||
if (!this.withConditions) {
|
||||
return alert('Available only in Pro')
|
||||
}
|
||||
|
||||
if (this.newCondition.field_uuid) {
|
||||
this.field.conditions = [this.newCondition]
|
||||
} else {
|
||||
delete this.field.conditions
|
||||
}
|
||||
|
||||
this.save()
|
||||
this.$emit('close')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -14,7 +14,10 @@
|
||||
@focus="$emit('focus', $event)"
|
||||
@blur="onBlur"
|
||||
>
|
||||
{{ value }}
|
||||
<template
|
||||
v-for="(item, index) in (value || '').split(/(_)/)"
|
||||
:key="index"
|
||||
>{{ item }}<wbr></template>
|
||||
</span>
|
||||
<span
|
||||
v-if="withRequired"
|
||||
@@ -27,7 +30,7 @@
|
||||
<IconPencil
|
||||
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"
|
||||
:title="t('edit')"
|
||||
:class="{ invisible: !editable, 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
|
||||
:width="iconWidth"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
@@ -44,6 +47,7 @@ export default {
|
||||
components: {
|
||||
IconPencil
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
/>
|
||||
<ReplaceButton
|
||||
v-if="withReplaceButton"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:template-id="template.id"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
@click.stop
|
||||
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
|
||||
/>
|
||||
@@ -63,10 +63,10 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isDirectUpload: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
acceptFileTypes: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'image/*, application/pdf'
|
||||
},
|
||||
withReplaceButton: {
|
||||
type: Boolean,
|
||||
@@ -80,11 +80,6 @@ export default {
|
||||
}
|
||||
},
|
||||
emits: ['change', 'remove', 'up', 'down', 'replace'],
|
||||
mounted () {
|
||||
if (this.isDirectUpload) {
|
||||
import('@rails/activestorage')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
upload: Upload.methods.upload,
|
||||
onUpdateName (value) {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div
|
||||
class="modal modal-open items-start !animate-none overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<span>
|
||||
{{ field.name || buildDefaultName(field, template.fields) }}
|
||||
</span>
|
||||
<a
|
||||
href="#"
|
||||
class="text-xl"
|
||||
@click.prevent="$emit('close')"
|
||||
>×</a>
|
||||
</div>
|
||||
<div>
|
||||
<form @submit.prevent="saveAndClose">
|
||||
<div class="space-y-1 mb-1">
|
||||
<div>
|
||||
<label
|
||||
dir="auto"
|
||||
class="label text-sm"
|
||||
for="description_field"
|
||||
>
|
||||
{{ t('description') }}
|
||||
</label>
|
||||
<textarea
|
||||
id="description_field"
|
||||
ref="textarea"
|
||||
v-model="description"
|
||||
dir="auto"
|
||||
class="base-textarea !text-base w-full"
|
||||
@input="resizeTextarea"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
dir="auto"
|
||||
class="label text-sm"
|
||||
for="title_field"
|
||||
>
|
||||
{{ t('display_title') }} ({{ t('optional') }})
|
||||
</label>
|
||||
<input
|
||||
id="title_field"
|
||||
v-model="title"
|
||||
dir="auto"
|
||||
class="base-input !text-base w-full"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="base-button w-full mt-4"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DescriptionModal',
|
||||
inject: ['t', 'save', 'template'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
buildDefaultName: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['close'],
|
||||
data () {
|
||||
return {
|
||||
description: this.field.description,
|
||||
title: this.field.title
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.resizeTextarea()
|
||||
},
|
||||
methods: {
|
||||
saveAndClose () {
|
||||
this.field.description = this.description
|
||||
this.field.title = this.title
|
||||
|
||||
this.save()
|
||||
this.$emit('close')
|
||||
},
|
||||
resizeTextarea () {
|
||||
const textarea = this.$refs.textarea
|
||||
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = textarea.scrollHeight + 'px'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -12,6 +12,7 @@
|
||||
:default-fields="defaultFields"
|
||||
:default-submitters="defaultSubmitters"
|
||||
:draw-field="drawField"
|
||||
:draw-field-type="drawFieldType"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
:image="image"
|
||||
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
|
||||
@@ -43,6 +44,11 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
drawFieldType: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
defaultSubmitters: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -102,7 +108,7 @@ export default {
|
||||
return this.previewImagesIndex[i] || {
|
||||
metadata: lazyloadMetadata,
|
||||
id: Math.random().toString(),
|
||||
url: this.basePreviewUrl + `/preview/${this.document.uuid}/${i}.jpg`
|
||||
url: this.basePreviewUrl + `/preview/${this.document.signed_uuid || this.document.uuid}/${i}.jpg`
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
@drop.prevent="onDropFiles"
|
||||
>
|
||||
<label
|
||||
class="w-full relative bg-base-200 hover:bg-base-200/70 rounded-md border border-base-content border-dashed"
|
||||
class="w-full relative hover:bg-base-200/30 rounded-md border border-2 border-base-content/10 border-dashed"
|
||||
:for="inputId"
|
||||
:class="{ 'opacity-50': isLoading || isProcessing }"
|
||||
>
|
||||
@@ -71,11 +71,6 @@ export default {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'image/*, application/pdf'
|
||||
},
|
||||
isDirectUpload: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['success'],
|
||||
@@ -101,11 +96,6 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.isDirectUpload) {
|
||||
import('@rails/activestorage')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
upload: Upload.methods.upload,
|
||||
onDropFiles (e) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="group pb-2"
|
||||
class="list-field group mb-2"
|
||||
>
|
||||
<div
|
||||
class="border border-base-300 rounded rounded-tr-none relative group"
|
||||
@@ -56,7 +56,7 @@
|
||||
>
|
||||
<button
|
||||
v-if="field && !field.areas.length"
|
||||
title="Draw"
|
||||
:title="t('draw')"
|
||||
class="relative cursor-pointer text-transparent group-hover:text-base-content"
|
||||
@click="$emit('set-draw', { field })"
|
||||
>
|
||||
@@ -65,6 +65,28 @@
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="field.preferences?.formula"
|
||||
class="relative cursor-pointer text-transparent group-hover:text-base-content"
|
||||
:title="t('formula')"
|
||||
@click="isShowFormulaModal = true"
|
||||
>
|
||||
<IconMathFunction
|
||||
:width="18"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="field.conditions?.length"
|
||||
class="relative cursor-pointer text-transparent group-hover:text-base-content"
|
||||
:title="t('condition')"
|
||||
@click="isShowConditionsModal = true"
|
||||
>
|
||||
<IconRouteAltLeft
|
||||
:width="18"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
<PaymentSettings
|
||||
v-if="field.type === 'payment'"
|
||||
:field="field"
|
||||
@@ -77,7 +99,7 @@
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
title="Settings"
|
||||
:title="t('settings')"
|
||||
class="cursor-pointer text-transparent group-hover:text-base-content"
|
||||
>
|
||||
<IconSettings
|
||||
@@ -94,15 +116,41 @@
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<div
|
||||
v-if="field.type === 'text' && !defaultField"
|
||||
v-if="['number'].includes(field.type)"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<select
|
||||
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal"
|
||||
@change="[field.preferences ||= {}, field.preferences.align = $event.target.value, save()]"
|
||||
>
|
||||
<option
|
||||
v-for="value in ['left', 'right', 'center']"
|
||||
:key="value"
|
||||
:selected="field.preferences?.align ? value === field.preferences.align : value === 'left'"
|
||||
:value="value"
|
||||
>
|
||||
{{ t(value) }}
|
||||
</option>
|
||||
</select>
|
||||
<label
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>
|
||||
{{ t('align') }}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="['text', 'number'].includes(field.type) && !defaultField"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
v-model="field.default_value"
|
||||
type="text"
|
||||
:placeholder="t('default_value')"
|
||||
dir="auto"
|
||||
:type="field.type"
|
||||
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
|
||||
@blur="save"
|
||||
>
|
||||
@@ -142,8 +190,45 @@
|
||||
{{ t('format') }}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="field.type === 'signature'"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<select
|
||||
:placeholder="t('format')"
|
||||
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0"
|
||||
@change="[field.preferences.format = $event.target.value, save()]"
|
||||
>
|
||||
<option
|
||||
value="any"
|
||||
:selected="!field.preferences?.format || field.preferences.format === 'any'"
|
||||
>
|
||||
{{ t('any') }}
|
||||
</option>
|
||||
<option
|
||||
value="drawn"
|
||||
:selected="field.preferences?.format === 'drawn'"
|
||||
>
|
||||
{{ t('drawn') }}
|
||||
</option>
|
||||
<option
|
||||
value="typed"
|
||||
:selected="field.preferences?.format === 'typed'"
|
||||
>
|
||||
{{ t('typed') }}
|
||||
</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'"
|
||||
v-if="field.type != 'phone' && field.type != 'stamp'"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
@@ -157,7 +242,49 @@
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type === 'text' && !defaultField"
|
||||
v-if="field.type == 'stamp'"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
<input
|
||||
:checked="field.preferences?.with_logo != false"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
@change="[field.preferences ||= {}, field.preferences.with_logo = field.preferences.with_logo == false, save()]"
|
||||
>
|
||||
<span class="label-text">{{ t('with_logo') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type == 'checkbox'"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
<input
|
||||
v-model="field.default_value"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
@update:model-value="[field.default_value = $event, field.readonly = $event, save()]"
|
||||
>
|
||||
<span class="label-text">{{ t('checked') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type == 'date'"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
<input
|
||||
v-model="field.readonly"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
@update:model-value="[field.default_value = $event ? '{{date}}' : null, field.readonly = $event, save()]"
|
||||
>
|
||||
<span class="label-text">{{ t('set_signing_date') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="['text', 'number'].includes(field.type) && !defaultField"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
@@ -170,9 +297,56 @@
|
||||
<span class="label-text">{{ t('read_only') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<hr
|
||||
v-if="field.type != 'stamp'"
|
||||
class="pb-0.5 mt-0.5"
|
||||
>
|
||||
<li
|
||||
v-if="field.type != 'stamp'"
|
||||
>
|
||||
<label
|
||||
class="label-text cursor-pointer text-center w-full flex items-center"
|
||||
@click="isShowDescriptionModal = !isShowDescriptionModal"
|
||||
>
|
||||
<IconInfoCircle
|
||||
width="18"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{{ t('description') }}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type != 'stamp'"
|
||||
>
|
||||
<label
|
||||
class="label-text cursor-pointer text-center w-full flex items-center"
|
||||
@click="isShowConditionsModal = !isShowConditionsModal"
|
||||
>
|
||||
<IconRouteAltLeft
|
||||
width="18"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{{ t('condition') }}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
<li v-if="field.type == 'number'">
|
||||
<label
|
||||
class="label-text cursor-pointer text-center w-full flex items-center"
|
||||
@click="isShowFormulaModal = true"
|
||||
>
|
||||
<IconMathFunction
|
||||
width="18"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{{ t('formula') }}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
<hr class="pb-0.5 mt-0.5">
|
||||
<li
|
||||
v-for="(area, index) in field.areas || []"
|
||||
v-for="(area, index) in sortedAreas"
|
||||
:key="index"
|
||||
>
|
||||
<a
|
||||
@@ -184,7 +358,8 @@
|
||||
:width="20"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
{{ t('page') }} {{ area.page + 1 }}
|
||||
{{ t('page') }}
|
||||
<template v-if="template.schema.length > 1">{{ template.schema.findIndex((item) => item.attachment_uuid === area.attachment_uuid) + 1 }}-</template>{{ area.page + 1 }}
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)">
|
||||
@@ -217,7 +392,7 @@
|
||||
</span>
|
||||
<button
|
||||
class="relative text-transparent group-hover:text-base-content pr-1"
|
||||
title="Remove"
|
||||
:title="t('remove')"
|
||||
@click="$emit('remove', field)"
|
||||
>
|
||||
<IconTrashX
|
||||
@@ -243,7 +418,7 @@
|
||||
{{ 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)"
|
||||
v-if="editable && ['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
|
||||
@@ -256,7 +431,7 @@
|
||||
@blur="save"
|
||||
>
|
||||
<button
|
||||
title="Draw"
|
||||
:title="t('draw')"
|
||||
tabindex="-1"
|
||||
@click.prevent="$emit('set-draw', { field, option })"
|
||||
>
|
||||
@@ -272,12 +447,14 @@
|
||||
class="w-full input input-primary input-xs text-sm bg-transparent"
|
||||
:placeholder="`${t('option')} ${index + 1}`"
|
||||
type="text"
|
||||
:readonly="!editable"
|
||||
required
|
||||
dir="auto"
|
||||
@focus="maybeFocusOnOptionArea(option)"
|
||||
@blur="save"
|
||||
>
|
||||
<button
|
||||
v-if="editable"
|
||||
class="text-sm w-3.5"
|
||||
tabindex="-1"
|
||||
@click="removeOption(option)"
|
||||
@@ -285,8 +462,12 @@
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="field.options && !editable"
|
||||
class="pb-1"
|
||||
/>
|
||||
<button
|
||||
v-if="field.options"
|
||||
v-else-if="field.options && editable"
|
||||
class="text-center text-sm w-full pb-1"
|
||||
@click="addOption"
|
||||
>
|
||||
@@ -294,6 +475,36 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport
|
||||
v-if="isShowFormulaModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<FormulaModal
|
||||
:field="field"
|
||||
:build-default-name="buildDefaultName"
|
||||
@close="isShowFormulaModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowConditionsModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<ConditionsModal
|
||||
:field="field"
|
||||
:build-default-name="buildDefaultName"
|
||||
@close="isShowConditionsModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowDescriptionModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<DescriptionModal
|
||||
:field="field"
|
||||
:build-default-name="buildDefaultName"
|
||||
@close="isShowDescriptionModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -301,7 +512,10 @@
|
||||
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 FormulaModal from './formula_modal'
|
||||
import ConditionsModal from './conditions_modal'
|
||||
import DescriptionModal from './description_modal'
|
||||
import { IconInfoCircle, IconRouteAltLeft, IconMathFunction, IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
export default {
|
||||
@@ -312,7 +526,13 @@ export default {
|
||||
IconShape,
|
||||
PaymentSettings,
|
||||
IconNewSection,
|
||||
IconInfoCircle,
|
||||
FormulaModal,
|
||||
DescriptionModal,
|
||||
ConditionsModal,
|
||||
IconRouteAltLeft,
|
||||
IconTrashX,
|
||||
IconMathFunction,
|
||||
IconCopy,
|
||||
FieldType
|
||||
},
|
||||
@@ -338,13 +558,31 @@ export default {
|
||||
return {
|
||||
isNameFocus: false,
|
||||
showPaymentModal: false,
|
||||
isShowFormulaModal: false,
|
||||
isShowConditionsModal: false,
|
||||
isShowDescriptionModal: false,
|
||||
renderDropdown: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
schemaAttachmentsIndexes () {
|
||||
return (this.template.schema || []).reduce((acc, item, index) => {
|
||||
acc[item.attachment_uuid] = index
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
sortedAreas () {
|
||||
return (this.field.areas || []).sort((a, b) => {
|
||||
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
|
||||
})
|
||||
},
|
||||
modalContainerEl () {
|
||||
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
|
||||
},
|
||||
dateFormats () {
|
||||
return [
|
||||
const formats = [
|
||||
'MM/DD/YYYY',
|
||||
'DD/MM/YYYY',
|
||||
'YYYY-MM-DD',
|
||||
@@ -355,24 +593,19 @@ export default {
|
||||
'D MMM YYYY',
|
||||
'D MMMM YYYY'
|
||||
]
|
||||
|
||||
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) {
|
||||
formats.push('YYYY년 MM월 DD일')
|
||||
}
|
||||
|
||||
if (this.field.preferences?.format && !formats.includes(this.field.preferences.format)) {
|
||||
formats.unshift(this.field.preferences.format)
|
||||
}
|
||||
|
||||
return formats
|
||||
},
|
||||
defaultName () {
|
||||
if (this.field.type === 'payment' && this.field.preferences?.price) {
|
||||
const { price, currency } = this.field.preferences || {}
|
||||
|
||||
const formattedPrice = new Intl.NumberFormat([], {
|
||||
style: 'currency',
|
||||
currency
|
||||
}).format(price)
|
||||
|
||||
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}`
|
||||
}
|
||||
return this.buildDefaultName(this.field, this.template.fields)
|
||||
},
|
||||
areas () {
|
||||
return this.field.areas || []
|
||||
@@ -387,6 +620,24 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
buildDefaultName (field, fields) {
|
||||
if (field.type === 'payment' && field.preferences?.price) {
|
||||
const { price, currency } = field.preferences || {}
|
||||
|
||||
const formattedPrice = new Intl.NumberFormat([], {
|
||||
style: 'currency',
|
||||
currency
|
||||
}).format(price)
|
||||
|
||||
return `${this.fieldNames[field.type]} ${formattedPrice}`
|
||||
} else {
|
||||
const typeIndex = fields.filter((f) => f.type === field.type).indexOf(field)
|
||||
|
||||
const suffix = { multiple: this.t('select'), radio: this.t('group') }[field.type] || this.t('field')
|
||||
|
||||
return `${this.fieldNames[field.type]} ${suffix} ${typeIndex + 1}`
|
||||
}
|
||||
},
|
||||
formatDate (date, format) {
|
||||
const monthFormats = {
|
||||
M: 'numeric',
|
||||
@@ -420,11 +671,13 @@ export default {
|
||||
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), attachment_uuid: attachment.uuid, page: parseInt(page.filename) })
|
||||
const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length
|
||||
|
||||
for (let page = 0; page <= numberOfPages - 1; page++) {
|
||||
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === page)) {
|
||||
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
this.$nextTick(() => {
|
||||
@@ -450,7 +703,7 @@ export default {
|
||||
}
|
||||
},
|
||||
scrollToFirstArea () {
|
||||
return this.field.areas?.[0] && this.$emit('scroll-to', this.field.areas[0])
|
||||
return this.sortedAreas[0] && this.$emit('scroll-to', this.sortedAreas[0])
|
||||
},
|
||||
closeDropdown () {
|
||||
document.activeElement.blur()
|
||||
@@ -468,7 +721,12 @@ export default {
|
||||
},
|
||||
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)
|
||||
|
||||
const optionIndex = this.field.areas.findIndex((a) => a.option_uuid === option.uuid)
|
||||
|
||||
if (optionIndex !== -1) {
|
||||
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
|
||||
}
|
||||
|
||||
this.save()
|
||||
},
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
</button>
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="submitters.length < 10 && editable">
|
||||
<li v-if="submitters.length < names.length && editable">
|
||||
<a
|
||||
href="#"
|
||||
class="flex px-2"
|
||||
@@ -95,7 +95,7 @@
|
||||
<label
|
||||
v-if="compact"
|
||||
tabindex="0"
|
||||
:title="selectedSubmitter.name"
|
||||
:title="selectedSubmitter?.name"
|
||||
class="cursor-pointer text-base-100 flex h-full items-center justify-center"
|
||||
>
|
||||
<button
|
||||
@@ -145,7 +145,7 @@
|
||||
<a
|
||||
href="#"
|
||||
class="flex px-2 group justify-between items-center"
|
||||
:class="{ 'active': submitter === selectedSubmitter }"
|
||||
:class="{ 'active': submitter === selectedSubmitter, 'py-0.5': submitters.length > 8 }"
|
||||
@click.prevent="selectSubmitter(submitter)"
|
||||
>
|
||||
<span class="py-1 flex items-center">
|
||||
@@ -163,7 +163,7 @@
|
||||
>
|
||||
<div class="flex-col pr-1 hidden group-hover:flex -mt-1 h-0">
|
||||
<button
|
||||
title="Up"
|
||||
:title="t('up')"
|
||||
class="relative w-2"
|
||||
style="font-size: 10px; margin-bottom: -4px"
|
||||
@click.prevent.stop="[move(submitter, -1), $refs.label.focus()] "
|
||||
@@ -171,7 +171,7 @@
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
title="Down"
|
||||
:title="t('down')"
|
||||
class="relative w-2"
|
||||
style="font-size: 10px; margin-top: -4px"
|
||||
@click.prevent.stop="[move(submitter, 1), $refs.label.focus()] "
|
||||
@@ -189,7 +189,7 @@
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="submitters.length < 10 && editable && allowAddNew">
|
||||
<li v-if="submitters.length < names.length && editable && allowAddNew">
|
||||
<a
|
||||
href="#"
|
||||
class="flex px-2"
|
||||
@@ -268,6 +268,16 @@ export default {
|
||||
computed: {
|
||||
colors () {
|
||||
return [
|
||||
'bg-red-500',
|
||||
'bg-sky-500',
|
||||
'bg-emerald-500',
|
||||
'bg-yellow-300',
|
||||
'bg-purple-600',
|
||||
'bg-pink-500',
|
||||
'bg-cyan-500',
|
||||
'bg-orange-500',
|
||||
'bg-lime-500',
|
||||
'bg-indigo-500',
|
||||
'bg-red-500',
|
||||
'bg-sky-500',
|
||||
'bg-emerald-500',
|
||||
@@ -291,7 +301,17 @@ export default {
|
||||
this.t('seventh_party'),
|
||||
this.t('eighth_party'),
|
||||
this.t('ninth_party'),
|
||||
this.t('tenth_party')
|
||||
this.t('tenth_party'),
|
||||
this.t('eleventh_party'),
|
||||
this.t('twelfth_party'),
|
||||
this.t('thirteenth_party'),
|
||||
this.t('fourteenth_party'),
|
||||
this.t('fifteenth_party'),
|
||||
this.t('sixteenth_party'),
|
||||
this.t('seventeenth_party'),
|
||||
this.t('eighteenth_party'),
|
||||
this.t('nineteenth_party'),
|
||||
this.t('twentieth_party')
|
||||
]
|
||||
},
|
||||
selectedSubmitter () {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<template
|
||||
v-for="(icon, type) in fieldIcons"
|
||||
v-for="(icon, type) in fieldIconsSorted"
|
||||
:key="type"
|
||||
>
|
||||
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')">
|
||||
@@ -50,7 +50,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp } from '@tabler/icons-vue'
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1 } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FiledTypeDropdown',
|
||||
@@ -94,6 +94,7 @@ export default {
|
||||
signature: this.t('signature'),
|
||||
initials: this.t('initials'),
|
||||
date: this.t('date'),
|
||||
number: this.t('number'),
|
||||
image: this.t('image'),
|
||||
file: this.t('file'),
|
||||
select: this.t('select'),
|
||||
@@ -112,17 +113,29 @@ export default {
|
||||
signature: IconWritingSign,
|
||||
initials: IconLetterCaseUpper,
|
||||
date: IconCalendarEvent,
|
||||
number: IconSquareNumber1,
|
||||
image: IconPhoto,
|
||||
file: IconPaperclip,
|
||||
checkbox: IconCheckbox,
|
||||
multiple: IconChecks,
|
||||
file: IconPaperclip,
|
||||
radio: IconCircleDot,
|
||||
select: IconSelect,
|
||||
multiple: IconChecks,
|
||||
cells: IconColumns3,
|
||||
stamp: IconRubberStamp,
|
||||
payment: IconCreditCard,
|
||||
phone: IconPhoneCheck
|
||||
}
|
||||
},
|
||||
fieldIconsSorted () {
|
||||
if (this.fieldTypes.length) {
|
||||
return this.fieldTypes.reduce((acc, type) => {
|
||||
acc[type] = this.fieldIcons[type]
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
} else {
|
||||
return this.fieldIcons
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div :class="withStickySubmitters ? 'sticky top-0 z-10' : ''">
|
||||
<FieldSubmitter
|
||||
:model-value="selectedSubmitter.uuid"
|
||||
class="w-full rounded-lg"
|
||||
class="roles-dropdown w-full rounded-lg"
|
||||
:class="{ 'bg-base-100': withStickySubmitters }"
|
||||
:submitters="submitters"
|
||||
:editable="editable && !defaultSubmitters.length"
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
<div
|
||||
ref="fields"
|
||||
class="mb-1 mt-2"
|
||||
class="fields mb-1 mt-2"
|
||||
@dragover.prevent="onFieldDragover"
|
||||
@drop="reorderFields"
|
||||
>
|
||||
@@ -24,11 +24,11 @@
|
||||
:data-uuid="field.uuid"
|
||||
:field="field"
|
||||
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
|
||||
:editable="editable && (!dragField || dragField !== field)"
|
||||
:editable="editable && (!fieldsDragFieldRef.value || fieldsDragFieldRef.value !== field)"
|
||||
:default-field="defaultFields.find((f) => f.name === field.name)"
|
||||
:draggable="editable"
|
||||
@dragstart="dragField = field"
|
||||
@dragend="dragField = null"
|
||||
@dragstart="fieldsDragFieldRef.value = field"
|
||||
@dragend="fieldsDragFieldRef.value = null"
|
||||
@remove="removeField"
|
||||
@scroll-to="$emit('scroll-to-area', $event)"
|
||||
@set-draw="$emit('set-draw', $event)"
|
||||
@@ -36,52 +36,87 @@
|
||||
</div>
|
||||
<div v-if="submitterDefaultFields.length">
|
||||
<hr class="mb-2">
|
||||
<template
|
||||
v-for="field in submitterDefaultFields"
|
||||
:key="field.name"
|
||||
<template v-if="isShowFieldSearch">
|
||||
<input
|
||||
v-model="defaultFieldsSearch"
|
||||
:placeholder="t('search_field')"
|
||||
class="input input-ghost input-xs px-0 text-base mb-2 !outline-0 !rounded bg-transparent w-full"
|
||||
>
|
||||
<hr class="mb-2">
|
||||
</template>
|
||||
<div
|
||||
class="overflow-auto relative"
|
||||
:style="{
|
||||
maxHeight: isShowFieldSearch ? '210px' : '',
|
||||
minHeight: isShowFieldSearch ? '210px' : ''
|
||||
}"
|
||||
>
|
||||
<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')"
|
||||
v-if="!filteredSubmitterDefaultFields.length && defaultFieldsSearch"
|
||||
class="top-0 bottom-0 text-center absolute flex items-center justify-center w-full flex-col"
|
||||
>
|
||||
<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>
|
||||
{{ t('field_not_found') }}
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
class="link"
|
||||
@click.prevent="defaultFieldsSearch = ''"
|
||||
>
|
||||
{{ t('clear') }}
|
||||
</a>
|
||||
</div>
|
||||
<template
|
||||
v-for="field in filteredSubmitterDefaultFields"
|
||||
:key="field.name"
|
||||
>
|
||||
<div
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
draggable="true"
|
||||
class="default-field 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>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="editable && !onlyDefinedFields"
|
||||
class="grid grid-cols-3 gap-1 pb-2"
|
||||
>
|
||||
<template
|
||||
v-for="(icon, type) in fieldIcons"
|
||||
v-for="(icon, type) in fieldIconsSorted"
|
||||
:key="type"
|
||||
>
|
||||
<button
|
||||
v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')"
|
||||
draggable="true"
|
||||
class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
|
||||
class="field-type-button group flex items-center justify-center border border-dashed w-full rounded relative"
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
:class="drawFieldType === type ? 'border-base-content/40' : 'border-base-300 hover:border-base-content/20'"
|
||||
@dragstart="onDragstart({ type: type })"
|
||||
@dragend="$emit('drag-end')"
|
||||
@click="addField(type)"
|
||||
@click="['file', 'payment'].includes(type) ? $emit('add-field', type) : $emit('set-draw-type', type)"
|
||||
>
|
||||
<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
|
||||
class="flex items-console transition-all cursor-grab h-full absolute left-0"
|
||||
:class="drawFieldType === type ? 'bg-base-200/50' : 'group-hover:bg-base-200/50'"
|
||||
>
|
||||
<IconDrag class="my-auto" />
|
||||
</div>
|
||||
<div class="flex items-center flex-col px-2 py-2">
|
||||
<component :is="icon" />
|
||||
@@ -93,7 +128,7 @@
|
||||
<div
|
||||
v-else-if="type == 'phone' && (fieldTypes.length === 0 || fieldTypes.includes(type))"
|
||||
class="tooltip tooltip-bottom flex"
|
||||
:class="{'tooltip-bottom-start': !withPayment, 'tooltip-bottom': withPayment }"
|
||||
:class="{'tooltip-bottom-end': withPayment, 'tooltip-bottom': !withPayment }"
|
||||
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
|
||||
>
|
||||
<a
|
||||
@@ -139,10 +174,9 @@
|
||||
|
||||
<script>
|
||||
import Field from './field'
|
||||
import { v4 } from 'uuid'
|
||||
import FieldType from './field_type'
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import { IconLock } from '@tabler/icons-vue'
|
||||
import { IconLock, IconCirclePlus } from '@tabler/icons-vue'
|
||||
import IconDrag from './icon_drag'
|
||||
|
||||
export default {
|
||||
@@ -150,11 +184,12 @@ export default {
|
||||
components: {
|
||||
Field,
|
||||
FieldType,
|
||||
IconCirclePlus,
|
||||
FieldSubmitter,
|
||||
IconDrag,
|
||||
IconLock
|
||||
},
|
||||
inject: ['save', 'backgroundColor', 'withPhone', 'withPayment', 't'],
|
||||
inject: ['save', 'backgroundColor', 'withPhone', 'withPayment', 't', 'fieldsDragFieldRef'],
|
||||
props: {
|
||||
fields: {
|
||||
type: Array,
|
||||
@@ -180,6 +215,11 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
drawFieldType: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
defaultSubmitters: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -204,15 +244,29 @@ export default {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['set-draw', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'],
|
||||
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'],
|
||||
data () {
|
||||
return {
|
||||
dragField: null
|
||||
defaultFieldsSearch: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldIcons: FieldType.computed.fieldIcons,
|
||||
isShowFieldSearch () {
|
||||
return this.submitterDefaultFields.length > 15
|
||||
},
|
||||
fieldIconsSorted () {
|
||||
if (this.fieldTypes.length) {
|
||||
return this.fieldTypes.reduce((acc, type) => {
|
||||
acc[type] = this.fieldIcons[type]
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
} else {
|
||||
return this.fieldIcons
|
||||
}
|
||||
},
|
||||
submitterFields () {
|
||||
return this.fields.filter((f) => f.submitter_uuid === this.selectedSubmitter.uuid)
|
||||
},
|
||||
@@ -220,6 +274,13 @@ export default {
|
||||
return this.defaultFields.filter((f) => {
|
||||
return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name)
|
||||
})
|
||||
},
|
||||
filteredSubmitterDefaultFields () {
|
||||
if (this.defaultFieldsSearch) {
|
||||
return this.submitterDefaultFields.filter((f) => f.name.toLowerCase().includes(this.defaultFieldsSearch.toLowerCase()))
|
||||
} else {
|
||||
return this.submitterDefaultFields
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -228,7 +289,7 @@ export default {
|
||||
},
|
||||
onFieldDragover (e) {
|
||||
const targetField = e.target.closest('[data-uuid]')
|
||||
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.dragField.uuid}"]`)
|
||||
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.fieldsDragFieldRef.value.uuid}"]`)
|
||||
|
||||
if (dragField && targetField && targetField !== dragField) {
|
||||
const fields = Array.from(this.$refs.fields.children)
|
||||
@@ -272,37 +333,13 @@ export default {
|
||||
removeField (field) {
|
||||
this.fields.splice(this.fields.indexOf(field), 1)
|
||||
|
||||
this.save()
|
||||
},
|
||||
addField (type, area = null) {
|
||||
const field = {
|
||||
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.fields.push(field)
|
||||
|
||||
if (!['payment', 'file'].includes(type)) {
|
||||
this.$emit('set-draw', { field })
|
||||
}
|
||||
this.fields.forEach((f) => {
|
||||
(f.conditions || []).forEach((c) => {
|
||||
if (c.field_uuid === field.uuid) {
|
||||
f.conditions.splice(f.conditions.indexOf(c), 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.save()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div
|
||||
class="modal modal-open items-start !animate-none overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<span>
|
||||
{{ t('formula') }} - {{ field.name || buildDefaultName(field, template.fields) }}
|
||||
</span>
|
||||
<a
|
||||
href="#"
|
||||
class="text-xl"
|
||||
@click.prevent="$emit('close')"
|
||||
>×</a>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-if="!withFormula"
|
||||
class="bg-base-300 rounded-xl py-2 px-3 text-center"
|
||||
>
|
||||
<a
|
||||
href="https://www.docuseal.co/pricing"
|
||||
target="_blank"
|
||||
class="link"
|
||||
>Available in Pro</a>
|
||||
</div>
|
||||
<div class="flex-inline mb-2 gap-2 space-y-1">
|
||||
<button
|
||||
v-for="f in fields"
|
||||
:key="f.uuid"
|
||||
class="mr-1 btn btn-neutral btn-outline border-base-content/20 btn-sm normal-case font-normal bg-white !rounded-xl"
|
||||
@click.prevent="insertTextUnderCursor(`{{${f.name || buildDefaultName(f, template.fields)}}}`)"
|
||||
>
|
||||
<IconCodePlus
|
||||
width="20"
|
||||
height="20"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
{{ f.name || buildDefaultName(f, template.fields) }}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex">
|
||||
<textarea
|
||||
ref="textarea"
|
||||
v-model="formula"
|
||||
class="base-textarea !rounded-xl !text-base font-mono w-full !outline-0 !ring-0 !px-3"
|
||||
required="true"
|
||||
@input="resizeTextarea"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3 mt-1">
|
||||
<div
|
||||
target="blank"
|
||||
class="text-sm mb-2 inline space-x-2"
|
||||
>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor(' + ')"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor(' - ')"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor(' * ')"
|
||||
>
|
||||
*
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor(' / ')"
|
||||
>
|
||||
/
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor(' % ')"
|
||||
>
|
||||
%
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor('^')"
|
||||
>
|
||||
^
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor('round()')"
|
||||
>
|
||||
round(n, d)
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor('abs()')"
|
||||
>
|
||||
abs(n)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="base-button w-full"
|
||||
@click.prevent="validateSaveAndClose"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconCodePlus } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FormulaModal',
|
||||
components: {
|
||||
IconCodePlus
|
||||
},
|
||||
inject: ['t', 'save', 'template', 'withFormula'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
buildDefaultName: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['close'],
|
||||
data () {
|
||||
return {
|
||||
formula: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fields () {
|
||||
return this.template.fields.reduce((acc, f) => {
|
||||
if (f !== this.field && f.submitter_uuid === this.field.submitter_uuid && ['number'].includes(f.type) && !f.preferences?.formula) {
|
||||
acc.push(f)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.field.preferences ||= {}
|
||||
},
|
||||
mounted () {
|
||||
this.formula = this.humanizeFormula(this.field.preferences.formula || '')
|
||||
},
|
||||
methods: {
|
||||
humanizeFormula (text) {
|
||||
return text.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
const foundField = this.fields.find((f) => f.uuid === uuid)
|
||||
|
||||
if (foundField) {
|
||||
return `{{${foundField.name || this.buildDefaultName(foundField, this.template.fields)}}}`
|
||||
} else {
|
||||
return '{{FIELD NOT FOUND}}'
|
||||
}
|
||||
})
|
||||
},
|
||||
normalizeFormula (text) {
|
||||
return text.replace(/{{(.*?)}}/g, (match, name) => {
|
||||
const foundField = this.fields.find((f) => {
|
||||
return (f.name || this.buildDefaultName(f, this.template.fields)).trim() === name.trim()
|
||||
})
|
||||
|
||||
if (foundField) {
|
||||
return `{{${foundField.uuid}}}`
|
||||
} else {
|
||||
return '{{FIELD NOT FOUND}}'
|
||||
}
|
||||
})
|
||||
},
|
||||
validateSaveAndClose () {
|
||||
if (!this.withFormula) {
|
||||
return alert('Available only in Pro')
|
||||
}
|
||||
|
||||
const normalizedFormula = this.normalizeFormula(this.formula)
|
||||
|
||||
if (normalizedFormula.includes('FIELD NOT FOUND')) {
|
||||
alert('Some fields are missing in the formula.')
|
||||
} else {
|
||||
this.field.preferences.formula = normalizedFormula
|
||||
this.field.readonly = !!normalizedFormula
|
||||
|
||||
this.save()
|
||||
|
||||
this.$emit('close')
|
||||
}
|
||||
},
|
||||
insertTextUnderCursor (textToInsert) {
|
||||
const textarea = this.$refs.textarea
|
||||
|
||||
const selectionEnd = textarea.selectionEnd
|
||||
const cursorPos = selectionEnd
|
||||
|
||||
const newText = textarea.value.substring(0, cursorPos) + textToInsert + textarea.value.substring(cursorPos)
|
||||
|
||||
this.formula = newText
|
||||
|
||||
textarea.setSelectionRange(cursorPos + textToInsert.length, cursorPos + textToInsert.length)
|
||||
|
||||
textarea.focus()
|
||||
},
|
||||
resizeTextarea () {
|
||||
const textarea = this.$refs.textarea
|
||||
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = textarea.scrollHeight + 'px'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,40 @@
|
||||
const en = {
|
||||
search_field: 'Search field',
|
||||
field_not_found: 'Field not found',
|
||||
clear: 'Clear',
|
||||
align: 'Align',
|
||||
left: 'Left',
|
||||
right: 'Right',
|
||||
center: 'Center',
|
||||
description: 'Description',
|
||||
display_title: 'Display title',
|
||||
with_logo: 'With logo',
|
||||
unchecked: 'Unchecked',
|
||||
equal: 'Equal',
|
||||
not_equal: 'Not equal',
|
||||
contains: 'Contains',
|
||||
does_not_contain: 'Does not contain',
|
||||
not_empty: 'Not empty',
|
||||
empty: 'Empty',
|
||||
select_field_: 'Select field...',
|
||||
select_value_: 'Select value...',
|
||||
remove_condition: 'Remove condition',
|
||||
are_you_sure: 'Are you sure?',
|
||||
sign_yourself: 'Sign Yourself',
|
||||
set_signing_date: 'Set signing date',
|
||||
send: 'Send',
|
||||
remove: 'Remove',
|
||||
edit: 'Edit',
|
||||
settings: 'Settings',
|
||||
up: 'Up',
|
||||
down: 'Down',
|
||||
checked: 'Checked',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
any: 'Any',
|
||||
drawn: 'Drawn',
|
||||
formula: 'Formula',
|
||||
typed: 'Typed',
|
||||
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',
|
||||
@@ -20,6 +51,7 @@ const en = {
|
||||
copy_to_all_pages: 'Copy to All Pages',
|
||||
add_option: 'Add option',
|
||||
option: 'Option',
|
||||
condition: 'Condition',
|
||||
first_party: 'First Party',
|
||||
second_party: 'Second Party',
|
||||
third_party: 'Third Party',
|
||||
@@ -30,9 +62,21 @@ const en = {
|
||||
eighth_party: 'Eighth Party',
|
||||
ninth_party: 'Ninth Party',
|
||||
tenth_party: 'Tenth Party',
|
||||
eleventh_party: 'Eleventh Party',
|
||||
twelfth_party: 'Twelfth Party',
|
||||
thirteenth_party: 'Thirteenth Party',
|
||||
fourteenth_party: 'Fourteenth Party',
|
||||
fifteenth_party: 'Fifteenth Party',
|
||||
sixteenth_party: 'Sixteenth Party',
|
||||
seventeenth_party: 'Seventeenth Party',
|
||||
eighteenth_party: 'Eighteenth Party',
|
||||
nineteenth_party: 'Nineteenth Party',
|
||||
twentieth_party: 'Twentieth Party',
|
||||
draw: 'Draw',
|
||||
add: 'Add',
|
||||
or_add_field_without_drawing: 'Or add field without drawing',
|
||||
text: 'Text',
|
||||
number: 'Number',
|
||||
signature: 'Signature',
|
||||
initials: 'Initials',
|
||||
date: 'Date',
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="selectedSheetIndex === null && spreadsheet">
|
||||
<form @submit.prevent="[selectedSheetIndex = $refs.selectWorksheet.value, buildDefaultMappings()]">
|
||||
<label class="label">
|
||||
Select Worksheet
|
||||
</label>
|
||||
<select
|
||||
ref="selectWorksheet"
|
||||
class="base-select"
|
||||
>
|
||||
<option
|
||||
v-for="(sheet, index) in spreadsheet"
|
||||
:key="index"
|
||||
:value="index"
|
||||
>
|
||||
{{ sheet[0] || index }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="base-button mt-4 w-full">
|
||||
Open
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else-if="selectedSheetIndex !== null">
|
||||
<div
|
||||
v-for="submitter in submitters"
|
||||
:key="submitter.uuid"
|
||||
class="mb-4"
|
||||
>
|
||||
<div
|
||||
v-if="submitters.length > 1"
|
||||
class="px-3 border-y py-2 border-base-300 text-center w-full"
|
||||
>
|
||||
{{ submitter.name }}
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="relative w-full py-2 px-2 text-sm">
|
||||
Recipient field
|
||||
</div>
|
||||
<div class="relative w-full py-2 pl-4 text-sm">
|
||||
Spreadsheet column
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="mapping in mappings.filter((m) => m.submitter_uuid === submitter.uuid)"
|
||||
:key="mapping.uuid"
|
||||
class="mb-2"
|
||||
>
|
||||
<div class="flex">
|
||||
<select
|
||||
class="base-select !select-sm !h-10"
|
||||
required
|
||||
@change="mapping.field_name = $event.target.value"
|
||||
>
|
||||
<option
|
||||
disabled
|
||||
value=""
|
||||
:selected="!mapping.field_name"
|
||||
>
|
||||
Select Field
|
||||
</option>
|
||||
<option
|
||||
v-for="(field, index) in selectFieldsForSubmitter(submitter)"
|
||||
:key="index"
|
||||
:value="field.name"
|
||||
:selected="mapping.field_name === field.name"
|
||||
>
|
||||
{{ field.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="flex items-center px-1">
|
||||
<IconArrowsHorizontal style="width: 19px; height: 19px" />
|
||||
</div>
|
||||
<div class="w-full relative">
|
||||
<select
|
||||
class="base-select !select-sm !h-10"
|
||||
required
|
||||
@change="mapping.column_index = parseInt($event.target.value)"
|
||||
>
|
||||
<option
|
||||
disabled
|
||||
value=""
|
||||
:selected="mapping.column_index == null"
|
||||
>
|
||||
Select Column
|
||||
</option>
|
||||
<template
|
||||
v-for="(column, index) in columns"
|
||||
:key="index"
|
||||
>
|
||||
<option
|
||||
v-if="column"
|
||||
:value="index"
|
||||
:selected="index === mapping.column_index"
|
||||
>
|
||||
{{ column }}
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
<div
|
||||
v-if="mapping.column_index != null"
|
||||
class="absolute top-0 bottom-0 right-1 flex items-center"
|
||||
>
|
||||
<span
|
||||
class="tooltip tooltip-bottom-end pr-1 tooltip-pre"
|
||||
style="padding-top: 2px"
|
||||
:data-tip="[0, 1, 2].map((i) => rows[i]?.[mapping.column_index] ?? '---').join('\n')"
|
||||
>
|
||||
<button
|
||||
class="btn btn-xs btn-circle bg-white border-0 border-gray-300"
|
||||
@click.prevent
|
||||
>
|
||||
<IconInfoCircle class="h-4 w-4" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center pl-1">
|
||||
<span
|
||||
class="tooltip tooltip-top"
|
||||
data-tip="Remove"
|
||||
>
|
||||
<button
|
||||
:disabled="mappings.filter((m) => m.submitter_uuid === submitter.uuid).length < 2"
|
||||
class="btn btn-xs btn-circle"
|
||||
@click.prevent="mappings.splice(mappings.indexOf(mapping), 1)"
|
||||
>
|
||||
<IconX class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-sm btn-primary w-full !normal-case font-medium"
|
||||
@click.prevent="addMapping(submitter)"
|
||||
>
|
||||
<IconPlus class="w-4 h-4" />
|
||||
New Field Mapping
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
name="submissions_json"
|
||||
hidden
|
||||
:value="JSON.stringify(submissionsData.slice(0, 1100))"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="px-3 border-y py-2 border-base-300 text-center w-full text-sm font-semibold"
|
||||
>
|
||||
Total entries: {{ submissionsData.length }}
|
||||
<template v-if="submissionsData.length >= 1000">
|
||||
/ 1000
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
>
|
||||
<div
|
||||
class="flex h-52 w-full"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onDropFiles"
|
||||
>
|
||||
<label
|
||||
class="w-full relative bg-base-200/20 hover:bg-base-200/30 rounded-md border border-2 border-base-content/10 border-dashed"
|
||||
for="import_list_file"
|
||||
:class="{ 'opacity-50': isLoading }"
|
||||
>
|
||||
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<IconInnerShadowTop
|
||||
v-if="isLoading"
|
||||
class="animate-spin"
|
||||
:width="40"
|
||||
:height="40"
|
||||
/>
|
||||
<IconCloudUpload
|
||||
v-else
|
||||
:width="40"
|
||||
:height="40"
|
||||
/>
|
||||
<div
|
||||
class="font-medium text-lg mb-1"
|
||||
>
|
||||
Upload CSV or XLSX Spreadsheet
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span class="font-medium">Click to Upload</span> or drag and drop files.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
ref="form"
|
||||
class="hidden"
|
||||
>
|
||||
<input
|
||||
id="import_list_file"
|
||||
ref="input"
|
||||
type="file"
|
||||
name="file"
|
||||
accept=".xlsx, .xls, .csv"
|
||||
@change="onSelectFile"
|
||||
>
|
||||
</form>
|
||||
</label>
|
||||
</div>
|
||||
<div class="text-center mt-2">
|
||||
Or <a
|
||||
:download="`${template.name}.csv`"
|
||||
:href="`data:text/csv;base64,${csvBase64}`"
|
||||
class="link font-medium"
|
||||
>download</a> a spreadsheet to fill and import
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconCloudUpload, IconX, IconPlus, IconArrowsHorizontal, IconInfoCircle, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
export default {
|
||||
name: 'FileDropzone',
|
||||
components: {
|
||||
IconCloudUpload,
|
||||
IconX,
|
||||
IconArrowsHorizontal,
|
||||
IconPlus,
|
||||
IconInfoCircle,
|
||||
IconInnerShadowTop
|
||||
},
|
||||
props: {
|
||||
template: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
authenticityToken: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
spreadsheet: null,
|
||||
selectedSheetIndex: null,
|
||||
mappings: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
table () {
|
||||
return this.spreadsheet[this.selectedSheetIndex][1]
|
||||
},
|
||||
submissionsData () {
|
||||
const submissions = []
|
||||
|
||||
this.rows.forEach((row) => {
|
||||
const submittersIndex = {}
|
||||
|
||||
this.mappings.forEach((mapping) => {
|
||||
if (mapping.field_name && mapping.column_index != null) {
|
||||
submittersIndex[mapping.submitter_uuid] ||= { uuid: mapping.submitter_uuid, fields: [] }
|
||||
|
||||
if (['name', 'email', 'phone'].includes(mapping.field_name.toLowerCase())) {
|
||||
submittersIndex[mapping.submitter_uuid][mapping.field_name.toLowerCase()] = row[mapping.column_index]
|
||||
} else {
|
||||
submittersIndex[mapping.submitter_uuid].fields.push({
|
||||
name: mapping.field_name, default_value: row[mapping.column_index], readonly: true
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(submittersIndex).length !== 0) {
|
||||
submissions.push({ submitters: Object.values(submittersIndex) })
|
||||
}
|
||||
})
|
||||
|
||||
return submissions
|
||||
},
|
||||
csvBase64 () {
|
||||
const rows = []
|
||||
|
||||
this.submitters.forEach((submitter) => {
|
||||
this.selectFieldsForSubmitter(submitter).forEach((field) => {
|
||||
rows.push(this.submitters.length > 1 ? `${submitter.name} - ${field.name}` : field.name)
|
||||
})
|
||||
})
|
||||
|
||||
const csv = rows.map(item => {
|
||||
if (/[",\n]/.test(item)) {
|
||||
return `"${item.replace(/"/g, '""')}"`
|
||||
} else {
|
||||
return item
|
||||
}
|
||||
}).join(',')
|
||||
|
||||
return window.btoa(window.unescape(window.encodeURIComponent(csv + '\n' + rows.map(() => '').join(',') + '\n')))
|
||||
},
|
||||
submitters () {
|
||||
return this.template.submitters
|
||||
},
|
||||
columns () {
|
||||
return this.table[0]
|
||||
},
|
||||
form () {
|
||||
return this.$el.closest('form')
|
||||
},
|
||||
fieldTypes () {
|
||||
return ['text', 'cells', 'date', 'number', 'radio', 'select', 'checkbox']
|
||||
},
|
||||
defaultFields () {
|
||||
return [
|
||||
{ name: 'Name' },
|
||||
{ name: 'Email' },
|
||||
{ name: 'Phone' }
|
||||
]
|
||||
},
|
||||
rows () {
|
||||
return this.table.slice(1)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectedSheetIndex (value) {
|
||||
if (value !== null) {
|
||||
document.getElementById('list_form_buttons')?.classList?.remove('hidden')
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onDropFiles (e) {
|
||||
this.uploadFile(e.dataTransfer.files[0])
|
||||
},
|
||||
onSelectFile (e) {
|
||||
this.uploadFile(e.target.files[0])
|
||||
},
|
||||
addMapping (submitter) {
|
||||
this.mappings.push({ uuid: v4(), field_name: '', column_index: null, submitter_uuid: submitter.uuid })
|
||||
},
|
||||
selectFieldsForSubmitter (submitter) {
|
||||
const templateFields = this.template.fields.filter((field) => {
|
||||
return field.submitter_uuid === submitter.uuid &&
|
||||
field.name &&
|
||||
this.fieldTypes.includes(field.type) &&
|
||||
this.defaultFields.every((f) => field.name?.toLowerCase() !== f.name?.toLowerCase())
|
||||
})
|
||||
|
||||
return [...this.defaultFields, ...templateFields]
|
||||
},
|
||||
buildDefaultMappings () {
|
||||
this.submitters.forEach((submitter) => {
|
||||
const fields = this.selectFieldsForSubmitter(submitter)
|
||||
|
||||
fields.forEach((field) => {
|
||||
const columnIndex = this.columns.findIndex((column, index) => {
|
||||
return column &&
|
||||
column.toString().toLowerCase().includes(field.name?.toLowerCase()) &&
|
||||
this.mappings.every((m) => m.column_index !== index)
|
||||
})
|
||||
|
||||
if (columnIndex !== -1) {
|
||||
this.mappings.push({ uuid: v4(), field_name: field.name, column_index: columnIndex, submitter_uuid: submitter.uuid })
|
||||
}
|
||||
})
|
||||
|
||||
if (!this.mappings.some((m) => m.field_name.toLowerCase() === 'name' && m.submitter_uuid === submitter.uuid)) {
|
||||
this.mappings.unshift({ uuid: v4(), field_name: 'Name', submitter_uuid: submitter.uuid })
|
||||
}
|
||||
|
||||
if (!this.mappings.some((m) => m.field_name.toLowerCase() === 'email' && m.submitter_uuid === submitter.uuid)) {
|
||||
this.mappings.unshift({ uuid: v4(), field_name: 'Email', submitter_uuid: submitter.uuid })
|
||||
}
|
||||
})
|
||||
},
|
||||
uploadFile (file) {
|
||||
this.isLoading = true
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('file', file)
|
||||
|
||||
return fetch('/upload_spreadsheet', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-Token': this.authenticityToken
|
||||
}
|
||||
}).then(resp => resp.json()).then((data) => {
|
||||
if (data.error) {
|
||||
return alert(data.error)
|
||||
}
|
||||
|
||||
this.spreadsheet = data
|
||||
|
||||
if (data.length === 1) {
|
||||
this.selectedSheetIndex = 0
|
||||
|
||||
this.buildDefaultMappings()
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -39,7 +39,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile)"
|
||||
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value"
|
||||
id="mask"
|
||||
ref="mask"
|
||||
class="top-0 bottom-0 left-0 right-0 absolute"
|
||||
@@ -61,7 +61,7 @@ export default {
|
||||
components: {
|
||||
FieldArea
|
||||
},
|
||||
inject: ['fieldTypes'],
|
||||
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef'],
|
||||
props: {
|
||||
image: {
|
||||
type: Object,
|
||||
@@ -77,6 +77,11 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
drawFieldType: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
allowDraw: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -123,7 +128,11 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
defaultFieldType () {
|
||||
if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes('text')) {
|
||||
if (this.drawFieldType) {
|
||||
return this.drawFieldType
|
||||
} else if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
|
||||
return this.defaultDrawFieldType
|
||||
} else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes('text')) {
|
||||
return this.fieldTypes[0]
|
||||
} else {
|
||||
return 'text'
|
||||
@@ -160,8 +169,8 @@ export default {
|
||||
},
|
||||
onDrop (e) {
|
||||
this.$emit('drop-field', {
|
||||
x: e.layerX,
|
||||
y: e.layerY,
|
||||
x: e.offsetX,
|
||||
y: e.offsetY,
|
||||
maskW: this.$refs.mask.clientWidth,
|
||||
maskH: this.$refs.mask.clientHeight,
|
||||
page: this.number
|
||||
@@ -184,10 +193,10 @@ export default {
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.newArea = {
|
||||
initialX: e.layerX / this.$refs.mask.clientWidth,
|
||||
initialY: e.layerY / this.$refs.mask.clientHeight,
|
||||
x: e.layerX / this.$refs.mask.clientWidth,
|
||||
y: e.layerY / this.$refs.mask.clientHeight,
|
||||
initialX: e.offsetX / this.$refs.mask.clientWidth,
|
||||
initialY: e.offsetY / this.$refs.mask.clientHeight,
|
||||
x: e.offsetX / this.$refs.mask.clientWidth,
|
||||
y: e.offsetY / this.$refs.mask.clientHeight,
|
||||
w: 0,
|
||||
h: 0
|
||||
}
|
||||
@@ -195,19 +204,19 @@ export default {
|
||||
},
|
||||
onPointermove (e) {
|
||||
if (this.newArea) {
|
||||
const dx = e.layerX / this.$refs.mask.clientWidth - this.newArea.initialX
|
||||
const dy = e.layerY / this.$refs.mask.clientHeight - this.newArea.initialY
|
||||
const dx = e.offsetX / this.$refs.mask.clientWidth - this.newArea.initialX
|
||||
const dy = e.offsetY / this.$refs.mask.clientHeight - this.newArea.initialY
|
||||
|
||||
if (dx > 0) {
|
||||
this.newArea.x = this.newArea.initialX
|
||||
} else {
|
||||
this.newArea.x = e.layerX / this.$refs.mask.clientWidth
|
||||
this.newArea.x = e.offsetX / this.$refs.mask.clientWidth
|
||||
}
|
||||
|
||||
if (dy > 0) {
|
||||
this.newArea.y = this.newArea.initialY
|
||||
} else {
|
||||
this.newArea.y = e.layerY / this.$refs.mask.clientHeight
|
||||
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight
|
||||
}
|
||||
|
||||
if (this.drawField?.type === 'cells') {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
title="Settings"
|
||||
:title="t('settings')"
|
||||
class="cursor-pointer text-transparent group-hover:text-base-content"
|
||||
>
|
||||
<IconSettings
|
||||
@@ -167,7 +167,7 @@ export default {
|
||||
IconInnerShadowTop,
|
||||
IconBrandStripe
|
||||
},
|
||||
inject: ['backgroundColor', 'save', 'currencies'],
|
||||
inject: ['backgroundColor', 'save', 'currencies', 't', 'isPaymentConnected'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -221,6 +221,8 @@ export default {
|
||||
mounted () {
|
||||
this.field.preferences.currency ||= this.defaultCurrency
|
||||
|
||||
isConnected.value ||= this.isPaymentConnected
|
||||
|
||||
if (!this.isConnected) {
|
||||
this.checkStatus()
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
<div class="">
|
||||
<ReplaceButton
|
||||
v-if="withReplaceButton"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:template-id="template.id"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
class="opacity-0 group-hover:opacity-100"
|
||||
@@ -115,11 +114,6 @@ export default {
|
||||
required: true,
|
||||
default: true
|
||||
},
|
||||
isDirectUpload: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
withArrows: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -132,11 +126,6 @@ export default {
|
||||
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))[0]
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.isDirectUpload) {
|
||||
import('@rails/activestorage')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
upload: Upload.methods.upload,
|
||||
onUpdateName (value) {
|
||||
|
||||
@@ -36,11 +36,6 @@ export default {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'image/*, application/pdf'
|
||||
},
|
||||
isDirectUpload: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['success'],
|
||||
@@ -64,11 +59,6 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.isDirectUpload) {
|
||||
import('@rails/activestorage')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
upload: Upload.methods.upload
|
||||
}
|
||||
|
||||
@@ -60,11 +60,6 @@ export default {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'image/*, application/pdf'
|
||||
},
|
||||
isDirectUpload: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['success'],
|
||||
@@ -79,112 +74,43 @@ export default {
|
||||
return 'el' + Math.random().toString(32).split('.')[1]
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.isDirectUpload) {
|
||||
import('@rails/activestorage')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async upload () {
|
||||
this.isLoading = true
|
||||
|
||||
if (this.isDirectUpload) {
|
||||
const { DirectUpload } = await import('@rails/activestorage')
|
||||
this.baseFetch(`/templates/${this.templateId}/documents`, {
|
||||
method: 'POST',
|
||||
body: new FormData(this.$refs.form)
|
||||
}).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)
|
||||
|
||||
const blobs = await Promise.all(
|
||||
Array.from(this.$refs.input.files).map(async (file) => {
|
||||
const upload = new DirectUpload(
|
||||
file,
|
||||
'/direct_uploads',
|
||||
this.$refs.input
|
||||
)
|
||||
formData.append('password', prompt('Enter PDF password'))
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
upload.create((error, blob) => {
|
||||
if (error) {
|
||||
console.error(error)
|
||||
|
||||
return reject(error)
|
||||
this.baseFetch(`/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 {
|
||||
return resolve(blob)
|
||||
alert('Wrong password')
|
||||
}
|
||||
})
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}
|
||||
})
|
||||
).finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
|
||||
this.isProcessing = true
|
||||
|
||||
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ blobs }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).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
|
||||
})
|
||||
} else {
|
||||
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
|
||||
method: 'POST',
|
||||
body: new FormData(this.$refs.form)
|
||||
}).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
|
||||
})
|
||||
}
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ class ProcessSubmitterCompletionJob < ApplicationJob
|
||||
enqueue_completed_emails(submitter)
|
||||
end
|
||||
|
||||
return if Accounts.load_webhook_configs(submitter.account).blank?
|
||||
return if Accounts.load_webhook_url(submitter.account).blank?
|
||||
|
||||
SendFormCompletedWebhookRequestJob.perform_later(submitter)
|
||||
end
|
||||
@@ -26,13 +26,16 @@ class ProcessSubmitterCompletionJob < ApplicationJob
|
||||
|
||||
user = submission.created_by_user || submitter.template.author
|
||||
|
||||
if submitter.template.account.users.exists?(id: user.id) &&
|
||||
submission.preferences['send_email'] != false
|
||||
SubmitterMailer.completed_email(submitter, user).deliver_later!
|
||||
if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false
|
||||
if submission.submitters.map(&:email).exclude?(user.email) &&
|
||||
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false &&
|
||||
user.role != 'integration'
|
||||
SubmitterMailer.completed_email(submitter, user).deliver_later!
|
||||
end
|
||||
|
||||
bcc = submission.preferences['bcc_completed'].presence ||
|
||||
submission.template.account.account_configs
|
||||
.find_by(key: AccountConfig::BCC_EMAILS)&.value.presence
|
||||
submission.account.account_configs
|
||||
.find_by(key: AccountConfig::BCC_EMAILS)&.value
|
||||
|
||||
bcc.to_s.scan(User::EMAIL_REGEXP).each do |to|
|
||||
SubmitterMailer.completed_email(submitter, user, to:).deliver_later!
|
||||
|
||||
@@ -3,22 +3,42 @@
|
||||
class SendFormCompletedWebhookRequestJob < ApplicationJob
|
||||
USER_AGENT = 'DocuSeal.co Webhook'
|
||||
|
||||
def perform(submitter)
|
||||
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
return if config.blank? || config.value.blank?
|
||||
def perform(submitter, params = {})
|
||||
attempt = params[:attempt].to_i
|
||||
url = Accounts.load_webhook_url(submitter.submission.account)
|
||||
|
||||
return if url.blank?
|
||||
|
||||
preferences = Accounts.load_webhook_preferences(submitter.submission.account)
|
||||
|
||||
return if preferences['form.completed'] == false
|
||||
|
||||
Submissions::EnsureResultGenerated.call(submitter)
|
||||
|
||||
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.completed',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
resp = begin
|
||||
Faraday.post(url,
|
||||
{
|
||||
event_type: 'form.completed',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
rescue Faraday::Error
|
||||
nil
|
||||
end
|
||||
|
||||
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
|
||||
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
|
||||
SendFormCompletedWebhookRequestJob.set(wait: (2**attempt).minutes)
|
||||
.perform_later(submitter, {
|
||||
attempt: attempt + 1,
|
||||
last_status: resp&.status.to_i
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,20 +3,40 @@
|
||||
class SendFormStartedWebhookRequestJob < ApplicationJob
|
||||
USER_AGENT = 'DocuSeal.co Webhook'
|
||||
|
||||
def perform(submitter)
|
||||
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
return if config.blank? || config.value.blank?
|
||||
def perform(submitter, params = {})
|
||||
attempt = params[:attempt].to_i
|
||||
url = Accounts.load_webhook_url(submitter.submission.account)
|
||||
|
||||
return if url.blank?
|
||||
|
||||
preferences = Accounts.load_webhook_preferences(submitter.submission.account)
|
||||
|
||||
return if preferences['form.started'] == false
|
||||
|
||||
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.started',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
resp = begin
|
||||
Faraday.post(url,
|
||||
{
|
||||
event_type: 'form.started',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
rescue Faraday::Error
|
||||
nil
|
||||
end
|
||||
|
||||
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
|
||||
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
|
||||
SendFormStartedWebhookRequestJob.set(wait: (2**attempt).minutes)
|
||||
.perform_later(submitter, {
|
||||
attempt: attempt + 1,
|
||||
last_status: resp&.status.to_i
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,20 +3,40 @@
|
||||
class SendFormViewedWebhookRequestJob < ApplicationJob
|
||||
USER_AGENT = 'DocuSeal.co Webhook'
|
||||
|
||||
def perform(submitter)
|
||||
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
return if config.blank? || config.value.blank?
|
||||
def perform(submitter, params = {})
|
||||
attempt = params[:attempt].to_i
|
||||
url = Accounts.load_webhook_url(submitter.submission.account)
|
||||
|
||||
return if url.blank?
|
||||
|
||||
preferences = Accounts.load_webhook_preferences(submitter.submission.account)
|
||||
|
||||
return if preferences['form.viewed'] == false
|
||||
|
||||
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.viewed',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
resp = begin
|
||||
Faraday.post(url,
|
||||
{
|
||||
event_type: 'form.viewed',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
rescue Faraday::Error
|
||||
nil
|
||||
end
|
||||
|
||||
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
|
||||
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
|
||||
SendFormViewedWebhookRequestJob.set(wait: (2**attempt).minutes)
|
||||
.perform_later(submitter, {
|
||||
attempt: attempt + 1,
|
||||
last_status: resp&.status.to_i
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user