Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c88715daec | |||
| 0375dad7c9 | |||
| de3f83db96 | |||
| a7bb96464c | |||
| 1d48fe9621 | |||
| 9544bb3d76 | |||
| 4e94664008 | |||
| e41fe5fbca | |||
| 24d713dec6 | |||
| f464e1c46a | |||
| 85d52d1f28 | |||
| e77b5fa2c9 | |||
| c91b4a765b | |||
| 2a031c14c8 | |||
| f7dfc0b2ed | |||
| 3b894f5281 | |||
| 9645332e48 | |||
| 5670390b37 | |||
| 260bd6d5d5 | |||
| fa0733f480 | |||
| 8b9678a434 | |||
| 43669bf095 | |||
| 2ef8df3f0c | |||
| 1c9d15aa7e | |||
| 9fcd8955d3 | |||
| 5e44ba49b8 | |||
| ac1f90e711 | |||
| c6eae66127 | |||
| 08aa902e07 | |||
| 1690337699 | |||
| f78cf8cc6e | |||
| 343118d00d | |||
| a3bd37c9a2 | |||
| 8bc13a5900 | |||
| 21138c1d02 | |||
| 38674a9fc9 | |||
| a7ed0b86a4 | |||
| 91a96f06c8 | |||
| 23631b75f2 | |||
| 0fb9c2bbf6 | |||
| 14fa8fc8e7 | |||
| 047c192988 | |||
| bfa244c8fa | |||
| 019222ae40 | |||
| 8a10852fe2 | |||
| 01232aed5a | |||
| 82f3ff4824 | |||
| 7be3f5a931 | |||
| 9d013a7384 | |||
| 0c71d7bff8 | |||
| fdd030652e | |||
| f2bf1461f0 | |||
| fc6f796f30 | |||
| 5f44806287 | |||
| 318b65af18 | |||
| 91b182b04a | |||
| 15b886d4ff | |||
| 0122a237e3 | |||
| 2dca7d8a8e | |||
| 87265e2f22 | |||
| 3166defa1a | |||
| 93ba17f42e | |||
| 751be1dae5 | |||
| 3f8d2831e2 | |||
| 4d8942803e | |||
| 46d9bb2c87 | |||
| 34433c0575 | |||
| 544306eac4 | |||
| 0eeebfd3cf | |||
| 050224a10d | |||
| 7b6fc7d06f | |||
| 7fb148c85c | |||
| dffa59fecd | |||
| 24fe2a1192 | |||
| 97b8ac4444 | |||
| e34a763dd9 | |||
| 298b1d3e1e | |||
| d9978519cc | |||
| b4a68cf79e | |||
| 5663e60f92 | |||
| 19e8835809 | |||
| 330fae927f | |||
| c5a5212947 | |||
| 21d81e38bf | |||
| 5b1a4ebffd | |||
| 5f5dabd0e5 | |||
| ff46993de6 | |||
| 7676db2196 | |||
| 3ef0d28ffe | |||
| 231177f057 | |||
| e5f7739952 | |||
| 4a8e2a82e5 | |||
| 6efe8027f1 | |||
| 96bb4eb6e5 | |||
| 38e68814cf | |||
| 786c80bee7 | |||
| 98dcce299e | |||
| cbff242a5d | |||
| 1876b3efba | |||
| 51091f05f0 | |||
| d76eee2ae4 | |||
| d7bfa96d57 | |||
| be60608211 | |||
| 4e5abb3c09 | |||
| 7fee82ded2 | |||
| e1cd62ff40 | |||
| b498291b62 | |||
| def3009846 | |||
| 9d43fd9aa5 | |||
| 22a21fb037 | |||
| 2a9c375273 | |||
| 847e70ec1b | |||
| 7964d6e0f0 | |||
| d0259791bf | |||
| a9dcaeccbc | |||
| 501b91a603 | |||
| 2b5f772feb | |||
| 7cada0af8b | |||
| 67be785e69 | |||
| d1cfc64adc | |||
| 4e5157d1ee | |||
| 5e27cc36c6 | |||
| 6e81a3be3f | |||
| 858b2f33fd | |||
| 6adcc87b2b | |||
| e1d5a5b6a6 | |||
| f090e79002 | |||
| 56b32e35a2 | |||
| b40c27cdbf | |||
| c4505e67f0 | |||
| d0799b8cc1 | |||
| 889fcad859 | |||
| c133fef99d | |||
| cfdc978a2a | |||
| bff1651966 | |||
| b97854f2fe | |||
| 7a4629bf79 | |||
| 2573d8cb19 | |||
| dd559e90f1 | |||
| e5db61e358 | |||
| 69376c0faf | |||
| 1f48be135c | |||
| 1cae12b728 | |||
| 86d2cd93f8 | |||
| b0110d9340 | |||
| 7e70f4fb14 | |||
| 28e3d65e63 | |||
| f1a2b71b59 | |||
| 47c7270911 | |||
| 4e1fa336a2 | |||
| 244bc32136 | |||
| 42c30bdfd0 | |||
| 0abf48ddba |
@@ -11,7 +11,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 3.4.1
|
||||
ruby-version: 3.4.2
|
||||
- name: Cache gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 3.4.1
|
||||
ruby-version: 3.4.2
|
||||
- name: Cache gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 3.4.1
|
||||
ruby-version: 3.4.2
|
||||
- name: Cache gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 3.4.1
|
||||
ruby-version: 3.4.2
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
@@ -157,7 +157,10 @@ jobs:
|
||||
bundle install --jobs 4 --retry 4
|
||||
yarn install
|
||||
sudo apt-get update
|
||||
sudo apt-get install libvips
|
||||
sudo apt-get install -y libvips
|
||||
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
|
||||
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
|
||||
rm -f pdfium-linux.tgz
|
||||
- name: Run
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
FROM ruby:3.4.1-alpine AS fonts
|
||||
FROM ruby:3.4.2-alpine AS download
|
||||
|
||||
WORKDIR /fonts
|
||||
|
||||
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 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 && \
|
||||
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
|
||||
mkdir -p /pdfium-linux && \
|
||||
tar -xzf pdfium-linux.tgz -C /pdfium-linux
|
||||
|
||||
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
|
||||
|
||||
FROM ruby:3.4.1-alpine AS webpack
|
||||
FROM ruby:3.4.2-alpine AS webpack
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV NODE_ENV=production
|
||||
@@ -32,7 +41,7 @@ COPY ./app/views ./app/views
|
||||
|
||||
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
|
||||
|
||||
FROM ruby:3.4.1-alpine AS app
|
||||
FROM ruby:3.4.2-alpine AS app
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV BUNDLE_WITHOUT="development:test"
|
||||
@@ -41,7 +50,7 @@ ENV OPENSSL_CONF=/app/openssl_legacy.cnf
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils redis libheif@edge vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
|
||||
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev@edge redis libheif@edge vips-heif@edge gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
|
||||
|
||||
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
|
||||
\n\
|
||||
@@ -70,8 +79,10 @@ COPY ./tmp ./tmp
|
||||
COPY LICENSE README.md Rakefile config.ru .version ./
|
||||
COPY .version ./public/version
|
||||
|
||||
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=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
|
||||
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
|
||||
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
|
||||
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
|
||||
COPY --from=webpack /app/public/packs ./public/packs
|
||||
|
||||
RUN ln -s /fonts /app/public/fonts
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby '3.4.1'
|
||||
ruby '3.4.2'
|
||||
|
||||
gem 'arabic-letter-connector', require: 'arabic-letter-connector/logic'
|
||||
gem 'aws-sdk-s3', require: false
|
||||
|
||||
@@ -338,18 +338,18 @@ GEM
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.5)
|
||||
nokogiri (1.18.8)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.5-aarch64-linux-gnu)
|
||||
nokogiri (1.18.8-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.5-aarch64-linux-musl)
|
||||
nokogiri (1.18.8-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.5-arm64-darwin)
|
||||
nokogiri (1.18.8-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.5-x86_64-linux-gnu)
|
||||
nokogiri (1.18.8-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.5-x86_64-linux-musl)
|
||||
nokogiri (1.18.8-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
oj (3.16.8)
|
||||
bigdecimal (>= 3.0)
|
||||
@@ -387,7 +387,7 @@ GEM
|
||||
puma (6.5.0)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.12)
|
||||
rack (3.1.14)
|
||||
rack-proxy (0.7.7)
|
||||
rack
|
||||
rack-session (2.0.0)
|
||||
@@ -652,7 +652,7 @@ DEPENDENCIES
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.4.1p0
|
||||
ruby 3.4.2p28
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.3
|
||||
|
||||
@@ -42,7 +42,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
- PDF signature verification
|
||||
- Users management
|
||||
- Mobile-optimized
|
||||
- 6 UI languages with signing available in 13 languages
|
||||
- 6 UI languages with signing available in 14 languages
|
||||
- API and Webhooks for integrations
|
||||
- Easy to deploy in minutes
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AccountConfigsController < ApplicationController
|
||||
before_action :load_account_config
|
||||
authorize_resource :account_config
|
||||
before_action :load_account_config, only: :create
|
||||
authorize_resource :account_config, only: :create
|
||||
|
||||
load_and_authorize_resource :account_config, only: :destroy
|
||||
|
||||
ALLOWED_KEYS = [
|
||||
AccountConfig::ALLOW_TYPED_SIGNATURE,
|
||||
@@ -15,6 +17,7 @@ class AccountConfigsController < ApplicationController
|
||||
AccountConfig::DOWNLOAD_LINKS_AUTH_KEY,
|
||||
AccountConfig::FORCE_SSO_AUTH_KEY,
|
||||
AccountConfig::FLATTEN_RESULT_PDF_KEY,
|
||||
AccountConfig::ENFORCE_SIGNING_ORDER_KEY,
|
||||
AccountConfig::WITH_SIGNATURE_ID,
|
||||
AccountConfig::COMBINE_PDF_RESULT_KEY,
|
||||
AccountConfig::REQUIRE_SIGNING_REASON_KEY,
|
||||
@@ -29,6 +32,14 @@ class AccountConfigsController < ApplicationController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def destroy
|
||||
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
|
||||
|
||||
@account_config.destroy!
|
||||
|
||||
redirect_back(fallback_location: root_path)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_account_config
|
||||
|
||||
@@ -45,7 +45,7 @@ class AccountsController < ApplicationController
|
||||
def destroy
|
||||
authorize!(:manage, current_account)
|
||||
|
||||
true_user.update!(locked_at: Time.current)
|
||||
true_user.update!(locked_at: Time.current, email: true_user.email.sub('@', '+removed@'))
|
||||
|
||||
# rubocop:disable Layout/LineLength
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
|
||||
@@ -21,7 +21,11 @@ module Api
|
||||
|
||||
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
|
||||
|
||||
authorization_check!(blob) if exp.blank?
|
||||
attachment = blob.attachments.take
|
||||
|
||||
@record = attachment.record
|
||||
|
||||
authorization_check!(attachment) if exp.blank?
|
||||
|
||||
if request.headers['Range'].present?
|
||||
send_blob_byte_range_data blob, request.headers['Range']
|
||||
@@ -37,9 +41,7 @@ module Api
|
||||
|
||||
private
|
||||
|
||||
def authorization_check!(blob)
|
||||
attachment = blob.attachments.take
|
||||
|
||||
def authorization_check!(attachment)
|
||||
is_authorized = attachment.name.in?(%w[logo preview_images]) ||
|
||||
(current_user && attachment.record.account.id == current_user.account_id) ||
|
||||
(current_user && !Docuseal.multitenant? && current_user.role == 'superadmin') ||
|
||||
|
||||
@@ -11,13 +11,7 @@ module Api
|
||||
|
||||
def index
|
||||
submissions = Submissions.search(@submissions, params[:q])
|
||||
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
|
||||
|
||||
if params[:template_folder].present?
|
||||
submissions = submissions.joins(template: :folder).where(folder: { name: params[:template_folder] })
|
||||
end
|
||||
|
||||
submissions = Submissions::Filter.call(submissions, current_user, params)
|
||||
submissions = filter_submissions(submissions, params)
|
||||
|
||||
submissions = paginate(submissions.preload(:created_by_user, :submitters,
|
||||
template: :folder,
|
||||
@@ -111,6 +105,21 @@ module Api
|
||||
|
||||
private
|
||||
|
||||
def filter_submissions(submissions, params)
|
||||
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
|
||||
submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
|
||||
|
||||
if params[:template_folder].present?
|
||||
submissions = submissions.joins(template: :folder).where(folder: { name: params[:template_folder] })
|
||||
end
|
||||
|
||||
if params.key?(:archived)
|
||||
submissions = params[:archived].in?(['true', true]) ? submissions.archived : submissions.active
|
||||
end
|
||||
|
||||
Submissions::Filter.call(submissions, current_user, params)
|
||||
end
|
||||
|
||||
def build_create_json(submissions)
|
||||
json = submissions.flat_map do |submission|
|
||||
submission.submitters.map do |s|
|
||||
|
||||
@@ -6,10 +6,10 @@ module Api
|
||||
skip_authorization_check
|
||||
|
||||
def create
|
||||
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
if params[:t] == SubmissionEvents.build_tracking_param(submitter, 'click_email')
|
||||
SubmissionEvents.create_with_tracking_data(submitter, 'click_email', request)
|
||||
if params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email')
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request)
|
||||
end
|
||||
|
||||
render json: {}
|
||||
|
||||
@@ -6,15 +6,15 @@ module Api
|
||||
skip_authorization_check
|
||||
|
||||
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
|
||||
@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)
|
||||
|
||||
WebhookUrls.for_account_id(submitter.account_id, 'form.viewed').each do |webhook_url|
|
||||
SendFormViewedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
|
||||
WebhookUrls.for_account_id(@submitter.account_id, 'form.viewed').each do |webhook_url|
|
||||
SendFormViewedWebhookRequestJob.perform_async('submitter_id' => @submitter.id,
|
||||
'webhook_url_id' => webhook_url.id)
|
||||
end
|
||||
|
||||
|
||||
@@ -7,15 +7,7 @@ module Api
|
||||
def index
|
||||
submitters = Submitters.search(@submitters, params[:q])
|
||||
|
||||
submitters = submitters.where(external_id: params[:application_key]) if params[:application_key].present?
|
||||
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
|
||||
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
|
||||
|
||||
if params[:template_id].present?
|
||||
submitters = submitters.joins(:submission).where(submission: { template_id: params[:template_id] })
|
||||
end
|
||||
|
||||
submitters = maybe_filder_by_completed_at(submitters, params)
|
||||
submitters = filter_submitters(submitters, params)
|
||||
|
||||
submitters = paginate(
|
||||
submitters.preload(:template, :submission, :submission_events,
|
||||
@@ -163,6 +155,19 @@ module Api
|
||||
submitter
|
||||
end
|
||||
|
||||
def filter_submitters(submitters, params)
|
||||
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(slug: params[:slug]) if params[:slug].present?
|
||||
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
|
||||
|
||||
if params[:template_id].present?
|
||||
submitters = submitters.joins(:submission).where(submissions: { template_id: params[:template_id] })
|
||||
end
|
||||
|
||||
maybe_filder_by_completed_at(submitters, params)
|
||||
end
|
||||
|
||||
def assign_external_id(submitter, attrs)
|
||||
submitter.external_id = attrs[:application_key] if attrs.key?(:application_key)
|
||||
submitter.external_id = attrs[:external_id] if attrs.key?(:external_id)
|
||||
|
||||
@@ -5,7 +5,7 @@ module Api
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
authorize!(:manage, @template)
|
||||
authorize!(:create, @template)
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
|
||||
@@ -90,6 +90,7 @@ module Api
|
||||
templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active
|
||||
templates = templates.where(external_id: params[:application_key]) if params[:application_key].present?
|
||||
templates = templates.where(external_id: params[:external_id]) if params[:external_id].present?
|
||||
templates = templates.where(slug: params[:slug]) if params[:slug].present?
|
||||
templates = templates.joins(:folder).where(folder: { name: params[:folder] }) if params[:folder].present?
|
||||
|
||||
templates
|
||||
@@ -99,6 +100,7 @@ module Api
|
||||
permitted_params = [
|
||||
:name,
|
||||
:external_id,
|
||||
:shared_link,
|
||||
{
|
||||
submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email]],
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type,
|
||||
|
||||
@@ -55,6 +55,14 @@ class ApplicationController < ActionController::Base
|
||||
request.session[:impersonated_user_id] = user.uuid
|
||||
end
|
||||
|
||||
def pagy_auto(collection, **keyword_args)
|
||||
if current_ability.can?(:manage, :countless)
|
||||
pagy_countless(collection, **keyword_args)
|
||||
else
|
||||
pagy(collection, **keyword_args)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def with_locale(&)
|
||||
|
||||
@@ -9,7 +9,9 @@ class EmailSmtpSettingsController < ApplicationController
|
||||
|
||||
def create
|
||||
if @encrypted_config.update(email_configs)
|
||||
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email'] || current_user.email).deliver_now!
|
||||
unless Docuseal.multitenant?
|
||||
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email'] || current_user.email).deliver_now!
|
||||
end
|
||||
|
||||
redirect_to settings_email_index_path, notice: I18n.t('changes_have_been_saved')
|
||||
else
|
||||
|
||||
@@ -12,6 +12,8 @@ class PreviewDocumentPageController < ActionController::API
|
||||
|
||||
return head :not_found unless attachment
|
||||
|
||||
@template = attachment.record
|
||||
|
||||
preview_image = attachment.preview_images.joins(:blob)
|
||||
.find_by(blob: { filename: ["#{params[:id]}.png", "#{params[:id]}.jpg"] })
|
||||
|
||||
|
||||
@@ -10,16 +10,17 @@ class SendSubmissionEmailController < ApplicationController
|
||||
SEND_DURATION = 30.minutes
|
||||
|
||||
def create
|
||||
@submitter =
|
||||
if params[:template_slug]
|
||||
Submitter.joins(submission: :template).find_by!(email: params[:email].to_s.downcase,
|
||||
template: { slug: params[:template_slug] })
|
||||
elsif params[:submission_slug]
|
||||
Submitter.joins(:submission).find_by!(email: params[:email].to_s.downcase,
|
||||
submission: { slug: params[:submission_slug] })
|
||||
else
|
||||
Submitter.find_by!(slug: params[:submitter_slug])
|
||||
end
|
||||
if params[:template_slug]
|
||||
@submitter = Submitter.completed.joins(submission: :template).find_by!(email: params[:email].to_s.downcase,
|
||||
template: { slug: params[:template_slug] })
|
||||
elsif params[:submission_slug]
|
||||
@submitter = Submitter.completed.joins(:submission).find_by(email: params[:email].to_s.downcase,
|
||||
submission: { slug: params[:submission_slug] })
|
||||
|
||||
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter
|
||||
else
|
||||
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
|
||||
end
|
||||
|
||||
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
|
||||
|
||||
|
||||
@@ -8,17 +8,28 @@ class StartFormController < ApplicationController
|
||||
|
||||
around_action :with_browser_locale, only: %i[show completed]
|
||||
before_action :maybe_redirect_com, only: %i[show completed]
|
||||
before_action :load_resubmit_submitter, only: :update
|
||||
before_action :load_template
|
||||
before_action :authorize_start!, only: :update
|
||||
|
||||
def show
|
||||
@submitter = @template.submissions.new(account_id: @template.account_id)
|
||||
.submitters.new(uuid: (filter_undefined_submitters(@template).first ||
|
||||
@template.submitters.first)['uuid'])
|
||||
raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa']
|
||||
|
||||
if @template.shared_link?
|
||||
@submitter = @template.submissions.new(account_id: @template.account_id)
|
||||
.submitters.new(account_id: @template.account_id,
|
||||
uuid: (filter_undefined_submitters(@template).first ||
|
||||
@template.submitters.first)['uuid'])
|
||||
else
|
||||
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
|
||||
|
||||
return render :private if current_user && current_ability.can?(:read, @template)
|
||||
|
||||
raise ActionController::RoutingError, I18n.t('not_found')
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
return redirect_to start_form_path(@template.slug) if @template.archived_at?
|
||||
|
||||
@submitter = find_or_initialize_submitter(@template, submitter_params)
|
||||
|
||||
if @submitter.completed_at?
|
||||
@@ -40,9 +51,11 @@ class StartFormController < ApplicationController
|
||||
|
||||
if @submitter.save
|
||||
if is_new_record
|
||||
WebhookUrls.for_account_id(@submitter.account_id, 'submission.created').each do |webhook_url|
|
||||
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => @submitter.submission_id,
|
||||
'webhook_url_id' => webhook_url.id)
|
||||
enqueue_submission_create_webhooks(@submitter)
|
||||
|
||||
if @submitter.submission.expire_at?
|
||||
ProcessSubmissionExpiredJob.perform_at(@submitter.submission.expire_at,
|
||||
'submission_id' => @submitter.submission_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -54,6 +67,8 @@ class StartFormController < ApplicationController
|
||||
end
|
||||
|
||||
def completed
|
||||
return redirect_to start_form_path(@template.slug) if !@template.shared_link? || @template.archived_at?
|
||||
|
||||
@submitter = Submitter.where(submission: @template.submissions)
|
||||
.where.not(completed_at: nil)
|
||||
.find_by!(email: params[:email])
|
||||
@@ -61,32 +76,55 @@ class StartFormController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def load_resubmit_submitter
|
||||
@resubmit_submitter =
|
||||
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
|
||||
Submitter.find_by(slug: params[:resubmit])
|
||||
end
|
||||
end
|
||||
|
||||
def authorize_start!
|
||||
return redirect_to start_form_path(@template.slug) if @template.archived_at?
|
||||
|
||||
return if @resubmit_submitter
|
||||
return if @template.shared_link? || (current_user && current_ability.can?(:read, @template))
|
||||
|
||||
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
|
||||
|
||||
redirect_to start_form_path(@template.slug)
|
||||
end
|
||||
|
||||
def enqueue_submission_create_webhooks(submitter)
|
||||
WebhookUrls.for_account_id(submitter.account_id, 'submission.created').each do |webhook_url|
|
||||
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
|
||||
'webhook_url_id' => webhook_url.id)
|
||||
end
|
||||
end
|
||||
|
||||
def find_or_initialize_submitter(template, submitter_params)
|
||||
Submitter.where(submission: template.submissions.where(expire_at: Time.current..)
|
||||
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
|
||||
.order(id: :desc)
|
||||
.where(declined_at: nil)
|
||||
.where(external_id: nil)
|
||||
.where(ip: [nil, request.remote_ip])
|
||||
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
|
||||
.find_or_initialize_by(email: submitter_params[:email], **submitter_params.compact_blank)
|
||||
Submitter
|
||||
.where(submission: template.submissions.where(expire_at: Time.current..)
|
||||
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
|
||||
.order(id: :desc)
|
||||
.where(declined_at: nil)
|
||||
.where(external_id: nil)
|
||||
.where(ip: [nil, request.remote_ip])
|
||||
.then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
|
||||
.find_or_initialize_by(email: submitter_params[:email], **submitter_params.compact_blank)
|
||||
end
|
||||
|
||||
def assign_submission_attributes(submitter, template)
|
||||
resubmit_submitter =
|
||||
(Submitter.where(submission: template.submissions).find_by(slug: params[:resubmit]) if params[:resubmit].present?)
|
||||
|
||||
submitter.assign_attributes(
|
||||
uuid: (filter_undefined_submitters(template).first || @template.submitters.first)['uuid'],
|
||||
ip: request.remote_ip,
|
||||
ua: request.user_agent,
|
||||
values: resubmit_submitter&.preferences&.fetch('default_values', nil) || {},
|
||||
preferences: resubmit_submitter&.preferences.presence || { 'send_email' => true },
|
||||
metadata: resubmit_submitter&.metadata.presence || {}
|
||||
values: @resubmit_submitter&.preferences&.fetch('default_values', nil) || {},
|
||||
preferences: @resubmit_submitter&.preferences.presence || { 'send_email' => true },
|
||||
metadata: @resubmit_submitter&.metadata.presence || {}
|
||||
)
|
||||
|
||||
if submitter.values.present?
|
||||
resubmit_submitter.attachments.each do |attachment|
|
||||
@resubmit_submitter.attachments.each do |attachment|
|
||||
submitter.attachments << attachment.dup if submitter.values.value?(attachment.uuid)
|
||||
end
|
||||
end
|
||||
@@ -94,6 +132,7 @@ class StartFormController < ApplicationController
|
||||
submitter.submission ||= Submission.new(template:,
|
||||
account_id: template.account_id,
|
||||
template_submitters: template.submitters,
|
||||
expire_at: Templates.build_default_expire_at(template),
|
||||
submitters: [submitter],
|
||||
source: :link)
|
||||
|
||||
@@ -107,15 +146,21 @@ class StartFormController < ApplicationController
|
||||
end
|
||||
|
||||
def submitter_params
|
||||
return current_user.slice(:email) if params[:selfsign]
|
||||
return @resubmit_submitter.slice(:name, :phone, :email) if @resubmit_submitter.present?
|
||||
|
||||
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
|
||||
attrs[:email] = Submissions.normalize_email(attrs[:email])
|
||||
end
|
||||
end
|
||||
|
||||
def load_template
|
||||
slug = params[:slug] || params[:start_form_slug]
|
||||
|
||||
@template = Template.find_by!(slug:)
|
||||
@template =
|
||||
if @resubmit_submitter
|
||||
@resubmit_submitter.template
|
||||
else
|
||||
Template.find_by!(slug: params[:slug] || params[:start_form_slug])
|
||||
end
|
||||
end
|
||||
|
||||
def multiple_submitters_error_message
|
||||
|
||||
@@ -7,9 +7,7 @@ class SubmissionsArchivedController < ApplicationController
|
||||
@submissions = @submissions.joins(:template)
|
||||
@submissions = @submissions.where.not(archived_at: nil)
|
||||
.or(@submissions.where.not(templates: { archived_at: nil }))
|
||||
.preload(:created_by_user, template: :author)
|
||||
|
||||
@submissions = @submissions.preload(:template_accesses) unless current_user.role.in?(%w[admin superadmin])
|
||||
.preload(:template_accesses, :created_by_user, template: :author)
|
||||
|
||||
@submissions = Submissions.search(@submissions, params[:q], search_template: true)
|
||||
@submissions = Submissions::Filter.call(@submissions, current_user, params)
|
||||
@@ -20,6 +18,6 @@ class SubmissionsArchivedController < ApplicationController
|
||||
@submissions.order(id: :desc)
|
||||
end
|
||||
|
||||
@pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events))
|
||||
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -57,6 +57,11 @@ class SubmissionsController < ApplicationController
|
||||
Submissions.send_signature_requests(submissions)
|
||||
|
||||
redirect_to template_path(@template), notice: I18n.t('new_recipients_have_been_added')
|
||||
rescue Submissions::CreateFromSubmitters::BaseError => e
|
||||
render turbo_stream: turbo_stream.replace(:submitters_error,
|
||||
partial: 'submissions/error',
|
||||
locals: { error: e.message }),
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
||||
@@ -8,9 +8,7 @@ class SubmissionsDashboardController < ApplicationController
|
||||
|
||||
@submissions = @submissions.where(archived_at: nil)
|
||||
.where(templates: { archived_at: nil })
|
||||
.preload(:created_by_user, template: :author)
|
||||
|
||||
@submissions = @submissions.preload(:template_accesses) unless current_user.role.in?(%w[admin superadmin])
|
||||
.preload(:template_accesses, :created_by_user, template: :author)
|
||||
|
||||
@submissions = Submissions.search(@submissions, params[:q], search_template: true)
|
||||
@submissions = Submissions::Filter.call(@submissions, current_user, params)
|
||||
@@ -21,6 +19,6 @@ class SubmissionsDashboardController < ApplicationController
|
||||
@submissions.order(id: :desc)
|
||||
end
|
||||
|
||||
@pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events))
|
||||
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,25 +8,25 @@ class SubmissionsDownloadController < ApplicationController
|
||||
FILES_TTL = 5.minutes
|
||||
|
||||
def index
|
||||
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
|
||||
signature_valid =
|
||||
if submitter&.slug == params[:submitter_slug]
|
||||
if @submitter&.slug == params[:submitter_slug]
|
||||
true
|
||||
else
|
||||
submitter = nil
|
||||
@submitter = nil
|
||||
end
|
||||
|
||||
submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
|
||||
@submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
Submissions::EnsureResultGenerated.call(submitter)
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
|
||||
last_submitter = submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
|
||||
return head :not_found unless last_submitter
|
||||
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
return head :not_found unless last_submitter.completed_at?
|
||||
|
||||
if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter)
|
||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
@@ -34,7 +34,7 @@ class SubmissionsDownloadController < ApplicationController
|
||||
end
|
||||
|
||||
if params[:combined] == 'true'
|
||||
url = build_combined_url(submitter)
|
||||
url = build_combined_url(@submitter)
|
||||
|
||||
if url
|
||||
render json: [url]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmissionsPreviewController < ApplicationController
|
||||
around_action :with_browser_locale
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ class SubmitFormController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
before_action :load_submitter, only: %i[show update completed]
|
||||
|
||||
CONFIG_KEYS = [].freeze
|
||||
|
||||
def show
|
||||
@submitter = Submitter.find_by!(slug: params[:slug])
|
||||
|
||||
submission = @submitter.submission
|
||||
|
||||
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
|
||||
@@ -20,7 +20,11 @@ class SubmitFormController < ApplicationController
|
||||
@submitter.account.archived_at?
|
||||
return render :expired if submission.expired?
|
||||
return render :declined if @submitter.declined_at?
|
||||
return render :awaiting if submission.template.preferences['submitters_order'] == 'preserved' &&
|
||||
|
||||
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
|
||||
|
||||
return render :awaiting if (@form_configs[:enforce_signing_order] ||
|
||||
submission.template.preferences['submitters_order'] == 'preserved') &&
|
||||
!Submitters.current_submitter_order?(@submitter)
|
||||
|
||||
Submitters.preload_with_pages(@submitter)
|
||||
@@ -29,8 +33,6 @@ class SubmitFormController < ApplicationController
|
||||
|
||||
@attachments_index = build_attachments_index(submission)
|
||||
|
||||
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
|
||||
|
||||
return unless @form_configs[:prefill_signature]
|
||||
|
||||
if (user_signature = UserConfigs.load_signature(current_user))
|
||||
@@ -48,40 +50,44 @@ class SubmitFormController < ApplicationController
|
||||
end
|
||||
|
||||
def update
|
||||
submitter = Submitter.find_by!(slug: params[:slug])
|
||||
|
||||
if submitter.completed_at?
|
||||
if @submitter.completed_at?
|
||||
return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if submitter.template.archived_at? || submitter.submission.archived_at?
|
||||
if @submitter.template.archived_at? || @submitter.submission.archived_at?
|
||||
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if submitter.submission.expired?
|
||||
if @submitter.submission.expired?
|
||||
return render json: { error: I18n.t('form_has_been_expired') }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if submitter.declined_at?
|
||||
if @submitter.declined_at?
|
||||
return render json: { error: I18n.t('form_has_been_declined') },
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
|
||||
Submitters::SubmitValues.call(submitter, params, request)
|
||||
Submitters::SubmitValues.call(@submitter, params, request)
|
||||
|
||||
head :ok
|
||||
rescue Submitters::SubmitValues::RequiredFieldError => e
|
||||
Rollbar.warning("Required field #{@submitter.id}: #{e.message}") if defined?(Rollbar)
|
||||
|
||||
render json: { field_uuid: e.message }, status: :unprocessable_entity
|
||||
rescue Submitters::SubmitValues::ValidationError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def completed
|
||||
@submitter = Submitter.completed.find_by!(slug: params[:submit_form_slug])
|
||||
end
|
||||
def completed; end
|
||||
|
||||
def success; end
|
||||
|
||||
private
|
||||
|
||||
def load_submitter
|
||||
@submitter = Submitter.find_by!(slug: params[:slug] || params[:submit_form_slug])
|
||||
end
|
||||
|
||||
def build_attachments_index(submission)
|
||||
ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments)
|
||||
.preload(:blob).index_by(&:uuid)
|
||||
|
||||
@@ -7,25 +7,25 @@ class SubmitFormDownloadController < ApplicationController
|
||||
FILES_TTL = 5.minutes
|
||||
|
||||
def index
|
||||
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
return redirect_to submitter_download_index_path(submitter.slug) if submitter.completed_at?
|
||||
return redirect_to submitter_download_index_path(@submitter.slug) if @submitter.completed_at?
|
||||
|
||||
return head :unprocessable_entity if submitter.declined_at? ||
|
||||
submitter.submission.archived_at? ||
|
||||
submitter.submission.expired? ||
|
||||
submitter.submission.template.archived_at?
|
||||
return head :unprocessable_entity if @submitter.declined_at? ||
|
||||
@submitter.submission.archived_at? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.template.archived_at?
|
||||
|
||||
last_completed_submitter = submitter.submission.submitters
|
||||
.where.not(id: submitter.id)
|
||||
.where.not(completed_at: nil)
|
||||
.max_by(&:completed_at)
|
||||
last_completed_submitter = @submitter.submission.submitters
|
||||
.where.not(id: @submitter.id)
|
||||
.where.not(completed_at: nil)
|
||||
.max_by(&:completed_at)
|
||||
|
||||
attachments =
|
||||
if last_completed_submitter
|
||||
Submitters.select_attachments_for_download(last_completed_submitter)
|
||||
else
|
||||
submitter.submission.template.schema_documents.preload(:blob)
|
||||
@submitter.submission.template.schema_documents.preload(:blob)
|
||||
end
|
||||
|
||||
urls = attachments.map do |attachment|
|
||||
|
||||
@@ -5,10 +5,11 @@ class TemplateFoldersController < ApplicationController
|
||||
|
||||
def show
|
||||
@templates = @template_folder.templates.active.accessible_by(current_ability)
|
||||
.preload(:author, :template_accesses).order(id: :desc)
|
||||
.preload(:author, :template_accesses)
|
||||
@templates = Templates.search(@templates, params[:q])
|
||||
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
|
||||
|
||||
@pagy, @templates = pagy(@templates, limit: 12)
|
||||
@pagy, @templates = pagy_auto(@templates, limit: 12)
|
||||
end
|
||||
|
||||
def edit; end
|
||||
|
||||
@@ -7,6 +7,6 @@ class TemplatesArchivedController < ApplicationController
|
||||
@templates = @templates.where.not(archived_at: nil).preload(:author, :folder, :template_accesses).order(id: :desc)
|
||||
@templates = Templates.search(@templates, params[:q])
|
||||
|
||||
@pagy, @templates = pagy(@templates, limit: 12)
|
||||
@pagy, @templates = pagy_auto(@templates, limit: 12)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController
|
||||
@submissions.order(id: :desc)
|
||||
end
|
||||
|
||||
@pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events))
|
||||
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to root_path
|
||||
end
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesCloneAndReplaceController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
return head :unprocessable_entity if params[:files].blank?
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [schema_documents: :preview_images_attachments]
|
||||
).call
|
||||
|
||||
cloned_template = Templates::Clone.call(@template, author: current_user)
|
||||
cloned_template.name = File.basename(params[:files].first.original_filename, '.*')
|
||||
cloned_template.save!
|
||||
|
||||
documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true)
|
||||
|
||||
cloned_template.save!
|
||||
|
||||
Templates::CloneAttachments.call(template: cloned_template, original_template: @template,
|
||||
excluded_attachment_uuids: documents.map(&:uuid))
|
||||
|
||||
respond_to do |f|
|
||||
f.html { redirect_to edit_template_path(cloned_template) }
|
||||
f.json { render json: { id: cloned_template.id } }
|
||||
end
|
||||
rescue Templates::CreateAttachments::PdfEncrypted
|
||||
respond_to do |f|
|
||||
f.html { render turbo_stream: turbo_stream.append(params[:form_id], html: helpers.tag.prompt_password) }
|
||||
f.json { render json: { error: 'PDF encrypted', status: 'pdf_encrypted' }, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -21,9 +21,7 @@ class TemplatesController < ApplicationController
|
||||
submissions.order(id: :desc)
|
||||
end
|
||||
|
||||
submissions = submissions.preload(:template_accesses) unless current_user.role.in?(%w[admin superadmin])
|
||||
|
||||
@pagy, @submissions = pagy(submissions.preload(submitters: :start_form_submission_events))
|
||||
@pagy, @submissions = pagy_auto(submissions.preload(:template_accesses, submitters: :start_form_submission_events))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to root_path
|
||||
end
|
||||
|
||||
@@ -9,13 +9,15 @@ class TemplatesDashboardController < ApplicationController
|
||||
FOLDERS_PER_PAGE = 18
|
||||
|
||||
def index
|
||||
@template_folders = @template_folders.where(id: @templates.active.select(:folder_id)).order(id: :desc)
|
||||
@template_folders = @template_folders.where(id: @templates.active.select(:folder_id))
|
||||
|
||||
@template_folders = TemplateFolders.search(@template_folders, params[:q])
|
||||
@template_folders = sort_template_folders(@template_folders, current_user,
|
||||
cookies.permanent[:dashboard_templates_order])
|
||||
|
||||
@pagy, @template_folders = pagy(
|
||||
@template_folders,
|
||||
items: FOLDERS_PER_PAGE,
|
||||
limit: FOLDERS_PER_PAGE,
|
||||
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
|
||||
)
|
||||
|
||||
@@ -24,6 +26,7 @@ class TemplatesDashboardController < ApplicationController
|
||||
else
|
||||
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
|
||||
@templates = filter_templates(@templates)
|
||||
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
|
||||
|
||||
limit =
|
||||
if @template_folders.size < 4
|
||||
@@ -32,26 +35,62 @@ class TemplatesDashboardController < ApplicationController
|
||||
(@template_folders.size < 7 ? 9 : 6)
|
||||
end
|
||||
|
||||
@pagy, @templates = pagy(@templates, limit:)
|
||||
@pagy, @templates = pagy_auto(@templates, limit:)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filter_templates(templates)
|
||||
rel = templates.active.preload(:author, :template_accesses).order(id: :desc)
|
||||
rel = templates.active.preload(:author, :template_accesses)
|
||||
|
||||
if params[:q].blank?
|
||||
if Docuseal.multitenant? && !current_account.testing?
|
||||
rel = rel.where(folder_id: current_account.default_template_folder.id)
|
||||
else
|
||||
shared_template_ids =
|
||||
TemplateSharing.where(account_id: [current_account.id, TemplateSharing::ALL_ID]).select(:template_id)
|
||||
if Docuseal.multitenant? ? current_account.testing? : current_account.linked_account_account
|
||||
shared_account_ids = [current_user.account_id]
|
||||
shared_account_ids << TemplateSharing::ALL_ID if !Docuseal.multitenant? && !current_account.testing?
|
||||
|
||||
shared_template_ids = TemplateSharing.where(account_id: shared_account_ids).select(:template_id)
|
||||
|
||||
rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids))
|
||||
else
|
||||
rel = rel.where(folder_id: current_account.default_template_folder.id)
|
||||
end
|
||||
end
|
||||
|
||||
Templates.search(rel, params[:q])
|
||||
end
|
||||
|
||||
def sort_template_folders(template_folders, current_user, order)
|
||||
case order
|
||||
when 'used_at'
|
||||
subquery =
|
||||
Template.left_joins(:submissions)
|
||||
.group(:folder_id)
|
||||
.where(account_id: current_user.account_id)
|
||||
.select(
|
||||
:folder_id,
|
||||
Template.arel_table[:updated_at].maximum.as('updated_at_max'),
|
||||
Submission.arel_table[:created_at].maximum.as('submission_created_at_max')
|
||||
)
|
||||
|
||||
template_folders = template_folders.joins(
|
||||
Template.arel_table
|
||||
.join(subquery.arel.as('templates'), Arel::Nodes::OuterJoin)
|
||||
.on(TemplateFolder.arel_table[:id].eq(Template.arel_table[:folder_id]))
|
||||
.join_sources
|
||||
)
|
||||
|
||||
template_folders.order(
|
||||
Arel::Nodes::Case.new
|
||||
.when(Template.arel_table[:submission_created_at_max].gt(Template.arel_table[:updated_at_max]))
|
||||
.then(Template.arel_table[:submission_created_at_max])
|
||||
.else(Template.arel_table[:updated_at_max])
|
||||
.desc
|
||||
)
|
||||
when 'name'
|
||||
template_folders.order(name: :asc)
|
||||
else
|
||||
template_folders.order(id: :desc)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,14 +24,22 @@ class TemplatesPreferencesController < ApplicationController
|
||||
documents_copy_email_enabled documents_copy_email_attach_audit
|
||||
documents_copy_email_attach_documents documents_copy_email_reply_to
|
||||
completed_notification_email_attach_documents
|
||||
completed_redirect_url
|
||||
submitters_order
|
||||
completed_redirect_url validate_unique_submitters
|
||||
submitters_order require_phone_2fa
|
||||
default_expire_at_duration
|
||||
default_expire_at
|
||||
completed_notification_email_subject completed_notification_email_body
|
||||
completed_notification_email_enabled completed_notification_email_attach_audit] +
|
||||
[completed_message: %i[title body],
|
||||
submitters: [%i[uuid request_email_subject request_email_body]]]
|
||||
).tap do |attrs|
|
||||
attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1'
|
||||
|
||||
if (default_expire_at = attrs.dig(:preferences, :default_expire_at).presence)
|
||||
attrs[:preferences][:default_expire_at] =
|
||||
(ActiveSupport::TimeZone[current_account.timezone] || Time.zone).parse(default_expire_at).utc
|
||||
end
|
||||
|
||||
attrs[:preferences] = attrs[:preferences].transform_values do |value|
|
||||
if %w[true false].include?(value)
|
||||
value == 'true'
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesShareLinkController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
authorize!(:update, @template)
|
||||
|
||||
@template.update!(template_params)
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(:shared_link)
|
||||
end
|
||||
end
|
||||
@@ -5,7 +5,8 @@ class UserConfigsController < ApplicationController
|
||||
authorize_resource :user_config
|
||||
|
||||
ALLOWED_KEYS = [
|
||||
UserConfig::RECEIVE_COMPLETED_EMAIL
|
||||
UserConfig::RECEIVE_COMPLETED_EMAIL,
|
||||
UserConfig::SHOW_APP_TOUR
|
||||
].freeze
|
||||
|
||||
InvalidKey = Class.new(StandardError)
|
||||
@@ -28,6 +29,7 @@ class UserConfigsController < ApplicationController
|
||||
def user_config_params
|
||||
params.required(:user_config).permit(:key, :value, { value: {} }, { value: [] }).tap do |attrs|
|
||||
attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0])
|
||||
attrs[:value] = attrs[:value] == 'true' if attrs[:value].in?(%w[true false])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,7 +16,7 @@ class UsersController < ApplicationController
|
||||
@users.active.where.not(role: 'integration')
|
||||
end
|
||||
|
||||
@pagy, @users = pagy(@users.where(account: current_account).order(id: :desc))
|
||||
@pagy, @users = pagy(@users.preload(account: :account_accesses).where(account: current_account).order(id: :desc))
|
||||
end
|
||||
|
||||
def new; end
|
||||
|
||||
@@ -24,6 +24,7 @@ import SubmitForm from './elements/submit_form'
|
||||
import PromptPassword from './elements/prompt_password'
|
||||
import EmailsTextarea from './elements/emails_textarea'
|
||||
import ToggleOnSubmit from './elements/toggle_on_submit'
|
||||
import CheckOnClick from './elements/check_on_click'
|
||||
import PasswordInput from './elements/password_input'
|
||||
import SearchInput from './elements/search_input'
|
||||
import ToggleAttribute from './elements/toggle_attribute'
|
||||
@@ -32,11 +33,11 @@ import CheckboxGroup from './elements/checkbox_group'
|
||||
import MaskedInput from './elements/masked_input'
|
||||
import SetDateButton from './elements/set_date_button'
|
||||
import IndeterminateCheckbox from './elements/indeterminate_checkbox'
|
||||
import AppTour from './elements/app_tour'
|
||||
import DashboardDropzone from './elements/dashboard_dropzone'
|
||||
|
||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||
|
||||
import './images/preview.png'
|
||||
|
||||
TurboInstantClick.start()
|
||||
|
||||
document.addEventListener('turbo:before-cache', () => {
|
||||
@@ -101,6 +102,9 @@ safeRegisterElement('checkbox-group', CheckboxGroup)
|
||||
safeRegisterElement('masked-input', MaskedInput)
|
||||
safeRegisterElement('set-date-button', SetDateButton)
|
||||
safeRegisterElement('indeterminate-checkbox', IndeterminateCheckbox)
|
||||
safeRegisterElement('app-tour', AppTour)
|
||||
safeRegisterElement('dashboard-dropzone', DashboardDropzone)
|
||||
safeRegisterElement('check-on-click', CheckOnClick)
|
||||
|
||||
safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
@@ -125,8 +129,10 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
withSendButton: this.dataset.withSendButton !== 'false',
|
||||
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
|
||||
withConditions: this.dataset.withConditions === 'true',
|
||||
withReplaceAndCloneUpload: true,
|
||||
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
|
||||
acceptFileTypes: this.dataset.acceptFileTypes
|
||||
acceptFileTypes: this.dataset.acceptFileTypes,
|
||||
showTourStartForm: this.dataset.showTourStartForm === 'true'
|
||||
})
|
||||
|
||||
this.component = this.app.mount(this.appElem)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SignaturePad from 'signature_pad'
|
||||
import { cropCanvasAndExportToPNG } from './submission_form/crop_canvas'
|
||||
import { isValidSignatureCanvas } from './submission_form/validate_signature'
|
||||
|
||||
window.customElements.define('draw-signature', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
@@ -43,6 +44,8 @@ window.customElements.define('draw-signature', class extends HTMLElement {
|
||||
|
||||
return response
|
||||
})
|
||||
}).catch(error => {
|
||||
console.log(error)
|
||||
}).finally(() => {
|
||||
this.submitButton.disabled = false
|
||||
})
|
||||
@@ -65,26 +68,26 @@ window.customElements.define('draw-signature', class extends HTMLElement {
|
||||
}
|
||||
|
||||
async submitImage () {
|
||||
return new Promise((resolve, reject) => {
|
||||
cropCanvasAndExportToPNG(this.canvas, { errorOnTooSmall: true }).then(async (blob) => {
|
||||
const file = new File([blob], 'signature.png', { type: 'image/png' })
|
||||
if (!isValidSignatureCanvas(this.pad.toData())) {
|
||||
alert('Signature is too small or simple. Please redraw.')
|
||||
|
||||
const formData = new FormData()
|
||||
return Promise.reject(new Error('Image too small or simple'))
|
||||
}
|
||||
|
||||
formData.append('file', file)
|
||||
formData.append('submitter_slug', this.dataset.slug)
|
||||
formData.append('name', 'attachments')
|
||||
formData.append('remember_signature', 'true')
|
||||
return cropCanvasAndExportToPNG(this.canvas).then(async (blob) => {
|
||||
const file = new File([blob], 'signature.png', { type: 'image/png' })
|
||||
|
||||
return fetch('/api/attachments', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then((resp) => resp.json()).then((attachment) => {
|
||||
return resolve(attachment)
|
||||
})
|
||||
}).catch((error) => {
|
||||
return reject(error)
|
||||
})
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('file', file)
|
||||
formData.append('submitter_slug', this.dataset.slug)
|
||||
formData.append('name', 'attachments')
|
||||
formData.append('remember_signature', 'true')
|
||||
|
||||
return fetch('/api/attachments', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(resp => resp.json())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
export default class extends HTMLElement {
|
||||
async connectedCallback () {
|
||||
this.tourType = this.dataset.type
|
||||
this.nextPagePath = this.dataset.nextPagePath
|
||||
this.I18n = JSON.parse(this.dataset.i18n || '{}')
|
||||
|
||||
if (this.dataset.showTour === 'true') this.start()
|
||||
}
|
||||
|
||||
async start () {
|
||||
if (window.innerWidth < 768) return
|
||||
|
||||
const [{ driver }] = await Promise.all([
|
||||
import('driver.js'),
|
||||
import('driver.js/dist/driver.css')
|
||||
])
|
||||
|
||||
this.driverObj = driver({
|
||||
showProgress: true,
|
||||
nextBtnText: this.I18n.next,
|
||||
prevBtnText: this.I18n.previous,
|
||||
doneBtnText: this.I18n.done,
|
||||
onDestroyStarted: () => {
|
||||
this.disableAppGuide().finally(() => { this.destroy() })
|
||||
},
|
||||
onHighlightStarted: (element) => {
|
||||
if (element) {
|
||||
const clickHandler = () => {
|
||||
this.disableAppGuide().finally(() => { this.destroy() })
|
||||
element.removeEventListener('click', clickHandler)
|
||||
}
|
||||
|
||||
element.addEventListener('click', clickHandler)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (this.tourType === 'dashboard') {
|
||||
this.showDashboardTour()
|
||||
} else if (this.tourType === 'builder') {
|
||||
this.showTemplateBuilderTour()
|
||||
} else if (this.tourType === 'account') {
|
||||
this.showAccountTour()
|
||||
} else if (this.tourType === 'template') {
|
||||
this.showTemplateTour()
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
if (this.driverObj) this.destroy()
|
||||
}
|
||||
|
||||
destroy () {
|
||||
if (this.builderTemplate) this.builderTemplate.fields.shift()
|
||||
if (this.driverObj) this.driverObj.destroy()
|
||||
}
|
||||
|
||||
showTemplateTour () {
|
||||
const steps = [
|
||||
{
|
||||
element: '#share_link_clipboard',
|
||||
popover: {
|
||||
title: this.I18n.copy_and_share_link,
|
||||
description: this.I18n.copy_and_share_link_description,
|
||||
side: 'bottom',
|
||||
align: 'end'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#sign_yourself_button',
|
||||
popover: {
|
||||
title: this.I18n.sign_the_document,
|
||||
description: this.I18n.sign_the_document_description,
|
||||
side: 'top',
|
||||
align: 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#send_to_recipients_button',
|
||||
popover: {
|
||||
title: this.I18n.send_for_signing,
|
||||
description: this.I18n.add_recipients_description,
|
||||
side: 'top',
|
||||
align: 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#add_recipients_button',
|
||||
popover: {
|
||||
title: this.I18n.add_recipients,
|
||||
description: this.I18n.add_recipients_description,
|
||||
side: 'bottom',
|
||||
align: 'end'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#account_settings_button',
|
||||
popover: {
|
||||
title: this.I18n.settings,
|
||||
description: this.I18n.settings_template_description,
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: this.nextPagePath ? ['next', 'previous', 'close'] : ['previous', 'close'],
|
||||
onNextClick: () => {
|
||||
if (this.nextPagePath) {
|
||||
window.Turbo.visit(this.nextPagePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
].filter((step) => document.querySelector(step.element))
|
||||
|
||||
this.driverObj.setSteps(steps)
|
||||
this.driverObj.drive()
|
||||
}
|
||||
|
||||
showDashboardTour () {
|
||||
this.driverObj.setSteps([
|
||||
{
|
||||
element: '#templates_submissions_toggle',
|
||||
popover: {
|
||||
title: this.I18n.template_and_submissions,
|
||||
description: this.I18n.template_and_submissions_description,
|
||||
side: 'right',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#templates_upload_button',
|
||||
popover: {
|
||||
title: this.I18n.upload_a_pdf_file,
|
||||
description: this.I18n.upload_a_pdf_file_description,
|
||||
side: 'left',
|
||||
align: 'start',
|
||||
showButtons: this.nextPagePath ? ['next', 'previous', 'close'] : ['previous', 'close'],
|
||||
onNextClick: () => {
|
||||
if (this.nextPagePath) {
|
||||
window.Turbo.visit(this.nextPagePath)
|
||||
}
|
||||
}
|
||||
},
|
||||
onHighlightStarted: () => {}
|
||||
}
|
||||
])
|
||||
|
||||
this.driverObj.drive()
|
||||
}
|
||||
|
||||
showAccountTour () {
|
||||
this.driverObj.setSteps([
|
||||
{
|
||||
element: '#account_settings_menu',
|
||||
popover: {
|
||||
title: this.I18n.settings,
|
||||
description: this.I18n.settings_account_description,
|
||||
side: 'right',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#support_channels',
|
||||
popover: {
|
||||
title: this.I18n.support,
|
||||
description: this.I18n.support_description,
|
||||
side: 'left',
|
||||
align: 'start'
|
||||
}
|
||||
}
|
||||
].filter((step) => document.querySelector(step.element)))
|
||||
|
||||
this.driverObj.drive()
|
||||
}
|
||||
|
||||
showTemplateBuilderTour () {
|
||||
const builderComponent = document.querySelector('template-builder')?.component
|
||||
|
||||
this.builderTemplate = builderComponent?.template
|
||||
|
||||
if (this.builderTemplate) {
|
||||
this.builderTemplate.fields.unshift({
|
||||
uuid: 'b387399b-88dc-4345-9d37-743e97a9b2b3',
|
||||
submitter_uuid: this.builderTemplate.submitters[0].uuid,
|
||||
name: 'First Name',
|
||||
type: 'text'
|
||||
})
|
||||
|
||||
builderComponent.$nextTick(() => {
|
||||
this.driverObj.setSteps([
|
||||
{
|
||||
element: '.roles-dropdown',
|
||||
popover: {
|
||||
title: this.I18n.select_a_signer_party,
|
||||
description: this.I18n.select_a_signer_party_description,
|
||||
side: 'left',
|
||||
align: 'start',
|
||||
onPopoverRender: () => {
|
||||
const rolesDropdown = document.querySelector('.roles-dropdown')
|
||||
|
||||
rolesDropdown.dispatchEvent(new Event('mouseenter', { bubbles: true, cancelable: true }))
|
||||
rolesDropdown.classList.add('dropdown-open')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.roles-dropdown .dropdown-content',
|
||||
popover: {
|
||||
title: this.I18n.available_parties,
|
||||
description: this.I18n.available_parties_description,
|
||||
side: 'left',
|
||||
align: 'start',
|
||||
onPopoverRender: () => {
|
||||
document.querySelector('.roles-dropdown .dropdown-content').classList.remove('driver-active-element')
|
||||
},
|
||||
onNextClick: () => {
|
||||
document.querySelector('.roles-dropdown').classList.remove('dropdown-open')
|
||||
this.driverObj.moveNext()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#field-types-grid',
|
||||
popover: {
|
||||
title: this.I18n.available_field_types,
|
||||
description: this.I18n.available_field_types_description,
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
onPrevClick: () => {
|
||||
document.querySelector('.roles-dropdown').classList.add('dropdown-open')
|
||||
this.driverObj.movePrevious()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#text_type_field_button',
|
||||
popover: {
|
||||
title: this.I18n.text_input_field,
|
||||
description: this.I18n.text_input_field_description,
|
||||
side: 'left',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#signature_type_field_button',
|
||||
popover: {
|
||||
title: this.I18n.signature_field,
|
||||
description: this.I18n.signature_field_description,
|
||||
side: 'left',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.fields',
|
||||
popover: {
|
||||
title: this.I18n.added_fields,
|
||||
description: this.I18n.added_fields_description,
|
||||
side: 'right',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.list-field label:has(svg.tabler-icon-settings)',
|
||||
popover: {
|
||||
title: this.I18n.open_field_settings,
|
||||
description: this.I18n.open_field_settings_description,
|
||||
side: 'bottom',
|
||||
align: 'end',
|
||||
onPopoverRender: () => {
|
||||
const settingsDropdown = document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings)')
|
||||
|
||||
document.querySelectorAll('.list-field div:first-child .text-transparent').forEach((e) => e.classList.remove('text-transparent'))
|
||||
settingsDropdown.dispatchEvent(new Event('mouseenter', { bubbles: true, cancelable: true }))
|
||||
settingsDropdown.classList.add('dropdown-open')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.list-field div:first-child span:has(svg.tabler-icon-settings) .dropdown-content',
|
||||
popover: {
|
||||
title: this.I18n.field_settings,
|
||||
description: this.I18n.field_settings_description,
|
||||
side: 'left',
|
||||
align: 'start',
|
||||
onPopoverRender: () => {
|
||||
document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings) .dropdown-content').classList.remove('driver-active-element')
|
||||
},
|
||||
onNextClick: () => {
|
||||
document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings)').classList.remove('dropdown-open')
|
||||
this.driverObj.moveNext()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#send_button',
|
||||
popover: {
|
||||
title: this.I18n.send_document,
|
||||
description: this.I18n.send_document_description,
|
||||
side: 'bottom',
|
||||
align: 'end',
|
||||
onPrevClick: () => {
|
||||
document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings)').classList.add('dropdown-open')
|
||||
this.driverObj.movePrevious()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#sign_yourself_button',
|
||||
popover: {
|
||||
title: this.I18n.sign_yourself,
|
||||
description: this.I18n.sign_yourself_description,
|
||||
side: 'bottom',
|
||||
align: 'end',
|
||||
onNextClick: () => {
|
||||
if (this.nextPagePath) {
|
||||
window.Turbo.visit(this.nextPagePath)
|
||||
} else {
|
||||
this.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
this.driverObj.drive()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async disableAppGuide () {
|
||||
return fetch('/user_configs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({ key: 'show_app_tour', value: false })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('click', () => {
|
||||
if (!this.element.checked) {
|
||||
this.element.checked = true
|
||||
this.element.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
get element () {
|
||||
return document.getElementById(this.dataset.elementId)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@ export default class extends HTMLElement {
|
||||
this.clearChecked()
|
||||
|
||||
this.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
const text = this.dataset.text || this.innerText.trim()
|
||||
|
||||
if (navigator.clipboard) {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { target, targets, targetable } from '@github/catalyst/lib/targetable'
|
||||
|
||||
const loadingIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 3a9 9 0 1 0 9 9" />
|
||||
</svg>`
|
||||
|
||||
export default targetable(class extends HTMLElement {
|
||||
static [targets.static] = [
|
||||
'hiddenOnDrag',
|
||||
'folderCards',
|
||||
'templateCards'
|
||||
]
|
||||
|
||||
static [target.static] = [
|
||||
'form',
|
||||
'fileDropzone',
|
||||
'fileDropzoneLoading'
|
||||
]
|
||||
|
||||
connectedCallback () {
|
||||
document.addEventListener('drop', this.onWindowDragdrop)
|
||||
document.addEventListener('dragover', this.onWindowDropover)
|
||||
|
||||
window.addEventListener('dragleave', this.onWindowDragleave)
|
||||
|
||||
this.fileDropzone?.addEventListener('drop', this.onDropFile)
|
||||
|
||||
this.folderCards.forEach((el) => el.addEventListener('drop', (e) => this.onDropFolder(e, el)))
|
||||
this.templateCards.forEach((el) => el.addEventListener('drop', this.onDropTemplate))
|
||||
this.templateCards.forEach((el) => el.addEventListener('dragstart', this.onTemplateDragStart))
|
||||
|
||||
return [this.fileDropzone, ...this.folderCards, ...this.templateCards].forEach((el) => {
|
||||
el?.addEventListener('dragover', this.onDragover)
|
||||
el?.addEventListener('dragleave', this.onDragleave)
|
||||
})
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('drop', this.onWindowDragdrop)
|
||||
document.removeEventListener('dragover', this.onWindowDropover)
|
||||
|
||||
window.removeEventListener('dragleave', this.onWindowDragleave)
|
||||
}
|
||||
|
||||
onTemplateDragStart = (e) => {
|
||||
const id = e.target.href.split('/').pop()
|
||||
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
|
||||
if (id) {
|
||||
e.dataTransfer.setData('template_id', id)
|
||||
|
||||
const dragPreview = e.target.cloneNode(true)
|
||||
const rect = e.target.getBoundingClientRect()
|
||||
|
||||
const height = e.target.children[0].getBoundingClientRect().height + 50
|
||||
|
||||
dragPreview.children[1].remove()
|
||||
dragPreview.style.width = `${rect.width}px`
|
||||
dragPreview.style.height = `${height}px`
|
||||
dragPreview.style.position = 'absolute'
|
||||
dragPreview.style.top = '-1000px'
|
||||
dragPreview.style.pointerEvents = 'none'
|
||||
dragPreview.style.opacity = '0.9'
|
||||
|
||||
document.body.appendChild(dragPreview)
|
||||
|
||||
e.dataTransfer.setDragImage(dragPreview, rect.width / 2, height / 2)
|
||||
|
||||
setTimeout(() => document.body.removeChild(dragPreview), 0)
|
||||
}
|
||||
}
|
||||
|
||||
onDropFile = (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
this.fileDropzoneLoading.classList.remove('hidden')
|
||||
this.fileDropzoneLoading.previousElementSibling.classList.add('hidden')
|
||||
this.fileDropzoneLoading.classList.add('opacity-50')
|
||||
|
||||
this.uploadFiles(e.dataTransfer.files, '/templates_upload')
|
||||
}
|
||||
|
||||
onDropFolder = (e, el) => {
|
||||
e.preventDefault()
|
||||
|
||||
const templateId = e.dataTransfer.getData('template_id')
|
||||
|
||||
if (e.dataTransfer.files.length || templateId) {
|
||||
const loading = document.createElement('div')
|
||||
const svg = el.querySelector('svg')
|
||||
|
||||
loading.innerHTML = loadingIconHtml
|
||||
loading.children[0].classList.add(...svg.classList)
|
||||
|
||||
el.replaceChild(loading.children[0], svg)
|
||||
el.classList.add('opacity-50')
|
||||
|
||||
if (e.dataTransfer.files.length) {
|
||||
const params = new URLSearchParams({ folder_name: el.innerText }).toString()
|
||||
|
||||
this.uploadFiles(e.dataTransfer.files, `/templates_upload?${params}`)
|
||||
} else {
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('name', el.innerText)
|
||||
|
||||
fetch(`/templates/${templateId}/folder`, {
|
||||
method: 'PUT',
|
||||
redirect: 'manual',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
}
|
||||
}).finally(() => {
|
||||
window.Turbo.cache.clear()
|
||||
window.Turbo.visit(location.href)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDropTemplate = (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (e.dataTransfer.files.length) {
|
||||
const loading = document.createElement('div')
|
||||
loading.classList.add('bottom-5', 'left-0', 'flex', 'justify-center', 'w-full', 'absolute')
|
||||
loading.innerHTML = loadingIconHtml
|
||||
|
||||
e.target.appendChild(loading)
|
||||
e.target.classList.add('opacity-50')
|
||||
|
||||
const id = e.target.href.split('/').pop()
|
||||
|
||||
this.uploadFiles(e.dataTransfer.files, `/templates/${id}/clone_and_replace`)
|
||||
}
|
||||
}
|
||||
|
||||
onWindowDragdrop = (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!this.isLoading) this.hideDraghover()
|
||||
}
|
||||
|
||||
uploadFiles (files, url) {
|
||||
this.isLoading = true
|
||||
|
||||
this.form.action = url
|
||||
|
||||
this.form.querySelector('[type="file"]').files = files
|
||||
|
||||
this.form.querySelector('[type="submit"]').click()
|
||||
}
|
||||
|
||||
onWindowDropover = (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (e.dataTransfer?.types?.includes('Files')) {
|
||||
this.showDraghover()
|
||||
}
|
||||
}
|
||||
|
||||
onDragover (e) {
|
||||
if (e.dataTransfer?.types?.includes('Files') || this.dataset.targets !== 'dashboard-dropzone.templateCards') {
|
||||
this.style.backgroundColor = '#F7F3F0'
|
||||
|
||||
if (this.classList.contains('before:border-base-300')) {
|
||||
this.classList.remove('before:border-base-300')
|
||||
this.classList.add('before:border-base-content/30')
|
||||
} else if (this.classList.contains('border-base-300')) {
|
||||
this.classList.remove('border-base-300')
|
||||
this.classList.add('border-base-content/30')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDragleave () {
|
||||
this.style.backgroundColor = null
|
||||
|
||||
if (this.classList.contains('before:border-base-content/30')) {
|
||||
this.classList.remove('before:border-base-content/30')
|
||||
this.classList.add('before:border-base-300')
|
||||
} else if (this.classList.contains('border-base-content/30')) {
|
||||
this.classList.remove('border-base-content/30')
|
||||
this.classList.add('border-base-300')
|
||||
}
|
||||
}
|
||||
|
||||
onWindowDragleave = (e) => {
|
||||
if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) {
|
||||
this.hideDraghover()
|
||||
}
|
||||
}
|
||||
|
||||
showDraghover = () => {
|
||||
if (this.isDrag) return
|
||||
|
||||
this.isDrag = true
|
||||
|
||||
this.fileDropzone?.classList?.remove('hidden')
|
||||
|
||||
this.hiddenOnDrag.forEach((el) => { el.style.display = 'none' })
|
||||
|
||||
return [...this.folderCards, ...this.templateCards].forEach((el) => {
|
||||
el.classList.remove('bg-base-200', 'before:hidden')
|
||||
})
|
||||
}
|
||||
|
||||
hideDraghover = () => {
|
||||
this.isDrag = false
|
||||
|
||||
this.fileDropzone?.classList?.add('hidden')
|
||||
|
||||
this.hiddenOnDrag.forEach((el) => { el.style.display = null })
|
||||
|
||||
return [...this.folderCards, ...this.templateCards].forEach((el) => {
|
||||
el.classList.add('bg-base-200', 'before:hidden')
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.form.addEventListener('submit', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
this.submit()
|
||||
})
|
||||
|
||||
if (this.dataset.onload === 'true') {
|
||||
this.form.querySelector('button').click()
|
||||
}
|
||||
}
|
||||
|
||||
submit () {
|
||||
fetch(this.form.action, {
|
||||
method: this.form.method,
|
||||
body: new FormData(this.form)
|
||||
}).then(async (resp) => {
|
||||
if (!resp.ok) {
|
||||
try {
|
||||
const data = JSON.parse(await resp.text())
|
||||
|
||||
if (data.error) {
|
||||
alert(data.error)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
get form () {
|
||||
return this.querySelector('form')
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,37 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
static [target.static] = [
|
||||
'loading',
|
||||
'icon',
|
||||
'input'
|
||||
'input',
|
||||
'area'
|
||||
]
|
||||
|
||||
connectedCallback () {
|
||||
this.addEventListener('drop', this.onDrop)
|
||||
|
||||
this.addEventListener('dragover', (e) => e.preventDefault())
|
||||
|
||||
this.addEventListener('drop', this.onDrop)
|
||||
document.addEventListener('turbo:submit-end', this.toggleLoading)
|
||||
this.area?.addEventListener('dragover', this.onDragover)
|
||||
this.area?.addEventListener('dragleave', this.onDragleave)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.removeEventListener('drop', this.onDrop)
|
||||
document.removeEventListener('turbo:submit-end', this.toggleLoading)
|
||||
this.area?.removeEventListener('dragover', this.onDragover)
|
||||
this.area?.removeEventListener('dragleave', this.onDragleave)
|
||||
}
|
||||
|
||||
onDragover (e) {
|
||||
if (e.dataTransfer?.types?.includes('Files')) {
|
||||
this.style.backgroundColor = '#F7F3F0'
|
||||
this.classList.remove('border-base-300', 'hover:bg-base-200/30')
|
||||
this.classList.add('border-base-content/30')
|
||||
}
|
||||
}
|
||||
|
||||
onDragleave () {
|
||||
this.style.backgroundColor = null
|
||||
this.classList.remove('border-base-content/30')
|
||||
this.classList.add('border-base-300', 'hover:bg-base-200/30')
|
||||
}
|
||||
|
||||
onDrop (e) {
|
||||
@@ -35,7 +53,7 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
}
|
||||
|
||||
toggleLoading = (e) => {
|
||||
if (e && e.target && !e.target.contains(this)) {
|
||||
if (e && e.target && (!e.target.contains(this) || !e.detail?.formSubmission?.formElement?.contains(this))) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.header = document.querySelector('#signing_form_header')
|
||||
|
||||
window.addEventListener('scroll', this.onScroll)
|
||||
window.addEventListener('resize', this.onResize)
|
||||
|
||||
if (!this.isNarrow() && this.isHeaderNotVisible()) {
|
||||
this.showButtons({ animate: false })
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
window.removeEventListener('scroll', this.onScroll)
|
||||
window.removeEventListener('resize', this.onResize)
|
||||
}
|
||||
|
||||
onResize = () => {
|
||||
if (this.isNarrow()) {
|
||||
this.hideButtons(true)
|
||||
} else if (this.isHeaderNotVisible()) {
|
||||
this.showButtons()
|
||||
}
|
||||
}
|
||||
|
||||
isNarrow () {
|
||||
return window.innerWidth < 1230
|
||||
}
|
||||
|
||||
onScroll = () => {
|
||||
if (this.isHeaderNotVisible() && !this.isNarrow()) {
|
||||
this.showButtons()
|
||||
} else {
|
||||
this.hideButtons()
|
||||
}
|
||||
}
|
||||
|
||||
isHeaderNotVisible () {
|
||||
const rect = this.header.getBoundingClientRect()
|
||||
return rect.bottom <= 0 || rect.top >= window.innerHeight
|
||||
}
|
||||
|
||||
showButtons ({ animate } = { animate: true }) {
|
||||
if (animate) {
|
||||
this.classList.add('transition-transform', 'duration-300')
|
||||
}
|
||||
|
||||
this.classList.remove('hidden', '-translate-y-10', 'opacity-0')
|
||||
this.classList.add('translate-y-0', 'opacity-100')
|
||||
}
|
||||
|
||||
hideButtons () {
|
||||
this.classList.remove('translate-y-0', 'opacity-100')
|
||||
this.classList.add('-translate-y-10', 'opacity-0')
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.classList.contains('-translate-y-10')) {
|
||||
this.classList.add('hidden')
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export default actionable(class extends HTMLElement {
|
||||
const elementIds = JSON.parse(this.dataset.elementIds)
|
||||
|
||||
elementIds.forEach((elementId) => {
|
||||
document.getElementById(elementId).classList.toggle('hidden', event.target.value !== elementId)
|
||||
document.getElementById(elementId).classList.toggle('hidden', (event.target.dataset.toggleId || event.target.value) !== elementId)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,11 +3,15 @@ import { createApp, reactive } from 'vue'
|
||||
import Form from './submission_form/form'
|
||||
import DownloadButton from './elements/download_button'
|
||||
import ToggleSubmit from './elements/toggle_submit'
|
||||
import FetchForm from './elements/fetch_form'
|
||||
import ScrollButtons from './elements/scroll_buttons'
|
||||
|
||||
const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
|
||||
|
||||
safeRegisterElement('download-button', DownloadButton)
|
||||
safeRegisterElement('toggle-submit', ToggleSubmit)
|
||||
safeRegisterElement('fetch-form', FetchForm)
|
||||
safeRegisterElement('scroll-buttons', ScrollButtons)
|
||||
safeRegisterElement('submission-form', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.appElem = document.createElement('div')
|
||||
|
||||
|
Before Width: | Height: | Size: 41 KiB |
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="isActive && withLabel && (!area.option_uuid || !option.value)"
|
||||
class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap pointer-events-none"
|
||||
class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap pointer-events-none field-area-active-label"
|
||||
>
|
||||
<template v-if="area.option_uuid && !option.value">
|
||||
{{ optionValue(option) }}
|
||||
@@ -55,10 +55,12 @@
|
||||
>
|
||||
<div
|
||||
v-else-if="field.type === 'signature' && signature"
|
||||
class="flex flex-col justify-between h-full overflow-hidden"
|
||||
class="flex justify-between h-full gap-1 overflow-hidden w-full"
|
||||
:class="isNarrow && (withSignatureId || field.preferences?.reason_field_uuid) ? 'flex-row' : 'flex-col'"
|
||||
>
|
||||
<div
|
||||
class="flex-grow flex overflow-hidden"
|
||||
class="flex overflow-hidden"
|
||||
:class="isNarrow && (withSignatureId || field.preferences?.reason_field_uuid) ? 'w-1/2' : 'flex-grow'"
|
||||
style="min-height: 50%"
|
||||
>
|
||||
<img
|
||||
@@ -67,8 +69,9 @@
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="withSignatureId"
|
||||
class="w-full mt-1 text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem]"
|
||||
v-if="withSignatureId || field.preferences?.reason_field_uuid"
|
||||
class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem]"
|
||||
:class="isNarrow ? 'w-1/2' : 'w-full'"
|
||||
>
|
||||
<div class="truncate uppercase">
|
||||
ID: {{ signature.uuid }}
|
||||
@@ -183,7 +186,7 @@
|
||||
v-else
|
||||
ref="textContainer"
|
||||
dir="auto"
|
||||
class="flex items-center px-0.5 w-full"
|
||||
class="flex px-0.5 w-full"
|
||||
:class="{ ...alignClasses, ...fontClasses }"
|
||||
>
|
||||
<span
|
||||
@@ -327,13 +330,16 @@ export default {
|
||||
},
|
||||
alignClasses () {
|
||||
if (!this.field.preferences) {
|
||||
return {}
|
||||
return { 'items-center': true }
|
||||
}
|
||||
|
||||
return {
|
||||
'text-center': this.field.preferences.align === 'center',
|
||||
'text-left': this.field.preferences.align === 'left',
|
||||
'text-right': this.field.preferences.align === 'right'
|
||||
'text-right': this.field.preferences.align === 'right',
|
||||
'items-center': !this.field.preferences.valign || this.field.preferences.valign === 'center',
|
||||
'items-start': this.field.preferences.valign === 'top',
|
||||
'items-end': this.field.preferences.valign === 'bottom'
|
||||
}
|
||||
},
|
||||
fontClasses () {
|
||||
@@ -441,6 +447,9 @@ export default {
|
||||
}
|
||||
|
||||
return style
|
||||
},
|
||||
isNarrow () {
|
||||
return this.area.h > 0 && (this.area.w / this.area.h) > 6
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
||||
@@ -193,9 +193,9 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
if (window.decline_button) {
|
||||
window.decline_button.setAttribute('disabled', 'true')
|
||||
}
|
||||
document.querySelectorAll('#decline_button').forEach((button) => {
|
||||
button.setAttribute('disabled', 'true')
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
sendCopyToEmail () {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
:with-label="!isAnonymousChecboxes && showFieldNames"
|
||||
:current-step="currentStepFields"
|
||||
:scroll-padding="scrollPadding"
|
||||
@focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']"
|
||||
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
|
||||
/>
|
||||
<FieldAreas
|
||||
:steps="readonlyConditionalFields.map((e) => [e])"
|
||||
@@ -93,7 +93,7 @@
|
||||
<div
|
||||
v-show="isFormVisible"
|
||||
id="form_container"
|
||||
class="shadow-md bg-base-100 absolute bottom-0 w-full border-base-200 border p-4 rounded form-container"
|
||||
class="shadow-md bg-base-100 absolute bottom-0 w-full border-base-200 border p-4 rounded form-container overflow-hidden"
|
||||
:class="{ 'md:bottom-4': isBreakpointMd }"
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
>
|
||||
@@ -1133,7 +1133,7 @@ export default {
|
||||
parent.style.overflow = 'hidden'
|
||||
|
||||
scrollbox.classList.add('h-full', 'overflow-y-auto')
|
||||
scrollbox.parentNode.classList.add('h-screen', 'overflow-y-auto')
|
||||
scrollbox.parentNode.classList.add('h-screen', 'h-[100dvh]', 'overflow-y-auto')
|
||||
scrollbox.parentNode.style.maxHeight = '-webkit-fill-available'
|
||||
})
|
||||
}
|
||||
@@ -1404,7 +1404,21 @@ export default {
|
||||
if (response.status === 422 || response.status === 500) {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.error) {
|
||||
if (data.field_uuid) {
|
||||
const field = this.fieldsUuidIndex[data.field_uuid]
|
||||
|
||||
if (field) {
|
||||
const step = this.stepFields.findIndex((fields) => fields.includes(field))
|
||||
|
||||
if (step !== -1) {
|
||||
this.goToStep(step, this.autoscrollFields)
|
||||
|
||||
this.showFillAllRequiredFields = true
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(new Error('Required field: ' + data.field_uuid))
|
||||
} else if (data.error) {
|
||||
const i18nKey = data.error.replace(/\s+/g, '_').toLowerCase()
|
||||
|
||||
alert(this.t(i18nKey) !== i18nKey ? this.t(i18nKey) : data.error)
|
||||
@@ -1439,11 +1453,7 @@ export default {
|
||||
this.isSubmittingComplete = false
|
||||
})
|
||||
}).catch(error => {
|
||||
if (error?.message === 'Image too small') {
|
||||
alert(this.t('signature_is_too_small_please_redraw'))
|
||||
} else {
|
||||
console.log(error)
|
||||
}
|
||||
console.log(error)
|
||||
}).finally(() => {
|
||||
this.isSubmitting = false
|
||||
this.isSubmittingComplete = false
|
||||
|
||||
@@ -93,7 +93,7 @@ const en = {
|
||||
reupload: 'Reupload',
|
||||
upload: 'Upload',
|
||||
files: 'Files',
|
||||
signature_is_too_small_please_redraw: 'Signature is too small. Please redraw.',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.',
|
||||
wait_countdown_seconds: 'Wait {countdown} seconds'
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ const es = {
|
||||
sign_now: 'Firmar ahora',
|
||||
type_here_: 'Escribe aquí...',
|
||||
optional: 'opcional',
|
||||
option: 'Opción',
|
||||
appears_on: 'Aparece en',
|
||||
page: 'Página',
|
||||
select_your_option: 'Selecciona tu opción',
|
||||
@@ -191,7 +192,7 @@ const es = {
|
||||
reupload: 'Volver a subir',
|
||||
upload: 'Subir',
|
||||
files: 'Archivos',
|
||||
signature_is_too_small_please_redraw: 'La firma es demasiado pequeña. Por favor, dibújala de nuevo.',
|
||||
signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.',
|
||||
wait_countdown_seconds: 'Espera {countdown} segundos'
|
||||
}
|
||||
|
||||
@@ -245,6 +246,7 @@ const it = {
|
||||
sign_now: 'Firma ora',
|
||||
type_here_: 'Digita qui...',
|
||||
optional: 'opzionale',
|
||||
option: 'Opzione',
|
||||
appears_on: 'Compare su',
|
||||
page: 'Pagina',
|
||||
take_photo: 'Scattare una foto',
|
||||
@@ -265,7 +267,7 @@ const it = {
|
||||
document_has_been_signed: 'Il documento è stato firmato!',
|
||||
documents_have_been_signed: 'I documenti sono stati firmati!',
|
||||
create_a_free_account: 'Crea un Account Gratuito',
|
||||
powered_by: 'Desarrollado por',
|
||||
powered_by: 'Fornito da',
|
||||
please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare.',
|
||||
open_source_documents_software: 'software di documenti open source',
|
||||
verified_phone_number: 'Verifica numero di telefono',
|
||||
@@ -289,7 +291,7 @@ const it = {
|
||||
reupload: 'Ricarica',
|
||||
upload: 'Carica',
|
||||
files: 'File',
|
||||
signature_is_too_small_please_redraw: 'La firma è troppo piccola. Ridisegnala per favore.',
|
||||
signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.',
|
||||
wait_countdown_seconds: 'Attendi {countdown} secondi'
|
||||
}
|
||||
|
||||
@@ -343,6 +345,7 @@ const de = {
|
||||
sign_now: 'Jetzt unterschreiben',
|
||||
type_here_: 'Hier eingeben...',
|
||||
optional: 'optional',
|
||||
option: 'Option',
|
||||
appears_on: 'Erscheint auf',
|
||||
page: 'Seite',
|
||||
take_photo: 'Foto aufnehmen',
|
||||
@@ -387,14 +390,14 @@ const de = {
|
||||
reupload: 'Erneut hochladen',
|
||||
upload: 'Hochladen',
|
||||
files: 'Dateien',
|
||||
signature_is_too_small_please_redraw: 'Die Unterschrift ist zu klein. Bitte erneut zeichnen.',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte erneut zeichnen.',
|
||||
wait_countdown_seconds: 'Warte {countdown} Sekunden'
|
||||
}
|
||||
|
||||
const fr = {
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour continuer la vérification de l'identité.",
|
||||
verif_id: "Vérification de l'ID",
|
||||
verif_identite: "Vérification de l'identité",
|
||||
verify_id: "Vérification de l'ID",
|
||||
identity_verification: "Vérification de l'identité",
|
||||
complete: 'Terminer',
|
||||
fill_all_required_fields_to_complete: 'Veuillez remplir tous les champs obligatoires pour compléter',
|
||||
sign_and_complete: 'Signer et Terminer',
|
||||
@@ -441,6 +444,7 @@ const fr = {
|
||||
sign_now: 'Signer maintenant',
|
||||
type_here_: 'Tapez ici...',
|
||||
optional: 'facultatif',
|
||||
option: 'Option',
|
||||
appears_on: 'Apparaît sur',
|
||||
page: 'Page',
|
||||
take_photo: 'Prendre une photo',
|
||||
@@ -485,7 +489,7 @@ const fr = {
|
||||
reupload: 'Recharger',
|
||||
upload: 'Télécharger',
|
||||
files: 'Fichiers',
|
||||
signature_is_too_small_please_redraw: 'La signature est trop petite. Veuillez la redessiner.',
|
||||
signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.',
|
||||
wait_countdown_seconds: 'Attendez {countdown} secondes'
|
||||
}
|
||||
|
||||
@@ -539,6 +543,7 @@ const pl = {
|
||||
sign_now: 'Podpisz teraz',
|
||||
type_here_: 'Wpisz tutaj...',
|
||||
optional: 'opcjonalny',
|
||||
option: 'Opcja',
|
||||
appears_on: 'Pojawia się na',
|
||||
page: 'Strona',
|
||||
select_your_option: 'Wybierz swoją opcję',
|
||||
@@ -583,7 +588,8 @@ const pl = {
|
||||
reupload: 'Ponowne przesłanie',
|
||||
upload: 'Przesyłanie',
|
||||
files: 'Pliki',
|
||||
signature_is_too_small_please_redraw: 'Podpis jest zbyt mały. Proszę narysować go ponownie.'
|
||||
signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.',
|
||||
wait_countdown_seconds: 'Poczekaj {countdown} sekund'
|
||||
}
|
||||
|
||||
const uk = {
|
||||
@@ -636,6 +642,7 @@ const uk = {
|
||||
sign_now: 'Підписати зараз',
|
||||
type_here_: 'Введіть тут',
|
||||
optional: 'необов’язково',
|
||||
option: 'Опція',
|
||||
appears_on: "З'являється на",
|
||||
page: 'Сторінка',
|
||||
take_photo: 'Зробити фото',
|
||||
@@ -658,7 +665,7 @@ const uk = {
|
||||
create_a_free_account: 'Створити безкоштовний обліковий запис',
|
||||
powered_by: 'Працює на базі',
|
||||
please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити.',
|
||||
open_source_documents_software: 'відкритий програмний засіб для документів',
|
||||
open_source_documents_software: 'відкрите програмне забезпечення для документів',
|
||||
verified_phone_number: 'Підтвердіть номер телефону',
|
||||
use_international_format: 'Використовуйте міжнародний формат: +1xxx',
|
||||
six_digits_code: '6-значний код',
|
||||
@@ -680,7 +687,7 @@ const uk = {
|
||||
reupload: 'Перезавантажити',
|
||||
upload: 'Завантажити',
|
||||
files: 'Файли',
|
||||
signature_is_too_small_please_redraw: 'Підпис занадто малий. Будь ласка, перемалюйте його.',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.',
|
||||
wait_countdown_seconds: 'Зачекайте {countdown} секунд'
|
||||
}
|
||||
|
||||
@@ -734,6 +741,7 @@ const cs = {
|
||||
sign_now: 'Podepsat nyní',
|
||||
type_here_: 'Zadejte zde',
|
||||
optional: 'volitelné',
|
||||
option: 'Možnost',
|
||||
appears_on: 'Zobrazuje se na',
|
||||
page: 'Stránka',
|
||||
select_your_option: 'Vyberte svou volbu',
|
||||
@@ -778,7 +786,7 @@ const cs = {
|
||||
reupload: 'Znovu nahrát',
|
||||
upload: 'Nahrát',
|
||||
files: 'Soubory',
|
||||
signature_is_too_small_please_redraw: 'Podpis je příliš malý. Prosím, překreslete ho.',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.',
|
||||
wait_countdown_seconds: 'Počkejte {countdown} sekund'
|
||||
}
|
||||
|
||||
@@ -832,6 +840,7 @@ const pt = {
|
||||
sign_now: 'Assinar agora',
|
||||
type_here_: 'Digite aqui',
|
||||
optional: 'opcional',
|
||||
option: 'Opção',
|
||||
appears_on: 'Aparece em',
|
||||
page: 'Página',
|
||||
take_photo: 'Tirar foto',
|
||||
@@ -876,7 +885,7 @@ const pt = {
|
||||
reupload: 'Reenviar',
|
||||
upload: 'Carregar',
|
||||
files: 'Arquivos',
|
||||
signature_is_too_small_please_redraw: 'A assinatura é muito pequena. Por favor, redesenhe-a.',
|
||||
signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.',
|
||||
wait_countdown_seconds: 'Aguarde {countdown} segundos'
|
||||
}
|
||||
|
||||
@@ -975,7 +984,7 @@ const he = {
|
||||
reupload: 'העלה שוב',
|
||||
upload: 'העלאה',
|
||||
files: 'קבצים',
|
||||
signature_is_too_small_please_redraw: 'החתימה קטנה מדי. אנא צייר מחדש.',
|
||||
signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.',
|
||||
wait_countdown_seconds: 'המתן {countdown} שניות'
|
||||
}
|
||||
|
||||
@@ -1074,7 +1083,7 @@ const nl = {
|
||||
reupload: 'Opnieuw uploaden',
|
||||
upload: 'Uploaden',
|
||||
files: 'Bestanden',
|
||||
signature_is_too_small_please_redraw: 'De handtekening is te klein. Teken deze opnieuw, alstublieft.',
|
||||
signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.',
|
||||
wait_countdown_seconds: 'Wacht {countdown} seconden'
|
||||
}
|
||||
|
||||
@@ -1097,6 +1106,7 @@ const ar = {
|
||||
select_a_reason: 'اختر سببًا',
|
||||
value_is_invalid: 'القيمة غير صالحة',
|
||||
verification_code_is_invalid: 'رمز التحقق غير صالح',
|
||||
already_paid: 'تم الدفع بالفعل',
|
||||
drawn_signature_on_a_touchscreen_device: 'توقيع مرسوم على جهاز بشاشة تعمل باللمس',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'امسح رمز الاستجابة السريعة باستخدام تطبيق الكاميرا لفتح النموذج على الهاتف المحمول ورسم توقيعك',
|
||||
by_clicking_you_agree_to_the: 'بالنقر فوق "{button}"، أنت توافق على',
|
||||
@@ -1172,7 +1182,7 @@ const ar = {
|
||||
reupload: 'إعادة التحميل',
|
||||
upload: 'تحميل',
|
||||
files: 'الملفات',
|
||||
signature_is_too_small_please_redraw: 'التوقيع صغير جدًا. يرجى إعادة الرسم.',
|
||||
signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.',
|
||||
wait_countdown_seconds: 'انتظر {countdown} ثانية'
|
||||
}
|
||||
|
||||
@@ -1198,6 +1208,8 @@ const ko = {
|
||||
by_clicking_you_agree_to_the: '"{button}"를 클릭함으로써, 다음에 동의하게 됩니다',
|
||||
electronic_signature_disclosure: '전자 서명 공개',
|
||||
esignature_disclosure: '전자 서명 공개',
|
||||
value_is_invalid: '값이 올바르지 않습니다',
|
||||
verification_code_is_invalid: '인증 코드가 올바르지 않습니다',
|
||||
already_paid: '이미 지불됨',
|
||||
text: '텍스트',
|
||||
signature: '서명',
|
||||
@@ -1269,10 +1281,109 @@ const ko = {
|
||||
reupload: '다시 업로드',
|
||||
upload: '업로드',
|
||||
files: '파일',
|
||||
signature_is_too_small_please_redraw: '서명이 너무 작습니다. 다시 그려주세요.',
|
||||
signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.',
|
||||
wait_countdown_seconds: '{countdown}초 기다리세요'
|
||||
}
|
||||
|
||||
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko }
|
||||
const ja = {
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
|
||||
verify_id: '本人確認',
|
||||
identity_verification: '本人確認',
|
||||
complete: '完了',
|
||||
fill_all_required_fields_to_complete: '完了するにはすべての必須項目を入力してください',
|
||||
sign_and_complete: '署名して完了',
|
||||
text: 'テキスト',
|
||||
by_clicking_you_agree_to_the: '"{button}" をクリックすることで、次に同意したことになります:',
|
||||
electronic_signature_disclosure: '電子署名に関する開示',
|
||||
esignature_disclosure: '電子署名開示',
|
||||
signature: '署名',
|
||||
initials: 'イニシャル',
|
||||
drawn_signature_on_a_touchscreen_device: 'タッチスクリーンデバイスで描かれた署名',
|
||||
approved: '承認済み',
|
||||
reviewed: '確認済み',
|
||||
other: 'その他',
|
||||
authored_by_me: '自分が作成',
|
||||
invite: '招待',
|
||||
email: 'メール',
|
||||
approved_by: '承認者',
|
||||
reviewed_by: '確認者',
|
||||
authored_by: '作成者',
|
||||
select_a_reason: '理由を選択',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'QRコードをカメラアプリで読み取り、モバイルでフォームを開いて署名を描いてください',
|
||||
date: '日付',
|
||||
number: '数値',
|
||||
value_is_invalid: '無効な値です',
|
||||
verification_code_is_invalid: '認証コードが無効です',
|
||||
already_paid: 'すでに支払い済み',
|
||||
image: '画像',
|
||||
pay: '支払う',
|
||||
take_photo: '写真を撮る',
|
||||
number_phone_is_invalid: '{number} の電話番号は無効です',
|
||||
file: 'ファイル',
|
||||
digitally_signed_by: '電子署名者:',
|
||||
reason: '理由',
|
||||
select: '選択',
|
||||
checkbox: 'チェックボックス',
|
||||
multiple: '複数選択',
|
||||
radio: 'ラジオボタン',
|
||||
cells: 'セル',
|
||||
stamp: 'スタンプ',
|
||||
minimize: '最小化',
|
||||
payment: '支払い',
|
||||
phone: '電話',
|
||||
start_now: '今すぐ開始',
|
||||
continue: '続行',
|
||||
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: 'フォームが完了しました!',
|
||||
document_has_been_signed: 'ドキュメントが署名されました!',
|
||||
documents_have_been_signed: 'ドキュメントがすべて署名されました!',
|
||||
create_a_free_account: '無料アカウントを作成',
|
||||
powered_by: '提供元:',
|
||||
please_check_the_box_to_continue: '続行するにはボックスにチェックを入れてください。',
|
||||
open_source_documents_software: 'オープンソースのドキュメントソフトウェア',
|
||||
verified_phone_number: '電話番号を認証',
|
||||
use_international_format: '国際形式を使用してください:+1xxx',
|
||||
six_digits_code: '6桁のコード',
|
||||
change_phone_number: '電話番号を変更',
|
||||
sending: '送信中...',
|
||||
resend_code: 'コードを再送信',
|
||||
verification_code_has_been_resent: '認証コードがSMSで再送信されました',
|
||||
please_fill_all_required_fields: 'すべての必須項目を入力してください',
|
||||
set_today: '今日の日付を設定',
|
||||
toggle_multiline_text: '複数行テキスト切り替え',
|
||||
draw_signature: '署名を描く',
|
||||
type_initial: 'イニシャルを入力',
|
||||
draw: '描く',
|
||||
type: '入力',
|
||||
type_text: 'テキストを入力',
|
||||
email_has_been_sent: 'メールが送信されました',
|
||||
processing: '処理中',
|
||||
pay_with_strip: 'Stripeで支払う',
|
||||
reupload: '再アップロード',
|
||||
upload: 'アップロード',
|
||||
files: 'ファイル',
|
||||
signature_is_too_small_or_simple_please_redraw: '署名が小さすぎるか単純すぎます。もう一度描いてください。',
|
||||
wait_countdown_seconds: '{countdown} 秒お待ちください'
|
||||
}
|
||||
|
||||
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko, ja }
|
||||
|
||||
export default i18n
|
||||
|
||||
@@ -118,11 +118,17 @@
|
||||
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
|
||||
class="mx-auto bg-white border border-base-300 rounded max-h-44"
|
||||
>
|
||||
<canvas
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
ref="canvas"
|
||||
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
|
||||
/>
|
||||
<div class="relative">
|
||||
<div
|
||||
v-if="!isDrawInitials"
|
||||
class="absolute top-0 right-0 left-0 bottom-0"
|
||||
/>
|
||||
<canvas
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
ref="canvas"
|
||||
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
|
||||
id="initials_text_input"
|
||||
@@ -205,7 +211,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
computedPreviousValue () {
|
||||
if (this.isUsePreviousValue) {
|
||||
if (this.isUsePreviousValue && this.field.required === true) {
|
||||
return this.previousValue
|
||||
} else {
|
||||
return null
|
||||
@@ -333,8 +339,8 @@ export default {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => {
|
||||
const file = new File([blob], 'initials.png', { type: 'image/png' })
|
||||
|
||||
if (this.dryRun) {
|
||||
@@ -367,6 +373,14 @@ export default {
|
||||
return resolve(attachment)
|
||||
})
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (this.field.required === true) {
|
||||
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
|
||||
|
||||
return reject(error)
|
||||
} else {
|
||||
return resolve({})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -292,6 +292,7 @@
|
||||
<script>
|
||||
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue'
|
||||
import { cropCanvasAndExportToPNG } from './crop_canvas'
|
||||
import { isValidSignatureCanvas } from './validate_signature'
|
||||
import SignaturePad from 'signature_pad'
|
||||
import AppearsOn from './appears_on'
|
||||
import FileDropzone from './dropzone'
|
||||
@@ -413,7 +414,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computedPreviousValue () {
|
||||
if (this.isUsePreviousValue) {
|
||||
if (this.isUsePreviousValue && this.field.required === true) {
|
||||
return this.previousValue
|
||||
} else {
|
||||
return null
|
||||
@@ -598,6 +599,7 @@ export default {
|
||||
},
|
||||
drawImage (event) {
|
||||
this.remove()
|
||||
this.clear()
|
||||
this.isSignatureStarted = true
|
||||
|
||||
this.drawOnCanvas(event.target.files[0], this.$refs.canvas)
|
||||
@@ -680,6 +682,16 @@ export default {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
|
||||
if (this.isSignatureStarted && this.pad.toData().length > 0 && !isValidSignatureCanvas(this.pad.toData())) {
|
||||
if (this.field.required === true || this.pad.toData().length > 0) {
|
||||
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
|
||||
|
||||
return Promise.reject(new Error('Image too small or simple'))
|
||||
} else {
|
||||
Promise.resolve({})
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => {
|
||||
const file = new File([blob], 'signature.png', { type: 'image/png' })
|
||||
@@ -718,10 +730,12 @@ export default {
|
||||
})
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (error.message === 'Image too small' && this.field.required === false) {
|
||||
return resolve({})
|
||||
} else {
|
||||
if (this.field.required === true) {
|
||||
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
|
||||
|
||||
return reject(error)
|
||||
} else {
|
||||
return resolve({})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -151,8 +151,10 @@ export default {
|
||||
resizeTextarea () {
|
||||
const textarea = this.$refs.textarea
|
||||
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = Math.min(250, textarea.scrollHeight) + 'px'
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = Math.min(250, textarea.scrollHeight) + 'px'
|
||||
}
|
||||
},
|
||||
toggleTextArea () {
|
||||
this.isTextArea = true
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
function isValidSignatureCanvas (data) {
|
||||
if (data.length === 0) return false
|
||||
|
||||
const strokes = data.filter(stroke => Array.isArray(stroke.points) && stroke.points.length > 2)
|
||||
|
||||
if (strokes.length === 0) return false
|
||||
|
||||
let skippedStraightLine = 0
|
||||
|
||||
const validStrokes = strokes.filter(stroke => {
|
||||
const points = stroke.points
|
||||
const first = points[0]
|
||||
const last = points[points.length - 1]
|
||||
const A = last.y - first.y
|
||||
const B = first.x - last.x
|
||||
const C = last.x * first.y - first.x * last.y
|
||||
const lineLength = Math.sqrt(A * A + B * B)
|
||||
|
||||
const totalDeviation = points.reduce((sum, p) => {
|
||||
const distanceToLine = Math.abs(A * p.x + B * p.y + C) / lineLength
|
||||
return sum + distanceToLine
|
||||
}, 0)
|
||||
|
||||
const avgDeviation = totalDeviation / points.length
|
||||
|
||||
if (avgDeviation < 3 && skippedStraightLine < 2) {
|
||||
skippedStraightLine++
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return validStrokes.length > 0
|
||||
}
|
||||
|
||||
export { isValidSignatureCanvas }
|
||||
@@ -160,15 +160,15 @@
|
||||
</div>
|
||||
<div
|
||||
ref="touchValueTarget"
|
||||
class="flex items-center h-full w-full field-area"
|
||||
class="flex h-full w-full field-area"
|
||||
dir="auto"
|
||||
:class="[isValueInput ? 'bg-opacity-50' : 'bg-opacity-80', field.type === 'heading' ? 'bg-gray-50' : bgColors[submitterIndex % bgColors.length], isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas) ? fontClasses : 'justify-center']"
|
||||
:class="[isValueInput ? 'bg-opacity-50' : 'bg-opacity-80', field.type === 'heading' ? 'bg-gray-50' : bgColors[submitterIndex % bgColors.length], isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas) ? fontClasses : 'justify-center items-center']"
|
||||
@click="focusValueInput"
|
||||
>
|
||||
<span
|
||||
v-if="field"
|
||||
class="flex justify-center items-center space-x-1"
|
||||
:class="{ 'w-full': ['cells', 'checkbox'].includes(field.type), 'h-full': !isValueInput }"
|
||||
:class="{ 'w-full': ['cells', 'checkbox'].includes(field.type), 'h-full': !isValueInput && !isDefaultValuePresent }"
|
||||
>
|
||||
<div
|
||||
v-if="isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas && field.type !== 'checkbox')"
|
||||
@@ -403,10 +403,13 @@ export default {
|
||||
},
|
||||
fontClasses () {
|
||||
if (!this.field.preferences) {
|
||||
return {}
|
||||
return { 'items-center': true }
|
||||
}
|
||||
|
||||
return {
|
||||
'items-center': !this.field.preferences.valign || this.field.preferences.valign === 'center',
|
||||
'items-start': this.field.preferences.valign === 'top',
|
||||
'items-end': this.field.preferences.valign === 'bottom',
|
||||
'justify-center': this.field.preferences.align === 'center',
|
||||
'justify-start': this.field.preferences.align === 'left',
|
||||
'justify-end': this.field.preferences.align === 'right',
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
<template>
|
||||
<div
|
||||
ref="dragContainer"
|
||||
style="max-width: 1600px"
|
||||
class="mx-auto pl-3 h-full"
|
||||
:class="isMobile ? 'pl-4' : 'md:pl-4'"
|
||||
@dragover="onDragover"
|
||||
@drop="isDragFile = false"
|
||||
>
|
||||
<HoverDropzone
|
||||
v-if="sortedDocuments.length && withUploadButton && editable"
|
||||
:is-dragging="isDragFile"
|
||||
:template-id="template.id"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:with-replace-and-clone="withReplaceAndCloneUpload"
|
||||
@add="[updateFromUpload($event), isDragFile = false]"
|
||||
@replace="[onDocumentsReplace($event), isDragFile = false]"
|
||||
@replace-and-clone="onDocumentsReplaceAndTemplateClone($event)"
|
||||
@error="[onUploadFailed($event), isDragFile = false]"
|
||||
/>
|
||||
<DragPlaceholder
|
||||
ref="dragPlaceholder"
|
||||
:field="fieldsDragFieldRef.value || toRaw(dragField)"
|
||||
:is-field="template.fields.includes(fieldsDragFieldRef.value)"
|
||||
:is-default="defaultFields.includes(toRaw(dragField))"
|
||||
:is-required="defaultRequiredFields.includes(toRaw(dragField))"
|
||||
/>
|
||||
<div
|
||||
v-if="pendingFieldAttachmentUuids.length && editable"
|
||||
class="top-1.5 sticky h-0 z-20 max-w-2xl mx-auto"
|
||||
@@ -58,12 +79,52 @@
|
||||
name="buttons"
|
||||
/>
|
||||
<template v-else>
|
||||
<form
|
||||
v-if="withSignYourselfButton && template.submitters.length < 2"
|
||||
target="_blank"
|
||||
data-turbo="false"
|
||||
class="inline"
|
||||
method="post"
|
||||
:action="`/d/${template.slug}`"
|
||||
@submit="maybeShowErrorTemplateAlert"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="_method"
|
||||
value="put"
|
||||
autocomplete="off"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="authenticity_token"
|
||||
:value="authenticityToken"
|
||||
autocomplete="off"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="selfsign"
|
||||
value="true"
|
||||
autocomplete="off"
|
||||
>
|
||||
<button
|
||||
class="btn btn-primary btn-ghost text-base hidden md:flex"
|
||||
type="submit"
|
||||
>
|
||||
<IconWritingSign
|
||||
width="22"
|
||||
class="inline"
|
||||
/>
|
||||
<span class="hidden md:inline">
|
||||
{{ t('sign_yourself') }}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
<a
|
||||
v-if="withSignYourselfButton"
|
||||
:href="template.submitters.length > 1 ? `/templates/${template.id}/submissions/new?selfsign=true` : `/d/${template.slug}`"
|
||||
v-else-if="withSignYourselfButton"
|
||||
id="sign_yourself_button"
|
||||
:href="`/templates/${template.id}/submissions/new?selfsign=true`"
|
||||
class="btn btn-primary btn-ghost text-base hidden md:flex"
|
||||
:target="template.submitters.length > 1 ? '' : '_blank'"
|
||||
:data-turbo-frame="template.submitters.length > 1 ? 'modal' : ''"
|
||||
data-turbo-frame="modal"
|
||||
@click="maybeShowErrorTemplateAlert"
|
||||
>
|
||||
<IconWritingSign
|
||||
@@ -76,6 +137,7 @@
|
||||
</a>
|
||||
<a
|
||||
v-if="withSendButton"
|
||||
id="send_button"
|
||||
:href="`/templates/${template.id}/submissions/new?with_link=true`"
|
||||
data-turbo-frame="modal"
|
||||
class="white-button md:!px-6"
|
||||
@@ -91,6 +153,7 @@
|
||||
</a>
|
||||
<span
|
||||
v-if="editable"
|
||||
id="save_button_container"
|
||||
class="flex"
|
||||
>
|
||||
<button
|
||||
@@ -269,6 +332,7 @@
|
||||
:allow-draw="!onlyDefinedFields || drawField"
|
||||
:data-document-uuid="document.uuid"
|
||||
:default-submitters="defaultSubmitters"
|
||||
:drag-field-placeholder="fieldsDragFieldRef.value || dragField"
|
||||
:with-field-placeholder="withFieldPlaceholder"
|
||||
:draw-field="drawField"
|
||||
:draw-field-type="drawFieldType"
|
||||
@@ -374,12 +438,14 @@
|
||||
:with-sticky-submitters="withStickySubmitters"
|
||||
:only-defined-fields="onlyDefinedFields"
|
||||
:editable="editable"
|
||||
:show-tour-start-form="showTourStartForm"
|
||||
@add-field="addField"
|
||||
@set-draw="[drawField = $event.field, drawOption = $event.option]"
|
||||
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
|
||||
@set-drag="dragField = $event"
|
||||
@set-drag-placeholder="$refs.dragPlaceholder.dragPlaceholder = $event"
|
||||
@change-submitter="selectedSubmitter = $event"
|
||||
@drag-end="dragField = null"
|
||||
@drag-end="[dragField = null, $refs.dragPlaceholder.dragPlaceholder = null]"
|
||||
@scroll-to-area="scrollToArea"
|
||||
/>
|
||||
</div>
|
||||
@@ -418,6 +484,8 @@
|
||||
<script>
|
||||
import Upload from './upload'
|
||||
import Dropzone from './dropzone'
|
||||
import HoverDropzone from './hover_dropzone'
|
||||
import DragPlaceholder from './drag_placeholder'
|
||||
import Fields from './fields'
|
||||
import MobileDrawField from './mobile_draw_field'
|
||||
import Document from './document'
|
||||
@@ -429,13 +497,14 @@ import MobileFields from './mobile_fields'
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, toRaw } from 'vue'
|
||||
import * as i18n from './i18n'
|
||||
|
||||
export default {
|
||||
name: 'TemplateBuilder',
|
||||
components: {
|
||||
Upload,
|
||||
DragPlaceholder,
|
||||
Document,
|
||||
Fields,
|
||||
IconInfoCircle,
|
||||
@@ -445,6 +514,7 @@ export default {
|
||||
MobileFields,
|
||||
Logo,
|
||||
Dropzone,
|
||||
HoverDropzone,
|
||||
DocumentPreview,
|
||||
DocumentControls,
|
||||
IconInnerShadowTop,
|
||||
@@ -460,6 +530,7 @@ export default {
|
||||
template: this.template,
|
||||
save: this.save,
|
||||
t: this.t,
|
||||
assignDropAreaSize: this.assignDropAreaSize,
|
||||
currencies: this.currencies,
|
||||
locale: this.locale,
|
||||
baseFetch: this.baseFetch,
|
||||
@@ -647,6 +718,11 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withReplaceAndCloneUpload: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withPhone: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -686,6 +762,11 @@ export default {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({ headers: {} })
|
||||
},
|
||||
showTourStartForm: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
@@ -701,7 +782,8 @@ export default {
|
||||
copiedArea: null,
|
||||
drawFieldType: null,
|
||||
drawOption: null,
|
||||
dragField: null
|
||||
dragField: null,
|
||||
isDragFile: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -813,6 +895,7 @@ export default {
|
||||
window.addEventListener('keydown', this.onKeyDown)
|
||||
|
||||
window.addEventListener('resize', this.onWindowResize)
|
||||
window.addEventListener('dragleave', this.onWindowDragLeave)
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (document.location.search?.includes('stripe_connect_success')) {
|
||||
@@ -831,11 +914,30 @@ export default {
|
||||
window.removeEventListener('keydown', this.onKeyDown)
|
||||
|
||||
window.removeEventListener('resize', this.onWindowResize)
|
||||
window.removeEventListener('dragleave', this.onWindowDragLeave)
|
||||
},
|
||||
beforeUpdate () {
|
||||
this.documentRefs = []
|
||||
},
|
||||
methods: {
|
||||
toRaw,
|
||||
onDragover (e) {
|
||||
if (this.$refs.dragPlaceholder?.dragPlaceholder) {
|
||||
this.$refs.dragPlaceholder.isMask = e.target.id === 'mask'
|
||||
|
||||
const ref = this.$refs.dragPlaceholder.dragPlaceholder
|
||||
|
||||
ref.x = e.clientX - ref.offsetX
|
||||
ref.y = e.clientY - ref.offsetY
|
||||
} else if (e.dataTransfer?.types?.includes('Files')) {
|
||||
this.isDragFile = true
|
||||
}
|
||||
},
|
||||
onWindowDragLeave (event) {
|
||||
if (event.clientX <= 0 || event.clientY <= 0 || event.clientX >= window.innerWidth || event.clientY >= window.innerHeight) {
|
||||
this.isDragFile = false
|
||||
}
|
||||
},
|
||||
reorderFields (item) {
|
||||
const itemFields = []
|
||||
const fields = []
|
||||
@@ -1216,7 +1318,7 @@ export default {
|
||||
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
|
||||
}
|
||||
},
|
||||
onDraw (area) {
|
||||
onDraw ({ area, isTooSmall }) {
|
||||
if (this.drawField) {
|
||||
if (this.drawOption) {
|
||||
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
|
||||
@@ -1311,7 +1413,7 @@ export default {
|
||||
area.y -= area.h / 2
|
||||
}
|
||||
|
||||
if (area.w) {
|
||||
if (area.w && (type !== 'checkbox' || this.drawFieldType || !isTooSmall)) {
|
||||
this.addField(type, area)
|
||||
|
||||
this.selectedAreaRef.value = area
|
||||
@@ -1319,6 +1421,10 @@ export default {
|
||||
}
|
||||
},
|
||||
onDropfield (area) {
|
||||
if (this.$refs.dragPlaceholder) {
|
||||
this.$refs.dragPlaceholder.dragPlaceholder = null
|
||||
}
|
||||
|
||||
if (!this.editable) {
|
||||
return
|
||||
}
|
||||
@@ -1331,6 +1437,10 @@ export default {
|
||||
...this.dragField
|
||||
}
|
||||
|
||||
if (!field.type) {
|
||||
field.type = 'text'
|
||||
}
|
||||
|
||||
if (!this.fieldsDragFieldRef.value) {
|
||||
if (['select', 'multiple', 'radio'].includes(field.type)) {
|
||||
if (this.dragField?.options?.length) {
|
||||
@@ -1357,70 +1467,18 @@ export default {
|
||||
attachment_uuid: area.attachment_uuid
|
||||
}
|
||||
|
||||
const previousField = [...this.template.fields].reverse().find((f) => f.type === field.type)
|
||||
|
||||
let baseArea
|
||||
|
||||
if (this.selectedField?.type === field.type) {
|
||||
baseArea = this.selectedAreaRef.value
|
||||
} else if (previousField?.areas?.length) {
|
||||
baseArea = previousField.areas[previousField.areas.length - 1]
|
||||
} else {
|
||||
if (['checkbox'].includes(field.type)) {
|
||||
baseArea = {
|
||||
w: area.maskW / 30 / area.maskW,
|
||||
h: area.maskW / 30 / area.maskW * (area.maskW / area.maskH)
|
||||
}
|
||||
} else if (field.type === 'image') {
|
||||
baseArea = {
|
||||
w: area.maskW / 5 / area.maskW,
|
||||
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH)
|
||||
}
|
||||
} else if (field.type === 'signature' || field.type === 'stamp' || field.type === 'verification') {
|
||||
baseArea = {
|
||||
w: area.maskW / 5 / area.maskW,
|
||||
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
|
||||
}
|
||||
} else if (field.type === 'initials') {
|
||||
baseArea = {
|
||||
w: area.maskW / 10 / area.maskW,
|
||||
h: area.maskW / 35 / area.maskW
|
||||
}
|
||||
} else {
|
||||
baseArea = {
|
||||
w: area.maskW / 5 / area.maskW,
|
||||
h: area.maskW / 35 / area.maskW
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fieldArea.w = baseArea.w
|
||||
fieldArea.h = baseArea.h
|
||||
fieldArea.y = fieldArea.y - baseArea.h / 2
|
||||
|
||||
if (field.type === 'cells') {
|
||||
fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5)
|
||||
}
|
||||
|
||||
field.areas ||= []
|
||||
|
||||
const lastArea = field.areas[field.areas.length - 1]
|
||||
|
||||
if (lastArea) {
|
||||
fieldArea.w = lastArea.w
|
||||
fieldArea.h = lastArea.h
|
||||
}
|
||||
this.assignDropAreaSize(fieldArea, field, area)
|
||||
|
||||
if (field.width) {
|
||||
fieldArea.w = field.width / area.maskW
|
||||
delete field.width
|
||||
}
|
||||
|
||||
if (field.height) {
|
||||
fieldArea.h = field.height / area.maskH
|
||||
delete field.height
|
||||
}
|
||||
|
||||
field.areas ||= []
|
||||
|
||||
field.areas.push(fieldArea)
|
||||
|
||||
this.selectedAreaRef.value = fieldArea
|
||||
@@ -1444,6 +1502,72 @@ export default {
|
||||
})
|
||||
}
|
||||
},
|
||||
assignDropAreaSize (fieldArea, field, area) {
|
||||
const fieldType = field.type || 'text'
|
||||
|
||||
const previousField = [...this.template.fields].reverse().find((f) => f.type === fieldType)
|
||||
|
||||
let baseArea
|
||||
|
||||
if (this.selectedField?.type === fieldType) {
|
||||
baseArea = this.selectedAreaRef.value
|
||||
} else if (previousField?.areas?.length) {
|
||||
baseArea = previousField.areas[previousField.areas.length - 1]
|
||||
} else {
|
||||
if (['checkbox'].includes(fieldType)) {
|
||||
baseArea = {
|
||||
w: area.maskW / 30 / area.maskW,
|
||||
h: area.maskW / 30 / area.maskW * (area.maskW / area.maskH)
|
||||
}
|
||||
} else if (fieldType === 'image') {
|
||||
baseArea = {
|
||||
w: area.maskW / 5 / area.maskW,
|
||||
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH)
|
||||
}
|
||||
} else if (fieldType === 'signature' || fieldType === 'stamp' || fieldType === 'verification') {
|
||||
baseArea = {
|
||||
w: area.maskW / 5 / area.maskW,
|
||||
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
|
||||
}
|
||||
} else if (fieldType === 'initials') {
|
||||
baseArea = {
|
||||
w: area.maskW / 10 / area.maskW,
|
||||
h: area.maskW / 35 / area.maskW
|
||||
}
|
||||
} else {
|
||||
baseArea = {
|
||||
w: area.maskW / 5 / area.maskW,
|
||||
h: area.maskW / 35 / area.maskW
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fieldArea.w = baseArea.w
|
||||
fieldArea.h = baseArea.h
|
||||
|
||||
if (fieldType === 'cells') {
|
||||
fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5)
|
||||
}
|
||||
|
||||
if (field.areas?.length) {
|
||||
const lastArea = field.areas[field.areas.length - 1]
|
||||
|
||||
if (lastArea) {
|
||||
fieldArea.w = lastArea.w
|
||||
fieldArea.h = lastArea.h
|
||||
}
|
||||
}
|
||||
|
||||
if (field.width) {
|
||||
fieldArea.w = field.width / area.maskW
|
||||
}
|
||||
|
||||
if (field.height) {
|
||||
fieldArea.h = field.height / area.maskH
|
||||
}
|
||||
|
||||
fieldArea.y = fieldArea.y - fieldArea.h / 2
|
||||
},
|
||||
addBlankPage () {
|
||||
this.isLoadingBlankPage = true
|
||||
|
||||
@@ -1473,6 +1597,9 @@ export default {
|
||||
})
|
||||
}, 'image/png')
|
||||
},
|
||||
onUploadFailed (error) {
|
||||
if (error) alert(error)
|
||||
},
|
||||
updateFromUpload (data) {
|
||||
this.template.schema.push(...data.schema)
|
||||
this.template.documents.push(...data.documents)
|
||||
@@ -1593,6 +1720,29 @@ export default {
|
||||
|
||||
this.save()
|
||||
},
|
||||
onDocumentsReplace (data) {
|
||||
data.schema.forEach((schemaItem, index) => {
|
||||
const existingSchemaItem = this.template.schema[index]
|
||||
|
||||
if (this.template.schema[index]) {
|
||||
this.onDocumentReplace({
|
||||
replaceSchemaItem: existingSchemaItem,
|
||||
schema: [schemaItem],
|
||||
documents: [data.documents.find((doc) => doc.uuid === schemaItem.attachment_uuid)]
|
||||
})
|
||||
} else {
|
||||
this.updateFromUpload({
|
||||
schema: [schemaItem],
|
||||
documents: [data.documents.find((doc) => doc.uuid === schemaItem.attachment_uuid)],
|
||||
fields: data.fields,
|
||||
submitters: data.submitters
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
onDocumentsReplaceAndTemplateClone (template) {
|
||||
window.Turbo.visit(`/templates/${template.id}/edit`)
|
||||
},
|
||||
moveDocument (item, direction) {
|
||||
const currentIndex = this.template.schema.indexOf(item)
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ export default {
|
||||
fields () {
|
||||
if (this.item.submitter_uuid) {
|
||||
return this.template.fields.reduce((acc, f) => {
|
||||
if (f !== this.item) {
|
||||
if (f !== this.item && (!f.conditions?.length || !f.conditions.find((c) => c.field_uuid === this.item.uuid))) {
|
||||
acc.push(f)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
:is-drag="isDrag"
|
||||
:with-field-placeholder="withFieldPlaceholder"
|
||||
:default-fields="defaultFields"
|
||||
:drag-field-placeholder="dragFieldPlaceholder"
|
||||
:default-submitters="defaultSubmitters"
|
||||
:draw-field="drawField"
|
||||
:draw-field-type="drawFieldType"
|
||||
@@ -22,7 +23,7 @@
|
||||
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
|
||||
@remove-area="$emit('remove-area', $event)"
|
||||
@scroll-to="scrollToArea"
|
||||
@draw="$emit('draw', {...$event, attachment_uuid: document.uuid })"
|
||||
@draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -39,6 +40,11 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
dragFieldPlaceholder: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
inputMode: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<Field
|
||||
v-if="dragPlaceholder && isField && !isMask && field"
|
||||
ref="dragPlaceholder"
|
||||
:style="dragPlaceholderStyle"
|
||||
:field="field"
|
||||
:with-options="false"
|
||||
class="fixed z-20 pointer-events-none"
|
||||
:editable="false"
|
||||
/>
|
||||
<div
|
||||
v-else-if="dragPlaceholder && (isDefault || isRequired) && !isMask && field"
|
||||
ref="dragPlaceholder"
|
||||
:style="[dragPlaceholderStyle, { backgroundColor: backgroundColor }]"
|
||||
class="fixed z-20 border border-base-300 rounded group default-field fields-list-item pointer-events-none"
|
||||
>
|
||||
<div class="flex items-center justify-between relative cursor-grab">
|
||||
<div class="flex items-center p-1 space-x-1">
|
||||
<IconDrag />
|
||||
<component
|
||||
:is="fieldIcons[field.type || 'text']"
|
||||
:stroke-width="1.6"
|
||||
:width="20"
|
||||
/>
|
||||
<span class="block pl-0.5">
|
||||
{{ field.title || field.name }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="isRequired"
|
||||
:data-tip="t('required')"
|
||||
class="text-red-400 text-3xl pr-1.5 tooltip tooltip-left h-8"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="dragPlaceholder && !isMask && field"
|
||||
ref="dragPlaceholder"
|
||||
class="fixed field-type-button z-20 flex items-center justify-center border border-dashed w-full rounded border-base-content/20 opacity-90 pointer-events-none"
|
||||
:style="[dragPlaceholderStyle, { backgroundColor }]"
|
||||
>
|
||||
<div
|
||||
class="flex items-console cursor-grab h-full absolute left-0 bg-base-200/50"
|
||||
>
|
||||
<IconDrag class="my-auto" />
|
||||
</div>
|
||||
<div class="flex items-center flex-col px-2 py-2">
|
||||
<component :is="fieldIcons[field.type || 'text']" />
|
||||
<span class="text-xs mt-1">
|
||||
{{ fieldNames[field.type || 'text'] }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Field from './field'
|
||||
import IconDrag from './icon_drag'
|
||||
import FieldType from './field_type'
|
||||
|
||||
export default {
|
||||
name: 'DragPlaceholder',
|
||||
components: {
|
||||
Field,
|
||||
IconDrag
|
||||
},
|
||||
inject: ['t', 'backgroundColor'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
isDefault: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
isRequired: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
isField: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isMask: false,
|
||||
dragPlaceholder: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dragPlaceholderStyle () {
|
||||
if (this.dragPlaceholder) {
|
||||
return {
|
||||
left: this.dragPlaceholder.x + 'px',
|
||||
top: this.dragPlaceholder.y + 'px',
|
||||
width: this.dragPlaceholder.w + 'px',
|
||||
height: this.dragPlaceholder.h + 'px'
|
||||
}
|
||||
} else {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldIcons: FieldType.computed.fieldIcons
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,34 +2,42 @@
|
||||
<div
|
||||
class="flex h-60 w-full"
|
||||
@dragover.prevent
|
||||
@dragenter="isDragEntering = true"
|
||||
@dragleave="isDragEntering = false"
|
||||
@drop.prevent="onDropFiles"
|
||||
>
|
||||
<label
|
||||
id="document_dropzone"
|
||||
class="w-full relative hover:bg-base-200/30 rounded-md border border-2 border-base-content/10 border-dashed"
|
||||
class="w-full relative rounded-md border-2 border-base-content/10 border-dashed"
|
||||
:for="inputId"
|
||||
:class="{ 'opacity-50': isLoading || isProcessing }"
|
||||
:class="{ 'opacity-50': isLoading, 'hover:bg-base-200/50': withHoverClass && !isDragEntering, 'bg-base-200/50 border-base-content/30': isDragEntering }"
|
||||
>
|
||||
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
|
||||
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="flex flex-col items-center">
|
||||
<IconInnerShadowTop
|
||||
v-if="isLoading || isProcessing"
|
||||
v-if="isLoading"
|
||||
class="animate-spin"
|
||||
:width="40"
|
||||
:height="40"
|
||||
/>
|
||||
<IconCloudUpload
|
||||
<component
|
||||
:is="icon"
|
||||
v-else
|
||||
class="stroke-[1.5px]"
|
||||
:width="40"
|
||||
:height="40"
|
||||
/>
|
||||
<div
|
||||
v-if="message"
|
||||
class="font-medium text-lg mb-1"
|
||||
class="text-lg mb-1"
|
||||
:class="{ 'mt-1': !withDescription, 'font-medium': withDescription }"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<div
|
||||
v-if="withDescription"
|
||||
class="text-sm"
|
||||
>
|
||||
<span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,13 +62,16 @@
|
||||
|
||||
<script>
|
||||
import Upload from './upload'
|
||||
import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
import { IconCloudUpload, IconFilePlus, IconFileSymlink, IconFiles, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FileDropzone',
|
||||
components: {
|
||||
IconFilePlus,
|
||||
IconCloudUpload,
|
||||
IconInnerShadowTop
|
||||
IconInnerShadowTop,
|
||||
IconFileSymlink,
|
||||
IconFiles
|
||||
},
|
||||
inject: ['baseFetch', 't'],
|
||||
props: {
|
||||
@@ -68,35 +79,70 @@ export default {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
withHoverClass: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'IconCloudUpload'
|
||||
},
|
||||
cloneTemplateOnUpload: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withDescription: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
acceptFileTypes: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'image/*, application/pdf'
|
||||
}
|
||||
},
|
||||
emits: ['success'],
|
||||
emits: ['success', 'error', 'loading'],
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
isProcessing: false
|
||||
isDragEntering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
inputId () {
|
||||
return 'el' + Math.random().toString(32).split('.')[1]
|
||||
},
|
||||
uploadUrl () {
|
||||
if (this.cloneTemplateOnUpload) {
|
||||
return `/templates/${this.templateId}/clone_and_replace`
|
||||
} else {
|
||||
return `/templates/${this.templateId}/documents`
|
||||
}
|
||||
},
|
||||
message () {
|
||||
if (this.isLoading) {
|
||||
return this.t('uploading')
|
||||
} else if (this.isProcessing) {
|
||||
return this.t('processing_')
|
||||
} else if (this.acceptFileTypes === 'image/*, application/pdf') {
|
||||
return this.t('add_pdf_documents_or_images')
|
||||
return this.title || this.t('add_pdf_documents_or_images')
|
||||
} else {
|
||||
return this.t('add_documents_or_images')
|
||||
return this.title || this.t('add_documents_or_images')
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isLoading (value) {
|
||||
this.$emit('loading', value)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
upload: Upload.methods.upload,
|
||||
onDropFiles (e) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class="list-field group mb-2"
|
||||
>
|
||||
<div
|
||||
class="border border-base-300 rounded rounded-tr-none relative group fields-list-item"
|
||||
class="border border-base-300 rounded relative group fields-list-item"
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
>
|
||||
<div class="flex items-center justify-between relative group/contenteditable-container">
|
||||
@@ -149,7 +149,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="field.options"
|
||||
v-if="field.options && withOptions"
|
||||
ref="options"
|
||||
class="border-t border-base-300 mx-2 pt-2 space-y-1.5"
|
||||
draggable="true"
|
||||
@@ -302,6 +302,11 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
withOptions: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
defaultField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
v-for="(icon, type) in fieldIconsSorted"
|
||||
:key="type"
|
||||
>
|
||||
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification')">
|
||||
<li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))">
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm py-1 px-2"
|
||||
|
||||
@@ -25,11 +25,11 @@
|
||||
:data-uuid="field.uuid"
|
||||
:field="field"
|
||||
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
|
||||
:editable="editable && (!fieldsDragFieldRef.value || fieldsDragFieldRef.value !== field)"
|
||||
:editable="editable"
|
||||
:default-field="defaultFieldsIndex[field.name]"
|
||||
:draggable="editable"
|
||||
@dragstart="fieldsDragFieldRef.value = field"
|
||||
@dragend="fieldsDragFieldRef.value = null"
|
||||
@dragstart="[fieldsDragFieldRef.value = field, removeDragOverlay($event), setDragPlaceholder($event)]"
|
||||
@dragend="[fieldsDragFieldRef.value = null, $emit('set-drag-placeholder', null)]"
|
||||
@remove="removeField"
|
||||
@scroll-to="$emit('scroll-to-area', $event)"
|
||||
@set-draw="$emit('set-draw', $event)"
|
||||
@@ -74,8 +74,8 @@
|
||||
<div
|
||||
:style="{ backgroundColor }"
|
||||
draggable="true"
|
||||
class="border border-base-300 rounded rounded-tr-none relative group mb-2 default-field fields-list-item"
|
||||
@dragstart="onDragstart({ type: 'text', ...field })"
|
||||
class="border border-base-300 rounded relative group mb-2 default-field fields-list-item"
|
||||
@dragstart="onDragstart($event, field)"
|
||||
@dragend="$emit('drag-end')"
|
||||
>
|
||||
<div class="flex items-center justify-between relative cursor-grab">
|
||||
@@ -104,6 +104,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="editable && !onlyDefinedFields"
|
||||
id="field-types-grid"
|
||||
class="grid grid-cols-3 gap-1 pb-2 fields-grid"
|
||||
>
|
||||
<template
|
||||
@@ -111,12 +112,13 @@
|
||||
:key="type"
|
||||
>
|
||||
<button
|
||||
v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification')"
|
||||
v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))"
|
||||
:id="`${type}_type_field_button`"
|
||||
draggable="true"
|
||||
class="field-type-button group flex items-center justify-center border border-dashed w-full rounded relative fields-grid-item"
|
||||
:style="{ backgroundColor }"
|
||||
:class="drawFieldType === type ? 'border-base-content/40' : 'border-base-300 hover:border-base-content/20'"
|
||||
@dragstart="onDragstart({ type: type })"
|
||||
@dragstart="onDragstart($event, { type: type })"
|
||||
@dragend="$emit('drag-end')"
|
||||
@click="['file', 'payment', 'verification'].includes(type) ? $emit('add-field', type) : $emit('set-draw-type', type)"
|
||||
>
|
||||
@@ -166,7 +168,7 @@
|
||||
:data-tip="t('obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more')"
|
||||
>
|
||||
<a
|
||||
href="https://www.docuseal.com/contact"
|
||||
href="https://www.docuseal.com/qualified-electronic-signature"
|
||||
target="_blank"
|
||||
class="opacity-50 flex items-center justify-center border border-dashed border-base-300 w-full rounded relative fields-grid-item"
|
||||
:style="{ backgroundColor }"
|
||||
@@ -189,7 +191,7 @@
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="fields.length < 4 && editable && withHelp"
|
||||
v-if="fields.length < 4 && editable && withHelp && !showTourStartForm"
|
||||
class="text-xs p-2 border border-base-200 rounded"
|
||||
>
|
||||
<ul class="list-disc list-outside ml-3">
|
||||
@@ -204,6 +206,23 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-show="fields.length < 4 && editable && withHelp && showTourStartForm"
|
||||
class="rounded py-2 px-4 w-full border border-dashed border-base-300"
|
||||
>
|
||||
<div class="text-center text-sm">
|
||||
{{ t('start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document') }}
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<label
|
||||
for="start_tour_button"
|
||||
class="btn btn-sm btn-warning w-40 mt-2"
|
||||
@click="startTour"
|
||||
>
|
||||
{{ t('start_tour') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -290,9 +309,14 @@ export default {
|
||||
selectedSubmitter: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
showTourStartForm: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['add-field', 'set-draw', 'set-draw-type', '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', 'set-drag-placeholder'],
|
||||
data () {
|
||||
return {
|
||||
defaultFieldsSearch: ''
|
||||
@@ -343,22 +367,56 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onDragstart (field) {
|
||||
onDragstart (event, field) {
|
||||
this.removeDragOverlay(event)
|
||||
|
||||
this.setDragPlaceholder(event)
|
||||
|
||||
this.$emit('set-drag', field)
|
||||
},
|
||||
setDragPlaceholder (event) {
|
||||
this.$emit('set-drag-placeholder', {
|
||||
offsetX: event.offsetX,
|
||||
offsetY: event.offsetY,
|
||||
x: event.clientX - event.offsetX,
|
||||
y: event.clientY - event.offsetY,
|
||||
w: event.currentTarget.clientWidth + 2,
|
||||
h: event.currentTarget.clientHeight + 2
|
||||
})
|
||||
},
|
||||
removeDragOverlay (event) {
|
||||
const root = this.$el.getRootNode()
|
||||
const hiddenEl = document.createElement('div')
|
||||
|
||||
hiddenEl.style.width = '1px'
|
||||
hiddenEl.style.height = '1px'
|
||||
hiddenEl.style.opacity = '0'
|
||||
hiddenEl.style.position = 'fixed'
|
||||
|
||||
root.querySelector('#docuseal_modal_container').appendChild(hiddenEl)
|
||||
|
||||
event.dataTransfer.setDragImage(hiddenEl, 0, 0)
|
||||
|
||||
setTimeout(() => { hiddenEl.remove() }, 1000)
|
||||
},
|
||||
startTour () {
|
||||
document.querySelector('app-tour').start()
|
||||
},
|
||||
onFieldDragover (e) {
|
||||
const targetField = e.target.closest('[data-uuid]')
|
||||
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.fieldsDragFieldRef.value.uuid}"]`)
|
||||
if (this.fieldsDragFieldRef.value) {
|
||||
const targetField = e.target.closest('[data-uuid]')
|
||||
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.fieldsDragFieldRef.value.uuid}"]`)
|
||||
|
||||
if (dragField && targetField && targetField !== dragField) {
|
||||
const fields = Array.from(this.$refs.fields.children)
|
||||
const currentIndex = fields.indexOf(dragField)
|
||||
const targetIndex = fields.indexOf(targetField)
|
||||
if (dragField && targetField && targetField !== dragField) {
|
||||
const fields = Array.from(this.$refs.fields.children)
|
||||
const currentIndex = fields.indexOf(dragField)
|
||||
const targetIndex = fields.indexOf(targetField)
|
||||
|
||||
if (currentIndex < targetIndex) {
|
||||
targetField.after(dragField)
|
||||
} else {
|
||||
targetField.before(dragField)
|
||||
if (currentIndex < targetIndex) {
|
||||
targetField.after(dragField)
|
||||
} else {
|
||||
targetField.before(dragField)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<span class="relative">
|
||||
<select
|
||||
class="select input-bordered bg-white select-sm text-center pl-2"
|
||||
style="font-size: 16px; width: 86px; text-align-last: center;"
|
||||
style="font-size: 16px; line-height: 12px; width: 86px; text-align-last: center;"
|
||||
@change="$event.target.value ? preferences.font_size = parseInt($event.target.value) : delete preferences.font_size"
|
||||
>
|
||||
<option
|
||||
@@ -77,12 +77,12 @@
|
||||
</select>
|
||||
<span
|
||||
class="border-l pl-1.5 absolute bg-white bottom-0 pointer-events-none text-sm h-5"
|
||||
style="right: 13px; top: 7px"
|
||||
style="right: 13px; top: 6px"
|
||||
>
|
||||
pt
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span class="flex">
|
||||
<div
|
||||
class="join"
|
||||
style="height: 32px"
|
||||
@@ -98,7 +98,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
<span>
|
||||
<span class="flex">
|
||||
<div
|
||||
class="join"
|
||||
style="height: 32px"
|
||||
@@ -114,6 +114,32 @@
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
<span class="flex">
|
||||
<div class="dropdown modal-field-font-dropdown">
|
||||
<label
|
||||
tabindex="0"
|
||||
class="cursor-pointer flex bg-white border input-bordered rounded-md h-8 items-center justify-center px-1"
|
||||
style="-webkit-appearance: none; -moz-appearance: none;"
|
||||
>
|
||||
<component :is="valigns.find((v) => v.value === (preferences.valign || 'center'))?.icon" />
|
||||
</label>
|
||||
<div
|
||||
tabindex="0"
|
||||
class="dropdown-content p-0 mt-1 block z-10 menu shadow bg-white border border-base-300 rounded-md"
|
||||
>
|
||||
<div
|
||||
v-for="(valign, index) in valigns"
|
||||
:key="index"
|
||||
:value="valign.value"
|
||||
:class="{ 'bg-base-300': preferences.valign == valign.value }"
|
||||
class="hover:bg-base-300 px-2 py-1.5 cursor-pointer"
|
||||
@click="[valign.value ? preferences.valign = valign.value : delete preferences.valign, closeDropdown()]"
|
||||
>
|
||||
<component :is="valign.icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<span>
|
||||
<select
|
||||
class="input input-bordered bg-white input-sm text-lg rounded-md"
|
||||
@@ -134,7 +160,7 @@
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div
|
||||
class="flex items-center border border-base-content/20 rounded-xl bg-white px-4 h-16 modal-field-font-preview"
|
||||
class="flex border border-base-content/20 rounded-xl bg-white px-4 h-16 modal-field-font-preview"
|
||||
:style="{
|
||||
color: preferences.color || 'black',
|
||||
fontSize: (preferences.font_size || 12) + 'pt',
|
||||
@@ -143,7 +169,7 @@
|
||||
>
|
||||
<span
|
||||
contenteditable="true"
|
||||
class="outline-none"
|
||||
class="outline-none whitespace-nowrap truncate"
|
||||
>
|
||||
{{ field.default_value || field.name || buildDefaultName(field, template.fields) }}
|
||||
</span>
|
||||
@@ -163,7 +189,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconChevronDown, IconBold, IconItalic, IconAlignLeft, IconAlignRight, IconAlignCenter } from '@tabler/icons-vue'
|
||||
import { IconChevronDown, IconBold, IconItalic, IconAlignLeft, IconAlignRight, IconAlignCenter, IconAlignBoxCenterTop, IconAlignBoxCenterBottom, IconAlignBoxCenterMiddle } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FontModal',
|
||||
@@ -213,6 +239,13 @@ export default {
|
||||
{ icon: IconAlignRight, value: 'right' }
|
||||
]
|
||||
},
|
||||
valigns () {
|
||||
return [
|
||||
{ icon: IconAlignBoxCenterTop, value: 'top' },
|
||||
{ icon: IconAlignBoxCenterMiddle, value: 'center' },
|
||||
{ icon: IconAlignBoxCenterBottom, value: 'bottom' }
|
||||
]
|
||||
},
|
||||
sizes () {
|
||||
return [...Array(23).keys()].map(i => i + 6)
|
||||
},
|
||||
@@ -230,12 +263,15 @@ export default {
|
||||
'justify-center': this.preferences.align === 'center',
|
||||
'justify-start': this.preferences.align === 'left',
|
||||
'justify-end': this.preferences.align === 'right',
|
||||
'items-center': !this.preferences.valign || this.preferences.valign === 'center',
|
||||
'items-start': this.preferences.valign === 'top',
|
||||
'items-end': this.preferences.valign === 'bottom',
|
||||
'font-bold': ['bold_italic', 'bold'].includes(this.preferences.font_type),
|
||||
italic: ['bold_italic', 'italic'].includes(this.preferences.font_type)
|
||||
}
|
||||
},
|
||||
keys () {
|
||||
return ['font_type', 'font_size', 'color', 'align', 'font']
|
||||
return ['font_type', 'font_size', 'color', 'align', 'valign', 'font']
|
||||
}
|
||||
},
|
||||
created () {
|
||||
|
||||
@@ -154,7 +154,7 @@ export default {
|
||||
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) {
|
||||
if (f !== this.field && ['number'].includes(f.type) && (!f.preferences?.formula || f.submitter_uuid !== this.field.submitter_uuid)) {
|
||||
acc.push(f)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isDragging || isLoading"
|
||||
class="modal modal-open"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-4 items-center bg-base-100 h-full max-h-[85vh] max-w-6xl rounded-2xl w-full">
|
||||
<Dropzone
|
||||
class="flex-1 h-full"
|
||||
icon="IconFilePlus"
|
||||
:template-id="templateId"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:with-hover-class="false"
|
||||
:with-description="false"
|
||||
:title="t('add_a_new_document')"
|
||||
type="add_files"
|
||||
@loading="isLoading = $event"
|
||||
@success="$emit('add', $event)"
|
||||
@error="$emit('error', $event)"
|
||||
/>
|
||||
<div class="flex-1 flex gap-2 w-full">
|
||||
<Dropzone
|
||||
class="flex-1 h-full"
|
||||
icon="IconFileSymlink"
|
||||
:template-id="templateId"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:with-hover-class="false"
|
||||
:with-description="false"
|
||||
:title="t('replace_existing_document')"
|
||||
@loading="isLoading = $event"
|
||||
@success="$emit('replace', $event)"
|
||||
@error="$emit('error', $event)"
|
||||
/>
|
||||
<Dropzone
|
||||
v-if="withReplaceAndClone"
|
||||
class="flex-1 h-full"
|
||||
icon="IconFiles"
|
||||
:template-id="templateId"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:with-hover-class="false"
|
||||
:with-description="false"
|
||||
:clone-template-on-upload="true"
|
||||
:title="t('clone_and_replace_documents')"
|
||||
@loading="isLoading = $event"
|
||||
@success="$emit('replace-and-clone', $event)"
|
||||
@error="$emit('error', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Dropzone from './dropzone'
|
||||
|
||||
export default {
|
||||
name: 'HoverDropzone',
|
||||
components: {
|
||||
Dropzone
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
isDragging: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
templateId: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
withReplaceAndClone: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
acceptFileTypes: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'image/*, application/pdf'
|
||||
}
|
||||
},
|
||||
emits: ['add', 'replace', 'replace-and-clone', 'error'],
|
||||
data () {
|
||||
return {
|
||||
isLoading: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -63,6 +63,9 @@ const en = {
|
||||
processing_: 'Processing...',
|
||||
add_pdf_documents_or_images: 'Add PDF documents or images',
|
||||
add_documents_or_images: 'Add documents or images',
|
||||
add_a_new_document: 'Add a new document',
|
||||
replace_existing_document: 'Replace existing document',
|
||||
clone_and_replace_documents: 'Clone and replace documents',
|
||||
required: 'Required',
|
||||
default_value: 'Default value',
|
||||
format: 'Format',
|
||||
@@ -158,7 +161,9 @@ const en = {
|
||||
some_fields_are_missing_in_the_formula: 'Some fields are missing in the formula.',
|
||||
learn_more: 'Learn more',
|
||||
and: 'and',
|
||||
or: 'or'
|
||||
or: 'or',
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start a quick tour to learn how to create an send your first document',
|
||||
start_tour: 'Start Tour'
|
||||
}
|
||||
|
||||
const es = {
|
||||
@@ -223,6 +228,9 @@ const es = {
|
||||
processing_: 'Procesando...',
|
||||
add_pdf_documents_or_images: 'Agregar documentos PDF o imágenes',
|
||||
add_documents_or_images: 'Agregar documentos o imágenes',
|
||||
add_a_new_document: 'Agregar un nuevo documento',
|
||||
replace_existing_document: 'Reemplazar documento existente',
|
||||
clone_and_replace_documents: 'Clonar y reemplazar documentos',
|
||||
required: 'Requerido',
|
||||
default_value: 'Valor predeterminado',
|
||||
format: 'Formato',
|
||||
@@ -321,7 +329,9 @@ const es = {
|
||||
some_fields_are_missing_in_the_formula: 'Faltan algunos campos en la fórmula.',
|
||||
learn_more: 'Aprende más',
|
||||
and: 'y',
|
||||
or: 'o'
|
||||
or: 'o',
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inicia una guía rápida para aprender a crear y enviar tu primer documento.',
|
||||
start_tour: 'Iniciar guía'
|
||||
}
|
||||
|
||||
const it = {
|
||||
@@ -389,6 +399,9 @@ const it = {
|
||||
processing_: 'Elaborazione...',
|
||||
add_pdf_documents_or_images: 'Aggiungi documenti PDF o immagini',
|
||||
add_documents_or_images: 'Aggiungi documenti o immagini',
|
||||
add_a_new_document: 'Aggiungi un nuovo documento',
|
||||
replace_existing_document: 'Sostituisci documento esistente',
|
||||
clone_and_replace_documents: 'Clona e sostituisci documenti',
|
||||
required: 'Obbligatorio',
|
||||
default_value: 'Valore predefinito',
|
||||
format: 'Formato',
|
||||
@@ -484,7 +497,9 @@ const it = {
|
||||
some_fields_are_missing_in_the_formula: 'Alcuni campi mancano nella formula.',
|
||||
learn_more: 'Scopri di più',
|
||||
and: 'e',
|
||||
or: 'o'
|
||||
or: 'o',
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inizia un tour rapido per imparare a creare e inviare il tuo primo documento.',
|
||||
start_tour: 'Inizia il tour'
|
||||
}
|
||||
|
||||
const pt = {
|
||||
@@ -549,6 +564,9 @@ const pt = {
|
||||
processing_: 'Processando...',
|
||||
add_pdf_documents_or_images: 'Adicionar documentos PDF ou imagens',
|
||||
add_documents_or_images: 'Adicionar documentos ou imagens',
|
||||
add_a_new_document: 'Ajouter un nouveau document',
|
||||
replace_existing_document: 'Substituir documento existente',
|
||||
clone_and_replace_documents: 'Clonar e substituir documentos',
|
||||
required: 'Obrigatório',
|
||||
default_value: 'Valor padrão',
|
||||
format: 'Formato',
|
||||
@@ -647,7 +665,9 @@ const pt = {
|
||||
some_fields_are_missing_in_the_formula: 'Faltam alguns campos na fórmula.',
|
||||
learn_more: 'Saiba mais',
|
||||
and: 'e',
|
||||
or: 'ou'
|
||||
or: 'ou',
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Comece um tour rápido para aprender a criar e enviar seu primeiro documento.',
|
||||
start_tour: 'Iniciar tour'
|
||||
}
|
||||
|
||||
const fr = {
|
||||
@@ -711,6 +731,9 @@ const fr = {
|
||||
processing_: 'Traitement en cours...',
|
||||
add_pdf_documents_or_images: 'Ajoutez des documents PDF ou des images',
|
||||
add_documents_or_images: 'Ajoutez des documents ou des images',
|
||||
add_a_new_document: 'Adicionar um novo documento',
|
||||
replace_existing_document: 'Remplacer le document existant',
|
||||
clone_and_replace_documents: 'Cloner et remplacer les documents',
|
||||
required: 'Requis',
|
||||
default_value: 'Valeur par défaut',
|
||||
format: 'Format',
|
||||
@@ -810,7 +833,9 @@ const fr = {
|
||||
some_fields_are_missing_in_the_formula: 'Certains champs manquent dans la formule.',
|
||||
learn_more: 'En savoir plus',
|
||||
and: 'et',
|
||||
or: 'ou'
|
||||
or: 'ou',
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Lancez une visite rapide pour apprendre à créer et envoyer votre premier document.',
|
||||
start_tour: 'Démarrer'
|
||||
}
|
||||
|
||||
const de = {
|
||||
@@ -874,6 +899,9 @@ const de = {
|
||||
processing_: 'Verarbeitung...',
|
||||
add_pdf_documents_or_images: 'PDF-Dokumente oder Bilder hinzufügen',
|
||||
add_documents_or_images: 'Dokumente oder Bilder hinzufügen',
|
||||
add_a_new_document: 'Neues Dokument hinzufügen',
|
||||
replace_existing_document: 'Vorhandenes Dokument ersetzen',
|
||||
clone_and_replace_documents: 'Dokumente klonen und ersetzen',
|
||||
required: 'Erforderlich',
|
||||
default_value: 'Standardwert',
|
||||
format: 'Format',
|
||||
@@ -973,7 +1001,9 @@ const de = {
|
||||
some_fields_are_missing_in_the_formula: 'Einige Felder fehlen in der Formel.',
|
||||
learn_more: 'Erfahren Sie mehr',
|
||||
and: 'und',
|
||||
or: 'oder'
|
||||
or: 'oder',
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Starte eine kurze Tour, um zu lernen, wie du dein erstes Dokument erstellst und versendest.',
|
||||
start_tour: 'Starten'
|
||||
}
|
||||
|
||||
export { en, es, it, pt, fr, de }
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
v-for="(icon, type) in fieldIconsSorted"
|
||||
:key="type"
|
||||
>
|
||||
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification')">
|
||||
<li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))">
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm py-1 px-2"
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<FieldArea
|
||||
v-if="newArea"
|
||||
:is-draw="true"
|
||||
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || defaultFieldType }"
|
||||
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || dragFieldPlaceholder?.type || defaultFieldType }"
|
||||
:area="newArea"
|
||||
/>
|
||||
</div>
|
||||
@@ -49,7 +49,9 @@
|
||||
:class="{ 'z-10': !isMobile, 'cursor-grab': isDrag, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
|
||||
@pointermove="onPointermove"
|
||||
@pointerdown="onStartDraw"
|
||||
@dragover.prevent
|
||||
@dragover.prevent="onDragover"
|
||||
@dragenter="onDragenter"
|
||||
@dragleave="newArea = null"
|
||||
@drop="onDrop"
|
||||
@pointerup="onPointerup"
|
||||
/>
|
||||
@@ -64,12 +66,17 @@ export default {
|
||||
components: {
|
||||
FieldArea
|
||||
},
|
||||
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef'],
|
||||
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'assignDropAreaSize'],
|
||||
props: {
|
||||
image: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
dragFieldPlaceholder: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
areas: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -192,7 +199,24 @@ export default {
|
||||
this.areaRefs.push(el)
|
||||
}
|
||||
},
|
||||
onDragenter (e) {
|
||||
this.newArea = {}
|
||||
|
||||
this.assignDropAreaSize(this.newArea, this.dragFieldPlaceholder, {
|
||||
maskW: this.$refs.mask.clientWidth,
|
||||
maskH: this.$refs.mask.clientHeight
|
||||
})
|
||||
|
||||
this.newArea.x = (e.offsetX - 6) / this.$refs.mask.clientWidth
|
||||
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight - this.newArea.h / 2
|
||||
},
|
||||
onDragover (e) {
|
||||
this.newArea.x = (e.offsetX - 6) / this.$refs.mask.clientWidth
|
||||
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight - this.newArea.h / 2
|
||||
},
|
||||
onDrop (e) {
|
||||
this.newArea = null
|
||||
|
||||
this.$emit('drop-field', {
|
||||
x: e.offsetX,
|
||||
y: e.offsetY,
|
||||
@@ -266,7 +290,12 @@ export default {
|
||||
area.cell_w = this.newArea.cell_w
|
||||
}
|
||||
|
||||
this.$emit('draw', area)
|
||||
const dx = Math.abs(e.offsetX - this.$refs.mask.clientWidth * this.newArea.initialX)
|
||||
const dy = Math.abs(e.offsetY - this.$refs.mask.clientHeight * this.newArea.initialY)
|
||||
|
||||
const isTooSmall = dx < 8 && dy < 8
|
||||
|
||||
this.$emit('draw', { area, isTooSmall })
|
||||
}
|
||||
|
||||
this.showMask = false
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<label
|
||||
:for="inputId"
|
||||
class="btn btn-neutral btn-xs text-white transition-none replace-document-button"
|
||||
:class="{ 'opacity-100': isLoading || isProcessing }"
|
||||
:class="{ 'opacity-100': isLoading }"
|
||||
>
|
||||
{{ message }}
|
||||
<form
|
||||
@@ -41,19 +41,19 @@ export default {
|
||||
emits: ['success'],
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
isProcessing: false
|
||||
isLoading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
inputId () {
|
||||
return 'el' + Math.random().toString(32).split('.')[1]
|
||||
},
|
||||
uploadUrl () {
|
||||
return `/templates/${this.templateId}/documents`
|
||||
},
|
||||
message () {
|
||||
if (this.isLoading) {
|
||||
return this.t('uploading_')
|
||||
} else if (this.isProcessing) {
|
||||
return this.t('processing_')
|
||||
} else {
|
||||
return this.t('replace')
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
id="add_document_button"
|
||||
:for="inputId"
|
||||
class="btn btn-outline w-full add-document-button"
|
||||
:class="{ 'btn-disabled': isLoading || isProcessing }"
|
||||
:class="{ 'btn-disabled': isLoading }"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
v-if="isLoading || isProcessing"
|
||||
v-if="isLoading"
|
||||
width="20"
|
||||
class="animate-spin"
|
||||
/>
|
||||
@@ -18,9 +18,6 @@
|
||||
<span v-if="isLoading">
|
||||
{{ t('uploading_') }}
|
||||
</span>
|
||||
<span v-else-if="isProcessing">
|
||||
{{ t('processing_') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('add_document') }}
|
||||
</span>
|
||||
@@ -63,30 +60,34 @@ export default {
|
||||
default: 'image/*, application/pdf'
|
||||
}
|
||||
},
|
||||
emits: ['success'],
|
||||
emits: ['success', 'error'],
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
isProcessing: false
|
||||
isLoading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
inputId () {
|
||||
return 'el' + Math.random().toString(32).split('.')[1]
|
||||
},
|
||||
uploadUrl () {
|
||||
return `/templates/${this.templateId}/documents`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async upload () {
|
||||
this.isLoading = true
|
||||
|
||||
this.baseFetch(`/templates/${this.templateId}/documents`, {
|
||||
this.baseFetch(this.uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json' },
|
||||
body: new FormData(this.$refs.form)
|
||||
}).then((resp) => {
|
||||
if (resp.ok) {
|
||||
resp.json().then((data) => {
|
||||
this.$emit('success', data)
|
||||
this.$refs.input.value = ''
|
||||
this.isLoading = false
|
||||
})
|
||||
} else if (resp.status === 422) {
|
||||
resp.json().then((data) => {
|
||||
@@ -95,21 +96,33 @@ export default {
|
||||
|
||||
formData.append('password', prompt(this.t('enter_pdf_password')))
|
||||
|
||||
this.baseFetch(`/templates/${this.templateId}/documents`, {
|
||||
this.baseFetch(this.uploadUrl, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(async (resp) => {
|
||||
if (resp.ok) {
|
||||
this.$emit('success', await resp.json())
|
||||
this.$refs.input.value = ''
|
||||
this.isLoading = false
|
||||
} else {
|
||||
alert(this.t('wrong_password'))
|
||||
|
||||
this.$emit('error', await resp.json().error)
|
||||
this.isLoading = false
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.$emit('error', data.error)
|
||||
this.isLoading = false
|
||||
}
|
||||
})
|
||||
} else {
|
||||
resp.json().then((data) => {
|
||||
this.$emit('error', data.error)
|
||||
this.isLoading = false
|
||||
})
|
||||
}
|
||||
}).finally(() => {
|
||||
}).catch(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -94,11 +94,27 @@ class ProcessSubmitterCompletionJob
|
||||
end
|
||||
end
|
||||
|
||||
to = build_to_addresses(submitter)
|
||||
maybe_enqueue_copy_emails(submitter)
|
||||
end
|
||||
|
||||
return if to.blank? || submitter.template.preferences['documents_copy_email_enabled'] == false
|
||||
def maybe_enqueue_copy_emails(submitter)
|
||||
return if submitter.template.preferences['documents_copy_email_enabled'] == false
|
||||
|
||||
SubmitterMailer.documents_copy_email(submitter, to:).deliver_later!
|
||||
configs = AccountConfigs.find_or_initialize_for_key(submitter.account,
|
||||
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
|
||||
|
||||
return if configs.value['enabled'] == false
|
||||
|
||||
to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
|
||||
.sort_by(&:completed_at).select(&:email?).map(&:friendly_name)
|
||||
|
||||
return if to.blank?
|
||||
|
||||
if configs.value['bcc_recipients'] == true
|
||||
to.each { |to| SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! }
|
||||
else
|
||||
SubmitterMailer.documents_copy_email(submitter, to: to.join(', ')).deliver_later!
|
||||
end
|
||||
end
|
||||
|
||||
def build_bcc_addresses(submission)
|
||||
@@ -110,11 +126,6 @@ class ProcessSubmitterCompletionJob
|
||||
bcc.to_s.scan(User::EMAIL_REGEXP)
|
||||
end
|
||||
|
||||
def build_to_addresses(submitter)
|
||||
submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
|
||||
.sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ')
|
||||
end
|
||||
|
||||
def enqueue_next_submitter_request_notification(submitter)
|
||||
next_submitter_item =
|
||||
submitter.submission.template_submitters.find do |e|
|
||||
|
||||
@@ -20,7 +20,7 @@ class ApplicationMailer < ActionMailer::Base
|
||||
end
|
||||
|
||||
def set_message_metadata
|
||||
message.instance_variable_set(:@message_metadata, @message_metadata)
|
||||
message.instance_variable_set(:@message_metadata, @message_metadata || {})
|
||||
end
|
||||
|
||||
def set_message_uuid
|
||||
@@ -28,10 +28,14 @@ class ApplicationMailer < ActionMailer::Base
|
||||
end
|
||||
|
||||
def assign_message_metadata(tag, record)
|
||||
@message_metadata = {
|
||||
@message_metadata = (@message_metadata || {}).merge(
|
||||
'tag' => tag,
|
||||
'record_id' => record.id,
|
||||
'record_type' => record.class.name
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def put_metadata(attrs)
|
||||
@message_metadata = (@message_metadata || {}).merge(attrs)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -233,9 +233,17 @@ class SubmitterMailer < ApplicationMailer
|
||||
def from_address_for_submitter(submitter)
|
||||
if submitter.submission.source.in?(%w[api embed]) &&
|
||||
(from_email = AccountConfig.find_by(account: submitter.account, key: 'integration_from_email')&.value.presence)
|
||||
user = submitter.account.users.find_by(email: from_email)
|
||||
|
||||
put_metadata('from_user_id' => user.id)
|
||||
|
||||
from_email
|
||||
else
|
||||
(submitter.submission.created_by_user || submitter.submission.template.author).friendly_name
|
||||
user = submitter.submission.created_by_user || submitter.submission.template.author
|
||||
|
||||
put_metadata('from_user_id' => user.id)
|
||||
|
||||
user.friendly_name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,6 +33,7 @@ class Account < ApplicationRecord
|
||||
has_many :account_linked_accounts, dependent: :destroy
|
||||
has_many :email_events, dependent: :destroy
|
||||
has_many :webhook_urls, dependent: :destroy
|
||||
has_many :account_accesses, dependent: :destroy
|
||||
has_many :account_testing_accounts, -> { testing }, dependent: :destroy,
|
||||
class_name: 'AccountLinkedAccount',
|
||||
inverse_of: :account
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_accesses
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_account_accesses_on_account_id_and_user_id (account_id,user_id) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
#
|
||||
class AccountAccess < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :user
|
||||
end
|
||||
@@ -30,6 +30,7 @@ class AccountConfig < ApplicationRecord
|
||||
ALLOW_TO_RESUBMIT = 'allow_to_resubmit'
|
||||
ALLOW_TO_DECLINE_KEY = 'allow_to_decline'
|
||||
SUBMITTER_REMINDERS = 'submitter_reminders'
|
||||
ENFORCE_SIGNING_ORDER_KEY = 'enforce_signing_order'
|
||||
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
|
||||
FORM_COMPLETED_MESSAGE_KEY = 'form_completed_message'
|
||||
FORM_WITH_CONFETTI_KEY = 'form_with_confetti'
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_email_events_on_account_id (account_id)
|
||||
# index_email_events_on_email (email)
|
||||
# index_email_events_on_emailable (emailable_type,emailable_id)
|
||||
# index_email_events_on_message_id (message_id)
|
||||
# index_email_events_on_account_id_and_event_datetime (account_id,event_datetime)
|
||||
# index_email_events_on_email (email)
|
||||
# index_email_events_on_emailable (emailable_type,emailable_id)
|
||||
# index_email_events_on_message_id (message_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
|
||||
@@ -64,13 +64,20 @@ class Submission < ApplicationRecord
|
||||
through: :template, source: :documents_attachments
|
||||
|
||||
scope :active, -> { where(archived_at: nil) }
|
||||
scope :pending, -> { joins(:submitters).where(submitters: { completed_at: nil }).group(:id) }
|
||||
scope :archived, -> { where.not(archived_at: nil) }
|
||||
scope :pending, lambda {
|
||||
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
|
||||
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
|
||||
}
|
||||
scope :completed, lambda {
|
||||
where.not(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
|
||||
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
|
||||
}
|
||||
scope :declined, -> { joins(:submitters).where.not(submitters: { declined_at: nil }).group(:id) }
|
||||
scope :expired, -> { where(expire_at: ..Time.current) }
|
||||
scope :declined, lambda {
|
||||
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
|
||||
.and(Submitter.arel_table[:declined_at].not_eq(nil))).select(1).arel.exists)
|
||||
}
|
||||
scope :expired, -> { pending.where(expire_at: ..Time.current) }
|
||||
|
||||
enum :source, {
|
||||
invite: 'invite',
|
||||
@@ -89,6 +96,10 @@ class Submission < ApplicationRecord
|
||||
expire_at && expire_at <= Time.current
|
||||
end
|
||||
|
||||
def fields_uuid_index
|
||||
@fields_uuid_index ||= (template_fields || template.fields).index_by { |f| f['uuid'] }
|
||||
end
|
||||
|
||||
def audit_trail_url
|
||||
return if audit_trail.blank?
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ class SubmissionEvent < ApplicationRecord
|
||||
|
||||
enum :event_type, {
|
||||
send_email: 'send_email',
|
||||
bounce_email: 'bounce_email',
|
||||
complaint_email: 'complaint_email',
|
||||
send_reminder_email: 'send_reminder_email',
|
||||
send_sms: 'send_sms',
|
||||
send_2fa_sms: 'send_2fa_sms',
|
||||
|
||||
@@ -54,7 +54,7 @@ class Submitter < ApplicationRecord
|
||||
has_many_attached :documents
|
||||
has_many_attached :attachments
|
||||
has_many_attached :preview_documents
|
||||
has_many :template_accesses, through: :template
|
||||
has_many :template_accesses, through: :submission
|
||||
has_many :email_events, as: :emailable, dependent: (Docuseal.multitenant? ? nil : :destroy)
|
||||
|
||||
has_many :document_generation_events, dependent: :destroy
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
# name :string not null
|
||||
# preferences :text not null
|
||||
# schema :text not null
|
||||
# shared_link :boolean default(FALSE), not null
|
||||
# slug :string not null
|
||||
# source :text not null
|
||||
# submitters :text not null
|
||||
|
||||
@@ -73,6 +73,8 @@ class User < ApplicationRecord
|
||||
scope :archived, -> { where.not(archived_at: nil) }
|
||||
scope :admins, -> { where(role: ADMIN_ROLE) }
|
||||
|
||||
validates :email, format: { with: /\A[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z/ }
|
||||
|
||||
def access_token
|
||||
super || build_access_token.tap(&:save!)
|
||||
end
|
||||
|
||||
@@ -25,6 +25,7 @@ class UserConfig < ApplicationRecord
|
||||
INITIALS_KEY = 'initials'
|
||||
RECEIVE_COMPLETED_EMAIL = 'receive_completed_email'
|
||||
RECEIVE_DECLINED_EMAIL = 'receive_declined_email'
|
||||
SHOW_APP_TOUR = 'show_app_tour'
|
||||
|
||||
belongs_to :user
|
||||
|
||||
|
||||
@@ -151,6 +151,20 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if !Docuseal.multitenant? || can?(:manage, :personalization_advanced) %>
|
||||
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ENFORCE_SIGNING_ORDER_KEY) %>
|
||||
<% 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>
|
||||
<%= t('always_enforce_signing_order') %>
|
||||
</span>
|
||||
<%= f.check_box :value, class: 'toggle', checked: account_config.value, onchange: 'this.form.requestSubmit()' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render 'compliances' %>
|
||||
@@ -160,9 +174,10 @@
|
||||
<h2 class="text-2xl font-bold mb-2">
|
||||
<%= t('danger_zone') %>
|
||||
</h2>
|
||||
<%= button_to button_title(title: t('delete_my_account')), settings_account_path, class: 'btn btn-outline btn-error block', data: { turbo_confirm: t('schedule_account_for_deletion_') }, method: :delete, id: :account_delete_button %>
|
||||
<%= button_to button_title(title: t('delete_my_account')), settings_account_path, class: 'btn btn-outline btn-error block', data: { turbo_confirm: t('you_are_scheduling_your_account_for_deletion_after_deletion_your_data_will_be_permanently_removed_and_cannot_be_recovered_click_ok_if_you_would_like_to_continue') }, method: :delete, id: :account_delete_button %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="w-0 md:w-52"></div>
|
||||
</div>
|
||||
<%= render 'shared/app_tour', type: 'account' %>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<form action="<%= root_path %>" method="get" class="bg-base-200 px-1.5 rounded-xl py-1 whitespace-nowrap">
|
||||
<form action="<%= root_path %>" method="get" id="templates_submissions_toggle" class="bg-base-200 px-1.5 rounded-xl py-1 whitespace-nowrap">
|
||||
<toggle-cookies data-value="templates" data-key="dashboard_view" class="sm:tooltip tooltip-top" data-tip="<%= t('templates') %>">
|
||||
<button class="<%= local_assigns[:selected] == 'submissions' ? 'btn !border !rounded-lg btn-square !p-0 !btn-sm !h-8 !w-9' : 'btn btn-neutral !rounded-lg btn-square !p-0 hover:text-neutral-300 !btn-sm !h-8 !w-9 disabled:btn-neutral' %>">
|
||||
<%= svg_icon('layout_grid', class: 'w-6 h-6 stroke-2') %>
|
||||
|
||||
@@ -1,12 +1,52 @@
|
||||
<div class="text-center">
|
||||
<div class="join">
|
||||
<a href="https://docuseal.com<%= request.fullpath.gsub('docuseal.eu', 'docuseal.com') %>" class="btn bg-base-200 join-item w-32 <%= 'bg-base-300' if request.host == 'docuseal.com' %>">
|
||||
<%= svg_icon 'world', class: 'w-5 h-5' %>
|
||||
Global
|
||||
</a>
|
||||
<a href="https://docuseal.eu<%= request.fullpath.gsub('docuseal.com', 'docuseal.eu') %>" class="btn bg-base-200 join-item w-32 <%= 'bg-base-300' if request.host == 'docuseal.eu' %>">
|
||||
<%= svg_icon 'eu_flag', class: 'w-5 h-5' %>
|
||||
Europe
|
||||
</a>
|
||||
<% eu_server = request.host == 'docuseal.eu' %>
|
||||
<server-selector>
|
||||
<div id="global_server_selector" class="text-center hidden">
|
||||
<div class="join">
|
||||
<a href="https://docuseal.com<%= request.fullpath.gsub('docuseal.eu', 'docuseal.com') %>" class="btn bg-base-200 join-item w-40 <%= 'bg-base-300' unless eu_server %>">
|
||||
<%= svg_icon 'world', class: 'w-5 h-5' %>
|
||||
Global
|
||||
</a>
|
||||
<a href="https://docuseal.eu<%= request.fullpath.gsub('docuseal.com', 'docuseal.eu') %>" class="relative btn bg-base-200 join-item w-40 <%= 'bg-base-300' if eu_server %>">
|
||||
<%= svg_icon 'eu_flag', class: 'w-5 h-5' %>
|
||||
Europe
|
||||
<% unless eu_server %>
|
||||
<span id="eu_server_alert" class="absolute flex space-x-0.5 hidden" style="top: -1.5rem;">
|
||||
<span class="text-xs font-normal leading-none text-base-content normal-case">
|
||||
<%= t('eu_data_residency') %>
|
||||
</span>
|
||||
<%= svg_icon 'corner_right_down', class: 'w-4 h-5 stroke-1 shrink-0 pt-1' %>
|
||||
</span>
|
||||
<% end %>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="us_server_selector" class="flex justify-center hidden">
|
||||
<div class="dropdown">
|
||||
<label tabindex="0" class="relative btn btn-sm bg-transparent font-medium normal-case border-base-content/20 justify-start" style="width: 141px; padding: 0 20px">
|
||||
<% if eu_server %>
|
||||
<%= svg_icon 'eu_flag', class: 'w-5 h-5' %>
|
||||
<span>EU Cloud</span>
|
||||
<% else %>
|
||||
<%= svg_icon 'usa_flag', class: 'w-5 h-5' %>
|
||||
<span>US Cloud</span>
|
||||
<% end %>
|
||||
<%= svg_icon 'chevron_down', class: 'mr-1 w-4 h-4 absolute right-1' %>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu border border-base-content/20 mt-1 bg-base-100 rounded-box w-36">
|
||||
<li>
|
||||
<a href="https://docuseal.com<%= request.fullpath.gsub('docuseal.eu', 'docuseal.com') %>" class="flex items-center space-x-2 <%= 'bg-base-300' unless eu_server %>">
|
||||
<%= svg_icon 'usa_flag', class: 'w-5 h-5' %>
|
||||
US Cloud
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://docuseal.eu<%= request.fullpath.gsub('docuseal.com', 'docuseal.eu') %>" class="flex items-center space-x-2 <%= 'bg-base-300' if eu_server %>">
|
||||
<%= svg_icon 'eu_flag', class: 'w-5 h-5' %>
|
||||
EU Cloud
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</server-selector>
|
||||
<%= render 'scripts/server_selector' %>
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
<%= t('preferences') %>
|
||||
</h2>
|
||||
</div>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<% if can?(:manage, account_config) && (can?(:manage, :personalization_advanced) || !Docuseal.multitenant?) %>
|
||||
<%= 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">
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M3 9l4 -4l4 4m-4 -4v14" />
|
||||
<path d="M21 15l-4 4l-4 -4m4 4v-14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 362 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M20.986 12.502a9 9 0 1 0 -5.973 7.98" /><path d="M12 7v5l3 3" /><path d="M19 16v3" /><path d="M19 22v.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 400 B |
@@ -0,0 +1,3 @@
|
||||
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
|
||||
<path d="M6 6h6a3 3 0 0 1 3 3v10l-4 -4m8 0l-4 4"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 285 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" /><path d="M12 11v6" /><path d="M9.5 13.5l2.5 -2.5l2.5 2.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 462 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M15 3v4a1 1 0 0 0 1 1h4" />
|
||||
<path d="M18 17h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h4l5 5v7a2 2 0 0 1 -2 2z" />
|
||||
<path d="M16 17v2a2 2 0 0 1 -2 2h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 485 B |
@@ -1,9 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M4 8v-2a2 2 0 0 1 2 -2h2"></path>
|
||||
<path d="M4 16v2a2 2 0 0 0 2 2h2"></path>
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v2"></path>
|
||||
<path d="M16 20h2a2 2 0 0 0 2 -2v-2"></path>
|
||||
<path d="M8 11m0 1a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1v3a1 1 0 0 1 -1 1h-6a1 1 0 0 1 -1 -1z"></path>
|
||||
<path d="M10 11v-2a2 2 0 1 1 4 0v2"></path>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 8v-2a2 2 0 0 1 2 -2h2" /><path d="M4 16v2a2 2 0 0 0 2 2h2" /><path d="M16 4h2a2 2 0 0 1 2 2v2" /><path d="M16 20h2a2 2 0 0 0 2 -2v-2" /><path d="M8 11m0 1a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1v3a1 1 0 0 1 -1 1h-6a1 1 0 0 1 -1 -1z" /><path d="M10 11v-2a2 2 0 1 1 4 0v2" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 619 B After Width: | Height: | Size: 559 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 19h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v5.5" /><path d="M19 22v-6" /><path d="M22 19l-3 -3l-3 3" /><path d="M3 7l9 6l9 -6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 437 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
|
||||
<path d="M15 10v-5c0 -1.38 .62 -2 2 -2s2 .62 2 2v5m0 -3h-4"></path>
|
||||
<path d="M19 21h-4l4 -7h-4"></path> <path d="M4 15l3 3l3 -3"></path>
|
||||
<path d="M7 6v12"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 395 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
|
||||
<path d="M4 15l3 3l3 -3"></path>
|
||||
<path d="M7 6v12"></path>
|
||||
<path d="M17 14a2 2 0 0 1 2 2v3a2 2 0 1 1 -4 0v-3a2 2 0 0 1 2 -2z"></path>
|
||||
<path d="M17 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"></path>
|
||||
<path d="M19 5v3a2 2 0 0 1 -2 2h-1.5"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 474 B |