Compare commits

...
65 Commits
Author SHA1 Message Date
Pete Matsyburka 505b185cf2 fix download indicator 2024-03-31 18:31:09 +03:00
Pete Matsyburka 50c32ac6ff replace date value 2024-03-31 18:20:33 +03:00
Pete Matsyburka 878ab25fd4 access account via submission 2024-03-31 17:40:51 +03:00
Pete Matsyburka 1b31772ff6 show default date on the page 2024-03-31 17:34:23 +03:00
Pete Matsyburka d4940234b7 add bulk placeholder 2024-03-30 23:36:40 +02:00
Pete Matsyburka 9bdc5d4863 adjust batch limit 2024-03-30 14:42:42 +02:00
Pete Matsyburka a73fd7929c add smtp timeout 2024-03-30 12:43:15 +02:00
Pete Matsyburka 8269a61751 adjust payment connected 2024-03-29 23:24:51 +02:00
Pete Matsyburka de072d1437 scroll to multiple checkbox field 2024-03-29 21:43:13 +02:00
Pete Matsyburka 5b24793f2b render links with markdown in emails 2024-03-29 21:21:04 +02:00
Pete Matsyburka 660f7ab8f3 password reset neutral on-prem 2024-03-29 14:50:18 +02:00
Pete Matsyburka 6e3fea5fa0 validate email param 2024-03-28 11:25:32 +02:00
Pete Matsyburka 29ecbecbc2 flatten annots 2024-03-27 18:24:41 +02:00
Pete Matsyburka ad3bbe9892 sctoll to radio group 2024-03-27 18:24:35 +02:00
Pete Matsyburka b570ebee69 remember builder selected field type 2024-03-27 14:21:30 +02:00
Pete Matsyburka 207780fa3f clear fields on remove doc 2024-03-27 00:11:10 +02:00
Pete Matsyburka 8aa219433b render blank option checkbox 2024-03-26 19:28:41 +02:00
Pete Matsyburka cb1e9f2be4 do not send completed emails to integration 2024-03-26 18:01:35 +02:00
Pete Matsyburka 202943f58d fix pidfile issue 2024-03-26 01:17:55 +02:00
Pete Matsyburka 6dd15b2091 use letter page size for us 2024-03-25 20:20:38 +02:00
Pete Matsyburka 1c55e4f18b always display default value 2024-03-25 19:16:12 +02:00
Pete Matsyburka 6cc36b1728 allow to update template roles 2024-03-25 17:37:46 +02:00
Pete Matsyburka 37751cd5c1 update template submitters 2024-03-25 16:34:45 +02:00
Pete Matsyburka 80d882cd20 add reply to param 2024-03-24 14:09:28 +02:00
Pete Matsyburka 8bef75e570 move autocomplete controllers 2024-03-23 23:52:50 +02:00
Pete Matsyburka 077eab7005 refactor templates controller 2024-03-23 23:52:49 +02:00
Pete Matsyburka 715c70d422 rename to users 2024-03-23 21:23:59 +02:00
Pete Matsyburka a3c23f9d53 update readme 2024-03-22 17:35:52 +02:00
Pete Matsyburka c1e67a8af0 add submission id var 2024-03-22 13:11:33 +02:00
Pete Matsyburka 2fdc294293 fix annot 2024-03-21 16:25:49 +02:00
Pete Matsyburka 1f49ba069e disable puma pidfile 2024-03-21 15:03:50 +02:00
Pete Matsyburka 43ef0fc0d0 use FORCE_SSL=false 2024-03-21 14:12:11 +02:00
Pete Matsyburka a1a920e536 allow to remove default value 2024-03-21 00:04:53 +02:00
Pete Matsyburka dec33b4331 add extra small field font size 2024-03-20 16:52:53 +02:00
Pete Matsyburka 432f290633 assign template fields on completed via API 2024-03-20 00:13:16 +02:00
Pete Matsyburka cf98616a27 remove log 2024-03-19 13:57:21 +02:00
Pete Matsyburka 1a720cf1ad allow to clone template from testing 2024-03-19 01:44:16 +02:00
Pete Matsyburka 557c6c34f2 search default fields 2024-03-19 01:21:44 +02:00
Pete Matsyburka 943c76505d remove api error log 2024-03-18 23:18:35 +02:00
Pete Matsyburka 66eb83e6a7 allow to search on value 2024-03-18 20:16:26 +02:00
Pete Matsyburka 0844427357 add rollbar queue 2024-03-18 19:35:19 +02:00
Pete Matsyburka 72e9d9ea2d update readme 2024-03-17 23:16:27 +02:00
Pete Matsyburka 3db7d47746 fix option remove 2024-03-17 19:40:30 +02:00
Pete Matsyburka 95899336b8 align number field 2024-03-17 19:06:00 +02:00
Pete Matsyburka f6691c110e adjust field settings 2024-03-17 17:04:39 +02:00
Pete Matsyburka 749722a9df add rate limit 2024-03-16 23:22:11 +02:00
Pete Matsyburka 265b668cb4 show bulk send message 2024-03-16 22:21:23 +02:00
Pete Matsyburka 0527e6a5c1 fix form font size 2024-03-16 01:59:00 +02:00
Pete Matsyburka d989b61d19 fix font size 2024-03-15 23:06:16 +02:00
Pete Matsyburka c1f3e6ff13 reduce image size 2024-03-15 21:16:30 +02:00
Pete Matsyburka 3c0fff2d82 skip on missing font character error 2024-03-15 20:24:12 +02:00
Pete Matsyburka bd599c264f rescue Encoding::CompatibilityError 2024-03-15 13:44:16 +02:00
Pete Matsyburka 0274093c89 save multiple document attachments 2024-03-15 13:03:10 +02:00
Pete Matsyburka 5845b5bdfd process malformed PDF 2024-03-15 12:17:52 +02:00
Pete Matsyburka 3dd0ba6b0b fix pdf font width 2024-03-14 23:10:21 +02:00
Pete Matsyburka e79306f8a3 optimize build annotations 2024-03-14 21:30:57 +02:00
Pete Matsyburka 0171b0c2dd allow to disable stamp logo 2024-03-14 14:09:43 +02:00
Pete Matsyburka a963ef53d3 adjust submitter role assign 2024-03-14 01:21:42 +02:00
Pete Matsyburka 344f302c76 refactor field validation 2024-03-13 20:25:41 +02:00
Pete Matsyburka 17ded282b2 add symbols font 2024-03-13 14:58:32 +02:00
Pete Matsyburka 2ce57df485 sanitize href 2024-03-13 11:54:56 +02:00
Pete Matsyburka 849289287e add html escape replace variable option 2024-03-12 23:47:58 +02:00
Pete Matsyburka 704fbc2393 try get log params 2024-03-11 21:34:59 +02:00
Pete Matsyburka 168fc8bd03 fix testing acocunt 2024-03-11 14:55:50 +02:00
Pete Matsyburka c5598d9efe fix replace email vars 2024-03-11 10:05:43 +02:00
86 changed files with 1319 additions and 401 deletions

No files matched your search

+2 -1
View File
@@ -8,7 +8,8 @@
}, },
"rules": { "rules": {
"vue/no-deprecated-html-element-is": 0, "vue/no-deprecated-html-element-is": 0,
"vue/no-mutating-props": 0 "vue/no-mutating-props": 0,
"vue/one-component-per-file": 0
}, },
"parserOptions": { "parserOptions": {
"ecmaVersion": 2022, "ecmaVersion": 2022,
+1 -1
View File
@@ -44,7 +44,7 @@ Layout/LineLength:
AllowedPatterns: ['\A\s*#'] AllowedPatterns: ['\A\s*#']
Metrics/AbcSize: Metrics/AbcSize:
Max: 40 Max: 45
Metrics/ModuleLength: Metrics/ModuleLength:
Max: 500 Max: 500
+7 -5
View File
@@ -2,7 +2,9 @@ FROM ruby:3.2.2-alpine3.18 as fonts
WORKDIR /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 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 RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test" ENV BUNDLE_WITHOUT="development:test"
ENV PIDFILE=/dev/null
WORKDIR /app WORKDIR /app
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
COPY ./Gemfile ./Gemfile.lock ./ COPY ./Gemfile ./Gemfile.lock ./
RUN bundle update --bundler && bundle install && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf RUN apk add --no-cache build-base && bundle update --bundler && bundle install && apk del build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
COPY ./bin ./bin COPY ./bin ./bin
COPY ./app ./app COPY ./app ./app
@@ -55,6 +56,7 @@ COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./ 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/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=fonts /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --from=webpack /app/public/packs ./public/packs COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts RUN ln -s /fonts /app/public/fonts
@@ -64,4 +66,4 @@ WORKDIR /data/docuseal
ENV WORKDIR=/data/docuseal ENV WORKDIR=/data/docuseal
EXPOSE 3000 EXPOSE 3000
CMD ["/app/bin/rails", "server"] CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
+10 -5
View File
@@ -51,11 +51,13 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- User roles - User roles
- Automated reminders - Automated reminders
- Invitation and identify verification via SMS - Invitation and identify verification via SMS
- Conditional fields and formulas
- Bulk send with CSV, XLSX spreadsheet import
- SSO / SAML - SSO / SAML
- Template creation with HTML API ([Guide](https://www.docuseal.co/guides/create-pdf-document-fillable-form-with-html-api)) - 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)) - 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 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) 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) - [Learn more](https://www.docuseal.co/pricing)
## Deploy ## Deploy
@@ -65,9 +67,6 @@ DocuSeal is an open source platform that provides secure and efficient digital d
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)| | [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|**DigitalOcean**|**Render**| |**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render) | [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
|**Koyeb**|**Elestio**|
| [<img alt="Deploy on Koyeb" src="https://www.koyeb.com/static/images/deploy/button.svg" height="40">](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | [<img alt="Deploy on Elestio" src="https://pub-da36157c854648669813f3f76c526c2b.r2.dev/deploy-on-elestio-black.png">](https://dash.elest.io/deploy?soft=DocuSeal&id=339) |
#### Docker #### Docker
@@ -100,3 +99,9 @@ At DocuSeal we have expertise and technologies to make documents creation, filli
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information. Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information.
Unless otherwise noted, all files © 2023 DocuSeal LLC. Unless otherwise noted, all files © 2023 DocuSeal LLC.
## Tools
- [Signature Maker](https://www.docuseal.co/online-signature)
- [Sign Document Online](https://www.docuseal.co/sign-documents-online)
- [Fill PDF Online](https://www.docuseal.co/fill-pdf)
+6 -2
View File
@@ -19,10 +19,14 @@ module Api
render json: { error: e.message }, status: :unprocessable_entity render json: { error: e.message }, status: :unprocessable_entity
end end
rescue_from RateLimit::LimitApproached do |e|
Rollbar.error(e) if defined?(Rollbar)
render json: { error: 'Too many requests' }, status: :too_many_requests
end
if Rails.env.production? if Rails.env.production?
rescue_from CanCan::AccessDenied do |e| rescue_from CanCan::AccessDenied do |e|
Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :forbidden render json: { error: e.message }, status: :forbidden
end end
@@ -161,11 +161,11 @@ module Api
def submissions_params def submissions_params
permitted_attrs = [ permitted_attrs = [
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to,
{ {
message: %i[subject body], message: %i[subject body],
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role, submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :completed, :phone, :application_key, :external_id, :reply_to,
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body], { metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
fields: [%i[name default_value title description fields: [%i[name default_value title description
readonly validation_pattern invalid_message]] }]] readonly validation_pattern invalid_message]] }]]
@@ -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
@@ -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
+10 -15
View File
@@ -49,6 +49,14 @@ module Api
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name) @template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
end end
Array.wrap(params[:roles].presence || params.dig(:template, :roles).presence).each_with_index do |role, index|
if (item = @template.submitters[index])
item['name'] = role
else
@template.submitters << { 'name' => role, 'uuid' => SecureRandom.uuid }
end
end
archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived) archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived)
if archived.in?([true, false]) if archived.in?([true, false])
@@ -83,23 +91,10 @@ module Api
end end
def template_params 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,
:title, :description,
{ 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) if params.key?(:template)
params.require(:template).permit(*permit_params) params.require(:template).permit(:name, submitters: [%i[name uuid]])
else else
params.permit(*permit_params) params.permit(:name, submitters: [%i[name uuid]])
end end
end end
end end
@@ -1,29 +0,0 @@
# frozen_string_literal: true
module Api
class TemplatesDocumentsController < ApiBaseController
load_and_authorize_resource :template
def create
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
documents = Templates::CreateAttachments.call(@template, params)
schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base }
end
render json: {
schema:,
documents: documents.as_json(
methods: %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 redirect_to request.path
end end
rescue_from RateLimit::LimitApproached do |e|
Rollbar.error(e) if defined?(Rollbar)
redirect_to request.referer, alert: 'Too many requests', status: :too_many_requests
end
if Rails.env.production? if Rails.env.production?
rescue_from CanCan::AccessDenied do |e| rescue_from CanCan::AccessDenied do |e|
Rollbar.warning(e) if defined?(Rollbar) Rollbar.warning(e) if defined?(Rollbar)
@@ -5,6 +5,10 @@ class ConsoleRedirectController < ApplicationController
skip_authorization_check skip_authorization_check
def index def index
if request.path == '/upgrade'
params[:redir] = Docuseal.multitenant? ? "#{Docuseal::CONSOLE_URL}/plans" : "#{Docuseal::CONSOLE_URL}/on_premise"
end
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank? return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank?
auth = JsonWebToken.encode(uuid: true_user.uuid, auth = JsonWebToken.encode(uuid: true_user.uuid,
+6
View File
@@ -5,6 +5,12 @@ class PasswordsController < Devise::PasswordsController
attribute :user attribute :user
end end
def create
super do |resource|
resource.errors.clear unless Docuseal.multitenant?
end
end
def update def update
super do |resource| super do |resource|
Current.user = resource Current.user = resource
@@ -21,6 +21,8 @@ class SendSubmissionEmailController < ApplicationController
Submitter.find_by!(slug: params[:submitter_slug]) Submitter.find_by!(slug: params[:submitter_slug])
end end
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later!
respond_to do |f| 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 def index
@submissions = @submissions.where.not(archived_at: nil) @submissions = @submissions.where.not(archived_at: nil)
@submissions = Submissions.search(@submissions, params[:q]) @submissions = Submissions.search(@submissions, params[:q], search_values: true)
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc)) @pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
+23 -5
View File
@@ -8,7 +8,7 @@ class TemplatesController < ApplicationController
def show def show
submissions = @template.submissions.accessible_by(current_ability) submissions = @template.submissions.accessible_by(current_ability)
submissions = submissions.active if @template.archived_at.blank? submissions = submissions.active if @template.archived_at.blank?
submissions = Submissions.search(submissions, params[:q]) submissions = Submissions.search(submissions, params[:q], search_values: true)
@base_submissions = submissions @base_submissions = submissions
@@ -67,6 +67,12 @@ class TemplatesController < ApplicationController
end end
end end
def update
@template.update!(template_params)
head :ok
end
def destroy def destroy
notice = notice =
if params[:permanently].present? if params[:permanently].present?
@@ -86,6 +92,22 @@ class TemplatesController < ApplicationController
private 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) 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) true_user.account_id.to_s == account_id.to_s || true_user.account.linked_accounts.exists?(id: account_id)
end end
@@ -98,10 +120,6 @@ class TemplatesController < ApplicationController
end end
end end
def template_params
params.require(:template).permit(:name)
end
def load_base_template def load_base_template
return if params[:base_template_id].blank? return if params[:base_template_id].blank?
+25
View File
@@ -3,6 +3,7 @@ import { encodeMethodIntoRequestBody } from '@hotwired/turbo-rails/app/javascrip
import { createApp, reactive } from 'vue' import { createApp, reactive } from 'vue'
import TemplateBuilder from './template_builder/builder' import TemplateBuilder from './template_builder/builder'
import ImportList from './template_builder/import_list'
import ToggleVisible from './elements/toggle_visible' import ToggleVisible from './elements/toggle_visible'
import DisableHidden from './elements/disable_hidden' import DisableHidden from './elements/disable_hidden'
@@ -20,6 +21,7 @@ import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form' import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form' import SubmitForm from './elements/submit_form'
import PromptPassword from './elements/prompt_password' import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -53,6 +55,7 @@ window.customElements.define('folder-autocomplete', FolderAutocomplete)
window.customElements.define('signature-form', SignatureForm) window.customElements.define('signature-form', SignatureForm)
window.customElements.define('submit-form', SubmitForm) window.customElements.define('submit-form', SubmitForm)
window.customElements.define('prompt-password', PromptPassword) window.customElements.define('prompt-password', PromptPassword)
window.customElements.define('emails-textarea', EmailsTextarea)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody) document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => { document.addEventListener('turbo:submit-end', async (event) => {
@@ -89,7 +92,9 @@ window.customElements.define('template-builder', class extends HTMLElement {
withPhone: this.dataset.withPhone === 'true', withPhone: this.dataset.withPhone === 'true',
withLogo: this.dataset.withLogo !== 'false', withLogo: this.dataset.withLogo !== 'false',
editable: this.dataset.editable !== 'false', editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withPayment: this.dataset.withPayment === 'true', withPayment: this.dataset.withPayment === 'true',
isPaymentConnected: this.dataset.isPaymentConnected === 'true',
withFormula: this.dataset.withFormula === 'true', withFormula: this.dataset.withFormula === 'true',
withConditions: this.dataset.withConditions === 'true', withConditions: this.dataset.withConditions === 'true',
currencies: (this.dataset.currencies || '').split(',').filter(Boolean), currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
@@ -107,3 +112,23 @@ window.customElements.define('template-builder', class extends HTMLElement {
this.appElem?.remove() this.appElem?.remove()
} }
}) })
window.customElements.define('import-list', class extends HTMLElement {
connectedCallback () {
this.appElem = document.createElement('div')
this.app = createApp(ImportList, {
template: JSON.parse(this.dataset.template),
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content
})
this.app.mount(this.appElem)
this.appendChild(this.appElem)
}
disconnectedCallback () {
this.app?.unmount()
this.appElem?.remove()
}
})
+5
View File
@@ -83,6 +83,11 @@ button[disabled] .enabled {
bottom: auto; bottom: auto;
} }
.tooltip-pre:before {
white-space: pre;
text-align: left;
}
.tooltip-bottom-end:after { .tooltip-bottom-end:after {
transform: translateX(-25%); transform: translateX(-25%);
border-color: transparent transparent var(--tooltip-color) transparent; border-color: transparent transparent var(--tooltip-color) transparent;
@@ -0,0 +1,56 @@
const emailRegexp = /([^@;,<>\s]+@[^@;,<>\s]+)/g
export default class extends HTMLElement {
connectedCallback () {
if (this.dataset.limit) {
this.textarea.addEventListener('input', () => {
const emails = this.textarea.value.match(emailRegexp) || []
this.updateCounter(emails.length)
})
}
}
updateCounter (count) {
let counter = document.getElementById('emails_counter')
let bulkMessage = document.getElementById('bulk_message')
if (count < 2) {
counter?.remove()
return
}
if ((count + 10) > this.dataset.limit) {
if (!counter) {
counter = document.createElement('span')
counter.id = 'emails_counter'
counter.classList.add('text-xs', 'right-0', 'absolute')
counter.style.bottom = '-15px'
this.textarea.parentNode.append(counter)
}
counter.innerText = `${count} / ${this.dataset.limit}`
}
if (this.dataset.bulkEnabled !== 'true') {
if (!bulkMessage) {
bulkMessage = document.createElement('span')
bulkMessage.id = 'bulk_message'
bulkMessage.classList.add('text-xs', 'left-0', 'absolute')
bulkMessage.style.bottom = '-15px'
this.textarea.parentNode.append(bulkMessage)
}
bulkMessage.innerHTML = '<a class="link" data-turbo="false" href="/upgrade">Upgrade</a> to bulk send multiple recipients'
}
}
get textarea () {
return this.querySelector('textarea')
}
}
@@ -20,7 +20,7 @@ export default class extends HTMLElement {
fetch = (text, resolve) => { fetch = (text, resolve) => {
const queryParams = new URLSearchParams({ q: text }) const queryParams = new URLSearchParams({ q: text })
fetch('/api/template_folders_autocomplete?' + queryParams).then(async (resp) => { fetch('/template_folders_autocomplete?' + queryParams).then(async (resp) => {
const items = await resp.json() const items = await resp.json()
resolve(items) resolve(items)
@@ -28,6 +28,8 @@ export default class extends HTMLElement {
if (textarea && item[field]) { if (textarea && item[field]) {
textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ') textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ')
textarea.dispatchEvent(new Event('input', { bubbles: true }))
} }
}) })
} }
@@ -38,7 +40,7 @@ export default class extends HTMLElement {
if (q) { if (q) {
const queryParams = new URLSearchParams({ q, field: this.dataset.field }) const queryParams = new URLSearchParams({ q, field: this.dataset.field })
this.currentFetch ||= fetch('/api/submitters_autocomplete?' + queryParams) this.currentFetch ||= fetch('/submitters_autocomplete?' + queryParams)
this.currentFetch.then(async (resp) => { this.currentFetch.then(async (resp) => {
const items = await resp.json() const items = await resp.json()
+1
View File
@@ -17,6 +17,7 @@ window.customElements.define('submission-form', class extends HTMLElement {
attribution: this.dataset.attribution !== 'false', attribution: this.dataset.attribution !== 'false',
withConfetti: this.dataset.withConfetti !== 'false', withConfetti: this.dataset.withConfetti !== 'false',
withTypedSignature: this.dataset.withTypedSignature !== 'false', withTypedSignature: this.dataset.withTypedSignature !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
values: reactive(JSON.parse(this.dataset.values)), values: reactive(JSON.parse(this.dataset.values)),
completedButton: JSON.parse(this.dataset.completedButton), completedButton: JSON.parse(this.dataset.completedButton),
completedRedirectUrl: this.dataset.completedRedirectUrl, completedRedirectUrl: this.dataset.completedRedirectUrl,
+19 -8
View File
@@ -157,6 +157,7 @@
ref="textContainer" ref="textContainer"
dir="auto" dir="auto"
class="flex items-center px-0.5 w-full" class="flex items-center px-0.5 w-full"
:class="alignClasses[field.preferences?.align]"
> >
<span v-if="Array.isArray(modelValue)"> <span v-if="Array.isArray(modelValue)">
{{ modelValue.join(', ') }} {{ modelValue.join(', ') }}
@@ -167,6 +168,7 @@
<span <span
v-else v-else
class="whitespace-pre-wrap" class="whitespace-pre-wrap"
:class="{ 'w-full': field.preferences?.align }"
>{{ modelValue }}</span> >{{ modelValue }}</span>
</div> </div>
</div> </div>
@@ -253,6 +255,13 @@ export default {
phone: this.t('phone') phone: this.t('phone')
} }
}, },
alignClasses () {
return {
center: 'text-center',
left: 'text-left',
right: 'text-right'
}
},
option () { option () {
return this.field.options.find((o) => o.uuid === this.area.option_uuid) return this.field.options.find((o) => o.uuid === this.area.option_uuid)
}, },
@@ -338,17 +347,19 @@ export default {
}, },
watch: { watch: {
modelValue () { modelValue () {
if (this.field.type === 'text' && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.modelValue.length)) { this.$nextTick(() => {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0 if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.modelValue}`.length)) {
} this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue}`.length : 0
}
})
} }
}, },
mounted () { mounted () {
if (this.field.type === 'text' && this.$refs.textContainer) { this.$nextTick(() => {
this.$nextTick(() => { if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0 this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue}`.length : 0
}) }
} })
}, },
methods: { methods: {
optionValue (option) { optionValue (option) {
+10 -4
View File
@@ -16,7 +16,8 @@
<div class="space-y-3 mt-5"> <div class="space-y-3 mt-5">
<a <a
v-if="completedButton.url" v-if="completedButton.url"
:href="completedButton.url" :href="sanitizeHref(completedButton.url)"
rel="noopener noreferrer nofollow"
class="white-button flex items-center w-full" class="white-button flex items-center w-full"
> >
<span> <span>
@@ -211,9 +212,14 @@ export default {
fileRequests.reduce( fileRequests.reduce(
(prevPromise, request) => prevPromise.then(() => request()), (prevPromise, request) => prevPromise.then(() => request()),
Promise.resolve() Promise.resolve()
) ).finally(() => {
this.isDownloading = false
this.isDownloading = false })
},
sanitizeHref (href) {
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
return href.replace(/javascript:/g, '')
}
}, },
downloadSafariIos (urls) { downloadSafariIos (urls) {
const fileRequests = urls.map((url) => { const fileRequests = urls.map((url) => {
+6 -3
View File
@@ -208,6 +208,7 @@
:name="`values[${currentField.uuid}]`" :name="`values[${currentField.uuid}]`"
:value="option.value || `${t('option')} ${index + 1}`" :value="option.value || `${t('option')} ${index + 1}`"
:required="currentField.required" :required="currentField.required"
@click="scrollIntoField(currentField)"
> >
<span class="text-xl"> <span class="text-xl">
{{ option.value || `${t('option')} ${index + 1}` }} {{ option.value || `${t('option')} ${index + 1}` }}
@@ -611,6 +612,11 @@ export default {
required: false, required: false,
default: () => ({}) default: () => ({})
}, },
authenticityToken: {
type: String,
required: false,
default: ''
},
i18n: { i18n: {
type: Object, type: Object,
required: false, required: false,
@@ -658,9 +664,6 @@ export default {
queryParams () { queryParams () {
return new URLSearchParams(window.location.search) return new URLSearchParams(window.location.search)
}, },
authenticityToken () {
return document.querySelector('meta[name="csrf-token"]')?.content
},
submitterSlug () { submitterSlug () {
return this.submitter.slug return this.submitter.slug
}, },
@@ -6,7 +6,7 @@
> >
<a <a
v-if="item.startsWith('<a') && item.endsWith('</a>')" v-if="item.startsWith('<a') && item.endsWith('</a>')"
:href="extractAttr(item, 'href')" :href="sanitizeHref(extractAttr(item, 'href'))"
rel="noopener noreferrer nofollow" rel="noopener noreferrer nofollow"
:class="extractAttr(item, 'class') || 'link'" :class="extractAttr(item, 'class') || 'link'"
target="_blank" target="_blank"
@@ -55,6 +55,11 @@ export default {
} }
}, },
methods: { methods: {
sanitizeHref (href) {
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
return href.replace(/javascript:/g, '')
}
},
extractAttr (text, attr) { extractAttr (text, attr) {
if (text.includes(attr)) { if (text.includes(attr)) {
return text.split(attr).pop().split('"')[1] return text.split(attr).pop().split('"')[1]
@@ -45,6 +45,7 @@
<label <label
:for="option.uuid" :for="option.uuid"
class="flex items-center space-x-3" class="flex items-center space-x-3"
@click="scrollIntoField(field)"
> >
<input <input
:id="option.uuid" :id="option.uuid"
@@ -68,14 +68,13 @@
class="base-input !text-2xl w-full" class="base-input !text-2xl w-full"
autocomplete="tel" autocomplete="tel"
pattern="^\+[0-9\s\-]+$" pattern="^\+[0-9\s\-]+$"
:oninvalid="`this.value ? this.setCustomValidity('${t('use_international_format')}...') : ''`"
oninput="this.setCustomValidity('')"
type="tel" type="tel"
inputmode="tel" inputmode="tel"
:required="field.required" :required="field.required"
placeholder="+1 234 567-8900" placeholder="+1 234 567-8900"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@input="$emit('update:model-value', $event.target.value)" @invalid="$event.target.value ? $event.target.setCustomValidity(`${t('use_international_format')}...`) : ''"
@input="[$event.target.setCustomValidity(''), $emit('update:model-value', $event.target.value)]"
@focus="$emit('focus')" @focus="$emit('focus')"
> >
</div> </div>
+2 -2
View File
@@ -35,11 +35,11 @@
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }" :class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
:required="field.required" :required="field.required"
:pattern="field.validation?.pattern" :pattern="field.validation?.pattern"
:oninvalid="field.validation?.message ? `this.setCustomValidity(${JSON.stringify(field.validation.message)})` : ''"
:oninput="field.validation?.message ? `this.setCustomValidity('')` : ''"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`" :placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text" type="text"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@invalid="field.validation?.message ? $event.target.setCustomValidity(field.validation.message) : ''"
@input="field.validation?.message ? $event.target.setCustomValidity('') : ''"
@focus="$emit('focus')" @focus="$emit('focus')"
> >
<textarea <textarea
+19 -10
View File
@@ -42,7 +42,7 @@
v-model="field.submitter_uuid" v-model="field.submitter_uuid"
class="border-r" class="border-r"
:compact="true" :compact="true"
:editable="editable && !defaultField" :editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
:allow-add-new="!defaultSubmitters.length" :allow-add-new="!defaultSubmitters.length"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'" :menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
:submitters="template.submitters" :submitters="template.submitters"
@@ -99,7 +99,7 @@
<div <div
class="flex items-center h-full w-full" class="flex items-center h-full w-full"
dir="auto" dir="auto"
:class="[bgColors[submitterIndex], field?.default_value ? '' : 'justify-center']" :class="[bgColors[submitterIndex], field?.default_value ? (alignClasses[field.preferences?.align] || '') : 'justify-center']"
> >
<span <span
v-if="field" v-if="field"
@@ -211,6 +211,13 @@ export default {
defaultName () { defaultName () {
return this.buildDefaultName(this.field, this.template.fields) return this.buildDefaultName(this.field, this.template.fields)
}, },
alignClasses () {
return {
center: 'justify-center',
left: 'justify-start',
right: 'justify-end'
}
},
optionIndexText () { optionIndexText () {
if (this.area.option_uuid && this.field.options) { if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.` return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
@@ -301,17 +308,19 @@ export default {
}, },
watch: { watch: {
'field.default_value' () { 'field.default_value' () {
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value.length)) { this.$nextTick(() => {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0 if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
} this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
}
})
} }
}, },
mounted () { mounted () {
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value)) { this.$nextTick(() => {
this.$nextTick(() => { 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 this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
}) }
} })
}, },
methods: { methods: {
buildDefaultName: Field.methods.buildDefaultName, buildDefaultName: Field.methods.buildDefaultName,
+112 -47
View File
@@ -166,9 +166,10 @@
:allow-draw="!onlyDefinedFields" :allow-draw="!onlyDefinedFields"
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:draw-field="drawField" :draw-field="drawField"
:draw-field-type="drawFieldType"
:editable="editable" :editable="editable"
:base-url="baseUrl" :base-url="baseUrl"
@draw="onDraw" @draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
@drop-field="onDropfield" @drop-field="onDropfield"
@remove-area="removeArea" @remove-area="removeArea"
/> />
@@ -210,13 +211,13 @@
:class="drawField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'" :class="drawField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'"
> >
<div <div
v-if="drawField" v-if="showDrawField || drawField"
class="sticky inset-0 h-full z-20" class="sticky inset-0 h-full z-20"
:style="{ backgroundColor }" :style="{ backgroundColor }"
> >
<div class="bg-base-300 rounded-lg p-5 text-center space-y-4"> <div class="bg-base-200 rounded-lg p-5 text-center space-y-4">
<p> <p>
{{ t('draw_field_on_the_document').replace('{field}', drawField.name) }} {{ t('draw_field_on_the_document').replace('{field}', drawField?.name || '') }}
</p> </p>
<div> <div>
<button <button
@@ -226,10 +227,10 @@
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<a <a
v-if="!drawOption && !drawField.areas.length && !['stamp', 'signature', 'initials'].includes(drawField.type)" v-if="!drawField && !drawOption && !['stamp', 'signature', 'initials'].includes(drawField?.type || drawFieldType)"
href="#" href="#"
class="link block mt-3 text-sm" class="link block mt-3 text-sm"
@click.prevent="[drawField = null, drawOption = null]" @click.prevent="[addField(drawFieldType), drawField = null, drawOption = null, withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
> >
{{ t('or_add_field_without_drawing') }} {{ t('or_add_field_without_drawing') }}
</a> </a>
@@ -244,12 +245,15 @@
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:with-help="withHelp" :with-help="withHelp"
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:draw-field-type="drawFieldType"
:default-fields="defaultFields" :default-fields="defaultFields"
:field-types="fieldTypes" :field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters" :with-sticky-submitters="withStickySubmitters"
:only-defined-fields="onlyDefinedFields" :only-defined-fields="onlyDefinedFields"
:editable="editable" :editable="editable"
@add-field="addField"
@set-draw="[drawField = $event.field, drawOption = $event.option]" @set-draw="[drawField = $event.field, drawOption = $event.option]"
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
@set-drag="dragField = $event" @set-drag="dragField = $event"
@change-submitter="selectedSubmitter = $event" @change-submitter="selectedSubmitter = $event"
@drag-end="dragField = null" @drag-end="dragField = null"
@@ -328,6 +332,7 @@ export default {
backgroundColor: this.backgroundColor, backgroundColor: this.backgroundColor,
withPhone: this.withPhone, withPhone: this.withPhone,
withPayment: this.withPayment, withPayment: this.withPayment,
isPaymentConnected: this.isPaymentConnected,
withFormula: this.withFormula, withFormula: this.withFormula,
withConditions: this.withConditions, withConditions: this.withConditions,
defaultDrawFieldType: this.defaultDrawFieldType, defaultDrawFieldType: this.defaultDrawFieldType,
@@ -375,6 +380,11 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
withSelectedFieldType: {
type: Boolean,
required: false,
default: false
},
defaultDrawFieldType: { defaultDrawFieldType: {
type: String, type: String,
required: false, required: false,
@@ -444,6 +454,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
authenticityToken: {
type: String,
required: false,
default: ''
},
withDocumentsList: { withDocumentsList: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -459,6 +474,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
isPaymentConnected: {
type: Boolean,
required: false,
default: false
},
withFormula: { withFormula: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -486,7 +506,9 @@ export default {
isBreakpointLg: false, isBreakpointLg: false,
isSaving: false, isSaving: false,
selectedSubmitter: null, selectedSubmitter: null,
showDrawField: false,
drawField: null, drawField: null,
drawFieldType: null,
drawOption: null, drawOption: null,
dragField: null dragField: null
} }
@@ -569,6 +591,34 @@ export default {
t (key) { t (key) {
return this.i18n[key] || i18nEn[key] || key return this.i18n[key] || i18nEn[key] || key
}, },
addField (type, area = null) {
const field = {
name: '',
uuid: v4(),
required: type !== 'checkbox',
areas: area ? [area] : [],
submitter_uuid: this.selectedSubmitter.uuid,
type
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = [{ value: '', uuid: v4() }]
}
if (type === 'stamp') {
field.readonly = true
}
if (type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
}
this.template.fields.push(field)
this.save()
},
startFieldDraw ({ name, type }) { startFieldDraw ({ name, type }) {
const existingField = this.template.fields?.find((f) => f.submitter_uuid === this.selectedSubmitter.uuid && name && name === f.name) const existingField = this.template.fields?.find((f) => f.submitter_uuid === this.selectedSubmitter.uuid && name && name === f.name)
@@ -649,15 +699,13 @@ export default {
ref.$el.scrollIntoView({ behavior: 'smooth', block: 'start' }) ref.$el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, },
clearDrawField () { clearDrawField () {
if (this.drawField && !this.drawOption && this.drawField.areas.length === 0) {
const fieldIndex = this.template.fields.indexOf(this.drawField)
if (fieldIndex !== -1) {
this.template.fields.splice(fieldIndex, 1)
}
}
this.drawField = null this.drawField = null
this.drawOption = null this.drawOption = null
this.showDrawField = false
if (!this.withSelectedFieldType) {
this.drawFieldType = ''
}
}, },
onKeyUp (e) { onKeyUp (e) {
if (e.code === 'Escape') { if (e.code === 'Escape') {
@@ -707,6 +755,27 @@ export default {
} }
} }
}, },
setDefaultAreaSize (area, type) {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const pageMask = documentRef.pageRefs[area.page].$refs.mask
if (type === 'checkbox') {
area.w = pageMask.clientWidth / 30 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 30 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (type === 'image') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (type === 'signature' || type === 'stamp') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight) / 2
} else if (type === 'initials') {
area.w = pageMask.clientWidth / 10 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
} else {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
}
},
onDraw (area) { onDraw (area) {
if (this.drawField) { if (this.drawField) {
if (this.drawOption) { if (this.drawOption) {
@@ -729,25 +798,7 @@ export default {
area.w = previousArea.w area.w = previousArea.w
area.h = previousArea.h area.h = previousArea.h
} else { } else {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid) this.setDefaultAreaSize(area, this.drawOption ? 'checkbox' : this.drawField?.type)
const pageMask = documentRef.pageRefs[area.page].$refs.mask
if (this.drawField.type === 'checkbox' || this.drawOption) {
area.w = pageMask.clientWidth / 30 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 30 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (this.drawField.type === 'image') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (this.drawField.type === 'signature' || this.drawField.type === 'stamp') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight) / 2
} else if (this.drawField.type === 'initials') {
area.w = pageMask.clientWidth / 10 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
} else {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
}
} }
area.x -= area.w / 2 area.x -= area.w / 2
@@ -782,13 +833,15 @@ export default {
let type = (pageMask.clientWidth * area.w) < 35 ? 'checkbox' : 'text' let type = (pageMask.clientWidth * area.w) < 35 ? 'checkbox' : 'text'
if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') { if (this.drawFieldType) {
type = this.drawFieldType
} else if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
type = this.defaultDrawFieldType type = this.defaultDrawFieldType
} else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) { } else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) {
type = this.fieldTypes[0] type = this.fieldTypes[0]
} }
if (type === 'checkbox') { if (type === 'checkbox' && !this.drawFieldType) {
const previousField = [...this.template.fields].reverse().find((f) => f.type === type) const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
const previousArea = previousField?.areas?.[previousField.areas.length - 1] const previousArea = previousField?.areas?.[previousField.areas.length - 1]
@@ -806,21 +859,22 @@ export default {
} }
} }
if (area.w) { if (this.drawFieldType && (area.w === 0 || area.h === 0)) {
const field = { if (this.selectedField?.type === this.drawFieldType) {
name: '', area.w = this.selectedAreaRef.value.w
uuid: v4(), area.h = this.selectedAreaRef.value.h
required: type !== 'checkbox', } else {
type, this.setDefaultAreaSize(area, this.drawFieldType)
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
} }
this.template.fields.push(field) area.x -= area.w / 2
area.y -= area.h / 2
}
if (area.w) {
this.addField(type, area)
this.selectedAreaRef.value = area this.selectedAreaRef.value = area
this.save()
} }
} }
}, },
@@ -951,14 +1005,21 @@ export default {
this.template.schema.splice(this.template.schema.indexOf(item), 1) this.template.schema.splice(this.template.schema.indexOf(item), 1)
} }
const removedFieldUuids = []
this.template.fields.forEach((field) => { this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => { [...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === item.attachment_uuid) { if (area.attachment_uuid === item.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1) field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid)
} }
}) })
}) })
this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
this.save() this.save()
}, },
onDocumentReplace ({ replaceSchemaItem, schema, documents }) { onDocumentReplace ({ replaceSchemaItem, schema, documents }) {
@@ -1023,7 +1084,11 @@ export default {
baseFetch (path, options = {}) { baseFetch (path, options = {}) {
return fetch(this.baseUrl + path, { return fetch(this.baseUrl + path, {
...options, ...options,
headers: { ...this.fetchOptions.headers, ...options.headers } headers: {
'X-CSRF-Token': this.authenticityToken,
...this.fetchOptions.headers,
...options.headers
}
}) })
}, },
save ({ force } = { force: false }) { save ({ force } = { force: false }) {
@@ -1039,7 +1104,7 @@ export default {
this.pushUndo() this.pushUndo()
return this.baseFetch(`/api/templates/${this.template.id}`, { return this.baseFetch(`/templates/${this.template.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
template: { template: {
@@ -12,6 +12,7 @@
:default-fields="defaultFields" :default-fields="defaultFields"
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:draw-field="drawField" :draw-field="drawField"
:draw-field-type="drawFieldType"
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:image="image" :image="image"
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })" @drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
@@ -43,6 +44,11 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
drawFieldType: {
type: String,
required: false,
default: ''
},
defaultSubmitters: { defaultSubmitters: {
type: Array, type: Array,
required: false, required: false,
+1 -1
View File
@@ -5,7 +5,7 @@
@drop.prevent="onDropFiles" @drop.prevent="onDropFiles"
> >
<label <label
class="w-full relative bg-base-200 hover:bg-base-200/70 rounded-md border border-base-content border-dashed" class="w-full relative hover:bg-base-200/30 rounded-md border border-2 border-base-content/10 border-dashed"
:for="inputId" :for="inputId"
:class="{ 'opacity-50': isLoading || isProcessing }" :class="{ 'opacity-50': isLoading || isProcessing }"
> >
+60 -8
View File
@@ -116,15 +116,41 @@
@click="closeDropdown" @click="closeDropdown"
> >
<div <div
v-if="field.type === 'text' && !defaultField" v-if="['number'].includes(field.type)"
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal"
@change="[field.preferences ||= {}, field.preferences.align = $event.target.value, save()]"
>
<option
v-for="value in ['left', 'right', 'center']"
:key="value"
:selected="field.preferences?.align ? value === field.preferences.align : value === 'left'"
:value="value"
>
{{ t(value) }}
</option>
</select>
<label
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('align') }}
</label>
</div>
<div
v-if="['text', 'number'].includes(field.type) && !defaultField"
class="py-1.5 px-1 relative" class="py-1.5 px-1 relative"
@click.stop @click.stop
> >
<input <input
v-model="field.default_value" v-model="field.default_value"
type="text"
:placeholder="t('default_value')" :placeholder="t('default_value')"
dir="auto" dir="auto"
:type="field.type"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0" class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
@blur="save" @blur="save"
> >
@@ -202,7 +228,7 @@
</label> </label>
</div> </div>
<li <li
v-if="field.type != 'phone'" v-if="field.type != 'phone' && field.type != 'stamp'"
@click.stop @click.stop
> >
<label class="cursor-pointer py-1.5"> <label class="cursor-pointer py-1.5">
@@ -215,6 +241,20 @@
<span class="label-text">{{ t('required') }}</span> <span class="label-text">{{ t('required') }}</span>
</label> </label>
</li> </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 <li
v-if="field.type == 'checkbox'" v-if="field.type == 'checkbox'"
@click.stop @click.stop
@@ -244,7 +284,7 @@
</label> </label>
</li> </li>
<li <li
v-if="field.type === 'text' && !defaultField" v-if="['text', 'number'].includes(field.type) && !defaultField"
@click.stop @click.stop
> >
<label class="cursor-pointer py-1.5"> <label class="cursor-pointer py-1.5">
@@ -257,8 +297,13 @@
<span class="label-text">{{ t('read_only') }}</span> <span class="label-text">{{ t('read_only') }}</span>
</label> </label>
</li> </li>
<hr class="pb-0.5 mt-0.5"> <hr
<li> v-if="field.type != 'stamp'"
class="pb-0.5 mt-0.5"
>
<li
v-if="field.type != 'stamp'"
>
<label <label
class="label-text cursor-pointer text-center w-full flex items-center" class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowDescriptionModal = !isShowDescriptionModal" @click="isShowDescriptionModal = !isShowDescriptionModal"
@@ -271,7 +316,9 @@
</span> </span>
</label> </label>
</li> </li>
<li> <li
v-if="field.type != 'stamp'"
>
<label <label
class="label-text cursor-pointer text-center w-full flex items-center" class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowConditionsModal = !isShowConditionsModal" @click="isShowConditionsModal = !isShowConditionsModal"
@@ -664,7 +711,12 @@ export default {
}, },
removeOption (option) { removeOption (option) {
this.field.options.splice(this.field.options.indexOf(option), 1) this.field.options.splice(this.field.options.indexOf(option), 1)
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
const optionIndex = this.field.areas.findIndex((a) => a.option_uuid === option.uuid)
if (optionIndex !== -1) {
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
}
this.save() this.save()
}, },
+83 -60
View File
@@ -36,32 +36,63 @@
</div> </div>
<div v-if="submitterDefaultFields.length"> <div v-if="submitterDefaultFields.length">
<hr class="mb-2"> <hr class="mb-2">
<template <template v-if="isShowFieldSearch">
v-for="field in submitterDefaultFields" <input
:key="field.name" v-model="defaultFieldsSearch"
:placeholder="t('search_field')"
class="input input-ghost input-xs px-0 text-base mb-2 !outline-0 !rounded bg-transparent w-full"
>
<hr class="mb-2">
</template>
<div
class="overflow-auto relative"
:style="{
maxHeight: isShowFieldSearch ? '210px' : '',
minHeight: isShowFieldSearch ? '210px' : ''
}"
> >
<div <div
:style="{ backgroundColor: backgroundColor }" v-if="!filteredSubmitterDefaultFields.length && defaultFieldsSearch"
draggable="true" class="top-0 bottom-0 text-center absolute flex items-center justify-center w-full flex-col"
class="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>
<div class="flex items-center p-1 space-x-1"> {{ t('field_not_found') }}
<IconDrag /> </div>
<FieldType <a
:model-value="field.type || 'text'" href="#"
:editable="false" class="link"
:button-width="20" @click.prevent="defaultFieldsSearch = ''"
/> >
<span class="block pl-0.5"> {{ t('clear') }}
{{ field.name }} </a>
</span> </div>
<template
v-for="field in filteredSubmitterDefaultFields"
:key="field.name"
>
<div
:style="{ backgroundColor: backgroundColor }"
draggable="true"
class="default-field border border-base-300 rounded rounded-tr-none relative group mb-2"
@dragstart="onDragstart({ type: 'text', ...field })"
@dragend="$emit('drag-end')"
>
<div class="flex items-center justify-between relative cursor-grab">
<div class="flex items-center p-1 space-x-1">
<IconDrag />
<FieldType
:model-value="field.type || 'text'"
:editable="false"
:button-width="20"
/>
<span class="block pl-0.5">
{{ field.name }}
</span>
</div>
</div> </div>
</div> </div>
</div> </template>
</template> </div>
</div> </div>
<div <div
v-if="editable && !onlyDefinedFields" v-if="editable && !onlyDefinedFields"
@@ -74,14 +105,18 @@
<button <button
v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')" v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')"
draggable="true" draggable="true"
class="field-type-button group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative" class="field-type-button group flex items-center justify-center border border-dashed w-full rounded relative"
:style="{ backgroundColor: backgroundColor }" :style="{ backgroundColor: backgroundColor }"
:class="drawFieldType === type ? 'border-base-content/40' : 'border-base-300 hover:border-base-content/20'"
@dragstart="onDragstart({ type: type })" @dragstart="onDragstart({ type: type })"
@dragend="$emit('drag-end')" @dragend="$emit('drag-end')"
@click="addField(type)" @click="['file', 'payment'].includes(type) ? $emit('add-field', type) : $emit('set-draw-type', type)"
> >
<div class="flex items-console group-hover:bg-base-200/50 transition-all cursor-grab h-full absolute left-0"> <div
<IconDrag class=" my-auto" /> class="flex items-console transition-all cursor-grab h-full absolute left-0"
:class="drawFieldType === type ? 'bg-base-200/50' : 'group-hover:bg-base-200/50'"
>
<IconDrag class="my-auto" />
</div> </div>
<div class="flex items-center flex-col px-2 py-2"> <div class="flex items-center flex-col px-2 py-2">
<component :is="icon" /> <component :is="icon" />
@@ -139,10 +174,9 @@
<script> <script>
import Field from './field' import Field from './field'
import { v4 } from 'uuid'
import FieldType from './field_type' import FieldType from './field_type'
import FieldSubmitter from './field_submitter' import FieldSubmitter from './field_submitter'
import { IconLock } from '@tabler/icons-vue' import { IconLock, IconCirclePlus } from '@tabler/icons-vue'
import IconDrag from './icon_drag' import IconDrag from './icon_drag'
export default { export default {
@@ -150,6 +184,7 @@ export default {
components: { components: {
Field, Field,
FieldType, FieldType,
IconCirclePlus,
FieldSubmitter, FieldSubmitter,
IconDrag, IconDrag,
IconLock IconLock
@@ -180,6 +215,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
drawFieldType: {
type: String,
required: false,
default: ''
},
defaultSubmitters: { defaultSubmitters: {
type: Array, type: Array,
required: false, required: false,
@@ -204,10 +244,18 @@ export default {
required: true required: true
} }
}, },
emits: ['set-draw', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'], emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'],
data () {
return {
defaultFieldsSearch: ''
}
},
computed: { computed: {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons, fieldIcons: FieldType.computed.fieldIcons,
isShowFieldSearch () {
return this.submitterDefaultFields.length > 15
},
fieldIconsSorted () { fieldIconsSorted () {
if (this.fieldTypes.length) { if (this.fieldTypes.length) {
return this.fieldTypes.reduce((acc, type) => { return this.fieldTypes.reduce((acc, type) => {
@@ -226,6 +274,13 @@ export default {
return this.defaultFields.filter((f) => { return this.defaultFields.filter((f) => {
return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name) return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name)
}) })
},
filteredSubmitterDefaultFields () {
if (this.defaultFieldsSearch) {
return this.submitterDefaultFields.filter((f) => f.name.toLowerCase().includes(this.defaultFieldsSearch.toLowerCase()))
} else {
return this.submitterDefaultFields
}
} }
}, },
methods: { methods: {
@@ -286,38 +341,6 @@ export default {
}) })
}) })
this.save()
},
addField (type, area = null) {
const field = {
name: '',
uuid: v4(),
required: type !== 'checkbox',
areas: [],
submitter_uuid: this.selectedSubmitter.uuid,
type
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = [{ value: '', uuid: v4() }]
}
if (type === 'stamp') {
field.readonly = true
}
if (type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
}
this.fields.push(field)
if (!['payment', 'file'].includes(type)) {
this.$emit('set-draw', { field })
}
this.save() this.save()
} }
} }
+8
View File
@@ -1,6 +1,14 @@
const en = { const en = {
search_field: 'Search field',
field_not_found: 'Field not found',
clear: 'Clear',
align: 'Align',
left: 'Left',
right: 'Right',
center: 'Center',
description: 'Description', description: 'Description',
display_title: 'Display title', display_title: 'Display title',
with_logo: 'With logo',
unchecked: 'Unchecked', unchecked: 'Unchecked',
equal: 'Equal', equal: 'Equal',
not_equal: 'Not equal', not_equal: 'Not equal',
@@ -0,0 +1,383 @@
<template>
<div>
<div v-if="selectedSheetIndex === null && spreadsheet">
<form @submit.prevent="[selectedSheetIndex = $refs.selectWorksheet.value, buildDefaultMappings()]">
<label class="label">
Select Worksheet
</label>
<select
ref="selectWorksheet"
class="base-select"
>
<option
v-for="(sheet, index) in spreadsheet"
:key="index"
:value="index"
>
{{ sheet[0] || index }}
</option>
</select>
<button class="base-button mt-4 w-full">
Open
</button>
</form>
</div>
<div v-else-if="selectedSheetIndex !== null">
<div
v-for="submitter in submitters"
:key="submitter.uuid"
class="mb-4"
>
<div
v-if="submitters.length > 1"
class="px-3 border-y py-2 border-base-300 text-center w-full"
>
{{ submitter.name }}
</div>
<div class="flex">
<div class="relative w-full py-2 px-2 text-sm">
Recipient field
</div>
<div class="relative w-full py-2 pl-4 text-sm">
Spreadsheet column
</div>
</div>
<div
v-for="mapping in mappings.filter((m) => m.submitter_uuid === submitter.uuid)"
:key="mapping.uuid"
class="mb-2"
>
<div class="flex">
<select
class="base-select !select-sm !h-10"
required
@change="mapping.field_name = $event.target.value"
>
<option
disabled
value=""
:selected="!mapping.field_name"
>
Select Field
</option>
<option
v-for="(field, index) in selectFieldsForSubmitter(submitter)"
:key="index"
:value="field.name"
:selected="mapping.field_name === field.name"
>
{{ field.name }}
</option>
</select>
<div class="flex items-center px-1">
<IconArrowsHorizontal style="width: 19px; height: 19px" />
</div>
<div class="w-full relative">
<select
class="base-select !select-sm !h-10"
required
@change="mapping.column_index = parseInt($event.target.value)"
>
<option
disabled
value=""
:selected="mapping.column_index == null"
>
Select Column
</option>
<template
v-for="(column, index) in columns"
:key="index"
>
<option
v-if="column"
:value="index"
:selected="index === mapping.column_index"
>
{{ column }}
</option>
</template>
</select>
<div
v-if="mapping.column_index != null"
class="absolute top-0 bottom-0 right-1 flex items-center"
>
<span
class="tooltip tooltip-bottom-end pr-1 tooltip-pre"
style="padding-top: 2px"
:data-tip="[0, 1, 2].map((i) => rows[i]?.[mapping.column_index] ?? '---').join('\n')"
>
<button
class="btn btn-xs btn-circle bg-white border-0 border-gray-300"
@click.prevent
>
<IconInfoCircle class="h-4 w-4" />
</button>
</span>
</div>
</div>
<div class="flex items-center pl-1">
<span
class="tooltip tooltip-top"
data-tip="Remove"
>
<button
:disabled="mappings.filter((m) => m.submitter_uuid === submitter.uuid).length < 2"
class="btn btn-xs btn-circle"
@click.prevent="mappings.splice(mappings.indexOf(mapping), 1)"
>
<IconX class="h-3.5 w-3.5" />
</button>
</span>
</div>
</div>
</div>
<div>
<button
class="btn btn-sm btn-primary w-full !normal-case font-medium"
@click.prevent="addMapping(submitter)"
>
<IconPlus class="w-4 h-4" />
New Field Mapping
</button>
</div>
</div>
<div>
<input
name="submissions_json"
hidden
:value="JSON.stringify(submissionsData.slice(0, 1100))"
>
</div>
<div
class="px-3 border-y py-2 border-base-300 text-center w-full text-sm font-semibold"
>
Total entries: {{ submissionsData.length }}
<template v-if="submissionsData.length >= 1000">
/ 1000
</template>
</div>
</div>
<div
v-else
class="flex h-52 w-full"
@dragover.prevent
@drop.prevent="onDropFiles"
>
<label
class="w-full relative hover:bg-base-200/30 rounded-md border border-2 border-base-content/10 border-dashed"
for="import_list_file"
:class="{ 'opacity-50': isLoading }"
>
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
<div class="flex flex-col items-center">
<IconInnerShadowTop
v-if="isLoading"
class="animate-spin"
:width="40"
:height="40"
/>
<IconCloudUpload
v-else
:width="40"
:height="40"
/>
<div
class="font-medium text-lg mb-1"
>
Upload CSV or XLSX Spreadsheet
</div>
<div class="text-sm">
<span class="font-medium">Click to Upload</span> or drag and drop files.
</div>
</div>
</div>
<form
ref="form"
class="hidden"
>
<input
id="import_list_file"
ref="input"
type="file"
name="file"
accept=".xlsx, .xls, .csv"
@change="onSelectFile"
>
</form>
</label>
</div>
</div>
</template>
<script>
import { IconCloudUpload, IconX, IconPlus, IconArrowsHorizontal, IconInfoCircle, IconInnerShadowTop } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
name: 'FileDropzone',
components: {
IconCloudUpload,
IconX,
IconArrowsHorizontal,
IconPlus,
IconInfoCircle,
IconInnerShadowTop
},
props: {
template: {
type: Object,
required: true
},
authenticityToken: {
type: String,
required: false,
default: ''
}
},
data () {
return {
isLoading: false,
spreadsheet: null,
selectedSheetIndex: null,
mappings: []
}
},
computed: {
table () {
return this.spreadsheet[this.selectedSheetIndex][1]
},
submissionsData () {
const submissions = []
this.rows.forEach((row) => {
const submittersIndex = {}
this.mappings.forEach((mapping) => {
if (mapping.field_name && mapping.column_index != null) {
submittersIndex[mapping.submitter_uuid] ||= { uuid: mapping.submitter_uuid, fields: [] }
if (['name', 'email', 'phone'].includes(mapping.field_name.toLowerCase())) {
submittersIndex[mapping.submitter_uuid][mapping.field_name.toLowerCase()] = row[mapping.column_index]
} else {
submittersIndex[mapping.submitter_uuid].fields.push({
name: mapping.field_name, default_value: row[mapping.column_index], readonly: true
})
}
}
})
if (Object.keys(submittersIndex).length !== 0) {
submissions.push({ submitters: Object.values(submittersIndex) })
}
})
return submissions
},
submitters () {
return this.template.submitters
},
columns () {
return this.table[0]
},
form () {
return this.$el.closest('form')
},
fieldTypes () {
return ['text', 'cells', 'date', 'number', 'radio', 'select', 'checkbox']
},
defaultFields () {
return [
{ name: 'Name' },
{ name: 'Email' },
{ name: 'Phone' }
]
},
rows () {
return this.table.slice(1)
}
},
watch: {
selectedSheetIndex (value) {
if (value !== null) {
document.getElementById('list_form_buttons')?.classList?.remove('hidden')
}
}
},
methods: {
onDropFiles (e) {
this.uploadFile(e.dataTransfer.files[0])
},
onSelectFile (e) {
this.uploadFile(e.target.files[0])
},
addMapping (submitter) {
this.mappings.push({ uuid: v4(), field_name: '', column_index: null, submitter_uuid: submitter.uuid })
},
selectFieldsForSubmitter (submitter) {
const templateFields = this.template.fields.filter((field) => {
return field.submitter_uuid === submitter.uuid &&
field.name &&
this.fieldTypes.includes(field.type) &&
this.defaultFields.every((f) => field.name?.toLowerCase() !== f.name?.toLowerCase())
})
return [...this.defaultFields, ...templateFields]
},
buildDefaultMappings () {
this.submitters.forEach((submitter) => {
const fields = this.selectFieldsForSubmitter(submitter)
fields.forEach((field) => {
const columnIndex = this.columns.findIndex((column, index) => {
return column &&
column.toString().toLowerCase().includes(field.name?.toLowerCase()) &&
this.mappings.every((m) => m.column_index !== index)
})
if (columnIndex !== -1) {
this.mappings.push({ uuid: v4(), field_name: field.name, column_index: columnIndex, submitter_uuid: submitter.uuid })
}
})
if (!this.mappings.some((m) => m.field_name.toLowerCase() === 'name' && m.submitter_uuid === submitter.uuid)) {
this.mappings.unshift({ uuid: v4(), field_name: 'Name', submitter_uuid: submitter.uuid })
}
if (!this.mappings.some((m) => m.field_name.toLowerCase() === 'email' && m.submitter_uuid === submitter.uuid)) {
this.mappings.unshift({ uuid: v4(), field_name: 'Email', submitter_uuid: submitter.uuid })
}
})
},
uploadFile (file) {
this.isLoading = true
const formData = new FormData()
formData.append('file', file)
return fetch('/upload_spreadsheet', {
method: 'POST',
body: formData,
headers: {
'X-CSRF-Token': this.authenticityToken
}
}).then(resp => resp.json()).then((data) => {
if (data.error) {
return alert(data.error)
}
this.spreadsheet = data
if (data.length === 1) {
this.selectedSheetIndex = 0
this.buildDefaultMappings()
}
}).finally(() => {
this.isLoading = false
})
}
}
}
</script>
+8 -1
View File
@@ -77,6 +77,11 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
drawFieldType: {
type: String,
required: false,
default: ''
},
allowDraw: { allowDraw: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -123,7 +128,9 @@ export default {
}, },
computed: { computed: {
defaultFieldType () { defaultFieldType () {
if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') { if (this.drawFieldType) {
return this.drawFieldType
} else if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
return this.defaultDrawFieldType return this.defaultDrawFieldType
} else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes('text')) { } else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes('text')) {
return this.fieldTypes[0] return this.fieldTypes[0]
@@ -167,7 +167,7 @@ export default {
IconInnerShadowTop, IconInnerShadowTop,
IconBrandStripe IconBrandStripe
}, },
inject: ['backgroundColor', 'save', 'currencies', 't'], inject: ['backgroundColor', 'save', 'currencies', 't', 'isPaymentConnected'],
props: { props: {
field: { field: {
type: Object, type: Object,
@@ -221,6 +221,8 @@ export default {
mounted () { mounted () {
this.field.preferences.currency ||= this.defaultCurrency this.field.preferences.currency ||= this.defaultCurrency
isConnected.value ||= this.isPaymentConnected
if (!this.isConnected) { if (!this.isConnected) {
this.checkStatus() this.checkStatus()
} }
+4 -4
View File
@@ -119,7 +119,7 @@ export default {
this.isProcessing = true this.isProcessing = true
this.baseFetch(`/api/templates/${this.templateId}/documents`, { this.baseFetch(`/templates/${this.templateId}/documents`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ blobs }), body: JSON.stringify({ blobs }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
@@ -132,7 +132,7 @@ export default {
} else if (resp.status === 422) { } else if (resp.status === 422) {
resp.json().then((data) => { resp.json().then((data) => {
if (data.error === 'PDF encrypted') { if (data.error === 'PDF encrypted') {
this.baseFetch(`/api/templates/${this.templateId}/documents`, { this.baseFetch(`/templates/${this.templateId}/documents`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ blobs, password: prompt('Enter PDF password') }), body: JSON.stringify({ blobs, password: prompt('Enter PDF password') }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
@@ -151,7 +151,7 @@ export default {
this.isProcessing = false this.isProcessing = false
}) })
} else { } else {
this.baseFetch(`/api/templates/${this.templateId}/documents`, { this.baseFetch(`/templates/${this.templateId}/documents`, {
method: 'POST', method: 'POST',
body: new FormData(this.$refs.form) body: new FormData(this.$refs.form)
}).then((resp) => { }).then((resp) => {
@@ -167,7 +167,7 @@ export default {
formData.append('password', prompt('Enter PDF password')) formData.append('password', prompt('Enter PDF password'))
this.baseFetch(`/api/templates/${this.templateId}/documents`, { this.baseFetch(`/templates/${this.templateId}/documents`, {
method: 'POST', method: 'POST',
body: formData body: formData
}).then(async (resp) => { }).then(async (resp) => {
+4 -3
View File
@@ -27,14 +27,15 @@ class ProcessSubmitterCompletionJob < ApplicationJob
user = submission.created_by_user || submitter.template.author user = submission.created_by_user || submitter.template.author
if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false
if submitter.submission.submitters.map(&:email).exclude?(user.email) && if submission.submitters.map(&:email).exclude?(user.email) &&
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false &&
user.role != 'integration'
SubmitterMailer.completed_email(submitter, user).deliver_later! SubmitterMailer.completed_email(submitter, user).deliver_later!
end end
bcc = submission.preferences['bcc_completed'].presence || bcc = submission.preferences['bcc_completed'].presence ||
submission.account.account_configs submission.account.account_configs
.find_by(key: AccountConfig::BCC_EMAILS)&.value.presence .find_by(key: AccountConfig::BCC_EMAILS)&.value
bcc.to_s.scan(User::EMAIL_REGEXP).each do |to| bcc.to_s.scan(User::EMAIL_REGEXP).each do |to|
SubmitterMailer.completed_email(submitter, user, to:).deliver_later! SubmitterMailer.completed_email(submitter, user, to:).deliver_later!
+8 -4
View File
@@ -7,7 +7,7 @@ class SubmitterMailer < ApplicationMailer
DEFAULT_INVITATION_SUBJECT = 'You are invited to submit a form' DEFAULT_INVITATION_SUBJECT = 'You are invited to submit a form'
def invitation_email(submitter) def invitation_email(submitter)
@current_account = submitter.submission.template.account @current_account = submitter.submission.account
@submitter = submitter @submitter = submitter
if submitter.preferences['email_message_uuid'] if submitter.preferences['email_message_uuid']
@@ -30,12 +30,13 @@ class SubmitterMailer < ApplicationMailer
to: @submitter.friendly_name, to: @submitter.friendly_name,
from: from_address_for_submitter(submitter), from: from_address_for_submitter(submitter),
subject:, subject:,
reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@') reply_to: submitter.preferences['reply_to'].presence ||
(submitter.submission.created_by_user || submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@')
) )
end end
def completed_email(submitter, user, to: nil) def completed_email(submitter, user, to: nil)
@current_account = submitter.submission.template.account @current_account = submitter.submission.account
@submitter = submitter @submitter = submitter
@submission = submitter.submission @submission = submitter.submission
@user = user @user = user
@@ -63,7 +64,7 @@ class SubmitterMailer < ApplicationMailer
end end
def documents_copy_email(submitter, to: nil, sig: false) def documents_copy_email(submitter, to: nil, sig: false)
@current_account = submitter.submission.template.account @current_account = submitter.submission.account
@submitter = submitter @submitter = submitter
@sig = submitter.signed_id(expires_in: SIGN_TTL, purpose: :download_completed) if sig @sig = submitter.signed_id(expires_in: SIGN_TTL, purpose: :download_completed) if sig
@@ -84,6 +85,9 @@ class SubmitterMailer < ApplicationMailer
mail(from: from_address_for_submitter(submitter), mail(from: from_address_for_submitter(submitter),
to: to || @submitter.friendly_name, to: to || @submitter.friendly_name,
reply_to: @submitter.preferences['reply_to'].presence ||
(@submitter.submission.created_by_user ||
@submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@'),
subject:) subject:)
end end
+2 -3
View File
@@ -3,10 +3,9 @@
<head> <head>
<title></title> <title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"> <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style> <style></style>
</style>
</head> </head>
<body dir="<%= yield&.match?(TextUtils::RTL_REGEXP) ? 'rtl' : 'auto' %>"> <body dir="<%= TextUtils.rtl?(yield) ? 'rtl' : 'auto' %>">
<%= yield %> <%= yield %>
<%= render partial: 'shared/mailer_attribution' %> <%= render partial: 'shared/mailer_attribution' %>
</body> </body>
@@ -5,6 +5,10 @@
if (this.field.scrollHeight > this.field.clientHeight) { if (this.field.scrollHeight > this.field.clientHeight) {
this.field.classList.remove('text-[1.5vw]', 'lg:text-base'); this.field.classList.remove('text-[1.5vw]', 'lg:text-base');
this.field.classList.add('text-[1.0vw]', 'lg:text-[0.70rem]'); 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() { get field() {
+1 -1
View File
@@ -39,7 +39,7 @@
<% end %> <% end %>
<% if can?(:read, User) %> <% if can?(:read, User) %>
<li> <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> </li>
<% end %> <% end %>
<% unless Docuseal.multitenant? %> <% unless Docuseal.multitenant? %>
@@ -0,0 +1,11 @@
<div class="alert">
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
<div>
<p class="font-bold">Bulk send from Excel XLSX or CSV</p>
<p class="text-gray-700">
Unlock with DocuSeal Pro
<br>
<a class="link font-medium" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}" %>" data-turbo="false">Learn More</a>
</p>
</div>
</div>
+7 -5
View File
@@ -1,11 +1,13 @@
<%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %> <%= 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 %> <% if template.submitters.size == 1 %>
<submitter-item class="form-control"> <submitter-item class="form-control">
<submitters-autocomplete data-field="email"> <emails-textarea data-bulk-enabled="<%= Docuseal.demo? || !Docuseal.multitenant? || can?(:manage, :bulk_send) %>" data-limit="<%= Docuseal.multitenant? ? (can?(:manage, :bulk_send) ? 40 : 1) : nil %>">
<autoresize-textarea> <submitters-autocomplete data-field="email" class="block relative">
<%= f.text_area :emails, required: true, class: 'base-textarea w-full', placeholder: 'Type emails here...' %> <autoresize-textarea>
</autoresize-textarea> <%= f.text_area :emails, required: true, class: 'base-textarea w-full', placeholder: 'Type emails here...' %>
</submitters-autocomplete> </autoresize-textarea>
</submitters-autocomplete>
</emails-textarea>
</submitter-item> </submitter-item>
<% else %> <% else %>
<dynamic-list class="space-y-4"> <dynamic-list class="space-y-4">
@@ -0,0 +1 @@
<%= render 'submissions/bulk_send_placeholder' %>
+6 -3
View File
@@ -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']) %> <% if field['type'].in?(['signature', 'image', 'initials', 'stamp']) %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy"> <img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'].in?(['file', 'payment']) %> <% elsif field['type'].in?(['file', 'payment']) %>
@@ -17,7 +18,8 @@
</div> </div>
<% elsif field['type'].in?(%w[multiple radio]) && area['option_uuid'] %> <% elsif field['type'].in?(%w[multiple radio]) && area['option_uuid'] %>
<% option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] } %> <% option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] } %>
<% if option && Array.wrap(value).include?(option['value']) %> <% option_name = option['value'].presence || "Option #{field['options'].index(option) + 1}" %>
<% if option && Array.wrap(value).include?(option_name) %>
<div class="w-full flex items-center justify-center"> <div class="w-full flex items-center justify-center">
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %> <%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
</div> </div>
@@ -31,9 +33,10 @@
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
<% elsif field['type'] == 'date' && value != '{{date}}' %> <% elsif field['type'] == 'date' %>
<autosize-field></autosize-field> <autosize-field></autosize-field>
<div class="flex items-center px-0.5"> <div class="flex items-center px-0.5">
<% value = Time.current.in_time_zone(local_assigns[:timezone]).to_date.to_s if value == '{{date}}' %>
<%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), local_assigns[:locale]) %> <%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), local_assigns[:locale]) %>
</div> </div>
<% else %> <% else %>
+4 -6
View File
@@ -1,5 +1,5 @@
<%= render 'shared/turbo_modal', title: params[:selfsign] ? 'Add Recipients' : 'Add New Recipients' do %> <%= render 'shared/turbo_modal', title: params[:selfsign] ? 'Add Recipients' : 'Add New Recipients' do %>
<% options = [['via Email', 'email'], ['via Phone', 'phone'], %w[Detailed detailed], (Docuseal.multitenant? && params[:with_link] && @template.submitters.to_a.size < 2 ? ['via Link', 'link'] : nil)].compact %> <% options = [['via Email', 'email'], ['via Phone', 'phone'], %w[Detailed detailed], ['Upload List', 'list']].compact %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center mt-4 block"> <toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center mt-4 block">
<div class="join"> <div class="join">
<% options.each_with_index do |(label, value), index| %> <% options.each_with_index do |(label, value), index| %>
@@ -22,11 +22,9 @@
<div id="detailed" class="hidden"> <div id="detailed" class="hidden">
<%= render 'detailed_form', template: @template %> <%= render 'detailed_form', template: @template %>
</div> </div>
<% if Docuseal.multitenant? && params[:with_link] && @template.submitters.to_a.size < 2 %> <div id="list" class="hidden">
<div id="link" class="hidden"> <%= render 'list_form', template: @template %>
<%= render 'link_form', template: @template %> </div>
</div>
<% end %>
</div> </div>
<%= content_for(:modal_extra) %> <%= content_for(:modal_extra) %>
<% end %> <% end %>
+4 -4
View File
@@ -1,6 +1,6 @@
<div style="max-width: 1600px" class="mx-auto pl-4"> <div style="max-width: 1600px" class="mx-auto pl-4">
<div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100"> <div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100">
<a href="<%= signed_in? && @submission.template.account_id == current_account&.id ? template_path(@submission.template) : '/' %>" class="flex items-center space-x-3 py-1"> <a href="<%= signed_in? && @submission.account_id == current_account&.id ? template_path(@submission.template) : '/' %>" class="flex items-center space-x-3 py-1">
<span><%= render 'submissions/logo' %></span> <span><%= render 'submissions/logo' %></span>
<span class="text-xl md:text-3xl font-semibold focus:text-clip" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;"><%= @submission.template.name %></span> <span class="text-xl md:text-3xl font-semibold focus:text-clip" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;"><%= @submission.template.name %></span>
</a> </a>
@@ -63,7 +63,7 @@
<% fields_index.dig(document.uuid, index)&.each do |(area, field)| %> <% fields_index.dig(document.uuid, index)&.each do |(area, field)| %>
<% value = values[field['uuid']] %> <% value = values[field['uuid']] %>
<% next if value.blank? %> <% next if value.blank? %>
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submission.template.account.locale %> <%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submission.account.locale, timezone: @submission.account.timezone %>
<% end %> <% end %>
</div> </div>
</div> </div>
@@ -112,7 +112,7 @@
<div class="flex items-center space-x-1 mt-1"> <div class="flex items-center space-x-1 mt-1">
<%= svg_icon('writing', class: 'w-5 h-5') %> <%= svg_icon('writing', class: 'w-5 h-5') %>
<span> <span>
<%= submitter&.completed_at? ? l(submitter.completed_at.in_time_zone(@submission.template.account.timezone), format: :long, locale: @submission.template.account.locale) : 'Not completed yet' %> <%= submitter&.completed_at? ? l(submitter.completed_at.in_time_zone(@submission.account.timezone), format: :long, locale: @submission.account.locale) : 'Not completed yet' %>
</span> </span>
</div> </div>
<% if signed_in? && submitter && submitter.email && !submitter.completed_at && can?(:update, submitter) %> <% if signed_in? && submitter && submitter.email && !submitter.completed_at && can?(:update, submitter) %>
@@ -162,7 +162,7 @@
<% elsif field['type'] == 'checkbox' %> <% elsif field['type'] == 'checkbox' %>
<%= svg_icon('check', class: 'w-6 h-6') %> <%= svg_icon('check', class: 'w-6 h-6') %>
<% elsif field['type'] == 'date' %> <% elsif field['type'] == 'date' %>
<%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), @submission.template.account.locale) %> <%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), @submission.account.locale) %>
<% else %> <% else %>
<%= Array.wrap(value).join(', ') %> <%= Array.wrap(value).join(', ') %>
<% end %> <% end %>
@@ -1,4 +1,4 @@
<% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %> <% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %>
<% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %> <% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %>
<% configs = Submitters::FormConfigs.call(submitter) %> <% configs = Submitters::FormConfigs.call(submitter) %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'] %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>"></submission-form> <submission-form data-is-demo="<%= Docuseal.demo? %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'] %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>"></submission-form>
+2 -2
View File
@@ -21,11 +21,11 @@
<%= render 'submissions/annotation', annot: %> <%= render 'submissions/annotation', annot: %>
<% end %> <% end %>
<% fields_index.dig(document.uuid, index)&.each do |(area, field)| %> <% 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 value.blank? %>
<% next if !field['readonly'] && field['submitter_uuid'] == @submitter.uuid %> <% next if !field['readonly'] && field['submitter_uuid'] == @submitter.uuid %>
<% next if field.dig('preferences', 'formula') && field['submitter_uuid'] == @submitter.uuid %> <% next if field.dig('preferences', 'formula') && field['submitter_uuid'] == @submitter.uuid %>
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submitter.account.locale %> <%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submitter.account.locale, timezone: @submitter.account.timezone %>
<% end %> <% end %>
</div> </div>
</div> </div>
@@ -1 +1 @@
<%= auto_link(simple_format(h(local_assigns[:content]))) %> <%= auto_link(simple_format(MarkdownToHtml.call(h(ReplaceEmailVariables.call(local_assigns[:content], submitter: local_assigns[:submitter], sig: local_assigns[:sig]))))) %>
@@ -1,5 +1,5 @@
<% if @email_config %> <% if @email_config %>
<%= render 'custom_content', content: ReplaceEmailVariables.call(@email_config.value['body'], submitter: @submitter) %> <%= render 'custom_content', content: @email_config.value['body'], submitter: @submitter %>
<% else %> <% else %>
<p>Hi there,</p> <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> <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 %> <% if @email_config %>
<%= render 'custom_content', content: ReplaceEmailVariables.call(@email_config.value['body'], submitter: @submitter, sig: @sig) %> <%= render 'custom_content', content: @email_config.value['body'], submitter: @submitter, sig: @sig %>
<% else %> <% else %>
<p>Hi there,</p> <p>Hi there,</p>
<p>Please check the copy of your "<%= @submitter.submission.template.name %>" submission in the email attachments.</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? %> <% if @email_config || @body.present? %>
<% body = (@body.presence || @email_config.value['body']).to_s %> <% body = (@body.presence || @email_config.value['body']).to_s %>
<%= render 'custom_content', content: 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]) %> <% 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> <p><%= link_to nil, submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email')) %></p>
<% end %> <% end %>
+5 -5
View File
@@ -3,6 +3,11 @@
<% if @base_template %> <% if @base_template %>
<%= hidden_field_tag :base_template_id, @base_template.id %> <%= hidden_field_tag :base_template_id, @base_template.id %>
<% end %> <% 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"> <div class="form-control mt-6">
<%= f.text_field :name, required: true, placeholder: 'Document Name', class: 'base-input', dir: 'auto' %> <%= f.text_field :name, required: true, placeholder: 'Document Name', class: 'base-input', dir: 'auto' %>
</div> </div>
@@ -17,11 +22,6 @@
</a> </a>
</folder-autocomplete> </folder-autocomplete>
</div> </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"> <div class="form-control">
<%= f.button button_title(title: @base_template ? 'Submit' : 'Create', disabled_with: 'Creating'), class: 'base-button' %> <%= f.button button_title(title: @base_template ? 'Submit' : 'Create', disabled_with: 'Creating'), class: 'base-button' %>
</div> </div>
+1 -1
View File
@@ -2,7 +2,7 @@
<%= render 'shared/settings_nav' %> <%= render 'shared/settings_nav' %>
<div class="md:flex-grow"> <div class="md:flex-grow">
<div class="flex justify-between mb-4"> <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"> <div class="flex items-center space-x-4">
<% if can?(:create, User.new(account: current_account)) %> <% 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 %> <%= link_to new_user_path, class: 'btn btn-primary btn-md gap-2', data: { turbo_frame: 'modal' } do %>
+2 -2
View File
@@ -60,7 +60,7 @@ Rails.application.configure do
# config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] # 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. # 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 # Include generic and useful information about system operation, but avoid logging too much
# information to avoid inadvertent exposure of personally identifiable information (PII). # 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.formatter = ->(data) { data.except(:path, :location).to_json }
config.lograge.custom_payload do |controller| config.lograge.custom_payload do |controller|
params = controller.request.params || {} params = controller.request.try(:params) || {}
{ {
fwd: controller.request.ip, fwd: controller.request.ip,
+30 -1
View File
@@ -1,8 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
# fix NoMethodError: undefined method `field_value' for #<HexaPDF::Type::AcroForm::Field
module HexaPDF module HexaPDF
module Type module Type
# fix NoMethodError: undefined method `field_value' for #<HexaPDF::Type::AcroForm::Field
module AcroForm module AcroForm
class Field class Field
def field_value def field_value
@@ -10,5 +10,34 @@ module HexaPDF
end end
end 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
end end
+1 -1
View File
@@ -24,7 +24,7 @@ port ENV.fetch('PORT', 3000)
environment ENV.fetch('RAILS_ENV', 'development') environment ENV.fetch('RAILS_ENV', 'development')
# Specifies the `pidfile` that Puma will use. # 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. # Specifies the number of `workers` to boot in clustered mode.
# Workers are forked web server processes. If using threads and workers together # Workers are forked web server processes. If using threads and workers together
+5 -4
View File
@@ -24,8 +24,6 @@ Rails.application.routes.draw do
namespace :api, defaults: { format: :json } do namespace :api, defaults: { format: :json } do
resources :attachments, only: %i[create] 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_email_clicks, only: %i[create]
resources :submitter_form_views, only: %i[create] resources :submitter_form_views, only: %i[create]
resources :submitters, only: %i[index show update] 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 :templates, only: %i[update show index destroy] do
resources :clone, only: %i[create], controller: 'templates_clone' resources :clone, only: %i[create], controller: 'templates_clone'
resources :submissions, only: %i[index create] resources :submissions, only: %i[index create]
resources :documents, only: %i[create], controller: 'templates_documents'
end end
end end
@@ -55,8 +52,11 @@ Rails.application.routes.draw do
resource :user_signature, only: %i[edit update destroy] resource :user_signature, only: %i[edit update destroy]
resources :submissions, only: %i[show destroy] resources :submissions, only: %i[show destroy]
resources :console_redirect, only: %i[index] resources :console_redirect, only: %i[index]
resources :upgrade, only: %i[index], controller: 'console_redirect'
resource :testing_account, only: %i[show destroy] resource :testing_account, only: %i[show destroy]
resources :testing_api_settings, only: %i[index] 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] resource :templates_upload, only: %i[create]
authenticated do authenticated do
resource :templates_upload, only: %i[show], path: 'new' 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 :templates_archived, only: %i[index], path: 'archived'
resources :folders, only: %i[show edit update destroy], controller: 'template_folders' resources :folders, only: %i[show edit update destroy], controller: 'template_folders'
resources :template_sharings_testing, only: %i[create] 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 :restore, only: %i[create], controller: 'templates_restore'
resources :archived, only: %i[index], controller: 'templates_archived_submissions' resources :archived, only: %i[index], controller: 'templates_archived_submissions'
resources :submissions, only: %i[new create] resources :submissions, only: %i[new create]
+1
View File
@@ -2,6 +2,7 @@ queues:
- [default, 1] - [default, 1]
- [mailers, 1] - [mailers, 1]
- [recurrent, 1] - [recurrent, 1]
- [rollbar, 1]
production: production:
:concurrency: 15 :concurrency: 15
@@ -9,7 +9,6 @@ class UpdateFieldOptions < ActiveRecord::Migration[7.0]
self.table_name = 'submissions' self.table_name = 'submissions'
end end
# rubocop:disable Metrics
def up def up
MigrationTemplate.find_each do |template| MigrationTemplate.find_each do |template|
next if template.fields.blank? next if template.fields.blank?
@@ -43,7 +42,6 @@ class UpdateFieldOptions < ActiveRecord::Migration[7.0]
submission.update_columns(template_fields: new_fields.to_json) if template_fields != new_fields submission.update_columns(template_fields: new_fields.to_json) if template_fields != new_fields
end end
end end
# rubocop:enable Metrics
def down def down
nil nil
+1 -1
View File
@@ -32,7 +32,7 @@ module Accounts
end end
def find_or_create_testing_user(account) 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 return user if user
+5
View File
@@ -1,6 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
module ActionMailerConfigsInterceptor module ActionMailerConfigsInterceptor
OPEN_TIMEOUT = 15
READ_TIMEOUT = 25
module_function module_function
def delivering_email(message) def delivering_email(message)
@@ -43,6 +46,8 @@ module ActionMailerConfigsInterceptor
openssl_verify_mode: OpenSSL::SSL::VERIFY_NONE, openssl_verify_mode: OpenSSL::SSL::VERIFY_NONE,
authentication: value.fetch('authentication', 'plain'), authentication: value.fetch('authentication', 'plain'),
enable_starttls_auto: true, enable_starttls_auto: true,
open_timeout: OPEN_TIMEOUT,
read_timeout: READ_TIMEOUT,
ssl: value['security'] == 'ssl', ssl: value['security'] == 'ssl',
tls: value['security'] == 'tls' || (value['security'].blank? && value['port'].to_s == '465') tls: value['security'] == 'tls' || (value['security'].blank? && value['port'].to_s == '465')
}.compact_blank }.compact_blank
+12
View File
@@ -0,0 +1,12 @@
# frozen_string_literal: true
module MarkdownToHtml
LINK_REGEXP = %r{\[([^\]]+)\]\((https?://[^)]+)\)}
LINK_REPLACE = '<a href="\2">\1</a>'
module_function
def call(text)
text.gsub(LINK_REGEXP, LINK_REPLACE)
end
end
@@ -42,6 +42,9 @@ module Params
type(params, :order, String) type(params, :order, String)
type(params, :completed_redirect_url, String) type(params, :completed_redirect_url, String)
type(params, :bcc_completed, String) type(params, :bcc_completed, String)
type(params, :reply_to, String)
format(params, :bcc_completed, /@/, message: 'bcc_completed email is invalid')
format(params, :reply_to, /@/, message: 'reply_to email is invalid')
type(params, :message, Hash) type(params, :message, Hash)
type(params, :submitters, Array) type(params, :submitters, Array)
@@ -69,8 +72,10 @@ module Params
required(submitter_params, %i[email phone name]) required(submitter_params, %i[email phone name])
type(submitter_params, :name, String) type(submitter_params, :name, String)
type(submitter_params, :reply_to, String)
type(submitter_params, :email, String) type(submitter_params, :email, String)
format(submitter_params, :email, /@/, message: 'email is invalid') format(submitter_params, :email, /@/, message: 'email is invalid')
format(submitter_params, :reply_to, /@/, message: 'reply_to email is invalid')
type(submitter_params, :phone, String) type(submitter_params, :phone, String)
format(submitter_params, :phone, /\A\+\d+\z/, format(submitter_params, :phone, /\A\+\d+\z/,
message: 'phone should start with +<country code> and contain only digits') message: 'phone should start with +<country code> and contain only digits')
@@ -100,6 +105,7 @@ module Params
type(params, :order, String) type(params, :order, String)
type(params, :completed_redirect_url, String) type(params, :completed_redirect_url, String)
type(params, :bcc_completed, String) type(params, :bcc_completed, String)
format(params, :bcc_completed, /@/, message: 'bcc_completed email is invalid')
type(params, :message, Hash) type(params, :message, Hash)
in_path(params, :message) do |message_params| in_path(params, :message) do |message_params|
+19
View File
@@ -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
+26 -16
View File
@@ -20,25 +20,25 @@ module ReplaceEmailVariables
module_function module_function
# rubocop:disable Metrics # rubocop:disable Metrics
def call(text, submitter:, tracking_event_type: 'click_email', sig: nil) def call(text, submitter:, tracking_event_type: 'click_email', html_escape: false, sig: nil)
text = text.gsub(TEMPLATE_NAME) { submitter.template.name } text = replace(text, TEMPLATE_NAME, html_escape:) { submitter.template.name }
text = text.gsub(TEMPLATE_ID) { submitter.template.id } text = replace(text, TEMPLATE_ID, html_escape:) { submitter.template.id }
text = text.gsub(SUBMITTER_ID) { submitter.id } text = replace(text, SUBMITTER_ID, html_escape:) { submitter.id }
text = text.gsub(SUBMITTER_SLUG) { submitter.slug } text = replace(text, SUBMITTER_SLUG, html_escape:) { submitter.slug }
text = text.gsub(SUBMISSION_ID) { submitter.submission.id } text = replace(text, SUBMISSION_ID, html_escape:) { submitter.submission.id }
text = text.gsub(SUBMITTER_EMAIL) { submitter.email } text = replace(text, SUBMITTER_EMAIL, html_escape:) { submitter.email }
text = text.gsub(SUBMITTER_NAME) { submitter.name || submitter.email || submitter.phone } text = replace(text, SUBMITTER_NAME, html_escape:) { submitter.name || submitter.email || submitter.phone }
text = text.gsub(SUBMITTER_LINK) { build_submitter_link(submitter, tracking_event_type) } text = replace(text, SUBMITTER_LINK, html_escape:) { build_submitter_link(submitter, tracking_event_type) }
text = text.gsub(SUBMISSION_LINK) do text = replace(text, SUBMISSION_LINK, html_escape:) do
submitter.submission ? build_submission_link(submitter.submission) : '' submitter.submission ? build_submission_link(submitter.submission) : ''
end end
text = text.gsub(SUBMISSION_SUBMITTERS) { build_submission_submitters(submitter.submission) } text = replace(text, SUBMISSION_SUBMITTERS, html_escape:) { build_submission_submitters(submitter.submission) }
text = text.gsub(DOCUMENTS_LINKS) { build_documents_links_text(submitter, sig) } text = replace(text, DOCUMENTS_LINKS, html_escape:) { build_documents_links_text(submitter, sig) }
text = text.gsub(DOCUMENTS_LINK) { build_documents_links_text(submitter, sig) } text = replace(text, DOCUMENTS_LINK, html_escape:) { build_documents_links_text(submitter, sig) }
text = text.gsub(ACCOUNT_NAME) { submitter.account.name } text = replace(text, ACCOUNT_NAME, html_escape:) { submitter.submission.account.name }
text = text.gsub(SENDER_NAME) { submitter.submission.created_by_user&.full_name } text = replace(text, SENDER_NAME, html_escape:) { submitter.submission.created_by_user&.full_name }
text.gsub(SENDER_EMAIL) { submitter.submission.created_by_user&.email.to_s.sub(/\+\w+@/, '@') } replace(text, SENDER_EMAIL, html_escape:) { submitter.submission.created_by_user&.email.to_s.sub(/\+\w+@/, '@') }
end end
# rubocop:enable Metrics # rubocop:enable Metrics
@@ -48,6 +48,16 @@ module ReplaceEmailVariables
) )
end 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) def build_submitter_link(submitter, tracking_event_type)
if tracking_event_type == 'click_email' if tracking_event_type == 'click_email'
Rails.application.routes.url_helpers.submit_form_url( Rails.application.routes.url_helpers.submit_form_url(
+3 -1
View File
@@ -7,7 +7,7 @@ module Submissions
module_function module_function
def search(submissions, keyword) def search(submissions, keyword, search_values: false)
return submissions if keyword.blank? return submissions if keyword.blank?
term = "%#{keyword.downcase}%" term = "%#{keyword.downcase}%"
@@ -18,6 +18,8 @@ module Submissions
.or(arel_table[:phone].matches(term)) .or(arel_table[:phone].matches(term))
.or(arel_table[:name].lower.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 submissions.joins(:submitters).where(arel).distinct
end end
+23 -11
View File
@@ -7,7 +7,7 @@ module Submissions
def call(template:, user:, submissions_attrs:, source:, submitters_order:, mark_as_sent: false, params: {}) def call(template:, user:, submissions_attrs:, source:, submitters_order:, mark_as_sent: false, params: {})
preferences = Submitters.normalize_preferences(user.account, user, params) preferences = Submitters.normalize_preferences(user.account, user, params)
Array.wrap(submissions_attrs).map do |attrs| Array.wrap(submissions_attrs).filter_map do |attrs|
submission_preferences = Submitters.normalize_preferences(user.account, user, attrs) submission_preferences = Submitters.normalize_preferences(user.account, user, attrs)
submission_preferences = preferences.merge(submission_preferences) submission_preferences = preferences.merge(submission_preferences)
@@ -36,6 +36,8 @@ module Submissions
preferences: preferences.merge(submission_preferences)) preferences: preferences.merge(submission_preferences))
end end
next if submission.submitters.blank?
submission.tap(&:save!) submission.tap(&:save!)
end end
end end
@@ -52,7 +54,8 @@ module Submissions
process_fields_param(submitter_attrs[:fields], template_fields, submitter_uuid) process_fields_param(submitter_attrs[:fields], template_fields, submitter_uuid)
end 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_fields = template_fields
submission.template_schema = submission.template.schema submission.template_schema = submission.template.schema
end end
@@ -80,11 +83,15 @@ module Submissions
next if f['type'].in?(%w[signature image initials file]) next if f['type'].in?(%w[signature image initials file])
next if f['submitter_uuid'] != submitter_uuid next if f['submitter_uuid'] != submitter_uuid
next unless values.key?(f['uuid'])
value = values[f['uuid']] value = values[f['uuid']]
next if value.blank? if value.present?
f['default_value'] = value
f['default_value'] = value else
f.delete('default_value')
end
end end
end end
@@ -110,8 +117,12 @@ module Submissions
field['readonly'] = attrs['readonly'] if attrs.key?('readonly') field['readonly'] = attrs['readonly'] if attrs.key?('readonly')
field['required'] = attrs['required'] if attrs.key?('required') field['required'] = attrs['required'] if attrs.key?('required')
if attrs['default_value'].present? && !field['type'].in?(%w[signature image initials file]) if attrs.key?('default_value') && !field['type'].in?(%w[signature image initials file])
field['default_value'] = Submitters::NormalizeValues.normalize_value(field, attrs['default_value']) if attrs['default_value'].present?
field['default_value'] = Submitters::NormalizeValues.normalize_value(field, attrs['default_value'])
else
field.delete('default_value')
end
end end
return field if attrs['validation_pattern'].blank? return field if attrs['validation_pattern'].blank?
@@ -125,9 +136,10 @@ module Submissions
end end
def find_submitter_uuid(template, attrs, index) def find_submitter_uuid(template, attrs, index)
attrs[:uuid].presence || uuid = attrs[:uuid].presence
template.submitters.find { |e| e['name'] == attrs[:role] }&.dig('uuid') || uuid ||= template.submitters.find { |e| e['name'].to_s.casecmp(attrs[:role].to_s).zero? }&.dig('uuid')
template.submitters[index]&.dig('uuid')
uuid || template.submitters[index]&.dig('uuid')
end end
def build_submitter(submission:, attrs:, uuid:, is_order_sent:, mark_as_sent:, user:, preferences:) def build_submitter(submission:, attrs:, uuid:, is_order_sent:, mark_as_sent:, user:, preferences:)
@@ -139,7 +151,7 @@ module Submissions
phone: attrs[:phone].to_s.gsub(/[^0-9+]/, ''), phone: attrs[:phone].to_s.gsub(/[^0-9+]/, ''),
name: attrs[:name], name: attrs[:name],
external_id: attrs[:external_id].presence || attrs[:application_key], 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, sent_at: mark_as_sent && email.present? && is_order_sent ? Time.current : nil,
values: attrs[:values] || {}, values: attrs[:values] || {},
metadata: attrs[:metadata] || {}, metadata: attrs[:metadata] || {},
+12 -3
View File
@@ -30,17 +30,27 @@ module Submissions
RTL_REGEXP = TextUtils::RTL_REGEXP RTL_REGEXP = TextUtils::RTL_REGEXP
MAX_IMAGE_HEIGHT = 100 MAX_IMAGE_HEIGHT = 100
US_TIMEZONES = %w[EST CST MST PST HST AKDT].freeze
module_function module_function
# rubocop:disable Metrics # rubocop:disable Metrics
def call(submission) def call(submission)
account = submission.template.account account = submission.account
pkcs = Accounts.load_signing_pkcs(account) pkcs = Accounts.load_signing_pkcs(account)
tsa_url = Accounts.load_timeserver_url(account) tsa_url = Accounts.load_timeserver_url(account)
verify_url = Rails.application.routes.url_helpers.settings_esign_url(**Docuseal.default_url_options) 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 = HexaPDF::Composer.new(skip_page_creation: true)
composer.document.fonts.add(FONT_BOLD_NAME, variant: :bold) 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( divider = HexaPDF::Layout::Box.create(
margin: [0, 0, 15, 0], margin: [0, 0, 15, 0],
@@ -51,7 +61,7 @@ module Submissions
height: 1 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) box = canvas.context.box(:media)
canvas.save_graphics_state do canvas.save_graphics_state do
canvas.fill_color('FAF7F5') canvas.fill_color('FAF7F5')
@@ -245,7 +255,6 @@ module Submissions
end end
value = value.join(', ') if value.is_a?(Array) 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') }], 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, text_align: value.to_s.match?(RTL_REGEXP) ? :right : :left,
+89 -26
View File
@@ -21,9 +21,18 @@ module Submissions
A4_SIZE = [595, 842].freeze A4_SIZE = [595, 842].freeze
SUPPORTED_IMAGE_TYPES = ['image/png', 'image/jpeg'].freeze SUPPORTED_IMAGE_TYPES = ['image/png', 'image/jpeg'].freeze
REPLACE_EMOJI = { MISSING_GLYPH_REPLACE = {
'▪' => '-',
'✔️' => 'V', '✔️' => 'V',
'✔' => 'V' '✔' => 'V',
'✅' => 'V'
}.freeze
MISSING_GLYPH_REPLACE_TYPE1 = {
'▪' => :bullet,
'✔️' => :V,
'✔' => :V,
'✅' => :V
}.freeze }.freeze
module_function module_function
@@ -34,7 +43,7 @@ module Submissions
template = submitter.submission.template template = submitter.submission.template
account = submitter.submission.template.account account = submitter.account
pkcs = Accounts.load_signing_pkcs(account) pkcs = Accounts.load_signing_pkcs(account)
tsa_url = Accounts.load_timeserver_url(account) tsa_url = Accounts.load_timeserver_url(account)
attachments_data_cache = {} attachments_data_cache = {}
@@ -62,16 +71,24 @@ module Submissions
width = page.box.width width = page.box.width
height = page.box.height 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']] value = submitter.values[field['uuid']]
layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center, text_align = field.dig('preferences', 'align').to_s.to_sym.presence ||
text_align: value.to_s.match?(RTL_REGEXP) ? :right : :left, (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:) font: pdf.fonts.add(FONT_NAME), font_size:)
next if Array.wrap(value).compact_blank.blank? next if Array.wrap(value).compact_blank.blank?
begin
page.flatten_annotations
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
end
canvas = page.canvas(type: :overlay) canvas = page.canvas(type: :overlay)
canvas.font(FONT_NAME, size: font_size) canvas.font(FONT_NAME, size: font_size)
@@ -148,7 +165,9 @@ module Submissions
if field['type'].in?(%w[multiple radio]) if field['type'].in?(%w[multiple radio])
option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] } option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] }
value = Array.wrap(value).include?(option['value']) option_name = option['value'].presence || "Option #{field['options'].index(option) + 1}"
value = Array.wrap(value).include?(option_name)
end end
next unless value == true next unless value == true
@@ -181,7 +200,6 @@ module Submissions
end end
value = TextUtils.maybe_rtl_reverse(Array.wrap(value).join(', ')) 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), text = HexaPDF::Layout::TextFragment.create(value, font: pdf.fonts.add(FONT_NAME),
font_size:) font_size:)
@@ -194,15 +212,33 @@ module Submissions
font: pdf.fonts.add(FONT_NAME), font: pdf.fonts.add(FONT_NAME),
font_size: (font_size / 1.4).to_i) 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) box_height = lines.sum(&:height)
end end
height_diff = [0, box_height - (area['h'] * height)].max height_diff = [0, box_height - (area['h'] * height)].max
layouter.fit([text], area['w'] * width, height_diff.positive? ? box_height : area['h'] * height) right_align_x_adjustment =
.draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN, 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) height - (area['y'] * height) + height_diff - TEXT_TOP_MARGIN)
end end
end end
@@ -211,28 +247,28 @@ module Submissions
image_pdfs = [] image_pdfs = []
original_documents = template.documents.preload(:blob) original_documents = template.documents.preload(:blob)
results = result_attachments =
submitter.submission.template_schema.map do |item| submitter.submission.template_schema.map do |item|
pdf = pdfs_index[item['attachment_uuid']] pdf = pdfs_index[item['attachment_uuid']]
attachment = save_pdf(pdf:, submitter:, pkcs:, tsa_url:, attachment = build_pdf_attachment(pdf:, submitter:, pkcs:, tsa_url:,
uuid: item['attachment_uuid'], uuid: item['attachment_uuid'],
name: item['name']) name: item['name'])
image_pdfs << pdf if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image? image_pdfs << pdf if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
attachment attachment
end end
return results if image_pdfs.size < 2 return result_attachments.map { |e| e.tap(&:save!) } if image_pdfs.size < 2
images_pdf = images_pdf =
image_pdfs.each_with_object(HexaPDF::Document.new) do |pdf, doc| image_pdfs.each_with_object(HexaPDF::Document.new) do |pdf, doc|
pdf.pages.each { |page| doc.pages << doc.import(page) } pdf.pages.each { |page| doc.pages << doc.import(page) }
end end
images_pdf_result = images_pdf_attachment =
save_pdf( build_pdf_attachment(
pdf: images_pdf, pdf: images_pdf,
submitter:, submitter:,
tsa_url:, tsa_url:,
@@ -241,11 +277,10 @@ module Submissions
name: template.name name: template.name
) )
results + [images_pdf_result] (result_attachments + [images_pdf_attachment]).map { |e| e.tap(&:save!) }
end 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 io = StringIO.new
pdf.trailer.info[:Creator] = info_creator pdf.trailer.info[:Creator] = info_creator
@@ -265,12 +300,25 @@ module Submissions
sign_params[:signature_size] = 10_000 sign_params[:signature_size] = 10_000
end end
pdf.sign(io, write_options: { validate: false }, **sign_params) begin
else pdf.sign(io, write_options: { validate: false }, **sign_params)
pdf.write(io, incremental: true, validate: false) rescue HexaPDF::MalformedPDFError => e
end 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"), blob: ActiveStorage::Blob.create_and_upload!(io: StringIO.new(io.string), filename: "#{name}.pdf"),
metadata: { original_uuid: uuid, metadata: { original_uuid: uuid,
analyzed: true, analyzed: true,
@@ -306,10 +354,25 @@ module Submissions
Rollbar.error(e) if defined?(Rollbar) Rollbar.error(e) if defined?(Rollbar)
end end
pdf.config['font.on_missing_glyph'] = method(:on_missing_glyph).to_proc
[attachment.metadata['original_uuid'] || attachment.uuid, pdf] [attachment.metadata['original_uuid'] || attachment.uuid, pdf]
end end
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) def find_last_submitter(submitter)
submitter.submission.submitters submitter.submission.submitters
.select(&:completed_at?) .select(&:completed_at?)
+1 -1
View File
@@ -22,7 +22,7 @@ module Submissions
default_values = submitter_params[:values] || {} default_values = submitter_params[:values] || {}
submitter_params[:fields]&.each do |f| 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 end
return submitter_params if default_values.blank? return submitter_params if default_values.blank?
+1
View File
@@ -61,6 +61,7 @@ module Submitters
preferences['send_email'] = params['send_email'].in?(TRUE_VALUES) if params.key?('send_email') 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['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['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['completed_redirect_url'] = params['completed_redirect_url'] if params.key?('completed_redirect_url')
preferences preferences
+9 -4
View File
@@ -13,8 +13,8 @@ module Submitters
module_function module_function
def call(submitter) def call(submitter, with_logo: true)
image = generate_stamp_image(submitter) image = generate_stamp_image(submitter, with_logo:)
image_data = image.write_to_buffer('.png') image_data = image.write_to_buffer('.png')
@@ -30,8 +30,13 @@ module Submitters
) )
end end
def generate_stamp_image(submitter) def generate_stamp_image(submitter, with_logo: true)
logo = Vips::Image.new_from_buffer(load_logo(submitter).read, '') 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) logo = logo.resize([WIDTH / logo.width.to_f, HEIGHT / logo.height.to_f].min)
+1 -1
View File
@@ -9,7 +9,7 @@ module Submitters
module_function module_function
def call(submitter, keys = []) def call(submitter, keys = [])
configs = submitter.submission.template.account.account_configs configs = submitter.submission.account.account_configs
.where(key: DEFAULT_KEYS + keys) .where(key: DEFAULT_KEYS + keys)
completed_button = configs.find { |e| e.key == AccountConfig::FORM_COMPLETED_BUTTON_KEY }&.value || {} completed_button = configs.find { |e| e.key == AccountConfig::FORM_COMPLETED_BUTTON_KEY }&.value || {}
+13 -5
View File
@@ -38,6 +38,9 @@ module Submitters
submitter.ua = request.user_agent submitter.ua = request.user_agent
submitter.values = merge_default_values(submitter) submitter.values = merge_default_values(submitter)
submitter.values = merge_formula_values(submitter) submitter.values = merge_formula_values(submitter)
submitter.values = submitter.values.transform_values do |v|
v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v
end
end end
ApplicationRecord.transaction do ApplicationRecord.transaction do
@@ -78,7 +81,9 @@ module Submitters
next if field['submitter_uuid'] != submitter.uuid next if field['submitter_uuid'] != submitter.uuid
if field['type'] == 'stamp' 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 next
end end
@@ -119,25 +124,28 @@ module Submitters
replace_default_variables(value, replace_default_variables(value,
submitter.attributes.merge('role' => role), submitter.attributes.merge('role' => role),
submitter.submission.template, submitter.submission,
with_time:) with_time:)
end end
def replace_default_variables(value, attrs, template, with_time: false) def replace_default_variables(value, attrs, submission, with_time: false)
return value if value.in?([true, false]) return value if value.in?([true, false])
return if value.blank? return if value.blank?
value.to_s.gsub(VARIABLE_REGEXP) do |e| value.to_s.gsub(VARIABLE_REGEXP) do |e|
case key = ::Regexp.last_match(1) case key = ::Regexp.last_match(1)
when 'id'
attrs['submission_id']
when 'time' when 'time'
if with_time if with_time
I18n.l(Time.current.in_time_zone(template.account.timezone), format: :long, locale: template.account.locale) I18n.l(Time.current.in_time_zone(submission.account.timezone),
format: :long, locale: submission.account.locale)
else else
e e
end end
when 'date' when 'date'
if with_time if with_time
I18n.l(Time.current.in_time_zone(template.account.timezone).to_date) I18n.l(Time.current.in_time_zone(submission.account.timezone).to_date)
else else
e e
end end
+7 -8
View File
@@ -5,12 +5,11 @@ module Templates
module_function module_function
def call(data) 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| pdf.pages.flat_map.with_index do |page, index|
annotations = page.objects.deref!(page.attributes[:Annots]) || [] (page[:Annots] || []).filter_map do |annot|
annotations.filter_map do |annot| next if annot.blank?
next if annot.nil?
next if annot[:A].blank? || annot[:A][:URI].blank? next if annot[:A].blank? || annot[:A][:URI].blank?
next unless annot[:Subtype] == :Link next unless annot[:Subtype] == :Link
next if !annot[:A][:URI].starts_with?('https://') && !annot[:A][:URI].starts_with?('http://') next if !annot[:A][:URI].starts_with?('https://') && !annot[:A][:URI].starts_with?('http://')
@@ -30,10 +29,10 @@ module Templates
{ {
'type' => 'external_link', 'type' => 'external_link',
'value' => annot[:A][:URI], 'value' => annot[:A][:URI],
'x' => left / page.width.to_f, 'x' => left / page.box.width.to_f,
'y' => (page.height - top) / page.height.to_f, 'y' => (page.box.height - top) / page.box.height.to_f,
'w' => (right - left) / page.width.to_f, 'w' => (right - left) / page.box.width.to_f,
'h' => (top - bottom) / page.height.to_f 'h' => (top - bottom) / page.box.height.to_f
} }
end end
end end
+8
View File
@@ -5,6 +5,14 @@ module TextUtils
module_function 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) def maybe_rtl_reverse(text)
if text.match?(RTL_REGEXP) if text.match?(RTL_REGEXP)
TwitterCldr::Shared::Bidi TwitterCldr::Shared::Bidi
+1
View File
@@ -8,6 +8,7 @@ module.exports = {
'./app/views/start_form/**/*.erb', './app/views/start_form/**/*.erb',
'./app/views/shared/_button_title.html.erb', './app/views/shared/_button_title.html.erb',
'./app/views/shared/_attribution.html.erb', './app/views/shared/_attribution.html.erb',
'./app/views/scripts/_autosize_field.html.erb',
'./app/views/send_submission_email/**/*.erb' './app/views/send_submission_email/**/*.erb'
] ]
} }