Compare commits

..
25 Commits
Author SHA1 Message Date
Pete Matsyburka e9277f4eeb use node 20 in GH actions 2024-02-25 21:17:50 +02:00
Pete Matsyburka 4052b6fe13 puma pid /dev/null 2024-02-25 21:15:00 +02:00
Pete Matsyburka 608347b1e6 adjust pdf processor 2024-02-25 17:19:51 +02:00
Pete Matsyburka 345fe674d4 store original doc uuid in metadata 2024-02-25 16:25:04 +02:00
Pete Matsyburka 92f8901a67 better log 2024-02-25 13:11:13 +02:00
Pete Matsyburka fcd40308ee allow to resent copy email 2024-02-25 12:53:36 +02:00
Pete Matsyburka 2bd023e5d0 fix i18n 2024-02-25 00:33:34 +02:00
Pete Matsyburka a697d6330e allow to disable completed submission emails 2024-02-25 00:08:59 +02:00
Pete Matsyburka ab56e8be20 retry webhooks on timeout 2024-02-24 20:24:43 +02:00
Pete Matsyburka b9a906b569 optimize templates api 2024-02-24 19:59:50 +02:00
Pete Matsyburka 02adbae758 add expand prop 2024-02-24 17:35:38 +02:00
Pete Matsyburka 866aab7fac fix submission preview page 2024-02-24 15:36:57 +02:00
Pete Matsyburka c81b4d855b check signed blob data purpose 2024-02-24 13:04:14 +02:00
Pete Matsyburka 4ad58fc285 not found on wrong blob signature 2024-02-24 13:00:09 +02:00
Pete Matsyburka 8227dd4f6a add formula placeholder 2024-02-24 12:04:08 +02:00
Pete Matsyburka beb677734f do not skip required checkboxes 2024-02-23 19:38:53 +02:00
Pete Matsyburka 03db636d51 add number field type 2024-02-23 15:58:49 +02:00
Pete Matsyburka b83abc9499 fix copy field to multiple pages 2024-02-23 10:50:53 +02:00
Pete Matsyburka 9738cdcf8f fix preview page images 2024-02-23 02:15:33 +02:00
Pete Matsyburka 179ae01551 remove rollbar 2024-02-21 20:16:10 +02:00
Pete Matsyburka 295860a9de update readme 2024-02-21 17:07:56 +02:00
Pete Matsyburka 0a6c15b6b8 add AR form i18n 2024-02-21 16:45:34 +02:00
Pete Matsyburka 18f80020ee update readme 2024-02-21 10:44:19 +02:00
Pete Matsyburka 3768fb94bf prefill stamp field 2024-02-21 02:32:31 +02:00
Pete Matsyburka 7b2cadcb91 add default draw field prop 2024-02-21 02:04:44 +02:00
54 changed files with 1047 additions and 211 deletions

No files matched your search

+2 -2
View File
@@ -59,7 +59,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v1
with:
node-version: 16.13.1
node-version: 20.9.0
- name: Cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
@@ -104,7 +104,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v1
with:
node-version: 16.13.1
node-version: 20.9.0
- name: Install Chrome
uses: browser-actions/setup-chrome@latest
- name: Cache node_modules
+1
View File
@@ -34,6 +34,7 @@ FROM ruby:3.2.2-alpine3.18 as app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
ENV PIDFILE=/dev/null
WORKDIR /app
-1
View File
@@ -32,7 +32,6 @@ gem 'rack'
gem 'rails'
gem 'rails_autolink'
gem 'rails-i18n'
gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
gem 'rotp'
gem 'rqrcode'
gem 'ruby-vips'
-2
View File
@@ -433,7 +433,6 @@ GEM
railties (>= 5.2)
retriable (3.1.2)
rexml (3.2.6)
rollbar (3.4.2)
rotp (6.3.0)
rqrcode (2.2.0)
chunky_png (~> 1.0)
@@ -605,7 +604,6 @@ DEPENDENCIES
rails
rails-i18n
rails_autolink
rollbar
rotp
rqrcode
rspec-rails
+2 -1
View File
@@ -42,6 +42,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- PDF signature verification
- Users management
- Mobile-optimized
- Signing available in 12 Languages
- API and Webhooks for integrations
- Easy to deploy in minutes
@@ -52,7 +53,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- Invitation and identify verification via SMS
- 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 text tags API ([Guide](https://www.docuseal.co/guides/use-embedded-text-field-tags-in-the-pdf-to-create-a-fillable-form))
- Template creation with PDF or DOCX and field tags API ([Guide](https://www.docuseal.co/guides/use-embedded-text-field-tags-in-the-pdf-to-create-a-fillable-form))
- Embedded signing form ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue) or [JavaScript](https://www.docuseal.co/docs/embedded))
- Embedded 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))
- [Learn more](https://www.docuseal.co/pricing)
@@ -10,9 +10,9 @@ module Api
before_action :set_cors_headers
def show
blob_uuid, = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
if blob_uuid.blank?
if blob_uuid.blank? || purp != 'blob' || (exp && exp < Time.current.to_i)
Rollbar.error('Blob not found') if defined?(Rollbar)
return head :not_found
@@ -11,7 +11,9 @@ module Api
def show
Rollbar.info('Blob legacy') if defined?(Rollbar)
blob = ActiveStorage::Blob.find_signed!(params[:signed_blob_id] || params[:signed_id])
blob = ActiveStorage::Blob.find_signed(params[:signed_blob_id] || params[:signed_id])
return head :not_found unless blob
is_permitted = blob.attachments.any? do |a|
(current_user && a.record.account.id == current_user.account_id) ||
+24 -11
View File
@@ -7,10 +7,31 @@ module Api
def index
templates = filter_templates(@templates, params)
templates = paginate(templates.preload(:author, documents_attachments: :blob))
templates = paginate(templates.preload(:author, :folder))
schema_documents =
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
record_type: 'Template',
name: :documents,
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
.preload(:blob)
preview_image_attachments =
ActiveStorage::Attachment.joins(:blob)
.where(blob: { filename: '0.jpg' })
.where(record_id: schema_documents.map(&:id),
record_type: 'ActiveStorage::Attachment',
name: :preview_images)
.preload(:blob)
render json: {
data: templates.as_json(serialize_params),
data: templates.map do |t|
Templates::SerializeForApi.call(
t,
schema_documents.select { |e| e.record_id == t.id },
preview_image_attachments
)
end,
pagination: {
count: templates.size,
next: templates.last&.id,
@@ -20,7 +41,7 @@ module Api
end
def show
render json: @template.as_json(serialize_params)
render json: Templates::SerializeForApi.call(@template)
end
def update
@@ -61,14 +82,6 @@ module Api
templates
end
def serialize_params
{
methods: %i[application_key],
include: { author: { only: %i[id email first_name last_name] },
documents: { only: %i[id uuid], methods: %i[url preview_image_url filename] } }
}
end
def template_params
permit_params = [
:name,
@@ -8,7 +8,7 @@ class PreviewDocumentPageController < ActionController::API
def show
attachment_uuid = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid], purpose: :attachment)
attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid, name: :preview_images) if attachment_uuid
attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid) if attachment_uuid
return head :not_found unless attachment
@@ -12,13 +12,16 @@ class SendSubmissionEmailController < ApplicationController
def create
@submitter =
if params[:template_slug]
Submitter.joins(submission: :template).find_by!(email: params[:email],
Submitter.joins(submission: :template).find_by!(email: params[:email].to_s.downcase,
template: { slug: params[:template_slug] })
elsif params[:submission_slug]
Submitter.joins(:submission).find_by!(email: params[:email].to_s.downcase,
submission: { slug: params[:submission_slug] })
else
Submitter.find_by!(slug: params[:submitter_slug])
end
SubmitterMailer.documents_copy_email(@submitter).deliver_later!
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later!
respond_to do |f|
f.html { redirect_to success_send_submission_email_index_path }
+1 -16
View File
@@ -6,23 +6,8 @@ class SubmissionsController < ApplicationController
load_and_authorize_resource :submission, only: %i[show destroy]
PRELOAD_ALL_PAGES_AMOUNT = 200
def show
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [:template, { template_schema_documents: :blob }]
).call
total_pages =
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: @submission.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
@submission = Submissions.preload_with_pages(@submission)
render :show, layout: 'plain'
end
@@ -4,10 +4,20 @@ class SubmissionsDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
TTL = 20.minutes
TTL = 40.minutes
FILES_TTL = 5.minutes
def index
submitter = Submitter.find_by!(slug: params[:submitter_slug])
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid =
if submitter&.slug == params[:submitter_slug]
true
else
submitter = nil
end
submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
Submissions::EnsureResultGenerated.call(submitter)
@@ -17,18 +27,24 @@ class SubmissionsDownloadController < ApplicationController
return head :not_found unless last_submitter.completed_at?
if last_submitter.completed_at < TTL.ago &&
(current_user.nil? || !current_user.account.submitters.exists?(id: last_submitter.id))
if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter)
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
urls =
Submitters.select_attachments_for_download(last_submitter).map do |attachment|
ActiveStorage::Blob.proxy_url(attachment.blob)
end
render json: build_urls(last_submitter)
end
render json: urls
private
def current_user_submitter?(submitter)
current_user && current_user.account.submitters.exists?(id: submitter.id)
end
def build_urls(submitter)
Submitters.select_attachments_for_download(submitter).map do |attachment|
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.minutes.from_now.to_i)
end
end
end
@@ -4,37 +4,31 @@ class SubmissionsPreviewController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
PRELOAD_ALL_PAGES_AMOUNT = 200
TTL = 20.minutes
TTL = 40.minutes
def show
@submission = Submission.find_by!(slug: params[:slug])
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid =
if submitter && submitter.submission.slug == params[:slug]
@submission = submitter.submission
true
end
@submission ||= Submission.find_by!(slug: params[:slug])
if !@submission.submitters.all?(&:completed_at?) && current_user.blank?
raise ActionController::RoutingError, 'Not Found'
end
unless submission_valid_ttl?(@submission)
if !submission_valid_ttl?(@submission) && !signature_valid
Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar)
return redirect_to submissions_preview_completed_path(@submission.slug)
end
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [:template, { template_schema_documents: :blob }]
).call
total_pages =
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: @submission.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
@submission = Submissions.preload_with_pages(@submission)
render 'submissions/show', layout: 'plain'
end
@@ -42,7 +36,7 @@ class SubmissionsPreviewController < ApplicationController
def completed
@submission = Submission.find_by!(slug: params[:submissions_preview_slug])
render :completed, layout: 'plain'
render :completed, layout: 'form'
end
private
@@ -0,0 +1,31 @@
# frozen_string_literal: true
class UserConfigsController < ApplicationController
before_action :load_user_config
authorize_resource :user_config
ALLOWED_KEYS = [
UserConfig::RECEIVE_COMPLETED_EMAIL
].freeze
def create
@user_config.update!(user_config_params)
head :ok
end
private
def load_user_config
return head :not_found unless ALLOWED_KEYS.include?(user_config_params[:key])
@user_config =
UserConfig.find_or_initialize_by(user: current_user, key: user_config_params[:key])
end
def user_config_params
params.required(:user_config).permit!.tap do |attrs|
attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0])
end
end
end
+1
View File
@@ -90,6 +90,7 @@ window.customElements.define('template-builder', class extends HTMLElement {
withLogo: this.dataset.withLogo !== 'false',
editable: this.dataset.editable !== 'false',
withPayment: this.dataset.withPayment === 'true',
withFormula: this.dataset.withFormula === 'true',
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
acceptFileTypes: this.dataset.acceptFileTypes,
isDirectUpload: this.dataset.isDirectUpload === 'true'
+4 -4
View File
@@ -75,16 +75,16 @@ button[disabled] .enabled {
@apply select base-input w-full font-normal;
}
.tooltip-bottom-start:before {
transform: translateX(-30%);
.tooltip-bottom-end:before {
transform: translateX(-95%);
top: var(--tooltip-offset);
left: 100%;
right: auto;
bottom: auto;
}
.tooltip-bottom-start:after {
transform: translateX(-75%);
.tooltip-bottom-end:after {
transform: translateX(-25%);
border-color: transparent transparent var(--tooltip-color) transparent;
top: var(--tooltip-tail-offset);
left: 50%;
+3 -1
View File
@@ -173,7 +173,7 @@
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1 } from '@tabler/icons-vue'
export default {
name: 'FieldArea',
@@ -240,6 +240,7 @@ export default {
signature: this.t('signature'),
initials: this.t('initials'),
date: this.t('date'),
number: this.t('number'),
image: this.t('image'),
file: this.t('file'),
select: this.t('select'),
@@ -260,6 +261,7 @@ export default {
text: IconTextSize,
signature: IconWritingSign,
date: IconCalendarEvent,
number: IconSquareNumber1,
image: IconPhoto,
initials: IconLetterCaseUpper,
file: IconPaperclip,
+63 -7
View File
@@ -8,11 +8,16 @@
:current-step="currentStepFields"
@focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']"
/>
<FormulaFieldAreas
v-if="formulaFields.length"
:fields="formulaFields"
:values="values"
/>
<button
v-if="!isFormVisible"
id="expand_form_button"
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"
@click.prevent="[isFormVisible = true, scrollIntoField(currentField)]"
>
{{ t('submit_form') }}
<IconArrowsDiagonal
@@ -72,6 +77,14 @@
@focus="scrollIntoField(currentField)"
/>
</div>
<NumberStep
v-else-if="currentField.type === 'number'"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:show-field-names="showFieldNames"
:field="currentField"
@focus="scrollIntoField(currentField)"
/>
<DateStep
v-else-if="currentField.type === 'date'"
:key="currentField.uuid"
@@ -388,6 +401,7 @@
<script>
import FieldAreas from './areas'
import FormulaFieldAreas from './formula_areas'
import ImageStep from './image_step'
import SignatureStep from './signature_step'
import InitialsStep from './initials_step'
@@ -396,12 +410,31 @@ import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step'
import PaymentStep from './payment_step'
import TextStep from './text_step'
import NumberStep from './number_step'
import DateStep from './date_step'
import FormCompleted from './completed'
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import AppearsOn from './appears_on'
import i18n from './i18n'
const isEmpty = (obj) => {
if (obj == null) return true
if (Array.isArray(obj) || typeof obj === 'string') {
return obj.length === 0
}
if (typeof obj === 'object') {
return Object.keys(obj).length === 0
}
if (obj === false) {
return true
}
return false
}
export default {
name: 'SubmissionForm',
components: {
@@ -416,6 +449,8 @@ export default {
DateStep,
IconArrowsDiagonal,
TextStep,
NumberStep,
FormulaFieldAreas,
PhoneStep,
PaymentStep,
IconArrowsDiagonalMinimize2,
@@ -451,6 +486,11 @@ export default {
return () => {}
}
},
expand: {
type: Boolean,
required: false,
default: true
},
withConfetti: {
type: Boolean,
required: false,
@@ -550,7 +590,7 @@ export default {
data () {
return {
isCompleted: false,
isFormVisible: true,
isFormVisible: this.expand,
showFillAllRequiredFields: false,
currentStep: 0,
isSubmitting: false,
@@ -580,7 +620,7 @@ export default {
return this.values[initialsField?.uuid]
},
isAnonymousChecboxes () {
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4
},
isButtonDisabled () {
if (this.recalculateButtonDisabledKey) {
@@ -608,6 +648,9 @@ export default {
return acc
}, [])
},
formulaFields () {
return this.fields.filter((f) => f.preferences?.formula)
},
attachmentsIndex () {
return this.attachments.reduce((acc, a) => {
acc[a.uuid] = a
@@ -619,6 +662,11 @@ export default {
return `/s/${this.submitterSlug}`
}
},
watch: {
expand (value) {
this.isFormVisible = value
}
},
mounted () {
this.submittedValues = JSON.parse(JSON.stringify(this.values))
@@ -757,7 +805,7 @@ export default {
this.scrollIntoField(step[0])
}
this.$refs.form.querySelector('input[type="date"], input[type="text"], select')?.focus()
this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], select')?.focus()
if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) {
this.$refs.form.querySelector('input[type="file"]')?.click()
@@ -765,7 +813,7 @@ export default {
})
},
saveStep (formData) {
const currentFieldUuid = this.currentField.uuid
const currentFieldUuids = this.currentStepFields.map((f) => f.uuid)
if (this.isCompleted) {
return Promise.resolve({})
@@ -775,7 +823,9 @@ export default {
body: formData || new FormData(this.$refs.form)
}).then((response) => {
if (response.status === 200) {
this.submittedValues[currentFieldUuid] = this.values[currentFieldUuid]
currentFieldUuids.forEach((fieldUuid) => {
this.submittedValues[fieldUuid] = this.values[fieldUuid]
})
}
return response
@@ -797,7 +847,13 @@ export default {
stepPromise().then(async () => {
const emptyRequiredField = this.stepFields.find((fields, index) => {
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && (this.submittedValues[fields[0].uuid] === true ? false : !this.submittedValues[fields[0].uuid]?.length)
if (index >= this.currentStep) {
return false
}
return fields.some((f) => {
return f.required && (f.type === 'phone' || !this.allowToSkip) && isEmpty(this.submittedValues[f.uuid])
})
})
const formData = new FormData(this.$refs.form)
@@ -0,0 +1,97 @@
<template>
<template
v-for="(field, fieldIndex) in fields"
:key="field.uuid"
>
<template
v-for="(area, areaIndex) in field.areas"
:key="areaIndex"
>
<Teleport
v-if="findPageElementForArea(area)"
:to="findPageElementForArea(area)"
>
<FieldArea
v-if="isMathLoaded"
:model-value="calculateFormula(field)"
:field="field"
:area="area"
:submittable="false"
:field-index="fieldIndex"
/>
</Teleport>
</template>
</template>
</template>
<script>
import FieldArea from './area'
export default {
name: 'FormulaFieldAreas',
components: {
FieldArea
},
props: {
fields: {
type: Array,
required: false,
default: () => []
},
values: {
type: Object,
required: false,
default: () => ({})
}
},
data () {
return {
isMathLoaded: false
}
},
async mounted () {
const {
create,
evaluateDependencies,
addDependencies,
subtractDependencies,
divideDependencies,
multiplyDependencies,
powDependencies,
roundDependencies,
absDependencies,
sinDependencies,
tanDependencies,
cosDependencies
} = await import('mathjs')
this.math = create({
evaluateDependencies,
addDependencies,
subtractDependencies,
divideDependencies,
multiplyDependencies,
powDependencies,
roundDependencies,
absDependencies,
sinDependencies,
tanDependencies,
cosDependencies
})
this.isMathLoaded = true
},
methods: {
findPageElementForArea (area) {
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${area.attachment_uuid}-${area.page}`)
},
calculateFormula (field) {
const transformedFormula = field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
return this.values[uuid] || 0.0
})
return this.math.evaluate(transformedFormula.toLowerCase())
}
}
}
</script>
+78 -2
View File
@@ -3,6 +3,7 @@ const en = {
signature: 'Signature',
initials: 'Initials',
date: 'Date',
number: 'Number',
image: 'Image',
take_photo: 'Take photo',
file: 'File',
@@ -68,6 +69,7 @@ const es = {
signature: 'Firma',
initials: 'Iniciales',
date: 'Fecha',
number: 'Número',
image: 'Imagen',
file: 'Archivo',
select: 'Seleccionar',
@@ -131,6 +133,7 @@ const it = {
signature: 'Firma',
initials: 'Iniziali',
date: 'Data',
number: 'Numero',
image: 'Immagine',
file: 'File',
select: 'Seleziona',
@@ -194,6 +197,7 @@ const de = {
signature: 'Unterschrift',
initials: 'Initialen',
date: 'Datum',
number: 'Nummer',
image: 'Bild',
file: 'Datei',
select: 'Auswählen',
@@ -257,6 +261,7 @@ const fr = {
signature: 'Signature',
initials: 'Initiales',
date: 'Date',
number: 'Numéro',
image: 'Image',
file: 'Fichier',
select: 'Choisir',
@@ -320,6 +325,7 @@ const pl = {
signature: 'Podpis',
initials: 'Inicjały',
date: 'Data',
number: 'Numer',
image: 'Obraz',
file: 'Plik',
select: 'Wybierz',
@@ -383,6 +389,7 @@ const uk = {
signature: 'Підпис',
initials: 'Ініціали',
date: 'Дата',
number: 'Число',
image: 'Зображення',
file: 'Файл',
select: 'Вибрати',
@@ -446,6 +453,7 @@ const cs = {
signature: 'Podpis',
initials: 'Iniciály',
date: 'Datum',
number: 'Číslo',
image: 'Obrázek',
file: 'Soubor',
select: 'Vybrat',
@@ -509,6 +517,7 @@ const pt = {
signature: 'Assinatura',
initials: 'Iniciais',
date: 'Data',
number: 'Número',
image: 'Imagem',
file: 'Arquivo',
select: 'Selecionar',
@@ -570,8 +579,9 @@ const he = {
minimize: 'לקטן',
text: 'טקסט',
signature: 'חתימה',
initials: 'ראשי תיקיות',
initials: 'ראשי תיבות',
date: 'תאריך',
number: 'מספר',
image: 'תמונה',
file: 'קובץ',
select: 'בחר',
@@ -636,6 +646,7 @@ const nl = {
signature: 'Handtekening',
initials: 'Initialen',
date: 'Datum',
number: 'Nummer',
image: 'Afbeelding',
take_photo: 'Maak een foto',
file: 'Bestand',
@@ -694,6 +705,71 @@ const nl = {
files: 'Bestanden'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl }
const ar = {
text: 'نص',
signature: 'توقيع',
initials: 'الاختصارات',
date: 'تاريخ',
number: 'رقم',
image: 'صورة',
take_photo: 'التقاط صورة',
file: 'ملف',
select: 'اختيار',
checkbox: 'خانة اختيار',
multiple: 'متعدد',
radio: 'راديو',
cells: 'خلايا',
stamp: 'ختم',
minimize: 'تصغير',
payment: 'الدفع',
phone: 'هاتف',
submit_form: 'إرسال النموذج',
type_here_: 'اكتب هنا',
optional: 'اختياري',
option: 'خيار',
appears_on: 'يظهر على',
page: 'صفحة',
select_your_option: 'اختر خيارك',
complete_hightlighted_checkboxes_and_click: 'أكمل الخانات المميزة وانقر',
submit: 'إرسال',
next: 'التالي',
click_to_upload: 'انقر للتحميل',
or_drag_and_drop_files: 'أو اسحب وأسقط الملفات',
send_copy_via_email: 'إرسال نسخة عبر البريد الإلكتروني',
download: 'تحميل',
clear: 'مسح',
redraw: 'إعادة الرسم',
draw_initials: 'ارسم الاختصارات',
type_signature_here: 'اكتب التوقيع هنا',
type_initial_here: 'اكتب الاختصارات هنا',
form_has_been_completed: 'تم إكمال النموذج!',
create_a_free_account: 'إنشاء حساب مجاني',
signed_with: 'تم التوقيع بواسطة',
please_check_the_box_to_continue: 'الرجاء التحقق من الخانة للمتابعة',
open_source_documents_software: 'برنامج وثائق مفتوح المصدر',
verified_phone_number: 'تحقق من رقم الهاتف',
use_international_format: 'استخدم الشكل الدولي: +1xxx',
six_digits_code: 'رمز مكون من 6 أرقام',
change_phone_number: 'تغيير رقم الهاتف',
sending: 'جارٍ الإرسال',
resend_code: 'إعادة إرسال الرمز',
verification_code_has_been_resent: 'تم إعادة إرسال رمز التحقق عبر الرسائل القصيرة',
please_fill_all_required_fields: 'الرجاء ملء جميع الحقول المطلوبة',
set_today: 'تعيين اليوم',
toggle_multiline_text: 'تبديل النصوص متعددة الأسطر',
draw_signature: 'ارسم التوقيع',
type_initial: 'اكتب الاختصارات',
draw: 'ارسم',
type: 'اكتب',
type_text: 'اكتب نصًا',
email_has_been_sent: 'تم إرسال البريد الإلكتروني',
processing: 'جارٍ المعالجة',
pay_with_strip: 'الدفع بواسطة Stripe',
reupload: 'إعادة التحميل',
upload: 'تحميل',
files: 'الملفات'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar }
export default i18n
@@ -0,0 +1,79 @@
<template>
<label
v-if="showFieldNames && field.name"
: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>
<template v-else>{{ field.name }}</template>
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
<div
v-else
class="py-1"
/>
<div
v-if="field.description"
class="mb-3 px-1 text-lg"
v-html="field.description"
/>
<AppearsOn :field="field" />
<div class="items-center flex">
<input
type="hidden"
name="cast_number"
value="true"
>
<input
:id="field.uuid"
v-model="number"
type="number"
class="base-input !text-2xl w-full"
step="any"
:required="field.required"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
:name="`values[${field.uuid}]`"
@focus="$emit('focus')"
>
</div>
</template>
<script>
import AppearsOn from './appears_on'
export default {
name: 'TextStep',
components: {
AppearsOn
},
inject: ['t'],
props: {
field: {
type: Object,
required: true
},
showFieldNames: {
type: Boolean,
required: false,
default: true
},
modelValue: {
type: [String, Number],
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus'],
computed: {
number: {
set (value) {
this.$emit('update:model-value', value)
},
get () {
return this.modelValue
}
}
}
}
</script>
+4 -1
View File
@@ -206,9 +206,11 @@ export default {
}
},
computed: {
defaultName: Field.computed.defaultName,
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
defaultName () {
return this.buildDefaultName(this.field, this.template.fields)
},
optionIndexText () {
if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
@@ -312,6 +314,7 @@ export default {
}
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
onNameFocus (e) {
this.selectedAreaRef.value = this.area
+16 -1
View File
@@ -273,6 +273,7 @@
@select="startFieldDraw($event)"
/>
</div>
<div id="docuseal_modal_container" />
</div>
</template>
@@ -321,6 +322,8 @@ export default {
backgroundColor: this.backgroundColor,
withPhone: this.withPhone,
withPayment: this.withPayment,
withFormula: this.withFormula,
defaultDrawFieldType: this.defaultDrawFieldType,
selectedAreaRef: computed(() => this.selectedAreaRef)
}
},
@@ -364,6 +367,11 @@ export default {
required: false,
default: () => []
},
defaultDrawFieldType: {
type: String,
required: false,
default: 'text'
},
currencies: {
type: Array,
required: false,
@@ -443,6 +451,11 @@ export default {
required: false,
default: false
},
withFormula: {
type: Boolean,
required: false,
default: false
},
onlyDefinedFields: {
type: Boolean,
required: false,
@@ -755,7 +768,9 @@ export default {
let type = (pageMask.clientWidth * area.w) < 35 ? 'checkbox' : 'text'
if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) {
if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
type = this.defaultDrawFieldType
} else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) {
type = this.fieldTypes[0]
}
+75 -22
View File
@@ -65,6 +65,17 @@
:stroke-width="1.6"
/>
</button>
<button
v-if="field.preferences?.formula"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
:disabled="!withFormula"
@click="isShowFormulaModal = true"
>
<IconMathFunction
:width="18"
:stroke-width="1.6"
/>
</button>
<PaymentSettings
v-if="field.type === 'payment'"
:field="field"
@@ -193,6 +204,26 @@
<span class="label-text">{{ t('required') }}</span>
</label>
</li>
<li
v-if="field.type == 'number'"
:class="{'tooltip tooltip-bottom': !withFormula}"
:data-tip="withFormula ? '' : 'Available in Pro'"
@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>
</li>
<li
v-if="field.type == 'checkbox'"
@click.stop
@@ -359,6 +390,16 @@
</button>
</div>
</div>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:build-default-name="buildDefaultName"
@close="isShowFormulaModal = false"
/>
</Teleport>
</div>
</template>
@@ -366,7 +407,8 @@
import Contenteditable from './contenteditable'
import FieldType from './field_type'
import PaymentSettings from './payment_settings'
import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
import FormulaModal from './formula_modal'
import { IconMathFunction, IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
@@ -377,11 +419,13 @@ export default {
IconShape,
PaymentSettings,
IconNewSection,
FormulaModal,
IconTrashX,
IconMathFunction,
IconCopy,
FieldType
},
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't'],
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't', 'withFormula'],
props: {
field: {
type: Object,
@@ -403,11 +447,15 @@ export default {
return {
isNameFocus: false,
showPaymentModal: false,
isShowFormulaModal: false,
renderDropdown: false
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
dateFormats () {
return [
'MM/DD/YYYY',
@@ -422,22 +470,7 @@ export default {
]
},
defaultName () {
if (this.field.type === 'payment' && this.field.preferences?.price) {
const { price, currency } = this.field.preferences || {}
const formattedPrice = new Intl.NumberFormat([], {
style: 'currency',
currency
}).format(price)
return `${this.fieldNames[this.field.type]} ${formattedPrice}`
} else {
const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field)
const suffix = { multiple: this.t('select'), radio: this.t('group') }[this.field.type] || this.t('field')
return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}`
}
return this.buildDefaultName(this.field, this.template.fields)
},
areas () {
return this.field.areas || []
@@ -452,6 +485,24 @@ export default {
}
},
methods: {
buildDefaultName (field, fields) {
if (field.type === 'payment' && field.preferences?.price) {
const { price, currency } = field.preferences || {}
const formattedPrice = new Intl.NumberFormat([], {
style: 'currency',
currency
}).format(price)
return `${this.fieldNames[field.type]} ${formattedPrice}`
} else {
const typeIndex = fields.filter((f) => f.type === field.type).indexOf(field)
const suffix = { multiple: this.t('select'), radio: this.t('group') }[field.type] || this.t('field')
return `${this.fieldNames[field.type]} ${suffix} ${typeIndex + 1}`
}
},
formatDate (date, format) {
const monthFormats = {
M: 'numeric',
@@ -485,11 +536,13 @@ export default {
const areaString = JSON.stringify(field.areas[0])
this.template.documents.forEach((attachment) => {
attachment.preview_images.forEach((page) => {
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === parseInt(page.filename))) {
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page: parseInt(page.filename) })
const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length
for (let page = 0; page <= numberOfPages - 1; page++) {
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === page)) {
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
}
})
}
})
this.$nextTick(() => {
@@ -50,7 +50,7 @@
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1 } from '@tabler/icons-vue'
export default {
name: 'FiledTypeDropdown',
@@ -94,6 +94,7 @@ export default {
signature: this.t('signature'),
initials: this.t('initials'),
date: this.t('date'),
number: this.t('number'),
image: this.t('image'),
file: this.t('file'),
select: this.t('select'),
@@ -112,12 +113,13 @@ export default {
signature: IconWritingSign,
initials: IconLetterCaseUpper,
date: IconCalendarEvent,
number: IconSquareNumber1,
image: IconPhoto,
file: IconPaperclip,
checkbox: IconCheckbox,
multiple: IconChecks,
file: IconPaperclip,
radio: IconCircleDot,
select: IconSelect,
multiple: IconChecks,
cells: IconColumns3,
stamp: IconRubberStamp,
payment: IconCreditCard,
+1 -1
View File
@@ -93,7 +93,7 @@
<div
v-else-if="type == 'phone' && (fieldTypes.length === 0 || fieldTypes.includes(type))"
class="tooltip tooltip-bottom flex"
:class="{'tooltip-bottom-start': !withPayment, 'tooltip-bottom': withPayment }"
:class="{'tooltip-bottom-end': withPayment, 'tooltip-bottom': !withPayment }"
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
>
<a
@@ -0,0 +1,216 @@
<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('formula') }}
</span>
<a
href="#"
class="text-xl"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<div>
<div class="flex-inline mb-2 gap-2 space-y-1">
<button
v-for="f in fields"
:key="f.uuid"
class="mr-1 btn btn-neutral btn-outline border-base-content/20 btn-sm normal-case font-normal bg-white !rounded-xl"
@click.prevent="insertTextUnderCursor(`{{${f.name || buildDefaultName(f, template.fields)}}}`)"
>
<IconCodePlus
width="20"
height="20"
stroke-width="1.5"
/>
{{ f.name || buildDefaultName(f, template.fields) }}
</button>
</div>
<div>
<div class="flex">
<textarea
ref="textarea"
v-model="formula"
class="base-textarea !rounded-xl !text-base font-mono w-full !outline-0 !ring-0 !px-3"
required="true"
@input="resizeTextarea"
/>
</div>
<div class="mb-3 mt-1">
<div
target="blank"
class="text-sm mb-2 inline space-x-2"
>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' + ')"
>
+
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' - ')"
>
-
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' * ')"
>
*
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' / ')"
>
/
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' % ')"
>
%
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor('^')"
>
^
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor('round()')"
>
round(n, d)
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor('abs()')"
>
abs(n)
</button>
</div>
</div>
</div>
<button
class="base-button w-full"
@click.prevent="validateSaveAndClose"
>
{{ t('save') }}
</button>
</div>
</div>
</div>
</template>
<script>
import { IconCodePlus } from '@tabler/icons-vue'
export default {
name: 'FormulaModal',
components: {
IconCodePlus
},
inject: ['t', 'save', 'template'],
props: {
field: {
type: Object,
required: true
},
buildDefaultName: {
type: Function,
required: true
}
},
emits: ['close'],
data () {
return {
formula: ''
}
},
computed: {
fields () {
return this.template.fields.reduce((acc, f) => {
if (f !== this.field && f.submitter_uuid === this.field.submitter_uuid && ['number'].includes(f.type) && !f.preferences?.formula) {
acc.push(f)
}
return acc
}, [])
}
},
created () {
this.field.preferences ||= {}
},
mounted () {
this.formula = this.humanizeFormula(this.field.preferences.formula || '')
},
methods: {
humanizeFormula (text) {
return text.replace(/{{(.*?)}}/g, (match, uuid) => {
const foundField = this.fields.find((f) => f.uuid === uuid)
if (foundField) {
return `{{${foundField.name || this.buildDefaultName(foundField, this.template.fields)}}}`
} else {
return '{{FIELD NOT FOUND}}'
}
})
},
normalizeFormula (text) {
return text.replace(/{{(.*?)}}/g, (match, name) => {
const foundField = this.fields.find((f) => {
return (f.name || this.buildDefaultName(f, this.template.fields)).trim() === name.trim()
})
if (foundField) {
return `{{${foundField.uuid}}}`
} else {
return '{{FIELD NOT FOUND}}'
}
})
},
validateSaveAndClose () {
const normalizedFormula = this.normalizeFormula(this.formula)
if (normalizedFormula.includes('FIELD NOT FOUND')) {
alert('Some fields are missing in the formula.')
} else {
this.field.preferences.formula = normalizedFormula
this.field.readonly = !!normalizedFormula
this.save()
this.$emit('close')
}
},
insertTextUnderCursor (textToInsert) {
const textarea = this.$refs.textarea
const selectionEnd = textarea.selectionEnd
const cursorPos = selectionEnd
const newText = textarea.value.substring(0, cursorPos) + textToInsert + textarea.value.substring(cursorPos)
this.formula = newText
textarea.setSelectionRange(cursorPos + textToInsert.length, cursorPos + textToInsert.length)
textarea.focus()
},
resizeTextarea () {
const textarea = this.$refs.textarea
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
}
}
}
</script>
+2
View File
@@ -13,6 +13,7 @@ const en = {
cancel: 'Cancel',
any: 'Any',
drawn: 'Drawn',
formula: 'Formula',
typed: 'Typed',
draw_field_on_the_document: 'Draw {field} field on the document',
click_to_upload: 'Click to upload',
@@ -54,6 +55,7 @@ const en = {
add: 'Add',
or_add_field_without_drawing: 'Or add field without drawing',
text: 'Text',
number: 'Number',
signature: 'Signature',
initials: 'Initials',
date: 'Date',
+4 -2
View File
@@ -61,7 +61,7 @@ export default {
components: {
FieldArea
},
inject: ['fieldTypes'],
inject: ['fieldTypes', 'defaultDrawFieldType'],
props: {
image: {
type: Object,
@@ -123,7 +123,9 @@ export default {
},
computed: {
defaultFieldType () {
if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes('text')) {
if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
return this.defaultDrawFieldType
} else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes('text')) {
return this.fieldTypes[0]
} else {
return 'text'
@@ -15,21 +15,25 @@ class SendFormCompletedWebhookRequestJob < ApplicationJob
ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = Faraday.post(config.value,
{
event_type: 'form.completed',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
resp = begin
Faraday.post(config.value,
{
event_type: 'form.completed',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::TimeoutError
nil
end
if resp.status.to_i >= 400 && attempt <= MAX_ATTEMPTS &&
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormCompletedWebhookRequestJob.set(wait: (2**attempt).minutes)
.perform_later(submitter, {
attempt: attempt + 1,
last_status: resp.status.to_i
last_status: resp&.status.to_i
})
end
end
@@ -13,21 +13,25 @@ class SendFormStartedWebhookRequestJob < ApplicationJob
ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = Faraday.post(config.value,
{
event_type: 'form.started',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
resp = begin
Faraday.post(config.value,
{
event_type: 'form.started',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::TimeoutError
nil
end
if resp.status.to_i >= 400 && attempt <= MAX_ATTEMPTS &&
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormStartedWebhookRequestJob.set(wait: (2**attempt).minutes)
.perform_later(submitter, {
attempt: attempt + 1,
last_status: resp.status.to_i
last_status: resp&.status.to_i
})
end
end
@@ -13,21 +13,25 @@ class SendFormViewedWebhookRequestJob < ApplicationJob
ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = Faraday.post(config.value,
{
event_type: 'form.viewed',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
resp = begin
Faraday.post(config.value,
{
event_type: 'form.viewed',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::TimeoutError
nil
end
if resp.status.to_i >= 400 && attempt <= MAX_ATTEMPTS &&
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormViewedWebhookRequestJob.set(wait: (2**attempt).minutes)
.perform_later(submitter, {
attempt: attempt + 1,
last_status: resp.status.to_i
last_status: resp&.status.to_i
})
end
end
+3 -1
View File
@@ -2,6 +2,7 @@
class SubmitterMailer < ApplicationMailer
MAX_ATTACHMENTS_SIZE = 10.megabytes
SIGN_TTL = 1.hour + 20.minutes
DEFAULT_INVITATION_SUBJECT = 'You are invited to submit a form'
@@ -61,9 +62,10 @@ class SubmitterMailer < ApplicationMailer
subject:)
end
def documents_copy_email(submitter, to: nil)
def documents_copy_email(submitter, to: nil, sig: false)
@current_account = submitter.submission.template.account
@submitter = submitter
@sig = submitter.signed_id(expires_in: SIGN_TTL, purpose: :download_completed) if sig
Submissions::EnsureResultGenerated.call(@submitter)
@@ -2,6 +2,20 @@
<%= render 'shared/settings_nav' %>
<div class="flex-grow max-w-xl mx-auto">
<h1 class="text-4xl font-bold mb-4">Email Notifications</h1>
<div class="mt-2 mb-1">
<% user_config = UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::RECEIVE_COMPLETED_EMAIL) %>
<% if can?(:manage, user_config) %>
<%= form_for user_config, url: user_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %>
<div class="flex items-center justify-between">
<span>
Receive notification emails on completed submission
</span>
<%= f.check_box :value, class: 'toggle', checked: user_config.value != false, onchange: 'this.form.requestSubmit()' %>
</div>
<% end %>
<% end %>
</div>
<%= render 'email_stats' %>
<%= render 'bcc_form', config: @bcc_config %>
<div class="flex justify-between items-end mb-4 mt-8">
+3 -3
View File
@@ -6,7 +6,7 @@
</a>
<div class="space-x-3 flex items-center">
<% if @submission.audit_trail.present? %>
<a href="<%= ActiveStorage::Blob.proxy_url(@submission.audit_trail.blob) %>" class="white-button" target="_blank">
<a href="<%= ActiveStorage::Blob.proxy_url(@submission.audit_trail.blob, expires_at: 4.hours.from_now) %>" class="white-button" target="_blank">
<%= svg_icon('external_link', class: 'w-6 h-6') %>
<span class="hidden md:inline">
Audit Log
@@ -14,7 +14,7 @@
</a>
<% end %>
<% if last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %>
<download-button data-src="<%= submitter_download_index_path(last_submitter.slug) %>" class="base-button">
<download-button data-src="<%= submitter_download_index_path(last_submitter.slug, { sig: params[:sig] }.compact) %>" class="base-button">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6') %>
<span class="hidden md:inline">Download</span>
@@ -121,7 +121,7 @@
</div>
<% end %>
<% if signed_in? && submitter && submitter.phone && !submitter.completed_at && can?(:update, submitter) %>
<%= render 'send_sms_button', submitter: %>
<%= render 'submissions/send_sms_button', submitter: %>
<% end %>
<% if signed_in? && submitter && !submitter.completed_at? && can?(:create, submitter) %>
<div class="mt-2 mb-1">
@@ -20,6 +20,21 @@
</div>
</div>
</div>
<% if Accounts.can_send_emails?(@submission.account) %>
<%= form_for '', url: send_submission_email_index_path, method: :post, html: { class: 'space-y-4', onsubmit: 'event.submitter.disabled = true' } do |f| %>
<div dir="auto" class="form-control !mt-0">
<%= f.hidden_field :submission_slug, value: @submission.slug %>
<%= f.label :email, t('email'), class: 'label' %>
<%= f.email_field :email, value: current_user&.email || params[:email], required: true, class: 'base-input', placeholder: t('send_copy_to_email') %>
</div>
<div dir="auto" class="form-control">
<%= f.button button_title(title: t('send_copy_to_email'), disabled_with: t('starting')), class: 'base-button' %>
</div>
<% end %>
<% if Docuseal.multitenant? %>
<div class="divider uppercase"><%= t('or') %></div>
<% end %>
<% end %>
<% if Docuseal.multitenant? %>
<div>
<%= link_to 'Create free account', registration_path, class: 'white-button w-full' %>
+1 -1
View File
@@ -25,7 +25,7 @@
<div class="py-2"></div>
<% end %>
<% end %>
<% if @submitter.completed_at > 15.minutes.ago || (current_user && current_user.account.submitters.exists?(id: @submitter.id)) %>
<% if @submitter.completed_at > 30.minutes.ago || (current_user && current_user.account.submitters.exists?(id: @submitter.id)) %>
<download-button data-src="<%= submitter_download_index_path(@submitter.slug) %>" class="base-button w-full">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6') %>
+1
View File
@@ -24,6 +24,7 @@
<% 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) %>
<% next if value.blank? %>
<% next if !field['readonly'] && field['submitter_uuid'] == @submitter.uuid %>
<% next if field.dig('preferences', 'formula') && field['submitter_uuid'] == @submitter.uuid %>
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submitter.account.locale %>
<% end %>
</div>
@@ -1,11 +1,11 @@
<% if @email_config %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(@email_config.value['body'], submitter: @submitter)))) %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(@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>
<p>Alternatively, you can review and download your copy using:</p>
<p>
<%= link_to @submitter.template.name, submissions_preview_url(@submitter.submission.slug) %>
<%= link_to @submitter.template.name, submissions_preview_url(@submitter.submission.slug, { sig: @sig }.compact) %>
</p>
<p>
Thanks,<br><%= @current_account.name %>
+8 -2
View File
@@ -128,9 +128,15 @@ Rails.application.configure do
config.lograge.formatter = ->(data) { data.except(:path, :location).to_json }
config.lograge.custom_payload do |controller|
params = controller.request.params || {}
{
fwd: controller.request.ip.to_s[/\A\d+\.(.*)/, 1],
params: controller.request.params&.slice(:id),
fwd: controller.request.ip,
params: {
id: params[:id],
sig: (params[:signed_uuid] || params[:signed_id]).to_s.split('--').first,
slug: (params[:slug] || params[:submission_slug] || params[:template_slug]).to_s.last(5)
}.compact_blank,
host: controller.request.host,
uid: controller.instance_variable_get(:@current_user).try(:id)
}
+6 -12
View File
@@ -8,22 +8,14 @@ ActiveSupport.on_load(:active_storage_attachment) do
def signed_uuid
@signed_uuid ||= ApplicationRecord.signed_id_verifier.generate(uuid, expires_in: 6.hours, purpose: :attachment)
end
def preview_image_url
first_page = preview_images.joins(:blob).find_by(blob: { filename: '0.jpg' })
return unless first_page
ActiveStorage::Blob.proxy_url(first_page.blob)
end
end
ActiveSupport.on_load(:active_storage_blob) do
attribute :uuid, :string, default: -> { SecureRandom.uuid }
def self.proxy_url(blob, expires_in: nil)
def self.proxy_url(blob, expires_at: nil)
Rails.application.routes.url_helpers.blobs_proxy_url(
signed_uuid: blob.signed_uuid(expires_in:), filename: blob.filename,
signed_uuid: blob.signed_uuid(expires_at:), filename: blob.filename,
**Docuseal.default_url_options
)
end
@@ -36,8 +28,10 @@ ActiveSupport.on_load(:active_storage_blob) do
end
end
def signed_uuid(expires_in: nil)
ApplicationRecord.signed_id_verifier.generate([uuid, 'blob'], expires_in:)
def signed_uuid(expires_at: nil)
expires_at = expires_at.to_i if expires_at
ApplicationRecord.signed_id_verifier.generate([uuid, 'blob', expires_at].compact)
end
def delete
-30
View File
@@ -1,30 +0,0 @@
# frozen_string_literal: true
require 'rollbar' if ENV.key?('ROLLBAR_ACCESS_TOKEN')
if defined?(Rollbar)
Rollbar.configure do |config|
config.access_token = ENV.fetch('ROLLBAR_ACCESS_TOKEN', nil)
config.transform << proc do |options|
data = options[:payload]['data']
if data[:request]
data[:request][:cookies] = {}
data[:request][:session] = {}
data[:request][:url] =
data[:request][:url].to_s.sub(%r{(/[sde]/)\w{8}}, '\1********').sub(/\A(.*?)--(.*)/, '\1--********')
end
end
config.enabled = true
config.collect_user_ip = false
config.anonymize_user_ip = true
config.scrub_headers += %w[X-Auth-Token Cookie X-Csrf-Token Referer]
config.scrub_fields += %i[slug uuid attachment_uuid]
config.exception_level_filters['ActionController::RoutingError'] = 'ignore'
config.environment = ENV['ROLLBAR_ENV'].presence || Rails.env
end
end
+21
View File
@@ -224,6 +224,27 @@ nl:
downloading: Downloaden
completed_successfully: Succesvol voltooid
ar:
email: البريد الإلكتروني
digitally_signed_by: تم التوقيع الرقمي بواسطة
role: الدور
provide_your_email_to_start: قدم بريدك الإلكتروني للبدء
start: بدء
starting: بداية
form_has_been_deleted_by_html: 'تم حذف الاستمارة بواسطة <span class="font-semibold">%{name}</span>.'
invited_by_html: 'تمت الدعوة بواسطة <span class="font-semibold">%{name}</span>'
you_have_been_invited_to_submit_a_form: تمت دعوتك لتقديم استمارة
signed_on_time: 'تم التوقيع في %{time}'
form_has_been_submitted_already: تم تقديم الاستمارة بالفعل
send_copy_to_email: إرسال نسخة إلى البريد الإلكتروني
sending: جارٍ الإرسال
resubmit: إعادة التقديم
form_has_been_deleted_by_html: 'تم حذف الاستمارة بواسطة <span class="font-semibold">%{name}</span>.'
or: أو
download_documents: تحميل الوثائق
downloading: جارٍ التحميل
completed_successfully: تم الانتهاء بنجاح
en-US:
<<: *en
date:
+1
View File
@@ -53,6 +53,7 @@ Rails.application.routes.draw do
resources :verify_pdf_signature, only: %i[create]
resource :mfa_setup, only: %i[show new edit create destroy], controller: 'mfa_setup'
resources :account_configs, only: %i[create]
resources :user_configs, only: %i[create]
resources :encrypted_user_configs, only: %i[destroy]
resources :timestamp_server, only: %i[create]
resources :dashboard, only: %i[index]
+2 -2
View File
@@ -51,7 +51,7 @@ class PdfProcessor < HexaPDF::Content::Processor
handler.call(self, operator, operands)
end
def self.call(data, process_handler, result_handler, acc = {})
def self.call(data, process_handler, result_handler, acc = {}, remove_tags: true)
doc = HexaPDF::Document.new(io: StringIO.new(data))
doc.pages.each do |page|
@@ -67,7 +67,7 @@ class PdfProcessor < HexaPDF::Content::Processor
page.process_contents(processor)
page.contents = process_handler_instance.contents
page.contents = process_handler_instance.contents if process_handler_instance.result.present? && remove_tags
end
page[:Annots].to_a.each do |annot|
+5 -5
View File
@@ -18,7 +18,7 @@ module ReplaceEmailVariables
module_function
# rubocop:disable Metrics
def call(text, submitter:, tracking_event_type: 'click_email')
def call(text, submitter:, tracking_event_type: 'click_email', sig: nil)
submitter_link = build_submitter_link(submitter, tracking_event_type)
submission_link = build_submission_link(submitter.submission) if submitter.submission
@@ -35,8 +35,8 @@ module ReplaceEmailVariables
if text.include?(SUBMISSION_SUBMITTERS)
text = text.gsub(SUBMISSION_SUBMITTERS, build_submission_submitters(submitter.submission))
end
text = text.gsub(DOCUMENTS_LINKS, build_documents_links_text(submitter))
text = text.gsub(DOCUMENTS_LINK, build_documents_links_text(submitter))
text = text.gsub(DOCUMENTS_LINKS, build_documents_links_text(submitter, sig))
text = text.gsub(DOCUMENTS_LINK, build_documents_links_text(submitter, sig))
text = text.gsub(ACCOUNT_NAME, submitter.template.account.name) if submitter.template
@@ -44,9 +44,9 @@ module ReplaceEmailVariables
end
# rubocop:enable Metrics
def build_documents_links_text(submitter)
def build_documents_links_text(submitter, sig = nil)
Rails.application.routes.url_helpers.submissions_preview_url(
submitter.submission.slug, **Docuseal.default_url_options
submitter.submission.slug, { sig:, **Docuseal.default_url_options }.compact
)
end
+21
View File
@@ -3,6 +3,8 @@
module Submissions
DEFAULT_SUBMITTERS_ORDER = 'random'
PRELOAD_ALL_PAGES_AMOUNT = 200
module_function
def search(submissions, keyword)
@@ -27,6 +29,25 @@ module Submissions
submission.save!
end
def preload_with_pages(submission)
ActiveRecord::Associations::Preloader.new(
records: [submission],
associations: [:template, { template_schema_documents: :blob }]
).call
total_pages =
submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: submission.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
submission
end
def create_from_emails(template:, user:, emails:, source:, mark_as_sent: false, params: {})
preferences = Submitters.normalize_preferences(user.account, user, params)
+16 -9
View File
@@ -37,6 +37,7 @@ module Submissions
account = submitter.submission.template.account
pkcs = Accounts.load_signing_pkcs(account)
tsa_url = Accounts.load_timeserver_url(account)
attachments_data_cache = {}
pdfs_index = build_pdfs_index(submitter)
@@ -78,7 +79,9 @@ module Submissions
when 'image', 'signature', 'initials', 'stamp'
attachment = submitter.attachments.find { |a| a.uuid == value }
image = Vips::Image.new_from_buffer(attachment.download, '').autorot
attachments_data_cache[attachment.uuid] ||= attachment.download
image = Vips::Image.new_from_buffer(attachments_data_cache[attachment.uuid], '').autorot
scale = [(area['w'] * width) / image.width,
(area['h'] * height) / image.height].min
@@ -268,9 +271,10 @@ module Submissions
end
ActiveStorage::Attachment.create!(
uuid:,
blob: ActiveStorage::Blob.create_and_upload!(io: StringIO.new(io.string), filename: "#{name}.pdf"),
metadata: { sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) },
metadata: { original_uuid: uuid,
analyzed: true,
sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) },
name: 'documents',
record: submitter
)
@@ -281,11 +285,7 @@ module Submissions
end
def build_pdfs_index(submitter)
latest_submitter =
submitter.submission.submitters
.select(&:completed_at?)
.select { |e| e.id != submitter.id && e.completed_at <= submitter.completed_at }
.max_by(&:completed_at)
latest_submitter = find_last_submitter(submitter)
Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter
@@ -306,10 +306,17 @@ module Submissions
Rollbar.error(e) if defined?(Rollbar)
end
[attachment.uuid, pdf]
[attachment.metadata['original_uuid'] || attachment.uuid, pdf]
end
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)
end
def build_pdf_from_image(attachment)
pdf = HexaPDF::Document.new
page = pdf.pages.add
+2 -1
View File
@@ -24,7 +24,8 @@ module Submitters
is_more_than_two_images = original_documents.count(&:image?) > 1
submitter.documents.preload(:blob).reject do |attachment|
is_more_than_two_images && original_documents.find { |a| a.uuid == attachment.uuid }&.image?
is_more_than_two_images &&
original_documents.find { |a| a.uuid == (attachment.metadata['original_uuid'] || attachment.uuid) }&.image?
end
end
+3 -1
View File
@@ -33,7 +33,7 @@ module Submitters
field = fields_uuid_index[key]
if field['type'].in?(%w[initials signature image file]) && value.present?
if field['type'].in?(%w[initials signature image file stamp]) && value.present?
new_value, new_attachments =
normalize_attachment_value(value, field['type'], template.account, attachments, for_submitter)
@@ -51,6 +51,8 @@ module Submitters
def normalize_value(field, value)
if field['type'] == 'text' && value.present?
value.to_s
elsif field['type'] == 'number' && value.present?
(value.to_f % 1).zero? ? value.to_i : value.to_f
elsif field['type'] == 'date' && value.present?
if value.is_a?(Integer)
Time.zone.at(value.to_s.first(10).to_i).to_date
+21
View File
@@ -37,6 +37,7 @@ module Submitters
submitter.ip = request.remote_ip
submitter.ua = request.user_agent
submitter.values = merge_default_values(submitter)
submitter.values = merge_formula_values(submitter)
end
ApplicationRecord.transaction do
@@ -54,6 +55,8 @@ module Submitters
params.fetch(:values, {}).to_unsafe_h.transform_values do |v|
if params[:cast_boolean] == 'true'
v == 'true'
elsif params[:cast_number] == 'true'
(v.to_f % 1).zero? ? v.to_i : v.to_f
elsif params[:normalize_phone] == 'true'
v.to_s.gsub(/[^0-9+]/, '')
else
@@ -90,6 +93,24 @@ module Submitters
default_values.compact_blank.merge(submitter.values)
end
def merge_formula_values(submitter)
computed_values = submitter.submission.template_fields.each_with_object({}) do |field, acc|
next if field['submitter_uuid'] != submitter.uuid
formula = field.dig('preferences', 'formula')
next if formula.blank?
acc[field['uuid']] = calculate_formula_value(formula, submitter.values.merge(acc.compact_blank))
end
submitter.values.merge(computed_values.compact_blank)
end
def calculate_formula_value(_formula, _values)
0
end
def template_default_value_for_submitter(value, submitter, with_time: false)
return if value.blank?
return if submitter.blank?
+33
View File
@@ -0,0 +1,33 @@
# frozen_string_literal: true
module Templates
module SerializeForApi
module_function
def call(template, schema_documents = template.schema_documents, preview_image_attachments = [])
json = template.as_json(
methods: %i[application_key],
include: { author: { only: %i[id email first_name last_name] } }
)
json[:folder_name] = template.folder.name
json[:documents] = template.schema.map do |item|
attachment = schema_documents.find { |e| e.uuid == item['attachment_uuid'] }
first_page_blob = preview_image_attachments.find { |e| e.record_id == attachment.id }&.blob
first_page_blob ||= attachment.preview_images.joins(:blob).find_by(blob: { filename: '0.jpg' })&.blob
{
id: attachment.id,
uuid: attachment.uuid,
url: ActiveStorage::Blob.proxy_url(attachment.blob),
preview_image_url: first_page_blob && ActiveStorage::Blob.proxy_url(first_page_blob),
filename: attachment.filename
}
end
json
end
end
end
+1
View File
@@ -21,6 +21,7 @@
"css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^5.0.0",
"daisyui": "^3.2.1",
"mathjs": "^12.4.0",
"mini-css-extract-plugin": "^2.7.5",
"postcss": "^8.4.23",
"postcss-import": "^15.1.0",
+67
View File
@@ -904,6 +904,13 @@
dependencies:
regenerator-runtime "^0.13.11"
"@babel/runtime@^7.23.9":
version "7.23.9"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7"
integrity sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/template@^7.18.10", "@babel/template@^7.20.7":
version "7.20.7"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8"
@@ -2062,6 +2069,11 @@ commondir@^1.0.1:
resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==
complex.js@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/complex.js/-/complex.js-2.1.1.tgz#0675dac8e464ec431fb2ab7d30f41d889fb25c31"
integrity sha512-8njCHOTtFFLtegk6zQo0kkVX1rngygb/KQI6z1qZxlFI3scluC+LVTCFbrkWjBv4vvLlbQ9t88IPMC6k95VTTg==
compressible@~2.0.16:
version "2.0.18"
resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
@@ -2337,6 +2349,11 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4:
dependencies:
ms "2.1.2"
decimal.js@^10.4.3:
version "10.4.3"
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23"
integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==
deep-is@^0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
@@ -2578,6 +2595,11 @@ escape-html@~1.0.3:
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
escape-latex@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/escape-latex/-/escape-latex-1.2.0.tgz#07c03818cf7dac250cce517f4fda1b001ef2bca1"
integrity sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==
escape-string-regexp@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
@@ -3002,6 +3024,11 @@ forwarded@0.2.0:
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
fraction.js@4.3.4:
version "4.3.4"
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.4.tgz#b2bac8249a610c3396106da97c5a71da75b94b1c"
integrity sha512-pwiTgt0Q7t+GHZA4yaLjObx4vXmmdcS0iSJ19o8d/goUGgItX9UZWKWNnLHehxviD8wU2IWRsnR8cD5+yOJP2Q==
fraction.js@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950"
@@ -3553,6 +3580,11 @@ isobject@^3.0.1:
resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==
javascript-natural-sort@^0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz#f9e2303d4507f6d74355a73664d1440fb5a0ef59"
integrity sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==
jest-util@^29.5.0:
version "29.5.0"
resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.5.0.tgz#24a4d3d92fc39ce90425311b23c27a6e0ef16b8f"
@@ -3768,6 +3800,21 @@ make-dir@^3.0.2:
dependencies:
semver "^6.0.0"
mathjs@^12.4.0:
version "12.4.0"
resolved "https://registry.yarnpkg.com/mathjs/-/mathjs-12.4.0.tgz#875c2ec19e5be69885b29769f78bbb37220322b6"
integrity sha512-4Moy0RNjwMSajEkGGxNUyMMC/CZAcl87WBopvNsJWB4E4EFebpTedr+0/rhqmnOSTH3Wu/3WfiWiw6mqiaHxVw==
dependencies:
"@babel/runtime" "^7.23.9"
complex.js "^2.1.1"
decimal.js "^10.4.3"
escape-latex "^1.2.0"
fraction.js "4.3.4"
javascript-natural-sort "^0.7.1"
seedrandom "^3.0.5"
tiny-emitter "^2.1.0"
typed-function "^4.1.1"
mdn-data@2.0.28:
version "2.0.28"
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.28.tgz#5ec48e7bef120654539069e1ae4ddc81ca490eba"
@@ -4589,6 +4636,11 @@ regenerator-runtime@^0.13.11:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
regenerator-runtime@^0.14.0:
version "0.14.1"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f"
integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==
regenerator-transform@^0.15.1:
version "0.15.1"
resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.1.tgz#f6c4e99fc1b4591f780db2586328e4d9a9d8dc56"
@@ -4749,6 +4801,11 @@ schema-utils@^4.0.0:
ajv-formats "^2.1.1"
ajv-keywords "^5.1.0"
seedrandom@^3.0.5:
version "3.0.5"
resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-3.0.5.tgz#54edc85c95222525b0c7a6f6b3543d8e0b3aa0a7"
integrity sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==
select-hose@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
@@ -5170,6 +5227,11 @@ thunky@^1.0.2:
resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d"
integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==
tiny-emitter@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423"
integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==
to-fast-properties@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
@@ -5236,6 +5298,11 @@ typed-array-length@^1.0.4:
for-each "^0.3.3"
is-typed-array "^1.1.9"
typed-function@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/typed-function/-/typed-function-4.1.1.tgz#38ce3cae31f4f513bcb263563fdad27b2afa73e8"
integrity sha512-Pq1DVubcvibmm8bYcMowjVnnMwPVMeh0DIdA8ad8NZY2sJgapANJmiigSUwlt+EgXxpfIv8MWrQXTIzkfYZLYQ==
unbox-primitive@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"