Compare commits

...
35 Commits
Author SHA1 Message Date
Pete Matsyburka fed420cae1 Merge pull request #39 from docusealco/wip 2024-10-21 12:15:46 +03:00
Pete Matsyburka 910490528d dry run email validation 2024-10-20 15:44:24 +03:00
Alex Turchyn ab810be250 validate email format 2024-10-20 15:42:20 +03:00
Alex Turchyn 178ee47093 add more specs 2024-10-20 15:26:14 +03:00
Alex Turchyn 6f90147a11 validate the uniqueness of submitter roles 2024-10-20 15:26:04 +03:00
Pete Matsyburka 3ebaacbb7a locale date format 2024-10-20 09:48:39 +03:00
Pete Matsyburka 64b39b53a2 fix com from 2024-10-19 10:09:53 +03:00
Pete Matsyburka 2948910c25 fix com selector 2024-10-19 09:41:13 +03:00
Pete Matsyburka 1d20c79cf4 com host 2024-10-18 23:15:55 +03:00
Pete Matsyburka 48f3b13f3e adjust smtp from 2024-10-18 23:07:40 +03:00
Pete Matsyburka 1fc1e9af16 fix font size 2024-10-18 17:48:31 +03:00
Pete Matsyburka ce70016603 update rails 2024-10-16 23:58:37 +03:00
Pete Matsyburka e0e79e343d update email 2024-10-16 12:16:20 +03:00
Pete Matsyburka 6568f81ee9 adjust pdf generation 2024-10-15 20:58:08 +03:00
Oleksandr Turchyn 386f130d7a allow to paste date in date field 2024-10-15 19:31:25 +03:00
Pete Matsyburka 3ee41975fd add documents preview endpoint 2024-10-15 19:31:24 +03:00
Pete Matsyburka d9f314898b with style false 2024-10-14 12:28:26 +03:00
Pete Matsyburka 4fc7412cac fix ios 18 heif images 2024-10-14 12:28:26 +03:00
Pete Matsyburka 9adbfc52c2 fix signing date format 2024-10-14 12:28:26 +03:00
Pete Matsyburka 1ad8d9196a optimize phone step 2024-10-14 12:28:26 +03:00
Oleksandr Turchyn e9172392b5 add the phone code selector 2024-10-14 12:28:26 +03:00
Pete Matsyburka d6d94de328 fix submit form step 2024-10-14 12:28:26 +03:00
Alex Turchyn 77261f457c fix selector colors 2024-10-14 12:28:26 +03:00
Pete Matsyburka c258915031 fix z index 2024-10-14 12:28:26 +03:00
Pete Matsyburka c1db8bdd3b rename job 2024-10-14 12:28:26 +03:00
Pete Matsyburka c700484ce8 add reuse signature config 2024-10-14 12:28:26 +03:00
Pete Matsyburka b62bd200ed fix layout 2024-10-14 12:28:26 +03:00
Pete Matsyburka a164fe8dd8 fix sign 2024-10-14 12:28:26 +03:00
Pete Matsyburka bb40e872b3 fix custom message 2024-10-14 12:28:26 +03:00
Pete Matsyburka bab80e9488 fix image preview format 2024-10-14 12:28:26 +03:00
Pete Matsyburka d090d79ebf process images async 2024-10-14 12:28:26 +03:00
Pete Matsyburka 2299ba4e29 fix clear button 2024-10-14 12:28:26 +03:00
Pete Matsyburka d6474f0e6e adjust submitter edit 2024-10-14 12:28:26 +03:00
Oleksandr Turchyn e0a297cf81 add ability to edit submitter details 2024-10-14 12:28:26 +03:00
Pete Matsyburka 40f5fa4217 add recurrent payment 2024-10-14 12:28:26 +03:00
58 changed files with 2499 additions and 280 deletions

No files matched your search

+2 -2
View File
@@ -41,7 +41,7 @@ ENV OPENSSL_CONF=/app/openssl_legacy.cnf
WORKDIR /app
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils redis vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils redis libheif@edge vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\
@@ -57,7 +57,7 @@ activate = 1' >> /app/openssl_legacy.cnf
COPY ./Gemfile ./Gemfile.lock ./
RUN apk add --no-cache build-base && 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
RUN apk add --no-cache build-base && bundle install && apk del --no-cache 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
+56 -56
View File
@@ -1,29 +1,29 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
actioncable (7.2.1.1)
actionpack (= 7.2.1.1)
activesupport (= 7.2.1.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.1)
actionpack (= 7.2.1)
activejob (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
actionmailbox (7.2.1.1)
actionpack (= 7.2.1.1)
activejob (= 7.2.1.1)
activerecord (= 7.2.1.1)
activestorage (= 7.2.1.1)
activesupport (= 7.2.1.1)
mail (>= 2.8.0)
actionmailer (7.2.1)
actionpack (= 7.2.1)
actionview (= 7.2.1)
activejob (= 7.2.1)
activesupport (= 7.2.1)
actionmailer (7.2.1.1)
actionpack (= 7.2.1.1)
actionview (= 7.2.1.1)
activejob (= 7.2.1.1)
activesupport (= 7.2.1.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.1)
actionview (= 7.2.1)
activesupport (= 7.2.1)
actionpack (7.2.1.1)
actionview (= 7.2.1.1)
activesupport (= 7.2.1.1)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.2)
@@ -32,35 +32,35 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.1)
actionpack (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
actiontext (7.2.1.1)
actionpack (= 7.2.1.1)
activerecord (= 7.2.1.1)
activestorage (= 7.2.1.1)
activesupport (= 7.2.1.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.1)
activesupport (= 7.2.1)
actionview (7.2.1.1)
activesupport (= 7.2.1.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.1)
activesupport (= 7.2.1)
activejob (7.2.1.1)
activesupport (= 7.2.1.1)
globalid (>= 0.3.6)
activemodel (7.2.1)
activesupport (= 7.2.1)
activerecord (7.2.1)
activemodel (= 7.2.1)
activesupport (= 7.2.1)
activemodel (7.2.1.1)
activesupport (= 7.2.1.1)
activerecord (7.2.1.1)
activemodel (= 7.2.1.1)
activesupport (= 7.2.1.1)
timeout (>= 0.4.0)
activestorage (7.2.1)
actionpack (= 7.2.1)
activejob (= 7.2.1)
activerecord (= 7.2.1)
activesupport (= 7.2.1)
activestorage (7.2.1.1)
actionpack (= 7.2.1.1)
activejob (= 7.2.1.1)
activerecord (= 7.2.1.1)
activesupport (= 7.2.1.1)
marcel (~> 1.0)
activesupport (7.2.1)
activesupport (7.2.1.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -320,7 +320,7 @@ GEM
mysql2 (0.5.6)
net-http-persistent (4.0.4)
connection_pool (~> 2.2)
net-imap (0.4.16)
net-imap (0.4.17)
date
net-protocol
net-pop (0.1.2)
@@ -372,7 +372,7 @@ GEM
puma (6.4.3)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.1.7)
rack (3.1.8)
rack-proxy (0.7.7)
rack
rack-session (2.0.0)
@@ -382,20 +382,20 @@ GEM
rackup (2.1.0)
rack (>= 3)
webrick (~> 1.8)
rails (7.2.1)
actioncable (= 7.2.1)
actionmailbox (= 7.2.1)
actionmailer (= 7.2.1)
actionpack (= 7.2.1)
actiontext (= 7.2.1)
actionview (= 7.2.1)
activejob (= 7.2.1)
activemodel (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
rails (7.2.1.1)
actioncable (= 7.2.1.1)
actionmailbox (= 7.2.1.1)
actionmailer (= 7.2.1.1)
actionpack (= 7.2.1.1)
actiontext (= 7.2.1.1)
actionview (= 7.2.1.1)
activejob (= 7.2.1.1)
activemodel (= 7.2.1.1)
activerecord (= 7.2.1.1)
activestorage (= 7.2.1.1)
activesupport (= 7.2.1.1)
bundler (>= 1.15.0)
railties (= 7.2.1)
railties (= 7.2.1.1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -410,9 +410,9 @@ GEM
actionview (> 3.1)
activesupport (> 3.1)
railties (> 3.1)
railties (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
railties (7.2.1.1)
actionpack (= 7.2.1.1)
activesupport (= 7.2.1.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -559,7 +559,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.18)
zeitwerk (2.7.0)
PLATFORMS
aarch64-linux
+1 -1
View File
@@ -1,6 +1,6 @@
# Reporting a Vulnerability
If you come across any security concern or vulnarability, please report the information via email to security@docuseal.co instead of opening a GitHub issue. We will promptly respond and will collaborate with you to validate the issue, and resolve it ASAP.
If you come across any security concern or vulnarability, please report the information via email to security@docuseal.com instead of opening a GitHub issue. We will promptly respond and will collaborate with you to validate the issue, and resolve it ASAP.
**We have a bug bounty program to reward security researchers.**
@@ -0,0 +1,45 @@
# frozen_string_literal: true
module Api
class SubmissionDocumentsController < ApiBaseController
load_and_authorize_resource :submission
def index
documents =
if @submission.submitters.all?(&:completed_at?)
last_submitter = @submission.submitters.max_by(&:completed_at)
if last_submitter.documents_attachments.blank?
last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(submitter)
end
last_submitter.documents_attachments
else
values_hash = Submissions::GeneratePreviewAttachments.build_values_hash(@submission)
if @submission.preview_documents.present? &&
@submission.preview_documents.all? { |s| s.metadata['values_hash'] == values_hash }
@submission.preview_documents
else
ApplicationRecord.no_touching do
@submission.preview_documents.each(&:destroy)
end
Submissions::GeneratePreviewAttachments.call(@submission, values_hash:)
end
end
ActiveRecord::Associations::Preloader.new(
records: documents,
associations: [:blob]
).call
render json: {
id: @submission.id,
documents: documents.map do |attachment|
{ name: attachment.filename.base, url: ActiveStorage::Blob.proxy_url(attachment.blob) }
end
}
end
end
end
@@ -38,6 +38,10 @@ class ApplicationController < ActionController::Base
end
def default_url_options
if request.domain == 'docuseal.com'
return { host: 'docuseal.com', protocol: ENV['FORCE_SSL'].present? ? 'https' : 'http' }
end
Docuseal.default_url_options
end
@@ -12,7 +12,8 @@ class PreviewDocumentPageController < ActionController::API
return head :not_found unless attachment
preview_image = attachment.preview_images.joins(:blob).find_by(blob: { filename: "#{params[:id]}#{FORMAT}" })
preview_image = attachment.preview_images.joins(:blob)
.find_by(blob: { filename: ["#{params[:id]}.png", "#{params[:id]}.jpg"] })
return redirect_to preview_image.url, allow_other_host: true if preview_image
@@ -9,6 +9,13 @@ class SubmissionsController < ApplicationController
def show
@submission = Submissions.preload_with_pages(@submission)
unless @submission.submitters.all?(&:completed_at?)
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [submitters: :start_form_submission_events]
).call
end
render :show, layout: 'plain'
end
@@ -15,6 +15,6 @@ class SubmissionsDashboardController < ApplicationController
@submissions = @submissions.pending if params[:status] == 'pending'
@submissions = @submissions.completed if params[:status] == 'completed'
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
@pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events).order(id: :desc))
end
end
+80
View File
@@ -0,0 +1,80 @@
# frozen_string_literal: true
class SubmittersController < ApplicationController
load_and_authorize_resource :submitter, only: %i[edit update]
def edit
@submitter_email_message =
if @submitter.preferences['email_message_uuid'].present?
@submitter.account.email_messages.find_by(uuid: @submitter.preferences['email_message_uuid'])
end
end
def update
submission = @submitter.submission
if @submitter.submission_events.exists?(event_type: 'start_form') || submission.archived_at? || submission.expired?
return redirect_back fallback_location: submission_path(submission), alert: I18n.t('submitter_cannot_be_updated')
end
if submitter_params.values.all?(&:blank?)
return redirect_back fallback_location: submission_path(submission),
alert: I18n.t('at_least_one_field_must_be_filled')
end
if params[:is_custom_message] != '1'
params.delete(:subject)
params.delete(:body)
end
submitter_preferences = Submitters.normalize_preferences(@submitter.account, current_user, params)
if submitter_preferences.key?('email_message_uuid')
@submitter.preferences['email_message_uuid'] = submitter_preferences['email_message_uuid']
end
assign_submitter_attrs(@submitter, submitter_params)
if @submitter.save
maybe_resend_email_sms(@submitter, params)
redirect_back fallback_location: submission_path(submission), notice: I18n.t('changes_have_been_saved')
else
redirect_back fallback_location: submission_path(submission), alert: I18n.t('unable_to_save')
end
end
private
def maybe_resend_email_sms(submitter, params)
if params[:send_email] == '1' && submitter.email.present?
is_sent_recently = Docuseal.multitenant? &&
EmailEvent.exists?(email: submitter.email,
tag: 'submitter_invitation',
emailable: submitter,
event_type: 'send',
created_at: 4.hours.ago..Time.current)
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id) unless is_sent_recently
end
return if submitter.phone.blank?
return unless params[:send_sms] == '1'
SendSubmitterInvitationSmsJob.perform_async('submitter_id' => submitter.id)
end
def assign_submitter_attrs(submitter, attrs)
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
submitter.name = attrs[:name] if attrs.key?(:name)
submitter
end
def submitter_params
params.require(:submitter).permit(:email, :name, :phone).transform_values(&:strip)
end
end
+1 -1
View File
@@ -15,7 +15,7 @@ class TemplatesController < ApplicationController
submissions = submissions.pending if params[:status] == 'pending'
submissions = submissions.completed if params[:status] == 'completed'
@pagy, @submissions = pagy(submissions.preload(:submitters).order(id: :desc))
@pagy, @submissions = pagy(submissions.preload(submitters: :start_form_submission_events).order(id: :desc))
rescue ActiveRecord::RecordNotFound
redirect_to root_path
end
-4
View File
@@ -130,7 +130,3 @@ button[disabled] .enabled {
outline-offset: 3px;
outline-color: hsl(var(--bc) / 0.2);
}
select:required:invalid {
color: gray !important;
}
+1
View File
@@ -28,6 +28,7 @@ safeRegisterElement('submission-form', class extends HTMLElement {
requireSigningReason: this.dataset.requireSigningReason === 'true',
withConfetti: this.dataset.withConfetti !== 'false',
withDisclosure: this.dataset.withDisclosure === 'true',
reuseSignature: this.dataset.reuseSignature !== 'false',
withTypedSignature: this.dataset.withTypedSignature !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
rememberSignature: this.dataset.rememberSignature === 'true',
-4
View File
@@ -31,10 +31,6 @@ button[disabled] .enabled {
display: none;
}
select:required:invalid {
@apply text-gray-300;
}
.input-bordered {
@apply border-base-content/20;
}
+2 -1
View File
@@ -405,7 +405,8 @@ export default {
}
if (this.field.preferences?.font_size) {
style.fontSize = this.field.preferences.font_size + 'pt'
style.fontSize = `clamp(4pt, 1.6vw, ${this.field.preferences.font_size}pt)`
style.lineHeight = `clamp(6pt, 2.0vw, ${parseInt(this.field.preferences.font_size) + 3}pt)`
}
return style
@@ -45,6 +45,7 @@
:name="`values[${field.uuid}]`"
@keydown.enter="onEnter"
@focus="$emit('focus')"
@paste="onPaste"
>
</div>
</div>
@@ -98,6 +99,25 @@ export default {
this.$emit('submit')
}
},
onPaste (e) {
e.preventDefault()
let pasteData = e.clipboardData.getData('text').trim()
if (pasteData.match(/^\d{2}\.\d{2}\.\d{4}$/)) {
pasteData = pasteData.split('.').reverse().join('-')
}
const parsedDate = new Date(pasteData)
if (!isNaN(parsedDate)) {
const inputEl = this.$refs.input
inputEl.valueAsDate = new Date(parsedDate.getTime() - parsedDate.getTimezoneOffset() * 60000)
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
}
},
setCurrentDate () {
const inputEl = this.$refs.input
+11 -6
View File
@@ -115,7 +115,7 @@
v-model="values[currentField.uuid]"
:show-field-names="showFieldNames"
:field="currentField"
@submit="submitStep"
@submit="!isSubmitting && submitStep()"
@focus="scrollIntoField(currentField)"
/>
<div v-else-if="currentField.type === 'select'">
@@ -152,6 +152,7 @@
dir="auto"
:required="currentField.required"
class="select base-input !text-2xl w-full text-center font-normal"
:class="{ 'text-gray-300': !values[currentField.uuid] }"
:name="`values[${currentField.uuid}]`"
@change="values[currentField.uuid] = $event.target.value"
@focus="scrollIntoField(currentField)"
@@ -159,6 +160,7 @@
<option
value=""
:selected="!values[currentField.uuid]"
class="text-gray-300"
>
{{ t('select_your_option') }}
</option>
@@ -167,6 +169,7 @@
:key="option.uuid"
:selected="values[currentField.uuid] == option.value"
:value="option.value"
class="text-base-content"
>
{{ option.value }}
</option>
@@ -392,7 +395,7 @@
:default-value="submitter.phone"
:submitter-slug="submitterSlug"
@focus="scrollIntoField(currentField)"
@submit="submitStep"
@submit="!isSubmitting && submitStep()"
/>
<PaymentStep
v-else-if="currentField.type === 'payment'"
@@ -404,7 +407,7 @@
:values="values"
@attached="attachments.push($event)"
@focus="scrollIntoField(currentField)"
@submit="submitStep"
@submit="!isSubmitting && submitStep()"
/>
</div>
<div
@@ -1142,13 +1145,15 @@ export default {
async submitStep () {
this.isSubmitting = true
const submitStep = this.currentStep
const stepPromise = ['signature', 'phone', 'initials', 'payment'].includes(this.currentField.type)
? this.$refs.currentStep.submit
: () => Promise.resolve({})
stepPromise().then(async () => {
const emptyRequiredField = this.stepFields.find((fields, index) => {
if (index >= this.currentStep) {
if (index >= submitStep) {
return false
}
@@ -1158,7 +1163,7 @@ export default {
})
const formData = new FormData(this.$refs.form)
const isLastStep = this.currentStep === this.stepFields.length - 1
const isLastStep = submitStep === this.stepFields.length - 1
if (isLastStep && !emptyRequiredField && !this.inviteSubmitters.length) {
formData.append('completed', 'true')
@@ -1183,7 +1188,7 @@ export default {
return Promise.reject(new Error(data.error))
}
const nextStep = (isLastStep && emptyRequiredField) || this.stepFields[this.currentStep + 1]
const nextStep = (isLastStep && emptyRequiredField) || this.stepFields[submitStep + 1]
if (nextStep) {
if (this.alwaysMinimize) {
File diff suppressed because it is too large. Load diff
+106 -21
View File
@@ -61,30 +61,55 @@
</a>
</div>
</div>
<input
<div
v-show="!isCodeSent"
:id="field.uuid"
ref="phone"
:value="modelValue || defaultValue"
:readonly="!!defaultValue"
class="base-input !text-2xl w-full"
autocomplete="tel"
pattern="^\+[0-9\s\-]+$"
type="tel"
inputmode="tel"
:required="field.required"
placeholder="+1 234 567-8900"
:name="`values[${field.uuid}]`"
@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')"
class="flex w-full rounded-full outline-neutral-content outline-2 outline-offset-2 focus-within:outline"
>
<div class="relative inline-block">
<div class="btn bg-base-200 border border-neutral-300 text-2xl whitespace-nowrap font-normal rounded-l-full">
{{ selectedCountry.flag }} +{{ selectedCountry.dial }}
</div>
<select
class="absolute top-0 bottom-0 right-0 left-0 opacity-0 w-full h-full cursor-pointer"
:disabled="!!defaultValue"
@change="onCountrySelect(countries.find((country) => country.flag === $event.target.value))"
>
<option
v-for="(country, index) in countries"
:key="index"
:value="country.flag"
>
{{ country.flag }} {{ country.name }}
</option>
</select>
</div>
<input
:name="`values[${field.uuid}]`"
:value="fullInternationalPhoneValue"
hidden
>
<input
:id="field.uuid"
ref="phone"
:value="phoneValue"
:readonly="!!defaultValue"
class="base-input !text-2xl !rounded-l-none !border-l-0 !outline-none w-full"
autocomplete="tel"
type="tel"
inputmode="tel"
:required="field.required"
placeholder="234 567-8900"
@input="onPhoneInput"
@focus="$emit('focus')"
>
</div>
</div>
</div>
</template>
<script>
import MarkdownContent from './markdown_content'
import phoneData from './phone_data'
function throttle (func, delay) {
let lastCallTime = 0
@@ -144,13 +169,73 @@ export default {
data () {
return {
isCodeSent: false,
isResendLoading: false
isResendLoading: false,
phoneValue: this.modelValue || this.defaultValue || '',
selectedCountry: {}
}
},
computed: {
countries () {
return phoneData.map(([name, dial, flag, tz]) => {
return { name, dial, flag, tz }
})
},
countriesDialIndex () {
return this.countries.reduce((acc, item) => {
acc[item.dial] ||= item
return acc
}, {})
},
dialCodesRegexp () {
const dialCodes = this.countries.map((country) => country.dial).sort((a, b) => b.length - a.length)
return new RegExp(`^\\+(${dialCodes.join('|')})`)
},
detectedPhoneValueDialCode () {
return (this.phoneValue || '').replace(/[^\d+]/g, '').match(this.dialCodesRegexp)?.[1]
},
fullInternationalPhoneValue () {
if (this.detectedPhoneValueDialCode) {
return this.phoneValue
} else if (this.phoneValue) {
return ['+', this.selectedCountry.dial, this.phoneValue].filter(Boolean).join('')
} else {
return ''
}
}
},
mounted () {
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
if (this.detectedPhoneValueDialCode) {
this.selectedCountry = this.countriesDialIndex[this.detectedPhoneValueDialCode]
} else if (browserTimeZone) {
const tz = browserTimeZone.split('/')[1]
this.selectedCountry = this.countries.find((country) => country.tz.includes(tz)) || this.countries[0]
}
},
methods: {
emitSubmit: throttle(function (e) {
this.$emit('submit')
}, 1000),
onCountrySelect (country) {
if (this.selectedCountry.flag !== country.flag) {
this.phoneValue = this.phoneValue.replace(`+${this.selectedCountry.dial}`, `+${country.dial}`)
}
this.selectedCountry = country
this.$refs.phone.focus()
},
onPhoneInput (e) {
this.phoneValue = e.target.value
if (this.detectedPhoneValueDialCode) {
this.selectedCountry = this.countriesDialIndex[this.detectedPhoneValueDialCode]
}
},
onInputCode (e) {
if (e.target.value.length === 6) {
this.emitSubmit()
@@ -171,14 +256,14 @@ export default {
body: JSON.stringify({
submitter_slug: this.submitterSlug,
locale: this.locale,
phone: this.$refs.phone.value
phone: this.fullInternationalPhoneValue
}),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
if (resp.status === 422) {
const data = await resp.json()
alert(this.t('number_phone_is_invalid').replace('{number}', this.$refs.phone.value))
alert(this.t('number_phone_is_invalid').replace('{number}', this.fullInternationalPhoneValue))
return Promise.reject(new Error(data.error))
}
@@ -189,13 +274,13 @@ export default {
return Promise.resolve({})
}
if (!this.$refs.phone.value.toString().startsWith('+')) {
if (!this.fullInternationalPhoneValue.toString().startsWith('+')) {
alert(this.t('use_international_format'))
return Promise.reject(new Error('phone invalid'))
} else if (!this.isCodeSent) {
return this.sendVerificationCode().then(() => {
this.$emit('update:model-value', this.$refs.phone.value)
this.$emit('update:model-value', this.fullInternationalPhoneValue)
this.isCodeSent = true
@@ -197,6 +197,7 @@
<select
v-if="requireSigningReason && !isOtherReason"
class="select base-input !text-2xl w-full mt-6 text-center"
:class="{ 'text-gray-300': !reason }"
required
:name="`values[${field.preferences.reason_field_uuid}]`"
@change="$event.target.value === 'other' ? [reason = '', isOtherReason = true] : $emit('update:reason', $event.target.value)"
@@ -205,6 +206,7 @@
value=""
disabled
:selected="!reason"
class="text-gray-300"
>
{{ t('select_a_reason') }}
</option>
@@ -213,10 +215,14 @@
:key="option"
:value="option"
:selected="reason === option"
class="text-base-content"
>
{{ label }}
</option>
<option value="other">
<option
value="other"
class="text-base-content"
>
{{ t('other') }}
</option>
</select>
@@ -693,6 +693,7 @@ export default {
'de-DE': 'DD.MM.YYYY',
'fr-FR': 'DD/MM/YYYY',
'it-IT': 'DD/MM/YYYY',
'en-GB': 'DD/MM/YYYY',
'es-ES': 'DD/MM/YYYY'
}
},
@@ -50,6 +50,7 @@
</div>
<select
class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal"
:class="{ 'text-gray-300': !condition.field_uuid }"
required
@change="[
condition.field_uuid = $event.target.value,
@@ -68,6 +69,7 @@
v-for="f in fields"
:key="f.uuid"
:value="f.uuid"
class="text-base-content"
:selected="condition.field_uuid === f.uuid"
>
{{ f.name || buildDefaultName(f, template.fields) }}
@@ -90,6 +92,7 @@
<select
v-if="conditionField(condition)?.options?.length"
class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal"
:class="{ 'text-gray-300': !condition.value }"
required
@change="condition.value = $event.target.value"
>
@@ -105,6 +108,7 @@
:key="option.uuid"
:value="option.uuid"
:selected="condition.value === option.uuid"
class="text-base-content"
>
{{ option.value || `${t('option')} ${index + 1}` }}
</option>
+12
View File
@@ -1,5 +1,7 @@
const en = {
editable: 'Editable',
recurrent: 'Recurrent',
one_off: 'One-off',
search_field: 'Search field',
field_not_found: 'Field not found',
clear: 'Clear',
@@ -151,6 +153,8 @@ const en = {
}
const es = {
recurrent: 'Recurrente',
one_off: 'Único',
editable: 'Editable',
search_field: 'Campo de búsqueda',
field_not_found: 'Campo no encontrado',
@@ -303,6 +307,8 @@ const es = {
}
const it = {
ricorrente: 'Ricorrente',
una_volta: 'Una volta',
editable: 'Modificabile',
search_field: 'Campo di ricerca',
field_not_found: 'Campo non trovato',
@@ -455,6 +461,8 @@ const it = {
}
const pt = {
recurrent: 'Recurrente',
one_off: 'Único',
editable: 'Editável',
search_field: 'Campo de busca',
field_not_found: 'Campo não encontrado',
@@ -607,6 +615,8 @@ const pt = {
}
const fr = {
recurrent: 'Récurrent',
one_off: 'Ponctuel',
editable: 'Éditable',
search_field: 'Champ de recherche',
field_not_found: 'Champ non trouvé',
@@ -759,6 +769,8 @@ const fr = {
}
const de = {
wiederkehrend: 'Wiederkehrend',
einmalig: 'Einmalig',
editable: 'Bearbeitbar',
search_field: 'Suchfeld',
field_not_found: 'Feld nicht gefunden',
@@ -50,6 +50,7 @@
<div class="flex">
<select
class="base-select !select-sm !h-10"
:class="{ '!text-gray-300': !mapping.field_name }"
required
@change="mapping.field_name = $event.target.value"
>
@@ -65,6 +66,7 @@
:key="index"
:value="field.name"
:selected="mapping.field_name === field.name"
class="text-base-content"
>
{{ field.name }}
</option>
@@ -75,6 +77,7 @@
<div class="w-full relative">
<select
class="base-select !select-sm !h-10"
:class="{ '!text-gray-300': !mapping.column_index }"
required
@change="mapping.column_index = parseInt($event.target.value)"
>
@@ -93,6 +96,7 @@
v-if="column"
:value="index"
:selected="index === mapping.column_index"
class="text-base-content"
>
{{ column }}
</option>
@@ -21,6 +21,7 @@
@click="closeDropdown"
>
<div
v-if="!('price_id' in field.preferences)"
class="py-1.5 px-1 relative"
@click.stop
>
@@ -58,6 +59,13 @@
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
@blur="save"
>
<input
v-else-if="'price_id' in field.preferences"
v-model="field.preferences.price_id"
placeholder="Price ID: price_XXXXX"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
@blur="save"
>
<input
v-else
v-model="field.preferences.price"
@@ -74,6 +82,23 @@
>
{{ t('price') }}
</label>
<div class="flex items-center justify-center">
<a
href="#"
class="hover:underline"
style="font-size: 11px"
:class="{'underline': !('price_id' in field.preferences)}"
@click="delete field.preferences.price_id"
>{{ t('one_off') }}</a>
<span class="h-2.5 border-l border-base-content mx-1" />
<a
href="#"
class="hover:underline"
style="font-size: 11px"
:class="{'underline': ('price_id' in field.preferences)}"
@click="field.preferences.price_id ??= ''"
>{{ t('recurrent') }}</a>
</div>
</div>
<div
v-if="!isConnected || isOauthSuccess"
@@ -158,7 +183,10 @@
data-turbo="false"
>{{ t('learn_more') }}</a>
</div>
<li class="mb-1">
<li
v-if="!('price_id' in field.preferences)"
class="mb-1"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-formula')"
+17
View File
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class GeneratePreviewImagesJob
include Sidekiq::Job
sidekiq_options queue: :images
def perform(params = {})
attachment = ActiveStorage::Attachment.find(params['attachment_id'])
max_page = [attachment.metadata['pdf']['number_of_pages'].to_i - 1,
Templates::ProcessDocument::MAX_NUMBER_OF_PAGES_PROCESSED].min
Templates::ProcessDocument.generate_document_preview_images(attachment, attachment.download, (1..max_page),
concurrency: 1)
end
end
+1
View File
@@ -41,6 +41,7 @@ class AccountConfig < ApplicationRecord
WITH_SIGNATURE_ID = 'with_signature_id'
WITH_AUDIT_VALUES_KEY = 'with_audit_values'
REQUIRE_SIGNING_REASON_KEY = 'require_signing_reason'
REUSE_SIGNATURE_KEY = 'reuse_signature'
COMBINE_PDF_RESULT_KEY = 'combine_pdf_result_key'
DEFAULT_VALUES = {
+2
View File
@@ -55,6 +55,8 @@ class Submission < ApplicationRecord
has_one_attached :audit_trail
has_one_attached :combined_document
has_many_attached :preview_documents
has_many :template_schema_documents,
->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) },
through: :template, source: :documents_attachments
+2
View File
@@ -56,6 +56,8 @@ class Submitter < ApplicationRecord
has_many :document_generation_events, dependent: :destroy
has_many :submission_events, dependent: :destroy
has_many :start_form_submission_events, -> { where(event_type: :start_form) },
class_name: 'SubmissionEvent', dependent: :destroy, inverse_of: :submitter
scope :completed, -> { where.not(completed_at: nil) }
@@ -1,6 +1,6 @@
<div class="text-center">
<div class="join">
<a href="https://docuseal.co<%= request.fullpath.gsub('docuseal.eu', 'docuseal.co') %>" class="btn bg-base-200 join-item w-32 <%= 'bg-base-300' if request.host == 'docuseal.co' %>">
<a href="https://docuseal.co<%= request.fullpath.gsub('docuseal.eu', 'docuseal.co') %>" class="btn bg-base-200 join-item w-32 <%= 'bg-base-300' if request.host == 'docuseal.co' || request.host == 'docuseal.com' %>">
<%= svg_icon 'world', class: 'w-5 h-5' %>
Global
</a>
@@ -31,7 +31,7 @@
</submitters-autocomplete>
<submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "detailed_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<input type="tel" pattern="^\+[0-9\s\-]+$" oninvalid="this.value ? this.setCustomValidity('Use internatioanl format: +1xxx...') : ''" oninput="this.setCustomValidity('')" name="submission[1][submitters][][phone]" autocomplete="off" class="base-input !h-10 mt-1.5 w-full" placeholder="<%= "#{t('phone')} (#{t('optional')})" %>" id="detailed_phone_<%= item['uuid'] %>">
<input type="tel" pattern="^\+[0-9\s\-]+$" oninvalid="this.value ? this.setCustomValidity('<%= t('use_international_format_1xxx_') %>') : ''" oninput="this.setCustomValidity('')" name="submission[1][submitters][][phone]" autocomplete="off" class="base-input !h-10 mt-1.5 w-full" placeholder="<%= "#{t('phone')} (#{t('optional')})" %>" id="detailed_phone_<%= item['uuid'] %>">
</linked-input>
</submitters-autocomplete>
</div>
+1 -1
View File
@@ -21,7 +21,7 @@
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "phone_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', oninvalid: "this.value ? this.setCustomValidity('Use internatioanl format: +1xxx...') : ''", oninput: "this.setCustomValidity('')", name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 w-full', placeholder: t('phone'), required: index.zero?, id: "phone_phone_#{item['uuid']}" %>
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', oninvalid: "this.value ? this.setCustomValidity('#{t('use_international_format_1xxx_')}') : ''", oninput: "this.setCustomValidity('')", name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 w-full', placeholder: t('phone'), required: index.zero?, id: "phone_phone_#{item['uuid']}" %>
</linked-input>
</submitters-autocomplete>
<% if submitters.size > 1 %>
+12 -10
View File
@@ -2,12 +2,12 @@
<% can_send_emails = Accounts.can_send_emails?(current_account) %>
<div class="flex justify-between items-center">
<%= f.label :send_email, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %>
<%= f.check_box :send_email, id: uuid, class: 'base-checkbox', disabled: !can_send_emails, checked: can_send_emails %>
<span class="label"><%= t('send_emails') %></span>
<%= f.check_box :send_email, id: uuid, class: 'base-checkbox', disabled: !can_send_emails, checked: can_send_emails && !local_assigns.key?(:resend_email) %>
<span class="label"><%= local_assigns[:resend_email] ? t('re_send_email') : t('send_email') %></span>
<% end %>
<div>
<% if can_send_emails %>
<%= render 'email_stats' %>
<%= render 'submissions/email_stats' %>
<%= content_for(:edit_button) || capture do %>
<label>
<%= f.check_box :is_custom_message, onchange: "[this.form.querySelector('#message_field').classList.toggle('hidden', !event.currentTarget.checked)]", checked: false, class: 'hidden peer' %>
@@ -39,7 +39,7 @@
<div class="form-control space-y-2">
<div class="form-control">
<%= f.label :subject, t('subject'), class: 'label' %>
<%= f.text_field :subject, value: @template.preferences['request_email_subject'].presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
<%= f.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || template.preferences['request_email_subject'].presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
</div>
<div class="form-control">
<div class="flex items-center">
@@ -49,14 +49,16 @@
</span>
</div>
<autoresize-textarea>
<%= f.text_area :body, value: @template.preferences['request_email_body'].presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
<%= f.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || template.preferences['request_email_body'].presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
</autoresize-textarea>
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
<%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %>
<span class="label"><%= t('save_as_default_template_message') %></span>
</label>
<% unless local_assigns.fetch(:disable_save_as_default_template_option, false) %>
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
<%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %>
<span class="label"><%= t('save_as_default_template_message') %></span>
</label>
<% end %>
</div>
<%= render 'message_fields' %>
<%= render 'submissions/message_fields' %>
</div>
</div>
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
<% align = field.dig('preferences', 'align') %>
<field-value dir="auto" class="flex absolute text-[1.6vw] lg:text-base <%= 'font-serif' if field.dig('preferences', 'font') == 'Times' %> <%= 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 %>%; <%= "font-size: #{field['preferences']['font_size']}pt" if field.dig('preferences', 'font_size') %>">
<field-value dir="auto" class="flex absolute text-[1.6vw] lg:text-base <%= 'font-serif' if field.dig('preferences', 'font') == 'Times' %> <%= 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 %>%; <%= "font-size: clamp(4pt, 1.6vw, #{field['preferences']['font_size']}pt); line-height: `clamp(6pt, 2.0vw, #{field['preferences']['font_size'].to_i + 3}pt)`" if field.dig('preferences', 'font_size') %>">
<% if field['type'] == 'signature' %>
<div class="flex flex-col justify-between h-full overflow-hidden">
<div class="flex-grow flex overflow-hidden" style="min-height: 50%">
+15 -6
View File
@@ -107,12 +107,21 @@
<% (@submission.template_submitters || @submission.template.submitters).each_with_index do |item, index| %>
<% submitter = @submission.submitters.find { |e| e.uuid == item['uuid'] } %>
<div class="sticky -top-1 bg-base-100 pt-1 -mt-1">
<div class="border border-base-300 rounded-md px-2 py-1 mb-1">
<div class="flex items-center space-x-1">
<span class="mx-1 w-3 h-3 rounded-full <%= colors[index] %>"></span>
<span class="text-lg" dir="auto">
<%= (@submission.template_submitters || @submission.template.submitters).find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %>
</span>
<div class="group border border-base-300 rounded-md px-2 py-1 mb-1">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-1">
<span class="mx-1 w-3 h-3 shrink-0 rounded-full <%= colors[index] %>"></span>
<span class="text-lg" dir="auto">
<%= (@submission.template_submitters || @submission.template.submitters).find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %>
</span>
</div>
<% if signed_in? && can?(:update, submitter) && !submitter.completed_at? && !submitter.declined_at? && !@submission.archived_at? && !@submission.expired? && !submitter.start_form_submission_events.any? %>
<span class="tooltip tooltip-left" data-tip="<%= t('edit') %>">
<%= link_to edit_submitter_path(submitter), class: 'shrink-0 inline md:hidden md:group-hover:inline', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('pencil', class: 'w-5 h-5') %>
<% end %>
</span>
<% end %>
</div>
<% if submitter&.name.present? %>
<div class="flex items-center space-x-1 mt-1">
@@ -1,4 +1,4 @@
<% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid created_at], methods: %i[url filename content_type]) %>
<% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %>
<% invite_submitters = (submitter.submission.template_submitters || submitter.submission.template.submitters).select { |s| s['invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] } }.to_json %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-require-signing-reason="<%= configs[:require_signing_reason] %>" data-with-signature-id="<%= configs[:with_signature_id] %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'].presence || submitter.submission.template.preferences['completed_redirect_url'] %>" data-completed-message="<%= (configs[:completed_message]&.compact_blank.presence || submitter.submission.template.preferences['completed_message'] || {}).to_json %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.preferences.key?('go_to_last') ? submitter.preferences['go_to_last'] : submitter.opened_at? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(submitter.submission.account) %>" data-invite-submitters="<%= invite_submitters %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>" data-previous-signature-value="<%= local_assigns[:signature_attachment]&.uuid %>" data-remember-signature="<%= configs[:prefill_signature] %>" data-dry-run="<%= local_assigns[:dry_run] %>" data-expand="<%= local_assigns[:expand] %>" data-scroll-padding="<%= local_assigns[:scroll_padding] %>" data-language="<%= I18n.locale.to_s.split('-').first %>"></submission-form>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-reuse-signature="<%= configs[:reuse_signature] %>" data-require-signing-reason="<%= configs[:require_signing_reason] %>" data-with-signature-id="<%= configs[:with_signature_id] %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'].presence || submitter.submission.template.preferences['completed_redirect_url'] %>" data-completed-message="<%= (configs[:completed_message]&.compact_blank.presence || submitter.submission.template.preferences['completed_message'] || {}).to_json %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.preferences.key?('go_to_last') ? submitter.preferences['go_to_last'] : submitter.opened_at? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(submitter.submission.account) %>" data-invite-submitters="<%= invite_submitters %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>" data-previous-signature-value="<%= local_assigns[:signature_attachment]&.uuid %>" data-remember-signature="<%= configs[:prefill_signature] %>" data-dry-run="<%= local_assigns[:dry_run] %>" data-expand="<%= local_assigns[:expand] %>" data-scroll-padding="<%= local_assigns[:scroll_padding] %>" data-language="<%= I18n.locale.to_s.split('-').first %>"></submission-form>
+5 -4
View File
@@ -21,12 +21,13 @@
<% end %>
</div>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<span><%= t('download') %></span>
<span class="flex items-center justify-center" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %>
<span class="hidden md:inline"><%= t('download') %></span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span><%= t('downloading') %></span>
<span class="hidden md:inline"><%= t('downloading') %></span>
</span>
</download-button>
</div>
@@ -59,7 +60,7 @@
</div>
<% end %>
<% end %>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account, with_style: false %>
</div>
</div>
</div>
@@ -23,7 +23,7 @@
</div>
<div class="w-full bg-white rounded-2xl border relative" style="height: 300px">
<canvas class="w-full"></canvas>
<button aria-label="<%= t('clear') %>" class="btn btn-ghost btn-sm font-medium top-0 right-0 absolute mt-1 mr-1">
<button aria-label="Clear" class="btn btn-ghost btn-sm font-medium top-0 right-0 absolute mt-1 mr-1">
<%= svg_icon('reload', class: 'w-5 h-5') %>
<span class="inline"><%= t('clear') %></span>
</button>
+29
View File
@@ -0,0 +1,29 @@
<%= render 'shared/turbo_modal_large', title: t('edit_recipient') do %>
<div class="px-5 mb-5 mt-4">
<%= form_for '', url: submitter_path(@submitter), method: :patch, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<div class="mb-2">
<submitter-item class="form-control">
<submitters-autocomplete data-field="name">
<%= text_field_tag 'submitter[name]', @submitter.name, autocomplete: 'off', class: 'base-input !h-10 w-full', placeholder: "#{t('name')} (#{t('optional')})", dir: 'auto' %>
</submitters-autocomplete>
<div class="grid md:grid-cols-2 gap-1.5 mt-1.5">
<submitters-autocomplete data-field="email">
<%= email_field_tag 'submitter[email]', @submitter.email, autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: "#{t('email')} (#{t('optional')})" %>
</submitters-autocomplete>
<submitters-autocomplete data-field="phone">
<%= telephone_field_tag 'submitter[phone]', @submitter.phone, autocomplete: 'off', pattern: '^\+[0-9\s\-]+$', class: 'base-input !h-10 mt-1.5 w-full', placeholder: "#{t('phone')} (#{t('optional')})", oninvalid: "this.value ? this.setCustomValidity('#{t('use_international_format_1xxx_')}') : ''", oninput: "this.setCustomValidity('')" %>
</submitters-autocomplete>
</div>
</submitter-item>
</div>
<div>
<%= render 'submissions/send_email', f:, template: @submitter.template, submitter: @submitter, resend_email: @submitter.sent_at?, submitter_email_message: @submitter_email_message, disable_save_as_default_template_option: true %>
<%= render 'submissions/send_sms', f:, resend_sms: @submitter.sent_at? %>
</div>
<div class="form-control mt-4">
<%= f.button button_title(title: t('update_recipient'), disabled_with: t('updating')), class: 'base-button' %>
</div>
<% end %>
</div>
<%= content_for(:modal_extra) %>
<% end %>
+86 -71
View File
@@ -26,7 +26,7 @@
</div>
</a>
<% end %>
<a href="<%= submission_path(submission) %>" class="w-full flex flex-col md:flex-row space-y-4 md:space-y-0 md:justify-between px-5 md:px-6 pb-5 md:items-center pt-5">
<div class="w-full flex flex-col md:flex-row space-y-4 md:space-y-0 md:justify-between px-5 md:px-6 pb-5 md:items-center pt-5 relative cursor-pointer">
<% submitters = (submission.template_submitters || submission.template.submitters).filter_map { |item| submission.submitters.find { |e| e.uuid == item['uuid'] } } %>
<% is_submission_completed = submitters.all?(&:completed_at?) && submitters.size.positive? %>
<% if submitters.size == 1 %>
@@ -41,22 +41,31 @@
</span>
</div>
<% else %>
<div class="tooltip flex" data-tip="<%= l(submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge <%= status_badges[submitter.status] %> md:w-32 badge-lg bg-opacity-50 uppercase text-sm font-semibold">
<a href="<%= submission_path(submission) %>" class="flex z-[1]">
<span class="badge <%= status_badges[submitter.status] %> md:w-32 badge-lg bg-opacity-50 uppercase text-sm font-semibold tooltip" data-tip="<%= l(submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<%= t(submitter.status) %>
</span>
</div>
</a>
<% end %>
<span class="text-lg break-all flex items-center">
<%= submitter.name || submitter.email || submitter.phone %>
<span class="flex items-center z-[1]">
<a href="<%= submission_path(submission) %>" class="text-lg break-all peer">
<%= submitter.name || submitter.email || submitter.phone %>
</a>
<% if can?(:update, submitter) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? %>
<span class="pl-0.5 tooltip tooltip-top md:opacity-0 md:hover:opacity-100 md:peer-hover:opacity-100" data-tip="<%= t('edit') %>">
<%= link_to edit_submitter_path(submitter), class: 'shrink-0', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('pencil', class: 'w-5 h-5') %>
<% end %>
</span>
<% end %>
</span>
</span>
</div>
</div>
<div class="flex space-x-2 items-center">
<div class="flex space-x-2 items-center z-[1]">
<% if submitter.completed_at? %>
<form onsubmit="event.preventDefault()" class="flex-1 md:flex-none">
<button onclick="event.stopPropagation()" class="w-full md:w-fit">
<div class="flex-1 md:flex-none">
<div class="w-full md:w-fit">
<download-button data-src="<%= submitter_download_index_path(submitter.slug) %>" class="btn btn-sm btn-neutral text-white w-full md:w-36">
<span class="flex items-center justify-center space-x-1 <%= 'md:space-x-2' if t('download').length < 11 %>" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5 stroke-2') %>
@@ -67,37 +76,39 @@
<span class="inline"><%= t('download')[..-2] %>...</span>
</span>
</download-button>
</button>
</form>
</div>
</div>
<% elsif !submission.archived_at? && !template.archived_at? && !submission.expired? && !submitter.declined_at? %>
<% if current_user.email == submitter.email %>
<form data-turbo="false" method="get" action="<%= submit_form_url(slug: submitter.slug) %>" class="flex-1 md:flex-none">
<button onclick="event.stopPropagation()" class="btn btn-sm btn-neutral btn-outline bg-white w-full md:w-36 flex">
<div class="flex-1 md:flex-none md:w-36 flex">
<a href="<%= submit_form_url(slug: submitter.slug) %>" data-turbo="false" target="_blank" class="btn btn-sm btn-neutral btn-outline bg-white w-full md:w-36 flex z-[1]">
<span class="flex items-center justify-center space-x-1 md:space-x-2">
<% if t('sign_now').length < 12 %>
<%= svg_icon('writing_sign', class: 'w-4 h-4 stroke-2') %>
<% end %>
<span class="inline shrink-0"><%= t('sign_now') %></span>
</span>
</button>
</form>
</a>
</div>
<% else %>
<div class="flex-1 md:flex-none">
<%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug), class: 'btn btn-sm btn-neutral text-white md:w-36 flex', icon_class: 'w-6 h-6 text-white', copy_title: t('copy_link').length < 10 ? t('copy_link') : t('copy'), copy_title_md: t('copy'), copied_title_md: t('copied') %>
<%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug), class: 'btn btn-sm btn-neutral text-white md:w-36 flex z-[1]', icon_class: 'w-6 h-6 text-white', copy_title: t('copy_link').length < 10 ? t('copy_link') : t('copy'), copy_title_md: t('copy'), copied_title_md: t('copied') %>
</div>
<% end %>
<% end %>
<div class="flex-1 md:flex-none">
<span class="btn btn-outline btn-sm w-full md:w-24"><%= t('view') %></span>
<div class="flex-1 md:flex-none z-[1]">
<a href="<%= submission_path(submission) %>" class="btn btn-outline btn-sm w-full md:w-24">
<%= t('view') %>
</a>
</div>
<% if !submission.archived_at? && !template.archived_at? && can?(:destroy, submission) %>
<span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: t(:archive).first(4), icon: svg_icon('archive', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete, onclick: 'event.stopPropagation()' %>
<%= button_to button_title(title: nil, disabled_with: t(:archive).first(4), icon: svg_icon('archive', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %>
</span>
<% end %>
<% if local_assigns[:archived] && can?(:destroy, submission) %>
<span data-tip="<%= t('remove') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: t(:remove).first(3), icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission, permanently: true), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('remove'), method: :delete, data: { turbo_confirm: t('submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_') }, onclick: 'event.stopPropagation()' %>
<%= button_to button_title(title: nil, disabled_with: t(:remove).first(3), icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission, permanently: true), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('remove'), method: :delete, data: { turbo_confirm: t('submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_') } %>
</span>
<% end %>
</div>
@@ -106,11 +117,11 @@
<div class="flex flex-col md:flex-row md:items-center gap-3">
<% if is_submission_completed %>
<% latest_submitter = submitters.select(&:completed_at?).max_by(&:completed_at) %>
<div class="tooltip flex" data-tip="<%= l(latest_submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<a href="<%= submission_path(submission) %>" class="z-[1] tooltip flex" data-tip="<%= l(latest_submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge <%= status_badges[latest_submitter.status] %> md:w-32 bg-opacity-50 badge-lg uppercase text-sm font-semibold">
<%= t(latest_submitter.status) %>
</span>
</div>
</a>
<% elsif submission.expired? %>
<div class="tooltip flex" data-tip="<%= l(submission.expire_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge badge-error md:w-32 bg-opacity-50 badge-lg uppercase text-sm font-semibold">
@@ -123,46 +134,49 @@
<div class="relative flex justify-between items-start md:items-center space-x-3">
<span class="flex flex-col md:flex-row md:items-center gap-2">
<% if !is_submission_completed && !submission.expired? %>
<div class="tooltip flex" data-tip="<%= l(submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<a href="<%= submission_path(submission) %>" class="z-[1] tooltip flex" data-tip="<%= l(submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge md:w-24 <%= status_badges[submitter.status] %> bg-opacity-50 uppercase text-xs font-semibold">
<%= t(submitter.status) %>
</span>
</div>
</a>
<% end %>
<span class="text-lg break-all">
<%= submitter.name || submitter.email || submitter.phone %>
<span class="flex items-center z-[1]">
<a href="<%= submission_path(submission) %>" class="text-lg break-all peer">
<%= submitter.name || submitter.email || submitter.phone %>
</a>
<% if can?(:update, submitter) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? %>
<span class="pl-0.5 tooltip tooltip-top md:opacity-0 md:hover:opacity-100 md:peer-hover:opacity-100" data-tip="<%= t('edit') %>">
<%= link_to edit_submitter_path(submitter), class: 'shrink-0', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('pencil', class: 'w-5 h-5') %>
<% end %>
</span>
<% end %>
</span>
</span>
<% if submitter.completed_at? && !is_submission_completed %>
<form onsubmit="event.preventDefault()">
<button onclick="event.stopPropagation()">
<download-button data-src="<%= submitter_download_index_path(submitter.slug) %>" class="absolute md:relative top-0 right-0 btn btn-xs btn-neutral text-white md:w-36">
<span class="flex items-center justify-center space-x-1 md:space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-4 h-4 stroke-2') %>
<span class="inline"><%= t('download') %></span>
</span>
<span class="flex items-center justify-center space-x-1 md:space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin') %>
<span class="inline"><%= t('download')[..-2] %>...</span>
</span>
</download-button>
</button>
</form>
<download-button data-src="<%= submitter_download_index_path(submitter.slug) %>" class="absolute md:relative top-0 right-0 btn btn-xs btn-neutral text-white md:w-36 z-[1]">
<span class="flex items-center justify-center space-x-1 md:space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-4 h-4 stroke-2') %>
<span class="inline"><%= t('download') %></span>
</span>
<span class="flex items-center justify-center space-x-1 md:space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin') %>
<span class="inline"><%= t('download')[..-2] %>...</span>
</span>
</download-button>
<% elsif !template.archived_at? && !submission.archived_at? && !is_submission_completed && !submission.expired? && !submitter.declined_at? %>
<div class="relative flex items-center space-x-3">
<% if current_user.email == submitter.email %>
<form data-turbo="false" method="get" action="<%= submit_form_url(slug: submitter.slug) %>">
<button onclick="event.stopPropagation()" class="absolute md:relative top-0 right-0 btn btn-xs btn-outline btn-neutral bg-white w-28 md:w-36">
<span class="flex items-center justify-center space-x-1 md:space-x-2">
<% if t('sign_now').length < 12 %>
<%= svg_icon('writing_sign', class: 'w-4 h-4 stroke-2') %>
<% end %>
<span class="inline shrink-0"><%= t('sign_now') %></span>
</span>
</button>
</form>
<a href="<%= submit_form_url(slug: submitter.slug) %>" data-turbo="false" target="_blank" class="absolute md:relative top-0 right-0 btn btn-xs btn-outline btn-neutral bg-white w-28 md:w-36 z-[1]">
<span class="flex items-center justify-center space-x-1 md:space-x-2">
<% if t('sign_now').length < 12 %>
<%= svg_icon('writing_sign', class: 'w-4 h-4 stroke-2') %>
<% end %>
<span class="inline shrink-0"><%= t('sign_now') %></span>
</span>
</a>
<% else %>
<%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug), class: 'absolute md:relative top-0 right-0 btn btn-xs text-xs btn-neutral text-white w-28 md:w-36 flex', icon_class: 'w-4 h-4 text-white', copy_title: t('copy_link'), copy_title_md: t('copy_link').length < 10 ? t('copy_link') : t('copy'), copied_title_md: t('copied') %>
<%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug), class: 'absolute md:relative top-0 right-0 btn btn-xs text-xs btn-neutral text-white w-28 md:w-36 flex z-[1]', icon_class: 'w-4 h-4 text-white', copy_title: t('copy_link'), copy_title_md: t('copy_link').length < 10 ? t('copy_link') : t('copy'), copied_title_md: t('copied') %>
<% end %>
</div>
<% end %>
@@ -171,40 +185,41 @@
</div>
</div>
</div>
<div class="flex space-x-1 md:space-x-2 items-center">
<div class="flex space-x-1 md:space-x-2 items-center z-[1]">
<% if is_submission_completed %>
<% latest_submitter = submitters.select(&:completed_at?).max_by(&:completed_at) %>
<div class="flex-1 md:flex-none">
<form onsubmit="event.preventDefault()" class="w-full md:w-fit">
<button onclick="event.stopPropagation()" class="w-full">
<download-button data-src="<%= submitter_download_index_path(latest_submitter.slug) %>" class="btn btn-sm btn-neutral text-white w-full md:w-36">
<span class="flex items-center justify-center space-x-1 md:space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5 stroke-2') %>
<span class="inline"><%= t('download') %></span>
</span>
<span class="flex items-center justify-center space-x-1 md:space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
<span class="inline"><%= t('download')[..-2] %>...</span>
</span>
</download-button>
</button>
</form>
<div class="w-full md:w-fit">
<download-button data-src="<%= submitter_download_index_path(latest_submitter.slug) %>" class="btn btn-sm btn-neutral text-white w-full md:w-36 z-[1]">
<span class="flex items-center justify-center space-x-1 md:space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5 stroke-2') %>
<span class="inline"><%= t('download') %></span>
</span>
<span class="flex items-center justify-center space-x-1 md:space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
<span class="inline"><%= t('download')[..-2] %>...</span>
</span>
</download-button>
</div>
</div>
<% end %>
<div class="flex-1 md:flex-none">
<span class="btn btn-outline btn-sm w-full md:w-24"><%= t('view') %></span>
<div class="flex-1 md:flex-none z-[1]">
<a href="<%= submission_path(submission) %>" class="btn btn-outline btn-sm w-full md:w-24">
<%= t('view') %>
</a>
</div>
<% if !submission.archived_at? && !template.archived_at? %>
<span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: t(:archive).first(4), icon: svg_icon('archive', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete, onclick: 'event.stopPropagation()' %>
<%= button_to button_title(title: nil, disabled_with: t(:archive).first(4), icon: svg_icon('archive', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %>
</span>
<% end %>
<% if local_assigns[:archived] && can?(:destroy, submission) %>
<span data-tip="<%= t('remove') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: t(:remove).first(3), icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission, permanently: true), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('remove'), method: :delete, data: { turbo_confirm: t('submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_') }, onclick: 'event.stopPropagation()' %>
<%= button_to button_title(title: nil, disabled_with: t(:remove).first(3), icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission, permanently: true), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('remove'), method: :delete, data: { turbo_confirm: t('submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_') } %>
</span>
<% end %>
</div>
<% end %>
</a>
<a href="<%= submission_path(submission) %>" class="top-0 bottom-0 left-0 right-0 absolute"></a>
</div>
</div>
+30
View File
@@ -613,6 +613,11 @@ en: &en
unverified: Unverified
document: Document
completed_at: Completed At
edit_recipient: Edit Recipient
update_recipient: Update Recipient
use_international_format_1xxx_: 'Use internatioanl format: +1xxx...'
submitter_cannot_be_updated: Submitter cannot be updated.
at_least_one_field_must_be_filled: At least one field must be filled.
submission_event_names:
send_email_to_html: '<b>Email sent</b> to %{submitter_name}'
send_reminder_email_to_html: '<b>Reminder email sent</b> to %{submitter_name}'
@@ -1244,6 +1249,11 @@ es: &es
unverified: No verificado
document: Documento
completed_at: Completado el
edit_recipient: Editar Destinatario
update_recipient: Actualizar Destinatario
use_international_format_1xxx_: 'Usa el formato internacional: +1xxx...'
submitter_cannot_be_updated: El remitente no puede ser actualizado.
at_least_one_field_must_be_filled: Al menos un campo debe estar completo.
submission_event_names:
send_email_to_html: '<b>Correo electrónico enviado</b> a %{submitter_name}'
send_reminder_email_to_html: '<b>Correo de recordatorio enviado</b> a %{submitter_name}'
@@ -1875,6 +1885,11 @@ it: &it
unverified: Non verificato
document: Documento
completed_at: Completato il
edit_recipient: Modifica Destinatario
update_recipient: Aggiorna Destinatario
use_international_format_1xxx_: 'Utilizza il formato internazionale: +1xxx...'
submitter_cannot_be_updated: Il mittente non può essere aggiornato.
at_least_one_field_must_be_filled: Almeno un campo deve essere compilato.
submission_event_names:
send_email_to_html: '<b>E-mail inviato</b> a %{submitter_name}'
send_reminder_email_to_html: '<b>E-mail di promemoria inviato</b> a %{submitter_name}'
@@ -2507,6 +2522,11 @@ fr: &fr
unverified: Non vérifié
document: Document
completed_at: Terminé le
edit_recipient: Modifica Destinatario
update_recipient: Aggiorna Destinatario
use_international_format_1xxx_: 'Utilizza il formato internazionale: +1xxx...'
submitter_cannot_be_updated: Il mittente non può essere aggiornato.
at_least_one_field_must_be_filled: Almeno un campo deve essere compilato.
submission_event_names:
send_email_to_html: '<b>E-mail envoyé</b> à %{submitter_name}'
send_reminder_email_to_html: '<b>E-mail de rappel envoyé</b> à %{submitter_name}'
@@ -3138,6 +3158,11 @@ pt: &pt
unverified: Não verificado
document: Documento
completed_at: Concluído em
edit_recipient: Editar Destinatário
update_recipient: Atualizar Destinatário
use_international_format_1xxx_: 'Use o formato internacional: +1xxx...'
submitter_cannot_be_updated: O remetente não pode ser atualizado.
at_least_one_field_must_be_filled: Pelo menos um campo deve ser preenchido.
submission_event_names:
send_email_to_html: '<b>E-mail enviado</b> para %{submitter_name}'
send_reminder_email_to_html: '<b>E-mail de lembrete enviado</b> para %{submitter_name}'
@@ -3769,6 +3794,11 @@ de: &de
unverified: Nicht verifiziert
document: Dokument
completed_at: Abgeschlossen am
edit_recipient: Empfänger bearbeiten
update_recipient: Empfänger aktualisieren
use_international_format_1xxx_: 'Verwenden Sie das internationale Format: +1xxx...'
submitter_cannot_be_updated: Der Absender kann nicht aktualisiert werden.
at_least_one_field_must_be_filled: Mindestens ein Feld muss ausgefüllt werden.
submission_event_names:
send_email_to_html: '<b>E-Mail gesendet</b> an %{submitter_name}'
send_reminder_email_to_html: '<b>Erinnerungs-E-Mail gesendet</b> an %{submitter_name}'
+2
View File
@@ -34,6 +34,7 @@ Rails.application.routes.draw do
resources :submitter_form_views, only: %i[create]
resources :submitters, only: %i[index show update]
resources :submissions, only: %i[index show create destroy] do
resources :documents, only: %i[index], controller: 'submission_documents'
collection do
resources :init, only: %i[create], controller: 'submissions'
resources :emails, only: %i[create], controller: 'submissions', as: :submissions_emails
@@ -69,6 +70,7 @@ Rails.application.routes.draw do
resources :submissions_archived, only: %i[index], path: 'submissions/archived'
resources :submissions, only: %i[index], controller: 'submissions_dashboard'
resources :submissions, only: %i[show destroy]
resources :submitters, only: %i[edit update]
resources :console_redirect, only: %i[index]
resources :upgrade, only: %i[index], controller: 'console_redirect'
resources :manage, only: %i[index], controller: 'console_redirect'
+1
View File
@@ -1,6 +1,7 @@
queues:
- [default, 1]
- [webhooks, 1]
- [images, 1]
- [mailers, 1]
- [recurrent, 1]
- [rollbar, 1]
+1
View File
@@ -11,6 +11,7 @@ const configs = generateWebpackConfig({
},
optimization: {
runtimeChunk: false,
concatenateModules: !process.env.BUNDLE_ANALYZE,
splitChunks: {
chunks (chunk) {
return chunk.name !== 'rollbar'
+1 -1
View File
@@ -11,7 +11,7 @@ module Accounts
new_user.uuid = SecureRandom.uuid
new_user.account = new_account
new_user.encrypted_password = SecureRandom.hex
new_user.email = "#{SecureRandom.hex}@docuseal.co"
new_user.email = "#{SecureRandom.hex}@docuseal.com"
account.templates.each do |template|
new_template = template.dup
+9 -1
View File
@@ -16,7 +16,15 @@ module ActionMailerConfigsInterceptor
end
if Rails.env.production? && Rails.application.config.action_mailer.delivery_method
message.from = ENV.fetch('SMTP_FROM')
from = ENV.fetch('SMTP_FROM').to_s.split(',').sample
message.from = from
if from == 'DocuSeal <info@docuseal.com>'
message.body.instance_variable_set(
:@raw_source, message.body.raw_source.gsub('https://docuseal.co/', 'https://docuseal.com/')
)
end
return message
end
+1 -1
View File
@@ -22,7 +22,7 @@ module ActionMailerEventsObserver
emailable_type:,
event_type: :send,
email:,
data: { method: mail.delivery_method.class.name.underscore },
data: { from: mail.from, method: mail.delivery_method.class.name.underscore },
event_datetime: Time.current
)
end
+1 -1
View File
@@ -12,7 +12,7 @@ module Docuseal
TWITTER_URL = 'https://twitter.com/docusealco'
TWITTER_HANDLE = '@docusealco'
CHATGPT_URL = 'https://chatgpt.com/g/g-9hg8AAw0r-docuseal'
SUPPORT_EMAIL = 'support@docuseal.co'
SUPPORT_EMAIL = 'support@docuseal.com'
HOST = ENV.fetch('HOST', 'localhost')
AATL_CERT_NAME = 'docuseal_aatl'
CONSOLE_URL = if Rails.env.development?
+23
View File
@@ -2,6 +2,8 @@
module Params
class BaseValidator
EMAIL_REGEXP = /\A[a-z0-9][\.']?(?:(?:[a-z0-9_-]+[\.\+'])*[a-z0-9_-]+)*@(?:[a-z0-9]+[\.-])*[a-z0-9]+\.[a-z]{2,}\z/i
InvalidParameterError = Class.new(StandardError)
def self.call(...)
@@ -65,6 +67,27 @@ module Params
raise_error(message || "#{key} must follow the #{regexp.source} format")
end
def email_format(params, key, message: nil)
return if params.blank?
return if params[key].blank?
return if params[key].to_s.strip.split(/\s*[;,]\s*/).all? { |email| email.match?(EMAIL_REGEXP) }
if Rails.env.production?
Rollbar.error(message || "#{key} must follow the email format") if defined?(Rollbar)
else
raise_error(message || "#{key} must follow the email format")
end
end
def unique_value(params, key, message: nil)
return unless params.is_a?(Array)
return if params.none?
return if params.all? { |p| p[key].blank? }
return if params.pluck(key).compact_blank.uniq.size == params.pluck(key).compact_blank.size
raise_error(message || "#{key} must be unique")
end
def in_path(params, path = [])
old_path = @current_path
+7 -5
View File
@@ -22,6 +22,7 @@ module Params
required(params, %i[emails email])
type(params, :emails, String)
email_format(params, :emails, message: 'emails are invalid')
boolean(params, :send_email)
type(params, :message, Hash)
@@ -43,8 +44,8 @@ module Params
type(params, :completed_redirect_url, String)
type(params, :bcc_completed, String)
type(params, :reply_to, String)
format(params, :bcc_completed, /@/, message: 'bcc_completed email is invalid')
format(params, :reply_to, /@/, message: 'reply_to email is invalid')
email_format(params, :bcc_completed, message: 'bcc_completed email is invalid')
email_format(params, :reply_to, message: 'reply_to email is invalid')
type(params, :message, Hash)
type(params, :submitters, Array)
@@ -60,6 +61,7 @@ module Params
if params[:submitters].present?
in_path(params, :submitters) do |submitters_params|
type(submitters_params, 0, Hash)
unique_value(submitters_params, :role)
end
end
@@ -74,8 +76,8 @@ module Params
type(submitter_params, :name, String)
type(submitter_params, :reply_to, String)
type(submitter_params, :email, String)
format(submitter_params, :email, /@/, message: 'email is invalid')
format(submitter_params, :reply_to, /@/, message: 'reply_to email is invalid')
email_format(submitter_params, :email, message: 'email is invalid')
email_format(submitter_params, :reply_to, message: 'reply_to email is invalid')
type(submitter_params, :phone, String)
format(submitter_params, :phone, /\A\+\d+\z/,
message: 'phone should start with +<country code> and contain only digits')
@@ -105,7 +107,7 @@ module Params
type(params, :order, String)
type(params, :completed_redirect_url, String)
type(params, :bcc_completed, String)
format(params, :bcc_completed, /@/, message: 'bcc_completed email is invalid')
email_format(params, :bcc_completed, message: 'bcc_completed email is invalid')
type(params, :message, Hash)
in_path(params, :message) do |message_params|
@@ -0,0 +1,96 @@
# frozen_string_literal: true
module Submissions
module GeneratePreviewAttachments
module_function
# rubocop:disable Metrics
def call(submission, values_hash: nil)
values_hash ||= build_values_hash(submission)
with_signature_id = submission.account.account_configs
.exists?(key: AccountConfig::WITH_SIGNATURE_ID, value: true)
is_flatten =
submission.account.account_configs
.find_or_initialize_by(key: AccountConfig::FLATTEN_RESULT_PDF_KEY).value != false
pdfs_index = GenerateResultAttachments.build_pdfs_index(submission, flatten: is_flatten)
submission.submitters.where(completed_at: nil).preload(attachments_attachments: :blob).each do |submitter|
GenerateResultAttachments.fill_submitter_fields(submitter, submission.account, pdfs_index,
with_signature_id:, is_flatten:)
end
template = submission.template
image_pdfs = []
original_documents = template.documents.preload(:blob)
result_attachments =
(submission.template_schema || template.schema).map do |item|
pdf = pdfs_index[item['attachment_uuid']]
if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
pdf = GenerateResultAttachments.normalize_image_pdf(pdf)
image_pdfs << pdf
end
build_pdf_attachment(pdf:, submission:,
uuid: item['attachment_uuid'],
values_hash:,
name: item['name'])
end
return ApplicationRecord.no_touching { result_attachments.map { |e| e.tap(&:save!) } } if image_pdfs.size < 2
images_pdf =
image_pdfs.each_with_object(HexaPDF::Document.new) do |pdf, doc|
pdf.pages.each { |page| doc.pages << doc.import(page) }
end
images_pdf = GenerateResultAttachments.normalize_image_pdf(images_pdf)
images_pdf_attachment =
build_pdf_attachment(
pdf: images_pdf,
submission:,
uuid: GenerateResultAttachments.images_pdf_uuid(original_documents.select(&:image?)),
values_hash:,
name: template.name
)
ApplicationRecord.no_touching do
(result_attachments + [images_pdf_attachment]).map { |e| e.tap(&:save!) }
end
end
def build_values_hash(submission)
submission.submitters.reduce({}) { |acc, s| acc.merge(s.values) }.hash
end
def build_pdf_attachment(pdf:, submission:, uuid:, name:, values_hash:)
io = StringIO.new
begin
pdf.write(io, incremental: true, validate: false)
rescue HexaPDF::MalformedPDFError => e
Rollbar.error(e) if defined?(Rollbar)
pdf.write(io, incremental: false, validate: false)
end
ActiveStorage::Attachment.new(
blob: ActiveStorage::Blob.create_and_upload!(io: io.tap(&:rewind), filename: "#{name}.pdf"),
metadata: { original_uuid: uuid,
values_hash:,
analyzed: true,
sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) },
name: 'preview_documents',
record: submission
)
end
# rubocop:enable Metrics
end
end
+25 -19
View File
@@ -92,19 +92,13 @@ module Submissions
end
def generate_pdfs(submitter)
cell_layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center, text_align: :center)
configs = submitter.account.account_configs.where(key: [AccountConfig::FLATTEN_RESULT_PDF_KEY,
AccountConfig::WITH_SIGNATURE_ID])
with_signature_id = submitter.account.account_configs
.exists?(key: AccountConfig::WITH_SIGNATURE_ID, value: true)
with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true
is_flatten = configs.find { |c| c.key == AccountConfig::FLATTEN_RESULT_PDF_KEY }&.value != false
is_flatten =
submitter.account.account_configs
.find_or_initialize_by(key: AccountConfig::FLATTEN_RESULT_PDF_KEY).value != false
account = submitter.account
attachments_data_cache = {}
pdfs_index = build_pdfs_index(submitter, flatten: is_flatten)
pdfs_index = build_pdfs_index(submitter.submission, submitter:, flatten: is_flatten)
if with_signature_id || submitter.account.testing?
pdfs_index.each_value do |pdf|
@@ -145,6 +139,16 @@ module Submissions
end
end
fill_submitter_fields(submitter, submitter.account, pdfs_index, with_signature_id:, is_flatten:)
end
def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is_flatten:)
cell_layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center, text_align: :center)
attachments_data_cache = {}
return pdfs_index if submitter.submission.template_fields.blank?
submitter.submission.template_fields.each do |field|
next if field['submitter_uuid'] != submitter.uuid
@@ -459,6 +463,8 @@ module Submissions
**build_signing_params(submitter, pkcs, tsa_url)
}
pdf.pages.first[:Annots] = [] unless pdf.pages.first[:Annots].respond_to?(:<<)
begin
pdf.sign(io, write_options: { validate: false }, **sign_params)
rescue HexaPDF::MalformedPDFError => e
@@ -512,13 +518,13 @@ module Submissions
Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, attachments.map(&:uuid).sort.join(':'))
end
def build_pdfs_index(submitter, flatten: true)
latest_submitter = find_last_submitter(submitter)
def build_pdfs_index(submission, submitter: nil, flatten: true)
latest_submitter = find_last_submitter(submission, submitter:)
Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter
documents = latest_submitter&.documents&.preload(:blob).to_a.presence
documents ||= submitter.submission.template_schema_documents.preload(:blob)
documents ||= submission.template_schema_documents.preload(:blob)
documents.to_h do |attachment|
pdf =
@@ -580,11 +586,11 @@ module Submissions
font_wrapper.custom_glyph(replace_with, character)
end
def find_last_submitter(submitter)
submitter.submission.submitters
.select(&:completed_at?)
.select { |e| e.id != submitter.id && e.completed_at <= submitter.completed_at }
.max_by(&:completed_at)
def find_last_submitter(submission, submitter: nil)
submission.submitters
.select(&:completed_at?)
.select { |e| submitter.nil? ? true : e.id != submitter.id && e.completed_at <= submitter.completed_at }
.max_by(&:completed_at)
end
def build_pdf_from_image(attachment)
+12 -7
View File
@@ -8,6 +8,7 @@ module Submitters
AccountConfig::FORM_PREFILL_SIGNATURE_KEY,
AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::REQUIRE_SIGNING_REASON_KEY,
AccountConfig::REUSE_SIGNATURE_KEY,
AccountConfig::ALLOW_TYPED_SIGNATURE].freeze
module_function
@@ -20,16 +21,20 @@ module Submitters
with_typed_signature = find_safe_value(configs, AccountConfig::ALLOW_TYPED_SIGNATURE) != false
with_confetti = find_safe_value(configs, AccountConfig::FORM_WITH_CONFETTI_KEY) != false
prefill_signature = find_safe_value(configs, AccountConfig::FORM_PREFILL_SIGNATURE_KEY) != false
reuse_signature = find_safe_value(configs, AccountConfig::REUSE_SIGNATURE_KEY) != false
with_signature_id = find_safe_value(configs, AccountConfig::WITH_SIGNATURE_ID) == true
require_signing_reason = find_safe_value(configs, AccountConfig::REQUIRE_SIGNING_REASON_KEY) == true
attrs = { completed_button:,
with_typed_signature:,
with_confetti:,
completed_message:,
require_signing_reason:,
prefill_signature:,
with_signature_id: }
attrs = {
completed_button:,
with_typed_signature:,
with_confetti:,
reuse_signature:,
completed_message:,
require_signing_reason:,
prefill_signature:,
with_signature_id:
}
keys.each do |key|
attrs[key.to_sym] = configs.find { |e| e.key == key.to_s }&.value
+1 -1
View File
@@ -220,7 +220,7 @@ module Submitters
end
when 'date'
if with_time
I18n.l(Time.current.in_time_zone(submission.account.timezone).to_date)
Time.current.in_time_zone(submission.account.timezone).to_date.to_s
else
e
end
+10 -6
View File
@@ -17,7 +17,7 @@ module Templates
module_function
def call(attachment, data, extract_fields: false)
def call(attachment, data, extract_fields: false, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED)
if attachment.content_type == PDF_CONTENT_TYPE
if extract_fields && data.size < MAX_FLATTEN_FILE_SIZE
pdf = HexaPDF::Document.new(io: StringIO.new(data))
@@ -25,7 +25,7 @@ module Templates
fields = Templates::FindAcroFields.call(pdf, attachment)
end
generate_pdf_preview_images(attachment, data, pdf)
generate_pdf_preview_images(attachment, data, pdf, max_pages:)
attachment.metadata['pdf']['fields'] = fields if fields
elsif attachment.image?
@@ -56,7 +56,7 @@ module Templates
)
end
def generate_pdf_preview_images(attachment, data, pdf = nil)
def generate_pdf_preview_images(attachment, data, pdf = nil, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED)
ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all
pdf ||= HexaPDF::Document.new(io: StringIO.new(data))
@@ -71,12 +71,16 @@ module Templates
attachment.save!
end
max_pages_to_process = data.size < GENERATE_PREVIEW_SIZE_LIMIT ? MAX_NUMBER_OF_PAGES_PROCESSED : 1
max_pages_to_process = data.size < GENERATE_PREVIEW_SIZE_LIMIT ? max_pages : 1
pool = Concurrent::FixedThreadPool.new(CONCURRENCY)
generate_document_preview_images(attachment, data, (0..[number_of_pages - 1, max_pages_to_process].min))
end
def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY)
pool = Concurrent::FixedThreadPool.new(concurrency)
promises =
(0..[number_of_pages - 1, max_pages_to_process].min).map do |page_number|
range.map do |page_number|
Concurrent::Promise.execute(executor: pool) { build_and_upload_blob(data, page_number) }
end
+43 -32
View File
@@ -7,7 +7,11 @@ FactoryBot.define do
author factory: %i[user]
name { Faker::Book.title }
after(:create) do |template|
transient do
submitter_count { 1 }
end
after(:create) do |template, evaluator|
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/fixtures/sample-document.pdf').open,
filename: 'sample-document.pdf',
@@ -22,39 +26,46 @@ FactoryBot.define do
Templates::ProcessDocument.call(attachment, attachment.download)
template.schema = [{ attachment_uuid: attachment.uuid, name: 'sample-document' }]
template.submitters = [
number_words = %w[first second third fourth fifth sixth seventh eighth ninth tenth]
template.submitters = Array.new(evaluator.submitter_count) do |i|
{
'name' => 'First Party',
'uuid' => '513848eb-1096-4abc-a743-68596b5aaa4c'
'name' => "#{number_words[i]&.capitalize} Party",
'uuid' => SecureRandom.uuid
}
]
template.fields = [
{
'uuid' => '21637fc9-0655-45df-8952-04ec64949e85',
'submitter_uuid' => '513848eb-1096-4abc-a743-68596b5aaa4c',
'name' => 'First Name',
'type' => 'text',
'required' => true,
'areas' => [
{
'x' => 0.09027777777777778,
'y' => 0.1197252208047105,
'w' => 0.3069444444444444,
'h' => 0.03336604514229637,
'attachment_uuid' => attachment.uuid,
'page' => 0
}
]
},
{
'uuid' => '1f97f8e3-dc82-4586-aeea-6ebed6204e46',
'submitter_uuid' => '513848eb-1096-4abc-a743-68596b5aaa4c',
'name' => '',
'type' => 'signature',
'required' => true,
'areas' => []
}
]
end
template.fields = template.submitters.reduce([]) do |fields, submitter|
fields += [
{
'uuid' => SecureRandom.uuid,
'submitter_uuid' => submitter['uuid'],
'name' => 'First Name',
'type' => 'text',
'required' => true,
'areas' => [
{
'x' => 0.09027777777777778,
'y' => 0.1197252208047105,
'w' => 0.3069444444444444,
'h' => 0.03336604514229637,
'attachment_uuid' => attachment.uuid,
'page' => 0
}
]
},
{
'uuid' => SecureRandom.uuid,
'submitter_uuid' => submitter['uuid'],
'name' => '',
'type' => 'signature',
'required' => true,
'areas' => []
}
]
fields
end
template.save!
end
+139
View File
@@ -0,0 +1,139 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Params::BaseValidator do
let(:validator) { described_class.new({}) }
describe '#email_format' do
it 'when email is valid' do
emails = [
' john.doe@example.com ',
'john.doe@example.com',
'jane+newsletter@domain.org',
'mike_smith@company.net',
'lisa-wong@sub.example.co.uk',
'peter@webmail.com',
'anna.jones123@my-domain.com',
'contact@company.email',
'info@my-company123.org',
'hello.world@business.info',
'feedback@new-domain.com',
'alerts+user@localdomain.net',
'webmaster@industry.biz',
'services@agency.example',
'george123@consultant.pro',
'sales-team@company.io'
]
emails.each do |email|
expect { validator.email_format({ email: }, :email) }.not_to raise_error
end
end
it 'when signle email is invalid' do
emails = [
'jone.doe@',
'mike.smith@',
'jane.doe@@example.com',
'@example.com',
'lisa.wong@example',
'peter.parker..@example.com',
'anna.jones@.com',
'jack.brown@com',
'john doe@example.com',
'laura.martin@ example.com',
'dave.clark@example .com',
'susan.green@example,com',
'chris.lee@example;com',
'jenny.king@.example.com',
'.henry.ford@example.com',
'amy.baker@sub_domain.com',
'george.morris@-example.com',
'nancy.davis@example..com',
'kevin.white@.',
'diana.robinson@.example..com',
'oliver.scott@example.c',
'email1@g.comemail@g.com',
'user.name@subdomain.example@example.com',
'double@at@sign.com',
'user@@example.com',
'email@123.123.123.123',
'this...is@strange.but.valid.com',
'mix-and.match@strangely-formed-email_address.com',
'email@domain..com',
'user@-weird-domain-.com',
'user.name@[IPv6:2001:db8::1]',
'tricky.email@sub.example-.com',
'user@domain.c0m'
]
emails.each do |email|
expect do
validator.email_format({ email: }, :email)
end.to raise_error(described_class::InvalidParameterError, 'email must follow the email format')
end
end
it 'when multiple emails are valid' do
emails = [
'john.doe@example.com, jane.doe+newsletter@domain.org',
'joshua@automobile.car ; chloe+fashion@food.delivery',
'mike-smith@company.net;lisa.wong-sales@sub.example.co.uk',
'peter.parker+info@webmail.com,laura.martin-office@company.co',
'anna.jones123@my-domain.com, jack.brown+work@college.edu',
'susan.green@business-info.org; dave.clark+personal@nonprofit.org',
'chris.lee+team@new-domain.com;jenny.king.marketing@localdomain.net',
'george.morris@consultant.pro; nancy.davis-office@company.io',
'joshua-jones@automobile.car; chloe.taylor+fashion@food.delivery',
'ryan.moore+alerts@music-band.com,isabella.walker.design@fashion.design',
'support-team@company.com, contact.us@domain.org',
'admin.office@industry.biz, hr.department@service.pro',
'feedback@agency-example.org; hello.world@creative-studio.net',
'sales-team@e-commerce.shop, support.department@technology.co',
'media.contact@financial.servicesl; events-coordinator@food.delivery',
'order@music-band.com; info.support@creative.example',
'design.team@webmail.com , admin-office@company.co',
'contact.sales@sub-example.co.uk, support+info@legal.gov',
'support@media.group;subscribe-updates@concert.events'
]
emails.each do |email|
expect { validator.email_format({ email: }, :email) }.not_to raise_error
end
end
it 'when multiple emails are invalid' do
emails = [
'jone@gmail.com, ,mike@gmail.com',
'john.doe@example.com dave@nonprofit.org',
'; oliver.scott@example.com',
'amy.baker@ example.com, george.morris@ example.com',
'jenny.king@example.com . diana.robinson@example.com',
'nancy.davis@.com, henry.ford@.com',
'jack.brown@example.com, laura.martin@example .com',
'anna.jones@example,com lisa.wong@example.com',
'dave.clark@example.com kevin.white@example;com',
'susan.green@ example.com; john.doe@example.com',
'amy.baker@sub_domain.com george.morris@-example.com',
'nancy.davis@example..com john.doe@example.c',
'peter.parker@example.com, .henry.ford@example.com',
'diana.robinson@.example..com, mike.smith@.',
'oliver.scott@example.com; laura.martin@ example.com, jane.doe@@example.com'
]
emails.each do |email|
expect do
validator.email_format({ email: }, :email)
end.to raise_error(described_class::InvalidParameterError, 'email must follow the email format')
end
end
it 'when email is invalid with custom message' do
expect do
validator.email_format({ email: 'jone.doe@' }, :email, message: 'email is invalid')
end.to raise_error(described_class::InvalidParameterError, 'email is invalid')
end
end
end
+84 -5
View File
@@ -7,6 +7,7 @@ describe 'Submission API', type: :request do
let!(:author) { create(:user, account:) }
let!(:folder) { create(:template_folder, account:) }
let!(:templates) { create_list(:template, 2, account:, author:, folder:) }
let!(:multiple_submitters_template) { create(:template, submitter_count: 3, account:, author:, folder:) }
describe 'GET /api/submissions' do
it 'returns a list of submissions' do
@@ -56,13 +57,16 @@ describe 'Submission API', type: :request do
expect(response.parsed_body).to eq(JSON.parse(create_submission_body(submission).to_json))
end
end
describe 'POST /api/submissions/emails' do
it 'creates a submission using email' do
it 'creates a submission when some submitter roles are not provided' do
post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: templates[0].id,
emails: 'john.doe@example.com'
template_id: multiple_submitters_template.id,
send_email: true,
submitters: [
{ role: 'First Role', email: 'john.doe@example.com' },
{ email: 'jane.doe@example.com' },
{ email: 'mike.doe@example.com' }
]
}.to_json
expect(response).to have_http_status(:ok)
@@ -70,6 +74,81 @@ describe 'Submission API', type: :request do
submission = Submission.last
expect(response.parsed_body).to eq(JSON.parse(create_submission_body(submission).to_json))
expect(response.parsed_body).to eq(JSON.parse(create_submission_body(submission).to_json))
expect(response.parsed_body[0]['role']).to eq('First Party')
expect(response.parsed_body[0]['email']).to eq('john.doe@example.com')
expect(response.parsed_body[1]['role']).to eq('Second Party')
expect(response.parsed_body[1]['email']).to eq('jane.doe@example.com')
expect(response.parsed_body[2]['role']).to eq('Third Party')
expect(response.parsed_body[2]['email']).to eq('mike.doe@example.com')
end
it 'returns an error if the submitter email is invalid' do
post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: templates[0].id,
send_email: true,
submitters: [
{ role: 'First Role', email: 'john@example' }
]
}.to_json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body).to eq({ 'error' => 'email is invalid in `submitters[0]`.' })
end
it 'returns an error if the template fields are missing' do
templates[0].update(fields: [])
post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: templates[0].id,
send_email: true,
submitters: [{ role: 'First Role', email: 'john.doe@example.com' }]
}.to_json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body).to eq({ 'error' => 'Template does not contain fields' })
end
it 'returns an error if submitter roles are not unique' do
post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: multiple_submitters_template.id,
send_email: true,
submitters: [
{ role: 'First Role', email: 'john.doe@example.com' },
{ role: 'First Role', email: 'jane.doe@example.com' }
]
}.to_json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body).to eq({ 'error' => 'role must be unique in `submitters`.' })
end
end
describe 'POST /api/submissions/emails' do
it 'creates a submission using email' do
post '/api/submissions/emails', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: templates[0].id,
emails: 'john.doe@example.com,jane.doe@example.com'
}.to_json
expect(response).to have_http_status(:ok)
submissions = Submission.last(2)
submissions_body = submissions.reduce([]) { |acc, submission| acc + create_submission_body(submission) }
expect(response.parsed_body).to eq(JSON.parse(submissions_body.to_json))
end
it 'returns an error if emails are invalid' do
post '/api/submissions/emails', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: templates[0].id,
emails: 'amy.baker@example.com, george.morris@example.com@gmail.com'
}.to_json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body).to eq({ 'error' => 'emails are invalid' })
end
end
+4 -4
View File
@@ -144,8 +144,8 @@ describe 'Templates API', type: :request do
name: template.name,
fields: [
{
'uuid' => '21637fc9-0655-45df-8952-04ec64949e85',
'submitter_uuid' => '513848eb-1096-4abc-a743-68596b5aaa4c',
'uuid' => template.fields[0]['uuid'],
'submitter_uuid' => template.submitters[0]['uuid'],
'name' => 'First Name',
'type' => 'text',
'required' => true,
@@ -161,8 +161,8 @@ describe 'Templates API', type: :request do
]
},
{
'uuid' => '1f97f8e3-dc82-4586-aeea-6ebed6204e46',
'submitter_uuid' => '513848eb-1096-4abc-a743-68596b5aaa4c',
'uuid' => template.fields[1]['uuid'],
'submitter_uuid' => template.submitters[0]['uuid'],
'name' => '',
'type' => 'signature',
'required' => true,