mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 14:42:32 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -9,7 +9,7 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"vue/no-deprecated-html-element-is": 0,
|
"vue/no-deprecated-html-element-is": 0,
|
||||||
"vue/no-mutating-props": 0,
|
"vue/no-mutating-props": 0,
|
||||||
"vue/no-v-html": 0
|
"vue/one-component-per-file": 0
|
||||||
},
|
},
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"ecmaVersion": 2022,
|
"ecmaVersion": 2022,
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 16.13.1
|
node-version: 20.9.0
|
||||||
- name: Cache directory path
|
- name: Cache directory path
|
||||||
id: yarn-cache-dir-path
|
id: yarn-cache-dir-path
|
||||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||||
@@ -104,7 +104,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 16.13.1
|
node-version: 20.9.0
|
||||||
- name: Install Chrome
|
- name: Install Chrome
|
||||||
uses: browser-actions/setup-chrome@latest
|
uses: browser-actions/setup-chrome@latest
|
||||||
- name: Cache node_modules
|
- name: Cache node_modules
|
||||||
|
|||||||
+2
-2
@@ -30,7 +30,7 @@ Metrics/ParameterLists:
|
|||||||
Max: 10
|
Max: 10
|
||||||
|
|
||||||
Metrics/MethodLength:
|
Metrics/MethodLength:
|
||||||
Max: 25
|
Max: 30
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'db/migrate/**'
|
- 'db/migrate/**'
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ Layout/LineLength:
|
|||||||
AllowedPatterns: ['\A\s*#']
|
AllowedPatterns: ['\A\s*#']
|
||||||
|
|
||||||
Metrics/AbcSize:
|
Metrics/AbcSize:
|
||||||
Max: 40
|
Max: 45
|
||||||
|
|
||||||
Metrics/ModuleLength:
|
Metrics/ModuleLength:
|
||||||
Max: 500
|
Max: 500
|
||||||
|
|||||||
+7
-6
@@ -2,9 +2,9 @@ FROM ruby:3.2.2-alpine3.18 as fonts
|
|||||||
|
|
||||||
WORKDIR /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
|
FROM ruby:3.2.2-alpine3.18 as webpack
|
||||||
|
|
||||||
@@ -39,11 +39,11 @@ ENV BUNDLE_WITHOUT="development:test"
|
|||||||
|
|
||||||
WORKDIR /app
|
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 ./
|
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 ./bin ./bin
|
||||||
COPY ./app ./app
|
COPY ./app ./app
|
||||||
@@ -55,7 +55,8 @@ COPY ./public ./public
|
|||||||
COPY ./tmp ./tmp
|
COPY ./tmp ./tmp
|
||||||
COPY LICENSE README.md Rakefile config.ru .version ./
|
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
|
COPY --from=webpack /app/public/packs ./public/packs
|
||||||
|
|
||||||
RUN ln -s /fonts /app/public/fonts
|
RUN ln -s /fonts /app/public/fonts
|
||||||
@@ -65,4 +66,4 @@ WORKDIR /data/docuseal
|
|||||||
ENV WORKDIR=/data/docuseal
|
ENV WORKDIR=/data/docuseal
|
||||||
|
|
||||||
EXPOSE 3000
|
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'
|
||||||
gem 'rails_autolink'
|
gem 'rails_autolink'
|
||||||
gem 'rails-i18n'
|
gem 'rails-i18n'
|
||||||
gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
|
|
||||||
gem 'rotp'
|
gem 'rotp'
|
||||||
gem 'rqrcode'
|
gem 'rqrcode'
|
||||||
gem 'ruby-vips'
|
gem 'ruby-vips'
|
||||||
|
|||||||
+1
-3
@@ -261,7 +261,7 @@ GEM
|
|||||||
signet (>= 0.16, < 2.a)
|
signet (>= 0.16, < 2.a)
|
||||||
hashdiff (1.1.0)
|
hashdiff (1.1.0)
|
||||||
hashery (2.1.2)
|
hashery (2.1.2)
|
||||||
hexapdf (0.34.1)
|
hexapdf (0.37.1)
|
||||||
cmdparse (~> 3.0, >= 3.0.3)
|
cmdparse (~> 3.0, >= 3.0.3)
|
||||||
geom2d (~> 0.4, >= 0.4.1)
|
geom2d (~> 0.4, >= 0.4.1)
|
||||||
openssl (>= 2.2.1)
|
openssl (>= 2.2.1)
|
||||||
@@ -433,7 +433,6 @@ GEM
|
|||||||
railties (>= 5.2)
|
railties (>= 5.2)
|
||||||
retriable (3.1.2)
|
retriable (3.1.2)
|
||||||
rexml (3.2.6)
|
rexml (3.2.6)
|
||||||
rollbar (3.4.2)
|
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rqrcode (2.2.0)
|
rqrcode (2.2.0)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
@@ -605,7 +604,6 @@ DEPENDENCIES
|
|||||||
rails
|
rails
|
||||||
rails-i18n
|
rails-i18n
|
||||||
rails_autolink
|
rails_autolink
|
||||||
rollbar
|
|
||||||
rotp
|
rotp
|
||||||
rqrcode
|
rqrcode
|
||||||
rspec-rails
|
rspec-rails
|
||||||
|
|||||||
@@ -33,17 +33,32 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
|||||||
[](https://demo.docuseal.co)
|
[](https://demo.docuseal.co)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- [x] PDF form fields builder (WYSIWYG)
|
- PDF form fields builder (WYSIWYG)
|
||||||
- [x] 11 field types available (Signature, Date, File, Checkbox etc.)
|
- 12 field types available (Signature, Date, File, Checkbox etc.)
|
||||||
- [x] Multiple submitters per document
|
- Multiple submitters per document
|
||||||
- [x] Automated emails via SMTP
|
- Automated emails via SMTP
|
||||||
- [x] Files storage on disk or AWS S3, Google Storage, Azure Cloud
|
- Files storage on disk or AWS S3, Google Storage, Azure Cloud
|
||||||
- [x] Automatic PDF eSignature
|
- Automatic PDF eSignature
|
||||||
- [x] PDF signature verification
|
- PDF signature verification
|
||||||
- [x] Users management
|
- Users management
|
||||||
- [x] Mobile-optimized
|
- Mobile-optimized
|
||||||
- [x] API and Webhooks for integrations
|
- Signing available in 12 Languages
|
||||||
- [x] Easy to deploy in minutes
|
- 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
|
## 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)|
|
| [<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**|
|
|**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)
|
| [<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
|
#### Docker
|
||||||
|
|
||||||
@@ -77,7 +89,7 @@ sudo HOST=your-domain-name.com docker compose up
|
|||||||
```
|
```
|
||||||
|
|
||||||
## For Businesses
|
## 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.
|
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.
|
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.
|
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::ALLOW_TYPED_SIGNATURE,
|
||||||
AccountConfig::FORCE_MFA,
|
AccountConfig::FORCE_MFA,
|
||||||
AccountConfig::ALLOW_TO_RESUBMIT,
|
AccountConfig::ALLOW_TO_RESUBMIT,
|
||||||
AccountConfig::ESIGNING_PREFERENCE_KEY
|
AccountConfig::ESIGNING_PREFERENCE_KEY,
|
||||||
|
AccountConfig::FORM_WITH_CONFETTI_KEY
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
|
InvalidKey = Class.new(StandardError)
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@account_config.update!(account_config_params)
|
@account_config.update!(account_config_params)
|
||||||
|
|
||||||
@@ -20,7 +23,7 @@ class AccountConfigsController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def load_account_config
|
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 =
|
@account_config =
|
||||||
AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key])
|
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
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
end
|
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?
|
if Rails.env.production?
|
||||||
rescue_from CanCan::AccessDenied do |e|
|
rescue_from CanCan::AccessDenied do |e|
|
||||||
Rollbar.error(e) if defined?(Rollbar)
|
|
||||||
|
|
||||||
render json: { error: e.message }, status: :forbidden
|
render json: { error: e.message }, status: :forbidden
|
||||||
end
|
end
|
||||||
|
|
||||||
rescue_from JSON::ParserError do |e|
|
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
|
render json: { error: "JSON parse error: #{e.message}" }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
@@ -39,8 +43,8 @@ module Api
|
|||||||
result = relation.order(id: :desc)
|
result = relation.order(id: :desc)
|
||||||
.limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min)
|
.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(id: ...params[:after].to_i) if params[:after].present?
|
||||||
result = result.where(relation.arel_table[:id].gt(params[:before])) if params[:before].present?
|
result = result.where(id: (params[:before].to_i + 1)...) if params[:before].present?
|
||||||
|
|
||||||
result
|
result
|
||||||
end
|
end
|
||||||
@@ -59,5 +63,13 @@ module Api
|
|||||||
def current_account
|
def current_account
|
||||||
current_user&.account
|
current_user&.account
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
@@ -33,12 +33,18 @@ module Api
|
|||||||
def show
|
def show
|
||||||
submitters = @submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob)
|
submitters = @submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob)
|
||||||
|
|
||||||
serialized_submitters = submitters.map do |submitter|
|
submitters.each do |submitter|
|
||||||
Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at?
|
if submitter.completed_at? && submitter.documents_attachments.blank?
|
||||||
|
submitter.documents_attachments = Submissions::EnsureResultGenerated.call(submitter)
|
||||||
Submitters::SerializeForApi.call(submitter)
|
end
|
||||||
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(
|
json = @submission.as_json(
|
||||||
serialize_params.deep_merge(
|
serialize_params.deep_merge(
|
||||||
include: { submission_events: { only: %i[id submitter_id event_type event_timestamp] } }
|
include: { submission_events: { only: %i[id submitter_id event_type event_timestamp] } }
|
||||||
@@ -68,7 +74,7 @@ module Api
|
|||||||
return render json: { error: 'Template not found' }, status: :unprocessable_entity if @template.nil?
|
return render json: { error: 'Template not found' }, status: :unprocessable_entity if @template.nil?
|
||||||
|
|
||||||
if @template.fields.blank?
|
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
|
return render json: { error: 'Template does not contain fields' }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
@@ -80,8 +86,16 @@ module Api
|
|||||||
|
|
||||||
Submissions.send_signature_requests(submissions)
|
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)
|
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
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -118,8 +132,13 @@ module Api
|
|||||||
params:
|
params:
|
||||||
)
|
)
|
||||||
|
|
||||||
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments,
|
submitters = submissions.flat_map(&: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
|
submissions
|
||||||
end
|
end
|
||||||
@@ -142,11 +161,11 @@ module Api
|
|||||||
|
|
||||||
def submissions_params
|
def submissions_params
|
||||||
permitted_attrs = [
|
permitted_attrs = [
|
||||||
:send_email, :send_sms, :bcc_completed, :completed_redirect_url,
|
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to,
|
||||||
{
|
{
|
||||||
message: %i[subject body],
|
message: %i[subject body],
|
||||||
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
|
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
|
||||||
:completed, :phone, :application_key, :external_id,
|
:completed, :phone, :application_key, :external_id, :reply_to,
|
||||||
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
|
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
|
||||||
fields: [%i[name default_value title description
|
fields: [%i[name default_value title description
|
||||||
readonly validation_pattern invalid_message]] }]]
|
readonly validation_pattern invalid_message]] }]]
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ module Api
|
|||||||
def create
|
def create
|
||||||
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||||
|
|
||||||
|
submitter.opened_at = Time.current
|
||||||
|
submitter.save
|
||||||
|
|
||||||
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request)
|
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request)
|
||||||
|
|
||||||
SendFormViewedWebhookRequestJob.perform_later(submitter)
|
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[:application_key]) if params[:application_key].present?
|
||||||
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].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 = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
|
||||||
|
submitters = maybe_filder_by_completed_at(submitters, params)
|
||||||
|
|
||||||
submitters = paginate(
|
submitters = paginate(
|
||||||
submitters.preload(:template, :submission, :submission_events,
|
submitters.preload(:template, :submission, :submission_events,
|
||||||
@@ -55,6 +56,8 @@ module Api
|
|||||||
@submitter.save!
|
@submitter.save!
|
||||||
|
|
||||||
@submitter.submission.save!
|
@submitter.submission.save!
|
||||||
|
|
||||||
|
SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request) if @submitter.completed_at?
|
||||||
end
|
end
|
||||||
|
|
||||||
if @submitter.completed_at?
|
if @submitter.completed_at?
|
||||||
@@ -79,6 +82,18 @@ module Api
|
|||||||
|
|
||||||
private
|
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)
|
def assign_submitter_attrs(submitter, attrs)
|
||||||
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
|
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
|
||||||
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
|
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
|
||||||
|
|||||||
@@ -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)
|
Templates::CloneAttachments.call(template: cloned_template, original_template: @template)
|
||||||
|
|
||||||
render json: cloned_template.as_json(serialize_params)
|
render json: Templates::SerializeForApi.call(cloned_template)
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def serialize_params
|
|
||||||
{
|
|
||||||
include: { author: { only: %i[id email first_name last_name] },
|
|
||||||
documents: { only: %i[id uuid], methods: %i[url filename] } }
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -7,10 +7,31 @@ module Api
|
|||||||
def index
|
def index
|
||||||
templates = filter_templates(@templates, params)
|
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: {
|
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: {
|
pagination: {
|
||||||
count: templates.size,
|
count: templates.size,
|
||||||
next: templates.last&.id,
|
next: templates.last&.id,
|
||||||
@@ -20,14 +41,28 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
render json: @template.as_json(serialize_params)
|
render json: Templates::SerializeForApi.call(@template)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
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)
|
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
|
||||||
end
|
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)
|
@template.update!(template_params)
|
||||||
|
|
||||||
render json: @template.as_json(only: %i[id updated_at])
|
render json: @template.as_json(only: %i[id updated_at])
|
||||||
@@ -55,24 +90,12 @@ module Api
|
|||||||
templates
|
templates
|
||||||
end
|
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
|
def template_params
|
||||||
params.require(:template).permit(
|
if params.key?(:template)
|
||||||
:name,
|
params.require(:template).permit(:name, submitters: [%i[name uuid]])
|
||||||
schema: [%i[attachment_uuid name]],
|
else
|
||||||
submitters: [%i[name uuid]],
|
params.permit(:name, submitters: [%i[name uuid]])
|
||||||
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
|
end
|
||||||
{ preferences: {},
|
|
||||||
options: [%i[value uuid]],
|
|
||||||
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
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
|
redirect_to request.path
|
||||||
end
|
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?
|
if Rails.env.production?
|
||||||
rescue_from CanCan::AccessDenied do |e|
|
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
|
redirect_to root_path, alert: e.message
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ class ConsoleRedirectController < ApplicationController
|
|||||||
skip_authorization_check
|
skip_authorization_check
|
||||||
|
|
||||||
def index
|
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?
|
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank?
|
||||||
|
|
||||||
auth = JsonWebToken.encode(uuid: true_user.uuid,
|
auth = JsonWebToken.encode(uuid: true_user.uuid,
|
||||||
|
|||||||
@@ -54,10 +54,14 @@ class DashboardController < ApplicationController
|
|||||||
rel = templates.active.preload(:author).order(id: :desc)
|
rel = templates.active.preload(:author).order(id: :desc)
|
||||||
|
|
||||||
if params[:q].blank?
|
if params[:q].blank?
|
||||||
shared_template_ids =
|
if Docuseal.multitenant? && !current_account.testing?
|
||||||
TemplateSharing.where(account_id: [current_account.id, TemplateSharing::ALL_ID]).select(:template_id)
|
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))
|
rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Templates.search(rel, params[:q])
|
Templates.search(rel, params[:q])
|
||||||
|
|||||||
@@ -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|
|
respond_to do |f|
|
||||||
f.json do
|
f.json do
|
||||||
|
set_cors_headers
|
||||||
|
|
||||||
render json: { status: error_status_code }, status: error_status_code
|
render json: { status: error_status_code }, status: error_status_code
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -31,6 +33,14 @@ class ErrorsController < ActionController::Base
|
|||||||
|
|
||||||
private
|
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
|
def error_status_code
|
||||||
@error_status_code ||=
|
@error_status_code ||=
|
||||||
ActionDispatch::ExceptionWrapper.new(request.env,
|
ActionDispatch::ExceptionWrapper.new(request.env,
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ class PasswordsController < Devise::PasswordsController
|
|||||||
attribute :user
|
attribute :user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
super do |resource|
|
||||||
|
resource.errors.clear unless Docuseal.multitenant?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
super do |resource|
|
super do |resource|
|
||||||
Current.user = resource
|
Current.user = resource
|
||||||
|
|||||||
@@ -1,26 +1,51 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class PersonalizationSettingsController < ApplicationController
|
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
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
InvalidKey = Class.new(StandardError)
|
||||||
|
|
||||||
|
before_action :load_and_authorize_account_config, only: :create
|
||||||
|
|
||||||
def show
|
def show
|
||||||
authorize!(:read, AccountConfig)
|
authorize!(:read, AccountConfig)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
account_config =
|
if @account_config.value != false && @account_config.value.blank?
|
||||||
current_account.account_configs.find_or_initialize_by(key: account_config_params[:key])
|
@account_config.destroy!
|
||||||
|
else
|
||||||
authorize!(:create, account_config)
|
@account_config.save!
|
||||||
|
end
|
||||||
account_config.update!(account_config_params)
|
|
||||||
|
|
||||||
redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.')
|
redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.')
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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
|
def account_config_params
|
||||||
attrs = params.require(:account_config).permit!
|
attrs = params.require(:account_config).permit!
|
||||||
|
|
||||||
|
return attrs if attrs[:value].is_a?(String)
|
||||||
|
|
||||||
attrs[:value]&.transform_values! do |value|
|
attrs[:value]&.transform_values! do |value|
|
||||||
if value.in?(%w[true false])
|
if value.in?(%w[true false])
|
||||||
value == 'true'
|
value == 'true'
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ class PreviewDocumentPageController < ActionController::API
|
|||||||
FORMAT = Templates::ProcessDocument::FORMAT
|
FORMAT = Templates::ProcessDocument::FORMAT
|
||||||
|
|
||||||
def show
|
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
|
return head :not_found unless attachment
|
||||||
|
|
||||||
@@ -21,9 +23,10 @@ class PreviewDocumentPageController < ActionController::API
|
|||||||
find_or_create_document_tempfile_path(attachment)
|
find_or_create_document_tempfile_path(attachment)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
def find_or_create_document_tempfile_path(attachment)
|
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
|
def create
|
||||||
@submitter =
|
@submitter =
|
||||||
if params[:template_slug]
|
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] })
|
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
|
else
|
||||||
Submitter.find_by!(slug: params[:submitter_slug])
|
Submitter.find_by!(slug: params[:submitter_slug])
|
||||||
end
|
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|
|
respond_to do |f|
|
||||||
f.html { redirect_to success_send_submission_email_index_path }
|
f.html { redirect_to success_send_submission_email_index_path }
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ class SessionsController < Devise::SessionsController
|
|||||||
before_action :configure_permitted_parameters
|
before_action :configure_permitted_parameters
|
||||||
|
|
||||||
def create
|
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)),
|
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
|
||||||
notice: 'Create a new account'
|
notice: 'Create a new account'
|
||||||
end
|
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
|
return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class StartFormController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
return redirect_to start_form_path(@template.slug) if @template.archived_at?
|
||||||
|
|
||||||
@submitter = Submitter.where(submission: @template.submissions.where(archived_at: nil))
|
@submitter = Submitter.where(submission: @template.submissions.where(archived_at: nil))
|
||||||
.order(id: :desc)
|
.order(id: :desc)
|
||||||
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
|
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
|
||||||
|
|||||||
@@ -6,23 +6,8 @@ class SubmissionsController < ApplicationController
|
|||||||
|
|
||||||
load_and_authorize_resource :submission, only: %i[show destroy]
|
load_and_authorize_resource :submission, only: %i[show destroy]
|
||||||
|
|
||||||
PRELOAD_ALL_PAGES_AMOUNT = 200
|
|
||||||
|
|
||||||
def show
|
def show
|
||||||
ActiveRecord::Associations::Preloader.new(
|
@submission = Submissions.preload_with_pages(@submission)
|
||||||
records: [@submission],
|
|
||||||
associations: [:template, { template_schema_documents: :blob }]
|
|
||||||
).call
|
|
||||||
|
|
||||||
total_pages =
|
|
||||||
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
|
|
||||||
|
|
||||||
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
|
|
||||||
ActiveRecord::Associations::Preloader.new(
|
|
||||||
records: @submission.template_schema_documents,
|
|
||||||
associations: [:blob, { preview_images_attachments: :blob }]
|
|
||||||
).call
|
|
||||||
end
|
|
||||||
|
|
||||||
render :show, layout: 'plain'
|
render :show, layout: 'plain'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,8 +4,20 @@ class SubmissionsDownloadController < ApplicationController
|
|||||||
skip_before_action :authenticate_user!
|
skip_before_action :authenticate_user!
|
||||||
skip_authorization_check
|
skip_authorization_check
|
||||||
|
|
||||||
|
TTL = 40.minutes
|
||||||
|
FILES_TTL = 5.minutes
|
||||||
|
|
||||||
def index
|
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)
|
Submissions::EnsureResultGenerated.call(submitter)
|
||||||
|
|
||||||
@@ -13,11 +25,26 @@ class SubmissionsDownloadController < ApplicationController
|
|||||||
|
|
||||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||||
|
|
||||||
urls =
|
return head :not_found unless last_submitter.completed_at?
|
||||||
Submitters.select_attachments_for_download(last_submitter).map do |attachment|
|
|
||||||
helpers.rails_blob_url(attachment)
|
|
||||||
end
|
|
||||||
|
|
||||||
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
|
||||||
end
|
end
|
||||||
@@ -4,26 +4,48 @@ class SubmissionsPreviewController < ApplicationController
|
|||||||
skip_before_action :authenticate_user!
|
skip_before_action :authenticate_user!
|
||||||
skip_authorization_check
|
skip_authorization_check
|
||||||
|
|
||||||
PRELOAD_ALL_PAGES_AMOUNT = 200
|
TTL = 40.minutes
|
||||||
|
|
||||||
def show
|
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(
|
signature_valid =
|
||||||
records: [@submission],
|
if submitter && submitter.submission.slug == params[:slug]
|
||||||
associations: [:template, { template_schema_documents: :blob }]
|
@submission = submitter.submission
|
||||||
).call
|
|
||||||
|
|
||||||
total_pages =
|
true
|
||||||
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
|
end
|
||||||
|
|
||||||
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
|
@submission ||= Submission.find_by!(slug: params[:slug])
|
||||||
ActiveRecord::Associations::Preloader.new(
|
|
||||||
records: @submission.template_schema_documents,
|
if !@submission.submitters.all?(&:completed_at?) && current_user.blank?
|
||||||
associations: [:blob, { preview_images_attachments: :blob }]
|
raise ActionController::RoutingError, 'Not Found'
|
||||||
).call
|
|
||||||
end
|
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'
|
render 'submissions/show', layout: 'plain'
|
||||||
end
|
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
|
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,27 @@
|
|||||||
|
# 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?
|
||||||
|
|
||||||
|
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: %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
|
def index
|
||||||
@submissions = @submissions.where.not(archived_at: nil)
|
@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))
|
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class TemplatesController < ApplicationController
|
|||||||
def show
|
def show
|
||||||
submissions = @template.submissions.accessible_by(current_ability)
|
submissions = @template.submissions.accessible_by(current_ability)
|
||||||
submissions = submissions.active if @template.archived_at.blank?
|
submissions = submissions.active if @template.archived_at.blank?
|
||||||
submissions = Submissions.search(submissions, params[:q])
|
submissions = Submissions.search(submissions, params[:q], search_values: true)
|
||||||
|
|
||||||
@base_submissions = submissions
|
@base_submissions = submissions
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ class TemplatesController < ApplicationController
|
|||||||
@template_data =
|
@template_data =
|
||||||
@template.as_json.merge(
|
@template.as_json.merge(
|
||||||
documents: @template.schema_documents.as_json(
|
documents: @template.schema_documents.as_json(
|
||||||
methods: [:metadata],
|
methods: %i[metadata signed_uuid],
|
||||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||||
)
|
)
|
||||||
).to_json
|
).to_json
|
||||||
@@ -51,17 +51,28 @@ class TemplatesController < ApplicationController
|
|||||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
||||||
end
|
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
|
if @template.save
|
||||||
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
|
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
|
||||||
|
|
||||||
redirect_to edit_template_path(@template)
|
maybe_redirect_to_template(@template)
|
||||||
else
|
else
|
||||||
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_entity
|
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@template.update!(template_params)
|
||||||
|
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
notice =
|
notice =
|
||||||
if params[:permanently].present?
|
if params[:permanently].present?
|
||||||
@@ -82,7 +93,31 @@ class TemplatesController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def template_params
|
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
|
end
|
||||||
|
|
||||||
def load_base_template
|
def load_base_template
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class TemplatesPreviewController < ApplicationController
|
|||||||
@template_data =
|
@template_data =
|
||||||
@template.as_json.merge(
|
@template.as_json.merge(
|
||||||
documents: @template.schema_documents.as_json(
|
documents: @template.schema_documents.as_json(
|
||||||
methods: [:metadata],
|
methods: %i[metadata signed_uuid],
|
||||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||||
)
|
)
|
||||||
).to_json
|
).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
|
||||||
@@ -3,6 +3,7 @@ import { encodeMethodIntoRequestBody } from '@hotwired/turbo-rails/app/javascrip
|
|||||||
|
|
||||||
import { createApp, reactive } from 'vue'
|
import { createApp, reactive } from 'vue'
|
||||||
import TemplateBuilder from './template_builder/builder'
|
import TemplateBuilder from './template_builder/builder'
|
||||||
|
import ImportList from './template_builder/import_list'
|
||||||
|
|
||||||
import ToggleVisible from './elements/toggle_visible'
|
import ToggleVisible from './elements/toggle_visible'
|
||||||
import DisableHidden from './elements/disable_hidden'
|
import DisableHidden from './elements/disable_hidden'
|
||||||
@@ -20,6 +21,7 @@ import FolderAutocomplete from './elements/folder_autocomplete'
|
|||||||
import SignatureForm from './elements/signature_form'
|
import SignatureForm from './elements/signature_form'
|
||||||
import SubmitForm from './elements/submit_form'
|
import SubmitForm from './elements/submit_form'
|
||||||
import PromptPassword from './elements/prompt_password'
|
import PromptPassword from './elements/prompt_password'
|
||||||
|
import EmailsTextarea from './elements/emails_textarea'
|
||||||
|
|
||||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ window.customElements.define('folder-autocomplete', FolderAutocomplete)
|
|||||||
window.customElements.define('signature-form', SignatureForm)
|
window.customElements.define('signature-form', SignatureForm)
|
||||||
window.customElements.define('submit-form', SubmitForm)
|
window.customElements.define('submit-form', SubmitForm)
|
||||||
window.customElements.define('prompt-password', PromptPassword)
|
window.customElements.define('prompt-password', PromptPassword)
|
||||||
|
window.customElements.define('emails-textarea', EmailsTextarea)
|
||||||
|
|
||||||
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
|
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
|
||||||
document.addEventListener('turbo:submit-end', async (event) => {
|
document.addEventListener('turbo:submit-end', async (event) => {
|
||||||
@@ -89,7 +92,11 @@ window.customElements.define('template-builder', class extends HTMLElement {
|
|||||||
withPhone: this.dataset.withPhone === 'true',
|
withPhone: this.dataset.withPhone === 'true',
|
||||||
withLogo: this.dataset.withLogo !== 'false',
|
withLogo: this.dataset.withLogo !== 'false',
|
||||||
editable: this.dataset.editable !== 'false',
|
editable: this.dataset.editable !== 'false',
|
||||||
|
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||||
withPayment: this.dataset.withPayment === 'true',
|
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),
|
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
|
||||||
acceptFileTypes: this.dataset.acceptFileTypes,
|
acceptFileTypes: this.dataset.acceptFileTypes,
|
||||||
isDirectUpload: this.dataset.isDirectUpload === 'true'
|
isDirectUpload: this.dataset.isDirectUpload === 'true'
|
||||||
@@ -105,3 +112,23 @@ window.customElements.define('template-builder', class extends HTMLElement {
|
|||||||
this.appElem?.remove()
|
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)
|
||||||
|
|
||||||
|
this.appendChild(this.appElem)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback () {
|
||||||
|
this.app?.unmount()
|
||||||
|
this.appElem?.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -75,16 +75,21 @@ button[disabled] .enabled {
|
|||||||
@apply select base-input w-full font-normal;
|
@apply select base-input w-full font-normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-bottom-start:before {
|
.tooltip-bottom-end:before {
|
||||||
transform: translateX(-30%);
|
transform: translateX(-95%);
|
||||||
top: var(--tooltip-offset);
|
top: var(--tooltip-offset);
|
||||||
left: 100%;
|
left: 100%;
|
||||||
right: auto;
|
right: auto;
|
||||||
bottom: auto;
|
bottom: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-bottom-start:after {
|
.tooltip-pre:before {
|
||||||
transform: translateX(-75%);
|
white-space: pre;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-bottom-end:after {
|
||||||
|
transform: translateX(-25%);
|
||||||
border-color: transparent transparent var(--tooltip-color) transparent;
|
border-color: transparent transparent var(--tooltip-color) transparent;
|
||||||
top: var(--tooltip-tail-offset);
|
top: var(--tooltip-tail-offset);
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -125,3 +130,7 @@ button[disabled] .enabled {
|
|||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
outline-color: hsl(var(--bc) / 0.2);
|
outline-color: hsl(var(--bc) / 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select:required:invalid {
|
||||||
|
color: gray !important;
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export default class extends HTMLElement {
|
|||||||
fetch = (text, resolve) => {
|
fetch = (text, resolve) => {
|
||||||
const queryParams = new URLSearchParams({ q: text })
|
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()
|
const items = await resp.json()
|
||||||
|
|
||||||
resolve(items)
|
resolve(items)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export default class extends HTMLElement {
|
|||||||
preventSubmit: 1,
|
preventSubmit: 1,
|
||||||
minLength: 1,
|
minLength: 1,
|
||||||
showOnFocus: true,
|
showOnFocus: true,
|
||||||
|
debounceWaitMs: 200,
|
||||||
onSelect: this.onSelect,
|
onSelect: this.onSelect,
|
||||||
render: this.render,
|
render: this.render,
|
||||||
fetch: this.fetch
|
fetch: this.fetch
|
||||||
@@ -27,6 +28,8 @@ export default class extends HTMLElement {
|
|||||||
|
|
||||||
if (textarea && item[field]) {
|
if (textarea && item[field]) {
|
||||||
textarea.value = textarea.value.replace(/[^;,\s]+$/, 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) {
|
if (q) {
|
||||||
const queryParams = new URLSearchParams({ q, field: this.dataset.field })
|
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()
|
const items = await resp.json()
|
||||||
|
|
||||||
if (q.length < 3) {
|
if (q.length < 3) {
|
||||||
@@ -47,6 +52,8 @@ export default class extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
resolve([])
|
resolve([])
|
||||||
|
}).finally(() => {
|
||||||
|
this.currentFetch = null
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
resolve([])
|
resolve([])
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ window.customElements.define('submission-form', class extends HTMLElement {
|
|||||||
attribution: this.dataset.attribution !== 'false',
|
attribution: this.dataset.attribution !== 'false',
|
||||||
withConfetti: this.dataset.withConfetti !== 'false',
|
withConfetti: this.dataset.withConfetti !== 'false',
|
||||||
withTypedSignature: this.dataset.withTypedSignature !== 'false',
|
withTypedSignature: this.dataset.withTypedSignature !== 'false',
|
||||||
|
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||||
values: reactive(JSON.parse(this.dataset.values)),
|
values: reactive(JSON.parse(this.dataset.values)),
|
||||||
completedButton: JSON.parse(this.dataset.completedButton),
|
completedButton: JSON.parse(this.dataset.completedButton),
|
||||||
completedRedirectUrl: this.dataset.completedRedirectUrl,
|
completedRedirectUrl: this.dataset.completedRedirectUrl,
|
||||||
|
|||||||
@@ -157,6 +157,7 @@
|
|||||||
ref="textContainer"
|
ref="textContainer"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
class="flex items-center px-0.5 w-full"
|
class="flex items-center px-0.5 w-full"
|
||||||
|
:class="alignClasses[field.preferences?.align]"
|
||||||
>
|
>
|
||||||
<span v-if="Array.isArray(modelValue)">
|
<span v-if="Array.isArray(modelValue)">
|
||||||
{{ modelValue.join(', ') }}
|
{{ modelValue.join(', ') }}
|
||||||
@@ -167,13 +168,14 @@
|
|||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
class="whitespace-pre-wrap"
|
class="whitespace-pre-wrap"
|
||||||
|
:class="{ 'w-full': field.preferences?.align }"
|
||||||
>{{ modelValue }}</span>
|
>{{ modelValue }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<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 {
|
export default {
|
||||||
name: 'FieldArea',
|
name: 'FieldArea',
|
||||||
@@ -240,6 +242,7 @@ export default {
|
|||||||
signature: this.t('signature'),
|
signature: this.t('signature'),
|
||||||
initials: this.t('initials'),
|
initials: this.t('initials'),
|
||||||
date: this.t('date'),
|
date: this.t('date'),
|
||||||
|
number: this.t('number'),
|
||||||
image: this.t('image'),
|
image: this.t('image'),
|
||||||
file: this.t('file'),
|
file: this.t('file'),
|
||||||
select: this.t('select'),
|
select: this.t('select'),
|
||||||
@@ -252,6 +255,13 @@ export default {
|
|||||||
phone: this.t('phone')
|
phone: this.t('phone')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
alignClasses () {
|
||||||
|
return {
|
||||||
|
center: 'text-center',
|
||||||
|
left: 'text-left',
|
||||||
|
right: 'text-right'
|
||||||
|
}
|
||||||
|
},
|
||||||
option () {
|
option () {
|
||||||
return this.field.options.find((o) => o.uuid === this.area.option_uuid)
|
return this.field.options.find((o) => o.uuid === this.area.option_uuid)
|
||||||
},
|
},
|
||||||
@@ -260,6 +270,7 @@ export default {
|
|||||||
text: IconTextSize,
|
text: IconTextSize,
|
||||||
signature: IconWritingSign,
|
signature: IconWritingSign,
|
||||||
date: IconCalendarEvent,
|
date: IconCalendarEvent,
|
||||||
|
number: IconSquareNumber1,
|
||||||
image: IconPhoto,
|
image: IconPhoto,
|
||||||
initials: IconLetterCaseUpper,
|
initials: IconLetterCaseUpper,
|
||||||
file: IconPaperclip,
|
file: IconPaperclip,
|
||||||
@@ -307,7 +318,7 @@ export default {
|
|||||||
formattedDate () {
|
formattedDate () {
|
||||||
if (this.field.type === 'date' && this.modelValue) {
|
if (this.field.type === 'date' && this.modelValue) {
|
||||||
return this.formatDate(
|
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')
|
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -336,17 +347,19 @@ export default {
|
|||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
modelValue () {
|
modelValue () {
|
||||||
if (this.field.type === 'text' && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.modelValue.length)) {
|
this.$nextTick(() => {
|
||||||
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0
|
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 () {
|
mounted () {
|
||||||
if (this.field.type === 'text' && this.$refs.textContainer) {
|
this.$nextTick(() => {
|
||||||
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
|
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue}`.length : 0
|
||||||
})
|
}
|
||||||
}
|
})
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
optionValue (option) {
|
optionValue (option) {
|
||||||
|
|||||||
@@ -41,6 +41,13 @@
|
|||||||
:name="`values[${field.uuid}][]`"
|
:name="`values[${field.uuid}][]`"
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
|
<div
|
||||||
|
v-if="field.description && !modelValue.length"
|
||||||
|
dir="auto"
|
||||||
|
class="mb-3 px-1"
|
||||||
|
>
|
||||||
|
<MarkdownContent :string="field.description" />
|
||||||
|
</div>
|
||||||
<FileDropzone
|
<FileDropzone
|
||||||
:message="`${t('upload')} ${field.name || t('files')}${field.required ? '' : ` (${t('optional')})`}`"
|
:message="`${t('upload')} ${field.name || t('files')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||||
:submitter-slug="submitterSlug"
|
:submitter-slug="submitterSlug"
|
||||||
@@ -53,12 +60,14 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import FileDropzone from './dropzone'
|
import FileDropzone from './dropzone'
|
||||||
|
import MarkdownContent from './markdown_content'
|
||||||
import { IconPaperclip, IconTrashX } from '@tabler/icons-vue'
|
import { IconPaperclip, IconTrashX } from '@tabler/icons-vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AttachmentStep',
|
name: 'AttachmentStep',
|
||||||
components: {
|
components: {
|
||||||
FileDropzone,
|
FileDropzone,
|
||||||
|
MarkdownContent,
|
||||||
IconPaperclip,
|
IconPaperclip,
|
||||||
IconTrashX
|
IconTrashX
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
<div class="space-y-3 mt-5">
|
<div class="space-y-3 mt-5">
|
||||||
<a
|
<a
|
||||||
v-if="completedButton.url"
|
v-if="completedButton.url"
|
||||||
:href="completedButton.url"
|
:href="sanitizeHref(completedButton.url)"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
class="white-button flex items-center w-full"
|
class="white-button flex items-center w-full"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
@@ -211,9 +212,14 @@ export default {
|
|||||||
fileRequests.reduce(
|
fileRequests.reduce(
|
||||||
(prevPromise, request) => prevPromise.then(() => request()),
|
(prevPromise, request) => prevPromise.then(() => request()),
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
)
|
).finally(() => {
|
||||||
|
this.isDownloading = false
|
||||||
this.isDownloading = false
|
})
|
||||||
|
},
|
||||||
|
sanitizeHref (href) {
|
||||||
|
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
|
||||||
|
return href.replace(/javascript:/g, '')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
downloadSafariIos (urls) {
|
downloadSafariIos (urls) {
|
||||||
const fileRequests = urls.map((url) => {
|
const fileRequests = urls.map((url) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
function cropCanvasAndExportToPNG (canvas) {
|
function cropCanvasAndExportToPNG (canvas, { errorOnTooSmall } = { errorOnTooSmall: false }) {
|
||||||
const ctx = canvas.getContext('2d')
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
const width = canvas.width
|
const width = canvas.width
|
||||||
@@ -33,6 +33,10 @@ function cropCanvasAndExportToPNG (canvas) {
|
|||||||
croppedCanvas.height = croppedHeight
|
croppedCanvas.height = croppedHeight
|
||||||
const croppedCtx = croppedCanvas.getContext('2d')
|
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)
|
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div dir="auto">
|
<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
|
<label
|
||||||
:for="field.uuid"
|
:for="field.uuid"
|
||||||
class="label text-2xl"
|
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>
|
</label>
|
||||||
<button
|
<button
|
||||||
class="btn btn-outline btn-sm !normal-case font-normal"
|
class="btn btn-outline btn-sm !normal-case font-normal"
|
||||||
@@ -15,6 +25,13 @@
|
|||||||
{{ t('set_today') }}
|
{{ t('set_today') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="field.description"
|
||||||
|
class="mb-3 px-1"
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
<MarkdownContent :string="field.description" />
|
||||||
|
</div>
|
||||||
<AppearsOn :field="field" />
|
<AppearsOn :field="field" />
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<input
|
<input
|
||||||
@@ -33,11 +50,13 @@
|
|||||||
<script>
|
<script>
|
||||||
import { IconCalendarCheck } from '@tabler/icons-vue'
|
import { IconCalendarCheck } from '@tabler/icons-vue'
|
||||||
import AppearsOn from './appears_on'
|
import AppearsOn from './appears_on'
|
||||||
|
import MarkdownContent from './markdown_content'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'DateStep',
|
name: 'DateStep',
|
||||||
components: {
|
components: {
|
||||||
IconCalendarCheck,
|
IconCalendarCheck,
|
||||||
|
MarkdownContent,
|
||||||
AppearsOn
|
AppearsOn
|
||||||
},
|
},
|
||||||
inject: ['t'],
|
inject: ['t'],
|
||||||
|
|||||||
@@ -8,13 +8,24 @@
|
|||||||
:current-step="currentStepFields"
|
:current-step="currentStepFields"
|
||||||
@focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']"
|
@focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']"
|
||||||
/>
|
/>
|
||||||
|
<FormulaFieldAreas
|
||||||
|
v-if="formulaFields.length"
|
||||||
|
:fields="formulaFields"
|
||||||
|
:values="values"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="!isFormVisible"
|
v-if="!isFormVisible"
|
||||||
id="expand_form_button"
|
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"
|
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
|
<IconArrowsDiagonal
|
||||||
class="absolute right-0 mr-4"
|
class="absolute right-0 mr-4"
|
||||||
:width="20"
|
:width="20"
|
||||||
@@ -32,7 +43,7 @@
|
|||||||
v-if="!isCompleted"
|
v-if="!isCompleted"
|
||||||
id="minimize_form_button"
|
id="minimize_form_button"
|
||||||
class="absolute right-0 mr-2 mt-2 top-0 hidden md:block"
|
class="absolute right-0 mr-2 mt-2 top-0 hidden md:block"
|
||||||
title="Minimize"
|
:title="t('minimize')"
|
||||||
@click.prevent="isFormVisible = false"
|
@click.prevent="isFormVisible = false"
|
||||||
>
|
>
|
||||||
<IconArrowsDiagonalMinimize2
|
<IconArrowsDiagonalMinimize2
|
||||||
@@ -40,13 +51,16 @@
|
|||||||
:height="20"
|
:height="20"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div
|
||||||
|
:class="{ 'md:px-4': isBreakpointMd }"
|
||||||
|
>
|
||||||
<form
|
<form
|
||||||
v-if="!isCompleted"
|
v-if="!isCompleted"
|
||||||
ref="form"
|
ref="form"
|
||||||
:action="submitPath"
|
:action="submitPath"
|
||||||
method="post"
|
method="post"
|
||||||
:class="{ 'md:mx-16': isBreakpointMd }"
|
class="mx-auto"
|
||||||
|
:style="{ maxWidth: isBreakpointMd ? '582px' : '' }"
|
||||||
@submit.prevent="submitStep"
|
@submit.prevent="submitStep"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -69,6 +83,14 @@
|
|||||||
@focus="scrollIntoField(currentField)"
|
@focus="scrollIntoField(currentField)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<DateStep
|
||||||
v-else-if="currentField.type === 'date'"
|
v-else-if="currentField.type === 'date'"
|
||||||
:key="currentField.uuid"
|
:key="currentField.uuid"
|
||||||
@@ -79,17 +101,32 @@
|
|||||||
/>
|
/>
|
||||||
<div v-else-if="currentField.type === 'select'">
|
<div v-else-if="currentField.type === 'select'">
|
||||||
<label
|
<label
|
||||||
v-if="showFieldNames && currentField.name"
|
v-if="showFieldNames && (currentField.name || currentField.title)"
|
||||||
:for="currentField.uuid"
|
:for="currentField.uuid"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
class="label text-2xl mb-2"
|
class="label text-2xl"
|
||||||
>{{ currentField.name }}
|
:class="{ 'mb-2': !currentField.description }"
|
||||||
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
>
|
||||||
|
<MarkdownContent
|
||||||
|
v-if="currentField.title"
|
||||||
|
:string="currentField.title"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
{{ currentField.name }}
|
||||||
|
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
||||||
|
</template>
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="py-1"
|
class="py-1"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
v-if="currentField.description"
|
||||||
|
dir="auto"
|
||||||
|
class="mb-3 px-1"
|
||||||
|
>
|
||||||
|
<MarkdownContent :string="currentField.description" />
|
||||||
|
</div>
|
||||||
<AppearsOn :field="currentField" />
|
<AppearsOn :field="currentField" />
|
||||||
<select
|
<select
|
||||||
:id="currentField.uuid"
|
:id="currentField.uuid"
|
||||||
@@ -118,13 +155,28 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else-if="currentField.type === 'radio'">
|
<div v-else-if="currentField.type === 'radio'">
|
||||||
<label
|
<label
|
||||||
v-if="showFieldNames && currentField.name"
|
v-if="showFieldNames && (currentField.name || currentField.title)"
|
||||||
:for="currentField.uuid"
|
:for="currentField.uuid"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
class="label text-2xl mb-2"
|
class="label text-2xl"
|
||||||
>{{ currentField.name }}
|
:class="{ 'mb-2': !currentField.description }"
|
||||||
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
>
|
||||||
|
<MarkdownContent
|
||||||
|
v-if="currentField.title"
|
||||||
|
:string="currentField.title"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
{{ currentField.name }}
|
||||||
|
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
||||||
|
</template>
|
||||||
</label>
|
</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 class="flex w-full max-h-44 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
v-if="!showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4)"
|
v-if="!showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4)"
|
||||||
@@ -156,6 +208,7 @@
|
|||||||
:name="`values[${currentField.uuid}]`"
|
:name="`values[${currentField.uuid}]`"
|
||||||
:value="option.value || `${t('option')} ${index + 1}`"
|
:value="option.value || `${t('option')} ${index + 1}`"
|
||||||
:required="currentField.required"
|
:required="currentField.required"
|
||||||
|
@click="scrollIntoField(currentField)"
|
||||||
>
|
>
|
||||||
<span class="text-xl">
|
<span class="text-xl">
|
||||||
{{ option.value || `${t('option')} ${index + 1}` }}
|
{{ option.value || `${t('option')} ${index + 1}` }}
|
||||||
@@ -175,66 +228,77 @@
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-else-if="currentField.type === 'checkbox'"
|
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
|
<div
|
||||||
class="space-y-3.5 mx-auto"
|
v-if="currentField.description"
|
||||||
|
dir="auto"
|
||||||
|
class="mb-3 px-1"
|
||||||
>
|
>
|
||||||
<template v-if="isAnonymousChecboxes || !showFieldNames">
|
<MarkdownContent :string="currentField.description" />
|
||||||
<span class="text-xl">
|
</div>
|
||||||
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
|
<div
|
||||||
</span>
|
class="flex w-full max-h-44 overflow-y-auto"
|
||||||
<input
|
>
|
||||||
v-for="field in currentStepFields"
|
<input
|
||||||
:key="field.uuid"
|
type="hidden"
|
||||||
type="hidden"
|
name="cast_boolean"
|
||||||
:name="`values[${field.uuid}]`"
|
value="true"
|
||||||
:value="!!values[field.uuid]"
|
>
|
||||||
>
|
<div
|
||||||
</template>
|
class="space-y-3.5 mx-auto"
|
||||||
<template v-else>
|
>
|
||||||
<div
|
<template v-if="isAnonymousChecboxes || !showFieldNames">
|
||||||
v-for="(field, index) in currentStepFields"
|
<span class="text-xl">
|
||||||
:key="field.uuid"
|
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
|
||||||
>
|
</span>
|
||||||
<label
|
<input
|
||||||
:for="field.uuid"
|
v-for="field in currentStepFields"
|
||||||
class="flex items-center space-x-3"
|
:key="field.uuid"
|
||||||
|
type="hidden"
|
||||||
|
:name="`values[${field.uuid}]`"
|
||||||
|
:value="!!values[field.uuid]"
|
||||||
>
|
>
|
||||||
<input
|
</template>
|
||||||
type="hidden"
|
<template v-else>
|
||||||
:name="`values[${field.uuid}]`"
|
<div
|
||||||
:value="!!values[field.uuid]"
|
v-for="(field, index) in currentStepFields"
|
||||||
|
:key="field.uuid"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
:for="field.uuid"
|
||||||
|
class="flex items-center space-x-3"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
:id="field.uuid"
|
type="hidden"
|
||||||
type="checkbox"
|
:name="`values[${field.uuid}]`"
|
||||||
class="base-checkbox !h-7 !w-7"
|
:value="!!values[field.uuid]"
|
||||||
:oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`"
|
>
|
||||||
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
|
<input
|
||||||
:required="field.required"
|
:id="field.uuid"
|
||||||
:checked="!!values[field.uuid]"
|
type="checkbox"
|
||||||
@click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
|
class="base-checkbox !h-7 !w-7"
|
||||||
>
|
:oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`"
|
||||||
<span
|
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
|
||||||
v-if="field.title"
|
:required="field.required"
|
||||||
class="text-xl"
|
:checked="!!values[field.uuid]"
|
||||||
v-html="field.title"
|
@click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
|
||||||
/>
|
>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-if="field.title"
|
||||||
class="text-xl"
|
class="text-xl"
|
||||||
>
|
>
|
||||||
{{ field.name || field.type + ' ' + (index + 1) }}
|
<MarkdownContent :string="field.title" />
|
||||||
</span>
|
</span>
|
||||||
</label>
|
<span
|
||||||
</div>
|
v-else
|
||||||
</template>
|
class="text-xl"
|
||||||
|
>
|
||||||
|
{{ field.name || field.type + ' ' + (index + 1) }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ImageStep
|
<ImageStep
|
||||||
@@ -254,7 +318,7 @@
|
|||||||
:key="currentField.uuid"
|
:key="currentField.uuid"
|
||||||
v-model="values[currentField.uuid]"
|
v-model="values[currentField.uuid]"
|
||||||
:field="currentField"
|
:field="currentField"
|
||||||
:previous-value="previousSignatureValue"
|
:previous-value="previousSignatureValueFor(currentField)"
|
||||||
:is-direct-upload="isDirectUpload"
|
:is-direct-upload="isDirectUpload"
|
||||||
:with-typed-signature="withTypedSignature"
|
:with-typed-signature="withTypedSignature"
|
||||||
:attachments-index="attachmentsIndex"
|
:attachments-index="attachmentsIndex"
|
||||||
@@ -385,6 +449,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import FieldAreas from './areas'
|
import FieldAreas from './areas'
|
||||||
|
import FormulaFieldAreas from './formula_areas'
|
||||||
import ImageStep from './image_step'
|
import ImageStep from './image_step'
|
||||||
import SignatureStep from './signature_step'
|
import SignatureStep from './signature_step'
|
||||||
import InitialsStep from './initials_step'
|
import InitialsStep from './initials_step'
|
||||||
@@ -393,12 +458,32 @@ import MultiSelectStep from './multi_select_step'
|
|||||||
import PhoneStep from './phone_step'
|
import PhoneStep from './phone_step'
|
||||||
import PaymentStep from './payment_step'
|
import PaymentStep from './payment_step'
|
||||||
import TextStep from './text_step'
|
import TextStep from './text_step'
|
||||||
|
import NumberStep from './number_step'
|
||||||
import DateStep from './date_step'
|
import DateStep from './date_step'
|
||||||
|
import MarkdownContent from './markdown_content'
|
||||||
import FormCompleted from './completed'
|
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 AppearsOn from './appears_on'
|
||||||
import i18n from './i18n'
|
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 {
|
export default {
|
||||||
name: 'SubmissionForm',
|
name: 'SubmissionForm',
|
||||||
components: {
|
components: {
|
||||||
@@ -406,6 +491,7 @@ export default {
|
|||||||
ImageStep,
|
ImageStep,
|
||||||
SignatureStep,
|
SignatureStep,
|
||||||
AppearsOn,
|
AppearsOn,
|
||||||
|
IconWritingSign,
|
||||||
AttachmentStep,
|
AttachmentStep,
|
||||||
InitialsStep,
|
InitialsStep,
|
||||||
MultiSelectStep,
|
MultiSelectStep,
|
||||||
@@ -413,7 +499,10 @@ export default {
|
|||||||
DateStep,
|
DateStep,
|
||||||
IconArrowsDiagonal,
|
IconArrowsDiagonal,
|
||||||
TextStep,
|
TextStep,
|
||||||
|
NumberStep,
|
||||||
|
FormulaFieldAreas,
|
||||||
PhoneStep,
|
PhoneStep,
|
||||||
|
MarkdownContent,
|
||||||
PaymentStep,
|
PaymentStep,
|
||||||
IconArrowsDiagonalMinimize2,
|
IconArrowsDiagonalMinimize2,
|
||||||
FormCompleted
|
FormCompleted
|
||||||
@@ -448,6 +537,11 @@ export default {
|
|||||||
return () => {}
|
return () => {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
expand: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
withConfetti: {
|
withConfetti: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -518,6 +612,16 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: () => ({})
|
default: () => ({})
|
||||||
},
|
},
|
||||||
|
authenticityToken: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
i18n: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
withSendCopyButton: {
|
withSendCopyButton: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -542,7 +646,7 @@ export default {
|
|||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
isFormVisible: true,
|
isFormVisible: this.expand !== false,
|
||||||
showFillAllRequiredFields: false,
|
showFillAllRequiredFields: false,
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
@@ -560,16 +664,15 @@ export default {
|
|||||||
queryParams () {
|
queryParams () {
|
||||||
return new URLSearchParams(window.location.search)
|
return new URLSearchParams(window.location.search)
|
||||||
},
|
},
|
||||||
authenticityToken () {
|
|
||||||
return document.querySelector('meta[name="csrf-token"]')?.content
|
|
||||||
},
|
|
||||||
submitterSlug () {
|
submitterSlug () {
|
||||||
return this.submitter.slug
|
return this.submitter.slug
|
||||||
},
|
},
|
||||||
previousSignatureValue () {
|
fieldsUuidIndex () {
|
||||||
const signatureField = [...this.fields].reverse().find((field) => field.type === 'signature' && !!this.values[field.uuid])
|
return this.fields.reduce((acc, f) => {
|
||||||
|
acc[f.uuid] = f
|
||||||
|
|
||||||
return this.values[signatureField?.uuid]
|
return acc
|
||||||
|
}, {})
|
||||||
},
|
},
|
||||||
previousInitialsValue () {
|
previousInitialsValue () {
|
||||||
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
|
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
|
||||||
@@ -577,7 +680,7 @@ export default {
|
|||||||
return this.values[initialsField?.uuid]
|
return this.values[initialsField?.uuid]
|
||||||
},
|
},
|
||||||
isAnonymousChecboxes () {
|
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 () {
|
isButtonDisabled () {
|
||||||
if (this.recalculateButtonDisabledKey) {
|
if (this.recalculateButtonDisabledKey) {
|
||||||
@@ -596,15 +699,20 @@ export default {
|
|||||||
return this.fields.filter((f) => !f.readonly).reduce((acc, f) => {
|
return this.fields.filter((f) => !f.readonly).reduce((acc, f) => {
|
||||||
const prevStep = acc[acc.length - 1]
|
const prevStep = acc[acc.length - 1]
|
||||||
|
|
||||||
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox') {
|
if (this.checkFieldConditions(f)) {
|
||||||
prevStep.push(f)
|
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox' && !f.description) {
|
||||||
} else {
|
prevStep.push(f)
|
||||||
acc.push([f])
|
} else {
|
||||||
|
acc.push([f])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc
|
return acc
|
||||||
}, [])
|
}, [])
|
||||||
},
|
},
|
||||||
|
formulaFields () {
|
||||||
|
return this.fields.filter((f) => f.preferences?.formula)
|
||||||
|
},
|
||||||
attachmentsIndex () {
|
attachmentsIndex () {
|
||||||
return this.attachments.reduce((acc, a) => {
|
return this.attachments.reduce((acc, a) => {
|
||||||
acc[a.uuid] = a
|
acc[a.uuid] = a
|
||||||
@@ -616,12 +724,17 @@ export default {
|
|||||||
return `/s/${this.submitterSlug}`
|
return `/s/${this.submitterSlug}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
expand (value) {
|
||||||
|
this.isFormVisible = value
|
||||||
|
}
|
||||||
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
this.submittedValues = JSON.parse(JSON.stringify(this.values))
|
this.submittedValues = JSON.parse(JSON.stringify(this.values))
|
||||||
|
|
||||||
this.fields.forEach((field) => {
|
this.fields.forEach((field) => {
|
||||||
if (field.default_value && !field.readonly) {
|
if (field.default_value && !field.readonly) {
|
||||||
this.values[field.uuid] = field.default_value
|
this.values[field.uuid] ||= field.default_value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -648,6 +761,10 @@ export default {
|
|||||||
this.currentStep = Math.min(...indexesList)
|
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)) {
|
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
const root = this.$root.$el.parentNode.getRootNode()
|
const root = this.$root.$el.parentNode.getRootNode()
|
||||||
@@ -675,7 +792,41 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
t (key) {
|
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 () {
|
maybeTrackEmailClick () {
|
||||||
const { queryParams } = this
|
const { queryParams } = this
|
||||||
@@ -736,6 +887,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) {
|
goToStep (step, scrollToArea = false, clickUpload = false) {
|
||||||
this.currentStep = this.stepFields.indexOf(step)
|
this.currentStep = this.stepFields.indexOf(step)
|
||||||
this.showFillAllRequiredFields = false
|
this.showFillAllRequiredFields = false
|
||||||
@@ -747,7 +905,7 @@ export default {
|
|||||||
this.scrollIntoField(step[0])
|
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)) {
|
if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) {
|
||||||
this.$refs.form.querySelector('input[type="file"]')?.click()
|
this.$refs.form.querySelector('input[type="file"]')?.click()
|
||||||
@@ -755,7 +913,7 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
saveStep (formData) {
|
saveStep (formData) {
|
||||||
const currentFieldUuid = this.currentField.uuid
|
const currentFieldUuids = this.currentStepFields.map((f) => f.uuid)
|
||||||
|
|
||||||
if (this.isCompleted) {
|
if (this.isCompleted) {
|
||||||
return Promise.resolve({})
|
return Promise.resolve({})
|
||||||
@@ -765,7 +923,9 @@ export default {
|
|||||||
body: formData || new FormData(this.$refs.form)
|
body: formData || new FormData(this.$refs.form)
|
||||||
}).then((response) => {
|
}).then((response) => {
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
this.submittedValues[currentFieldUuid] = this.values[currentFieldUuid]
|
currentFieldUuids.forEach((fieldUuid) => {
|
||||||
|
this.submittedValues[fieldUuid] = this.values[fieldUuid]
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -787,7 +947,13 @@ export default {
|
|||||||
|
|
||||||
stepPromise().then(async () => {
|
stepPromise().then(async () => {
|
||||||
const emptyRequiredField = this.stepFields.find((fields, index) => {
|
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)
|
const formData = new FormData(this.$refs.form)
|
||||||
@@ -837,7 +1003,11 @@ export default {
|
|||||||
this.isSubmitting = false
|
this.isSubmitting = false
|
||||||
})
|
})
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
console.log(error)
|
if (error?.message === 'Image too small') {
|
||||||
|
alert('Signature is too small - please redraw.')
|
||||||
|
} else {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
this.isSubmitting = false
|
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',
|
signature: 'Signature',
|
||||||
initials: 'Initials',
|
initials: 'Initials',
|
||||||
date: 'Date',
|
date: 'Date',
|
||||||
|
number: 'Number',
|
||||||
image: 'Image',
|
image: 'Image',
|
||||||
take_photo: 'Take photo',
|
take_photo: 'Take photo',
|
||||||
file: 'File',
|
file: 'File',
|
||||||
@@ -12,9 +13,11 @@ const en = {
|
|||||||
radio: 'Radio',
|
radio: 'Radio',
|
||||||
cells: 'Cells',
|
cells: 'Cells',
|
||||||
stamp: 'Stamp',
|
stamp: 'Stamp',
|
||||||
|
minimize: 'Minimize',
|
||||||
payment: 'Payment',
|
payment: 'Payment',
|
||||||
phone: 'Phone',
|
phone: 'Phone',
|
||||||
submit_form: 'Submit Form',
|
submit_form: 'Submit Form',
|
||||||
|
sign_now: 'Sign Now',
|
||||||
type_here_: 'Type here...',
|
type_here_: 'Type here...',
|
||||||
optional: 'optional',
|
optional: 'optional',
|
||||||
option: 'Option',
|
option: 'Option',
|
||||||
@@ -62,10 +65,12 @@ const en = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const es = {
|
const es = {
|
||||||
|
minimize: 'Minimizar',
|
||||||
text: 'Texto',
|
text: 'Texto',
|
||||||
signature: 'Firma',
|
signature: 'Firma',
|
||||||
initials: 'Iniciales',
|
initials: 'Iniciales',
|
||||||
date: 'Fecha',
|
date: 'Fecha',
|
||||||
|
number: 'Número',
|
||||||
image: 'Imagen',
|
image: 'Imagen',
|
||||||
file: 'Archivo',
|
file: 'Archivo',
|
||||||
select: 'Seleccionar',
|
select: 'Seleccionar',
|
||||||
@@ -78,6 +83,7 @@ const es = {
|
|||||||
phone: 'Teléfono',
|
phone: 'Teléfono',
|
||||||
take_photo: 'Tomar foto',
|
take_photo: 'Tomar foto',
|
||||||
submit_form: 'Enviar Formulario',
|
submit_form: 'Enviar Formulario',
|
||||||
|
sign_now: 'Firmar ahora',
|
||||||
type_here_: 'Escribe aquí...',
|
type_here_: 'Escribe aquí...',
|
||||||
optional: 'opcional',
|
optional: 'opcional',
|
||||||
appears_on: 'Aparece en',
|
appears_on: 'Aparece en',
|
||||||
@@ -124,10 +130,12 @@ const es = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const it = {
|
const it = {
|
||||||
|
minimize: 'Minimizza',
|
||||||
text: 'Testo',
|
text: 'Testo',
|
||||||
signature: 'Firma',
|
signature: 'Firma',
|
||||||
initials: 'Iniziali',
|
initials: 'Iniziali',
|
||||||
date: 'Data',
|
date: 'Data',
|
||||||
|
number: 'Numero',
|
||||||
image: 'Immagine',
|
image: 'Immagine',
|
||||||
file: 'File',
|
file: 'File',
|
||||||
select: 'Seleziona',
|
select: 'Seleziona',
|
||||||
@@ -139,6 +147,7 @@ const it = {
|
|||||||
payment: 'Pagamento',
|
payment: 'Pagamento',
|
||||||
phone: 'Telefono',
|
phone: 'Telefono',
|
||||||
submit_form: 'Invia Modulo',
|
submit_form: 'Invia Modulo',
|
||||||
|
sign_now: 'Firma ora',
|
||||||
type_here_: 'Digita qui...',
|
type_here_: 'Digita qui...',
|
||||||
optional: 'opzionale',
|
optional: 'opzionale',
|
||||||
appears_on: 'Compare su',
|
appears_on: 'Compare su',
|
||||||
@@ -186,10 +195,12 @@ const it = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const de = {
|
const de = {
|
||||||
|
minimize: 'Minimieren',
|
||||||
text: 'Text',
|
text: 'Text',
|
||||||
signature: 'Unterschrift',
|
signature: 'Unterschrift',
|
||||||
initials: 'Initialen',
|
initials: 'Initialen',
|
||||||
date: 'Datum',
|
date: 'Datum',
|
||||||
|
number: 'Nummer',
|
||||||
image: 'Bild',
|
image: 'Bild',
|
||||||
file: 'Datei',
|
file: 'Datei',
|
||||||
select: 'Auswählen',
|
select: 'Auswählen',
|
||||||
@@ -201,6 +212,7 @@ const de = {
|
|||||||
payment: 'Zahlung',
|
payment: 'Zahlung',
|
||||||
phone: 'Telefon',
|
phone: 'Telefon',
|
||||||
submit_form: 'Formular absenden',
|
submit_form: 'Formular absenden',
|
||||||
|
sign_now: 'Jetzt unterschreiben',
|
||||||
type_here_: 'Hier eingeben...',
|
type_here_: 'Hier eingeben...',
|
||||||
optional: 'optional',
|
optional: 'optional',
|
||||||
appears_on: 'Erscheint auf',
|
appears_on: 'Erscheint auf',
|
||||||
@@ -248,10 +260,12 @@ const de = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fr = {
|
const fr = {
|
||||||
|
minimize: 'Réduire',
|
||||||
text: 'Texte',
|
text: 'Texte',
|
||||||
signature: 'Signature',
|
signature: 'Signature',
|
||||||
initials: 'Initiales',
|
initials: 'Initiales',
|
||||||
date: 'Date',
|
date: 'Date',
|
||||||
|
number: 'Numéro',
|
||||||
image: 'Image',
|
image: 'Image',
|
||||||
file: 'Fichier',
|
file: 'Fichier',
|
||||||
select: 'Choisir',
|
select: 'Choisir',
|
||||||
@@ -263,6 +277,7 @@ const fr = {
|
|||||||
payment: 'Paiement',
|
payment: 'Paiement',
|
||||||
phone: 'Téléphone',
|
phone: 'Téléphone',
|
||||||
submit_form: 'Envoyer le Formulaire',
|
submit_form: 'Envoyer le Formulaire',
|
||||||
|
sign_now: 'Signer maintenant',
|
||||||
type_here_: 'Tapez ici...',
|
type_here_: 'Tapez ici...',
|
||||||
optional: 'facultatif',
|
optional: 'facultatif',
|
||||||
appears_on: 'Apparaît sur',
|
appears_on: 'Apparaît sur',
|
||||||
@@ -310,10 +325,12 @@ const fr = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pl = {
|
const pl = {
|
||||||
|
minimize: 'Zminimalizuj',
|
||||||
text: 'Tekst',
|
text: 'Tekst',
|
||||||
signature: 'Podpis',
|
signature: 'Podpis',
|
||||||
initials: 'Inicjały',
|
initials: 'Inicjały',
|
||||||
date: 'Data',
|
date: 'Data',
|
||||||
|
number: 'Numer',
|
||||||
image: 'Obraz',
|
image: 'Obraz',
|
||||||
file: 'Plik',
|
file: 'Plik',
|
||||||
select: 'Wybierz',
|
select: 'Wybierz',
|
||||||
@@ -325,6 +342,7 @@ const pl = {
|
|||||||
payment: 'Płatność',
|
payment: 'Płatność',
|
||||||
phone: 'Telefon',
|
phone: 'Telefon',
|
||||||
submit_form: 'Wyślij Formularz',
|
submit_form: 'Wyślij Formularz',
|
||||||
|
sign_now: 'Podpisz teraz',
|
||||||
type_here_: 'Wpisz tutaj...',
|
type_here_: 'Wpisz tutaj...',
|
||||||
optional: 'opcjonalny',
|
optional: 'opcjonalny',
|
||||||
appears_on: 'Pojawia się na',
|
appears_on: 'Pojawia się na',
|
||||||
@@ -372,10 +390,12 @@ const pl = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uk = {
|
const uk = {
|
||||||
|
minimize: 'Зменшити',
|
||||||
text: 'Текст',
|
text: 'Текст',
|
||||||
signature: 'Підпис',
|
signature: 'Підпис',
|
||||||
initials: 'Ініціали',
|
initials: 'Ініціали',
|
||||||
date: 'Дата',
|
date: 'Дата',
|
||||||
|
number: 'Число',
|
||||||
image: 'Зображення',
|
image: 'Зображення',
|
||||||
file: 'Файл',
|
file: 'Файл',
|
||||||
select: 'Вибрати',
|
select: 'Вибрати',
|
||||||
@@ -387,6 +407,7 @@ const uk = {
|
|||||||
payment: 'Платіж',
|
payment: 'Платіж',
|
||||||
phone: 'Телефон',
|
phone: 'Телефон',
|
||||||
submit_form: 'Надіслати Форму',
|
submit_form: 'Надіслати Форму',
|
||||||
|
sign_now: 'Підписати зараз',
|
||||||
type_here_: 'Введіть тут',
|
type_here_: 'Введіть тут',
|
||||||
optional: 'необов’язково',
|
optional: 'необов’язково',
|
||||||
appears_on: "З'являється на",
|
appears_on: "З'являється на",
|
||||||
@@ -434,10 +455,12 @@ const uk = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cs = {
|
const cs = {
|
||||||
|
minimize: 'Minimalizovat',
|
||||||
text: 'Text',
|
text: 'Text',
|
||||||
signature: 'Podpis',
|
signature: 'Podpis',
|
||||||
initials: 'Iniciály',
|
initials: 'Iniciály',
|
||||||
date: 'Datum',
|
date: 'Datum',
|
||||||
|
number: 'Číslo',
|
||||||
image: 'Obrázek',
|
image: 'Obrázek',
|
||||||
file: 'Soubor',
|
file: 'Soubor',
|
||||||
select: 'Vybrat',
|
select: 'Vybrat',
|
||||||
@@ -449,6 +472,7 @@ const cs = {
|
|||||||
payment: 'Platba',
|
payment: 'Platba',
|
||||||
phone: 'Telefon',
|
phone: 'Telefon',
|
||||||
submit_form: 'Odeslat formulář',
|
submit_form: 'Odeslat formulář',
|
||||||
|
sign_now: 'Podepsat nyní',
|
||||||
type_here_: 'Zadejte zde',
|
type_here_: 'Zadejte zde',
|
||||||
optional: 'volitelné',
|
optional: 'volitelné',
|
||||||
appears_on: 'Zobrazuje se na',
|
appears_on: 'Zobrazuje se na',
|
||||||
@@ -496,10 +520,12 @@ const cs = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pt = {
|
const pt = {
|
||||||
|
minimize: 'Minimizar',
|
||||||
text: 'Texto',
|
text: 'Texto',
|
||||||
signature: 'Assinatura',
|
signature: 'Assinatura',
|
||||||
initials: 'Iniciais',
|
initials: 'Iniciais',
|
||||||
date: 'Data',
|
date: 'Data',
|
||||||
|
number: 'Número',
|
||||||
image: 'Imagem',
|
image: 'Imagem',
|
||||||
file: 'Arquivo',
|
file: 'Arquivo',
|
||||||
select: 'Selecionar',
|
select: 'Selecionar',
|
||||||
@@ -511,6 +537,7 @@ const pt = {
|
|||||||
payment: 'Pagamento',
|
payment: 'Pagamento',
|
||||||
phone: 'Telefone',
|
phone: 'Telefone',
|
||||||
submit_form: 'Enviar Formulário',
|
submit_form: 'Enviar Formulário',
|
||||||
|
sign_now: 'Assinar agora',
|
||||||
type_here_: 'Digite aqui',
|
type_here_: 'Digite aqui',
|
||||||
optional: 'opcional',
|
optional: 'opcional',
|
||||||
appears_on: 'Aparece em',
|
appears_on: 'Aparece em',
|
||||||
@@ -558,10 +585,12 @@ const pt = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const he = {
|
const he = {
|
||||||
|
minimize: 'לקטן',
|
||||||
text: 'טקסט',
|
text: 'טקסט',
|
||||||
signature: 'חתימה',
|
signature: 'חתימה',
|
||||||
initials: 'ראשי תיקיות',
|
initials: 'ראשי תיבות',
|
||||||
date: 'תאריך',
|
date: 'תאריך',
|
||||||
|
number: 'מספר',
|
||||||
image: 'תמונה',
|
image: 'תמונה',
|
||||||
file: 'קובץ',
|
file: 'קובץ',
|
||||||
select: 'בחר',
|
select: 'בחר',
|
||||||
@@ -573,6 +602,7 @@ const he = {
|
|||||||
payment: 'תשלום',
|
payment: 'תשלום',
|
||||||
phone: 'טלפון',
|
phone: 'טלפון',
|
||||||
submit_form: 'שלח טופס',
|
submit_form: 'שלח טופס',
|
||||||
|
sign_now: 'חתום כעת',
|
||||||
type_here_: 'הקלד כאן',
|
type_here_: 'הקלד כאן',
|
||||||
optional: 'אופציונלי',
|
optional: 'אופציונלי',
|
||||||
option: 'אפשרות',
|
option: 'אפשרות',
|
||||||
@@ -621,10 +651,12 @@ const he = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nl = {
|
const nl = {
|
||||||
|
minimize: 'Minimaliseren',
|
||||||
text: 'Tekst',
|
text: 'Tekst',
|
||||||
signature: 'Handtekening',
|
signature: 'Handtekening',
|
||||||
initials: 'Initialen',
|
initials: 'Initialen',
|
||||||
date: 'Datum',
|
date: 'Datum',
|
||||||
|
number: 'Nummer',
|
||||||
image: 'Afbeelding',
|
image: 'Afbeelding',
|
||||||
take_photo: 'Maak een foto',
|
take_photo: 'Maak een foto',
|
||||||
file: 'Bestand',
|
file: 'Bestand',
|
||||||
@@ -637,6 +669,7 @@ const nl = {
|
|||||||
payment: 'Betaling',
|
payment: 'Betaling',
|
||||||
phone: 'Telefoon',
|
phone: 'Telefoon',
|
||||||
submit_form: 'Formulier verzenden',
|
submit_form: 'Formulier verzenden',
|
||||||
|
sign_now: 'Nu ondertekenen',
|
||||||
type_here_: 'Typ hier...',
|
type_here_: 'Typ hier...',
|
||||||
optional: 'Optioneel',
|
optional: 'Optioneel',
|
||||||
option: 'Optie',
|
option: 'Optie',
|
||||||
@@ -683,6 +716,72 @@ const nl = {
|
|||||||
files: 'Bestanden'
|
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 i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar }
|
||||||
|
|
||||||
export default i18n
|
export default i18n
|
||||||
@@ -24,9 +24,17 @@
|
|||||||
:name="`values[${field.uuid}]`"
|
:name="`values[${field.uuid}]`"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div
|
||||||
|
v-if="!modelValue"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="field.description"
|
||||||
|
dir="auto"
|
||||||
|
class="mb-3 px-1"
|
||||||
|
>
|
||||||
|
<MarkdownContent :string="field.description" />
|
||||||
|
</div>
|
||||||
<FileDropzone
|
<FileDropzone
|
||||||
v-if="!modelValue"
|
|
||||||
:message="`${t('upload')} ${field.name || t('image')}${field.required ? '' : ` (${t('optional')})`}`"
|
:message="`${t('upload')} ${field.name || t('image')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||||
:submitter-slug="submitterSlug"
|
:submitter-slug="submitterSlug"
|
||||||
:accept="'image/*'"
|
:accept="'image/*'"
|
||||||
@@ -39,12 +47,14 @@
|
|||||||
<script>
|
<script>
|
||||||
import FileDropzone from './dropzone'
|
import FileDropzone from './dropzone'
|
||||||
import { IconReload } from '@tabler/icons-vue'
|
import { IconReload } from '@tabler/icons-vue'
|
||||||
|
import MarkdownContent from './markdown_content'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ImageStep',
|
name: 'ImageStep',
|
||||||
components: {
|
components: {
|
||||||
FileDropzone,
|
FileDropzone,
|
||||||
IconReload
|
IconReload,
|
||||||
|
MarkdownContent
|
||||||
},
|
},
|
||||||
inject: ['t'],
|
inject: ['t'],
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div dir="auto">
|
<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
|
<label
|
||||||
class="label text-2xl"
|
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">
|
<div class="space-x-2 flex">
|
||||||
<span
|
<span
|
||||||
v-if="isDrawInitials"
|
v-if="isDrawInitials"
|
||||||
class="tooltip"
|
class="tooltip"
|
||||||
:data-tip="t('type_initials')"
|
:data-tip="t('type_initial')"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
id="type_text_button"
|
id="type_text_button"
|
||||||
@@ -58,7 +69,7 @@
|
|||||||
{{ t('clear') }}
|
{{ t('clear') }}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
title="Minimize"
|
:title="t('minimize')"
|
||||||
href="#"
|
href="#"
|
||||||
class="py-1.5 inline md:hidden"
|
class="py-1.5 inline md:hidden"
|
||||||
@click.prevent="$emit('minimize')"
|
@click.prevent="$emit('minimize')"
|
||||||
@@ -70,6 +81,13 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="field.description"
|
||||||
|
dir="auto"
|
||||||
|
class="mb-3 px-1"
|
||||||
|
>
|
||||||
|
<MarkdownContent :string="field.description" />
|
||||||
|
</div>
|
||||||
<AppearsOn :field="field" />
|
<AppearsOn :field="field" />
|
||||||
<input
|
<input
|
||||||
:value="modelValue || computedPreviousValue"
|
:value="modelValue || computedPreviousValue"
|
||||||
@@ -84,7 +102,7 @@
|
|||||||
<canvas
|
<canvas
|
||||||
v-show="!modelValue && !computedPreviousValue"
|
v-show="!modelValue && !computedPreviousValue"
|
||||||
ref="canvas"
|
ref="canvas"
|
||||||
class="bg-white border border-base-300 rounded-2xl"
|
class="bg-white border border-base-300 rounded-2xl w-full"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
|
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
|
||||||
@@ -105,6 +123,9 @@ import { cropCanvasAndExportToPNG } from './crop_canvas'
|
|||||||
import { IconReload, IconTextSize, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
import { IconReload, IconTextSize, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||||
import SignaturePad from 'signature_pad'
|
import SignaturePad from 'signature_pad'
|
||||||
import AppearsOn from './appears_on'
|
import AppearsOn from './appears_on'
|
||||||
|
import MarkdownContent from './markdown_content'
|
||||||
|
|
||||||
|
const scale = 3
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'InitialsStep',
|
name: 'InitialsStep',
|
||||||
@@ -113,6 +134,7 @@ export default {
|
|||||||
IconReload,
|
IconReload,
|
||||||
IconTextSize,
|
IconTextSize,
|
||||||
IconSignature,
|
IconSignature,
|
||||||
|
MarkdownContent,
|
||||||
IconArrowsDiagonalMinimize2
|
IconArrowsDiagonalMinimize2
|
||||||
},
|
},
|
||||||
inject: ['baseUrl', 't'],
|
inject: ['baseUrl', 't'],
|
||||||
@@ -171,8 +193,10 @@ export default {
|
|||||||
async mounted () {
|
async mounted () {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
if (this.$refs.canvas) {
|
if (this.$refs.canvas) {
|
||||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
|
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
|
||||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 5
|
this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 3) * scale
|
||||||
|
|
||||||
|
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$refs.textInput?.focus()
|
this.$refs.textInput?.focus()
|
||||||
@@ -190,8 +214,26 @@ export default {
|
|||||||
|
|
||||||
this.$emit('start')
|
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: {
|
methods: {
|
||||||
remove () {
|
remove () {
|
||||||
this.$emit('update:model-value', '')
|
this.$emit('update:model-value', '')
|
||||||
@@ -209,7 +251,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateWrittenInitials (e) {
|
updateWrittenInitials (e) {
|
||||||
this.isInitialsStarted = true
|
this.isInitialsStarted = !!e.target.value
|
||||||
|
|
||||||
const canvas = this.$refs.canvas
|
const canvas = this.$refs.canvas
|
||||||
const context = canvas.getContext('2d')
|
const context = canvas.getContext('2d')
|
||||||
@@ -222,8 +264,8 @@ export default {
|
|||||||
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
|
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
|
||||||
context.textAlign = 'center'
|
context.textAlign = 'center'
|
||||||
|
|
||||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
context.clearRect(0, 0, canvas.width / scale, canvas.height / scale)
|
||||||
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11)
|
context.fillText(e.target.value, canvas.width / 2 / scale, canvas.height / 2 / scale + 11)
|
||||||
},
|
},
|
||||||
toggleTextInput () {
|
toggleTextInput () {
|
||||||
this.remove()
|
this.remove()
|
||||||
|
|||||||
@@ -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>
|
<template>
|
||||||
<label
|
<label
|
||||||
v-if="showFieldNames && field.name"
|
v-if="showFieldNames && (field.name || field.title)"
|
||||||
:for="field.uuid"
|
:for="field.uuid"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
class="label text-2xl mb-2"
|
class="label text-2xl"
|
||||||
>{{ field.name }}</label>
|
: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">
|
<div class="flex w-full max-h-44 overflow-y-auto">
|
||||||
<input
|
<input
|
||||||
v-if="modelValue.length === 0"
|
v-if="modelValue.length === 0"
|
||||||
@@ -32,6 +45,7 @@
|
|||||||
<label
|
<label
|
||||||
:for="option.uuid"
|
:for="option.uuid"
|
||||||
class="flex items-center space-x-3"
|
class="flex items-center space-x-3"
|
||||||
|
@click="scrollIntoField(field)"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
:id="option.uuid"
|
:id="option.uuid"
|
||||||
@@ -53,8 +67,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import MarkdownContent from './markdown_content'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'MultiSelectStep',
|
name: 'MultiSelectStep',
|
||||||
|
components: {
|
||||||
|
MarkdownContent
|
||||||
|
},
|
||||||
inject: ['t', 'scrollIntoField'],
|
inject: ['t', 'scrollIntoField'],
|
||||||
props: {
|
props: {
|
||||||
field: {
|
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>
|
<div>
|
||||||
<label
|
<label
|
||||||
:for="isCodeSent ? 'one_time_code' : field.uuid"
|
:for="isCodeSent ? 'one_time_code' : field.uuid"
|
||||||
class="label text-2xl mb-2"
|
class="label text-2xl"
|
||||||
>{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }}
|
:class="{ 'mb-2': !field.description }"
|
||||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
>
|
||||||
|
<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>
|
</label>
|
||||||
|
<div
|
||||||
|
v-if="field.description"
|
||||||
|
dir="auto"
|
||||||
|
class="mb-3 px-1"
|
||||||
|
>
|
||||||
|
<MarkdownContent :string="field.description" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
@@ -53,14 +68,13 @@
|
|||||||
class="base-input !text-2xl w-full"
|
class="base-input !text-2xl w-full"
|
||||||
autocomplete="tel"
|
autocomplete="tel"
|
||||||
pattern="^\+[0-9\s\-]+$"
|
pattern="^\+[0-9\s\-]+$"
|
||||||
:oninvalid="`this.value ? this.setCustomValidity('${t('use_international_format')}...') : ''`"
|
|
||||||
oninput="this.setCustomValidity('')"
|
|
||||||
type="tel"
|
type="tel"
|
||||||
inputmode="tel"
|
inputmode="tel"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
placeholder="+1 234 567-8900"
|
placeholder="+1 234 567-8900"
|
||||||
:name="`values[${field.uuid}]`"
|
: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')"
|
@focus="$emit('focus')"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,6 +82,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import MarkdownContent from './markdown_content'
|
||||||
|
|
||||||
function throttle (func, delay) {
|
function throttle (func, delay) {
|
||||||
let lastCallTime = 0
|
let lastCallTime = 0
|
||||||
|
|
||||||
@@ -83,6 +99,9 @@ function throttle (func, delay) {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'PhoneStep',
|
name: 'PhoneStep',
|
||||||
|
components: {
|
||||||
|
MarkdownContent
|
||||||
|
},
|
||||||
inject: ['t', 'baseUrl'],
|
inject: ['t', 'baseUrl'],
|
||||||
props: {
|
props: {
|
||||||
field: {
|
field: {
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div dir="auto">
|
<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
|
<label
|
||||||
class="label text-2xl"
|
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">
|
<div class="space-x-2 flex">
|
||||||
<span
|
<span
|
||||||
v-if="isTextSignature"
|
v-if="isTextSignature && field.preferences?.format !== 'typed'"
|
||||||
class="tooltip"
|
class="tooltip"
|
||||||
:data-tip="t('draw_signature')"
|
:data-tip="t('draw_signature')"
|
||||||
>
|
>
|
||||||
@@ -23,14 +34,14 @@
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-else-if="withTypedSignature"
|
v-else-if="withTypedSignature && field.preferences?.format !== 'typed' && field.preferences?.format !== 'drawn'"
|
||||||
class="tooltip ml-2"
|
class="tooltip ml-2"
|
||||||
:data-tip="t('type_text')"
|
:data-tip="t('type_text')"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
id="type_text_button"
|
id="type_text_button"
|
||||||
href="#"
|
href="#"
|
||||||
class="btn btn-outline btn-sm font-medium"
|
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
|
||||||
@click.prevent="toggleTextInput"
|
@click.prevent="toggleTextInput"
|
||||||
>
|
>
|
||||||
<IconTextSize :width="16" />
|
<IconTextSize :width="16" />
|
||||||
@@ -40,14 +51,16 @@
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
v-if="field.preferences?.format !== 'typed'"
|
||||||
class="tooltip"
|
class="tooltip"
|
||||||
:data-tip="t('take_photo')"
|
:data-tip="t('take_photo')"
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
class="btn btn-outline btn-sm font-medium"
|
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
|
||||||
>
|
>
|
||||||
<IconCamera :width="16" />
|
<IconCamera :width="16" />
|
||||||
<input
|
<input
|
||||||
|
:key="uploadImageInputKey"
|
||||||
type="file"
|
type="file"
|
||||||
hidden
|
hidden
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
@@ -78,7 +91,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
title="Minimize"
|
:title="t('minimize')"
|
||||||
class="py-1.5 inline md:hidden"
|
class="py-1.5 inline md:hidden"
|
||||||
@click.prevent="$emit('minimize')"
|
@click.prevent="$emit('minimize')"
|
||||||
>
|
>
|
||||||
@@ -89,6 +102,13 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="field.description"
|
||||||
|
dir="auto"
|
||||||
|
class="mb-3 px-1"
|
||||||
|
>
|
||||||
|
<MarkdownContent :string="field.description" />
|
||||||
|
</div>
|
||||||
<AppearsOn :field="field" />
|
<AppearsOn :field="field" />
|
||||||
<input
|
<input
|
||||||
:value="modelValue || computedPreviousValue"
|
:value="modelValue || computedPreviousValue"
|
||||||
@@ -104,14 +124,14 @@
|
|||||||
v-show="!modelValue && !computedPreviousValue"
|
v-show="!modelValue && !computedPreviousValue"
|
||||||
ref="canvas"
|
ref="canvas"
|
||||||
style="padding: 1px; 0"
|
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
|
<input
|
||||||
v-if="isTextSignature"
|
v-if="isTextSignature"
|
||||||
id="signature_text_input"
|
id="signature_text_input"
|
||||||
ref="textInput"
|
ref="textInput"
|
||||||
class="base-input !text-2xl w-full mt-6"
|
class="base-input !text-2xl w-full mt-6"
|
||||||
:required="field.required"
|
:required="field.required && !isSignatureStarted"
|
||||||
:placeholder="`${t('type_signature_here')}...`"
|
:placeholder="`${t('type_signature_here')}...`"
|
||||||
type="text"
|
type="text"
|
||||||
@input="updateWrittenSignature"
|
@input="updateWrittenSignature"
|
||||||
@@ -124,15 +144,19 @@ import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonal
|
|||||||
import { cropCanvasAndExportToPNG } from './crop_canvas'
|
import { cropCanvasAndExportToPNG } from './crop_canvas'
|
||||||
import SignaturePad from 'signature_pad'
|
import SignaturePad from 'signature_pad'
|
||||||
import AppearsOn from './appears_on'
|
import AppearsOn from './appears_on'
|
||||||
|
import MarkdownContent from './markdown_content'
|
||||||
|
|
||||||
let isFontLoaded = false
|
let isFontLoaded = false
|
||||||
|
|
||||||
|
const scale = 3
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'SignatureStep',
|
name: 'SignatureStep',
|
||||||
components: {
|
components: {
|
||||||
AppearsOn,
|
AppearsOn,
|
||||||
IconReload,
|
IconReload,
|
||||||
IconCamera,
|
IconCamera,
|
||||||
|
MarkdownContent,
|
||||||
IconTextSize,
|
IconTextSize,
|
||||||
IconSignature,
|
IconSignature,
|
||||||
IconArrowsDiagonalMinimize2
|
IconArrowsDiagonalMinimize2
|
||||||
@@ -183,7 +207,8 @@ export default {
|
|||||||
return {
|
return {
|
||||||
isSignatureStarted: !!this.previousValue,
|
isSignatureStarted: !!this.previousValue,
|
||||||
isUsePreviousValue: true,
|
isUsePreviousValue: true,
|
||||||
isTextSignature: false
|
isTextSignature: this.field.preferences?.format === 'typed',
|
||||||
|
uploadImageInputKey: Math.random().toString()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -198,8 +223,10 @@ export default {
|
|||||||
async mounted () {
|
async mounted () {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
if (this.$refs.canvas) {
|
if (this.$refs.canvas) {
|
||||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
|
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
|
||||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
|
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
|
||||||
|
|
||||||
|
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -215,8 +242,26 @@ export default {
|
|||||||
|
|
||||||
this.$emit('start')
|
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: {
|
methods: {
|
||||||
remove () {
|
remove () {
|
||||||
this.$emit('update:model-value', '')
|
this.$emit('update:model-value', '')
|
||||||
@@ -247,7 +292,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateWrittenSignature (e) {
|
updateWrittenSignature (e) {
|
||||||
this.isSignatureStarted = true
|
this.isSignatureStarted = !!e.target.value
|
||||||
|
|
||||||
const canvas = this.$refs.canvas
|
const canvas = this.$refs.canvas
|
||||||
const context = canvas.getContext('2d')
|
const context = canvas.getContext('2d')
|
||||||
@@ -260,8 +305,8 @@ export default {
|
|||||||
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
|
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
|
||||||
context.textAlign = 'center'
|
context.textAlign = 'center'
|
||||||
|
|
||||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
context.clearRect(0, 0, canvas.width / scale, canvas.height / scale)
|
||||||
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11)
|
context.fillText(e.target.value, canvas.width / 2 / scale, canvas.height / 2 / scale + 11)
|
||||||
},
|
},
|
||||||
toggleTextInput () {
|
toggleTextInput () {
|
||||||
this.remove()
|
this.remove()
|
||||||
@@ -297,14 +342,16 @@ export default {
|
|||||||
const context = canvas.getContext('2d')
|
const context = canvas.getContext('2d')
|
||||||
|
|
||||||
const aspectRatio = img.width / img.height
|
const aspectRatio = img.width / img.height
|
||||||
|
const canvasWidth = canvas.width / scale
|
||||||
|
const canvasHeight = canvas.height / scale
|
||||||
|
|
||||||
let targetWidth = canvas.width
|
let targetWidth = canvasWidth
|
||||||
let targetHeight = canvas.height
|
let targetHeight = canvasHeight
|
||||||
|
|
||||||
if (canvas.width / canvas.height > aspectRatio) {
|
if (canvasWidth / canvasHeight > aspectRatio) {
|
||||||
targetWidth = canvas.height * aspectRatio
|
targetWidth = canvasHeight * aspectRatio
|
||||||
} else {
|
} else {
|
||||||
targetHeight = canvas.width / aspectRatio
|
targetHeight = canvasWidth / aspectRatio
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetHeight > targetWidth) {
|
if (targetHeight > targetWidth) {
|
||||||
@@ -313,17 +360,21 @@ export default {
|
|||||||
targetHeight = targetHeight * scale
|
targetHeight = targetHeight * scale
|
||||||
}
|
}
|
||||||
|
|
||||||
const x = (canvas.width - targetWidth) / 2
|
const x = (canvasWidth - targetWidth) / 2
|
||||||
const y = (canvas.height - targetHeight) / 2
|
const y = (canvasHeight - targetHeight) / 2
|
||||||
|
|
||||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
setTimeout(() => {
|
||||||
context.drawImage(img, x, y, targetWidth, targetHeight)
|
context.clearRect(0, 0, canvasWidth, canvasHeight)
|
||||||
|
context.drawImage(img, x, y, targetWidth, targetHeight)
|
||||||
|
|
||||||
this.$emit('start')
|
this.$emit('start')
|
||||||
|
}, 50)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
|
|
||||||
|
this.uploadImageInputKey = Math.random().toString()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async submit () {
|
async submit () {
|
||||||
@@ -335,8 +386,8 @@ export default {
|
|||||||
return Promise.resolve({})
|
return Promise.resolve({})
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve, reject) => {
|
||||||
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
|
cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => {
|
||||||
const file = new File([blob], 'signature.png', { type: 'image/png' })
|
const file = new File([blob], 'signature.png', { type: 'image/png' })
|
||||||
|
|
||||||
if (this.isDirectUpload) {
|
if (this.isDirectUpload) {
|
||||||
@@ -378,6 +429,8 @@ export default {
|
|||||||
return resolve(attachment)
|
return resolve(attachment)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
return reject(error)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<label
|
<label
|
||||||
v-if="showFieldNames && field.name"
|
v-if="showFieldNames && (field.name || field.title)"
|
||||||
:for="field.uuid"
|
:for="field.uuid"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
class="label text-2xl"
|
class="label text-2xl"
|
||||||
:class="{ 'mb-2': !field.description }"
|
: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-else>{{ field.name }}</template>
|
||||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||||
</label>
|
</label>
|
||||||
@@ -15,9 +18,11 @@
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="field.description"
|
v-if="field.description"
|
||||||
class="mb-3 px-1 text-lg"
|
dir="auto"
|
||||||
v-html="field.description"
|
class="mb-3 px-1"
|
||||||
/>
|
>
|
||||||
|
<MarkdownContent :string="field.description" />
|
||||||
|
</div>
|
||||||
<AppearsOn :field="field" />
|
<AppearsOn :field="field" />
|
||||||
<div class="items-center flex">
|
<div class="items-center flex">
|
||||||
<input
|
<input
|
||||||
@@ -30,11 +35,11 @@
|
|||||||
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
|
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:pattern="field.validation?.pattern"
|
:pattern="field.validation?.pattern"
|
||||||
:oninvalid="field.validation?.message ? `this.setCustomValidity(${JSON.stringify(field.validation.message)})` : ''"
|
|
||||||
:oninput="field.validation?.message ? `this.setCustomValidity('')` : ''"
|
|
||||||
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
|
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||||
type="text"
|
type="text"
|
||||||
:name="`values[${field.uuid}]`"
|
:name="`values[${field.uuid}]`"
|
||||||
|
@invalid="field.validation?.message ? $event.target.setCustomValidity(field.validation.message) : ''"
|
||||||
|
@input="field.validation?.message ? $event.target.setCustomValidity('') : ''"
|
||||||
@focus="$emit('focus')"
|
@focus="$emit('focus')"
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -69,11 +74,13 @@
|
|||||||
<script>
|
<script>
|
||||||
import { IconAlignBoxLeftTop } from '@tabler/icons-vue'
|
import { IconAlignBoxLeftTop } from '@tabler/icons-vue'
|
||||||
import AppearsOn from './appears_on'
|
import AppearsOn from './appears_on'
|
||||||
|
import MarkdownContent from './markdown_content'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'TextStep',
|
name: 'TextStep',
|
||||||
components: {
|
components: {
|
||||||
IconAlignBoxLeftTop,
|
IconAlignBoxLeftTop,
|
||||||
|
MarkdownContent,
|
||||||
AppearsOn
|
AppearsOn
|
||||||
},
|
},
|
||||||
inject: ['t'],
|
inject: ['t'],
|
||||||
@@ -123,12 +130,14 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
this.isTextArea = this.modelValue?.includes('\n')
|
if (this.modelValue) {
|
||||||
|
this.isTextArea = this.modelValue.toString().includes('\n')
|
||||||
|
|
||||||
if (this.isTextArea) {
|
if (this.isTextArea) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.resizeTextarea()
|
this.resizeTextarea()
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
v-model="field.submitter_uuid"
|
v-model="field.submitter_uuid"
|
||||||
class="border-r"
|
class="border-r"
|
||||||
:compact="true"
|
:compact="true"
|
||||||
:editable="editable && !defaultField"
|
:editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
|
||||||
:allow-add-new="!defaultSubmitters.length"
|
: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]'"
|
: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"
|
:submitters="template.submitters"
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
<button
|
<button
|
||||||
v-else-if="editable"
|
v-else-if="editable"
|
||||||
class="pr-1"
|
class="pr-1"
|
||||||
title="Remove"
|
:title="t('remove')"
|
||||||
@click.prevent="$emit('remove')"
|
@click.prevent="$emit('remove')"
|
||||||
>
|
>
|
||||||
<IconX width="14" />
|
<IconX width="14" />
|
||||||
@@ -98,21 +98,32 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex items-center h-full w-full"
|
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
|
<span
|
||||||
v-if="field"
|
v-if="field"
|
||||||
class="flex justify-center items-center space-x-1 h-full"
|
class="flex justify-center items-center space-x-1 h-full"
|
||||||
|
:class="{'w-full h-full': field.type == 'checkbox'}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="field?.default_value"
|
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
|
<div
|
||||||
ref="textContainer"
|
ref="textContainer"
|
||||||
class="flex items-center px-0.5"
|
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>
|
||||||
</div>
|
</div>
|
||||||
<component
|
<component
|
||||||
@@ -141,13 +152,14 @@
|
|||||||
import FieldSubmitter from './field_submitter'
|
import FieldSubmitter from './field_submitter'
|
||||||
import FieldType from './field_type'
|
import FieldType from './field_type'
|
||||||
import Field from './field'
|
import Field from './field'
|
||||||
import { IconX } from '@tabler/icons-vue'
|
import { IconX, IconCheck } from '@tabler/icons-vue'
|
||||||
import { v4 } from 'uuid'
|
import { v4 } from 'uuid'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'FieldArea',
|
name: 'FieldArea',
|
||||||
components: {
|
components: {
|
||||||
FieldType,
|
FieldType,
|
||||||
|
IconCheck,
|
||||||
FieldSubmitter,
|
FieldSubmitter,
|
||||||
IconX
|
IconX
|
||||||
},
|
},
|
||||||
@@ -194,9 +206,18 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
defaultName: Field.computed.defaultName,
|
|
||||||
fieldNames: FieldType.computed.fieldNames,
|
fieldNames: FieldType.computed.fieldNames,
|
||||||
fieldIcons: FieldType.computed.fieldIcons,
|
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 () {
|
optionIndexText () {
|
||||||
if (this.area.option_uuid && this.field.options) {
|
if (this.area.option_uuid && this.field.options) {
|
||||||
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
|
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
|
||||||
@@ -225,6 +246,16 @@ export default {
|
|||||||
},
|
},
|
||||||
borderColors () {
|
borderColors () {
|
||||||
return [
|
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-red-500/80',
|
||||||
'border-sky-500/80',
|
'border-sky-500/80',
|
||||||
'border-emerald-500/80',
|
'border-emerald-500/80',
|
||||||
@@ -239,6 +270,16 @@ export default {
|
|||||||
},
|
},
|
||||||
bgColors () {
|
bgColors () {
|
||||||
return [
|
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-red-100/80',
|
||||||
'bg-sky-100/80',
|
'bg-sky-100/80',
|
||||||
'bg-emerald-100/80',
|
'bg-emerald-100/80',
|
||||||
@@ -267,19 +308,22 @@ export default {
|
|||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
'field.default_value' () {
|
'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.$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: {
|
methods: {
|
||||||
|
buildDefaultName: Field.methods.buildDefaultName,
|
||||||
onNameFocus (e) {
|
onNameFocus (e) {
|
||||||
this.selectedAreaRef.value = this.area
|
this.selectedAreaRef.value = this.area
|
||||||
|
|
||||||
@@ -308,7 +352,7 @@ export default {
|
|||||||
},
|
},
|
||||||
onResizeCell (e) {
|
onResizeCell (e) {
|
||||||
if (e.target.id === 'mask') {
|
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) {
|
if (positionX > this.area.x) {
|
||||||
this.area.cell_w = positionX - this.area.x
|
this.area.cell_w = positionX - this.area.x
|
||||||
@@ -354,16 +398,16 @@ export default {
|
|||||||
},
|
},
|
||||||
resize (e) {
|
resize (e) {
|
||||||
if (e.target.id === 'mask') {
|
if (e.target.id === 'mask') {
|
||||||
this.area.w = e.layerX / e.target.clientWidth - this.area.x
|
this.area.w = e.offsetX / e.target.clientWidth - this.area.x
|
||||||
this.area.h = e.layerY / e.target.clientHeight - this.area.y
|
this.area.h = e.offsetY / e.target.clientHeight - this.area.y
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
drag (e) {
|
drag (e) {
|
||||||
if (e.target.id === 'mask') {
|
if (e.target.id === 'mask') {
|
||||||
this.isDragged = true
|
this.isDragged = true
|
||||||
|
|
||||||
this.area.x = (e.layerX - this.dragFrom.x) / e.target.clientWidth
|
this.area.x = (e.offsetX - this.dragFrom.x) / e.target.clientWidth
|
||||||
this.area.y = (e.layerY - this.dragFrom.y) / e.target.clientHeight
|
this.area.y = (e.offsetY - this.dragFrom.y) / e.target.clientHeight
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
startDrag (e) {
|
startDrag (e) {
|
||||||
|
|||||||
@@ -94,11 +94,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
id="main_container"
|
||||||
class="flex"
|
class="flex"
|
||||||
:class="$slots.buttons || withTitle ? 'md:max-h-[calc(100%_-_60px)]' : 'md:max-h-[100%]'"
|
:class="$slots.buttons || withTitle ? 'md:max-h-[calc(100%_-_60px)]' : 'md:max-h-[100%]'"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="withDocumentsList"
|
v-if="withDocumentsList"
|
||||||
|
id="documents_container"
|
||||||
ref="previews"
|
ref="previews"
|
||||||
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
|
: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"
|
class="overflow-y-auto overflow-x-hidden w-52 flex-none pr-3 mt-0.5 pt-0.5 hidden lg:block"
|
||||||
@@ -134,7 +136,10 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
ref="documents"
|
ref="documents"
|
||||||
class="pr-3.5 pl-0.5"
|
class="pr-3.5 pl-0.5"
|
||||||
@@ -161,9 +166,10 @@
|
|||||||
:allow-draw="!onlyDefinedFields"
|
:allow-draw="!onlyDefinedFields"
|
||||||
:default-submitters="defaultSubmitters"
|
:default-submitters="defaultSubmitters"
|
||||||
:draw-field="drawField"
|
:draw-field="drawField"
|
||||||
|
:draw-field-type="drawFieldType"
|
||||||
:editable="editable"
|
:editable="editable"
|
||||||
:base-url="baseUrl"
|
:base-url="baseUrl"
|
||||||
@draw="onDraw"
|
@draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
|
||||||
@drop-field="onDropfield"
|
@drop-field="onDropfield"
|
||||||
@remove-area="removeArea"
|
@remove-area="removeArea"
|
||||||
/>
|
/>
|
||||||
@@ -200,17 +206,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="withFieldsList"
|
v-if="withFieldsList"
|
||||||
|
id="fields_list_container"
|
||||||
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
|
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'"
|
:class="drawField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="drawField"
|
v-if="showDrawField || drawField"
|
||||||
class="sticky inset-0 h-full z-20"
|
class="sticky inset-0 h-full z-20"
|
||||||
:style="{ backgroundColor }"
|
: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>
|
<p>
|
||||||
{{ t('draw_field_on_the_document').replace('{field}', drawField.name) }}
|
{{ t('draw_field_on_the_document').replace('{field}', drawField?.name || '') }}
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@@ -220,10 +227,10 @@
|
|||||||
{{ t('cancel') }}
|
{{ t('cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<a
|
<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="#"
|
href="#"
|
||||||
class="link block mt-3 text-sm"
|
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') }}
|
{{ t('or_add_field_without_drawing') }}
|
||||||
</a>
|
</a>
|
||||||
@@ -238,12 +245,15 @@
|
|||||||
:selected-submitter="selectedSubmitter"
|
:selected-submitter="selectedSubmitter"
|
||||||
:with-help="withHelp"
|
:with-help="withHelp"
|
||||||
:default-submitters="defaultSubmitters"
|
:default-submitters="defaultSubmitters"
|
||||||
|
:draw-field-type="drawFieldType"
|
||||||
:default-fields="defaultFields"
|
:default-fields="defaultFields"
|
||||||
:field-types="fieldTypes"
|
:field-types="fieldTypes"
|
||||||
:with-sticky-submitters="withStickySubmitters"
|
:with-sticky-submitters="withStickySubmitters"
|
||||||
:only-defined-fields="onlyDefinedFields"
|
:only-defined-fields="onlyDefinedFields"
|
||||||
:editable="editable"
|
:editable="editable"
|
||||||
|
@add-field="addField"
|
||||||
@set-draw="[drawField = $event.field, drawOption = $event.option]"
|
@set-draw="[drawField = $event.field, drawOption = $event.option]"
|
||||||
|
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
|
||||||
@set-drag="dragField = $event"
|
@set-drag="dragField = $event"
|
||||||
@change-submitter="selectedSubmitter = $event"
|
@change-submitter="selectedSubmitter = $event"
|
||||||
@drag-end="dragField = null"
|
@drag-end="dragField = null"
|
||||||
@@ -273,6 +283,7 @@
|
|||||||
@select="startFieldDraw($event)"
|
@select="startFieldDraw($event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="docuseal_modal_container" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -321,7 +332,12 @@ export default {
|
|||||||
backgroundColor: this.backgroundColor,
|
backgroundColor: this.backgroundColor,
|
||||||
withPhone: this.withPhone,
|
withPhone: this.withPhone,
|
||||||
withPayment: this.withPayment,
|
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: {
|
props: {
|
||||||
@@ -364,6 +380,16 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
|
withSelectedFieldType: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
defaultDrawFieldType: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: 'text'
|
||||||
|
},
|
||||||
currencies: {
|
currencies: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -428,6 +454,11 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: true
|
default: true
|
||||||
},
|
},
|
||||||
|
authenticityToken: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
withDocumentsList: {
|
withDocumentsList: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -443,6 +474,21 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: 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: {
|
onlyDefinedFields: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -460,13 +506,16 @@ export default {
|
|||||||
isBreakpointLg: false,
|
isBreakpointLg: false,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
selectedSubmitter: null,
|
selectedSubmitter: null,
|
||||||
|
showDrawField: false,
|
||||||
drawField: null,
|
drawField: null,
|
||||||
|
drawFieldType: null,
|
||||||
drawOption: null,
|
drawOption: null,
|
||||||
dragField: null
|
dragField: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
selectedAreaRef: () => ref(),
|
selectedAreaRef: () => ref(),
|
||||||
|
fieldsDragFieldRef: () => ref(),
|
||||||
fieldAreasIndex () {
|
fieldAreasIndex () {
|
||||||
const areas = {}
|
const areas = {}
|
||||||
|
|
||||||
@@ -502,7 +551,7 @@ export default {
|
|||||||
submitter.name = name
|
submitter.name = name
|
||||||
|
|
||||||
if (existingSubmittersUuids.filter(Boolean).length) {
|
if (existingSubmittersUuids.filter(Boolean).length) {
|
||||||
submitter.uuid = existingSubmittersUuids[index] || v4()
|
submitter.uuid = existingSubmittersUuids[index] || submitter.uuid || v4()
|
||||||
} else {
|
} else {
|
||||||
submitter.uuid ||= v4()
|
submitter.uuid ||= v4()
|
||||||
}
|
}
|
||||||
@@ -542,6 +591,34 @@ export default {
|
|||||||
t (key) {
|
t (key) {
|
||||||
return this.i18n[key] || i18nEn[key] || 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 }) {
|
startFieldDraw ({ name, type }) {
|
||||||
const existingField = this.template.fields?.find((f) => f.submitter_uuid === this.selectedSubmitter.uuid && name && name === f.name)
|
const existingField = this.template.fields?.find((f) => f.submitter_uuid === this.selectedSubmitter.uuid && name && name === f.name)
|
||||||
|
|
||||||
@@ -622,15 +699,13 @@ export default {
|
|||||||
ref.$el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
ref.$el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
},
|
},
|
||||||
clearDrawField () {
|
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.drawField = null
|
||||||
this.drawOption = null
|
this.drawOption = null
|
||||||
|
this.showDrawField = false
|
||||||
|
|
||||||
|
if (!this.withSelectedFieldType) {
|
||||||
|
this.drawFieldType = ''
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onKeyUp (e) {
|
onKeyUp (e) {
|
||||||
if (e.code === 'Escape') {
|
if (e.code === 'Escape') {
|
||||||
@@ -680,6 +755,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) {
|
onDraw (area) {
|
||||||
if (this.drawField) {
|
if (this.drawField) {
|
||||||
if (this.drawOption) {
|
if (this.drawOption) {
|
||||||
@@ -702,25 +798,7 @@ export default {
|
|||||||
area.w = previousArea.w
|
area.w = previousArea.w
|
||||||
area.h = previousArea.h
|
area.h = previousArea.h
|
||||||
} else {
|
} else {
|
||||||
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
|
this.setDefaultAreaSize(area, this.drawOption ? 'checkbox' : this.drawField?.type)
|
||||||
const pageMask = documentRef.pageRefs[area.page].$refs.mask
|
|
||||||
|
|
||||||
if (this.drawField.type === 'checkbox' || this.drawOption) {
|
|
||||||
area.w = pageMask.clientWidth / 30 / pageMask.clientWidth
|
|
||||||
area.h = (pageMask.clientWidth / 30 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
|
|
||||||
} else if (this.drawField.type === 'image') {
|
|
||||||
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
|
|
||||||
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
|
|
||||||
} else if (this.drawField.type === 'signature' || this.drawField.type === 'stamp') {
|
|
||||||
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
|
|
||||||
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight) / 2
|
|
||||||
} else if (this.drawField.type === 'initials') {
|
|
||||||
area.w = pageMask.clientWidth / 10 / pageMask.clientWidth
|
|
||||||
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
|
|
||||||
} else {
|
|
||||||
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
|
|
||||||
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
area.x -= area.w / 2
|
area.x -= area.w / 2
|
||||||
@@ -755,11 +833,15 @@ export default {
|
|||||||
|
|
||||||
let type = (pageMask.clientWidth * area.w) < 35 ? 'checkbox' : 'text'
|
let type = (pageMask.clientWidth * area.w) < 35 ? 'checkbox' : 'text'
|
||||||
|
|
||||||
if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) {
|
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]
|
type = this.fieldTypes[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'checkbox') {
|
if (type === 'checkbox' && !this.drawFieldType) {
|
||||||
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
|
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
|
||||||
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
|
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
|
||||||
|
|
||||||
@@ -777,26 +859,27 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (area.w) {
|
if (this.drawFieldType && (area.w === 0 || area.h === 0)) {
|
||||||
const field = {
|
if (this.selectedField?.type === this.drawFieldType) {
|
||||||
name: '',
|
area.w = this.selectedAreaRef.value.w
|
||||||
uuid: v4(),
|
area.h = this.selectedAreaRef.value.h
|
||||||
required: type !== 'checkbox',
|
} else {
|
||||||
type,
|
this.setDefaultAreaSize(area, this.drawFieldType)
|
||||||
submitter_uuid: this.selectedSubmitter.uuid,
|
|
||||||
areas: [area]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.selectedAreaRef.value = area
|
||||||
|
|
||||||
this.save()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDropfield (area) {
|
onDropfield (area) {
|
||||||
const field = {
|
const field = this.fieldsDragFieldRef.value || {
|
||||||
name: '',
|
name: '',
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
submitter_uuid: this.selectedSubmitter.uuid,
|
submitter_uuid: this.selectedSubmitter.uuid,
|
||||||
@@ -804,17 +887,19 @@ export default {
|
|||||||
...this.dragField
|
...this.dragField
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['select', 'multiple', 'radio'].includes(field.type)) {
|
if (!this.fieldsDragFieldRef.value) {
|
||||||
field.options = [{ value: '', uuid: v4() }]
|
if (['select', 'multiple', 'radio'].includes(field.type)) {
|
||||||
}
|
field.options = [{ value: '', uuid: v4() }]
|
||||||
|
}
|
||||||
|
|
||||||
if (field.type === 'stamp') {
|
if (field.type === 'stamp') {
|
||||||
field.readonly = true
|
field.readonly = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.type === 'date') {
|
if (field.type === 'date') {
|
||||||
field.preferences = {
|
field.preferences = {
|
||||||
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
|
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -870,11 +955,23 @@ export default {
|
|||||||
fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5)
|
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.selectedAreaRef.value = fieldArea
|
||||||
|
|
||||||
this.template.fields.push(field)
|
if (this.template.fields.indexOf(field) === -1) {
|
||||||
|
this.template.fields.push(field)
|
||||||
|
}
|
||||||
|
|
||||||
this.save()
|
this.save()
|
||||||
},
|
},
|
||||||
@@ -908,14 +1005,21 @@ export default {
|
|||||||
this.template.schema.splice(this.template.schema.indexOf(item), 1)
|
this.template.schema.splice(this.template.schema.indexOf(item), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removedFieldUuids = []
|
||||||
|
|
||||||
this.template.fields.forEach((field) => {
|
this.template.fields.forEach((field) => {
|
||||||
[...(field.areas || [])].forEach((area) => {
|
[...(field.areas || [])].forEach((area) => {
|
||||||
if (area.attachment_uuid === item.attachment_uuid) {
|
if (area.attachment_uuid === item.attachment_uuid) {
|
||||||
field.areas.splice(field.areas.indexOf(area), 1)
|
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()
|
this.save()
|
||||||
},
|
},
|
||||||
onDocumentReplace ({ replaceSchemaItem, schema, documents }) {
|
onDocumentReplace ({ replaceSchemaItem, schema, documents }) {
|
||||||
@@ -980,7 +1084,11 @@ export default {
|
|||||||
baseFetch (path, options = {}) {
|
baseFetch (path, options = {}) {
|
||||||
return fetch(this.baseUrl + path, {
|
return fetch(this.baseUrl + path, {
|
||||||
...options,
|
...options,
|
||||||
headers: { ...this.fetchOptions.headers, ...options.headers }
|
headers: {
|
||||||
|
'X-CSRF-Token': this.authenticityToken,
|
||||||
|
...this.fetchOptions.headers,
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
save ({ force } = { force: false }) {
|
save ({ force } = { force: false }) {
|
||||||
@@ -996,7 +1104,7 @@ export default {
|
|||||||
|
|
||||||
this.pushUndo()
|
this.pushUndo()
|
||||||
|
|
||||||
return this.baseFetch(`/api/templates/${this.template.id}`, {
|
return this.baseFetch(`/templates/${this.template.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
template: {
|
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>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<IconPencil
|
<IconPencil
|
||||||
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
|
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' }"
|
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
|
||||||
title="Edit"
|
:title="t('edit')"
|
||||||
:class="{ invisible: !editable, 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
|
:class="{ invisible: !editable, 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
|
||||||
:width="iconWidth"
|
:width="iconWidth"
|
||||||
:stroke-width="iconStrokeWidth"
|
:stroke-width="iconStrokeWidth"
|
||||||
@@ -44,6 +44,7 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
IconPencil
|
IconPencil
|
||||||
},
|
},
|
||||||
|
inject: ['t'],
|
||||||
props: {
|
props: {
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -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-fields="defaultFields"
|
||||||
:default-submitters="defaultSubmitters"
|
:default-submitters="defaultSubmitters"
|
||||||
:draw-field="drawField"
|
:draw-field="drawField"
|
||||||
|
:draw-field-type="drawFieldType"
|
||||||
:selected-submitter="selectedSubmitter"
|
:selected-submitter="selectedSubmitter"
|
||||||
:image="image"
|
:image="image"
|
||||||
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
|
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
|
||||||
@@ -43,6 +44,11 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
|
drawFieldType: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
defaultSubmitters: {
|
defaultSubmitters: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -102,7 +108,7 @@ export default {
|
|||||||
return this.previewImagesIndex[i] || {
|
return this.previewImagesIndex[i] || {
|
||||||
metadata: lazyloadMetadata,
|
metadata: lazyloadMetadata,
|
||||||
id: Math.random().toString(),
|
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"
|
@drop.prevent="onDropFiles"
|
||||||
>
|
>
|
||||||
<label
|
<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"
|
:for="inputId"
|
||||||
:class="{ 'opacity-50': isLoading || isProcessing }"
|
:class="{ 'opacity-50': isLoading || isProcessing }"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="group pb-2"
|
class="list-field group mb-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="border border-base-300 rounded rounded-tr-none relative group"
|
class="border border-base-300 rounded rounded-tr-none relative group"
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-if="field && !field.areas.length"
|
v-if="field && !field.areas.length"
|
||||||
title="Draw"
|
:title="t('draw')"
|
||||||
class="relative cursor-pointer text-transparent group-hover:text-base-content"
|
class="relative cursor-pointer text-transparent group-hover:text-base-content"
|
||||||
@click="$emit('set-draw', { field })"
|
@click="$emit('set-draw', { field })"
|
||||||
>
|
>
|
||||||
@@ -65,6 +65,28 @@
|
|||||||
:stroke-width="1.6"
|
:stroke-width="1.6"
|
||||||
/>
|
/>
|
||||||
</button>
|
</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
|
<PaymentSettings
|
||||||
v-if="field.type === 'payment'"
|
v-if="field.type === 'payment'"
|
||||||
:field="field"
|
:field="field"
|
||||||
@@ -77,7 +99,7 @@
|
|||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
title="Settings"
|
:title="t('settings')"
|
||||||
class="cursor-pointer text-transparent group-hover:text-base-content"
|
class="cursor-pointer text-transparent group-hover:text-base-content"
|
||||||
>
|
>
|
||||||
<IconSettings
|
<IconSettings
|
||||||
@@ -94,15 +116,41 @@
|
|||||||
@click="closeDropdown"
|
@click="closeDropdown"
|
||||||
>
|
>
|
||||||
<div
|
<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"
|
class="py-1.5 px-1 relative"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="field.default_value"
|
v-model="field.default_value"
|
||||||
type="text"
|
|
||||||
:placeholder="t('default_value')"
|
:placeholder="t('default_value')"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
|
:type="field.type"
|
||||||
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
|
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
|
||||||
@blur="save"
|
@blur="save"
|
||||||
>
|
>
|
||||||
@@ -142,8 +190,45 @@
|
|||||||
{{ t('format') }}
|
{{ t('format') }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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
|
<li
|
||||||
v-if="field.type != 'phone'"
|
v-if="field.type != 'phone' && field.type != 'stamp'"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<label class="cursor-pointer py-1.5">
|
<label class="cursor-pointer py-1.5">
|
||||||
@@ -157,7 +242,49 @@
|
|||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
<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
|
@click.stop
|
||||||
>
|
>
|
||||||
<label class="cursor-pointer py-1.5">
|
<label class="cursor-pointer py-1.5">
|
||||||
@@ -170,9 +297,56 @@
|
|||||||
<span class="label-text">{{ t('read_only') }}</span>
|
<span class="label-text">{{ t('read_only') }}</span>
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</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">
|
<hr class="pb-0.5 mt-0.5">
|
||||||
<li
|
<li
|
||||||
v-for="(area, index) in field.areas || []"
|
v-for="(area, index) in sortedAreas"
|
||||||
:key="index"
|
:key="index"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
@@ -184,7 +358,8 @@
|
|||||||
:width="20"
|
:width="20"
|
||||||
:stroke-width="1.6"
|
: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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)">
|
<li v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)">
|
||||||
@@ -217,7 +392,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
class="relative text-transparent group-hover:text-base-content pr-1"
|
class="relative text-transparent group-hover:text-base-content pr-1"
|
||||||
title="Remove"
|
:title="t('remove')"
|
||||||
@click="$emit('remove', field)"
|
@click="$emit('remove', field)"
|
||||||
>
|
>
|
||||||
<IconTrashX
|
<IconTrashX
|
||||||
@@ -243,7 +418,7 @@
|
|||||||
{{ index + 1 }}.
|
{{ index + 1 }}.
|
||||||
</span>
|
</span>
|
||||||
<div
|
<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"
|
class="items-center flex w-full"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -256,7 +431,7 @@
|
|||||||
@blur="save"
|
@blur="save"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
title="Draw"
|
:title="t('draw')"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@click.prevent="$emit('set-draw', { field, option })"
|
@click.prevent="$emit('set-draw', { field, option })"
|
||||||
>
|
>
|
||||||
@@ -272,12 +447,14 @@
|
|||||||
class="w-full input input-primary input-xs text-sm bg-transparent"
|
class="w-full input input-primary input-xs text-sm bg-transparent"
|
||||||
:placeholder="`${t('option')} ${index + 1}`"
|
:placeholder="`${t('option')} ${index + 1}`"
|
||||||
type="text"
|
type="text"
|
||||||
|
:readonly="!editable"
|
||||||
required
|
required
|
||||||
dir="auto"
|
dir="auto"
|
||||||
@focus="maybeFocusOnOptionArea(option)"
|
@focus="maybeFocusOnOptionArea(option)"
|
||||||
@blur="save"
|
@blur="save"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
v-if="editable"
|
||||||
class="text-sm w-3.5"
|
class="text-sm w-3.5"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@click="removeOption(option)"
|
@click="removeOption(option)"
|
||||||
@@ -285,8 +462,12 @@
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="field.options && !editable"
|
||||||
|
class="pb-1"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="field.options"
|
v-else-if="field.options && editable"
|
||||||
class="text-center text-sm w-full pb-1"
|
class="text-center text-sm w-full pb-1"
|
||||||
@click="addOption"
|
@click="addOption"
|
||||||
>
|
>
|
||||||
@@ -294,6 +475,36 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -301,7 +512,10 @@
|
|||||||
import Contenteditable from './contenteditable'
|
import Contenteditable from './contenteditable'
|
||||||
import FieldType from './field_type'
|
import FieldType from './field_type'
|
||||||
import PaymentSettings from './payment_settings'
|
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'
|
import { v4 } from 'uuid'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -312,7 +526,13 @@ export default {
|
|||||||
IconShape,
|
IconShape,
|
||||||
PaymentSettings,
|
PaymentSettings,
|
||||||
IconNewSection,
|
IconNewSection,
|
||||||
|
IconInfoCircle,
|
||||||
|
FormulaModal,
|
||||||
|
DescriptionModal,
|
||||||
|
ConditionsModal,
|
||||||
|
IconRouteAltLeft,
|
||||||
IconTrashX,
|
IconTrashX,
|
||||||
|
IconMathFunction,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
FieldType
|
FieldType
|
||||||
},
|
},
|
||||||
@@ -338,11 +558,29 @@ export default {
|
|||||||
return {
|
return {
|
||||||
isNameFocus: false,
|
isNameFocus: false,
|
||||||
showPaymentModal: false,
|
showPaymentModal: false,
|
||||||
|
isShowFormulaModal: false,
|
||||||
|
isShowConditionsModal: false,
|
||||||
|
isShowDescriptionModal: false,
|
||||||
renderDropdown: false
|
renderDropdown: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
fieldNames: FieldType.computed.fieldNames,
|
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 () {
|
dateFormats () {
|
||||||
return [
|
return [
|
||||||
'MM/DD/YYYY',
|
'MM/DD/YYYY',
|
||||||
@@ -357,22 +595,7 @@ export default {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
defaultName () {
|
defaultName () {
|
||||||
if (this.field.type === 'payment' && this.field.preferences?.price) {
|
return this.buildDefaultName(this.field, this.template.fields)
|
||||||
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}`
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
areas () {
|
areas () {
|
||||||
return this.field.areas || []
|
return this.field.areas || []
|
||||||
@@ -387,6 +610,24 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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) {
|
formatDate (date, format) {
|
||||||
const monthFormats = {
|
const monthFormats = {
|
||||||
M: 'numeric',
|
M: 'numeric',
|
||||||
@@ -420,11 +661,13 @@ export default {
|
|||||||
const areaString = JSON.stringify(field.areas[0])
|
const areaString = JSON.stringify(field.areas[0])
|
||||||
|
|
||||||
this.template.documents.forEach((attachment) => {
|
this.template.documents.forEach((attachment) => {
|
||||||
attachment.preview_images.forEach((page) => {
|
const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length
|
||||||
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) })
|
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(() => {
|
this.$nextTick(() => {
|
||||||
@@ -450,7 +693,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
scrollToFirstArea () {
|
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 () {
|
closeDropdown () {
|
||||||
document.activeElement.blur()
|
document.activeElement.blur()
|
||||||
@@ -468,7 +711,12 @@ export default {
|
|||||||
},
|
},
|
||||||
removeOption (option) {
|
removeOption (option) {
|
||||||
this.field.options.splice(this.field.options.indexOf(option), 1)
|
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()
|
this.save()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="submitters.length < 10 && editable">
|
<li v-if="submitters.length < names.length && editable">
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
class="flex px-2"
|
class="flex px-2"
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
class="flex px-2 group justify-between items-center"
|
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)"
|
@click.prevent="selectSubmitter(submitter)"
|
||||||
>
|
>
|
||||||
<span class="py-1 flex items-center">
|
<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">
|
<div class="flex-col pr-1 hidden group-hover:flex -mt-1 h-0">
|
||||||
<button
|
<button
|
||||||
title="Up"
|
:title="t('up')"
|
||||||
class="relative w-2"
|
class="relative w-2"
|
||||||
style="font-size: 10px; margin-bottom: -4px"
|
style="font-size: 10px; margin-bottom: -4px"
|
||||||
@click.prevent.stop="[move(submitter, -1), $refs.label.focus()] "
|
@click.prevent.stop="[move(submitter, -1), $refs.label.focus()] "
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
▲
|
▲
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Down"
|
:title="t('down')"
|
||||||
class="relative w-2"
|
class="relative w-2"
|
||||||
style="font-size: 10px; margin-top: -4px"
|
style="font-size: 10px; margin-top: -4px"
|
||||||
@click.prevent.stop="[move(submitter, 1), $refs.label.focus()] "
|
@click.prevent.stop="[move(submitter, 1), $refs.label.focus()] "
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="submitters.length < 10 && editable && allowAddNew">
|
<li v-if="submitters.length < names.length && editable && allowAddNew">
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
class="flex px-2"
|
class="flex px-2"
|
||||||
@@ -268,6 +268,16 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
colors () {
|
colors () {
|
||||||
return [
|
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-red-500',
|
||||||
'bg-sky-500',
|
'bg-sky-500',
|
||||||
'bg-emerald-500',
|
'bg-emerald-500',
|
||||||
@@ -291,7 +301,17 @@ export default {
|
|||||||
this.t('seventh_party'),
|
this.t('seventh_party'),
|
||||||
this.t('eighth_party'),
|
this.t('eighth_party'),
|
||||||
this.t('ninth_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 () {
|
selectedSubmitter () {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
@click="closeDropdown"
|
@click="closeDropdown"
|
||||||
>
|
>
|
||||||
<template
|
<template
|
||||||
v-for="(icon, type) in fieldIcons"
|
v-for="(icon, type) in fieldIconsSorted"
|
||||||
:key="type"
|
:key="type"
|
||||||
>
|
>
|
||||||
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')">
|
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')">
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<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 {
|
export default {
|
||||||
name: 'FiledTypeDropdown',
|
name: 'FiledTypeDropdown',
|
||||||
@@ -94,6 +94,7 @@ export default {
|
|||||||
signature: this.t('signature'),
|
signature: this.t('signature'),
|
||||||
initials: this.t('initials'),
|
initials: this.t('initials'),
|
||||||
date: this.t('date'),
|
date: this.t('date'),
|
||||||
|
number: this.t('number'),
|
||||||
image: this.t('image'),
|
image: this.t('image'),
|
||||||
file: this.t('file'),
|
file: this.t('file'),
|
||||||
select: this.t('select'),
|
select: this.t('select'),
|
||||||
@@ -112,17 +113,29 @@ export default {
|
|||||||
signature: IconWritingSign,
|
signature: IconWritingSign,
|
||||||
initials: IconLetterCaseUpper,
|
initials: IconLetterCaseUpper,
|
||||||
date: IconCalendarEvent,
|
date: IconCalendarEvent,
|
||||||
|
number: IconSquareNumber1,
|
||||||
image: IconPhoto,
|
image: IconPhoto,
|
||||||
file: IconPaperclip,
|
|
||||||
checkbox: IconCheckbox,
|
checkbox: IconCheckbox,
|
||||||
|
multiple: IconChecks,
|
||||||
|
file: IconPaperclip,
|
||||||
radio: IconCircleDot,
|
radio: IconCircleDot,
|
||||||
select: IconSelect,
|
select: IconSelect,
|
||||||
multiple: IconChecks,
|
|
||||||
cells: IconColumns3,
|
cells: IconColumns3,
|
||||||
stamp: IconRubberStamp,
|
stamp: IconRubberStamp,
|
||||||
payment: IconCreditCard,
|
payment: IconCreditCard,
|
||||||
phone: IconPhoneCheck
|
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: {
|
methods: {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div :class="withStickySubmitters ? 'sticky top-0 z-10' : ''">
|
<div :class="withStickySubmitters ? 'sticky top-0 z-10' : ''">
|
||||||
<FieldSubmitter
|
<FieldSubmitter
|
||||||
:model-value="selectedSubmitter.uuid"
|
:model-value="selectedSubmitter.uuid"
|
||||||
class="w-full rounded-lg"
|
class="roles-dropdown w-full rounded-lg"
|
||||||
:class="{ 'bg-base-100': withStickySubmitters }"
|
:class="{ 'bg-base-100': withStickySubmitters }"
|
||||||
:submitters="submitters"
|
:submitters="submitters"
|
||||||
:editable="editable && !defaultSubmitters.length"
|
:editable="editable && !defaultSubmitters.length"
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref="fields"
|
ref="fields"
|
||||||
class="mb-1 mt-2"
|
class="fields mb-1 mt-2"
|
||||||
@dragover.prevent="onFieldDragover"
|
@dragover.prevent="onFieldDragover"
|
||||||
@drop="reorderFields"
|
@drop="reorderFields"
|
||||||
>
|
>
|
||||||
@@ -24,11 +24,11 @@
|
|||||||
:data-uuid="field.uuid"
|
:data-uuid="field.uuid"
|
||||||
:field="field"
|
:field="field"
|
||||||
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
|
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
|
||||||
:editable="editable && (!dragField || dragField !== field)"
|
:editable="editable && (!fieldsDragFieldRef.value || fieldsDragFieldRef.value !== field)"
|
||||||
:default-field="defaultFields.find((f) => f.name === field.name)"
|
:default-field="defaultFields.find((f) => f.name === field.name)"
|
||||||
:draggable="editable"
|
:draggable="editable"
|
||||||
@dragstart="dragField = field"
|
@dragstart="fieldsDragFieldRef.value = field"
|
||||||
@dragend="dragField = null"
|
@dragend="fieldsDragFieldRef.value = null"
|
||||||
@remove="removeField"
|
@remove="removeField"
|
||||||
@scroll-to="$emit('scroll-to-area', $event)"
|
@scroll-to="$emit('scroll-to-area', $event)"
|
||||||
@set-draw="$emit('set-draw', $event)"
|
@set-draw="$emit('set-draw', $event)"
|
||||||
@@ -36,52 +36,87 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="submitterDefaultFields.length">
|
<div v-if="submitterDefaultFields.length">
|
||||||
<hr class="mb-2">
|
<hr class="mb-2">
|
||||||
<template
|
<template v-if="isShowFieldSearch">
|
||||||
v-for="field in submitterDefaultFields"
|
<input
|
||||||
:key="field.name"
|
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
|
<div
|
||||||
:style="{ backgroundColor: backgroundColor }"
|
v-if="!filteredSubmitterDefaultFields.length && defaultFieldsSearch"
|
||||||
draggable="true"
|
class="top-0 bottom-0 text-center absolute flex items-center justify-center w-full flex-col"
|
||||||
class="border border-base-300 rounded rounded-tr-none relative group mb-2"
|
|
||||||
@dragstart="onDragstart({ type: 'text', ...field })"
|
|
||||||
@dragend="$emit('drag-end')"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between relative cursor-grab">
|
<div>
|
||||||
<div class="flex items-center p-1 space-x-1">
|
{{ t('field_not_found') }}
|
||||||
<IconDrag />
|
</div>
|
||||||
<FieldType
|
<a
|
||||||
:model-value="field.type || 'text'"
|
href="#"
|
||||||
:editable="false"
|
class="link"
|
||||||
:button-width="20"
|
@click.prevent="defaultFieldsSearch = ''"
|
||||||
/>
|
>
|
||||||
<span class="block pl-0.5">
|
{{ t('clear') }}
|
||||||
{{ field.name }}
|
</a>
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="editable && !onlyDefinedFields"
|
v-if="editable && !onlyDefinedFields"
|
||||||
class="grid grid-cols-3 gap-1 pb-2"
|
class="grid grid-cols-3 gap-1 pb-2"
|
||||||
>
|
>
|
||||||
<template
|
<template
|
||||||
v-for="(icon, type) in fieldIcons"
|
v-for="(icon, type) in fieldIconsSorted"
|
||||||
:key="type"
|
:key="type"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')"
|
v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')"
|
||||||
draggable="true"
|
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 }"
|
:style="{ backgroundColor: backgroundColor }"
|
||||||
|
:class="drawFieldType === type ? 'border-base-content/40' : 'border-base-300 hover:border-base-content/20'"
|
||||||
@dragstart="onDragstart({ type: type })"
|
@dragstart="onDragstart({ type: type })"
|
||||||
@dragend="$emit('drag-end')"
|
@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">
|
<div
|
||||||
<IconDrag class=" my-auto" />
|
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>
|
||||||
<div class="flex items-center flex-col px-2 py-2">
|
<div class="flex items-center flex-col px-2 py-2">
|
||||||
<component :is="icon" />
|
<component :is="icon" />
|
||||||
@@ -93,7 +128,7 @@
|
|||||||
<div
|
<div
|
||||||
v-else-if="type == 'phone' && (fieldTypes.length === 0 || fieldTypes.includes(type))"
|
v-else-if="type == 'phone' && (fieldTypes.length === 0 || fieldTypes.includes(type))"
|
||||||
class="tooltip tooltip-bottom flex"
|
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."
|
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
@@ -139,10 +174,9 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Field from './field'
|
import Field from './field'
|
||||||
import { v4 } from 'uuid'
|
|
||||||
import FieldType from './field_type'
|
import FieldType from './field_type'
|
||||||
import FieldSubmitter from './field_submitter'
|
import FieldSubmitter from './field_submitter'
|
||||||
import { IconLock } from '@tabler/icons-vue'
|
import { IconLock, IconCirclePlus } from '@tabler/icons-vue'
|
||||||
import IconDrag from './icon_drag'
|
import IconDrag from './icon_drag'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -150,11 +184,12 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
Field,
|
Field,
|
||||||
FieldType,
|
FieldType,
|
||||||
|
IconCirclePlus,
|
||||||
FieldSubmitter,
|
FieldSubmitter,
|
||||||
IconDrag,
|
IconDrag,
|
||||||
IconLock
|
IconLock
|
||||||
},
|
},
|
||||||
inject: ['save', 'backgroundColor', 'withPhone', 'withPayment', 't'],
|
inject: ['save', 'backgroundColor', 'withPhone', 'withPayment', 't', 'fieldsDragFieldRef'],
|
||||||
props: {
|
props: {
|
||||||
fields: {
|
fields: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -180,6 +215,11 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: true
|
default: true
|
||||||
},
|
},
|
||||||
|
drawFieldType: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
defaultSubmitters: {
|
defaultSubmitters: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -204,15 +244,29 @@ export default {
|
|||||||
required: true
|
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 () {
|
data () {
|
||||||
return {
|
return {
|
||||||
dragField: null
|
defaultFieldsSearch: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
fieldNames: FieldType.computed.fieldNames,
|
fieldNames: FieldType.computed.fieldNames,
|
||||||
fieldIcons: FieldType.computed.fieldIcons,
|
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 () {
|
submitterFields () {
|
||||||
return this.fields.filter((f) => f.submitter_uuid === this.selectedSubmitter.uuid)
|
return this.fields.filter((f) => f.submitter_uuid === this.selectedSubmitter.uuid)
|
||||||
},
|
},
|
||||||
@@ -220,6 +274,13 @@ export default {
|
|||||||
return this.defaultFields.filter((f) => {
|
return this.defaultFields.filter((f) => {
|
||||||
return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name)
|
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: {
|
methods: {
|
||||||
@@ -228,7 +289,7 @@ export default {
|
|||||||
},
|
},
|
||||||
onFieldDragover (e) {
|
onFieldDragover (e) {
|
||||||
const targetField = e.target.closest('[data-uuid]')
|
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) {
|
if (dragField && targetField && targetField !== dragField) {
|
||||||
const fields = Array.from(this.$refs.fields.children)
|
const fields = Array.from(this.$refs.fields.children)
|
||||||
@@ -272,37 +333,13 @@ export default {
|
|||||||
removeField (field) {
|
removeField (field) {
|
||||||
this.fields.splice(this.fields.indexOf(field), 1)
|
this.fields.splice(this.fields.indexOf(field), 1)
|
||||||
|
|
||||||
this.save()
|
this.fields.forEach((f) => {
|
||||||
},
|
(f.conditions || []).forEach((c) => {
|
||||||
addField (type, area = null) {
|
if (c.field_uuid === field.uuid) {
|
||||||
const field = {
|
f.conditions.splice(f.conditions.indexOf(c), 1)
|
||||||
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.save()
|
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 = {
|
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?',
|
are_you_sure: 'Are you sure?',
|
||||||
sign_yourself: 'Sign Yourself',
|
sign_yourself: 'Sign Yourself',
|
||||||
|
set_signing_date: 'Set signing date',
|
||||||
send: 'Send',
|
send: 'Send',
|
||||||
|
remove: 'Remove',
|
||||||
|
edit: 'Edit',
|
||||||
|
settings: 'Settings',
|
||||||
|
up: 'Up',
|
||||||
|
down: 'Down',
|
||||||
|
checked: 'Checked',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
|
any: 'Any',
|
||||||
|
drawn: 'Drawn',
|
||||||
|
formula: 'Formula',
|
||||||
|
typed: 'Typed',
|
||||||
draw_field_on_the_document: 'Draw {field} field on the document',
|
draw_field_on_the_document: 'Draw {field} field on the document',
|
||||||
click_to_upload: 'Click to upload',
|
click_to_upload: 'Click to upload',
|
||||||
or_drag_and_drop_files: 'or drag and drop files',
|
or_drag_and_drop_files: 'or drag and drop files',
|
||||||
@@ -20,6 +51,7 @@ const en = {
|
|||||||
copy_to_all_pages: 'Copy to All Pages',
|
copy_to_all_pages: 'Copy to All Pages',
|
||||||
add_option: 'Add option',
|
add_option: 'Add option',
|
||||||
option: 'Option',
|
option: 'Option',
|
||||||
|
condition: 'Condition',
|
||||||
first_party: 'First Party',
|
first_party: 'First Party',
|
||||||
second_party: 'Second Party',
|
second_party: 'Second Party',
|
||||||
third_party: 'Third Party',
|
third_party: 'Third Party',
|
||||||
@@ -30,9 +62,21 @@ const en = {
|
|||||||
eighth_party: 'Eighth Party',
|
eighth_party: 'Eighth Party',
|
||||||
ninth_party: 'Ninth Party',
|
ninth_party: 'Ninth Party',
|
||||||
tenth_party: 'Tenth 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',
|
add: 'Add',
|
||||||
or_add_field_without_drawing: 'Or add field without drawing',
|
or_add_field_without_drawing: 'Or add field without drawing',
|
||||||
text: 'Text',
|
text: 'Text',
|
||||||
|
number: 'Number',
|
||||||
signature: 'Signature',
|
signature: 'Signature',
|
||||||
initials: 'Initials',
|
initials: 'Initials',
|
||||||
date: 'Date',
|
date: 'Date',
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
<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
|
||||||
|
class="flex h-52 w-full"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop.prevent="onDropFiles"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="w-full relative 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>
|
||||||
|
</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
|
||||||
|
},
|
||||||
|
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>
|
||||||
<div
|
<div
|
||||||
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile)"
|
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value"
|
||||||
id="mask"
|
id="mask"
|
||||||
ref="mask"
|
ref="mask"
|
||||||
class="top-0 bottom-0 left-0 right-0 absolute"
|
class="top-0 bottom-0 left-0 right-0 absolute"
|
||||||
@@ -61,7 +61,7 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
FieldArea
|
FieldArea
|
||||||
},
|
},
|
||||||
inject: ['fieldTypes'],
|
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef'],
|
||||||
props: {
|
props: {
|
||||||
image: {
|
image: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -77,6 +77,11 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
|
drawFieldType: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
allowDraw: {
|
allowDraw: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -123,7 +128,11 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
defaultFieldType () {
|
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]
|
return this.fieldTypes[0]
|
||||||
} else {
|
} else {
|
||||||
return 'text'
|
return 'text'
|
||||||
@@ -160,8 +169,8 @@ export default {
|
|||||||
},
|
},
|
||||||
onDrop (e) {
|
onDrop (e) {
|
||||||
this.$emit('drop-field', {
|
this.$emit('drop-field', {
|
||||||
x: e.layerX,
|
x: e.offsetX,
|
||||||
y: e.layerY,
|
y: e.offsetY,
|
||||||
maskW: this.$refs.mask.clientWidth,
|
maskW: this.$refs.mask.clientWidth,
|
||||||
maskH: this.$refs.mask.clientHeight,
|
maskH: this.$refs.mask.clientHeight,
|
||||||
page: this.number
|
page: this.number
|
||||||
@@ -184,10 +193,10 @@ export default {
|
|||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.newArea = {
|
this.newArea = {
|
||||||
initialX: e.layerX / this.$refs.mask.clientWidth,
|
initialX: e.offsetX / this.$refs.mask.clientWidth,
|
||||||
initialY: e.layerY / this.$refs.mask.clientHeight,
|
initialY: e.offsetY / this.$refs.mask.clientHeight,
|
||||||
x: e.layerX / this.$refs.mask.clientWidth,
|
x: e.offsetX / this.$refs.mask.clientWidth,
|
||||||
y: e.layerY / this.$refs.mask.clientHeight,
|
y: e.offsetY / this.$refs.mask.clientHeight,
|
||||||
w: 0,
|
w: 0,
|
||||||
h: 0
|
h: 0
|
||||||
}
|
}
|
||||||
@@ -195,19 +204,19 @@ export default {
|
|||||||
},
|
},
|
||||||
onPointermove (e) {
|
onPointermove (e) {
|
||||||
if (this.newArea) {
|
if (this.newArea) {
|
||||||
const dx = e.layerX / this.$refs.mask.clientWidth - this.newArea.initialX
|
const dx = e.offsetX / this.$refs.mask.clientWidth - this.newArea.initialX
|
||||||
const dy = e.layerY / this.$refs.mask.clientHeight - this.newArea.initialY
|
const dy = e.offsetY / this.$refs.mask.clientHeight - this.newArea.initialY
|
||||||
|
|
||||||
if (dx > 0) {
|
if (dx > 0) {
|
||||||
this.newArea.x = this.newArea.initialX
|
this.newArea.x = this.newArea.initialX
|
||||||
} else {
|
} else {
|
||||||
this.newArea.x = e.layerX / this.$refs.mask.clientWidth
|
this.newArea.x = e.offsetX / this.$refs.mask.clientWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dy > 0) {
|
if (dy > 0) {
|
||||||
this.newArea.y = this.newArea.initialY
|
this.newArea.y = this.newArea.initialY
|
||||||
} else {
|
} else {
|
||||||
this.newArea.y = e.layerY / this.$refs.mask.clientHeight
|
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.drawField?.type === 'cells') {
|
if (this.drawField?.type === 'cells') {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
title="Settings"
|
:title="t('settings')"
|
||||||
class="cursor-pointer text-transparent group-hover:text-base-content"
|
class="cursor-pointer text-transparent group-hover:text-base-content"
|
||||||
>
|
>
|
||||||
<IconSettings
|
<IconSettings
|
||||||
@@ -167,7 +167,7 @@ export default {
|
|||||||
IconInnerShadowTop,
|
IconInnerShadowTop,
|
||||||
IconBrandStripe
|
IconBrandStripe
|
||||||
},
|
},
|
||||||
inject: ['backgroundColor', 'save', 'currencies'],
|
inject: ['backgroundColor', 'save', 'currencies', 't', 'isPaymentConnected'],
|
||||||
props: {
|
props: {
|
||||||
field: {
|
field: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -221,6 +221,8 @@ export default {
|
|||||||
mounted () {
|
mounted () {
|
||||||
this.field.preferences.currency ||= this.defaultCurrency
|
this.field.preferences.currency ||= this.defaultCurrency
|
||||||
|
|
||||||
|
isConnected.value ||= this.isPaymentConnected
|
||||||
|
|
||||||
if (!this.isConnected) {
|
if (!this.isConnected) {
|
||||||
this.checkStatus()
|
this.checkStatus()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export default {
|
|||||||
|
|
||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
|
|
||||||
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
|
this.baseFetch(`/templates/${this.templateId}/documents`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ blobs }),
|
body: JSON.stringify({ blobs }),
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
@@ -132,7 +132,7 @@ export default {
|
|||||||
} else if (resp.status === 422) {
|
} else if (resp.status === 422) {
|
||||||
resp.json().then((data) => {
|
resp.json().then((data) => {
|
||||||
if (data.error === 'PDF encrypted') {
|
if (data.error === 'PDF encrypted') {
|
||||||
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
|
this.baseFetch(`/templates/${this.templateId}/documents`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ blobs, password: prompt('Enter PDF password') }),
|
body: JSON.stringify({ blobs, password: prompt('Enter PDF password') }),
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
@@ -151,7 +151,7 @@ export default {
|
|||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
|
this.baseFetch(`/templates/${this.templateId}/documents`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: new FormData(this.$refs.form)
|
body: new FormData(this.$refs.form)
|
||||||
}).then((resp) => {
|
}).then((resp) => {
|
||||||
@@ -167,7 +167,7 @@ export default {
|
|||||||
|
|
||||||
formData.append('password', prompt('Enter PDF password'))
|
formData.append('password', prompt('Enter PDF password'))
|
||||||
|
|
||||||
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
|
this.baseFetch(`/templates/${this.templateId}/documents`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
}).then(async (resp) => {
|
}).then(async (resp) => {
|
||||||
|
|||||||
@@ -26,13 +26,16 @@ class ProcessSubmitterCompletionJob < ApplicationJob
|
|||||||
|
|
||||||
user = submission.created_by_user || submitter.template.author
|
user = submission.created_by_user || submitter.template.author
|
||||||
|
|
||||||
if submitter.template.account.users.exists?(id: user.id) &&
|
if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false
|
||||||
submission.preferences['send_email'] != false
|
if submission.submitters.map(&:email).exclude?(user.email) &&
|
||||||
SubmitterMailer.completed_email(submitter, user).deliver_later!
|
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 ||
|
bcc = submission.preferences['bcc_completed'].presence ||
|
||||||
submission.template.account.account_configs
|
submission.account.account_configs
|
||||||
.find_by(key: AccountConfig::BCC_EMAILS)&.value.presence
|
.find_by(key: AccountConfig::BCC_EMAILS)&.value
|
||||||
|
|
||||||
bcc.to_s.scan(User::EMAIL_REGEXP).each do |to|
|
bcc.to_s.scan(User::EMAIL_REGEXP).each do |to|
|
||||||
SubmitterMailer.completed_email(submitter, user, to:).deliver_later!
|
SubmitterMailer.completed_email(submitter, user, to:).deliver_later!
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
class SendFormCompletedWebhookRequestJob < ApplicationJob
|
class SendFormCompletedWebhookRequestJob < ApplicationJob
|
||||||
USER_AGENT = 'DocuSeal.co Webhook'
|
USER_AGENT = 'DocuSeal.co Webhook'
|
||||||
|
|
||||||
def perform(submitter)
|
MAX_ATTEMPTS = 10
|
||||||
|
|
||||||
|
def perform(submitter, params = {})
|
||||||
|
attempt = params[:attempt].to_i
|
||||||
config = Accounts.load_webhook_configs(submitter.submission.account)
|
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||||
|
|
||||||
return if config.blank? || config.value.blank?
|
return if config.blank? || config.value.blank?
|
||||||
@@ -12,13 +15,26 @@ class SendFormCompletedWebhookRequestJob < ApplicationJob
|
|||||||
|
|
||||||
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||||
|
|
||||||
Faraday.post(config.value,
|
resp = begin
|
||||||
{
|
Faraday.post(config.value,
|
||||||
event_type: 'form.completed',
|
{
|
||||||
timestamp: Time.current,
|
event_type: 'form.completed',
|
||||||
data: Submitters::SerializeForWebhook.call(submitter)
|
timestamp: Time.current,
|
||||||
}.to_json,
|
data: Submitters::SerializeForWebhook.call(submitter)
|
||||||
'Content-Type' => 'application/json',
|
}.to_json,
|
||||||
'User-Agent' => USER_AGENT)
|
'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
|
||||||
end
|
end
|
||||||
@@ -3,20 +3,36 @@
|
|||||||
class SendFormStartedWebhookRequestJob < ApplicationJob
|
class SendFormStartedWebhookRequestJob < ApplicationJob
|
||||||
USER_AGENT = 'DocuSeal.co Webhook'
|
USER_AGENT = 'DocuSeal.co Webhook'
|
||||||
|
|
||||||
def perform(submitter)
|
MAX_ATTEMPTS = 10
|
||||||
|
|
||||||
|
def perform(submitter, params = {})
|
||||||
|
attempt = params[:attempt].to_i
|
||||||
config = Accounts.load_webhook_configs(submitter.submission.account)
|
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||||
|
|
||||||
return if config.blank? || config.value.blank?
|
return if config.blank? || config.value.blank?
|
||||||
|
|
||||||
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||||
|
|
||||||
Faraday.post(config.value,
|
resp = begin
|
||||||
{
|
Faraday.post(config.value,
|
||||||
event_type: 'form.started',
|
{
|
||||||
timestamp: Time.current,
|
event_type: 'form.started',
|
||||||
data: Submitters::SerializeForWebhook.call(submitter)
|
timestamp: Time.current,
|
||||||
}.to_json,
|
data: Submitters::SerializeForWebhook.call(submitter)
|
||||||
'Content-Type' => 'application/json',
|
}.to_json,
|
||||||
'User-Agent' => USER_AGENT)
|
'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
|
||||||
end
|
end
|
||||||
@@ -3,20 +3,36 @@
|
|||||||
class SendFormViewedWebhookRequestJob < ApplicationJob
|
class SendFormViewedWebhookRequestJob < ApplicationJob
|
||||||
USER_AGENT = 'DocuSeal.co Webhook'
|
USER_AGENT = 'DocuSeal.co Webhook'
|
||||||
|
|
||||||
def perform(submitter)
|
MAX_ATTEMPTS = 10
|
||||||
|
|
||||||
|
def perform(submitter, params = {})
|
||||||
|
attempt = params[:attempt].to_i
|
||||||
config = Accounts.load_webhook_configs(submitter.submission.account)
|
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||||
|
|
||||||
return if config.blank? || config.value.blank?
|
return if config.blank? || config.value.blank?
|
||||||
|
|
||||||
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||||
|
|
||||||
Faraday.post(config.value,
|
resp = begin
|
||||||
{
|
Faraday.post(config.value,
|
||||||
event_type: 'form.viewed',
|
{
|
||||||
timestamp: Time.current,
|
event_type: 'form.viewed',
|
||||||
data: Submitters::SerializeForWebhook.call(submitter)
|
timestamp: Time.current,
|
||||||
}.to_json,
|
data: Submitters::SerializeForWebhook.call(submitter)
|
||||||
'Content-Type' => 'application/json',
|
}.to_json,
|
||||||
'User-Agent' => USER_AGENT)
|
'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
|
||||||
end
|
end
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
class SubmitterMailer < ApplicationMailer
|
class SubmitterMailer < ApplicationMailer
|
||||||
MAX_ATTACHMENTS_SIZE = 10.megabytes
|
MAX_ATTACHMENTS_SIZE = 10.megabytes
|
||||||
|
SIGN_TTL = 1.hour + 20.minutes
|
||||||
|
|
||||||
DEFAULT_INVITATION_SUBJECT = 'You are invited to submit a form'
|
DEFAULT_INVITATION_SUBJECT = 'You are invited to submit a form'
|
||||||
|
|
||||||
def invitation_email(submitter)
|
def invitation_email(submitter)
|
||||||
@current_account = submitter.submission.template.account
|
@current_account = submitter.submission.account
|
||||||
@submitter = submitter
|
@submitter = submitter
|
||||||
|
|
||||||
if submitter.preferences['email_message_uuid']
|
if submitter.preferences['email_message_uuid']
|
||||||
@@ -29,12 +30,13 @@ class SubmitterMailer < ApplicationMailer
|
|||||||
to: @submitter.friendly_name,
|
to: @submitter.friendly_name,
|
||||||
from: from_address_for_submitter(submitter),
|
from: from_address_for_submitter(submitter),
|
||||||
subject:,
|
subject:,
|
||||||
reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@')
|
reply_to: submitter.preferences['reply_to'].presence ||
|
||||||
|
(submitter.submission.created_by_user || submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@')
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def completed_email(submitter, user, to: nil)
|
def completed_email(submitter, user, to: nil)
|
||||||
@current_account = submitter.submission.template.account
|
@current_account = submitter.submission.account
|
||||||
@submitter = submitter
|
@submitter = submitter
|
||||||
@submission = submitter.submission
|
@submission = submitter.submission
|
||||||
@user = user
|
@user = user
|
||||||
@@ -61,9 +63,10 @@ class SubmitterMailer < ApplicationMailer
|
|||||||
subject:)
|
subject:)
|
||||||
end
|
end
|
||||||
|
|
||||||
def documents_copy_email(submitter, to: nil)
|
def documents_copy_email(submitter, to: nil, sig: false)
|
||||||
@current_account = submitter.submission.template.account
|
@current_account = submitter.submission.account
|
||||||
@submitter = submitter
|
@submitter = submitter
|
||||||
|
@sig = submitter.signed_id(expires_in: SIGN_TTL, purpose: :download_completed) if sig
|
||||||
|
|
||||||
Submissions::EnsureResultGenerated.call(@submitter)
|
Submissions::EnsureResultGenerated.call(@submitter)
|
||||||
|
|
||||||
@@ -82,6 +85,9 @@ class SubmitterMailer < ApplicationMailer
|
|||||||
|
|
||||||
mail(from: from_address_for_submitter(submitter),
|
mail(from: from_address_for_submitter(submitter),
|
||||||
to: to || @submitter.friendly_name,
|
to: to || @submitter.friendly_name,
|
||||||
|
reply_to: @submitter.preferences['reply_to'].presence ||
|
||||||
|
(@submitter.submission.created_by_user ||
|
||||||
|
@submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@'),
|
||||||
subject:)
|
subject:)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class Account < ApplicationRecord
|
|||||||
has_many :template_folders, dependent: :destroy
|
has_many :template_folders, dependent: :destroy
|
||||||
has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) },
|
has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) },
|
||||||
class_name: 'TemplateFolder', dependent: :destroy, inverse_of: :account
|
class_name: 'TemplateFolder', dependent: :destroy, inverse_of: :account
|
||||||
has_many :submissions, through: :templates
|
has_many :submissions, dependent: :destroy
|
||||||
has_many :submitters, through: :submissions
|
has_many :submitters, through: :submissions
|
||||||
has_many :account_linked_accounts, dependent: :destroy
|
has_many :account_linked_accounts, dependent: :destroy
|
||||||
has_many :account_testing_accounts, -> { testing }, dependent: :destroy,
|
has_many :account_testing_accounts, -> { testing }, dependent: :destroy,
|
||||||
@@ -30,6 +30,10 @@ class Account < ApplicationRecord
|
|||||||
foreign_key: :linked_account_id,
|
foreign_key: :linked_account_id,
|
||||||
class_name: 'AccountLinkedAccount',
|
class_name: 'AccountLinkedAccount',
|
||||||
inverse_of: :linked_account
|
inverse_of: :linked_account
|
||||||
|
has_many :linked_account_accounts, dependent: :destroy,
|
||||||
|
foreign_key: :linked_account_id,
|
||||||
|
class_name: 'AccountLinkedAccount',
|
||||||
|
inverse_of: :linked_account
|
||||||
has_many :linked_accounts, through: :account_linked_accounts
|
has_many :linked_accounts, through: :account_linked_accounts
|
||||||
has_many :testing_accounts, through: :account_testing_accounts, source: :linked_account
|
has_many :testing_accounts, through: :account_testing_accounts, source: :linked_account
|
||||||
has_many :active_users, -> { active }, dependent: :destroy,
|
has_many :active_users, -> { active }, dependent: :destroy,
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class Submission < ApplicationRecord
|
|||||||
def audit_trail_url
|
def audit_trail_url
|
||||||
return if audit_trail.blank?
|
return if audit_trail.blank?
|
||||||
|
|
||||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(audit_trail, **Docuseal.default_url_options)
|
ActiveStorage::Blob.proxy_url(audit_trail.blob)
|
||||||
end
|
end
|
||||||
alias audit_log_url audit_trail_url
|
alias audit_log_url audit_trail_url
|
||||||
end
|
end
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
|
# index_submission_events_on_created_at (created_at)
|
||||||
# index_submission_events_on_submission_id (submission_id)
|
# index_submission_events_on_submission_id (submission_id)
|
||||||
# index_submission_events_on_submitter_id (submitter_id)
|
# index_submission_events_on_submitter_id (submitter_id)
|
||||||
#
|
#
|
||||||
@@ -46,7 +47,8 @@ class SubmissionEvent < ApplicationRecord
|
|||||||
phone_verified: 'phone_verified',
|
phone_verified: 'phone_verified',
|
||||||
start_form: 'start_form',
|
start_form: 'start_form',
|
||||||
view_form: 'view_form',
|
view_form: 'view_form',
|
||||||
complete_form: 'complete_form'
|
complete_form: 'complete_form',
|
||||||
|
api_complete_form: 'api_complete_form'
|
||||||
}, scope: false
|
}, scope: false
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
#
|
#
|
||||||
class UserConfig < ApplicationRecord
|
class UserConfig < ApplicationRecord
|
||||||
SIGNATURE_KEY = 'signature'
|
SIGNATURE_KEY = 'signature'
|
||||||
|
RECEIVE_COMPLETED_EMAIL = 'receive_completed_email'
|
||||||
|
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
<div class="max-w-lg mx-auto px-2">
|
|
||||||
<h1 class="text-3xl font-bold text-center mt-8">Profile Details</h1>
|
|
||||||
<%= form_for('', as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: new_registration_path) do |f| %>
|
|
||||||
<% if params[:redir].present? %>
|
|
||||||
<%= hidden_field_tag :redir, params[:redir] %>
|
|
||||||
<% end %>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<%= render 'devise/shared/error_messages', resource: %>
|
|
||||||
<%= f.fields_for resource do |ff| %>
|
|
||||||
<div class="grid gap-2 md:grid-cols-2 md:gap-4">
|
|
||||||
<div class="form-control">
|
|
||||||
<%= ff.label :first_name, class: 'label' %>
|
|
||||||
<%= ff.text_field :first_name, required: true, class: 'base-input' %>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<%= ff.label :last_name, class: 'label' %>
|
|
||||||
<%= ff.text_field :last_name, required: true, class: 'base-input' %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<%= f.fields_for resource do |ff| %>
|
|
||||||
<div class="form-control <%= 'hidden' if (params[:oauth_callback] || params[:sign_up]) && resource.email? %>">
|
|
||||||
<%= ff.label :email, class: 'label' %>
|
|
||||||
<%= ff.email_field :email, value: EmailTypo.call(resource.email), required: true, class: 'base-input' %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<%= f.fields_for resource.account do |ff| %>
|
|
||||||
<set-timezone data-input-id="_account_timezone"></set-timezone>
|
|
||||||
<%= ff.hidden_field :timezone %>
|
|
||||||
<div class="form-control">
|
|
||||||
<%= ff.label :name, 'Company name (optional)', class: 'label' %>
|
|
||||||
<%= ff.text_field :name, class: 'base-input' %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<%= f.fields_for resource do |ff| %>
|
|
||||||
<div class="form-control <%= 'hidden' if params[:oauth_callback] %>">
|
|
||||||
<%= ff.label :password, class: 'label' %>
|
|
||||||
<%= ff.password_field :password, required: !params[:oauth_callback], class: 'base-input' %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<%= f.button button_title(title: 'Sign up', disabled_with: 'Signing up'), class: 'base-button' %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<%= render 'devise/shared/links' %>
|
|
||||||
</div>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<div class="max-w-lg mx-auto px-2">
|
|
||||||
<% if params[:redir].to_s.ends_with?('on_premise') %>
|
|
||||||
<h1 class="text-3xl font-bold text-center mt-8 mb-6">DocuSeal Console</h1>
|
|
||||||
<div class="alert my-4 text-sm">
|
|
||||||
Sign up in DocuSeal Console to upgrade.
|
|
||||||
On-premises app is completely standalone, Console is used only to manage your license.
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<%= render 'devise/shared/select_server' if Docuseal.multitenant? %>
|
|
||||||
<h1 class="text-3xl font-bold text-center mt-8 mb-6">Create Free Account</h1>
|
|
||||||
<% end %>
|
|
||||||
<%= form_for(User.new, html: { class: 'space-y-6' }, url: new_registration_path, data: { turbo: params[:redir].blank? }, method: :get) do |f| %>
|
|
||||||
<% if params[:redir].present? %>
|
|
||||||
<%= hidden_field_tag :redir, params[:redir] %>
|
|
||||||
<% end %>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<%= f.label :email, class: 'label' %>
|
|
||||||
<%= f.email_field :email, autofocus: true, autocomplete: 'email', class: 'base-input', placeholder: 'Enter email to continue' %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<%= f.button button_title(title: 'Sign up', disabled_with: 'Sign up'), name: 'sign_up', value: true, class: 'base-button' %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<% if devise_mapping.omniauthable? %>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<% if User.omniauth_providers.include?(:google_oauth2) %>
|
|
||||||
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
|
|
||||||
<set-timezone data-input-id="state" data-params="true"></set-timezone>
|
|
||||||
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
|
|
||||||
<%= f.button button_title(title: 'Sign up with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
<% if User.omniauth_providers.include?(:microsoft_office365) %>
|
|
||||||
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
|
|
||||||
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
|
|
||||||
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
|
|
||||||
<%= f.button button_title(title: 'Sign up with Microsoft', icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<%= render 'devise/shared/links' %>
|
|
||||||
</div>
|
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<%= ff.label :security_label, 'SMTP Security', class: 'label' %>
|
<%= ff.label :security_label, 'SMTP Security', class: 'label' %>
|
||||||
<div class="flex items-center space-x-6">
|
<div class="flex items-center space-x-6">
|
||||||
<% [%w[None none], %w[SSL ssl], %w[TLS tls]].each do |(label, val)| %>
|
<% [%w[Auto none], %w[SSL ssl], %w[TLS tls]].each do |(label, val)| %>
|
||||||
<%= ff.label :security, value: val, for: "#{val}_radio", class: 'label' do %>
|
<%= ff.label :security, value: val, for: "#{val}_radio", class: 'label' do %>
|
||||||
<%= ff.radio_button :security, val, checked: (value['security'].blank? && val == 'none') || value['security'] == val, id: "#{val}_radio", class: 'base-radio mr-2' %>
|
<%= ff.radio_button :security, val, checked: (value['security'].blank? && val == 'none') || value['security'] == val, id: "#{val}_radio", class: 'base-radio mr-2' %>
|
||||||
<%= label %>
|
<%= label %>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
<span>
|
<span>
|
||||||
Timeserver URL
|
Timeserver URL
|
||||||
</span>
|
</span>
|
||||||
<span class="tooltip" data-tip="URL of the trusted timeserver to be used to generate timestamp signatures.">
|
<span class="tooltip" data-tip="URL of the trusted RFC 3161 timeserver to be used to generate timestamp signatures.">
|
||||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -123,26 +123,24 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if !Docuseal.multitenant? %>
|
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::ESIGNING_PREFERENCE_KEY).first_or_initialize(value: 'multiple') %>
|
||||||
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::ESIGNING_PREFERENCE_KEY).first_or_initialize(value: 'multiple') %>
|
<% if can?(:manage, account_config) %>
|
||||||
<% if can?(:manage, account_config) %>
|
<div class="px-1 mt-8 max-w-xl">
|
||||||
<div class="px-1 mt-8 max-w-xl">
|
<div class="flex justify-between items-end mb-4 mt-8">
|
||||||
<div class="flex justify-between items-end mb-4 mt-8">
|
<h2 class="text-3xl font-bold">Preferences</h2>
|
||||||
<h2 class="text-3xl font-bold">Preferences</h2>
|
|
||||||
</div>
|
|
||||||
<% if can?(:manage, account_config) %>
|
|
||||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
|
||||||
<%= f.hidden_field :key %>
|
|
||||||
<div class="flex items-center justify-between py-2.5">
|
|
||||||
<span>
|
|
||||||
Apply multiple PDF digital signatures in the document per each signer
|
|
||||||
</span>
|
|
||||||
<%= f.check_box :value, { class: 'toggle', checked: account_config.value == 'multiple', onchange: 'this.form.requestSubmit()' }, 'multiple', 'single' %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% if can?(:manage, account_config) %>
|
||||||
|
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||||
|
<%= f.hidden_field :key %>
|
||||||
|
<div class="flex items-center justify-between py-2.5">
|
||||||
|
<span>
|
||||||
|
Apply multiple PDF digital signatures in the document per each signer
|
||||||
|
</span>
|
||||||
|
<%= f.check_box :value, { class: 'toggle', checked: account_config.value == 'multiple', onchange: 'this.form.requestSubmit()' }, 'multiple', 'single' %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3,10 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<title></title>
|
<title></title>
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
<style>
|
<style></style>
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body dir="<%= yield&.match?(TextUtils::RTL_REGEXP) ? 'rtl' : 'auto' %>">
|
<body dir="<%= TextUtils.rtl?(yield) ? 'rtl' : 'auto' %>">
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
<%= render partial: 'shared/mailer_attribution' %>
|
<%= render partial: 'shared/mailer_attribution' %>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<p>
|
<p>
|
||||||
Send automatic email reminders to your recipients.
|
Send automatic email reminders to your recipients.
|
||||||
<br>
|
<br>
|
||||||
<a class="link font-medium" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}" %>">Learn More</a>
|
<a class="link font-medium" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}" %>" data-turbo="false">Learn More</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2,6 +2,20 @@
|
|||||||
<%= render 'shared/settings_nav' %>
|
<%= render 'shared/settings_nav' %>
|
||||||
<div class="flex-grow max-w-xl mx-auto">
|
<div class="flex-grow max-w-xl mx-auto">
|
||||||
<h1 class="text-4xl font-bold mb-4">Email Notifications</h1>
|
<h1 class="text-4xl font-bold mb-4">Email Notifications</h1>
|
||||||
|
<div class="mt-2 mb-1">
|
||||||
|
<% user_config = UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::RECEIVE_COMPLETED_EMAIL) %>
|
||||||
|
<% if can?(:manage, user_config) %>
|
||||||
|
<%= form_for user_config, url: user_configs_path, method: :post do |f| %>
|
||||||
|
<%= f.hidden_field :key %>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
Receive notification emails on completed submission
|
||||||
|
</span>
|
||||||
|
<%= f.check_box :value, class: 'toggle', checked: user_config.value != false, onchange: 'this.form.requestSubmit()' %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
<%= render 'email_stats' %>
|
<%= render 'email_stats' %>
|
||||||
<%= render 'bcc_form', config: @bcc_config %>
|
<%= render 'bcc_form', config: @bcc_config %>
|
||||||
<div class="flex justify-between items-end mb-4 mt-8">
|
<div class="flex justify-between items-end mb-4 mt-8">
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<%= render 'form_toggle_options' %>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::FORM_WITH_CONFETTI_KEY).first_or_initialize(value: true) %>
|
||||||
|
<% if can?(:manage, account_config) %>
|
||||||
|
<div class="px-1 mt-2">
|
||||||
|
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||||
|
<%= f.hidden_field :key %>
|
||||||
|
<div class="flex items-center justify-between py-2.5">
|
||||||
|
<span>
|
||||||
|
Show confetti on successful completion
|
||||||
|
</span>
|
||||||
|
<%= f.check_box :value, { class: 'toggle', checked: account_config.value != false, onchange: 'this.form.requestSubmit()' }, '1', '0' %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<p>
|
<p>
|
||||||
Display your company name and logo when signing documents.
|
Display your company name and logo when signing documents.
|
||||||
<br>
|
<br>
|
||||||
<a class="link font-medium" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}" %>">Learn More</a>
|
<a class="link font-medium" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}" %>" data-turbo="false">Learn More</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user