mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 06:42:32 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
202943f58d | ||
|
|
6dd15b2091 | ||
|
|
1c55e4f18b | ||
|
|
6cc36b1728 | ||
|
|
37751cd5c1 | ||
|
|
80d882cd20 | ||
|
|
8bef75e570 | ||
|
|
077eab7005 | ||
|
|
715c70d422 | ||
|
|
a3c23f9d53 | ||
|
|
c1e67a8af0 | ||
|
|
2fdc294293 | ||
|
|
1f49ba069e | ||
|
|
43ef0fc0d0 | ||
|
|
a1a920e536 | ||
|
|
dec33b4331 | ||
|
|
432f290633 | ||
|
|
cf98616a27 | ||
|
|
1a720cf1ad | ||
|
|
557c6c34f2 | ||
|
|
943c76505d | ||
|
|
66eb83e6a7 | ||
|
|
0844427357 | ||
|
|
72e9d9ea2d | ||
|
|
3db7d47746 | ||
|
|
95899336b8 | ||
|
|
f6691c110e | ||
|
|
749722a9df | ||
|
|
265b668cb4 | ||
|
|
0527e6a5c1 | ||
|
|
d989b61d19 | ||
|
|
c1f3e6ff13 | ||
|
|
3c0fff2d82 | ||
|
|
bd599c264f | ||
|
|
0274093c89 | ||
|
|
5845b5bdfd | ||
|
|
3dd0ba6b0b | ||
|
|
e79306f8a3 | ||
|
|
0171b0c2dd | ||
|
|
a963ef53d3 | ||
|
|
344f302c76 | ||
|
|
17ded282b2 | ||
|
|
2ce57df485 | ||
|
|
849289287e | ||
|
|
704fbc2393 | ||
|
|
168fc8bd03 | ||
|
|
c5598d9efe | ||
|
|
f02315726d | ||
|
|
e56dcb59c0 | ||
|
|
c74f350ea2 | ||
|
|
af9d015cf2 | ||
|
|
633e5ac5e4 | ||
|
|
2d0f323854 | ||
|
|
cfe5610ec9 | ||
|
|
55616ac321 | ||
|
|
8f2d1deb38 | ||
|
|
e2763ceb80 | ||
|
|
10462f3140 | ||
|
|
3aef92486b | ||
|
|
c1a9ee0bbb | ||
|
|
398fae82cb | ||
|
|
a16443f961 | ||
|
|
ad4e0e1d27 | ||
|
|
493a2108e9 | ||
|
|
c2c9bb64b1 | ||
|
|
bb1426c0af |
No files matched your search
@@ -8,8 +8,7 @@
|
||||
},
|
||||
"rules": {
|
||||
"vue/no-deprecated-html-element-is": 0,
|
||||
"vue/no-mutating-props": 0,
|
||||
"vue/no-v-html": 0
|
||||
"vue/no-mutating-props": 0
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
|
||||
+7
-5
@@ -2,7 +2,9 @@ FROM ruby:3.2.2-alpine3.18 as fonts
|
||||
|
||||
WORKDIR /fonts
|
||||
|
||||
RUN apk --no-cache add 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://github.com/impallari/DancingScript/raw/master/OFL.txt
|
||||
RUN apk --no-cache add fontforge wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
|
||||
|
||||
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
|
||||
|
||||
FROM ruby:3.2.2-alpine3.18 as webpack
|
||||
|
||||
@@ -34,15 +36,14 @@ FROM ruby:3.2.2-alpine3.18 as app
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV BUNDLE_WITHOUT="development:test"
|
||||
ENV PIDFILE=/dev/null
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts
|
||||
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
|
||||
|
||||
COPY ./Gemfile ./Gemfile.lock ./
|
||||
|
||||
RUN bundle update --bundler && bundle install && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
|
||||
RUN apk add --no-cache build-base && bundle update --bundler && bundle install && apk del build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
|
||||
|
||||
COPY ./bin ./bin
|
||||
COPY ./app ./app
|
||||
@@ -55,6 +56,7 @@ COPY ./tmp ./tmp
|
||||
COPY LICENSE README.md Rakefile config.ru .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=webpack /app/public/packs ./public/packs
|
||||
|
||||
RUN ln -s /fonts /app/public/fonts
|
||||
@@ -64,4 +66,4 @@ WORKDIR /data/docuseal
|
||||
ENV WORKDIR=/data/docuseal
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["/app/bin/rails", "server"]
|
||||
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
|
||||
@@ -51,11 +51,12 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
- User roles
|
||||
- Automated reminders
|
||||
- Invitation and identify verification via SMS
|
||||
- Conditional fields and formulas
|
||||
- SSO / SAML
|
||||
- Template creation with HTML API ([Guide](https://www.docuseal.co/guides/create-pdf-document-fillable-form-with-html-api))
|
||||
- Template creation with PDF or DOCX and field tags API ([Guide](https://www.docuseal.co/guides/use-embedded-text-field-tags-in-the-pdf-to-create-a-fillable-form))
|
||||
- Embedded signing form ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue) or [JavaScript](https://www.docuseal.co/docs/embedded))
|
||||
- Embedded document form builder ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue) or [JavaScript](https://www.docuseal.co/docs/embedded))
|
||||
- Embedded signing form ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue), [Angular](https://github.com/docusealco/docuseal-angular) or [JavaScript](https://www.docuseal.co/docs/embedded))
|
||||
- Embedded document form builder ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue), [Angular](https://github.com/docusealco/docuseal-angular) or [JavaScript](https://www.docuseal.co/docs/embedded))
|
||||
- [Learn more](https://www.docuseal.co/pricing)
|
||||
|
||||
## Deploy
|
||||
@@ -65,9 +66,6 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|
||||
|**DigitalOcean**|**Render**|
|
||||
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
|
||||
|**Koyeb**|**Elestio**|
|
||||
| [<img alt="Deploy on Koyeb" src="https://www.koyeb.com/static/images/deploy/button.svg" height="40">](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | [<img alt="Deploy on Elestio" src="https://pub-da36157c854648669813f3f76c526c2b.r2.dev/deploy-on-elestio-black.png">](https://dash.elest.io/deploy?soft=DocuSeal&id=339) |
|
||||
|
||||
|
||||
#### Docker
|
||||
|
||||
@@ -100,3 +98,9 @@ At DocuSeal we have expertise and technologies to make documents creation, filli
|
||||
|
||||
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information.
|
||||
Unless otherwise noted, all files © 2023 DocuSeal LLC.
|
||||
|
||||
## Tools
|
||||
|
||||
- [Signature Maker](https://www.docuseal.co/online-signature)
|
||||
- [Sign Document Online](https://www.docuseal.co/sign-documents-online)
|
||||
- [Fill PDF Online](https://www.docuseal.co/fill-pdf)
|
||||
@@ -8,7 +8,8 @@ class AccountConfigsController < ApplicationController
|
||||
AccountConfig::ALLOW_TYPED_SIGNATURE,
|
||||
AccountConfig::FORCE_MFA,
|
||||
AccountConfig::ALLOW_TO_RESUBMIT,
|
||||
AccountConfig::ESIGNING_PREFERENCE_KEY
|
||||
AccountConfig::ESIGNING_PREFERENCE_KEY,
|
||||
AccountConfig::FORM_WITH_CONFETTI_KEY
|
||||
].freeze
|
||||
|
||||
InvalidKey = Class.new(StandardError)
|
||||
|
||||
@@ -19,10 +19,14 @@ module Api
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
rescue_from RateLimit::LimitApproached do |e|
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: 'Too many requests' }, status: :too_many_requests
|
||||
end
|
||||
|
||||
if Rails.env.production?
|
||||
rescue_from CanCan::AccessDenied do |e|
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :forbidden
|
||||
end
|
||||
|
||||
@@ -39,8 +43,8 @@ module Api
|
||||
result = relation.order(id: :desc)
|
||||
.limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min)
|
||||
|
||||
result = result.where(relation.arel_table[:id].lt(params[:after])) if params[:after].present?
|
||||
result = result.where(relation.arel_table[:id].gt(params[:before])) if params[:before].present?
|
||||
result = result.where(id: ...params[:after].to_i) if params[:after].present?
|
||||
result = result.where(id: (params[:before].to_i + 1)...) if params[:before].present?
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
@@ -161,11 +161,11 @@ module Api
|
||||
|
||||
def submissions_params
|
||||
permitted_attrs = [
|
||||
:send_email, :send_sms, :bcc_completed, :completed_redirect_url,
|
||||
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to,
|
||||
{
|
||||
message: %i[subject body],
|
||||
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
|
||||
:completed, :phone, :application_key, :external_id,
|
||||
:completed, :phone, :application_key, :external_id, :reply_to,
|
||||
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
|
||||
fields: [%i[name default_value title description
|
||||
readonly validation_pattern invalid_message]] }]]
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class SubmittersAutocompleteController < ApiBaseController
|
||||
load_and_authorize_resource :submitter, parent: false
|
||||
|
||||
SELECT_COLUMNS = %w[email phone name].freeze
|
||||
LIMIT = 100
|
||||
|
||||
def index
|
||||
submitters = search_submitters(@submitters)
|
||||
|
||||
values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', '))
|
||||
|
||||
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
|
||||
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
|
||||
|
||||
render json: attrs
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_submitters(submitters)
|
||||
if SELECT_COLUMNS.include?(params[:field])
|
||||
column = Submitter.arel_table[params[:field].to_sym]
|
||||
|
||||
term = "#{params[:q].downcase}%"
|
||||
|
||||
submitters.where(column.matches(term, false, true))
|
||||
else
|
||||
Submitters.search(submitters, params[:q])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -10,6 +10,7 @@ module Api
|
||||
submitters = submitters.where(external_id: params[:application_key]) if params[:application_key].present?
|
||||
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
|
||||
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
|
||||
submitters = maybe_filder_by_completed_at(submitters, params)
|
||||
|
||||
submitters = paginate(
|
||||
submitters.preload(:template, :submission, :submission_events,
|
||||
@@ -81,6 +82,18 @@ module Api
|
||||
|
||||
private
|
||||
|
||||
def maybe_filder_by_completed_at(submitters, params)
|
||||
if params[:completed_after].present?
|
||||
submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..)
|
||||
end
|
||||
|
||||
if params[:completed_before].present?
|
||||
submitters = submitters.where(completed_at: ..Time.zone.parse(params[:completed_before]))
|
||||
end
|
||||
|
||||
submitters
|
||||
end
|
||||
|
||||
def assign_submitter_attrs(submitter, attrs)
|
||||
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
|
||||
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class TemplateFoldersAutocompleteController < ApiBaseController
|
||||
load_and_authorize_resource :template_folder, parent: false
|
||||
|
||||
LIMIT = 100
|
||||
|
||||
def index
|
||||
template_folders = @template_folders.joins(:templates).where(templates: { archived_at: nil }).distinct
|
||||
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT)
|
||||
|
||||
render json: template_folders.as_json(only: %i[name archived_at])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -49,6 +49,14 @@ module Api
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
|
||||
end
|
||||
|
||||
Array.wrap(params[:roles].presence || params.dig(:template, :roles).presence).each_with_index do |role, index|
|
||||
if (item = @template.submitters[index])
|
||||
item['name'] = role
|
||||
else
|
||||
@template.submitters << { 'name' => role, 'uuid' => SecureRandom.uuid }
|
||||
end
|
||||
end
|
||||
|
||||
archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived)
|
||||
|
||||
if archived.in?([true, false])
|
||||
@@ -83,21 +91,10 @@ module Api
|
||||
end
|
||||
|
||||
def template_params
|
||||
permit_params = [
|
||||
:name,
|
||||
{ schema: [%i[attachment_uuid name]],
|
||||
submitters: [%i[name uuid]],
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
|
||||
{ preferences: {},
|
||||
conditions: [%i[field_uuid value action]],
|
||||
options: [%i[value uuid]],
|
||||
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
|
||||
]
|
||||
|
||||
if params.key?(:template)
|
||||
params.require(:template).permit(*permit_params)
|
||||
params.require(:template).permit(:name, submitters: [%i[name uuid]])
|
||||
else
|
||||
params.permit(*permit_params)
|
||||
params.permit(:name, submitters: [%i[name uuid]])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class TemplatesDocumentsController < ApiBaseController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
|
||||
|
||||
documents = Templates::CreateAttachments.call(@template, params)
|
||||
|
||||
schema = documents.map do |doc|
|
||||
{ attachment_uuid: doc.uuid, name: doc.filename.base }
|
||||
end
|
||||
|
||||
render json: {
|
||||
schema:,
|
||||
documents: documents.as_json(
|
||||
methods: %i[metadata signed_uuid],
|
||||
include: {
|
||||
preview_images: { methods: %i[url metadata filename] }
|
||||
}
|
||||
)
|
||||
}
|
||||
rescue Templates::CreateAttachments::PdfEncrypted
|
||||
render json: { error: 'PDF encrypted' }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -22,6 +22,12 @@ class ApplicationController < ActionController::Base
|
||||
redirect_to request.path
|
||||
end
|
||||
|
||||
rescue_from RateLimit::LimitApproached do |e|
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
redirect_to request.referer, alert: 'Too many requests', status: :too_many_requests
|
||||
end
|
||||
|
||||
if Rails.env.production?
|
||||
rescue_from CanCan::AccessDenied do |e|
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
@@ -5,6 +5,10 @@ class ConsoleRedirectController < ApplicationController
|
||||
skip_authorization_check
|
||||
|
||||
def index
|
||||
if request.path == '/upgrade'
|
||||
params[:redir] = Docuseal.multitenant? ? "#{Docuseal::CONSOLE_URL}/plans" : "#{Docuseal::CONSOLE_URL}/on_premise"
|
||||
end
|
||||
|
||||
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank?
|
||||
|
||||
auth = JsonWebToken.encode(uuid: true_user.uuid,
|
||||
|
||||
@@ -22,6 +22,8 @@ class ErrorsController < ActionController::Base
|
||||
|
||||
respond_to do |f|
|
||||
f.json do
|
||||
set_cors_headers
|
||||
|
||||
render json: { status: error_status_code }, status: error_status_code
|
||||
end
|
||||
|
||||
@@ -31,6 +33,14 @@ class ErrorsController < ActionController::Base
|
||||
|
||||
private
|
||||
|
||||
def set_cors_headers
|
||||
headers['Access-Control-Allow-Origin'] = '*'
|
||||
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
|
||||
headers['Access-Control-Allow-Headers'] = '*'
|
||||
headers['Access-Control-Max-Age'] = '1728000'
|
||||
headers['Access-Control-Allow-Credentials'] = true
|
||||
end
|
||||
|
||||
def error_status_code
|
||||
@error_status_code ||=
|
||||
ActionDispatch::ExceptionWrapper.new(request.env,
|
||||
|
||||
@@ -17,7 +17,11 @@ class PersonalizationSettingsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
@account_config.save!
|
||||
if @account_config.value != false && @account_config.value.blank?
|
||||
@account_config.destroy!
|
||||
else
|
||||
@account_config.save!
|
||||
end
|
||||
|
||||
redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.')
|
||||
end
|
||||
@@ -40,6 +44,8 @@ class PersonalizationSettingsController < ApplicationController
|
||||
def account_config_params
|
||||
attrs = params.require(:account_config).permit!
|
||||
|
||||
return attrs if attrs[:value].is_a?(String)
|
||||
|
||||
attrs[:value]&.transform_values! do |value|
|
||||
if value.in?(%w[true false])
|
||||
value == 'true'
|
||||
|
||||
@@ -21,6 +21,8 @@ class SendSubmissionEmailController < ApplicationController
|
||||
Submitter.find_by!(slug: params[:submitter_slug])
|
||||
end
|
||||
|
||||
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
|
||||
|
||||
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later!
|
||||
|
||||
respond_to do |f|
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmittersAutocompleteController < ApplicationController
|
||||
load_and_authorize_resource :submitter, parent: false
|
||||
|
||||
SELECT_COLUMNS = %w[email phone name].freeze
|
||||
LIMIT = 100
|
||||
|
||||
def index
|
||||
submitters = search_submitters(@submitters)
|
||||
|
||||
values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', '))
|
||||
|
||||
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
|
||||
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
|
||||
|
||||
render json: attrs
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_submitters(submitters)
|
||||
if SELECT_COLUMNS.include?(params[:field])
|
||||
column = Submitter.arel_table[params[:field].to_sym]
|
||||
|
||||
term = "#{params[:q].downcase}%"
|
||||
|
||||
submitters.where(column.matches(term, false, true))
|
||||
else
|
||||
Submitters.search(submitters, params[:q])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplateDocumentsController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
|
||||
|
||||
documents = Templates::CreateAttachments.call(@template, params)
|
||||
|
||||
schema = documents.map do |doc|
|
||||
{ attachment_uuid: doc.uuid, name: doc.filename.base }
|
||||
end
|
||||
|
||||
render json: {
|
||||
schema:,
|
||||
documents: documents.as_json(
|
||||
methods: %i[metadata signed_uuid],
|
||||
include: {
|
||||
preview_images: { methods: %i[url metadata filename] }
|
||||
}
|
||||
)
|
||||
}
|
||||
rescue Templates::CreateAttachments::PdfEncrypted
|
||||
render json: { error: 'PDF encrypted' }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplateFoldersAutocompleteController < ApplicationController
|
||||
load_and_authorize_resource :template_folder, parent: false
|
||||
|
||||
LIMIT = 100
|
||||
|
||||
def index
|
||||
template_folders = @template_folders.joins(:templates).where(templates: { archived_at: nil }).distinct
|
||||
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT)
|
||||
|
||||
render json: template_folders.as_json(only: %i[name archived_at])
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController
|
||||
|
||||
def index
|
||||
@submissions = @submissions.where.not(archived_at: nil)
|
||||
@submissions = Submissions.search(@submissions, params[:q])
|
||||
@submissions = Submissions.search(@submissions, params[:q], search_values: true)
|
||||
|
||||
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
|
||||
@@ -8,7 +8,7 @@ class TemplatesController < ApplicationController
|
||||
def show
|
||||
submissions = @template.submissions.accessible_by(current_ability)
|
||||
submissions = submissions.active if @template.archived_at.blank?
|
||||
submissions = Submissions.search(submissions, params[:q])
|
||||
submissions = Submissions.search(submissions, params[:q], search_values: true)
|
||||
|
||||
@base_submissions = submissions
|
||||
|
||||
@@ -67,6 +67,12 @@ class TemplatesController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@template.update!(template_params)
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
def destroy
|
||||
notice =
|
||||
if params[:permanently].present?
|
||||
@@ -86,6 +92,22 @@ class TemplatesController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(
|
||||
:name,
|
||||
{ schema: [%i[attachment_uuid name]],
|
||||
submitters: [%i[name uuid]],
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type,
|
||||
:required, :readonly, :default_value,
|
||||
:title, :description,
|
||||
{ preferences: {},
|
||||
conditions: [%i[field_uuid value action]],
|
||||
options: [%i[value uuid]],
|
||||
validation: %i[message pattern],
|
||||
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
|
||||
)
|
||||
end
|
||||
|
||||
def authorized_clone_account_id?(account_id)
|
||||
true_user.account_id.to_s == account_id.to_s || true_user.account.linked_accounts.exists?(id: account_id)
|
||||
end
|
||||
@@ -98,10 +120,6 @@ class TemplatesController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(:name)
|
||||
end
|
||||
|
||||
def load_base_template
|
||||
return if params[:base_template_id].blank?
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import FolderAutocomplete from './elements/folder_autocomplete'
|
||||
import SignatureForm from './elements/signature_form'
|
||||
import SubmitForm from './elements/submit_form'
|
||||
import PromptPassword from './elements/prompt_password'
|
||||
import EmailsTextarea from './elements/emails_textarea'
|
||||
|
||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||
|
||||
@@ -53,6 +54,7 @@ window.customElements.define('folder-autocomplete', FolderAutocomplete)
|
||||
window.customElements.define('signature-form', SignatureForm)
|
||||
window.customElements.define('submit-form', SubmitForm)
|
||||
window.customElements.define('prompt-password', PromptPassword)
|
||||
window.customElements.define('emails-textarea', EmailsTextarea)
|
||||
|
||||
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
|
||||
document.addEventListener('turbo:submit-end', async (event) => {
|
||||
@@ -89,6 +91,7 @@ window.customElements.define('template-builder', class extends HTMLElement {
|
||||
withPhone: this.dataset.withPhone === 'true',
|
||||
withLogo: this.dataset.withLogo !== 'false',
|
||||
editable: this.dataset.editable !== 'false',
|
||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||
withPayment: this.dataset.withPayment === 'true',
|
||||
withFormula: this.dataset.withFormula === 'true',
|
||||
withConditions: this.dataset.withConditions === 'true',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
const emailRegexp = /([^@;,<>\s]+@[^@;,<>\s]+)/g
|
||||
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
if (this.dataset.limit) {
|
||||
this.textarea.addEventListener('input', () => {
|
||||
const emails = this.textarea.value.match(emailRegexp) || []
|
||||
|
||||
this.updateCounter(emails.length)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
updateCounter (count) {
|
||||
let counter = document.getElementById('emails_counter')
|
||||
let bulkMessage = document.getElementById('bulk_message')
|
||||
|
||||
if (count < 2) {
|
||||
counter?.remove()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if ((count + 10) > this.dataset.limit) {
|
||||
if (!counter) {
|
||||
counter = document.createElement('span')
|
||||
|
||||
counter.id = 'emails_counter'
|
||||
counter.classList.add('text-xs', 'right-0', 'absolute')
|
||||
counter.style.bottom = '-15px'
|
||||
|
||||
this.textarea.parentNode.append(counter)
|
||||
}
|
||||
|
||||
counter.innerText = `${count} / ${this.dataset.limit}`
|
||||
}
|
||||
|
||||
if (this.dataset.bulkEnabled !== 'true') {
|
||||
if (!bulkMessage) {
|
||||
bulkMessage = document.createElement('span')
|
||||
|
||||
bulkMessage.id = 'bulk_message'
|
||||
bulkMessage.classList.add('text-xs', 'left-0', 'absolute')
|
||||
bulkMessage.style.bottom = '-15px'
|
||||
|
||||
this.textarea.parentNode.append(bulkMessage)
|
||||
}
|
||||
|
||||
bulkMessage.innerHTML = '<a class="link" data-turbo="false" href="/upgrade">Upgrade</a> to bulk send multiple recipients'
|
||||
}
|
||||
}
|
||||
|
||||
get textarea () {
|
||||
return this.querySelector('textarea')
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export default class extends HTMLElement {
|
||||
fetch = (text, resolve) => {
|
||||
const queryParams = new URLSearchParams({ q: text })
|
||||
|
||||
fetch('/api/template_folders_autocomplete?' + queryParams).then(async (resp) => {
|
||||
fetch('/template_folders_autocomplete?' + queryParams).then(async (resp) => {
|
||||
const items = await resp.json()
|
||||
|
||||
resolve(items)
|
||||
|
||||
@@ -28,6 +28,8 @@ export default class extends HTMLElement {
|
||||
|
||||
if (textarea && item[field]) {
|
||||
textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ')
|
||||
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -38,7 +40,7 @@ export default class extends HTMLElement {
|
||||
if (q) {
|
||||
const queryParams = new URLSearchParams({ q, field: this.dataset.field })
|
||||
|
||||
this.currentFetch ||= fetch('/api/submitters_autocomplete?' + queryParams)
|
||||
this.currentFetch ||= fetch('/submitters_autocomplete?' + queryParams)
|
||||
|
||||
this.currentFetch.then(async (resp) => {
|
||||
const items = await resp.json()
|
||||
|
||||
@@ -17,6 +17,7 @@ window.customElements.define('submission-form', class extends HTMLElement {
|
||||
attribution: this.dataset.attribution !== 'false',
|
||||
withConfetti: this.dataset.withConfetti !== 'false',
|
||||
withTypedSignature: this.dataset.withTypedSignature !== 'false',
|
||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||
values: reactive(JSON.parse(this.dataset.values)),
|
||||
completedButton: JSON.parse(this.dataset.completedButton),
|
||||
completedRedirectUrl: this.dataset.completedRedirectUrl,
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
ref="textContainer"
|
||||
dir="auto"
|
||||
class="flex items-center px-0.5 w-full"
|
||||
:class="alignClasses[field.preferences?.align]"
|
||||
>
|
||||
<span v-if="Array.isArray(modelValue)">
|
||||
{{ modelValue.join(', ') }}
|
||||
@@ -167,6 +168,7 @@
|
||||
<span
|
||||
v-else
|
||||
class="whitespace-pre-wrap"
|
||||
:class="{ 'w-full': field.preferences?.align }"
|
||||
>{{ modelValue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,6 +255,13 @@ export default {
|
||||
phone: this.t('phone')
|
||||
}
|
||||
},
|
||||
alignClasses () {
|
||||
return {
|
||||
center: 'text-center',
|
||||
left: 'text-left',
|
||||
right: 'text-right'
|
||||
}
|
||||
},
|
||||
option () {
|
||||
return this.field.options.find((o) => o.uuid === this.area.option_uuid)
|
||||
},
|
||||
@@ -338,17 +347,19 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
modelValue () {
|
||||
if (this.field.type === 'text' && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.modelValue.length)) {
|
||||
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.modelValue}`.length)) {
|
||||
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue}`.length : 0
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.field.type === 'text' && this.$refs.textContainer) {
|
||||
this.$nextTick(() => {
|
||||
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0
|
||||
})
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer) {
|
||||
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue}`.length : 0
|
||||
}
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
optionValue (option) {
|
||||
|
||||
@@ -41,6 +41,13 @@
|
||||
:name="`values[${field.uuid}][]`"
|
||||
>
|
||||
</template>
|
||||
<div
|
||||
v-if="field.description && !modelValue.length"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<FileDropzone
|
||||
:message="`${t('upload')} ${field.name || t('files')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
:submitter-slug="submitterSlug"
|
||||
@@ -53,12 +60,14 @@
|
||||
|
||||
<script>
|
||||
import FileDropzone from './dropzone'
|
||||
import MarkdownContent from './markdown_content'
|
||||
import { IconPaperclip, IconTrashX } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'AttachmentStep',
|
||||
components: {
|
||||
FileDropzone,
|
||||
MarkdownContent,
|
||||
IconPaperclip,
|
||||
IconTrashX
|
||||
},
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
<div class="space-y-3 mt-5">
|
||||
<a
|
||||
v-if="completedButton.url"
|
||||
:href="completedButton.url"
|
||||
:href="sanitizeHref(completedButton.url)"
|
||||
rel="noopener noreferrer nofollow"
|
||||
class="white-button flex items-center w-full"
|
||||
>
|
||||
<span>
|
||||
@@ -215,6 +216,11 @@ export default {
|
||||
|
||||
this.isDownloading = false
|
||||
},
|
||||
sanitizeHref (href) {
|
||||
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
|
||||
return href.replace(/javascript:/g, '')
|
||||
}
|
||||
},
|
||||
downloadSafariIos (urls) {
|
||||
const fileRequests = urls.map((url) => {
|
||||
return fetch(url).then(async (resp) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
function cropCanvasAndExportToPNG (canvas) {
|
||||
function cropCanvasAndExportToPNG (canvas, { errorOnTooSmall } = { errorOnTooSmall: false }) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const width = canvas.width
|
||||
@@ -33,6 +33,10 @@ function cropCanvasAndExportToPNG (canvas) {
|
||||
croppedCanvas.height = croppedHeight
|
||||
const croppedCtx = croppedCanvas.getContext('2d')
|
||||
|
||||
if (errorOnTooSmall && (croppedWidth < 20 || croppedHeight < 20)) {
|
||||
return Promise.reject(new Error('Image too small'))
|
||||
}
|
||||
|
||||
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
<template>
|
||||
<div dir="auto">
|
||||
<div class="flex justify-between items-center w-full mb-2">
|
||||
<div
|
||||
class="flex justify-between items-center w-full"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
>
|
||||
<label
|
||||
:for="field.uuid"
|
||||
class="label text-2xl"
|
||||
>{{ field.name && showFieldNames ? field.name : t('date') }}
|
||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||
>
|
||||
<MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ field.name && showFieldNames ? field.name : t('date') }}
|
||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||
</template>
|
||||
</label>
|
||||
<button
|
||||
class="btn btn-outline btn-sm !normal-case font-normal"
|
||||
@@ -15,6 +25,13 @@
|
||||
{{ t('set_today') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="field.description"
|
||||
class="mb-3 px-1"
|
||||
dir="auto"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<AppearsOn :field="field" />
|
||||
<div class="text-center">
|
||||
<input
|
||||
@@ -33,11 +50,13 @@
|
||||
<script>
|
||||
import { IconCalendarCheck } from '@tabler/icons-vue'
|
||||
import AppearsOn from './appears_on'
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
export default {
|
||||
name: 'DateStep',
|
||||
components: {
|
||||
IconCalendarCheck,
|
||||
MarkdownContent,
|
||||
AppearsOn
|
||||
},
|
||||
inject: ['t'],
|
||||
|
||||
@@ -101,17 +101,32 @@
|
||||
/>
|
||||
<div v-else-if="currentField.type === 'select'">
|
||||
<label
|
||||
v-if="showFieldNames && currentField.name"
|
||||
v-if="showFieldNames && (currentField.name || currentField.title)"
|
||||
:for="currentField.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl mb-2"
|
||||
>{{ currentField.name }}
|
||||
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
||||
class="label text-2xl"
|
||||
:class="{ 'mb-2': !currentField.description }"
|
||||
>
|
||||
<MarkdownContent
|
||||
v-if="currentField.title"
|
||||
:string="currentField.title"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ currentField.name }}
|
||||
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
||||
</template>
|
||||
</label>
|
||||
<div
|
||||
v-else
|
||||
class="py-1"
|
||||
/>
|
||||
<div
|
||||
v-if="currentField.description"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="currentField.description" />
|
||||
</div>
|
||||
<AppearsOn :field="currentField" />
|
||||
<select
|
||||
:id="currentField.uuid"
|
||||
@@ -140,13 +155,28 @@
|
||||
</div>
|
||||
<div v-else-if="currentField.type === 'radio'">
|
||||
<label
|
||||
v-if="showFieldNames && currentField.name"
|
||||
v-if="showFieldNames && (currentField.name || currentField.title)"
|
||||
:for="currentField.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl mb-2"
|
||||
>{{ currentField.name }}
|
||||
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
||||
class="label text-2xl"
|
||||
:class="{ 'mb-2': !currentField.description }"
|
||||
>
|
||||
<MarkdownContent
|
||||
v-if="currentField.title"
|
||||
:string="currentField.title"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ currentField.name }}
|
||||
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
||||
</template>
|
||||
</label>
|
||||
<div
|
||||
v-if="currentField.description"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="currentField.description" />
|
||||
</div>
|
||||
<div class="flex w-full max-h-44 overflow-y-auto">
|
||||
<div
|
||||
v-if="!showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4)"
|
||||
@@ -197,66 +227,77 @@
|
||||
/>
|
||||
<div
|
||||
v-else-if="currentField.type === 'checkbox'"
|
||||
class="flex w-full max-h-44 overflow-y-auto"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="cast_boolean"
|
||||
value="true"
|
||||
>
|
||||
<div
|
||||
class="space-y-3.5 mx-auto"
|
||||
v-if="currentField.description"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<template v-if="isAnonymousChecboxes || !showFieldNames">
|
||||
<span class="text-xl">
|
||||
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
|
||||
</span>
|
||||
<input
|
||||
v-for="field in currentStepFields"
|
||||
:key="field.uuid"
|
||||
type="hidden"
|
||||
:name="`values[${field.uuid}]`"
|
||||
:value="!!values[field.uuid]"
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(field, index) in currentStepFields"
|
||||
:key="field.uuid"
|
||||
>
|
||||
<label
|
||||
:for="field.uuid"
|
||||
class="flex items-center space-x-3"
|
||||
<MarkdownContent :string="currentField.description" />
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full max-h-44 overflow-y-auto"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="cast_boolean"
|
||||
value="true"
|
||||
>
|
||||
<div
|
||||
class="space-y-3.5 mx-auto"
|
||||
>
|
||||
<template v-if="isAnonymousChecboxes || !showFieldNames">
|
||||
<span class="text-xl">
|
||||
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
|
||||
</span>
|
||||
<input
|
||||
v-for="field in currentStepFields"
|
||||
:key="field.uuid"
|
||||
type="hidden"
|
||||
:name="`values[${field.uuid}]`"
|
||||
:value="!!values[field.uuid]"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
:name="`values[${field.uuid}]`"
|
||||
:value="!!values[field.uuid]"
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(field, index) in currentStepFields"
|
||||
:key="field.uuid"
|
||||
>
|
||||
<label
|
||||
:for="field.uuid"
|
||||
class="flex items-center space-x-3"
|
||||
>
|
||||
<input
|
||||
:id="field.uuid"
|
||||
type="checkbox"
|
||||
class="base-checkbox !h-7 !w-7"
|
||||
:oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`"
|
||||
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
|
||||
:required="field.required"
|
||||
:checked="!!values[field.uuid]"
|
||||
@click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
|
||||
>
|
||||
<span
|
||||
v-if="field.title"
|
||||
class="text-xl"
|
||||
v-html="field.title"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="text-xl"
|
||||
>
|
||||
{{ field.name || field.type + ' ' + (index + 1) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<input
|
||||
type="hidden"
|
||||
:name="`values[${field.uuid}]`"
|
||||
:value="!!values[field.uuid]"
|
||||
>
|
||||
<input
|
||||
:id="field.uuid"
|
||||
type="checkbox"
|
||||
class="base-checkbox !h-7 !w-7"
|
||||
:oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`"
|
||||
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
|
||||
:required="field.required"
|
||||
:checked="!!values[field.uuid]"
|
||||
@click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
|
||||
>
|
||||
<span
|
||||
v-if="field.title"
|
||||
class="text-xl"
|
||||
>
|
||||
<MarkdownContent :string="field.title" />
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-xl"
|
||||
>
|
||||
{{ field.name || field.type + ' ' + (index + 1) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ImageStep
|
||||
@@ -418,6 +459,7 @@ import PaymentStep from './payment_step'
|
||||
import TextStep from './text_step'
|
||||
import NumberStep from './number_step'
|
||||
import DateStep from './date_step'
|
||||
import MarkdownContent from './markdown_content'
|
||||
import FormCompleted from './completed'
|
||||
import { IconInnerShadowTop, IconArrowsDiagonal, IconWritingSign, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||
import AppearsOn from './appears_on'
|
||||
@@ -459,6 +501,7 @@ export default {
|
||||
NumberStep,
|
||||
FormulaFieldAreas,
|
||||
PhoneStep,
|
||||
MarkdownContent,
|
||||
PaymentStep,
|
||||
IconArrowsDiagonalMinimize2,
|
||||
FormCompleted
|
||||
@@ -568,6 +611,11 @@ export default {
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
authenticityToken: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
i18n: {
|
||||
type: Object,
|
||||
required: false,
|
||||
@@ -615,9 +663,6 @@ export default {
|
||||
queryParams () {
|
||||
return new URLSearchParams(window.location.search)
|
||||
},
|
||||
authenticityToken () {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content
|
||||
},
|
||||
submitterSlug () {
|
||||
return this.submitter.slug
|
||||
},
|
||||
@@ -654,7 +699,7 @@ export default {
|
||||
const prevStep = acc[acc.length - 1]
|
||||
|
||||
if (this.checkFieldConditions(f)) {
|
||||
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox') {
|
||||
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox' && !f.description) {
|
||||
prevStep.push(f)
|
||||
} else {
|
||||
acc.push([f])
|
||||
@@ -957,7 +1002,11 @@ export default {
|
||||
this.isSubmitting = false
|
||||
})
|
||||
}).catch(error => {
|
||||
console.log(error)
|
||||
if (error?.message === 'Image too small') {
|
||||
alert('Signature is too small - please redraw.')
|
||||
} else {
|
||||
console.log(error)
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isSubmitting = false
|
||||
})
|
||||
|
||||
@@ -24,9 +24,17 @@
|
||||
:name="`values[${field.uuid}]`"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-if="!modelValue"
|
||||
>
|
||||
<div
|
||||
v-if="field.description"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<FileDropzone
|
||||
v-if="!modelValue"
|
||||
:message="`${t('upload')} ${field.name || t('image')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
:submitter-slug="submitterSlug"
|
||||
:accept="'image/*'"
|
||||
@@ -39,12 +47,14 @@
|
||||
<script>
|
||||
import FileDropzone from './dropzone'
|
||||
import { IconReload } from '@tabler/icons-vue'
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
export default {
|
||||
name: 'ImageStep',
|
||||
components: {
|
||||
FileDropzone,
|
||||
IconReload
|
||||
IconReload,
|
||||
MarkdownContent
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
<template>
|
||||
<div dir="auto">
|
||||
<div class="flex justify-between items-center w-full mb-2">
|
||||
<div
|
||||
class="flex justify-between items-center w-full"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
>
|
||||
<label
|
||||
class="label text-2xl"
|
||||
>{{ showFieldNames && field.name ? field.name : t('initials') }}</label>
|
||||
>
|
||||
<MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ showFieldNames && field.name ? field.name : t('initials') }}
|
||||
</template>
|
||||
</label>
|
||||
<div class="space-x-2 flex">
|
||||
<span
|
||||
v-if="isDrawInitials"
|
||||
@@ -70,6 +81,13 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="field.description"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<AppearsOn :field="field" />
|
||||
<input
|
||||
:value="modelValue || computedPreviousValue"
|
||||
@@ -105,6 +123,7 @@ import { cropCanvasAndExportToPNG } from './crop_canvas'
|
||||
import { IconReload, IconTextSize, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||
import SignaturePad from 'signature_pad'
|
||||
import AppearsOn from './appears_on'
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
const scale = 3
|
||||
|
||||
@@ -115,6 +134,7 @@ export default {
|
||||
IconReload,
|
||||
IconTextSize,
|
||||
IconSignature,
|
||||
MarkdownContent,
|
||||
IconArrowsDiagonalMinimize2
|
||||
},
|
||||
inject: ['baseUrl', 't'],
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<span>
|
||||
<template
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
>
|
||||
<a
|
||||
v-if="item.startsWith('<a') && item.endsWith('</a>')"
|
||||
:href="sanitizeHref(extractAttr(item, 'href'))"
|
||||
rel="noopener noreferrer nofollow"
|
||||
:class="extractAttr(item, 'class') || 'link'"
|
||||
target="_blank"
|
||||
>
|
||||
{{ extractText(item) }}
|
||||
</a>
|
||||
<b
|
||||
v-else-if="item.startsWith('<b>') || item.startsWith('<strong>')"
|
||||
>
|
||||
{{ extractText(item) }}
|
||||
</b>
|
||||
<i
|
||||
v-else-if="item.startsWith('<i>') || item.startsWith('<em>')"
|
||||
>
|
||||
{{ extractText(item) }}
|
||||
</i>
|
||||
<br
|
||||
v-else-if="item === '<br>' || item === '\n'"
|
||||
>
|
||||
<template
|
||||
v-else
|
||||
>
|
||||
{{ item }}
|
||||
</template>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import snarkdown from 'snarkdown'
|
||||
|
||||
const htmlSplitRegexp = /(<a.+?<\/a>|<i>.+?<\/i>|<b>.+?<\/b>|<em>.+?<\/em>|<strong>.+?<\/strong>|<br>)/
|
||||
|
||||
export default {
|
||||
name: 'MarkdownContent',
|
||||
props: {
|
||||
string: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
items () {
|
||||
return snarkdown(this.string.replace(/\n/g, '<br>')).split(htmlSplitRegexp)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
sanitizeHref (href) {
|
||||
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
|
||||
return href.replace(/javascript:/g, '')
|
||||
}
|
||||
},
|
||||
extractAttr (text, attr) {
|
||||
if (text.includes(attr)) {
|
||||
return text.split(attr).pop().split('"')[1]
|
||||
}
|
||||
},
|
||||
extractText (text) {
|
||||
if (text) {
|
||||
return text.match(/>(.+?)</)?.[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,10 +1,23 @@
|
||||
<template>
|
||||
<label
|
||||
v-if="showFieldNames && field.name"
|
||||
v-if="showFieldNames && (field.name || field.title)"
|
||||
:for="field.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl mb-2"
|
||||
>{{ field.name }}</label>
|
||||
class="label text-2xl"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
><MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>{{ field.name }}</template>
|
||||
</label>
|
||||
<div
|
||||
v-if="field.description"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<div class="flex w-full max-h-44 overflow-y-auto">
|
||||
<input
|
||||
v-if="modelValue.length === 0"
|
||||
@@ -53,8 +66,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
export default {
|
||||
name: 'MultiSelectStep',
|
||||
components: {
|
||||
MarkdownContent
|
||||
},
|
||||
inject: ['t', 'scrollIntoField'],
|
||||
props: {
|
||||
field: {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<template>
|
||||
<label
|
||||
v-if="showFieldNames && field.name"
|
||||
v-if="showFieldNames && (field.name || field.title)"
|
||||
:for="field.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
><template v-if="field.title"><span v-html="field.title" /></template>
|
||||
><MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>{{ field.name }}</template>
|
||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||
</label>
|
||||
@@ -15,9 +18,11 @@
|
||||
/>
|
||||
<div
|
||||
v-if="field.description"
|
||||
class="mb-3 px-1 text-lg"
|
||||
v-html="field.description"
|
||||
/>
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<AppearsOn :field="field" />
|
||||
<div class="items-center flex">
|
||||
<input
|
||||
@@ -41,11 +46,13 @@
|
||||
|
||||
<script>
|
||||
import AppearsOn from './appears_on'
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
export default {
|
||||
name: 'TextStep',
|
||||
components: {
|
||||
AppearsOn
|
||||
AppearsOn,
|
||||
MarkdownContent
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
|
||||
@@ -2,10 +2,25 @@
|
||||
<div>
|
||||
<label
|
||||
:for="isCodeSent ? 'one_time_code' : field.uuid"
|
||||
class="label text-2xl mb-2"
|
||||
>{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }}
|
||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||
class="label text-2xl"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
>
|
||||
<MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }}
|
||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||
</template>
|
||||
</label>
|
||||
<div
|
||||
v-if="field.description"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="hidden"
|
||||
@@ -53,14 +68,13 @@
|
||||
class="base-input !text-2xl w-full"
|
||||
autocomplete="tel"
|
||||
pattern="^\+[0-9\s\-]+$"
|
||||
:oninvalid="`this.value ? this.setCustomValidity('${t('use_international_format')}...') : ''`"
|
||||
oninput="this.setCustomValidity('')"
|
||||
type="tel"
|
||||
inputmode="tel"
|
||||
:required="field.required"
|
||||
placeholder="+1 234 567-8900"
|
||||
:name="`values[${field.uuid}]`"
|
||||
@input="$emit('update:model-value', $event.target.value)"
|
||||
@invalid="$event.target.value ? $event.target.setCustomValidity(`${t('use_international_format')}...`) : ''"
|
||||
@input="[$event.target.setCustomValidity(''), $emit('update:model-value', $event.target.value)]"
|
||||
@focus="$emit('focus')"
|
||||
>
|
||||
</div>
|
||||
@@ -68,6 +82,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
function throttle (func, delay) {
|
||||
let lastCallTime = 0
|
||||
|
||||
@@ -83,6 +99,9 @@ function throttle (func, delay) {
|
||||
|
||||
export default {
|
||||
name: 'PhoneStep',
|
||||
components: {
|
||||
MarkdownContent
|
||||
},
|
||||
inject: ['t', 'baseUrl'],
|
||||
props: {
|
||||
field: {
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
<template>
|
||||
<div dir="auto">
|
||||
<div class="flex justify-between items-center w-full mb-2">
|
||||
<div
|
||||
class="flex justify-between items-center w-full"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
>
|
||||
<label
|
||||
class="label text-2xl"
|
||||
>{{ showFieldNames && field.name ? field.name : t('signature') }}</label>
|
||||
>
|
||||
<MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ showFieldNames && field.name ? field.name : t('signature') }}
|
||||
</template>
|
||||
</label>
|
||||
<div class="space-x-2 flex">
|
||||
<span
|
||||
v-if="isTextSignature && field.preferences?.format !== 'typed'"
|
||||
@@ -30,7 +41,7 @@
|
||||
<a
|
||||
id="type_text_button"
|
||||
href="#"
|
||||
class="btn btn-outline btn-sm font-medium"
|
||||
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
|
||||
@click.prevent="toggleTextInput"
|
||||
>
|
||||
<IconTextSize :width="16" />
|
||||
@@ -45,7 +56,7 @@
|
||||
:data-tip="t('take_photo')"
|
||||
>
|
||||
<label
|
||||
class="btn btn-outline btn-sm font-medium"
|
||||
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
|
||||
>
|
||||
<IconCamera :width="16" />
|
||||
<input
|
||||
@@ -91,6 +102,13 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="field.description"
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<AppearsOn :field="field" />
|
||||
<input
|
||||
:value="modelValue || computedPreviousValue"
|
||||
@@ -126,6 +144,7 @@ import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonal
|
||||
import { cropCanvasAndExportToPNG } from './crop_canvas'
|
||||
import SignaturePad from 'signature_pad'
|
||||
import AppearsOn from './appears_on'
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
let isFontLoaded = false
|
||||
|
||||
@@ -137,6 +156,7 @@ export default {
|
||||
AppearsOn,
|
||||
IconReload,
|
||||
IconCamera,
|
||||
MarkdownContent,
|
||||
IconTextSize,
|
||||
IconSignature,
|
||||
IconArrowsDiagonalMinimize2
|
||||
@@ -366,8 +386,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], 'signature.png', { type: 'image/png' })
|
||||
|
||||
if (this.isDirectUpload) {
|
||||
@@ -409,6 +429,8 @@ export default {
|
||||
return resolve(attachment)
|
||||
})
|
||||
}
|
||||
}).catch((error) => {
|
||||
return reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<template>
|
||||
<label
|
||||
v-if="showFieldNames && field.name"
|
||||
v-if="showFieldNames && (field.name || field.title)"
|
||||
:for="field.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
><template v-if="field.title"><span v-html="field.title" /></template>
|
||||
><MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>{{ field.name }}</template>
|
||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||
</label>
|
||||
@@ -15,9 +18,11 @@
|
||||
/>
|
||||
<div
|
||||
v-if="field.description"
|
||||
class="mb-3 px-1 text-lg"
|
||||
v-html="field.description"
|
||||
/>
|
||||
dir="auto"
|
||||
class="mb-3 px-1"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<AppearsOn :field="field" />
|
||||
<div class="items-center flex">
|
||||
<input
|
||||
@@ -30,11 +35,11 @@
|
||||
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
|
||||
:required="field.required"
|
||||
:pattern="field.validation?.pattern"
|
||||
:oninvalid="field.validation?.message ? `this.setCustomValidity(${JSON.stringify(field.validation.message)})` : ''"
|
||||
:oninput="field.validation?.message ? `this.setCustomValidity('')` : ''"
|
||||
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
type="text"
|
||||
:name="`values[${field.uuid}]`"
|
||||
@invalid="field.validation?.message ? $event.target.setCustomValidity(field.validation.message) : ''"
|
||||
@input="field.validation?.message ? $event.target.setCustomValidity('') : ''"
|
||||
@focus="$emit('focus')"
|
||||
>
|
||||
<textarea
|
||||
@@ -69,11 +74,13 @@
|
||||
<script>
|
||||
import { IconAlignBoxLeftTop } from '@tabler/icons-vue'
|
||||
import AppearsOn from './appears_on'
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
export default {
|
||||
name: 'TextStep',
|
||||
components: {
|
||||
IconAlignBoxLeftTop,
|
||||
MarkdownContent,
|
||||
AppearsOn
|
||||
},
|
||||
inject: ['t'],
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
v-model="field.submitter_uuid"
|
||||
class="border-r"
|
||||
:compact="true"
|
||||
:editable="editable && !defaultField"
|
||||
:editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
|
||||
:allow-add-new="!defaultSubmitters.length"
|
||||
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
|
||||
:submitters="template.submitters"
|
||||
@@ -99,7 +99,7 @@
|
||||
<div
|
||||
class="flex items-center h-full w-full"
|
||||
dir="auto"
|
||||
:class="[bgColors[submitterIndex], field?.default_value ? '' : 'justify-center']"
|
||||
:class="[bgColors[submitterIndex], field?.default_value ? (alignClasses[field.preferences?.align] || '') : 'justify-center']"
|
||||
>
|
||||
<span
|
||||
v-if="field"
|
||||
@@ -211,6 +211,13 @@ export default {
|
||||
defaultName () {
|
||||
return this.buildDefaultName(this.field, this.template.fields)
|
||||
},
|
||||
alignClasses () {
|
||||
return {
|
||||
center: 'justify-center',
|
||||
left: 'justify-start',
|
||||
right: 'justify-end'
|
||||
}
|
||||
},
|
||||
optionIndexText () {
|
||||
if (this.area.option_uuid && this.field.options) {
|
||||
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
|
||||
@@ -301,17 +308,19 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
'field.default_value' () {
|
||||
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value.length)) {
|
||||
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
|
||||
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value)) {
|
||||
this.$nextTick(() => {
|
||||
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0
|
||||
})
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
|
||||
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
|
||||
}
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
buildDefaultName: Field.methods.buildDefaultName,
|
||||
@@ -343,7 +352,7 @@ export default {
|
||||
},
|
||||
onResizeCell (e) {
|
||||
if (e.target.id === 'mask') {
|
||||
const positionX = e.layerX / (e.target.clientWidth - 1)
|
||||
const positionX = e.offsetX / (e.target.clientWidth - 1)
|
||||
|
||||
if (positionX > this.area.x) {
|
||||
this.area.cell_w = positionX - this.area.x
|
||||
@@ -389,16 +398,16 @@ export default {
|
||||
},
|
||||
resize (e) {
|
||||
if (e.target.id === 'mask') {
|
||||
this.area.w = e.layerX / e.target.clientWidth - this.area.x
|
||||
this.area.h = e.layerY / e.target.clientHeight - this.area.y
|
||||
this.area.w = e.offsetX / e.target.clientWidth - this.area.x
|
||||
this.area.h = e.offsetY / e.target.clientHeight - this.area.y
|
||||
}
|
||||
},
|
||||
drag (e) {
|
||||
if (e.target.id === 'mask') {
|
||||
this.isDragged = true
|
||||
|
||||
this.area.x = (e.layerX - this.dragFrom.x) / e.target.clientWidth
|
||||
this.area.y = (e.layerY - this.dragFrom.y) / e.target.clientHeight
|
||||
this.area.x = (e.offsetX - this.dragFrom.x) / e.target.clientWidth
|
||||
this.area.y = (e.offsetY - this.dragFrom.y) / e.target.clientHeight
|
||||
}
|
||||
},
|
||||
startDrag (e) {
|
||||
|
||||
@@ -94,11 +94,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="main_container"
|
||||
class="flex"
|
||||
:class="$slots.buttons || withTitle ? 'md:max-h-[calc(100%_-_60px)]' : 'md:max-h-[100%]'"
|
||||
>
|
||||
<div
|
||||
v-if="withDocumentsList"
|
||||
id="documents_container"
|
||||
ref="previews"
|
||||
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
|
||||
class="overflow-y-auto overflow-x-hidden w-52 flex-none pr-3 mt-0.5 pt-0.5 hidden lg:block"
|
||||
@@ -134,7 +136,10 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
|
||||
<div
|
||||
id="pages_container"
|
||||
class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5"
|
||||
>
|
||||
<div
|
||||
ref="documents"
|
||||
class="pr-3.5 pl-0.5"
|
||||
@@ -200,6 +205,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="withFieldsList"
|
||||
id="fields_list_container"
|
||||
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
|
||||
:class="drawField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'"
|
||||
>
|
||||
@@ -438,6 +444,11 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
authenticityToken: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
withDocumentsList: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -523,7 +534,7 @@ export default {
|
||||
submitter.name = name
|
||||
|
||||
if (existingSubmittersUuids.filter(Boolean).length) {
|
||||
submitter.uuid = existingSubmittersUuids[index] || v4()
|
||||
submitter.uuid = existingSubmittersUuids[index] || submitter.uuid || v4()
|
||||
} else {
|
||||
submitter.uuid ||= v4()
|
||||
}
|
||||
@@ -1017,7 +1028,11 @@ export default {
|
||||
baseFetch (path, options = {}) {
|
||||
return fetch(this.baseUrl + path, {
|
||||
...options,
|
||||
headers: { ...this.fetchOptions.headers, ...options.headers }
|
||||
headers: {
|
||||
'X-CSRF-Token': this.authenticityToken,
|
||||
...this.fetchOptions.headers,
|
||||
...options.headers
|
||||
}
|
||||
})
|
||||
},
|
||||
save ({ force } = { force: false }) {
|
||||
@@ -1033,7 +1048,7 @@ export default {
|
||||
|
||||
this.pushUndo()
|
||||
|
||||
return this.baseFetch(`/api/templates/${this.template.id}`, {
|
||||
return this.baseFetch(`/templates/${this.template.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
template: {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div
|
||||
class="modal modal-open items-start !animate-none overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<span>
|
||||
{{ field.name || buildDefaultName(field, template.fields) }}
|
||||
</span>
|
||||
<a
|
||||
href="#"
|
||||
class="text-xl"
|
||||
@click.prevent="$emit('close')"
|
||||
>×</a>
|
||||
</div>
|
||||
<div>
|
||||
<form @submit.prevent="saveAndClose">
|
||||
<div class="space-y-1 mb-1">
|
||||
<div>
|
||||
<label
|
||||
dir="auto"
|
||||
class="label text-sm"
|
||||
for="description_field"
|
||||
>
|
||||
{{ t('description') }}
|
||||
</label>
|
||||
<textarea
|
||||
id="description_field"
|
||||
ref="textarea"
|
||||
v-model="description"
|
||||
dir="auto"
|
||||
class="base-textarea !text-base w-full"
|
||||
@input="resizeTextarea"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
dir="auto"
|
||||
class="label text-sm"
|
||||
for="title_field"
|
||||
>
|
||||
{{ t('display_title') }} ({{ t('optional') }})
|
||||
</label>
|
||||
<input
|
||||
id="title_field"
|
||||
v-model="title"
|
||||
dir="auto"
|
||||
class="base-input !text-base w-full"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="base-button w-full mt-4"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DescriptionModal',
|
||||
inject: ['t', 'save', 'template'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
buildDefaultName: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['close'],
|
||||
data () {
|
||||
return {
|
||||
description: this.field.description,
|
||||
title: this.field.title
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.resizeTextarea()
|
||||
},
|
||||
methods: {
|
||||
saveAndClose () {
|
||||
this.field.description = this.description
|
||||
this.field.title = this.title
|
||||
|
||||
this.save()
|
||||
this.$emit('close')
|
||||
},
|
||||
resizeTextarea () {
|
||||
const textarea = this.$refs.textarea
|
||||
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = textarea.scrollHeight + 'px'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="group pb-2"
|
||||
class="list-field group mb-2"
|
||||
>
|
||||
<div
|
||||
class="border border-base-300 rounded rounded-tr-none relative group"
|
||||
@@ -116,15 +116,41 @@
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<div
|
||||
v-if="field.type === 'text' && !defaultField"
|
||||
v-if="['number'].includes(field.type)"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<select
|
||||
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal"
|
||||
@change="[field.preferences ||= {}, field.preferences.align = $event.target.value, save()]"
|
||||
>
|
||||
<option
|
||||
v-for="value in ['left', 'right', 'center']"
|
||||
:key="value"
|
||||
:selected="field.preferences?.align ? value === field.preferences.align : value === 'left'"
|
||||
:value="value"
|
||||
>
|
||||
{{ t(value) }}
|
||||
</option>
|
||||
</select>
|
||||
<label
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>
|
||||
{{ t('align') }}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="['text', 'number'].includes(field.type) && !defaultField"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
v-model="field.default_value"
|
||||
type="text"
|
||||
:placeholder="t('default_value')"
|
||||
dir="auto"
|
||||
:type="field.type"
|
||||
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
|
||||
@blur="save"
|
||||
>
|
||||
@@ -202,7 +228,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<li
|
||||
v-if="field.type != 'phone'"
|
||||
v-if="field.type != 'phone' && field.type != 'stamp'"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
@@ -215,6 +241,20 @@
|
||||
<span class="label-text">{{ t('required') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type == 'stamp'"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
<input
|
||||
:checked="field.preferences?.with_logo != false"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
@change="[field.preferences ||= {}, field.preferences.with_logo = field.preferences.with_logo == false, save()]"
|
||||
>
|
||||
<span class="label-text">{{ t('with_logo') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type == 'checkbox'"
|
||||
@click.stop
|
||||
@@ -244,7 +284,7 @@
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type === 'text' && !defaultField"
|
||||
v-if="['text', 'number'].includes(field.type) && !defaultField"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
@@ -257,8 +297,28 @@
|
||||
<span class="label-text">{{ t('read_only') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<hr class="pb-0.5 mt-0.5">
|
||||
<li>
|
||||
<hr
|
||||
v-if="field.type != 'stamp'"
|
||||
class="pb-0.5 mt-0.5"
|
||||
>
|
||||
<li
|
||||
v-if="field.type != 'stamp'"
|
||||
>
|
||||
<label
|
||||
class="label-text cursor-pointer text-center w-full flex items-center"
|
||||
@click="isShowDescriptionModal = !isShowDescriptionModal"
|
||||
>
|
||||
<IconInfoCircle
|
||||
width="18"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{{ t('description') }}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type != 'stamp'"
|
||||
>
|
||||
<label
|
||||
class="label-text cursor-pointer text-center w-full flex items-center"
|
||||
@click="isShowConditionsModal = !isShowConditionsModal"
|
||||
@@ -286,7 +346,7 @@
|
||||
</li>
|
||||
<hr class="pb-0.5 mt-0.5">
|
||||
<li
|
||||
v-for="(area, index) in field.areas || []"
|
||||
v-for="(area, index) in sortedAreas"
|
||||
:key="index"
|
||||
>
|
||||
<a
|
||||
@@ -435,6 +495,16 @@
|
||||
@close="isShowConditionsModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowDescriptionModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<DescriptionModal
|
||||
:field="field"
|
||||
:build-default-name="buildDefaultName"
|
||||
@close="isShowDescriptionModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -444,7 +514,8 @@ import FieldType from './field_type'
|
||||
import PaymentSettings from './payment_settings'
|
||||
import FormulaModal from './formula_modal'
|
||||
import ConditionsModal from './conditions_modal'
|
||||
import { IconRouteAltLeft, IconMathFunction, IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
|
||||
import DescriptionModal from './description_modal'
|
||||
import { IconInfoCircle, IconRouteAltLeft, IconMathFunction, IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
export default {
|
||||
@@ -455,7 +526,9 @@ export default {
|
||||
IconShape,
|
||||
PaymentSettings,
|
||||
IconNewSection,
|
||||
IconInfoCircle,
|
||||
FormulaModal,
|
||||
DescriptionModal,
|
||||
ConditionsModal,
|
||||
IconRouteAltLeft,
|
||||
IconTrashX,
|
||||
@@ -487,11 +560,24 @@ export default {
|
||||
showPaymentModal: false,
|
||||
isShowFormulaModal: false,
|
||||
isShowConditionsModal: false,
|
||||
isShowDescriptionModal: false,
|
||||
renderDropdown: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
schemaAttachmentsIndexes () {
|
||||
return (this.template.schema || []).reduce((acc, item, index) => {
|
||||
acc[item.attachment_uuid] = index
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
sortedAreas () {
|
||||
return (this.field.areas || []).sort((a, b) => {
|
||||
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
|
||||
})
|
||||
},
|
||||
modalContainerEl () {
|
||||
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
|
||||
},
|
||||
@@ -607,7 +693,7 @@ export default {
|
||||
}
|
||||
},
|
||||
scrollToFirstArea () {
|
||||
return this.field.areas?.[0] && this.$emit('scroll-to', this.field.areas[0])
|
||||
return this.sortedAreas[0] && this.$emit('scroll-to', this.sortedAreas[0])
|
||||
},
|
||||
closeDropdown () {
|
||||
document.activeElement.blur()
|
||||
@@ -625,7 +711,12 @@ export default {
|
||||
},
|
||||
removeOption (option) {
|
||||
this.field.options.splice(this.field.options.indexOf(option), 1)
|
||||
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
|
||||
|
||||
const optionIndex = this.field.areas.findIndex((a) => a.option_uuid === option.uuid)
|
||||
|
||||
if (optionIndex !== -1) {
|
||||
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
|
||||
}
|
||||
|
||||
this.save()
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div :class="withStickySubmitters ? 'sticky top-0 z-10' : ''">
|
||||
<FieldSubmitter
|
||||
:model-value="selectedSubmitter.uuid"
|
||||
class="w-full rounded-lg"
|
||||
class="roles-dropdown w-full rounded-lg"
|
||||
:class="{ 'bg-base-100': withStickySubmitters }"
|
||||
:submitters="submitters"
|
||||
:editable="editable && !defaultSubmitters.length"
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
<div
|
||||
ref="fields"
|
||||
class="mb-1 mt-2"
|
||||
class="fields mb-1 mt-2"
|
||||
@dragover.prevent="onFieldDragover"
|
||||
@drop="reorderFields"
|
||||
>
|
||||
@@ -36,32 +36,63 @@
|
||||
</div>
|
||||
<div v-if="submitterDefaultFields.length">
|
||||
<hr class="mb-2">
|
||||
<template
|
||||
v-for="field in submitterDefaultFields"
|
||||
:key="field.name"
|
||||
<template v-if="isShowFieldSearch">
|
||||
<input
|
||||
v-model="defaultFieldsSearch"
|
||||
:placeholder="t('search_field')"
|
||||
class="input input-ghost input-xs px-0 text-base mb-2 !outline-0 !rounded bg-transparent w-full"
|
||||
>
|
||||
<hr class="mb-2">
|
||||
</template>
|
||||
<div
|
||||
class="overflow-auto relative"
|
||||
:style="{
|
||||
maxHeight: isShowFieldSearch ? '210px' : '',
|
||||
minHeight: isShowFieldSearch ? '210px' : ''
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
draggable="true"
|
||||
class="border border-base-300 rounded rounded-tr-none relative group mb-2"
|
||||
@dragstart="onDragstart({ type: 'text', ...field })"
|
||||
@dragend="$emit('drag-end')"
|
||||
v-if="!filteredSubmitterDefaultFields.length && defaultFieldsSearch"
|
||||
class="top-0 bottom-0 text-center absolute flex items-center justify-center w-full flex-col"
|
||||
>
|
||||
<div class="flex items-center justify-between relative cursor-grab">
|
||||
<div class="flex items-center p-1 space-x-1">
|
||||
<IconDrag />
|
||||
<FieldType
|
||||
:model-value="field.type || 'text'"
|
||||
:editable="false"
|
||||
:button-width="20"
|
||||
/>
|
||||
<span class="block pl-0.5">
|
||||
{{ field.name }}
|
||||
</span>
|
||||
<div>
|
||||
{{ t('field_not_found') }}
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
class="link"
|
||||
@click.prevent="defaultFieldsSearch = ''"
|
||||
>
|
||||
{{ t('clear') }}
|
||||
</a>
|
||||
</div>
|
||||
<template
|
||||
v-for="field in filteredSubmitterDefaultFields"
|
||||
:key="field.name"
|
||||
>
|
||||
<div
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
draggable="true"
|
||||
class="default-field border border-base-300 rounded rounded-tr-none relative group mb-2"
|
||||
@dragstart="onDragstart({ type: 'text', ...field })"
|
||||
@dragend="$emit('drag-end')"
|
||||
>
|
||||
<div class="flex items-center justify-between relative cursor-grab">
|
||||
<div class="flex items-center p-1 space-x-1">
|
||||
<IconDrag />
|
||||
<FieldType
|
||||
:model-value="field.type || 'text'"
|
||||
:editable="false"
|
||||
:button-width="20"
|
||||
/>
|
||||
<span class="block pl-0.5">
|
||||
{{ field.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="editable && !onlyDefinedFields"
|
||||
@@ -74,7 +105,7 @@
|
||||
<button
|
||||
v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')"
|
||||
draggable="true"
|
||||
class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
|
||||
class="field-type-button group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
@dragstart="onDragstart({ type: type })"
|
||||
@dragend="$emit('drag-end')"
|
||||
@@ -205,9 +236,17 @@ export default {
|
||||
}
|
||||
},
|
||||
emits: ['set-draw', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'],
|
||||
data () {
|
||||
return {
|
||||
defaultFieldsSearch: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldIcons: FieldType.computed.fieldIcons,
|
||||
isShowFieldSearch () {
|
||||
return this.submitterDefaultFields.length > 15
|
||||
},
|
||||
fieldIconsSorted () {
|
||||
if (this.fieldTypes.length) {
|
||||
return this.fieldTypes.reduce((acc, type) => {
|
||||
@@ -226,6 +265,13 @@ export default {
|
||||
return this.defaultFields.filter((f) => {
|
||||
return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name)
|
||||
})
|
||||
},
|
||||
filteredSubmitterDefaultFields () {
|
||||
if (this.defaultFieldsSearch) {
|
||||
return this.submitterDefaultFields.filter((f) => f.name.toLowerCase().includes(this.defaultFieldsSearch.toLowerCase()))
|
||||
} else {
|
||||
return this.submitterDefaultFields
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
const en = {
|
||||
search_field: 'Search field',
|
||||
field_not_found: 'Field not found',
|
||||
clear: 'Clear',
|
||||
align: 'Align',
|
||||
left: 'Left',
|
||||
right: 'Right',
|
||||
center: 'Center',
|
||||
description: 'Description',
|
||||
display_title: 'Display title',
|
||||
with_logo: 'With logo',
|
||||
unchecked: 'Unchecked',
|
||||
equal: 'Equal',
|
||||
not_equal: 'Not equal',
|
||||
|
||||
@@ -162,8 +162,8 @@ export default {
|
||||
},
|
||||
onDrop (e) {
|
||||
this.$emit('drop-field', {
|
||||
x: e.layerX,
|
||||
y: e.layerY,
|
||||
x: e.offsetX,
|
||||
y: e.offsetY,
|
||||
maskW: this.$refs.mask.clientWidth,
|
||||
maskH: this.$refs.mask.clientHeight,
|
||||
page: this.number
|
||||
@@ -186,10 +186,10 @@ export default {
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.newArea = {
|
||||
initialX: e.layerX / this.$refs.mask.clientWidth,
|
||||
initialY: e.layerY / this.$refs.mask.clientHeight,
|
||||
x: e.layerX / this.$refs.mask.clientWidth,
|
||||
y: e.layerY / this.$refs.mask.clientHeight,
|
||||
initialX: e.offsetX / this.$refs.mask.clientWidth,
|
||||
initialY: e.offsetY / this.$refs.mask.clientHeight,
|
||||
x: e.offsetX / this.$refs.mask.clientWidth,
|
||||
y: e.offsetY / this.$refs.mask.clientHeight,
|
||||
w: 0,
|
||||
h: 0
|
||||
}
|
||||
@@ -197,19 +197,19 @@ export default {
|
||||
},
|
||||
onPointermove (e) {
|
||||
if (this.newArea) {
|
||||
const dx = e.layerX / this.$refs.mask.clientWidth - this.newArea.initialX
|
||||
const dy = e.layerY / this.$refs.mask.clientHeight - this.newArea.initialY
|
||||
const dx = e.offsetX / this.$refs.mask.clientWidth - this.newArea.initialX
|
||||
const dy = e.offsetY / this.$refs.mask.clientHeight - this.newArea.initialY
|
||||
|
||||
if (dx > 0) {
|
||||
this.newArea.x = this.newArea.initialX
|
||||
} else {
|
||||
this.newArea.x = e.layerX / this.$refs.mask.clientWidth
|
||||
this.newArea.x = e.offsetX / this.$refs.mask.clientWidth
|
||||
}
|
||||
|
||||
if (dy > 0) {
|
||||
this.newArea.y = this.newArea.initialY
|
||||
} else {
|
||||
this.newArea.y = e.layerY / this.$refs.mask.clientHeight
|
||||
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight
|
||||
}
|
||||
|
||||
if (this.drawField?.type === 'cells') {
|
||||
|
||||
@@ -119,7 +119,7 @@ export default {
|
||||
|
||||
this.isProcessing = true
|
||||
|
||||
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
|
||||
this.baseFetch(`/templates/${this.templateId}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ blobs }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
@@ -132,7 +132,7 @@ export default {
|
||||
} else if (resp.status === 422) {
|
||||
resp.json().then((data) => {
|
||||
if (data.error === 'PDF encrypted') {
|
||||
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
|
||||
this.baseFetch(`/templates/${this.templateId}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ blobs, password: prompt('Enter PDF password') }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
@@ -151,7 +151,7 @@ export default {
|
||||
this.isProcessing = false
|
||||
})
|
||||
} else {
|
||||
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
|
||||
this.baseFetch(`/templates/${this.templateId}/documents`, {
|
||||
method: 'POST',
|
||||
body: new FormData(this.$refs.form)
|
||||
}).then((resp) => {
|
||||
@@ -167,7 +167,7 @@ export default {
|
||||
|
||||
formData.append('password', prompt('Enter PDF password'))
|
||||
|
||||
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
|
||||
this.baseFetch(`/templates/${this.templateId}/documents`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(async (resp) => {
|
||||
|
||||
@@ -30,7 +30,8 @@ class SubmitterMailer < ApplicationMailer
|
||||
to: @submitter.friendly_name,
|
||||
from: from_address_for_submitter(submitter),
|
||||
subject:,
|
||||
reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@')
|
||||
reply_to: submitter.preferences['reply_to'].presence ||
|
||||
(submitter.submission.created_by_user || submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@')
|
||||
)
|
||||
end
|
||||
|
||||
@@ -84,6 +85,9 @@ class SubmitterMailer < ApplicationMailer
|
||||
|
||||
mail(from: from_address_for_submitter(submitter),
|
||||
to: to || @submitter.friendly_name,
|
||||
reply_to: @submitter.preferences['reply_to'].presence ||
|
||||
(@submitter.submission.created_by_user ||
|
||||
@submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@'),
|
||||
subject:)
|
||||
end
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
<head>
|
||||
<title></title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<style>
|
||||
</style>
|
||||
<style></style>
|
||||
</head>
|
||||
<body dir="<%= yield&.match?(TextUtils::RTL_REGEXP) ? 'rtl' : 'auto' %>">
|
||||
<body dir="<%= TextUtils.rtl?(yield) ? 'rtl' : 'auto' %>">
|
||||
<%= yield %>
|
||||
<%= render partial: 'shared/mailer_attribution' %>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<%= render 'form_toggle_options' %>
|
||||
@@ -0,0 +1,14 @@
|
||||
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::FORM_WITH_CONFETTI_KEY).first_or_initialize(value: true) %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<div class="px-1 mt-2">
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between py-2.5">
|
||||
<span>
|
||||
Show confetti on successful completion
|
||||
</span>
|
||||
<%= f.check_box :value, { class: 'toggle', checked: account_config.value != false, onchange: 'this.form.requestSubmit()' }, '1', '0' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -9,8 +9,9 @@
|
||||
</div>
|
||||
<p class="text-4xl font-bold mb-4 mt-8">Company Logo</p>
|
||||
<%= render 'logo_form' %>
|
||||
<p class="text-4xl font-bold mb-4 mt-8">Submitter Form</p>
|
||||
<p class="text-4xl font-bold mb-4 mt-8">Submission Form</p>
|
||||
<%= render 'form_completed_button_form' %>
|
||||
<%= render 'form_customization_settings' %>
|
||||
</div>
|
||||
<div class="w-0 md:w-52"></div>
|
||||
</div>
|
||||
@@ -31,40 +31,42 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<a href="<%= edit_user_signature_path %>" data-turbo-frame="modal" class="base-button w-full">Update Signature</a>
|
||||
<p class="text-2xl font-bold mt-8 mb-4">Change Password</p>
|
||||
<%= form_for current_user, url: update_password_settings_profile_index_path, method: :patch, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
|
||||
<div class="form-control">
|
||||
<%= f.label :password, 'New password', class: 'label' %>
|
||||
<%= f.password_field :password, autocomplete: 'off', class: 'base-input' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<%= f.label :password_confirmation, 'Confirm new password', class: 'label' %>
|
||||
<%= f.password_field :password_confirmation, autocomplete: 'off', class: 'base-input' %>
|
||||
</div>
|
||||
<div class="form-control pt-2">
|
||||
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
|
||||
<% if true_user == current_user && !current_account.testing? %>
|
||||
<p class="text-2xl font-bold mt-8 mb-4">Change Password</p>
|
||||
<%= form_for current_user, url: update_password_settings_profile_index_path, method: :patch, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
|
||||
<div class="form-control">
|
||||
<%= f.label :password, 'New password', class: 'label' %>
|
||||
<%= f.password_field :password, autocomplete: 'off', class: 'base-input' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<%= f.label :password_confirmation, 'Confirm new password', class: 'label' %>
|
||||
<%= f.password_field :password_confirmation, autocomplete: 'off', class: 'base-input' %>
|
||||
</div>
|
||||
<div class="form-control pt-2">
|
||||
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<p class="text-2xl font-bold mt-8 mb-4">Two-Factor Authentication</p>
|
||||
<div class="space-y-4">
|
||||
<% if current_user.otp_required_for_login %>
|
||||
<p class="flex items-center space-x-1">
|
||||
<%= svg_icon('circle_check', class: 'stroke-success inline flex-none w-5 h-5') %>
|
||||
<span>
|
||||
2FA has been configured.
|
||||
</span>
|
||||
</p>
|
||||
<a href="<%= edit_mfa_setup_path %>" data-turbo-frame="modal" class="white-button w-full !px-8">🔓 Remove 2FA</a>
|
||||
<% else %>
|
||||
<p class="flex items-center space-x-1">
|
||||
<%= svg_icon('info_circle', class: 'stroke-warning inline flex-none w-5 h-5') %>
|
||||
<span>
|
||||
2FA is not configured.
|
||||
</span>
|
||||
</p>
|
||||
<a href="<%= new_mfa_setup_path %>" data-turbo-frame="modal" class="base-button w-full !px-8">🔒 Set up 2FA</a>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<p class="text-2xl font-bold mt-8 mb-4">Two-Factor Authentication</p>
|
||||
<div class="space-y-4">
|
||||
<% if current_user.otp_required_for_login %>
|
||||
<p class="flex items-center space-x-1">
|
||||
<%= svg_icon('circle_check', class: 'stroke-success inline flex-none w-5 h-5') %>
|
||||
<span>
|
||||
2FA has been configured.
|
||||
</span>
|
||||
</p>
|
||||
<a href="<%= edit_mfa_setup_path %>" data-turbo-frame="modal" class="white-button w-full !px-8">🔓 Remove 2FA</a>
|
||||
<% else %>
|
||||
<p class="flex items-center space-x-1">
|
||||
<%= svg_icon('info_circle', class: 'stroke-warning inline flex-none w-5 h-5') %>
|
||||
<span>
|
||||
2FA is not configured.
|
||||
</span>
|
||||
</p>
|
||||
<a href="<%= new_mfa_setup_path %>" data-turbo-frame="modal" class="base-button w-full !px-8">🔒 Set up 2FA</a>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-0 md:w-52"></div>
|
||||
</div>
|
||||
@@ -5,6 +5,10 @@
|
||||
if (this.field.scrollHeight > this.field.clientHeight) {
|
||||
this.field.classList.remove('text-[1.5vw]', 'lg:text-base');
|
||||
this.field.classList.add('text-[1.0vw]', 'lg:text-[0.70rem]');
|
||||
|
||||
if (this.field.scrollHeight > this.field.clientHeight) {
|
||||
this.field.classList.add('text-[0.8vw]', 'lg:text-[0.50rem]');
|
||||
}
|
||||
}
|
||||
}
|
||||
get field() {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<% end %>
|
||||
<% if can?(:read, User) %>
|
||||
<li>
|
||||
<%= link_to 'Team', settings_users_path, class: 'text-base hover:bg-base-300' %>
|
||||
<%= link_to 'Users', settings_users_path, class: 'text-base hover:bg-base-300' %>
|
||||
</li>
|
||||
<% end %>
|
||||
<% unless Docuseal.multitenant? %>
|
||||
@@ -67,7 +67,7 @@
|
||||
</li>
|
||||
<% end %>
|
||||
<%= render 'shared/settings_nav_extra' %>
|
||||
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %>
|
||||
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.testing?) %>
|
||||
<li>
|
||||
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
|
||||
Plans
|
||||
@@ -75,7 +75,7 @@
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %>
|
||||
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.testing?) %>
|
||||
<li>
|
||||
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/api") : "#{Docuseal::CONSOLE_URL}/on_premise", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
|
||||
<% if Docuseal.multitenant? %> API <% else %> Console <% end %>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
|
||||
<% if template.submitters.size == 1 %>
|
||||
<submitter-item class="form-control">
|
||||
<submitters-autocomplete data-field="email">
|
||||
<autoresize-textarea>
|
||||
<%= f.text_area :emails, required: true, class: 'base-textarea w-full', placeholder: 'Type emails here...' %>
|
||||
</autoresize-textarea>
|
||||
</submitters-autocomplete>
|
||||
<emails-textarea data-bulk-enabled="<%= Docuseal.demo? || !Docuseal.multitenant? || can?(:manage, :bulk_send) %>" data-limit="<%= Docuseal.multitenant? ? (can?(:manage, :bulk_send) ? 50 : 1) : nil %>">
|
||||
<submitters-autocomplete data-field="email" class="block relative">
|
||||
<autoresize-textarea>
|
||||
<%= f.text_area :emails, required: true, class: 'base-textarea w-full', placeholder: 'Type emails here...' %>
|
||||
</autoresize-textarea>
|
||||
</submitters-autocomplete>
|
||||
</emails-textarea>
|
||||
</submitter-item>
|
||||
<% else %>
|
||||
<dynamic-list class="space-y-4">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<field-value dir="auto" class="flex absolute text-[1.5vw] lg:text-base" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%">
|
||||
<% align = field.dig('preferences', 'align') %>
|
||||
<field-value dir="auto" class="flex absolute text-[1.5vw] lg:text-base <%= align == 'right' ? 'justify-end' : (align == 'center' ? 'justify-center' : '') %>" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%">
|
||||
<% if field['type'].in?(['signature', 'image', 'initials', 'stamp']) %>
|
||||
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
|
||||
<% elsif field['type'].in?(['file', 'payment']) %>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<%= render 'submissions/annotation', annot: %>
|
||||
<% end %>
|
||||
<% fields_index.dig(document.uuid, index)&.each do |(area, field)| %>
|
||||
<% value = values[field['uuid']].presence || (field['readonly'] ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submitter.submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %>
|
||||
<% value = values[field['uuid']].presence || (field['default_value'].present? ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submitter.submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %>
|
||||
<% next if value.blank? %>
|
||||
<% next if !field['readonly'] && field['submitter_uuid'] == @submitter.uuid %>
|
||||
<% next if field.dig('preferences', 'formula') && field['submitter_uuid'] == @submitter.uuid %>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(local_assigns[:content], submitter: local_assigns[:submitter], sig: local_assigns[:sig])))) %>
|
||||
@@ -1,5 +1,5 @@
|
||||
<% if @email_config %>
|
||||
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(@email_config.value['body'], submitter: @submitter)))) %>
|
||||
<%= render 'custom_content', content: @email_config.value['body'], submitter: @submitter %>
|
||||
<% else %>
|
||||
<p>Hi there,</p>
|
||||
<p>"<%= @submitter.submission.template.name %>" form has been completed by <%= @submitter.submission.submitters.order(:completed_at).map { |e| e.name || e.email || e.phone }.join(', ') %>.</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% if @email_config %>
|
||||
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(@email_config.value['body'], submitter: @submitter, sig: @sig)))) %>
|
||||
<%= render 'custom_content', content: @email_config.value['body'], submitter: @submitter, sig: @sig %>
|
||||
<% else %>
|
||||
<p>Hi there,</p>
|
||||
<p>Please check the copy of your "<%= @submitter.submission.template.name %>" submission in the email attachments.</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<% if @email_config || @body.present? %>
|
||||
<% body = (@body.presence || @email_config.value['body']).to_s %>
|
||||
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(body, submitter: @submitter)))) %>
|
||||
<%= render 'custom_content', content: body, submitter: @submitter %>
|
||||
<% if !body.include?(ReplaceEmailVariables::SUBMITTER_LINK) && !body.include?(ReplaceEmailVariables::SUBMITTER_ID) && !body.include?(ReplaceEmailVariables::SUBMISSION_LINK) && !body.include?(ReplaceEmailVariables::TEMPLATE_ID) && !@submitter.submission.source.in?(%w[api embed]) %>
|
||||
<p><%= link_to nil, submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email')) %></p>
|
||||
<% end %>
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
<% if @base_template %>
|
||||
<%= hidden_field_tag :base_template_id, @base_template.id %>
|
||||
<% end %>
|
||||
<% if @base_template && (can?(:manage, :tenants) || true_user != current_user) && true_user.account.account_linked_accounts.exists? %>
|
||||
<div class="form-control -mb-2 mt-2">
|
||||
<%= select_tag :account_id, options_for_select([true_user.account, *true_user.account.linked_accounts].uniq.map { |e| [e.name, e.id] }, current_account.id), required: true, class: 'base-select' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="form-control mt-6">
|
||||
<%= f.text_field :name, required: true, placeholder: 'Document Name', class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
@@ -17,11 +22,6 @@
|
||||
</a>
|
||||
</folder-autocomplete>
|
||||
</div>
|
||||
<% if @base_template && (can?(:manage, :tenants) || true_user != current_user) && true_user.account.account_linked_accounts.exists?(account_type: :linked) %>
|
||||
<div class="form-control mb-4">
|
||||
<%= select_tag :account_id, options_for_select([true_user.account, *true_user.account.linked_accounts.where.not(id: true_user.account.testing_accounts)].map { |e| [e.name, e.id] }, current_account.id), required: true, class: 'base-select' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="form-control">
|
||||
<%= f.button button_title(title: @base_template ? 'Submit' : 'Create', disabled_with: 'Creating'), class: 'base-button' %>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<%= render 'shared/settings_nav' %>
|
||||
<div class="md:flex-grow">
|
||||
<div class="flex justify-between mb-4">
|
||||
<h1 class="text-4xl font-bold">Team</h1>
|
||||
<h1 class="text-4xl font-bold">Users</h1>
|
||||
<div class="flex items-center space-x-4">
|
||||
<% if can?(:create, User.new(account: current_account)) %>
|
||||
<%= link_to new_user_path, class: 'btn btn-primary btn-md gap-2', data: { turbo_frame: 'modal' } do %>
|
||||
|
||||
@@ -60,7 +60,7 @@ Rails.application.configure do
|
||||
# config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ]
|
||||
|
||||
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
|
||||
config.force_ssl = ENV['FORCE_SSL'].present?
|
||||
config.force_ssl = ENV['FORCE_SSL'].present? && ENV['FORCE_SSL'] != 'false'
|
||||
|
||||
# Include generic and useful information about system operation, but avoid logging too much
|
||||
# information to avoid inadvertent exposure of personally identifiable information (PII).
|
||||
@@ -128,7 +128,7 @@ Rails.application.configure do
|
||||
config.lograge.formatter = ->(data) { data.except(:path, :location).to_json }
|
||||
|
||||
config.lograge.custom_payload do |controller|
|
||||
params = controller.request.params || {}
|
||||
params = controller.request.try(:params) || {}
|
||||
|
||||
{
|
||||
fwd: controller.request.ip,
|
||||
|
||||
@@ -69,7 +69,7 @@ Rails.configuration.to_prepare do
|
||||
|
||||
LoadActiveStorageConfigs.call
|
||||
rescue StandardError => e
|
||||
Rails.logger.error(e)
|
||||
Rails.logger.error(e) unless Rails.env.production?
|
||||
|
||||
nil
|
||||
end
|
||||
@@ -1,8 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# fix NoMethodError: undefined method `field_value' for #<HexaPDF::Type::AcroForm::Field
|
||||
module HexaPDF
|
||||
module Type
|
||||
# fix NoMethodError: undefined method `field_value' for #<HexaPDF::Type::AcroForm::Field
|
||||
module AcroForm
|
||||
class Field
|
||||
def field_value
|
||||
@@ -10,5 +10,34 @@ module HexaPDF
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# comparison of Integer with HexaPDF::PDFArray failed
|
||||
class CIDFont < Font
|
||||
private
|
||||
|
||||
def widths
|
||||
cache(:widths) do
|
||||
result = {}
|
||||
index = 0
|
||||
array = self[:W] || []
|
||||
|
||||
while index < array.size
|
||||
entry = array[index]
|
||||
value = array[index + 1]
|
||||
|
||||
if value.is_a?(Array) || value.is_a?(HexaPDF::PDFArray)
|
||||
value.each_with_index { |width, i| result[entry + i] = width }
|
||||
index += 2
|
||||
else
|
||||
width = array[index + 2]
|
||||
entry.upto(value) { |cid| result[cid] = width }
|
||||
index += 3
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -24,7 +24,7 @@ port ENV.fetch('PORT', 3000)
|
||||
environment ENV.fetch('RAILS_ENV', 'development')
|
||||
|
||||
# Specifies the `pidfile` that Puma will use.
|
||||
pidfile ENV.fetch('PIDFILE', "#{Dir.tmpdir}/server.pid")
|
||||
@options[:pidfile] = false
|
||||
|
||||
# Specifies the number of `workers` to boot in clustered mode.
|
||||
# Workers are forked web server processes. If using threads and workers together
|
||||
|
||||
+5
-4
@@ -24,8 +24,6 @@ Rails.application.routes.draw do
|
||||
|
||||
namespace :api, defaults: { format: :json } do
|
||||
resources :attachments, only: %i[create]
|
||||
resources :submitters_autocomplete, only: %i[index]
|
||||
resources :template_folders_autocomplete, only: %i[index]
|
||||
resources :submitter_email_clicks, only: %i[create]
|
||||
resources :submitter_form_views, only: %i[create]
|
||||
resources :submitters, only: %i[index show update]
|
||||
@@ -37,7 +35,6 @@ Rails.application.routes.draw do
|
||||
resources :templates, only: %i[update show index destroy] do
|
||||
resources :clone, only: %i[create], controller: 'templates_clone'
|
||||
resources :submissions, only: %i[index create]
|
||||
resources :documents, only: %i[create], controller: 'templates_documents'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -55,8 +52,11 @@ Rails.application.routes.draw do
|
||||
resource :user_signature, only: %i[edit update destroy]
|
||||
resources :submissions, only: %i[show destroy]
|
||||
resources :console_redirect, only: %i[index]
|
||||
resources :upgrade, only: %i[index], controller: 'console_redirect'
|
||||
resource :testing_account, only: %i[show destroy]
|
||||
resources :testing_api_settings, only: %i[index]
|
||||
resources :submitters_autocomplete, only: %i[index]
|
||||
resources :template_folders_autocomplete, only: %i[index]
|
||||
resource :templates_upload, only: %i[create]
|
||||
authenticated do
|
||||
resource :templates_upload, only: %i[show], path: 'new'
|
||||
@@ -64,7 +64,8 @@ Rails.application.routes.draw do
|
||||
resources :templates_archived, only: %i[index], path: 'archived'
|
||||
resources :folders, only: %i[show edit update destroy], controller: 'template_folders'
|
||||
resources :template_sharings_testing, only: %i[create]
|
||||
resources :templates, only: %i[new create edit show destroy] do
|
||||
resources :templates, only: %i[new create edit update show destroy] do
|
||||
resources :documents, only: %i[create], controller: 'template_documents'
|
||||
resources :restore, only: %i[create], controller: 'templates_restore'
|
||||
resources :archived, only: %i[index], controller: 'templates_archived_submissions'
|
||||
resources :submissions, only: %i[new create]
|
||||
|
||||
@@ -2,6 +2,7 @@ queues:
|
||||
- [default, 1]
|
||||
- [mailers, 1]
|
||||
- [recurrent, 1]
|
||||
- [rollbar, 1]
|
||||
|
||||
production:
|
||||
:concurrency: 15
|
||||
+1
-1
@@ -32,7 +32,7 @@ module Accounts
|
||||
end
|
||||
|
||||
def find_or_create_testing_user(account)
|
||||
user = User.find_by(account: account.testing_accounts)
|
||||
user = User.where(role: :admin).order(:id).find_by(account: account.testing_accounts)
|
||||
|
||||
return user if user
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ module Params
|
||||
type(params, :order, String)
|
||||
type(params, :completed_redirect_url, String)
|
||||
type(params, :bcc_completed, String)
|
||||
type(params, :reply_to, String)
|
||||
type(params, :message, Hash)
|
||||
type(params, :submitters, Array)
|
||||
|
||||
@@ -69,6 +70,7 @@ module Params
|
||||
required(submitter_params, %i[email phone name])
|
||||
|
||||
type(submitter_params, :name, String)
|
||||
type(submitter_params, :reply_to, String)
|
||||
type(submitter_params, :email, String)
|
||||
format(submitter_params, :email, /@/, message: 'email is invalid')
|
||||
type(submitter_params, :phone, String)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module RateLimit
|
||||
LimitApproached = Class.new(StandardError)
|
||||
|
||||
STORE = ActiveSupport::Cache::MemoryStore.new
|
||||
|
||||
module_function
|
||||
|
||||
def call(key, limit:, ttl:, enabled: Docuseal.multitenant?)
|
||||
return true unless enabled
|
||||
|
||||
value = STORE.increment(key, 1, expires_in: ttl)
|
||||
|
||||
raise LimitApproached if value > limit
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,8 @@ module ReplaceEmailVariables
|
||||
TEMPLATE_ID = '{{template.id}}'
|
||||
SUBMITTER_LINK = '{{submitter.link}}'
|
||||
ACCOUNT_NAME = '{{account.name}}'
|
||||
SENDER_NAME = '{{sender.name}}'
|
||||
SENDER_EMAIL = '{{sender.email}}'
|
||||
SUBMITTER_EMAIL = '{{submitter.email}}'
|
||||
SUBMITTER_NAME = '{{submitter.name}}'
|
||||
SUBMITTER_ID = '{{submitter.id}}'
|
||||
@@ -18,29 +20,25 @@ module ReplaceEmailVariables
|
||||
module_function
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def call(text, submitter:, tracking_event_type: 'click_email', sig: nil)
|
||||
submitter_link = build_submitter_link(submitter, tracking_event_type)
|
||||
|
||||
submission_link = build_submission_link(submitter.submission) if submitter.submission
|
||||
|
||||
text = text.gsub(TEMPLATE_NAME, submitter.template.name) if submitter.template
|
||||
text = text.gsub(TEMPLATE_ID, submitter.template.id.to_s) if submitter.template
|
||||
text = text.gsub(SUBMITTER_ID, submitter.id.to_s)
|
||||
text = text.gsub(SUBMITTER_SLUG, submitter.slug.to_s)
|
||||
text = text.gsub(SUBMISSION_ID, submitter.submission.id.to_s) if submitter.submission
|
||||
text = text.gsub(SUBMITTER_EMAIL, submitter.email) if submitter.email
|
||||
text = text.gsub(SUBMITTER_NAME, submitter.name || submitter.email || submitter.phone)
|
||||
text = text.gsub(SUBMITTER_LINK, submitter_link)
|
||||
text = text.gsub(SUBMISSION_LINK, submission_link) if submission_link
|
||||
if text.include?(SUBMISSION_SUBMITTERS)
|
||||
text = text.gsub(SUBMISSION_SUBMITTERS, build_submission_submitters(submitter.submission))
|
||||
def call(text, submitter:, tracking_event_type: 'click_email', html_escape: false, sig: nil)
|
||||
text = replace(text, TEMPLATE_NAME, html_escape:) { submitter.template.name }
|
||||
text = replace(text, TEMPLATE_ID, html_escape:) { submitter.template.id }
|
||||
text = replace(text, SUBMITTER_ID, html_escape:) { submitter.id }
|
||||
text = replace(text, SUBMITTER_SLUG, html_escape:) { submitter.slug }
|
||||
text = replace(text, SUBMISSION_ID, html_escape:) { submitter.submission.id }
|
||||
text = replace(text, SUBMITTER_EMAIL, html_escape:) { submitter.email }
|
||||
text = replace(text, SUBMITTER_NAME, html_escape:) { submitter.name || submitter.email || submitter.phone }
|
||||
text = replace(text, SUBMITTER_LINK, html_escape:) { build_submitter_link(submitter, tracking_event_type) }
|
||||
text = replace(text, SUBMISSION_LINK, html_escape:) do
|
||||
submitter.submission ? build_submission_link(submitter.submission) : ''
|
||||
end
|
||||
text = text.gsub(DOCUMENTS_LINKS, build_documents_links_text(submitter, sig))
|
||||
text = text.gsub(DOCUMENTS_LINK, build_documents_links_text(submitter, sig))
|
||||
text = replace(text, SUBMISSION_SUBMITTERS, html_escape:) { build_submission_submitters(submitter.submission) }
|
||||
text = replace(text, DOCUMENTS_LINKS, html_escape:) { build_documents_links_text(submitter, sig) }
|
||||
text = replace(text, DOCUMENTS_LINK, html_escape:) { build_documents_links_text(submitter, sig) }
|
||||
text = replace(text, ACCOUNT_NAME, html_escape:) { submitter.submission.account.name }
|
||||
text = replace(text, SENDER_NAME, html_escape:) { submitter.submission.created_by_user&.full_name }
|
||||
|
||||
text = text.gsub(ACCOUNT_NAME, submitter.account.name) if submitter.account
|
||||
|
||||
text
|
||||
replace(text, SENDER_EMAIL, html_escape:) { submitter.submission.created_by_user&.email.to_s.sub(/\+\w+@/, '@') }
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
|
||||
@@ -50,6 +48,16 @@ module ReplaceEmailVariables
|
||||
)
|
||||
end
|
||||
|
||||
def replace(text, var, html_escape: false)
|
||||
text.gsub(var) do
|
||||
if html_escape
|
||||
ERB::Util.html_escape(yield)
|
||||
else
|
||||
yield
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def build_submitter_link(submitter, tracking_event_type)
|
||||
if tracking_event_type == 'click_email'
|
||||
Rails.application.routes.url_helpers.submit_form_url(
|
||||
|
||||
+3
-1
@@ -7,7 +7,7 @@ module Submissions
|
||||
|
||||
module_function
|
||||
|
||||
def search(submissions, keyword)
|
||||
def search(submissions, keyword, search_values: false)
|
||||
return submissions if keyword.blank?
|
||||
|
||||
term = "%#{keyword.downcase}%"
|
||||
@@ -18,6 +18,8 @@ module Submissions
|
||||
.or(arel_table[:phone].matches(term))
|
||||
.or(arel_table[:name].lower.matches(term))
|
||||
|
||||
arel = arel.or(Arel::Table.new(:submitters)[:values].matches(term)) if search_values
|
||||
|
||||
submissions.joins(:submitters).where(arel).distinct
|
||||
end
|
||||
|
||||
|
||||
@@ -52,7 +52,8 @@ module Submissions
|
||||
process_fields_param(submitter_attrs[:fields], template_fields, submitter_uuid)
|
||||
end
|
||||
|
||||
if template_fields != submission.template.fields
|
||||
if template_fields != (submission.template_fields || submission.template.fields) ||
|
||||
submitters_attrs.any? { |e| e[:completed].present? }
|
||||
submission.template_fields = template_fields
|
||||
submission.template_schema = submission.template.schema
|
||||
end
|
||||
@@ -80,11 +81,15 @@ module Submissions
|
||||
next if f['type'].in?(%w[signature image initials file])
|
||||
next if f['submitter_uuid'] != submitter_uuid
|
||||
|
||||
next unless values.key?(f['uuid'])
|
||||
|
||||
value = values[f['uuid']]
|
||||
|
||||
next if value.blank?
|
||||
|
||||
f['default_value'] = value
|
||||
if value.present?
|
||||
f['default_value'] = value
|
||||
else
|
||||
f.delete('default_value')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -110,8 +115,12 @@ module Submissions
|
||||
field['readonly'] = attrs['readonly'] if attrs.key?('readonly')
|
||||
field['required'] = attrs['required'] if attrs.key?('required')
|
||||
|
||||
if attrs['default_value'].present? && !field['type'].in?(%w[signature image initials file])
|
||||
field['default_value'] = Submitters::NormalizeValues.normalize_value(field, attrs['default_value'])
|
||||
if attrs.key?('default_value') && !field['type'].in?(%w[signature image initials file])
|
||||
if attrs['default_value'].present?
|
||||
field['default_value'] = Submitters::NormalizeValues.normalize_value(field, attrs['default_value'])
|
||||
else
|
||||
field.delete('default_value')
|
||||
end
|
||||
end
|
||||
|
||||
return field if attrs['validation_pattern'].blank?
|
||||
@@ -125,9 +134,10 @@ module Submissions
|
||||
end
|
||||
|
||||
def find_submitter_uuid(template, attrs, index)
|
||||
attrs[:uuid].presence ||
|
||||
template.submitters.find { |e| e['name'] == attrs[:role] }&.dig('uuid') ||
|
||||
template.submitters[index]&.dig('uuid')
|
||||
uuid = attrs[:uuid].presence
|
||||
uuid ||= template.submitters.find { |e| e['name'].to_s.casecmp(attrs[:role].to_s).zero? }&.dig('uuid')
|
||||
|
||||
uuid || template.submitters[index]&.dig('uuid')
|
||||
end
|
||||
|
||||
def build_submitter(submission:, attrs:, uuid:, is_order_sent:, mark_as_sent:, user:, preferences:)
|
||||
@@ -139,7 +149,7 @@ module Submissions
|
||||
phone: attrs[:phone].to_s.gsub(/[^0-9+]/, ''),
|
||||
name: attrs[:name],
|
||||
external_id: attrs[:external_id].presence || attrs[:application_key],
|
||||
completed_at: attrs[:completed] ? Time.current : nil,
|
||||
completed_at: attrs[:completed].present? ? Time.current : nil,
|
||||
sent_at: mark_as_sent && email.present? && is_order_sent ? Time.current : nil,
|
||||
values: attrs[:values] || {},
|
||||
metadata: attrs[:metadata] || {},
|
||||
|
||||
@@ -33,7 +33,10 @@ module Submissions
|
||||
sleep WAIT_FOR_RETRY
|
||||
|
||||
retry
|
||||
rescue StandardError
|
||||
rescue StandardError => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
Rails.logger.error(e)
|
||||
|
||||
submitter.document_generation_events.create!(event_name: :fail)
|
||||
|
||||
raise
|
||||
|
||||
@@ -30,6 +30,8 @@ module Submissions
|
||||
RTL_REGEXP = TextUtils::RTL_REGEXP
|
||||
MAX_IMAGE_HEIGHT = 100
|
||||
|
||||
US_TIMEZONES = %w[EST CST MST PST HST AKDT].freeze
|
||||
|
||||
module_function
|
||||
|
||||
# rubocop:disable Metrics
|
||||
@@ -38,9 +40,17 @@ module Submissions
|
||||
pkcs = Accounts.load_signing_pkcs(account)
|
||||
tsa_url = Accounts.load_timeserver_url(account)
|
||||
verify_url = Rails.application.routes.url_helpers.settings_esign_url(**Docuseal.default_url_options)
|
||||
page_size =
|
||||
if TimeUtils.timezone_abbr(account.timezone, Time.current.beginning_of_year).in?(US_TIMEZONES)
|
||||
:Letter
|
||||
else
|
||||
:A4
|
||||
end
|
||||
|
||||
composer = HexaPDF::Composer.new(skip_page_creation: true)
|
||||
composer.document.fonts.add(FONT_BOLD_NAME, variant: :bold)
|
||||
composer.document.config['font.on_missing_glyph'] =
|
||||
Submissions::GenerateResultAttachments.method(:on_missing_glyph).to_proc
|
||||
|
||||
divider = HexaPDF::Layout::Box.create(
|
||||
margin: [0, 0, 15, 0],
|
||||
@@ -51,7 +61,7 @@ module Submissions
|
||||
height: 1
|
||||
)
|
||||
|
||||
composer.page_style(:default, page_size: :A4) do |canvas, style|
|
||||
composer.page_style(:default, page_size:) do |canvas, style|
|
||||
box = canvas.context.box(:media)
|
||||
canvas.save_graphics_state do
|
||||
canvas.fill_color('FAF7F5')
|
||||
@@ -90,7 +100,10 @@ module Submissions
|
||||
last_submitter = submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
|
||||
documents_data = Submitters.select_attachments_for_download(last_submitter).map do |document|
|
||||
original_documents = submission.template.documents.select { |e| e.uuid == document.uuid }.presence
|
||||
original_documents = submission.template.documents.select do |e|
|
||||
e.uuid == (document.metadata['original_uuid'] || document.uuid)
|
||||
end.presence
|
||||
|
||||
original_documents ||= submission.template.documents.select do |e|
|
||||
e.image? && submission.template_schema.any? do |item|
|
||||
item['attachment_uuid'] == e.uuid
|
||||
@@ -242,7 +255,6 @@ module Submissions
|
||||
end
|
||||
|
||||
value = value.join(', ') if value.is_a?(Array)
|
||||
value = Submissions::GenerateResultAttachments::REPLACE_EMOJI[value] || value
|
||||
|
||||
composer.formatted_text_box([{ text: TextUtils.maybe_rtl_reverse(value.to_s.presence || 'n/a') }],
|
||||
text_align: value.to_s.match?(RTL_REGEXP) ? :right : :left,
|
||||
|
||||
@@ -21,9 +21,18 @@ module Submissions
|
||||
A4_SIZE = [595, 842].freeze
|
||||
SUPPORTED_IMAGE_TYPES = ['image/png', 'image/jpeg'].freeze
|
||||
|
||||
REPLACE_EMOJI = {
|
||||
MISSING_GLYPH_REPLACE = {
|
||||
'▪' => '-',
|
||||
'✔️' => 'V',
|
||||
'✔' => 'V'
|
||||
'✔' => 'V',
|
||||
'✅' => 'V'
|
||||
}.freeze
|
||||
|
||||
MISSING_GLYPH_REPLACE_TYPE1 = {
|
||||
'▪' => :bullet,
|
||||
'✔️' => :V,
|
||||
'✔' => :V,
|
||||
'✅' => :V
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
@@ -62,12 +71,14 @@ module Submissions
|
||||
|
||||
width = page.box.width
|
||||
height = page.box.height
|
||||
font_size = ((page.box.width / A4_SIZE[0].to_f) * FONT_SIZE).to_i
|
||||
font_size = (([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * FONT_SIZE).to_i
|
||||
|
||||
value = submitter.values[field['uuid']]
|
||||
|
||||
layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center,
|
||||
text_align: value.to_s.match?(RTL_REGEXP) ? :right : :left,
|
||||
text_align = field.dig('preferences', 'align').to_s.to_sym.presence ||
|
||||
(value.to_s.match?(RTL_REGEXP) ? :right : :left)
|
||||
|
||||
layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center, text_align:,
|
||||
font: pdf.fonts.add(FONT_NAME), font_size:)
|
||||
|
||||
next if Array.wrap(value).compact_blank.blank?
|
||||
@@ -181,7 +192,6 @@ module Submissions
|
||||
end
|
||||
|
||||
value = TextUtils.maybe_rtl_reverse(Array.wrap(value).join(', '))
|
||||
value = REPLACE_EMOJI[value] || value
|
||||
|
||||
text = HexaPDF::Layout::TextFragment.create(value, font: pdf.fonts.add(FONT_NAME),
|
||||
font_size:)
|
||||
@@ -194,15 +204,33 @@ module Submissions
|
||||
font: pdf.fonts.add(FONT_NAME),
|
||||
font_size: (font_size / 1.4).to_i)
|
||||
|
||||
lines = layouter.fit([text], area['w'] * width, height).lines
|
||||
lines = layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width, height).lines
|
||||
|
||||
box_height = lines.sum(&:height)
|
||||
end
|
||||
|
||||
if box_height > (area['h'] * height) + 1
|
||||
text = HexaPDF::Layout::TextFragment.create(value,
|
||||
font: pdf.fonts.add(FONT_NAME),
|
||||
font_size: (font_size / 1.9).to_i)
|
||||
|
||||
lines = layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width, height).lines
|
||||
|
||||
box_height = lines.sum(&:height)
|
||||
end
|
||||
|
||||
height_diff = [0, box_height - (area['h'] * height)].max
|
||||
|
||||
layouter.fit([text], area['w'] * width, height_diff.positive? ? box_height : area['h'] * height)
|
||||
.draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN,
|
||||
right_align_x_adjustment =
|
||||
if field['type'].in?(%w[date number]) && text_align != :left
|
||||
(width - (area['w'] * width)) / (text_align == :center ? 2.0 : 1)
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width,
|
||||
height_diff.positive? ? box_height : area['h'] * height)
|
||||
.draw(canvas, (area['x'] * width) - right_align_x_adjustment + TEXT_LEFT_MARGIN,
|
||||
height - (area['y'] * height) + height_diff - TEXT_TOP_MARGIN)
|
||||
end
|
||||
end
|
||||
@@ -211,28 +239,28 @@ module Submissions
|
||||
image_pdfs = []
|
||||
original_documents = template.documents.preload(:blob)
|
||||
|
||||
results =
|
||||
result_attachments =
|
||||
submitter.submission.template_schema.map do |item|
|
||||
pdf = pdfs_index[item['attachment_uuid']]
|
||||
|
||||
attachment = save_pdf(pdf:, submitter:, pkcs:, tsa_url:,
|
||||
uuid: item['attachment_uuid'],
|
||||
name: item['name'])
|
||||
attachment = build_pdf_attachment(pdf:, submitter:, pkcs:, tsa_url:,
|
||||
uuid: item['attachment_uuid'],
|
||||
name: item['name'])
|
||||
|
||||
image_pdfs << pdf if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
|
||||
|
||||
attachment
|
||||
end
|
||||
|
||||
return results if image_pdfs.size < 2
|
||||
return result_attachments.map { |e| e.tap(&:save!) } if image_pdfs.size < 2
|
||||
|
||||
images_pdf =
|
||||
image_pdfs.each_with_object(HexaPDF::Document.new) do |pdf, doc|
|
||||
pdf.pages.each { |page| doc.pages << doc.import(page) }
|
||||
end
|
||||
|
||||
images_pdf_result =
|
||||
save_pdf(
|
||||
images_pdf_attachment =
|
||||
build_pdf_attachment(
|
||||
pdf: images_pdf,
|
||||
submitter:,
|
||||
tsa_url:,
|
||||
@@ -241,11 +269,10 @@ module Submissions
|
||||
name: template.name
|
||||
)
|
||||
|
||||
results + [images_pdf_result]
|
||||
(result_attachments + [images_pdf_attachment]).map { |e| e.tap(&:save!) }
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
|
||||
def save_pdf(pdf:, submitter:, pkcs:, tsa_url:, uuid:, name:)
|
||||
def build_pdf_attachment(pdf:, submitter:, pkcs:, tsa_url:, uuid:, name:)
|
||||
io = StringIO.new
|
||||
|
||||
pdf.trailer.info[:Creator] = info_creator
|
||||
@@ -265,12 +292,25 @@ module Submissions
|
||||
sign_params[:signature_size] = 10_000
|
||||
end
|
||||
|
||||
pdf.sign(io, write_options: { validate: false }, **sign_params)
|
||||
else
|
||||
pdf.write(io, incremental: true, validate: false)
|
||||
end
|
||||
begin
|
||||
pdf.sign(io, write_options: { validate: false }, **sign_params)
|
||||
rescue HexaPDF::MalformedPDFError => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
ActiveStorage::Attachment.create!(
|
||||
pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
|
||||
end
|
||||
else
|
||||
begin
|
||||
pdf.write(io, incremental: true, validate: false)
|
||||
rescue HexaPDF::MalformedPDFError => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
pdf.write(io, incremental: false, validate: false)
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
|
||||
ActiveStorage::Attachment.new(
|
||||
blob: ActiveStorage::Blob.create_and_upload!(io: StringIO.new(io.string), filename: "#{name}.pdf"),
|
||||
metadata: { original_uuid: uuid,
|
||||
analyzed: true,
|
||||
@@ -306,10 +346,25 @@ module Submissions
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
end
|
||||
|
||||
pdf.config['font.on_missing_glyph'] = method(:on_missing_glyph).to_proc
|
||||
|
||||
[attachment.metadata['original_uuid'] || attachment.uuid, pdf]
|
||||
end
|
||||
end
|
||||
|
||||
def on_missing_glyph(character, font_wrapper)
|
||||
Rollbar.error("Missing glyph: #{character}") if character.present? && defined?(Rollbar)
|
||||
|
||||
replace_with =
|
||||
if font_wrapper.font_type == :Type1
|
||||
MISSING_GLYPH_REPLACE_TYPE1[character] || :space
|
||||
else
|
||||
(MISSING_GLYPH_REPLACE[character] || ' ').bytes.first - 29
|
||||
end
|
||||
|
||||
font_wrapper.custom_glyph(replace_with, character)
|
||||
end
|
||||
|
||||
def find_last_submitter(submitter)
|
||||
submitter.submission.submitters
|
||||
.select(&:completed_at?)
|
||||
|
||||
@@ -22,7 +22,7 @@ module Submissions
|
||||
default_values = submitter_params[:values] || {}
|
||||
|
||||
submitter_params[:fields]&.each do |f|
|
||||
default_values[f[:name]] = f[:default_value] if f[:default_value].present?
|
||||
default_values[f[:name]] = f[:default_value] if f.key?(:default_value)
|
||||
end
|
||||
|
||||
return submitter_params if default_values.blank?
|
||||
|
||||
@@ -61,6 +61,7 @@ module Submitters
|
||||
preferences['send_email'] = params['send_email'].in?(TRUE_VALUES) if params.key?('send_email')
|
||||
preferences['send_sms'] = params['send_sms'].in?(TRUE_VALUES) if params.key?('send_sms')
|
||||
preferences['bcc_completed'] = params['bcc_completed'] if params.key?('bcc_completed')
|
||||
preferences['reply_to'] = params['reply_to'] if params.key?('reply_to')
|
||||
preferences['completed_redirect_url'] = params['completed_redirect_url'] if params.key?('completed_redirect_url')
|
||||
|
||||
preferences
|
||||
|
||||
@@ -13,8 +13,8 @@ module Submitters
|
||||
|
||||
module_function
|
||||
|
||||
def call(submitter)
|
||||
image = generate_stamp_image(submitter)
|
||||
def call(submitter, with_logo: true)
|
||||
image = generate_stamp_image(submitter, with_logo:)
|
||||
|
||||
image_data = image.write_to_buffer('.png')
|
||||
|
||||
@@ -30,8 +30,13 @@ module Submitters
|
||||
)
|
||||
end
|
||||
|
||||
def generate_stamp_image(submitter)
|
||||
logo = Vips::Image.new_from_buffer(load_logo(submitter).read, '')
|
||||
def generate_stamp_image(submitter, with_logo: true)
|
||||
logo =
|
||||
if with_logo
|
||||
Vips::Image.new_from_buffer(load_logo(submitter).read, '')
|
||||
else
|
||||
Vips::Image.new_from_buffer(TRANSPARENT_PIXEL, '').resize(WIDTH)
|
||||
end
|
||||
|
||||
logo = logo.resize([WIDTH / logo.width.to_f, HEIGHT / logo.height.to_f].min)
|
||||
|
||||
|
||||
@@ -2,20 +2,27 @@
|
||||
|
||||
module Submitters
|
||||
module FormConfigs
|
||||
DEFAULT_KEYS = [AccountConfig::FORM_COMPLETED_BUTTON_KEY,
|
||||
AccountConfig::FORM_WITH_CONFETTI_KEY,
|
||||
AccountConfig::ALLOW_TYPED_SIGNATURE].freeze
|
||||
|
||||
module_function
|
||||
|
||||
def call(submitter)
|
||||
def call(submitter, keys = [])
|
||||
configs = submitter.submission.template.account.account_configs
|
||||
.where(key: [AccountConfig::FORM_COMPLETED_BUTTON_KEY,
|
||||
AccountConfig::FORM_WITH_CONFETTI_KEY,
|
||||
AccountConfig::ALLOW_TYPED_SIGNATURE])
|
||||
.where(key: DEFAULT_KEYS + keys)
|
||||
|
||||
completed_button = configs.find { |e| e.key == AccountConfig::FORM_COMPLETED_BUTTON_KEY }&.value || {}
|
||||
|
||||
with_typed_signature = configs.find { |e| e.key == AccountConfig::ALLOW_TYPED_SIGNATURE }&.value != false
|
||||
with_confetti = configs.find { |e| e.key == AccountConfig::FORM_WITH_CONFETTI_KEY }&.value != false
|
||||
|
||||
{ completed_button:, with_typed_signature:, with_confetti: }
|
||||
attrs = { completed_button:, with_typed_signature:, with_confetti: }
|
||||
|
||||
keys.each do |key|
|
||||
attrs[key.to_sym] = configs.find { |e| e.key == key.to_s }&.value
|
||||
end
|
||||
|
||||
attrs
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,6 +9,7 @@ module Submitters
|
||||
UnknownFieldName = Class.new(BaseError)
|
||||
InvalidDefaultValue = Class.new(BaseError)
|
||||
UnknownSubmitterName = Class.new(BaseError)
|
||||
UnableToDownload = Class.new(BaseError)
|
||||
|
||||
module_function
|
||||
|
||||
@@ -167,7 +168,12 @@ module Submitters
|
||||
|
||||
return blob if blob
|
||||
|
||||
data = conn.get(Addressable::URI.parse(url).display_uri.to_s).body
|
||||
uri = Addressable::URI.parse(url)
|
||||
resp = conn.get(uri.display_uri.to_s)
|
||||
|
||||
raise UnableToDownload, "Error loading: #{uri.display_uri}" if resp.status >= 400
|
||||
|
||||
data = resp.body
|
||||
|
||||
checksum = Digest::MD5.base64digest(data)
|
||||
|
||||
|
||||
@@ -78,7 +78,9 @@ module Submitters
|
||||
next if field['submitter_uuid'] != submitter.uuid
|
||||
|
||||
if field['type'] == 'stamp'
|
||||
acc[field['uuid']] ||= Submitters::CreateStampAttachment.call(submitter).uuid
|
||||
acc[field['uuid']] ||=
|
||||
Submitters::CreateStampAttachment.call(submitter,
|
||||
with_logo: field.dig('preferences', 'with_logo') != false).uuid
|
||||
|
||||
next
|
||||
end
|
||||
@@ -129,6 +131,8 @@ module Submitters
|
||||
|
||||
value.to_s.gsub(VARIABLE_REGEXP) do |e|
|
||||
case key = ::Regexp.last_match(1)
|
||||
when 'id'
|
||||
attrs['submission_id']
|
||||
when 'time'
|
||||
if with_time
|
||||
I18n.l(Time.current.in_time_zone(template.account.timezone), format: :long, locale: template.account.locale)
|
||||
|
||||
@@ -5,12 +5,11 @@ module Templates
|
||||
module_function
|
||||
|
||||
def call(data)
|
||||
pdf = PDF::Reader.new(StringIO.new(data))
|
||||
pdf = HexaPDF::Document.new(io: StringIO.new(data))
|
||||
|
||||
pdf.pages.flat_map.with_index do |page, index|
|
||||
annotations = page.objects.deref!(page.attributes[:Annots]) || []
|
||||
annotations.filter_map do |annot|
|
||||
next if annot.nil?
|
||||
(page[:Annots] || []).filter_map do |annot|
|
||||
next if annot.blank?
|
||||
next if annot[:A].blank? || annot[:A][:URI].blank?
|
||||
next unless annot[:Subtype] == :Link
|
||||
next if !annot[:A][:URI].starts_with?('https://') && !annot[:A][:URI].starts_with?('http://')
|
||||
@@ -30,10 +29,10 @@ module Templates
|
||||
{
|
||||
'type' => 'external_link',
|
||||
'value' => annot[:A][:URI],
|
||||
'x' => left / page.width.to_f,
|
||||
'y' => (page.height - top) / page.height.to_f,
|
||||
'w' => (right - left) / page.width.to_f,
|
||||
'h' => (top - bottom) / page.height.to_f
|
||||
'x' => left / page.box.width.to_f,
|
||||
'y' => (page.box.height - top) / page.box.height.to_f,
|
||||
'w' => (right - left) / page.box.width.to_f,
|
||||
'h' => (top - bottom) / page.box.height.to_f
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,14 @@ module TextUtils
|
||||
|
||||
module_function
|
||||
|
||||
def rtl?(text)
|
||||
return false if text.blank?
|
||||
|
||||
text.match?(TextUtils::RTL_REGEXP)
|
||||
rescue Encoding::CompatibilityError
|
||||
false
|
||||
end
|
||||
|
||||
def maybe_rtl_reverse(text)
|
||||
if text.match?(RTL_REGEXP)
|
||||
TwitterCldr::Shared::Bidi
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"sass-loader": "^13.2.2",
|
||||
"shakapacker": "7.1.0",
|
||||
"signature_pad": "^4.1.5",
|
||||
"snarkdown": "^2.0.0",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"terser-webpack-plugin": "5.3.8",
|
||||
"uuid": "^9.0.0",
|
||||
|
||||
@@ -8,6 +8,7 @@ module.exports = {
|
||||
'./app/views/start_form/**/*.erb',
|
||||
'./app/views/shared/_button_title.html.erb',
|
||||
'./app/views/shared/_attribution.html.erb',
|
||||
'./app/views/scripts/_autosize_field.html.erb',
|
||||
'./app/views/send_submission_email/**/*.erb'
|
||||
]
|
||||
}
|
||||
@@ -4950,6 +4950,11 @@ sirv@^1.0.7:
|
||||
mrmime "^1.0.0"
|
||||
totalist "^1.0.0"
|
||||
|
||||
snarkdown@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/snarkdown/-/snarkdown-2.0.0.tgz#b1feb4db91b9f94a8ebbd7a50f3e99aee18b1e03"
|
||||
integrity sha512-MgL/7k/AZdXCTJiNgrO7chgDqaB9FGM/1Tvlcenenb7div6obaDATzs16JhFyHHBGodHT3B7RzRc5qk8pFhg3A==
|
||||
|
||||
sockjs@^0.3.24:
|
||||
version "0.3.24"
|
||||
resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce"
|
||||
|
||||
Reference in new issue
Block a user