Compare commits

...
91 Commits
Author SHA1 Message Date
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
Pete Matsyburka f02315726d error on too small signature 2024-03-10 19:55:45 +02:00
Pete Matsyburka e56dcb59c0 add cors headers on API error 2024-03-10 15:54:44 +02:00
Pete Matsyburka c74f350ea2 fix typo 2024-03-10 12:03:46 +02:00
Pete Matsyburka af9d015cf2 refactor mailer custom content 2024-03-10 00:41:18 +02:00
Pete Matsyburka 633e5ac5e4 allow to disable confetti 2024-03-10 00:13:35 +02:00
Pete Matsyburka 2d0f323854 adjust sender email var 2024-03-09 22:55:55 +02:00
Pete Matsyburka cfe5610ec9 add field description and title 2024-03-09 22:55:54 +02:00
Pete Matsyburka 55616ac321 sort field areas 2024-03-08 00:34:16 +02:00
Pete Matsyburka 8f2d1deb38 do not show console link on testing 2024-03-07 21:36:41 +02:00
Pete Matsyburka e2763ceb80 add builder selectors 2024-03-07 19:01:34 +02:00
Pete Matsyburka 10462f3140 fix existing submitter uuids 2024-03-07 16:28:14 +02:00
Pete Matsyburka 3aef92486b silence active storage error 2024-03-06 23:14:07 +02:00
Pete Matsyburka c1a9ee0bbb return error on file download 400+ resp 2024-03-06 22:36:15 +02:00
Pete Matsyburka 398fae82cb optimize api pagination 2024-03-06 18:58:27 +02:00
Pete Matsyburka a16443f961 add completed at submitter filter 2024-03-06 18:44:13 +02:00
Pete Matsyburka ad4e0e1d27 adjust email variables 2024-03-06 00:27:16 +02:00
Pete Matsyburka 493a2108e9 fix safari builder scaling 2024-03-05 20:53:54 +02:00
Pete Matsyburka c2c9bb64b1 fix audit log original sha 2024-03-05 17:16:18 +02:00
Pete Matsyburka bb1426c0af log generation error 2024-03-05 13:56:47 +02:00
Pete Matsyburka 2505768e4d SMTP auto option 2024-03-04 22:30:07 +02:00
Pete Matsyburka 23a1b70534 turbo false console links 2024-03-03 21:21:27 +02:00
Pete Matsyburka b34616ecbc fix default date value 2024-03-03 11:50:22 +02:00
Pete Matsyburka 0111252f6f add timeserver spec info 2024-03-02 23:16:43 +02:00
Pete Matsyburka a561cc5f73 adjust invalid date error 2024-03-02 19:50:58 +02:00
Pete Matsyburka 519e6f1f79 cleanup 2024-03-02 12:09:35 +02:00
Pete Matsyburka da871b5d3a adjust builder page numbers 2024-03-02 11:24:04 +02:00
Pete Matsyburka ae3f939d23 add field conditions placeholder 2024-03-02 11:13:39 +02:00
Pete Matsyburka fb6ede564e retry webhooks on faraday error 2024-03-01 18:51:11 +02:00
Pete Matsyburka 1c701d4fe8 adjust FR i18n 2024-03-01 16:34:11 +02:00
Pete Matsyburka c743cc689d validate account config keys 2024-02-29 23:59:49 +02:00
Pete Matsyburka 8432bec3d6 fix result generation with cell 2024-02-29 21:39:52 +02:00
Pete Matsyburka 8e72b0b0e4 show testing shared templates 2024-02-29 21:39:46 +02:00
Pete Matsyburka 19e4bbc01e parse emails user 2024-02-28 21:54:27 +02:00
Pete Matsyburka 56a9e412c8 autocomplete debounce 2024-02-28 14:13:23 +02:00
Pete Matsyburka 4b75623b86 add index on blob checksum 2024-02-28 10:35:51 +02:00
Pete Matsyburka e1ea42a997 add index on submission events 2024-02-28 09:49:59 +02:00
Pete Matsyburka ee37710e57 adjust expand form 2024-02-28 01:54:10 +02:00
Pete Matsyburka ae99218789 adjust field options preview mode 2024-02-28 00:40:42 +02:00
Pete Matsyburka 9fe8214ca0 adjust blobs proxy 2024-02-28 00:40:19 +02:00
Pete Matsyburka 6ca44a5cc3 optimize autocomplete 2024-02-27 15:24:28 +02:00
Pete Matsyburka 801abd197b optimize account submissions assoc 2024-02-27 13:29:18 +02:00
Pete Matsyburka 970977e469 allow to drag existing field on the page 2024-02-27 01:41:57 +02:00
Pete Matsyburka f83674b934 share template only hosted 2024-02-26 18:02:24 +02:00
Pete Matsyburka 57c7dc1b5e adjust template clone response 2024-02-25 22:53:35 +02:00
119 changed files with 1834 additions and 717 deletions

No files matched your search

+1 -2
View File
@@ -8,8 +8,7 @@
},
"rules": {
"vue/no-deprecated-html-element-is": 0,
"vue/no-mutating-props": 0,
"vue/no-v-html": 0
"vue/no-mutating-props": 0
},
"parserOptions": {
"ecmaVersion": 2022,
+7 -5
View File
@@ -2,7 +2,9 @@ FROM ruby:3.2.2-alpine3.18 as fonts
WORKDIR /fonts
RUN apk --no-cache add wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
RUN apk --no-cache add fontforge wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
FROM ruby:3.2.2-alpine3.18 as webpack
@@ -34,15 +36,14 @@ FROM ruby:3.2.2-alpine3.18 as app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
ENV PIDFILE=/dev/null
WORKDIR /app
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
COPY ./Gemfile ./Gemfile.lock ./
RUN bundle update --bundler && bundle install && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
RUN apk add --no-cache build-base && bundle update --bundler && bundle install && apk del build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
COPY ./bin ./bin
COPY ./app ./app
@@ -55,6 +56,7 @@ COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./
COPY --from=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=fonts /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
@@ -64,4 +66,4 @@ WORKDIR /data/docuseal
ENV WORKDIR=/data/docuseal
EXPOSE 3000
CMD ["/app/bin/rails", "server"]
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
+9 -5
View File
@@ -51,11 +51,12 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- User roles
- Automated reminders
- Invitation and identify verification via SMS
- Conditional fields and formulas
- SSO / SAML
- Template creation with HTML API ([Guide](https://www.docuseal.co/guides/create-pdf-document-fillable-form-with-html-api))
- Template creation with PDF or DOCX and field tags API ([Guide](https://www.docuseal.co/guides/use-embedded-text-field-tags-in-the-pdf-to-create-a-fillable-form))
- Embedded signing form ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue) or [JavaScript](https://www.docuseal.co/docs/embedded))
- Embedded document form builder ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue) or [JavaScript](https://www.docuseal.co/docs/embedded))
- Embedded signing form ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue), [Angular](https://github.com/docusealco/docuseal-angular) or [JavaScript](https://www.docuseal.co/docs/embedded))
- Embedded document form builder ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue), [Angular](https://github.com/docusealco/docuseal-angular) or [JavaScript](https://www.docuseal.co/docs/embedded))
- [Learn more](https://www.docuseal.co/pricing)
## Deploy
@@ -65,9 +66,6 @@ DocuSeal is an open source platform that provides secure and efficient digital d
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
|**Koyeb**|**Elestio**|
| [<img alt="Deploy on Koyeb" src="https://www.koyeb.com/static/images/deploy/button.svg" height="40">](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | [<img alt="Deploy on Elestio" src="https://pub-da36157c854648669813f3f76c526c2b.r2.dev/deploy-on-elestio-black.png">](https://dash.elest.io/deploy?soft=DocuSeal&id=339) |
#### Docker
@@ -100,3 +98,9 @@ At DocuSeal we have expertise and technologies to make documents creation, filli
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information.
Unless otherwise noted, all files © 2023 DocuSeal LLC.
## Tools
- [Signature Maker](https://www.docuseal.co/online-signature)
- [Sign Document Online](https://www.docuseal.co/sign-documents-online)
- [Fill PDF Online](https://www.docuseal.co/fill-pdf)
@@ -8,9 +8,12 @@ class AccountConfigsController < ApplicationController
AccountConfig::ALLOW_TYPED_SIGNATURE,
AccountConfig::FORCE_MFA,
AccountConfig::ALLOW_TO_RESUBMIT,
AccountConfig::ESIGNING_PREFERENCE_KEY
AccountConfig::ESIGNING_PREFERENCE_KEY,
AccountConfig::FORM_WITH_CONFETTI_KEY
].freeze
InvalidKey = Class.new(StandardError)
def create
@account_config.update!(account_config_params)
@@ -20,7 +23,7 @@ class AccountConfigsController < ApplicationController
private
def load_account_config
return head :not_found unless ALLOWED_KEYS.include?(account_config_params[:key])
raise InvalidKey unless ALLOWED_KEYS.include?(account_config_params[:key])
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key])
@@ -12,7 +12,7 @@ module Api
def show
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
if blob_uuid.blank? || purp != 'blob' || (exp && exp < Time.current.to_i)
if blob_uuid.blank? || (purp.present? && purp != 'blob') || (exp && exp < Time.current.to_i)
Rollbar.error('Blob not found') if defined?(Rollbar)
return head :not_found
+8 -4
View File
@@ -19,10 +19,14 @@ module Api
render json: { error: e.message }, status: :unprocessable_entity
end
rescue_from RateLimit::LimitApproached do |e|
Rollbar.error(e) if defined?(Rollbar)
render json: { error: 'Too many requests' }, status: :too_many_requests
end
if Rails.env.production?
rescue_from CanCan::AccessDenied do |e|
Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :forbidden
end
@@ -39,8 +43,8 @@ module Api
result = relation.order(id: :desc)
.limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min)
result = result.where(relation.arel_table[:id].lt(params[:after])) if params[:after].present?
result = result.where(relation.arel_table[:id].gt(params[:before])) if params[:before].present?
result = result.where(id: ...params[:after].to_i) if params[:after].present?
result = result.where(id: (params[:before].to_i + 1)...) if params[:before].present?
result
end
@@ -161,11 +161,11 @@ module Api
def submissions_params
permitted_attrs = [
:send_email, :send_sms, :bcc_completed, :completed_redirect_url,
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to,
{
message: %i[subject body],
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id,
:completed, :phone, :application_key, :external_id, :reply_to,
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
fields: [%i[name default_value title description
readonly validation_pattern invalid_message]] }]]
@@ -1,35 +0,0 @@
# frozen_string_literal: true
module Api
class SubmittersAutocompleteController < ApiBaseController
load_and_authorize_resource :submitter, parent: false
SELECT_COLUMNS = %w[email phone name].freeze
LIMIT = 100
def index
submitters = search_submitters(@submitters)
values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', '))
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
render json: attrs
end
private
def search_submitters(submitters)
if SELECT_COLUMNS.include?(params[:field])
column = Submitter.arel_table[params[:field].to_sym]
term = "%#{params[:q].downcase}%"
submitters.where(column.lower.matches(term))
else
Submitters.search(submitters, params[:q])
end
end
end
end
@@ -10,6 +10,7 @@ module Api
submitters = submitters.where(external_id: params[:application_key]) if params[:application_key].present?
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
submitters = maybe_filder_by_completed_at(submitters, params)
submitters = paginate(
submitters.preload(:template, :submission, :submission_events,
@@ -81,6 +82,18 @@ module Api
private
def maybe_filder_by_completed_at(submitters, params)
if params[:completed_after].present?
submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..)
end
if params[:completed_before].present?
submitters = submitters.where(completed_at: ..Time.zone.parse(params[:completed_before]))
end
submitters
end
def assign_submitter_attrs(submitter, attrs)
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
@@ -1,16 +0,0 @@
# frozen_string_literal: true
module Api
class TemplateFoldersAutocompleteController < ApiBaseController
load_and_authorize_resource :template_folder, parent: false
LIMIT = 100
def index
template_folders = @template_folders.joins(:templates).where(templates: { archived_at: nil }).distinct
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT)
render json: template_folders.as_json(only: %i[name archived_at])
end
end
end
@@ -20,16 +20,7 @@ module Api
Templates::CloneAttachments.call(template: cloned_template, original_template: @template)
render json: cloned_template.as_json(serialize_params)
end
private
def serialize_params
{
include: { author: { only: %i[id email first_name last_name] },
documents: { only: %i[id uuid], methods: %i[url filename] } }
}
render json: Templates::SerializeForApi.call(cloned_template)
end
end
end
+10 -12
View File
@@ -49,6 +49,14 @@ module Api
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
end
Array.wrap(params[:roles].presence || params.dig(:template, :roles).presence).each_with_index do |role, index|
if (item = @template.submitters[index])
item['name'] = role
else
@template.submitters << { 'name' => role, 'uuid' => SecureRandom.uuid }
end
end
archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived)
if archived.in?([true, false])
@@ -83,20 +91,10 @@ module Api
end
def template_params
permit_params = [
:name,
{ schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
{ preferences: {},
options: [%i[value uuid]],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
]
if params.key?(:template)
params.require(:template).permit(*permit_params)
params.require(:template).permit(:name, submitters: [%i[name uuid]])
else
params.permit(*permit_params)
params.permit(:name, submitters: [%i[name uuid]])
end
end
end
@@ -1,29 +0,0 @@
# frozen_string_literal: true
module Api
class TemplatesDocumentsController < ApiBaseController
load_and_authorize_resource :template
def create
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
documents = Templates::CreateAttachments.call(@template, params)
schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base }
end
render json: {
schema:,
documents: documents.as_json(
methods: %i[metadata signed_uuid],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
rescue Templates::CreateAttachments::PdfEncrypted
render json: { error: 'PDF encrypted' }, status: :unprocessable_entity
end
end
end
@@ -22,6 +22,12 @@ class ApplicationController < ActionController::Base
redirect_to request.path
end
rescue_from RateLimit::LimitApproached do |e|
Rollbar.error(e) if defined?(Rollbar)
redirect_to request.referer, alert: 'Too many requests', status: :too_many_requests
end
if Rails.env.production?
rescue_from CanCan::AccessDenied do |e|
Rollbar.warning(e) if defined?(Rollbar)
@@ -5,6 +5,10 @@ class ConsoleRedirectController < ApplicationController
skip_authorization_check
def index
if request.path == '/upgrade'
params[:redir] = Docuseal.multitenant? ? "#{Docuseal::CONSOLE_URL}/plans" : "#{Docuseal::CONSOLE_URL}/on_premise"
end
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank?
auth = JsonWebToken.encode(uuid: true_user.uuid,
+7 -3
View File
@@ -54,10 +54,14 @@ class DashboardController < ApplicationController
rel = templates.active.preload(:author).order(id: :desc)
if params[:q].blank?
shared_template_ids =
TemplateSharing.where(account_id: [current_account.id, TemplateSharing::ALL_ID]).select(:template_id)
if Docuseal.multitenant? && !current_account.testing?
rel = rel.where(folder_id: current_account.default_template_folder.id)
else
shared_template_ids =
TemplateSharing.where(account_id: [current_account.id, TemplateSharing::ALL_ID]).select(:template_id)
rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids))
rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids))
end
end
Templates.search(rel, params[:q])
+10
View File
@@ -22,6 +22,8 @@ class ErrorsController < ActionController::Base
respond_to do |f|
f.json do
set_cors_headers
render json: { status: error_status_code }, status: error_status_code
end
@@ -31,6 +33,14 @@ class ErrorsController < ActionController::Base
private
def set_cors_headers
headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
headers['Access-Control-Allow-Headers'] = '*'
headers['Access-Control-Max-Age'] = '1728000'
headers['Access-Control-Allow-Credentials'] = true
end
def error_status_code
@error_status_code ||=
ActionDispatch::ExceptionWrapper.new(request.env,
@@ -1,26 +1,51 @@
# frozen_string_literal: true
class PersonalizationSettingsController < ApplicationController
ALLOWED_KEYS = [
AccountConfig::FORM_COMPLETED_BUTTON_KEY,
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY,
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY,
AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY
].freeze
InvalidKey = Class.new(StandardError)
before_action :load_and_authorize_account_config, only: :create
def show
authorize!(:read, AccountConfig)
end
def create
account_config =
current_account.account_configs.find_or_initialize_by(key: account_config_params[:key])
authorize!(:create, account_config)
account_config.update!(account_config_params)
if @account_config.value != false && @account_config.value.blank?
@account_config.destroy!
else
@account_config.save!
end
redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.')
end
private
def load_and_authorize_account_config
@account_config =
current_account.account_configs.find_or_initialize_by(key: account_config_params[:key])
@account_config.assign_attributes(account_config_params)
authorize!(:create, @account_config)
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
@account_config
end
def account_config_params
attrs = params.require(:account_config).permit!
return attrs if attrs[:value].is_a?(String)
attrs[:value]&.transform_values! do |value|
if value.in?(%w[true false])
value == 'true'
@@ -1,62 +0,0 @@
# frozen_string_literal: true
class RegistrationsController < Devise::RegistrationsController
prepend_before_action :require_no_authentication, only: [:show]
prepend_before_action :maybe_redirect_if_signed_in, only: [:show]
def show; end
def create
super
Accounts.create_default_template(resource.account) if resource.account.persisted?
end
private
def after_sign_up_path_for(...)
if params[:redir].present?
return console_redirect_index_path(redir: params[:redir]) if params[:redir].starts_with?(Docuseal::CONSOLE_URL)
return params[:redir]
end
super
end
def maybe_redirect_if_signed_in
return unless signed_in?
return if params[:redir].blank?
redirect_to after_sign_up_path_for(current_user), allow_other_host: true
end
def set_flash_message(key, kind, options = {})
return if key == :alert && kind == 'already_authenticated'
super
end
def build_resource(_hash = {})
account = Account.new(account_params)
account.timezone = Accounts.normalize_timezone(account.timezone)
self.resource = account.users.new(user_params)
account.name ||= resource.full_name if params[:action] == 'create'
end
def user_params
return {} if params[:user].blank?
params.require(:user).permit(:first_name, :last_name, :email, :password).compact_blank.tap do |attrs|
attrs[:password] ||= SecureRandom.hex if params[:action] == 'create'
end
end
def account_params
return {} if params[:account].blank?
params.require(:account).permit(:name, :timezone).compact_blank
end
end
@@ -21,6 +21,8 @@ class SendSubmissionEmailController < ApplicationController
Submitter.find_by!(slug: params[:submitter_slug])
end
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later!
respond_to do |f|
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class SubmittersAutocompleteController < ApplicationController
load_and_authorize_resource :submitter, parent: false
SELECT_COLUMNS = %w[email phone name].freeze
LIMIT = 100
def index
submitters = search_submitters(@submitters)
values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', '))
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
render json: attrs
end
private
def search_submitters(submitters)
if SELECT_COLUMNS.include?(params[:field])
column = Submitter.arel_table[params[:field].to_sym]
term = "#{params[:q].downcase}%"
submitters.where(column.matches(term, false, true))
else
Submitters.search(submitters, params[:q])
end
end
end
@@ -0,0 +1,27 @@
# frozen_string_literal: true
class TemplateDocumentsController < ApplicationController
load_and_authorize_resource :template
def create
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
documents = Templates::CreateAttachments.call(@template, params)
schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base }
end
render json: {
schema:,
documents: documents.as_json(
methods: %i[metadata signed_uuid],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
rescue Templates::CreateAttachments::PdfEncrypted
render json: { error: 'PDF encrypted' }, status: :unprocessable_entity
end
end
@@ -0,0 +1,14 @@
# frozen_string_literal: true
class TemplateFoldersAutocompleteController < ApplicationController
load_and_authorize_resource :template_folder, parent: false
LIMIT = 100
def index
template_folders = @template_folders.joins(:templates).where(templates: { archived_at: nil }).distinct
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT)
render json: template_folders.as_json(only: %i[name archived_at])
end
end
@@ -6,7 +6,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController
def index
@submissions = @submissions.where.not(archived_at: nil)
@submissions = Submissions.search(@submissions, params[:q])
@submissions = Submissions.search(@submissions, params[:q], search_values: true)
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
rescue ActiveRecord::RecordNotFound
+23 -5
View File
@@ -8,7 +8,7 @@ class TemplatesController < ApplicationController
def show
submissions = @template.submissions.accessible_by(current_ability)
submissions = submissions.active if @template.archived_at.blank?
submissions = Submissions.search(submissions, params[:q])
submissions = Submissions.search(submissions, params[:q], search_values: true)
@base_submissions = submissions
@@ -67,6 +67,12 @@ class TemplatesController < ApplicationController
end
end
def update
@template.update!(template_params)
head :ok
end
def destroy
notice =
if params[:permanently].present?
@@ -86,6 +92,22 @@ class TemplatesController < ApplicationController
private
def template_params
params.require(:template).permit(
:name,
{ schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description,
{ preferences: {},
conditions: [%i[field_uuid value action]],
options: [%i[value uuid]],
validation: %i[message pattern],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
)
end
def authorized_clone_account_id?(account_id)
true_user.account_id.to_s == account_id.to_s || true_user.account.linked_accounts.exists?(id: account_id)
end
@@ -98,10 +120,6 @@ class TemplatesController < ApplicationController
end
end
def template_params
params.require(:template).permit(:name)
end
def load_base_template
return if params[:base_template_id].blank?
+3 -1
View File
@@ -8,6 +8,8 @@ class UserConfigsController < ApplicationController
UserConfig::RECEIVE_COMPLETED_EMAIL
].freeze
InvalidKey = Class.new(StandardError)
def create
@user_config.update!(user_config_params)
@@ -17,7 +19,7 @@ class UserConfigsController < ApplicationController
private
def load_user_config
return head :not_found unless ALLOWED_KEYS.include?(user_config_params[:key])
raise InvalidKey unless ALLOWED_KEYS.include?(user_config_params[:key])
@user_config =
UserConfig.find_or_initialize_by(user: current_user, key: user_config_params[:key])
+4
View File
@@ -20,6 +20,7 @@ import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form'
import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea'
import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -53,6 +54,7 @@ window.customElements.define('folder-autocomplete', FolderAutocomplete)
window.customElements.define('signature-form', SignatureForm)
window.customElements.define('submit-form', SubmitForm)
window.customElements.define('prompt-password', PromptPassword)
window.customElements.define('emails-textarea', EmailsTextarea)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => {
@@ -89,8 +91,10 @@ window.customElements.define('template-builder', class extends HTMLElement {
withPhone: this.dataset.withPhone === 'true',
withLogo: this.dataset.withLogo !== 'false',
editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withPayment: this.dataset.withPayment === 'true',
withFormula: this.dataset.withFormula === 'true',
withConditions: this.dataset.withConditions === 'true',
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
acceptFileTypes: this.dataset.acceptFileTypes,
isDirectUpload: this.dataset.isDirectUpload === 'true'
+4
View File
@@ -125,3 +125,7 @@ button[disabled] .enabled {
outline-offset: 3px;
outline-color: hsl(var(--bc) / 0.2);
}
select:required:invalid {
color: gray !important;
}
@@ -0,0 +1,56 @@
const emailRegexp = /([^@;,<>\s]+@[^@;,<>\s]+)/g
export default class extends HTMLElement {
connectedCallback () {
if (this.dataset.limit) {
this.textarea.addEventListener('input', () => {
const emails = this.textarea.value.match(emailRegexp) || []
this.updateCounter(emails.length)
})
}
}
updateCounter (count) {
let counter = document.getElementById('emails_counter')
let bulkMessage = document.getElementById('bulk_message')
if (count < 2) {
counter?.remove()
return
}
if ((count + 10) > this.dataset.limit) {
if (!counter) {
counter = document.createElement('span')
counter.id = 'emails_counter'
counter.classList.add('text-xs', 'right-0', 'absolute')
counter.style.bottom = '-15px'
this.textarea.parentNode.append(counter)
}
counter.innerText = `${count} / ${this.dataset.limit}`
}
if (this.dataset.bulkEnabled !== 'true') {
if (!bulkMessage) {
bulkMessage = document.createElement('span')
bulkMessage.id = 'bulk_message'
bulkMessage.classList.add('text-xs', 'left-0', 'absolute')
bulkMessage.style.bottom = '-15px'
this.textarea.parentNode.append(bulkMessage)
}
bulkMessage.innerHTML = '<a class="link" data-turbo="false" href="/upgrade">Upgrade</a> to bulk send multiple recipients'
}
}
get textarea () {
return this.querySelector('textarea')
}
}
@@ -20,7 +20,7 @@ export default class extends HTMLElement {
fetch = (text, resolve) => {
const queryParams = new URLSearchParams({ q: text })
fetch('/api/template_folders_autocomplete?' + queryParams).then(async (resp) => {
fetch('/template_folders_autocomplete?' + queryParams).then(async (resp) => {
const items = await resp.json()
resolve(items)
@@ -7,6 +7,7 @@ export default class extends HTMLElement {
preventSubmit: 1,
minLength: 1,
showOnFocus: true,
debounceWaitMs: 200,
onSelect: this.onSelect,
render: this.render,
fetch: this.fetch
@@ -27,6 +28,8 @@ export default class extends HTMLElement {
if (textarea && item[field]) {
textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ')
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
})
}
@@ -37,7 +40,9 @@ export default class extends HTMLElement {
if (q) {
const queryParams = new URLSearchParams({ q, field: this.dataset.field })
fetch('/api/submitters_autocomplete?' + queryParams).then(async (resp) => {
this.currentFetch ||= fetch('/submitters_autocomplete?' + queryParams)
this.currentFetch.then(async (resp) => {
const items = await resp.json()
if (q.length < 3) {
@@ -47,6 +52,8 @@ export default class extends HTMLElement {
}
}).catch(() => {
resolve([])
}).finally(() => {
this.currentFetch = null
})
} else {
resolve([])
+1
View File
@@ -17,6 +17,7 @@ window.customElements.define('submission-form', class extends HTMLElement {
attribution: this.dataset.attribution !== 'false',
withConfetti: this.dataset.withConfetti !== 'false',
withTypedSignature: this.dataset.withTypedSignature !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
values: reactive(JSON.parse(this.dataset.values)),
completedButton: JSON.parse(this.dataset.completedButton),
completedRedirectUrl: this.dataset.completedRedirectUrl,
+19 -8
View File
@@ -157,6 +157,7 @@
ref="textContainer"
dir="auto"
class="flex items-center px-0.5 w-full"
:class="alignClasses[field.preferences?.align]"
>
<span v-if="Array.isArray(modelValue)">
{{ modelValue.join(', ') }}
@@ -167,6 +168,7 @@
<span
v-else
class="whitespace-pre-wrap"
:class="{ 'w-full': field.preferences?.align }"
>{{ modelValue }}</span>
</div>
</div>
@@ -253,6 +255,13 @@ export default {
phone: this.t('phone')
}
},
alignClasses () {
return {
center: 'text-center',
left: 'text-left',
right: 'text-right'
}
},
option () {
return this.field.options.find((o) => o.uuid === this.area.option_uuid)
},
@@ -338,17 +347,19 @@ export default {
},
watch: {
modelValue () {
if (this.field.type === 'text' && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.modelValue.length)) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0
}
this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.modelValue}`.length)) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue}`.length : 0
}
})
}
},
mounted () {
if (this.field.type === 'text' && this.$refs.textContainer) {
this.$nextTick(() => {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0
})
}
this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue}`.length : 0
}
})
},
methods: {
optionValue (option) {
@@ -41,6 +41,13 @@
:name="`values[${field.uuid}][]`"
>
</template>
<div
v-if="field.description && !modelValue.length"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<FileDropzone
:message="`${t('upload')} ${field.name || t('files')}${field.required ? '' : ` (${t('optional')})`}`"
:submitter-slug="submitterSlug"
@@ -53,12 +60,14 @@
<script>
import FileDropzone from './dropzone'
import MarkdownContent from './markdown_content'
import { IconPaperclip, IconTrashX } from '@tabler/icons-vue'
export default {
name: 'AttachmentStep',
components: {
FileDropzone,
MarkdownContent,
IconPaperclip,
IconTrashX
},
+7 -1
View File
@@ -16,7 +16,8 @@
<div class="space-y-3 mt-5">
<a
v-if="completedButton.url"
:href="completedButton.url"
:href="sanitizeHref(completedButton.url)"
rel="noopener noreferrer nofollow"
class="white-button flex items-center w-full"
>
<span>
@@ -215,6 +216,11 @@ export default {
this.isDownloading = false
},
sanitizeHref (href) {
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
return href.replace(/javascript:/g, '')
}
},
downloadSafariIos (urls) {
const fileRequests = urls.map((url) => {
return fetch(url).then(async (resp) => {
@@ -1,4 +1,4 @@
function cropCanvasAndExportToPNG (canvas) {
function cropCanvasAndExportToPNG (canvas, { errorOnTooSmall } = { errorOnTooSmall: false }) {
const ctx = canvas.getContext('2d')
const width = canvas.width
@@ -33,6 +33,10 @@ function cropCanvasAndExportToPNG (canvas) {
croppedCanvas.height = croppedHeight
const croppedCtx = croppedCanvas.getContext('2d')
if (errorOnTooSmall && (croppedWidth < 20 || croppedHeight < 20)) {
return Promise.reject(new Error('Image too small'))
}
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
return new Promise((resolve, reject) => {
+22 -3
View File
@@ -1,11 +1,21 @@
<template>
<div dir="auto">
<div class="flex justify-between items-center w-full mb-2">
<div
class="flex justify-between items-center w-full"
:class="{ 'mb-2': !field.description }"
>
<label
:for="field.uuid"
class="label text-2xl"
>{{ field.name && showFieldNames ? field.name : t('date') }}
<template v-if="!field.required">({{ t('optional') }})</template>
>
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ field.name && showFieldNames ? field.name : t('date') }}
<template v-if="!field.required">({{ t('optional') }})</template>
</template>
</label>
<button
class="btn btn-outline btn-sm !normal-case font-normal"
@@ -15,6 +25,13 @@
{{ t('set_today') }}
</button>
</div>
<div
v-if="field.description"
class="mb-3 px-1"
dir="auto"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" />
<div class="text-center">
<input
@@ -33,11 +50,13 @@
<script>
import { IconCalendarCheck } from '@tabler/icons-vue'
import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
export default {
name: 'DateStep',
components: {
IconCalendarCheck,
MarkdownContent,
AppearsOn
},
inject: ['t'],
+178 -75
View File
@@ -19,7 +19,13 @@
class="btn btn-neutral flex text-white absolute rounded-none border-x-0 md:border md:rounded-full bottom-0 w-full md:mb-4 text-base"
@click.prevent="[isFormVisible = true, scrollIntoField(currentField)]"
>
{{ t('submit_form') }}
<template v-if="['initials', 'signature'].includes(currentField.type)">
<IconWritingSign stroke-width="1.5" />
{{ t('sign_now') }}
</template>
<template v-else>
{{ t('submit_form') }}
</template>
<IconArrowsDiagonal
class="absolute right-0 mr-4"
:width="20"
@@ -95,17 +101,32 @@
/>
<div v-else-if="currentField.type === 'select'">
<label
v-if="showFieldNames && currentField.name"
v-if="showFieldNames && (currentField.name || currentField.title)"
:for="currentField.uuid"
dir="auto"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
class="label text-2xl"
:class="{ 'mb-2': !currentField.description }"
>
<MarkdownContent
v-if="currentField.title"
:string="currentField.title"
/>
<template v-else>
{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</template>
</label>
<div
v-else
class="py-1"
/>
<div
v-if="currentField.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="currentField.description" />
</div>
<AppearsOn :field="currentField" />
<select
:id="currentField.uuid"
@@ -134,13 +155,28 @@
</div>
<div v-else-if="currentField.type === 'radio'">
<label
v-if="showFieldNames && currentField.name"
v-if="showFieldNames && (currentField.name || currentField.title)"
:for="currentField.uuid"
dir="auto"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
class="label text-2xl"
:class="{ 'mb-2': !currentField.description }"
>
<MarkdownContent
v-if="currentField.title"
:string="currentField.title"
/>
<template v-else>
{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</template>
</label>
<div
v-if="currentField.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="currentField.description" />
</div>
<div class="flex w-full max-h-44 overflow-y-auto">
<div
v-if="!showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4)"
@@ -191,66 +227,77 @@
/>
<div
v-else-if="currentField.type === 'checkbox'"
class="flex w-full max-h-44 overflow-y-auto"
>
<input
type="hidden"
name="cast_boolean"
value="true"
>
<div
class="space-y-3.5 mx-auto"
v-if="currentField.description"
dir="auto"
class="mb-3 px-1"
>
<template v-if="isAnonymousChecboxes || !showFieldNames">
<span class="text-xl">
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
</span>
<input
v-for="field in currentStepFields"
:key="field.uuid"
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
>
</template>
<template v-else>
<div
v-for="(field, index) in currentStepFields"
:key="field.uuid"
>
<label
:for="field.uuid"
class="flex items-center space-x-3"
<MarkdownContent :string="currentField.description" />
</div>
<div
class="flex w-full max-h-44 overflow-y-auto"
>
<input
type="hidden"
name="cast_boolean"
value="true"
>
<div
class="space-y-3.5 mx-auto"
>
<template v-if="isAnonymousChecboxes || !showFieldNames">
<span class="text-xl">
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
</span>
<input
v-for="field in currentStepFields"
:key="field.uuid"
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
>
<input
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
</template>
<template v-else>
<div
v-for="(field, index) in currentStepFields"
:key="field.uuid"
>
<label
:for="field.uuid"
class="flex items-center space-x-3"
>
<input
:id="field.uuid"
type="checkbox"
class="base-checkbox !h-7 !w-7"
:oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`"
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
:required="field.required"
:checked="!!values[field.uuid]"
@click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
>
<span
v-if="field.title"
class="text-xl"
v-html="field.title"
/>
<span
v-else
class="text-xl"
>
{{ field.name || field.type + ' ' + (index + 1) }}
</span>
</label>
</div>
</template>
<input
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
>
<input
:id="field.uuid"
type="checkbox"
class="base-checkbox !h-7 !w-7"
:oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`"
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
:required="field.required"
:checked="!!values[field.uuid]"
@click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
>
<span
v-if="field.title"
class="text-xl"
>
<MarkdownContent :string="field.title" />
</span>
<span
v-else
class="text-xl"
>
{{ field.name || field.type + ' ' + (index + 1) }}
</span>
</label>
</div>
</template>
</div>
</div>
</div>
<ImageStep
@@ -412,8 +459,9 @@ import PaymentStep from './payment_step'
import TextStep from './text_step'
import NumberStep from './number_step'
import DateStep from './date_step'
import MarkdownContent from './markdown_content'
import FormCompleted from './completed'
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { IconInnerShadowTop, IconArrowsDiagonal, IconWritingSign, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import AppearsOn from './appears_on'
import i18n from './i18n'
@@ -442,6 +490,7 @@ export default {
ImageStep,
SignatureStep,
AppearsOn,
IconWritingSign,
AttachmentStep,
InitialsStep,
MultiSelectStep,
@@ -452,6 +501,7 @@ export default {
NumberStep,
FormulaFieldAreas,
PhoneStep,
MarkdownContent,
PaymentStep,
IconArrowsDiagonalMinimize2,
FormCompleted
@@ -489,7 +539,7 @@ export default {
expand: {
type: Boolean,
required: false,
default: true
default: null
},
withConfetti: {
type: Boolean,
@@ -561,6 +611,11 @@ export default {
required: false,
default: () => ({})
},
authenticityToken: {
type: String,
required: false,
default: ''
},
i18n: {
type: Object,
required: false,
@@ -590,7 +645,7 @@ export default {
data () {
return {
isCompleted: false,
isFormVisible: this.expand,
isFormVisible: this.expand !== false,
showFillAllRequiredFields: false,
currentStep: 0,
isSubmitting: false,
@@ -608,12 +663,16 @@ export default {
queryParams () {
return new URLSearchParams(window.location.search)
},
authenticityToken () {
return document.querySelector('meta[name="csrf-token"]')?.content
},
submitterSlug () {
return this.submitter.slug
},
fieldsUuidIndex () {
return this.fields.reduce((acc, f) => {
acc[f.uuid] = f
return acc
}, {})
},
previousInitialsValue () {
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
@@ -639,10 +698,12 @@ export default {
return this.fields.filter((f) => !f.readonly).reduce((acc, f) => {
const prevStep = acc[acc.length - 1]
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox') {
prevStep.push(f)
} else {
acc.push([f])
if (this.checkFieldConditions(f)) {
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox' && !f.description) {
prevStep.push(f)
} else {
acc.push([f])
}
}
return acc
@@ -699,6 +760,10 @@ export default {
this.currentStep = Math.min(...indexesList)
}
if (document.body?.clientWidth >= 768 && this.expand !== true && ['signature', 'initials', 'file', 'image'].includes(this.currentField?.type)) {
this.isFormVisible = false
}
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
this.$nextTick(() => {
const root = this.$root.$el.parentNode.getRootNode()
@@ -728,6 +793,40 @@ export default {
t (key) {
return this.i18n[key] || i18n[this.language?.toLowerCase()]?.[key] || i18n[this.browserLanguage]?.[key] || i18n.en[key] || key
},
checkFieldConditions (field) {
if (field.conditions?.length) {
return field.conditions.reduce((acc, c) => {
if (['empty', 'unchecked'].includes(c.action)) {
return acc && isEmpty(this.values[c.field_uuid])
} else if (['not_empty', 'checked'].includes(c.action)) {
return acc && !isEmpty(this.values[c.field_uuid])
} else if (['equal', 'contains'].includes(c.action)) {
const field = this.fieldsUuidIndex[c.field_uuid]
const option = field.options.find((o) => o.uuid === c.value)
const values = [this.values[c.field_uuid]].flat()
return acc && values.includes(this.optionValue(option, field.options.indexOf(option)))
} else if (['not_equal', 'does_not_contain'].includes(c.action)) {
const field = this.fieldsUuidIndex[c.field_uuid]
const option = field.options.find((o) => o.uuid === c.value)
const values = [this.values[c.field_uuid]].flat()
return acc && !values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return acc
}
}, true)
} else {
return true
}
},
optionValue (option, index) {
if (option.value) {
return option.value
} else {
return `${this.t('option')} ${index + 1}`
}
},
maybeTrackEmailClick () {
const { queryParams } = this
@@ -903,7 +1002,11 @@ export default {
this.isSubmitting = false
})
}).catch(error => {
console.log(error)
if (error?.message === 'Image too small') {
alert('Signature is too small - please redraw.')
} else {
console.log(error)
}
}).finally(() => {
this.isSubmitting = false
})
+12
View File
@@ -17,6 +17,7 @@ const en = {
payment: 'Payment',
phone: 'Phone',
submit_form: 'Submit Form',
sign_now: 'Sign Now',
type_here_: 'Type here...',
optional: 'optional',
option: 'Option',
@@ -82,6 +83,7 @@ const es = {
phone: 'Teléfono',
take_photo: 'Tomar foto',
submit_form: 'Enviar Formulario',
sign_now: 'Firmar ahora',
type_here_: 'Escribe aquí...',
optional: 'opcional',
appears_on: 'Aparece en',
@@ -145,6 +147,7 @@ const it = {
payment: 'Pagamento',
phone: 'Telefono',
submit_form: 'Invia Modulo',
sign_now: 'Firma ora',
type_here_: 'Digita qui...',
optional: 'opzionale',
appears_on: 'Compare su',
@@ -209,6 +212,7 @@ const de = {
payment: 'Zahlung',
phone: 'Telefon',
submit_form: 'Formular absenden',
sign_now: 'Jetzt unterschreiben',
type_here_: 'Hier eingeben...',
optional: 'optional',
appears_on: 'Erscheint auf',
@@ -273,6 +277,7 @@ const fr = {
payment: 'Paiement',
phone: 'Téléphone',
submit_form: 'Envoyer le Formulaire',
sign_now: 'Signer maintenant',
type_here_: 'Tapez ici...',
optional: 'facultatif',
appears_on: 'Apparaît sur',
@@ -337,6 +342,7 @@ const pl = {
payment: 'Płatność',
phone: 'Telefon',
submit_form: 'Wyślij Formularz',
sign_now: 'Podpisz teraz',
type_here_: 'Wpisz tutaj...',
optional: 'opcjonalny',
appears_on: 'Pojawia się na',
@@ -401,6 +407,7 @@ const uk = {
payment: 'Платіж',
phone: 'Телефон',
submit_form: 'Надіслати Форму',
sign_now: 'Підписати зараз',
type_here_: 'Введіть тут',
optional: 'необов’язково',
appears_on: "З'являється на",
@@ -465,6 +472,7 @@ const cs = {
payment: 'Platba',
phone: 'Telefon',
submit_form: 'Odeslat formulář',
sign_now: 'Podepsat nyní',
type_here_: 'Zadejte zde',
optional: 'volitelné',
appears_on: 'Zobrazuje se na',
@@ -529,6 +537,7 @@ const pt = {
payment: 'Pagamento',
phone: 'Telefone',
submit_form: 'Enviar Formulário',
sign_now: 'Assinar agora',
type_here_: 'Digite aqui',
optional: 'opcional',
appears_on: 'Aparece em',
@@ -593,6 +602,7 @@ const he = {
payment: 'תשלום',
phone: 'טלפון',
submit_form: 'שלח טופס',
sign_now: 'חתום כעת',
type_here_: 'הקלד כאן',
optional: 'אופציונלי',
option: 'אפשרות',
@@ -659,6 +669,7 @@ const nl = {
payment: 'Betaling',
phone: 'Telefoon',
submit_form: 'Formulier verzenden',
sign_now: 'Nu ondertekenen',
type_here_: 'Typ hier...',
optional: 'Optioneel',
option: 'Optie',
@@ -724,6 +735,7 @@ const ar = {
payment: 'الدفع',
phone: 'هاتف',
submit_form: 'إرسال النموذج',
sign_now: 'وقع الآن',
type_here_: 'اكتب هنا',
optional: 'اختياري',
option: 'خيار',
+13 -3
View File
@@ -24,9 +24,17 @@
:name="`values[${field.uuid}]`"
>
</div>
<div>
<div
v-if="!modelValue"
>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<FileDropzone
v-if="!modelValue"
:message="`${t('upload')} ${field.name || t('image')}${field.required ? '' : ` (${t('optional')})`}`"
:submitter-slug="submitterSlug"
:accept="'image/*'"
@@ -39,12 +47,14 @@
<script>
import FileDropzone from './dropzone'
import { IconReload } from '@tabler/icons-vue'
import MarkdownContent from './markdown_content'
export default {
name: 'ImageStep',
components: {
FileDropzone,
IconReload
IconReload,
MarkdownContent
},
inject: ['t'],
props: {
@@ -1,9 +1,20 @@
<template>
<div dir="auto">
<div class="flex justify-between items-center w-full mb-2">
<div
class="flex justify-between items-center w-full"
:class="{ 'mb-2': !field.description }"
>
<label
class="label text-2xl"
>{{ showFieldNames && field.name ? field.name : t('initials') }}</label>
>
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ showFieldNames && field.name ? field.name : t('initials') }}
</template>
</label>
<div class="space-x-2 flex">
<span
v-if="isDrawInitials"
@@ -70,6 +81,13 @@
</a>
</div>
</div>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" />
<input
:value="modelValue || computedPreviousValue"
@@ -105,6 +123,7 @@ import { cropCanvasAndExportToPNG } from './crop_canvas'
import { IconReload, IconTextSize, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
const scale = 3
@@ -115,6 +134,7 @@ export default {
IconReload,
IconTextSize,
IconSignature,
MarkdownContent,
IconArrowsDiagonalMinimize2
},
inject: ['baseUrl', 't'],
@@ -0,0 +1,75 @@
<template>
<span>
<template
v-for="(item, index) in items"
:key="index"
>
<a
v-if="item.startsWith('<a') && item.endsWith('</a>')"
:href="sanitizeHref(extractAttr(item, 'href'))"
rel="noopener noreferrer nofollow"
:class="extractAttr(item, 'class') || 'link'"
target="_blank"
>
{{ extractText(item) }}
</a>
<b
v-else-if="item.startsWith('<b>') || item.startsWith('<strong>')"
>
{{ extractText(item) }}
</b>
<i
v-else-if="item.startsWith('<i>') || item.startsWith('<em>')"
>
{{ extractText(item) }}
</i>
<br
v-else-if="item === '<br>' || item === '\n'"
>
<template
v-else
>
{{ item }}
</template>
</template>
</span>
</template>
<script>
import snarkdown from 'snarkdown'
const htmlSplitRegexp = /(<a.+?<\/a>|<i>.+?<\/i>|<b>.+?<\/b>|<em>.+?<\/em>|<strong>.+?<\/strong>|<br>)/
export default {
name: 'MarkdownContent',
props: {
string: {
type: String,
required: false,
default: ''
}
},
computed: {
items () {
return snarkdown(this.string.replace(/\n/g, '<br>')).split(htmlSplitRegexp)
}
},
methods: {
sanitizeHref (href) {
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
return href.replace(/javascript:/g, '')
}
},
extractAttr (text, attr) {
if (text.includes(attr)) {
return text.split(attr).pop().split('"')[1]
}
},
extractText (text) {
if (text) {
return text.match(/>(.+?)</)?.[1]
}
}
}
}
</script>
@@ -1,10 +1,23 @@
<template>
<label
v-if="showFieldNames && field.name"
v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid"
dir="auto"
class="label text-2xl mb-2"
>{{ field.name }}</label>
class="label text-2xl"
:class="{ 'mb-2': !field.description }"
><MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name }}</template>
</label>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<div class="flex w-full max-h-44 overflow-y-auto">
<input
v-if="modelValue.length === 0"
@@ -53,8 +66,13 @@
</template>
<script>
import MarkdownContent from './markdown_content'
export default {
name: 'MultiSelectStep',
components: {
MarkdownContent
},
inject: ['t', 'scrollIntoField'],
props: {
field: {
+13 -6
View File
@@ -1,11 +1,14 @@
<template>
<label
v-if="showFieldNames && field.name"
v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid"
dir="auto"
class="label text-2xl"
:class="{ 'mb-2': !field.description }"
><template v-if="field.title"><span v-html="field.title" /></template>
><MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name }}</template>
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
@@ -15,9 +18,11 @@
/>
<div
v-if="field.description"
class="mb-3 px-1 text-lg"
v-html="field.description"
/>
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" />
<div class="items-center flex">
<input
@@ -41,11 +46,13 @@
<script>
import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
export default {
name: 'TextStep',
components: {
AppearsOn
AppearsOn,
MarkdownContent
},
inject: ['t'],
props: {
+25 -6
View File
@@ -2,10 +2,25 @@
<div>
<label
:for="isCodeSent ? 'one_time_code' : field.uuid"
class="label text-2xl mb-2"
>{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }}
<template v-if="!field.required">({{ t('optional') }})</template>
class="label text-2xl"
:class="{ 'mb-2': !field.description }"
>
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }}
<template v-if="!field.required">({{ t('optional') }})</template>
</template>
</label>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<div>
<input
type="hidden"
@@ -53,14 +68,13 @@
class="base-input !text-2xl w-full"
autocomplete="tel"
pattern="^\+[0-9\s\-]+$"
:oninvalid="`this.value ? this.setCustomValidity('${t('use_international_format')}...') : ''`"
oninput="this.setCustomValidity('')"
type="tel"
inputmode="tel"
:required="field.required"
placeholder="+1 234 567-8900"
:name="`values[${field.uuid}]`"
@input="$emit('update:model-value', $event.target.value)"
@invalid="$event.target.value ? $event.target.setCustomValidity(`${t('use_international_format')}...`) : ''"
@input="[$event.target.setCustomValidity(''), $emit('update:model-value', $event.target.value)]"
@focus="$emit('focus')"
>
</div>
@@ -68,6 +82,8 @@
</template>
<script>
import MarkdownContent from './markdown_content'
function throttle (func, delay) {
let lastCallTime = 0
@@ -83,6 +99,9 @@ function throttle (func, delay) {
export default {
name: 'PhoneStep',
components: {
MarkdownContent
},
inject: ['t', 'baseUrl'],
props: {
field: {
@@ -1,9 +1,20 @@
<template>
<div dir="auto">
<div class="flex justify-between items-center w-full mb-2">
<div
class="flex justify-between items-center w-full"
:class="{ 'mb-2': !field.description }"
>
<label
class="label text-2xl"
>{{ showFieldNames && field.name ? field.name : t('signature') }}</label>
>
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ showFieldNames && field.name ? field.name : t('signature') }}
</template>
</label>
<div class="space-x-2 flex">
<span
v-if="isTextSignature && field.preferences?.format !== 'typed'"
@@ -30,7 +41,7 @@
<a
id="type_text_button"
href="#"
class="btn btn-outline btn-sm font-medium"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
@click.prevent="toggleTextInput"
>
<IconTextSize :width="16" />
@@ -45,7 +56,7 @@
:data-tip="t('take_photo')"
>
<label
class="btn btn-outline btn-sm font-medium"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
>
<IconCamera :width="16" />
<input
@@ -91,6 +102,13 @@
</a>
</div>
</div>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" />
<input
:value="modelValue || computedPreviousValue"
@@ -126,6 +144,7 @@ import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonal
import { cropCanvasAndExportToPNG } from './crop_canvas'
import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
let isFontLoaded = false
@@ -137,6 +156,7 @@ export default {
AppearsOn,
IconReload,
IconCamera,
MarkdownContent,
IconTextSize,
IconSignature,
IconArrowsDiagonalMinimize2
@@ -366,8 +386,8 @@ export default {
return Promise.resolve({})
}
return new Promise((resolve) => {
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
return new Promise((resolve, reject) => {
cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' })
if (this.isDirectUpload) {
@@ -409,6 +429,8 @@ export default {
return resolve(attachment)
})
}
}).catch((error) => {
return reject(error)
})
})
}
+14 -7
View File
@@ -1,11 +1,14 @@
<template>
<label
v-if="showFieldNames && field.name"
v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid"
dir="auto"
class="label text-2xl"
:class="{ 'mb-2': !field.description }"
><template v-if="field.title"><span v-html="field.title" /></template>
><MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name }}</template>
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
@@ -15,9 +18,11 @@
/>
<div
v-if="field.description"
class="mb-3 px-1 text-lg"
v-html="field.description"
/>
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" />
<div class="items-center flex">
<input
@@ -30,11 +35,11 @@
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
:required="field.required"
:pattern="field.validation?.pattern"
:oninvalid="field.validation?.message ? `this.setCustomValidity(${JSON.stringify(field.validation.message)})` : ''"
:oninput="field.validation?.message ? `this.setCustomValidity('')` : ''"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${field.uuid}]`"
@invalid="field.validation?.message ? $event.target.setCustomValidity(field.validation.message) : ''"
@input="field.validation?.message ? $event.target.setCustomValidity('') : ''"
@focus="$emit('focus')"
>
<textarea
@@ -69,11 +74,13 @@
<script>
import { IconAlignBoxLeftTop } from '@tabler/icons-vue'
import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
export default {
name: 'TextStep',
components: {
IconAlignBoxLeftTop,
MarkdownContent,
AppearsOn
},
inject: ['t'],
+24 -15
View File
@@ -42,7 +42,7 @@
v-model="field.submitter_uuid"
class="border-r"
:compact="true"
:editable="editable && !defaultField"
:editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
:allow-add-new="!defaultSubmitters.length"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
:submitters="template.submitters"
@@ -99,7 +99,7 @@
<div
class="flex items-center h-full w-full"
dir="auto"
:class="[bgColors[submitterIndex], field?.default_value ? '' : 'justify-center']"
:class="[bgColors[submitterIndex], field?.default_value ? (alignClasses[field.preferences?.align] || '') : 'justify-center']"
>
<span
v-if="field"
@@ -211,6 +211,13 @@ export default {
defaultName () {
return this.buildDefaultName(this.field, this.template.fields)
},
alignClasses () {
return {
center: 'justify-center',
left: 'justify-start',
right: 'justify-end'
}
},
optionIndexText () {
if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
@@ -301,17 +308,19 @@ export default {
},
watch: {
'field.default_value' () {
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0
}
this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
}
})
}
},
mounted () {
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value)) {
this.$nextTick(() => {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0
})
}
this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
}
})
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
@@ -343,7 +352,7 @@ export default {
},
onResizeCell (e) {
if (e.target.id === 'mask') {
const positionX = e.layerX / (e.target.clientWidth - 1)
const positionX = e.offsetX / (e.target.clientWidth - 1)
if (positionX > this.area.x) {
this.area.cell_w = positionX - this.area.x
@@ -389,16 +398,16 @@ export default {
},
resize (e) {
if (e.target.id === 'mask') {
this.area.w = e.layerX / e.target.clientWidth - this.area.x
this.area.h = e.layerY / e.target.clientHeight - this.area.y
this.area.w = e.offsetX / e.target.clientWidth - this.area.x
this.area.h = e.offsetY / e.target.clientHeight - this.area.y
}
},
drag (e) {
if (e.target.id === 'mask') {
this.isDragged = true
this.area.x = (e.layerX - this.dragFrom.x) / e.target.clientWidth
this.area.y = (e.layerY - this.dragFrom.y) / e.target.clientHeight
this.area.x = (e.offsetX - this.dragFrom.x) / e.target.clientWidth
this.area.y = (e.offsetY - this.dragFrom.y) / e.target.clientHeight
}
},
startDrag (e) {
+54 -17
View File
@@ -94,11 +94,13 @@
</div>
</div>
<div
id="main_container"
class="flex"
:class="$slots.buttons || withTitle ? 'md:max-h-[calc(100%_-_60px)]' : 'md:max-h-[100%]'"
>
<div
v-if="withDocumentsList"
id="documents_container"
ref="previews"
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
class="overflow-y-auto overflow-x-hidden w-52 flex-none pr-3 mt-0.5 pt-0.5 hidden lg:block"
@@ -134,7 +136,10 @@
/>
</div>
</div>
<div class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
<div
id="pages_container"
class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5"
>
<div
ref="documents"
class="pr-3.5 pl-0.5"
@@ -200,6 +205,7 @@
</div>
<div
v-if="withFieldsList"
id="fields_list_container"
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
:class="drawField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'"
>
@@ -323,8 +329,10 @@ export default {
withPhone: this.withPhone,
withPayment: this.withPayment,
withFormula: this.withFormula,
withConditions: this.withConditions,
defaultDrawFieldType: this.defaultDrawFieldType,
selectedAreaRef: computed(() => this.selectedAreaRef)
selectedAreaRef: computed(() => this.selectedAreaRef),
fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef)
}
},
props: {
@@ -436,6 +444,11 @@ export default {
required: false,
default: true
},
authenticityToken: {
type: String,
required: false,
default: ''
},
withDocumentsList: {
type: Boolean,
required: false,
@@ -456,6 +469,11 @@ export default {
required: false,
default: false
},
withConditions: {
type: Boolean,
required: false,
default: false
},
onlyDefinedFields: {
type: Boolean,
required: false,
@@ -480,6 +498,7 @@ export default {
},
computed: {
selectedAreaRef: () => ref(),
fieldsDragFieldRef: () => ref(),
fieldAreasIndex () {
const areas = {}
@@ -515,7 +534,7 @@ export default {
submitter.name = name
if (existingSubmittersUuids.filter(Boolean).length) {
submitter.uuid = existingSubmittersUuids[index] || v4()
submitter.uuid = existingSubmittersUuids[index] || submitter.uuid || v4()
} else {
submitter.uuid ||= v4()
}
@@ -811,7 +830,7 @@ export default {
}
},
onDropfield (area) {
const field = {
const field = this.fieldsDragFieldRef.value || {
name: '',
uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid,
@@ -819,17 +838,19 @@ export default {
...this.dragField
}
if (['select', 'multiple', 'radio'].includes(field.type)) {
field.options = [{ value: '', uuid: v4() }]
}
if (!this.fieldsDragFieldRef.value) {
if (['select', 'multiple', 'radio'].includes(field.type)) {
field.options = [{ value: '', uuid: v4() }]
}
if (field.type === 'stamp') {
field.readonly = true
}
if (field.type === 'stamp') {
field.readonly = true
}
if (field.type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
if (field.type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
}
}
@@ -885,11 +906,23 @@ export default {
fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5)
}
field.areas = [fieldArea]
field.areas ||= []
const lastArea = field.areas[field.areas.length - 1]
if (lastArea) {
fieldArea.x -= lastArea.w / 2
fieldArea.w = lastArea.w
fieldArea.h = lastArea.h
}
field.areas.push(fieldArea)
this.selectedAreaRef.value = fieldArea
this.template.fields.push(field)
if (this.template.fields.indexOf(field) === -1) {
this.template.fields.push(field)
}
this.save()
},
@@ -995,7 +1028,11 @@ export default {
baseFetch (path, options = {}) {
return fetch(this.baseUrl + path, {
...options,
headers: { ...this.fetchOptions.headers, ...options.headers }
headers: {
'X-CSRF-Token': this.authenticityToken,
...this.fetchOptions.headers,
...options.headers
}
})
},
save ({ force } = { force: false }) {
@@ -1011,7 +1048,7 @@ export default {
this.pushUndo()
return this.baseFetch(`/api/templates/${this.template.id}`, {
return this.baseFetch(`/templates/${this.template.id}`, {
method: 'PUT',
body: JSON.stringify({
template: {
@@ -0,0 +1,195 @@
<template>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
>
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span>
{{ t('condition') }} - {{ field.name || buildDefaultName(field, template.fields) }}
</span>
<a
href="#"
class="text-xl"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<div>
<div
v-if="!withConditions"
class="bg-base-300 rounded-xl py-2 px-3 text-center"
>
<a
href="https://www.docuseal.co/pricing"
target="_blank"
class="link"
>Available in Pro</a>
</div>
<form @submit.prevent="validateSaveAndClose">
<div class="my-4">
<div class="space-y-4">
<select
class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal"
required
@change="[
newCondition.field_uuid = $event.target.value,
delete newCondition.value,
(conditionActions.includes(newCondition.action) ? '' : newCondition.action = conditionActions[0])
]"
>
<option
value=""
disabled
:selected="!newCondition.field_uuid"
>
{{ t('select_field_') }}
</option>
<option
v-for="f in fields"
:key="f.uuid"
:value="f.uuid"
:selected="newCondition.field_uuid === f.uuid"
>
{{ f.name || buildDefaultName(f, template.fields) }}
</option>
</select>
<select
v-model="newCondition.action"
class="select select-bordered select-sm w-full h-11 pl-4 text-base font-normal"
:class="{ 'bg-white': newCondition.field_uuid, 'bg-base-300': !newCondition.field_uuid }"
:required="newCondition.field_uuid"
>
<option
v-for="action in conditionActions"
:key="action"
:value="action"
>
{{ t(action) }}
</option>
</select>
<select
v-if="conditionField?.options?.length"
class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal"
required
@change="newCondition.value = $event.target.value"
>
<option
value=""
disabled
selected
>
{{ t('select_value_') }}
</option>
<option
v-for="(option, index) in conditionField.options"
:key="option.uuid"
:value="option.uuid"
:selected="newCondition.value === option.uuid"
>
{{ option.value || `${t('option')} ${index + 1}` }}
</option>
</select>
</div>
</div>
<button
class="base-button w-full mt-2"
>
{{ t('save') }}
</button>
</form>
<div
v-if="field.conditions?.[0]?.field_uuid"
class="text-center w-full mt-4"
>
<button
class="link"
@click="[newCondition.field_uuid = null, delete field.conditions, validateSaveAndClose()]"
>
{{ t('remove_condition') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ConditionModal',
inject: ['t', 'save', 'template', 'withConditions'],
props: {
field: {
type: Object,
required: true
},
buildDefaultName: {
type: Function,
required: true
}
},
emits: ['close'],
data () {
return {
newCondition: this.field.conditions?.[0] || {}
}
},
computed: {
conditionField () {
return this.fields.find((f) => f.uuid === this.newCondition.field_uuid)
},
conditionActions () {
return this.fieldActions(this.conditionField)
},
fields () {
return this.template.fields.reduce((acc, f) => {
if (f !== this.field && f.submitter_uuid === this.field.submitter_uuid) {
acc.push(f)
}
return acc
}, [])
}
},
created () {
this.field.conditions ||= []
},
methods: {
fieldActions (field) {
const actions = []
if (!field) {
return actions
}
if (field.type === 'checkbox') {
actions.push('checked', 'unchecked')
} else if (['radio', 'select'].includes(field.type)) {
actions.push('equal', 'not_equal')
} else if (['multiple'].includes(field.type)) {
actions.push('contains', 'does_not_contain')
} else {
actions.push('not_empty', 'empty')
}
return actions
},
validateSaveAndClose () {
if (!this.withConditions) {
return alert('Available only in Pro')
}
if (this.newCondition.field_uuid) {
this.field.conditions = [this.newCondition]
} else {
delete this.field.conditions
}
this.save()
this.$emit('close')
}
}
}
</script>
@@ -0,0 +1,107 @@
<template>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
>
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span>
{{ field.name || buildDefaultName(field, template.fields) }}
</span>
<a
href="#"
class="text-xl"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<div>
<form @submit.prevent="saveAndClose">
<div class="space-y-1 mb-1">
<div>
<label
dir="auto"
class="label text-sm"
for="description_field"
>
{{ t('description') }}
</label>
<textarea
id="description_field"
ref="textarea"
v-model="description"
dir="auto"
class="base-textarea !text-base w-full"
@input="resizeTextarea"
/>
</div>
<div>
<label
dir="auto"
class="label text-sm"
for="title_field"
>
{{ t('display_title') }} ({{ t('optional') }})
</label>
<input
id="title_field"
v-model="title"
dir="auto"
class="base-input !text-base w-full"
>
</div>
</div>
<button
class="base-button w-full mt-4"
>
{{ t('save') }}
</button>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'DescriptionModal',
inject: ['t', 'save', 'template'],
props: {
field: {
type: Object,
required: true
},
buildDefaultName: {
type: Function,
required: true
}
},
emits: ['close'],
data () {
return {
description: this.field.description,
title: this.field.title
}
},
mounted () {
this.resizeTextarea()
},
methods: {
saveAndClose () {
this.field.description = this.description
this.field.title = this.title
this.save()
this.$emit('close')
},
resizeTextarea () {
const textarea = this.$refs.textarea
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
}
}
}
</script>
+159 -29
View File
@@ -1,6 +1,6 @@
<template>
<div
class="group pb-2"
class="list-field group mb-2"
>
<div
class="border border-base-300 rounded rounded-tr-none relative group"
@@ -68,7 +68,7 @@
<button
v-if="field.preferences?.formula"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
:disabled="!withFormula"
:title="t('formula')"
@click="isShowFormulaModal = true"
>
<IconMathFunction
@@ -76,6 +76,17 @@
:stroke-width="1.6"
/>
</button>
<button
v-if="field.conditions?.length"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
:title="t('condition')"
@click="isShowConditionsModal = true"
>
<IconRouteAltLeft
:width="18"
:stroke-width="1.6"
/>
</button>
<PaymentSettings
v-if="field.type === 'payment'"
:field="field"
@@ -105,15 +116,41 @@
@click="closeDropdown"
>
<div
v-if="field.type === 'text' && !defaultField"
v-if="['number'].includes(field.type)"
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal"
@change="[field.preferences ||= {}, field.preferences.align = $event.target.value, save()]"
>
<option
v-for="value in ['left', 'right', 'center']"
:key="value"
:selected="field.preferences?.align ? value === field.preferences.align : value === 'left'"
:value="value"
>
{{ t(value) }}
</option>
</select>
<label
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('align') }}
</label>
</div>
<div
v-if="['text', 'number'].includes(field.type) && !defaultField"
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="field.default_value"
type="text"
:placeholder="t('default_value')"
dir="auto"
:type="field.type"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
@blur="save"
>
@@ -191,7 +228,7 @@
</label>
</div>
<li
v-if="field.type != 'phone'"
v-if="field.type != 'phone' && field.type != 'stamp'"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -205,23 +242,17 @@
</label>
</li>
<li
v-if="field.type == 'number'"
:class="{'tooltip tooltip-bottom': !withFormula}"
:data-tip="withFormula ? '' : 'Available in Pro'"
v-if="field.type == 'stamp'"
@click.stop
>
<label
class="label-text cursor-pointer py-1.5 text-center w-full flex items-center"
@click="isShowFormulaModal = withFormula"
>
<IconMathFunction
width="18px"
height="18px"
class="ml-0.5 mr-1"
/>
<span class="text-sm">
{{ t('formula') }}
</span>
<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
@@ -253,7 +284,7 @@
</label>
</li>
<li
v-if="field.type === 'text' && !defaultField"
v-if="['text', 'number'].includes(field.type) && !defaultField"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -266,9 +297,56 @@
<span class="label-text">{{ t('read_only') }}</span>
</label>
</li>
<hr
v-if="field.type != 'stamp'"
class="pb-0.5 mt-0.5"
>
<li
v-if="field.type != 'stamp'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowDescriptionModal = !isShowDescriptionModal"
>
<IconInfoCircle
width="18"
/>
<span class="text-sm">
{{ t('description') }}
</span>
</label>
</li>
<li
v-if="field.type != 'stamp'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowConditionsModal = !isShowConditionsModal"
>
<IconRouteAltLeft
width="18"
/>
<span class="text-sm">
{{ t('condition') }}
</span>
</label>
</li>
<li v-if="field.type == 'number'">
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowFormulaModal = true"
>
<IconMathFunction
width="18"
/>
<span class="text-sm">
{{ t('formula') }}
</span>
</label>
</li>
<hr class="pb-0.5 mt-0.5">
<li
v-for="(area, index) in field.areas || []"
v-for="(area, index) in sortedAreas"
:key="index"
>
<a
@@ -280,7 +358,8 @@
:width="20"
:stroke-width="1.6"
/>
{{ t('page') }} {{ area.page + 1 }}
{{ t('page') }}
<template v-if="template.schema.length > 1">{{ template.schema.findIndex((item) => item.attachment_uuid === area.attachment_uuid) + 1 }}-</template>{{ area.page + 1 }}
</a>
</li>
<li v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)">
@@ -339,7 +418,7 @@
{{ index + 1 }}.
</span>
<div
v-if="['radio', 'multiple'].includes(field.type) && (index > 0 || field.areas.find((a) => a.option_uuid) || !field.areas.length) && !field.areas.find((a) => a.option_uuid === option.uuid)"
v-if="editable && ['radio', 'multiple'].includes(field.type) && (index > 0 || field.areas.find((a) => a.option_uuid) || !field.areas.length) && !field.areas.find((a) => a.option_uuid === option.uuid)"
class="items-center flex w-full"
>
<input
@@ -368,12 +447,14 @@
class="w-full input input-primary input-xs text-sm bg-transparent"
:placeholder="`${t('option')} ${index + 1}`"
type="text"
:readonly="!editable"
required
dir="auto"
@focus="maybeFocusOnOptionArea(option)"
@blur="save"
>
<button
v-if="editable"
class="text-sm w-3.5"
tabindex="-1"
@click="removeOption(option)"
@@ -381,8 +462,12 @@
&times;
</button>
</div>
<div
v-if="field.options && !editable"
class="pb-1"
/>
<button
v-if="field.options"
v-else-if="field.options && editable"
class="text-center text-sm w-full pb-1"
@click="addOption"
>
@@ -400,6 +485,26 @@
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:field="field"
:build-default-name="buildDefaultName"
@close="isShowConditionsModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:build-default-name="buildDefaultName"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div>
</template>
@@ -408,7 +513,9 @@ import Contenteditable from './contenteditable'
import FieldType from './field_type'
import PaymentSettings from './payment_settings'
import FormulaModal from './formula_modal'
import { IconMathFunction, IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconInfoCircle, IconRouteAltLeft, IconMathFunction, IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
@@ -419,13 +526,17 @@ export default {
IconShape,
PaymentSettings,
IconNewSection,
IconInfoCircle,
FormulaModal,
DescriptionModal,
ConditionsModal,
IconRouteAltLeft,
IconTrashX,
IconMathFunction,
IconCopy,
FieldType
},
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't', 'withFormula'],
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't'],
props: {
field: {
type: Object,
@@ -448,11 +559,25 @@ export default {
isNameFocus: false,
showPaymentModal: false,
isShowFormulaModal: false,
isShowConditionsModal: false,
isShowDescriptionModal: false,
renderDropdown: false
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
schemaAttachmentsIndexes () {
return (this.template.schema || []).reduce((acc, item, index) => {
acc[item.attachment_uuid] = index
return acc
}, {})
},
sortedAreas () {
return (this.field.areas || []).sort((a, b) => {
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
})
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
@@ -568,7 +693,7 @@ export default {
}
},
scrollToFirstArea () {
return this.field.areas?.[0] && this.$emit('scroll-to', this.field.areas[0])
return this.sortedAreas[0] && this.$emit('scroll-to', this.sortedAreas[0])
},
closeDropdown () {
document.activeElement.blur()
@@ -586,7 +711,12 @@ export default {
},
removeOption (option) {
this.field.options.splice(this.field.options.indexOf(option), 1)
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
const optionIndex = this.field.areas.findIndex((a) => a.option_uuid === option.uuid)
if (optionIndex !== -1) {
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
}
this.save()
},
+79 -30
View File
@@ -2,7 +2,7 @@
<div :class="withStickySubmitters ? 'sticky top-0 z-10' : ''">
<FieldSubmitter
:model-value="selectedSubmitter.uuid"
class="w-full rounded-lg"
class="roles-dropdown w-full rounded-lg"
:class="{ 'bg-base-100': withStickySubmitters }"
:submitters="submitters"
:editable="editable && !defaultSubmitters.length"
@@ -14,7 +14,7 @@
</div>
<div
ref="fields"
class="mb-1 mt-2"
class="fields mb-1 mt-2"
@dragover.prevent="onFieldDragover"
@drop="reorderFields"
>
@@ -24,11 +24,11 @@
:data-uuid="field.uuid"
:field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
:editable="editable && (!dragField || dragField !== field)"
:editable="editable && (!fieldsDragFieldRef.value || fieldsDragFieldRef.value !== field)"
:default-field="defaultFields.find((f) => f.name === field.name)"
:draggable="editable"
@dragstart="dragField = field"
@dragend="dragField = null"
@dragstart="fieldsDragFieldRef.value = field"
@dragend="fieldsDragFieldRef.value = null"
@remove="removeField"
@scroll-to="$emit('scroll-to-area', $event)"
@set-draw="$emit('set-draw', $event)"
@@ -36,32 +36,63 @@
</div>
<div v-if="submitterDefaultFields.length">
<hr class="mb-2">
<template
v-for="field in submitterDefaultFields"
:key="field.name"
<template v-if="isShowFieldSearch">
<input
v-model="defaultFieldsSearch"
:placeholder="t('search_field')"
class="input input-ghost input-xs px-0 text-base mb-2 !outline-0 !rounded bg-transparent w-full"
>
<hr class="mb-2">
</template>
<div
class="overflow-auto relative"
:style="{
maxHeight: isShowFieldSearch ? '210px' : '',
minHeight: isShowFieldSearch ? '210px' : ''
}"
>
<div
:style="{ backgroundColor: backgroundColor }"
draggable="true"
class="border border-base-300 rounded rounded-tr-none relative group mb-2"
@dragstart="onDragstart({ type: 'text', ...field })"
@dragend="$emit('drag-end')"
v-if="!filteredSubmitterDefaultFields.length && defaultFieldsSearch"
class="top-0 bottom-0 text-center absolute flex items-center justify-center w-full flex-col"
>
<div class="flex items-center justify-between relative cursor-grab">
<div class="flex items-center p-1 space-x-1">
<IconDrag />
<FieldType
:model-value="field.type || 'text'"
:editable="false"
:button-width="20"
/>
<span class="block pl-0.5">
{{ field.name }}
</span>
<div>
{{ t('field_not_found') }}
</div>
<a
href="#"
class="link"
@click.prevent="defaultFieldsSearch = ''"
>
{{ t('clear') }}
</a>
</div>
<template
v-for="field in filteredSubmitterDefaultFields"
:key="field.name"
>
<div
:style="{ backgroundColor: backgroundColor }"
draggable="true"
class="default-field border border-base-300 rounded rounded-tr-none relative group mb-2"
@dragstart="onDragstart({ type: 'text', ...field })"
@dragend="$emit('drag-end')"
>
<div class="flex items-center justify-between relative cursor-grab">
<div class="flex items-center p-1 space-x-1">
<IconDrag />
<FieldType
:model-value="field.type || 'text'"
:editable="false"
:button-width="20"
/>
<span class="block pl-0.5">
{{ field.name }}
</span>
</div>
</div>
</div>
</div>
</template>
</template>
</div>
</div>
<div
v-if="editable && !onlyDefinedFields"
@@ -74,7 +105,7 @@
<button
v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')"
draggable="true"
class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
class="field-type-button group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
@dragstart="onDragstart({ type: type })"
@dragend="$emit('drag-end')"
@@ -154,7 +185,7 @@ export default {
IconDrag,
IconLock
},
inject: ['save', 'backgroundColor', 'withPhone', 'withPayment', 't'],
inject: ['save', 'backgroundColor', 'withPhone', 'withPayment', 't', 'fieldsDragFieldRef'],
props: {
fields: {
type: Array,
@@ -207,12 +238,15 @@ export default {
emits: ['set-draw', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'],
data () {
return {
dragField: null
defaultFieldsSearch: ''
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
isShowFieldSearch () {
return this.submitterDefaultFields.length > 15
},
fieldIconsSorted () {
if (this.fieldTypes.length) {
return this.fieldTypes.reduce((acc, type) => {
@@ -231,6 +265,13 @@ export default {
return this.defaultFields.filter((f) => {
return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name)
})
},
filteredSubmitterDefaultFields () {
if (this.defaultFieldsSearch) {
return this.submitterDefaultFields.filter((f) => f.name.toLowerCase().includes(this.defaultFieldsSearch.toLowerCase()))
} else {
return this.submitterDefaultFields
}
}
},
methods: {
@@ -239,7 +280,7 @@ export default {
},
onFieldDragover (e) {
const targetField = e.target.closest('[data-uuid]')
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.dragField.uuid}"]`)
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.fieldsDragFieldRef.value.uuid}"]`)
if (dragField && targetField && targetField !== dragField) {
const fields = Array.from(this.$refs.fields.children)
@@ -283,6 +324,14 @@ export default {
removeField (field) {
this.fields.splice(this.fields.indexOf(field), 1)
this.fields.forEach((f) => {
(f.conditions || []).forEach((c) => {
if (c.field_uuid === field.uuid) {
f.conditions.splice(f.conditions.indexOf(c), 1)
}
})
})
this.save()
},
addField (type, area = null) {
@@ -9,7 +9,7 @@
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span>
{{ t('formula') }}
{{ t('formula') }} - {{ field.name || buildDefaultName(field, template.fields) }}
</span>
<a
href="#"
@@ -18,6 +18,16 @@
>&times;</a>
</div>
<div>
<div
v-if="!withFormula"
class="bg-base-300 rounded-xl py-2 px-3 text-center"
>
<a
href="https://www.docuseal.co/pricing"
target="_blank"
class="link"
>Available in Pro</a>
</div>
<div class="flex-inline mb-2 gap-2 space-y-1">
<button
v-for="f in fields"
@@ -118,7 +128,7 @@ export default {
components: {
IconCodePlus
},
inject: ['t', 'save', 'template'],
inject: ['t', 'save', 'template', 'withFormula'],
props: {
field: {
type: Object,
@@ -178,6 +188,10 @@ export default {
})
},
validateSaveAndClose () {
if (!this.withFormula) {
return alert('Available only in Pro')
}
const normalizedFormula = this.normalizeFormula(this.formula)
if (normalizedFormula.includes('FIELD NOT FOUND')) {
+21
View File
@@ -1,4 +1,24 @@
const en = {
search_field: 'Search field',
field_not_found: 'Field not found',
clear: 'Clear',
align: 'Align',
left: 'Left',
right: 'Right',
center: 'Center',
description: 'Description',
display_title: 'Display title',
with_logo: 'With logo',
unchecked: 'Unchecked',
equal: 'Equal',
not_equal: 'Not equal',
contains: 'Contains',
does_not_contain: 'Does not contain',
not_empty: 'Not empty',
empty: 'Empty',
select_field_: 'Select field...',
select_value_: 'Select value...',
remove_condition: 'Remove condition',
are_you_sure: 'Are you sure?',
sign_yourself: 'Sign Yourself',
set_signing_date: 'Set signing date',
@@ -31,6 +51,7 @@ const en = {
copy_to_all_pages: 'Copy to All Pages',
add_option: 'Add option',
option: 'Option',
condition: 'Condition',
first_party: 'First Party',
second_party: 'Second Party',
third_party: 'Third Party',
+12 -12
View File
@@ -39,7 +39,7 @@
/>
</div>
<div
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile)"
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value"
id="mask"
ref="mask"
class="top-0 bottom-0 left-0 right-0 absolute"
@@ -61,7 +61,7 @@ export default {
components: {
FieldArea
},
inject: ['fieldTypes', 'defaultDrawFieldType'],
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef'],
props: {
image: {
type: Object,
@@ -162,8 +162,8 @@ export default {
},
onDrop (e) {
this.$emit('drop-field', {
x: e.layerX,
y: e.layerY,
x: e.offsetX,
y: e.offsetY,
maskW: this.$refs.mask.clientWidth,
maskH: this.$refs.mask.clientHeight,
page: this.number
@@ -186,10 +186,10 @@ export default {
this.$nextTick(() => {
this.newArea = {
initialX: e.layerX / this.$refs.mask.clientWidth,
initialY: e.layerY / this.$refs.mask.clientHeight,
x: e.layerX / this.$refs.mask.clientWidth,
y: e.layerY / this.$refs.mask.clientHeight,
initialX: e.offsetX / this.$refs.mask.clientWidth,
initialY: e.offsetY / this.$refs.mask.clientHeight,
x: e.offsetX / this.$refs.mask.clientWidth,
y: e.offsetY / this.$refs.mask.clientHeight,
w: 0,
h: 0
}
@@ -197,19 +197,19 @@ export default {
},
onPointermove (e) {
if (this.newArea) {
const dx = e.layerX / this.$refs.mask.clientWidth - this.newArea.initialX
const dy = e.layerY / this.$refs.mask.clientHeight - this.newArea.initialY
const dx = e.offsetX / this.$refs.mask.clientWidth - this.newArea.initialX
const dy = e.offsetY / this.$refs.mask.clientHeight - this.newArea.initialY
if (dx > 0) {
this.newArea.x = this.newArea.initialX
} else {
this.newArea.x = e.layerX / this.$refs.mask.clientWidth
this.newArea.x = e.offsetX / this.$refs.mask.clientWidth
}
if (dy > 0) {
this.newArea.y = this.newArea.initialY
} else {
this.newArea.y = e.layerY / this.$refs.mask.clientHeight
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight
}
if (this.drawField?.type === 'cells') {
+4 -4
View File
@@ -119,7 +119,7 @@ export default {
this.isProcessing = true
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
this.baseFetch(`/templates/${this.templateId}/documents`, {
method: 'POST',
body: JSON.stringify({ blobs }),
headers: { 'Content-Type': 'application/json' }
@@ -132,7 +132,7 @@ export default {
} else if (resp.status === 422) {
resp.json().then((data) => {
if (data.error === 'PDF encrypted') {
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
this.baseFetch(`/templates/${this.templateId}/documents`, {
method: 'POST',
body: JSON.stringify({ blobs, password: prompt('Enter PDF password') }),
headers: { 'Content-Type': 'application/json' }
@@ -151,7 +151,7 @@ export default {
this.isProcessing = false
})
} else {
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
this.baseFetch(`/templates/${this.templateId}/documents`, {
method: 'POST',
body: new FormData(this.$refs.form)
}).then((resp) => {
@@ -167,7 +167,7 @@ export default {
formData.append('password', prompt('Enter PDF password'))
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
this.baseFetch(`/templates/${this.templateId}/documents`, {
method: 'POST',
body: formData
}).then(async (resp) => {
@@ -24,7 +24,7 @@ class SendFormCompletedWebhookRequestJob < ApplicationJob
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::TimeoutError
rescue Faraday::Error
nil
end
@@ -22,7 +22,7 @@ class SendFormStartedWebhookRequestJob < ApplicationJob
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::TimeoutError
rescue Faraday::Error
nil
end
@@ -22,7 +22,7 @@ class SendFormViewedWebhookRequestJob < ApplicationJob
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::TimeoutError
rescue Faraday::Error
nil
end
+5 -1
View File
@@ -30,7 +30,8 @@ class SubmitterMailer < ApplicationMailer
to: @submitter.friendly_name,
from: from_address_for_submitter(submitter),
subject:,
reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@')
reply_to: submitter.preferences['reply_to'].presence ||
(submitter.submission.created_by_user || submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@')
)
end
@@ -84,6 +85,9 @@ class SubmitterMailer < ApplicationMailer
mail(from: from_address_for_submitter(submitter),
to: to || @submitter.friendly_name,
reply_to: @submitter.preferences['reply_to'].presence ||
(@submitter.submission.created_by_user ||
@submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@'),
subject:)
end
+1 -1
View File
@@ -20,7 +20,7 @@ class Account < ApplicationRecord
has_many :template_folders, dependent: :destroy
has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) },
class_name: 'TemplateFolder', dependent: :destroy, inverse_of: :account
has_many :submissions, through: :templates
has_many :submissions, dependent: :destroy
has_many :submitters, through: :submissions
has_many :account_linked_accounts, dependent: :destroy
has_many :account_testing_accounts, -> { testing }, dependent: :destroy,
+1
View File
@@ -15,6 +15,7 @@
#
# Indexes
#
# index_submission_events_on_created_at (created_at)
# index_submission_events_on_submission_id (submission_id)
# index_submission_events_on_submitter_id (submitter_id)
#
@@ -1,47 +0,0 @@
<div class="max-w-lg mx-auto px-2">
<h1 class="text-3xl font-bold text-center mt-8">Profile Details</h1>
<%= form_for('', as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: new_registration_path) do |f| %>
<% if params[:redir].present? %>
<%= hidden_field_tag :redir, params[:redir] %>
<% end %>
<div class="space-y-2">
<%= render 'devise/shared/error_messages', resource: %>
<%= f.fields_for resource do |ff| %>
<div class="grid gap-2 md:grid-cols-2 md:gap-4">
<div class="form-control">
<%= ff.label :first_name, class: 'label' %>
<%= ff.text_field :first_name, required: true, class: 'base-input' %>
</div>
<div class="form-control">
<%= ff.label :last_name, class: 'label' %>
<%= ff.text_field :last_name, required: true, class: 'base-input' %>
</div>
</div>
<% end %>
<%= f.fields_for resource do |ff| %>
<div class="form-control <%= 'hidden' if (params[:oauth_callback] || params[:sign_up]) && resource.email? %>">
<%= ff.label :email, class: 'label' %>
<%= ff.email_field :email, value: EmailTypo.call(resource.email), required: true, class: 'base-input' %>
</div>
<% end %>
<%= f.fields_for resource.account do |ff| %>
<set-timezone data-input-id="_account_timezone"></set-timezone>
<%= ff.hidden_field :timezone %>
<div class="form-control">
<%= ff.label :name, 'Company name (optional)', class: 'label' %>
<%= ff.text_field :name, class: 'base-input' %>
</div>
<% end %>
<%= f.fields_for resource do |ff| %>
<div class="form-control <%= 'hidden' if params[:oauth_callback] %>">
<%= ff.label :password, class: 'label' %>
<%= ff.password_field :password, required: !params[:oauth_callback], class: 'base-input' %>
</div>
<% end %>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Sign up', disabled_with: 'Signing up'), class: 'base-button' %>
</div>
<% end %>
<%= render 'devise/shared/links' %>
</div>
@@ -1,45 +0,0 @@
<div class="max-w-lg mx-auto px-2">
<% if params[:redir].to_s.ends_with?('on_premise') %>
<h1 class="text-3xl font-bold text-center mt-8 mb-6">DocuSeal Console</h1>
<div class="alert my-4 text-sm">
Sign up in DocuSeal Console to upgrade.
On-premises app is completely standalone, Console is used only to manage your license.
</div>
<% else %>
<%= render 'devise/shared/select_server' if Docuseal.multitenant? %>
<h1 class="text-3xl font-bold text-center mt-8 mb-6">Create Free Account</h1>
<% end %>
<%= form_for(User.new, html: { class: 'space-y-6' }, url: new_registration_path, data: { turbo: params[:redir].blank? }, method: :get) do |f| %>
<% if params[:redir].present? %>
<%= hidden_field_tag :redir, params[:redir] %>
<% end %>
<div class="space-y-2">
<div class="form-control">
<%= f.label :email, class: 'label' %>
<%= f.email_field :email, autofocus: true, autocomplete: 'email', class: 'base-input', placeholder: 'Enter email to continue' %>
</div>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Sign up', disabled_with: 'Sign up'), name: 'sign_up', value: true, class: 'base-button' %>
</div>
<% end %>
<% if devise_mapping.omniauthable? %>
<div class="space-y-4">
<% if User.omniauth_providers.include?(:google_oauth2) %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: 'Sign up with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %>
<% end %>
<% if User.omniauth_providers.include?(:microsoft_office365) %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
<%= f.button button_title(title: 'Sign up with Microsoft', icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
<% end %>
<% end %>
</div>
<% end %>
<%= render 'devise/shared/links' %>
</div>
+1 -1
View File
@@ -38,7 +38,7 @@
<div class="form-control">
<%= ff.label :security_label, 'SMTP Security', class: 'label' %>
<div class="flex items-center space-x-6">
<% [%w[None none], %w[SSL ssl], %w[TLS tls]].each do |(label, val)| %>
<% [%w[Auto none], %w[SSL ssl], %w[TLS tls]].each do |(label, val)| %>
<%= ff.label :security, value: val, for: "#{val}_radio", class: 'label' do %>
<%= ff.radio_button :security, val, checked: (value['security'].blank? && val == 'none') || value['security'] == val, id: "#{val}_radio", class: 'base-radio mr-2' %>
<%= label %>
+1 -1
View File
@@ -110,7 +110,7 @@
<span>
Timeserver URL
</span>
<span class="tooltip" data-tip="URL of the trusted timeserver to be used to generate timestamp signatures.">
<span class="tooltip" data-tip="URL of the trusted RFC 3161 timeserver to be used to generate timestamp signatures.">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
</span>
+2 -3
View File
@@ -3,10 +3,9 @@
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
</style>
<style></style>
</head>
<body dir="<%= yield&.match?(TextUtils::RTL_REGEXP) ? 'rtl' : 'auto' %>">
<body dir="<%= TextUtils.rtl?(yield) ? 'rtl' : 'auto' %>">
<%= yield %>
<%= render partial: 'shared/mailer_attribution' %>
</body>
@@ -5,7 +5,7 @@
<p>
Send automatic email reminders to your recipients.
<br>
<a class="link font-medium" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}" %>">Learn More</a>
<a class="link font-medium" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}" %>" data-turbo="false">Learn More</a>
</p>
</div>
</div>
@@ -0,0 +1 @@
<%= render 'form_toggle_options' %>
@@ -0,0 +1,14 @@
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::FORM_WITH_CONFETTI_KEY).first_or_initialize(value: true) %>
<% if can?(:manage, account_config) %>
<div class="px-1 mt-2">
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %>
<div class="flex items-center justify-between py-2.5">
<span>
Show confetti on successful completion
</span>
<%= f.check_box :value, { class: 'toggle', checked: account_config.value != false, onchange: 'this.form.requestSubmit()' }, '1', '0' %>
</div>
<% end %>
</div>
<% end %>
@@ -5,7 +5,7 @@
<p>
Display your company name and logo when signing documents.
<br>
<a class="link font-medium" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}" %>">Learn More</a>
<a class="link font-medium" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}" %>" data-turbo="false">Learn More</a>
</p>
</div>
</div>
@@ -9,8 +9,9 @@
</div>
<p class="text-4xl font-bold mb-4 mt-8">Company Logo</p>
<%= render 'logo_form' %>
<p class="text-4xl font-bold mb-4 mt-8">Submitter Form</p>
<p class="text-4xl font-bold mb-4 mt-8">Submission Form</p>
<%= render 'form_completed_button_form' %>
<%= render 'form_customization_settings' %>
</div>
<div class="w-0 md:w-52"></div>
</div>
+34 -32
View File
@@ -31,40 +31,42 @@
</div>
<% end %>
<a href="<%= edit_user_signature_path %>" data-turbo-frame="modal" class="base-button w-full">Update Signature</a>
<p class="text-2xl font-bold mt-8 mb-4">Change Password</p>
<%= form_for current_user, url: update_password_settings_profile_index_path, method: :patch, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<div class="form-control">
<%= f.label :password, 'New password', class: 'label' %>
<%= f.password_field :password, autocomplete: 'off', class: 'base-input' %>
</div>
<div class="form-control">
<%= f.label :password_confirmation, 'Confirm new password', class: 'label' %>
<%= f.password_field :password_confirmation, autocomplete: 'off', class: 'base-input' %>
</div>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
<% if true_user == current_user && !current_account.testing? %>
<p class="text-2xl font-bold mt-8 mb-4">Change Password</p>
<%= form_for current_user, url: update_password_settings_profile_index_path, method: :patch, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<div class="form-control">
<%= f.label :password, 'New password', class: 'label' %>
<%= f.password_field :password, autocomplete: 'off', class: 'base-input' %>
</div>
<div class="form-control">
<%= f.label :password_confirmation, 'Confirm new password', class: 'label' %>
<%= f.password_field :password_confirmation, autocomplete: 'off', class: 'base-input' %>
</div>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
</div>
<% end %>
<p class="text-2xl font-bold mt-8 mb-4">Two-Factor Authentication</p>
<div class="space-y-4">
<% if current_user.otp_required_for_login %>
<p class="flex items-center space-x-1">
<%= svg_icon('circle_check', class: 'stroke-success inline flex-none w-5 h-5') %>
<span>
2FA has been configured.
</span>
</p>
<a href="<%= edit_mfa_setup_path %>" data-turbo-frame="modal" class="white-button w-full !px-8">🔓 Remove 2FA</a>
<% else %>
<p class="flex items-center space-x-1">
<%= svg_icon('info_circle', class: 'stroke-warning inline flex-none w-5 h-5') %>
<span>
2FA is not configured.
</span>
</p>
<a href="<%= new_mfa_setup_path %>" data-turbo-frame="modal" class="base-button w-full !px-8">🔒 Set up 2FA</a>
<% end %>
</div>
<% end %>
<p class="text-2xl font-bold mt-8 mb-4">Two-Factor Authentication</p>
<div class="space-y-4">
<% if current_user.otp_required_for_login %>
<p class="flex items-center space-x-1">
<%= svg_icon('circle_check', class: 'stroke-success inline flex-none w-5 h-5') %>
<span>
2FA has been configured.
</span>
</p>
<a href="<%= edit_mfa_setup_path %>" data-turbo-frame="modal" class="white-button w-full !px-8">🔓 Remove 2FA</a>
<% else %>
<p class="flex items-center space-x-1">
<%= svg_icon('info_circle', class: 'stroke-warning inline flex-none w-5 h-5') %>
<span>
2FA is not configured.
</span>
</p>
<a href="<%= new_mfa_setup_path %>" data-turbo-frame="modal" class="base-button w-full !px-8">🔒 Set up 2FA</a>
<% end %>
</div>
</div>
<div class="w-0 md:w-52"></div>
</div>
@@ -5,6 +5,10 @@
if (this.field.scrollHeight > this.field.clientHeight) {
this.field.classList.remove('text-[1.5vw]', 'lg:text-base');
this.field.classList.add('text-[1.0vw]', 'lg:text-[0.70rem]');
if (this.field.scrollHeight > this.field.clientHeight) {
this.field.classList.add('text-[0.8vw]', 'lg:text-[0.50rem]');
}
}
}
get field() {
+3 -3
View File
@@ -39,7 +39,7 @@
<% end %>
<% if can?(:read, User) %>
<li>
<%= link_to 'Team', settings_users_path, class: 'text-base hover:bg-base-300' %>
<%= link_to 'Users', settings_users_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% unless Docuseal.multitenant? %>
@@ -67,7 +67,7 @@
</li>
<% end %>
<%= render 'shared/settings_nav_extra' %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.testing?) %>
<li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
Plans
@@ -75,7 +75,7 @@
<% end %>
</li>
<% end %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.testing?) %>
<li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/api") : "#{Docuseal::CONSOLE_URL}/on_premise", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
<% if Docuseal.multitenant? %> API <% else %> Console <% end %>
+1 -1
View File
@@ -5,7 +5,7 @@
<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}" %>">Learn More</a>
<a class="link font-medium" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}" %>" data-turbo="false">Learn More</a>
</p>
</div>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@
<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}" %>">Learn More</a>
<a class="link font-medium" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}" %>" data-turbo="false">Learn More</a>
</p>
</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| %>
<% if template.submitters.size == 1 %>
<submitter-item class="form-control">
<submitters-autocomplete data-field="email">
<autoresize-textarea>
<%= f.text_area :emails, required: true, class: 'base-textarea w-full', placeholder: 'Type emails here...' %>
</autoresize-textarea>
</submitters-autocomplete>
<emails-textarea data-bulk-enabled="<%= Docuseal.demo? || !Docuseal.multitenant? || can?(:manage, :bulk_send) %>" data-limit="<%= Docuseal.multitenant? ? (can?(:manage, :bulk_send) ? 50 : 1) : nil %>">
<submitters-autocomplete data-field="email" class="block relative">
<autoresize-textarea>
<%= f.text_area :emails, required: true, class: 'base-textarea w-full', placeholder: 'Type emails here...' %>
</autoresize-textarea>
</submitters-autocomplete>
</emails-textarea>
</submitter-item>
<% else %>
<dynamic-list class="space-y-4">
+3 -2
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']) %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'].in?(['file', 'payment']) %>
@@ -31,7 +32,7 @@
<% end %>
<% end %>
</div>
<% elsif field['type'] == 'date' %>
<% elsif field['type'] == 'date' && value != '{{date}}' %>
<autosize-field></autosize-field>
<div class="flex items-center px-0.5">
<%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), local_assigns[:locale]) %>
+1 -1
View File
@@ -21,7 +21,7 @@
<%= render 'submissions/annotation', annot: %>
<% end %>
<% fields_index.dig(document.uuid, index)&.each do |(area, field)| %>
<% value = values[field['uuid']].presence || (field['readonly'] ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submitter.submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %>
<% value = values[field['uuid']].presence || (field['default_value'].present? ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submitter.submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %>
<% next if value.blank? %>
<% next if !field['readonly'] && field['submitter_uuid'] == @submitter.uuid %>
<% next if field.dig('preferences', 'formula') && field['submitter_uuid'] == @submitter.uuid %>
@@ -0,0 +1 @@
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(local_assigns[:content], submitter: local_assigns[:submitter], sig: local_assigns[:sig])))) %>
@@ -1,5 +1,5 @@
<% if @email_config %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(@email_config.value['body'], submitter: @submitter)))) %>
<%= render 'custom_content', content: @email_config.value['body'], submitter: @submitter %>
<% else %>
<p>Hi there,</p>
<p>"<%= @submitter.submission.template.name %>" form has been completed by <%= @submitter.submission.submitters.order(:completed_at).map { |e| e.name || e.email || e.phone }.join(', ') %>.</p>
@@ -1,5 +1,5 @@
<% if @email_config %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(@email_config.value['body'], submitter: @submitter, sig: @sig)))) %>
<%= render 'custom_content', content: @email_config.value['body'], submitter: @submitter, sig: @sig %>
<% else %>
<p>Hi there,</p>
<p>Please check the copy of your "<%= @submitter.submission.template.name %>" submission in the email attachments.</p>
@@ -1,6 +1,6 @@
<% if @email_config || @body.present? %>
<% body = (@body.presence || @email_config.value['body']).to_s %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(body, submitter: @submitter)))) %>
<%= render 'custom_content', content: body, submitter: @submitter %>
<% if !body.include?(ReplaceEmailVariables::SUBMITTER_LINK) && !body.include?(ReplaceEmailVariables::SUBMITTER_ID) && !body.include?(ReplaceEmailVariables::SUBMISSION_LINK) && !body.include?(ReplaceEmailVariables::TEMPLATE_ID) && !@submitter.submission.source.in?(%w[api embed]) %>
<p><%= link_to nil, submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email')) %></p>
<% end %>
+5 -5
View File
@@ -3,6 +3,11 @@
<% if @base_template %>
<%= hidden_field_tag :base_template_id, @base_template.id %>
<% end %>
<% if @base_template && (can?(:manage, :tenants) || true_user != current_user) && true_user.account.account_linked_accounts.exists? %>
<div class="form-control -mb-2 mt-2">
<%= select_tag :account_id, options_for_select([true_user.account, *true_user.account.linked_accounts].uniq.map { |e| [e.name, e.id] }, current_account.id), required: true, class: 'base-select' %>
</div>
<% end %>
<div class="form-control mt-6">
<%= f.text_field :name, required: true, placeholder: 'Document Name', class: 'base-input', dir: 'auto' %>
</div>
@@ -17,11 +22,6 @@
</a>
</folder-autocomplete>
</div>
<% if @base_template && (can?(:manage, :tenants) || true_user != current_user) && true_user.account.account_linked_accounts.exists?(account_type: :linked) %>
<div class="form-control mb-4">
<%= select_tag :account_id, options_for_select([true_user.account, *true_user.account.linked_accounts.where.not(id: true_user.account.testing_accounts)].map { |e| [e.name, e.id] }, current_account.id), required: true, class: 'base-select' %>
</div>
<% end %>
<div class="form-control">
<%= f.button button_title(title: @base_template ? 'Submit' : 'Create', disabled_with: 'Creating'), class: 'base-button' %>
</div>
@@ -5,7 +5,7 @@
<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}" %>">Learn More</a>
<a class="link font-medium" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}" %>" data-turbo="false">Learn More</a>
</p>
</div>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@
<%= render 'shared/settings_nav' %>
<div class="md:flex-grow">
<div class="flex justify-between mb-4">
<h1 class="text-4xl font-bold">Team</h1>
<h1 class="text-4xl font-bold">Users</h1>
<div class="flex items-center space-x-4">
<% if can?(:create, User.new(account: current_account)) %>
<%= link_to new_user_path, class: 'btn btn-primary btn-md gap-2', data: { turbo_frame: 'modal' } do %>
+2 -2
View File
@@ -60,7 +60,7 @@ Rails.application.configure do
# config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ]
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
config.force_ssl = ENV['FORCE_SSL'].present?
config.force_ssl = ENV['FORCE_SSL'].present? && ENV['FORCE_SSL'] != 'false'
# Include generic and useful information about system operation, but avoid logging too much
# information to avoid inadvertent exposure of personally identifiable information (PII).
@@ -128,7 +128,7 @@ Rails.application.configure do
config.lograge.formatter = ->(data) { data.except(:path, :location).to_json }
config.lograge.custom_payload do |controller|
params = controller.request.params || {}
params = controller.request.try(:params) || {}
{
fwd: controller.request.ip,
+1 -1
View File
@@ -69,7 +69,7 @@ Rails.configuration.to_prepare do
LoadActiveStorageConfigs.call
rescue StandardError => e
Rails.logger.error(e)
Rails.logger.error(e) unless Rails.env.production?
nil
end
+30 -1
View File
@@ -1,8 +1,8 @@
# frozen_string_literal: true
# fix NoMethodError: undefined method `field_value' for #<HexaPDF::Type::AcroForm::Field
module HexaPDF
module Type
# fix NoMethodError: undefined method `field_value' for #<HexaPDF::Type::AcroForm::Field
module AcroForm
class Field
def field_value
@@ -10,5 +10,34 @@ module HexaPDF
end
end
end
# comparison of Integer with HexaPDF::PDFArray failed
class CIDFont < Font
private
def widths
cache(:widths) do
result = {}
index = 0
array = self[:W] || []
while index < array.size
entry = array[index]
value = array[index + 1]
if value.is_a?(Array) || value.is_a?(HexaPDF::PDFArray)
value.each_with_index { |width, i| result[entry + i] = width }
index += 2
else
width = array[index + 2]
entry.upto(value) { |cid| result[cid] = width }
index += 3
end
end
result
end
end
end
end
end
+9 -9
View File
@@ -65,21 +65,21 @@ fr: &fr
email: Email
digitally_signed_by: Signé numériquement par
role: Rôle
provide_your_email_to_start: Entrez votre email pour commencer
provide_your_email_to_start: Entrez votre adresse email pour commencer
start: Démarrer
starting: Démarrage
form_has_been_deleted_by_html: 'Le formulaire a été supprimé par <span class="font-semibold">%{name}</span>.'
invited_by_html: 'Invité par <span class="font-semibold">%{name}</span>'
you_have_been_invited_to_submit_a_form: Vous avez été invité à soumettre un formulaire
signed_on_time: 'Signé à %{time}'
form_has_been_submitted_already: Le formulaire a déjà été soumis
send_copy_to_email: Envoyer une copie par email
sending: Envoi
resubmit: Soumettre à nouveau
you_have_been_invited_to_submit_a_form: Vous avez été invité à remplir un formulaire
signed_on_time: 'Signé le %{time}'
form_has_been_submitted_already: Le formulaire a déjà été complété
send_copy_to_email: Envoyer une copie par e-mail
sending: Envoi en cours
resubmit: Compléter à nouveau
or: ou
download_documents: Télécharger des documents
download_documents: Télécharger les documents
downloading: Téléchargement
completed_successfully: Terminé avec succès
completed_successfully: Complété avec succès
pt: &pt
role: Função
+1 -1
View File
@@ -24,7 +24,7 @@ port ENV.fetch('PORT', 3000)
environment ENV.fetch('RAILS_ENV', 'development')
# Specifies the `pidfile` that Puma will use.
pidfile ENV.fetch('PIDFILE', "#{Dir.tmpdir}/server.pid")
@options[:pidfile] = false
# Specifies the number of `workers` to boot in clustered mode.
# Workers are forked web server processes. If using threads and workers together
+5 -13
View File
@@ -17,15 +17,6 @@ Rails.application.routes.draw do
end
devise_scope :user do
if Docuseal.multitenant?
resource :registration, only: %i[show], path: 'sign_up'
unauthenticated do
resource :registration, only: %i[create], path: 'new' do
get '' => :new, as: :new
end
end
end
resource :invitation, only: %i[update] do
get '' => :edit
end
@@ -33,8 +24,6 @@ Rails.application.routes.draw do
namespace :api, defaults: { format: :json } do
resources :attachments, only: %i[create]
resources :submitters_autocomplete, only: %i[index]
resources :template_folders_autocomplete, only: %i[index]
resources :submitter_email_clicks, only: %i[create]
resources :submitter_form_views, only: %i[create]
resources :submitters, only: %i[index show update]
@@ -46,7 +35,6 @@ Rails.application.routes.draw do
resources :templates, only: %i[update show index destroy] do
resources :clone, only: %i[create], controller: 'templates_clone'
resources :submissions, only: %i[index create]
resources :documents, only: %i[create], controller: 'templates_documents'
end
end
@@ -64,8 +52,11 @@ Rails.application.routes.draw do
resource :user_signature, only: %i[edit update destroy]
resources :submissions, only: %i[show destroy]
resources :console_redirect, only: %i[index]
resources :upgrade, only: %i[index], controller: 'console_redirect'
resource :testing_account, only: %i[show destroy]
resources :testing_api_settings, only: %i[index]
resources :submitters_autocomplete, only: %i[index]
resources :template_folders_autocomplete, only: %i[index]
resource :templates_upload, only: %i[create]
authenticated do
resource :templates_upload, only: %i[show], path: 'new'
@@ -73,7 +64,8 @@ Rails.application.routes.draw do
resources :templates_archived, only: %i[index], path: 'archived'
resources :folders, only: %i[show edit update destroy], controller: 'template_folders'
resources :template_sharings_testing, only: %i[create]
resources :templates, only: %i[new create edit show destroy] do
resources :templates, only: %i[new create edit update show destroy] do
resources :documents, only: %i[create], controller: 'template_documents'
resources :restore, only: %i[create], controller: 'templates_restore'
resources :archived, only: %i[index], controller: 'templates_archived_submissions'
resources :submissions, only: %i[new create]
+1
View File
@@ -2,6 +2,7 @@ queues:
- [default, 1]
- [mailers, 1]
- [recurrent, 1]
- [rollbar, 1]
production:
:concurrency: 15
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddIndexOnSubmissionEvents < ActiveRecord::Migration[7.1]
def change
add_index :submission_events, :created_at
end
end
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddIndexOnBlobsChecksum < ActiveRecord::Migration[7.1]
def change
add_index :active_storage_blobs, :checksum
end
end
+3 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_02_18_082157) do
ActiveRecord::Schema[7.1].define(version: 2024_02_28_083356) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -75,6 +75,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_02_18_082157) do
t.string "checksum"
t.datetime "created_at", null: false
t.string "uuid"
t.index ["checksum"], name: "index_active_storage_blobs_on_checksum"
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
t.index ["uuid"], name: "index_active_storage_blobs_on_uuid", unique: true
end
@@ -136,6 +137,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_02_18_082157) do
t.datetime "event_timestamp", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_at"], name: "index_submission_events_on_created_at"
t.index ["submission_id"], name: "index_submission_events_on_submission_id"
t.index ["submitter_id"], name: "index_submission_events_on_submitter_id"
end
+1 -1
View File
@@ -32,7 +32,7 @@ module Accounts
end
def find_or_create_testing_user(account)
user = User.find_by(account: account.testing_accounts)
user = User.where(role: :admin).order(:id).find_by(account: account.testing_accounts)
return user if user
@@ -42,6 +42,7 @@ module Params
type(params, :order, String)
type(params, :completed_redirect_url, String)
type(params, :bcc_completed, String)
type(params, :reply_to, String)
type(params, :message, Hash)
type(params, :submitters, Array)
@@ -69,6 +70,7 @@ module Params
required(submitter_params, %i[email phone name])
type(submitter_params, :name, String)
type(submitter_params, :reply_to, String)
type(submitter_params, :email, String)
format(submitter_params, :email, /@/, message: 'email is invalid')
type(submitter_params, :phone, String)
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more