mirror of
https://github.com/docusealco/docuseal.git
synced 2026-08-07 15:25:16 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22d4c1bd10 | |||
| e3dfb0ae83 | |||
| 8d7c3f0e26 | |||
| b11775ebda | |||
| 608191183a | |||
| 2ba903e170 | |||
| 342b64d502 | |||
| 1a8f048aaa | |||
| 7309581b75 | |||
| 36d59c40a7 | |||
| ed3c7c324b | |||
| fd868360a3 | |||
| 8dc2ebfbe0 | |||
| 997711b34a | |||
| ac01b7129d | |||
| f4b7528c0d | |||
| d7b9ae5df5 | |||
| 4db29126e9 | |||
| 4cfee2fe45 | |||
| db5dbd6ac2 | |||
| 44db775b89 | |||
| 8e2e780a1a | |||
| 7aba2eb4da | |||
| 3e250bfb28 | |||
| 9c02ed66f5 | |||
| f5ae8c60d3 | |||
| e90ac391b2 | |||
| 0e5f949bcf | |||
| b7c270bef8 | |||
| 5f16b506f5 | |||
| 176148fc71 |
@@ -4,6 +4,7 @@ source 'https://rubygems.org'
|
||||
|
||||
ruby '4.0.1'
|
||||
|
||||
gem 'addressable'
|
||||
gem 'arabic-letter-connector', require: false
|
||||
gem 'aws-sdk-s3', require: false
|
||||
gem 'aws-sdk-secretsmanager', require: false
|
||||
@@ -28,7 +29,6 @@ gem 'oj'
|
||||
gem 'onnxruntime', require: false
|
||||
gem 'pagy'
|
||||
gem 'pg', require: false
|
||||
gem 'premailer-rails'
|
||||
gem 'pretender'
|
||||
gem 'puma', require: false
|
||||
gem 'rack'
|
||||
@@ -43,7 +43,7 @@ gem 'shakapacker'
|
||||
gem 'sidekiq'
|
||||
gem 'sqlite3', require: false
|
||||
gem 'strip_attributes'
|
||||
gem 'trilogy', github: 'trilogy-libraries/trilogy', glob: 'contrib/ruby/*.gemspec', require: false
|
||||
gem 'trilogy', require: false
|
||||
gem 'turbo-rails'
|
||||
gem 'twitter_cldr', require: false
|
||||
gem 'tzinfo-data'
|
||||
|
||||
+29
-44
@@ -1,11 +1,3 @@
|
||||
GIT
|
||||
remote: https://github.com/trilogy-libraries/trilogy.git
|
||||
revision: 3963d490459df7a2b5bedb42424c3285f25eab22
|
||||
glob: contrib/ruby/*.gemspec
|
||||
specs:
|
||||
trilogy (2.10.0)
|
||||
bigdecimal
|
||||
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
@@ -116,7 +108,7 @@ GEM
|
||||
cgi
|
||||
rexml
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.21)
|
||||
bcrypt (3.1.22)
|
||||
better_html (2.2.0)
|
||||
actionview (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
@@ -158,8 +150,6 @@ GEM
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
css_parser (1.21.1)
|
||||
addressable
|
||||
csv (3.3.5)
|
||||
csv-safe (3.3.1)
|
||||
csv (~> 3.0)
|
||||
@@ -171,16 +161,16 @@ GEM
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
declarative (0.0.20)
|
||||
devise (4.9.4)
|
||||
devise (5.0.3)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 4.1.0)
|
||||
railties (>= 7.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-two-factor (6.3.1)
|
||||
activesupport (>= 7.0, < 8.2)
|
||||
devise (>= 4.0, < 5.0)
|
||||
railties (>= 7.0, < 8.2)
|
||||
devise-two-factor (6.4.0)
|
||||
activesupport (>= 7.2, < 8.2)
|
||||
devise (>= 4.0, < 6.0)
|
||||
railties (>= 7.2, < 8.2)
|
||||
rotp (~> 6.0)
|
||||
diff-lcs (1.6.2)
|
||||
digest-crc (0.7.0)
|
||||
@@ -189,7 +179,7 @@ GEM
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
email_typo (0.2.3)
|
||||
erb (6.0.1)
|
||||
erb (6.0.2)
|
||||
erb_lint (0.9.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
@@ -272,19 +262,19 @@ GEM
|
||||
geom2d (~> 0.4, >= 0.4.1)
|
||||
openssl (>= 2.2.1)
|
||||
strscan (>= 3.1.2)
|
||||
htmlentities (4.4.2)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.14.0)
|
||||
mini_magick (>= 4.9.5, < 6)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
io-console (0.8.2)
|
||||
irb (1.16.0)
|
||||
irb (1.17.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.18.1)
|
||||
json (2.19.2)
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
language_server-protocol (3.17.0.5)
|
||||
@@ -306,7 +296,7 @@ GEM
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.25.0)
|
||||
loofah (2.25.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.9.0)
|
||||
@@ -321,7 +311,8 @@ GEM
|
||||
mini_magick (5.3.1)
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (6.0.1)
|
||||
minitest (6.0.2)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.19.1)
|
||||
@@ -337,17 +328,17 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.1-aarch64-linux-gnu)
|
||||
nokogiri (1.19.2-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-aarch64-linux-musl)
|
||||
nokogiri (1.19.2-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-arm64-darwin)
|
||||
nokogiri (1.19.2-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-x86_64-linux-gnu)
|
||||
nokogiri (1.19.2-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-x86_64-linux-musl)
|
||||
nokogiri (1.19.2-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
numo-narray-alt (0.9.13)
|
||||
numo-narray-alt (0.10.3)
|
||||
oj (3.16.13)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
@@ -376,18 +367,10 @@ GEM
|
||||
pg (1.6.3-x86_64-linux-musl)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
premailer (1.27.0)
|
||||
addressable
|
||||
css_parser (>= 1.19.0)
|
||||
htmlentities (>= 4.0.0)
|
||||
premailer-rails (1.12.0)
|
||||
actionmailer (>= 3)
|
||||
net-smtp
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
pretender (0.6.0)
|
||||
actionpack (>= 7.1)
|
||||
prettyprint (0.2.0)
|
||||
prism (1.8.0)
|
||||
prism (1.9.0)
|
||||
pry (0.16.0)
|
||||
coderay (~> 1.1)
|
||||
method_source (~> 1.0)
|
||||
@@ -429,8 +412,8 @@ GEM
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
rails-html-sanitizer (1.7.0)
|
||||
loofah (~> 2.25)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (8.1.0)
|
||||
i18n (>= 0.7, < 2)
|
||||
@@ -446,7 +429,7 @@ GEM
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
rdoc (7.1.0)
|
||||
rdoc (7.2.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
@@ -562,6 +545,8 @@ GEM
|
||||
thor (1.5.0)
|
||||
timeout (0.6.0)
|
||||
trailblazer-option (0.1.2)
|
||||
trilogy (2.10.0)
|
||||
bigdecimal
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.21)
|
||||
actionpack (>= 7.1.0)
|
||||
@@ -601,7 +586,7 @@ GEM
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
yaml (0.4.0)
|
||||
zeitwerk (2.7.4)
|
||||
zeitwerk (2.7.5)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -611,6 +596,7 @@ PLATFORMS
|
||||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
addressable
|
||||
annotaterb
|
||||
arabic-letter-connector
|
||||
aws-sdk-s3
|
||||
@@ -647,7 +633,6 @@ DEPENDENCIES
|
||||
onnxruntime
|
||||
pagy
|
||||
pg
|
||||
premailer-rails
|
||||
pretender
|
||||
pry-rails
|
||||
puma
|
||||
@@ -669,7 +654,7 @@ DEPENDENCIES
|
||||
simplecov
|
||||
sqlite3
|
||||
strip_attributes
|
||||
trilogy!
|
||||
trilogy
|
||||
turbo-rails
|
||||
twitter_cldr
|
||||
tzinfo-data
|
||||
|
||||
@@ -64,7 +64,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
|
||||
|Heroku|Railway|
|
||||
|:--:|:---:|
|
||||
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|
||||
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.com/deploy/IGoDnc?referralCode=ruU7JR)|
|
||||
|**DigitalOcean**|**Render**|
|
||||
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ module Api
|
||||
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
if @submitter.declined_at?
|
||||
return render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
submission = @submitter.submission
|
||||
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ module Api
|
||||
SearchEntries.enqueue_reindex(@template)
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.updated')
|
||||
WebhookUrls.enqueue_events(@template, 'template.archived') if archived == true
|
||||
|
||||
render json: @template.as_json(only: %i[id updated_at])
|
||||
end
|
||||
@@ -78,6 +79,8 @@ module Api
|
||||
@template.destroy!
|
||||
else
|
||||
@template.update!(archived_at: Time.current)
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.archived')
|
||||
end
|
||||
|
||||
render json: @template.as_json(only: %i[id archived_at])
|
||||
|
||||
@@ -84,6 +84,8 @@ class TemplatesController < ApplicationController
|
||||
else
|
||||
@template.update!(archived_at: Time.current)
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.archived')
|
||||
|
||||
I18n.t('template_has_been_archived')
|
||||
end
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ class TemplatesRestoreController < ApplicationController
|
||||
def create
|
||||
@template.update!(archived_at: nil)
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.updated')
|
||||
|
||||
redirect_to template_path(@template), notice: I18n.t('template_has_been_unarchived')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@ class WebhookEventsController < ApplicationController
|
||||
Submissions::SerializeForApi.call(@webhook_event.record)
|
||||
when 'template.created', 'template.updated'
|
||||
Templates::SerializeForApi.call(@webhook_event.record)
|
||||
when 'submission.archived'
|
||||
when 'submission.archived', 'template.archived'
|
||||
@webhook_event.record.as_json(only: %i[id archived_at])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -359,7 +359,9 @@ export default {
|
||||
},
|
||||
initTextInitial () {
|
||||
if (this.submitter.name) {
|
||||
this.$refs.textInput.value = this.submitter.name.trim().split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase() || '').join('')
|
||||
const parts = this.submitter.name.trim().split(/\s+/)
|
||||
|
||||
this.$refs.textInput.value = (parts.length > 1 ? [parts[0], parts[parts.length - 1]] : parts).map((part) => part[0]?.toUpperCase() || '').join('')
|
||||
}
|
||||
|
||||
if (this.$refs.textInput.value) {
|
||||
|
||||
@@ -364,7 +364,14 @@
|
||||
:document="dynamicDocuments.find((dynamicDocument) => dynamicDocument.uuid === document.uuid)"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
:drag-field="dragField"
|
||||
:draw-field="drawField"
|
||||
:draw-field-type="drawFieldType"
|
||||
:draw-custom-field="drawCustomField"
|
||||
:draw-option="drawOption"
|
||||
@update="onDynamicDocumentUpdate"
|
||||
@draw="clearDrawField"
|
||||
@add-custom-field="addCustomField"
|
||||
@set-draw="[drawField = $event.field, drawOption = $event.option]"
|
||||
/>
|
||||
<Document
|
||||
v-else
|
||||
@@ -509,6 +516,8 @@
|
||||
:show-tour-start-form="showTourStartForm"
|
||||
@add-field="addField"
|
||||
@set-draw="[drawField = $event.field, drawOption = $event.option]"
|
||||
@remove-field="onRemoveField"
|
||||
@remove-submitter="onRemoveSubmitter"
|
||||
@select-submitter="selectedSubmitter = $event"
|
||||
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
|
||||
@set-draw-custom-field="[drawCustomField = $event, showDrawField = true]"
|
||||
@@ -1155,6 +1164,32 @@ export default {
|
||||
this.drawCustomField = null
|
||||
this.showDrawField = false
|
||||
},
|
||||
onRemoveField (field) {
|
||||
if (this.dynamicDocuments.length) {
|
||||
field.areas?.forEach((area) => {
|
||||
this.documentRefs.forEach((documentRef) => {
|
||||
if (documentRef.isDynamic && documentRef.document.uuid === area.attachment_uuid) {
|
||||
documentRef.removeArea(area)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
onRemoveSubmitter (submitter) {
|
||||
if (this.dynamicDocuments.length) {
|
||||
this.template.fields.forEach((field) => {
|
||||
if (field.submitter_uuid === submitter.uuid) {
|
||||
field.areas?.forEach((area) => {
|
||||
this.documentRefs.forEach((documentRef) => {
|
||||
if (documentRef.isDynamic && documentRef.document.uuid === area.attachment_uuid) {
|
||||
documentRef.removeArea(area)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
toggleSelectMode () {
|
||||
this.isSelectModeRef.value = !this.isSelectModeRef.value
|
||||
|
||||
@@ -2512,60 +2547,80 @@ export default {
|
||||
|
||||
this.save()
|
||||
},
|
||||
removeAreasByAttachmentUuid (attachmentUuid) {
|
||||
const removedFieldUuids = []
|
||||
|
||||
this.selectedAreasRef.value = this.selectedAreasRef.value.filter((area) => area.attachment_uuid !== attachmentUuid)
|
||||
|
||||
this.template.fields.forEach((field) => {
|
||||
[...(field.areas || [])].forEach((area) => {
|
||||
if (area.attachment_uuid === attachmentUuid) {
|
||||
field.areas.splice(field.areas.indexOf(area), 1)
|
||||
|
||||
removedFieldUuids.push(field.uuid)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.template.fields = this.template.fields.reduce((acc, field) => {
|
||||
if (removedFieldUuids.includes(field.uuid) && !field.areas?.length) {
|
||||
this.removeFieldConditions(field)
|
||||
} else {
|
||||
acc.push(field)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
},
|
||||
onDocumentRemove (item) {
|
||||
if (window.confirm(this.t('are_you_sure_'))) {
|
||||
this.template.schema.splice(this.template.schema.indexOf(item), 1)
|
||||
|
||||
const removedFieldUuids = []
|
||||
|
||||
this.template.fields.forEach((field) => {
|
||||
[...(field.areas || [])].forEach((area) => {
|
||||
if (area.attachment_uuid === item.attachment_uuid) {
|
||||
field.areas.splice(field.areas.indexOf(area), 1)
|
||||
|
||||
removedFieldUuids.push(field.uuid)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.template.fields = this.template.fields.reduce((acc, f) => {
|
||||
if (removedFieldUuids.includes(f.uuid) && !f.areas?.length) {
|
||||
this.removeFieldConditions(f)
|
||||
} else {
|
||||
acc.push(f)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
this.removeAreasByAttachmentUuid(item.attachment_uuid)
|
||||
|
||||
this.save()
|
||||
}
|
||||
},
|
||||
onDocumentReplace (data) {
|
||||
const { replaceSchemaItem, schema, documents } = data
|
||||
const isReplacingDynamicDocument = !!replaceSchemaItem.dynamic
|
||||
// eslint-disable-next-line camelcase
|
||||
const { google_drive_file_id, dynamic, ...cleanedReplaceSchemaItem } = replaceSchemaItem
|
||||
|
||||
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...cleanedReplaceSchemaItem, ...schema[0] })
|
||||
this.template.documents.push(...documents)
|
||||
|
||||
if (isReplacingDynamicDocument) {
|
||||
this.removeAreasByAttachmentUuid(replaceSchemaItem.attachment_uuid)
|
||||
|
||||
const dynamicDocumentIndex = this.dynamicDocuments.findIndex((doc) => doc.uuid === replaceSchemaItem.attachment_uuid)
|
||||
|
||||
if (dynamicDocumentIndex !== -1) {
|
||||
this.dynamicDocuments.splice(dynamicDocumentIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.fields) {
|
||||
this.template.fields = data.fields
|
||||
|
||||
const removedFieldUuids = []
|
||||
if (isReplacingDynamicDocument) {
|
||||
this.removeAreasByAttachmentUuid(replaceSchemaItem.attachment_uuid)
|
||||
} else {
|
||||
const removedFieldUuids = []
|
||||
|
||||
this.template.fields.forEach((field) => {
|
||||
[...(field.areas || [])].forEach((area) => {
|
||||
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
|
||||
field.areas.splice(field.areas.indexOf(area), 1)
|
||||
this.template.fields.forEach((field) => {
|
||||
[...(field.areas || [])].forEach((area) => {
|
||||
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
|
||||
field.areas.splice(field.areas.indexOf(area), 1)
|
||||
|
||||
removedFieldUuids.push(field.uuid)
|
||||
}
|
||||
removedFieldUuids.push(field.uuid)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
this.template.fields =
|
||||
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
|
||||
this.template.fields =
|
||||
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.submitters) {
|
||||
@@ -2576,13 +2631,15 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
this.template.fields.forEach((field) => {
|
||||
(field.areas || []).forEach((area) => {
|
||||
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
|
||||
area.attachment_uuid = schema[0].attachment_uuid
|
||||
}
|
||||
if (!isReplacingDynamicDocument) {
|
||||
this.template.fields.forEach((field) => {
|
||||
(field.areas || []).forEach((area) => {
|
||||
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
|
||||
area.attachment_uuid = schema[0].attachment_uuid
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (this.onUpload) {
|
||||
this.onUpload(this.template)
|
||||
@@ -2678,10 +2735,13 @@ export default {
|
||||
} else {
|
||||
this.isSaving = true
|
||||
|
||||
this.documentRefs.filter((ref) => ref.update).map((ref) => ref.update())
|
||||
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
|
||||
|
||||
dynamicDocumentRefs.map((ref) => ref.update())
|
||||
|
||||
this.rebuildVariablesSchema({ disable: false })
|
||||
|
||||
const dynamicDocumentSaves = this.documentRefs.filter((ref) => ref.saveBody).map((ref) => ref.saveBody())
|
||||
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
|
||||
|
||||
Promise.all([this.save(), ...dynamicDocumentSaves]).then(() => {
|
||||
window.Turbo.visit(`/templates/${this.template.id}`)
|
||||
@@ -2694,9 +2754,13 @@ export default {
|
||||
scrollToArea (area) {
|
||||
const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid)
|
||||
|
||||
documentRef.scrollToArea(area)
|
||||
if (documentRef.isDynamic) {
|
||||
this.selectedAreasRef.value = []
|
||||
} else {
|
||||
this.selectedAreasRef.value = [area]
|
||||
}
|
||||
|
||||
this.selectedAreasRef.value = [area]
|
||||
documentRef.scrollToArea(area)
|
||||
},
|
||||
baseFetch (path, options = {}) {
|
||||
return fetch(this.baseUrl + path, {
|
||||
@@ -2942,92 +3006,34 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
this.reconcileDynamicFields()
|
||||
this.save()
|
||||
},
|
||||
rebuildVariablesSchema ({ disable = true } = {}) {
|
||||
const parsed = {}
|
||||
const dynamicDocumentRef = this.documentRefs.find((e) => e.isDynamic)
|
||||
|
||||
const dynamicDocumentRef = this.documentRefs.find((e) => e.mergeSchemaProperties)
|
||||
if (dynamicDocumentRef) {
|
||||
const parsed = {}
|
||||
|
||||
this.documentRefs.forEach((ref) => {
|
||||
if (ref.updateVariablesSchema) {
|
||||
ref.updateVariablesSchema()
|
||||
}
|
||||
})
|
||||
|
||||
this.dynamicDocuments.forEach((doc) => {
|
||||
if (doc.variables_schema) {
|
||||
dynamicDocumentRef.mergeSchemaProperties(parsed, doc.variables_schema)
|
||||
}
|
||||
})
|
||||
|
||||
if (!this.template.variables_schema) {
|
||||
this.template.variables_schema = parsed
|
||||
} else {
|
||||
this.syncVariablesSchema(this.template.variables_schema, parsed, { disable })
|
||||
}
|
||||
},
|
||||
syncVariablesSchema (existing, parsed, { disable = true } = {}) {
|
||||
for (const key of Object.keys(parsed)) {
|
||||
if (!existing[key]) {
|
||||
existing[key] = parsed[key]
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(existing)) {
|
||||
if (!parsed[key]) {
|
||||
if (disable) {
|
||||
existing[key].disabled = true
|
||||
} else {
|
||||
delete existing[key]
|
||||
this.documentRefs.forEach((ref) => {
|
||||
if (ref.isDynamic) {
|
||||
ref.updateVariablesSchema()
|
||||
}
|
||||
})
|
||||
|
||||
this.dynamicDocuments.forEach((doc) => {
|
||||
if (doc.variables_schema) {
|
||||
dynamicDocumentRef.mergeSchemaProperties(parsed, doc.variables_schema)
|
||||
}
|
||||
})
|
||||
|
||||
if (!this.template.variables_schema) {
|
||||
this.template.variables_schema = parsed
|
||||
} else {
|
||||
delete existing[key].disabled
|
||||
|
||||
if (!existing[key].form_type) {
|
||||
existing[key].type = parsed[key].type
|
||||
}
|
||||
|
||||
if (parsed[key].items) {
|
||||
if (!existing[key].items) {
|
||||
existing[key].items = parsed[key].items
|
||||
} else if (existing[key].items.properties && parsed[key].items.properties) {
|
||||
this.syncVariablesSchema(existing[key].items.properties, parsed[key].items.properties, { disable })
|
||||
} else if (!existing[key].items.properties && !parsed[key].items.properties) {
|
||||
existing[key].items.type = parsed[key].items.type
|
||||
}
|
||||
}
|
||||
|
||||
if (existing[key].properties && parsed[key].properties) {
|
||||
this.syncVariablesSchema(existing[key].properties, parsed[key].properties, { disable })
|
||||
}
|
||||
dynamicDocumentRef.syncVariablesSchema(this.template.variables_schema, parsed, { disable })
|
||||
}
|
||||
} else {
|
||||
this.template.variables_schema = {}
|
||||
}
|
||||
},
|
||||
reconcileDynamicFields () {
|
||||
const dynamicFieldUuids = new Set()
|
||||
|
||||
this.dynamicDocuments.forEach((doc) => {
|
||||
const body = doc.body || ''
|
||||
const uuidRegex = /uuid="([^"]+)"/g
|
||||
let match
|
||||
|
||||
while ((match = uuidRegex.exec(body)) !== null) {
|
||||
dynamicFieldUuids.add(match[1])
|
||||
}
|
||||
})
|
||||
|
||||
const toRemove = this.template.fields.filter((field) => {
|
||||
if (field.areas && field.areas.length > 0) return false
|
||||
|
||||
return field.uuid && !dynamicFieldUuids.has(field.uuid)
|
||||
})
|
||||
|
||||
toRemove.forEach((field) => {
|
||||
this.template.fields.splice(this.template.fields.indexOf(field), 1)
|
||||
})
|
||||
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
:draggable="editable"
|
||||
:style="[nodeStyle]"
|
||||
@mousedown="selectArea"
|
||||
@click.stop
|
||||
@dragstart="onDragStart"
|
||||
@contextmenu.prevent.stop="onContextMenu"
|
||||
>
|
||||
@@ -25,7 +24,14 @@
|
||||
<span
|
||||
v-if="field?.default_value"
|
||||
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 font-normal pl-0.5"
|
||||
>{{ field.default_value }}</span>
|
||||
>
|
||||
<template v-if="field.default_value === '{{date}}'">
|
||||
{{ t('signing_date') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ field.default_value }}
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="field && !iconOnlyField"
|
||||
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 opacity-70 font-normal pl-0.5"
|
||||
@@ -227,6 +233,7 @@ export default {
|
||||
this.onAreaDragStart()
|
||||
},
|
||||
onContextMenu (e) {
|
||||
this.selectArea()
|
||||
this.onAreaContextMenu(this.area, e)
|
||||
},
|
||||
onResizeStart (e) {
|
||||
|
||||
@@ -29,18 +29,28 @@
|
||||
:container="$refs.container"
|
||||
:editable="editable"
|
||||
:section="section"
|
||||
:section-refs="sectionRefs"
|
||||
:container-width="containerWidth"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
:drag-field="dragField"
|
||||
:draw-field="drawField"
|
||||
:draw-field-type="drawFieldType"
|
||||
:draw-custom-field="drawCustomField"
|
||||
:render-html-for-save-ref="renderHtmlForSaveRef"
|
||||
:draw-option="drawOption"
|
||||
:attachment-uuid="document.uuid"
|
||||
@update="onSectionUpdate(section, $event)"
|
||||
@draw="$emit('draw', $event)"
|
||||
@add-custom-field="$emit('add-custom-field', $event)"
|
||||
@set-draw="$emit('set-draw', $event)"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
import DynamicSection from './dynamic_section.vue'
|
||||
import { dynamicStylesheet, tiptapStylesheet } from './dynamic_editor.js'
|
||||
import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js'
|
||||
@@ -70,9 +80,29 @@ export default {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
drawField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
drawFieldType: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
drawCustomField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
drawOption: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update'],
|
||||
emits: ['update', 'draw', 'set-draw', 'add-custom-field'],
|
||||
data () {
|
||||
return {
|
||||
containerWidth: 1040,
|
||||
@@ -81,6 +111,10 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
renderHtmlForSaveRef: () => ref(false),
|
||||
isDynamic () {
|
||||
return true
|
||||
},
|
||||
attachmentsIndex () {
|
||||
return (this.document.attachments || []).reduce((acc, att) => {
|
||||
acc[att.uuid] = att.url
|
||||
@@ -135,6 +169,15 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
mergeSchemaProperties,
|
||||
removeArea (area) {
|
||||
this.sectionRefs.forEach((sectionRef) => {
|
||||
const pos = sectionRef.findAreaNodePos(area.uuid)
|
||||
|
||||
if (pos !== -1) {
|
||||
sectionRef.editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run()
|
||||
}
|
||||
})
|
||||
},
|
||||
setSectionRefs (ref) {
|
||||
if (ref) {
|
||||
this.sectionRefs.push(ref)
|
||||
@@ -150,11 +193,13 @@ export default {
|
||||
}
|
||||
},
|
||||
scrollToArea (area) {
|
||||
this.sectionRefs.forEach(({ editor }) => editor.commands.setNodeSelection(0))
|
||||
|
||||
this.sectionRefs.forEach(({ editor }) => {
|
||||
const el = editor.view.dom.querySelector(`[data-area-uuid="${area.uuid}"]`)
|
||||
|
||||
if (el) {
|
||||
editor.chain().focus().setNodeSelection(editor.view.posAtDOM(el, 0)).run()
|
||||
editor.commands.setNodeSelection(editor.view.posAtDOM(el, 0))
|
||||
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
@@ -176,7 +221,7 @@ export default {
|
||||
const target = this.bodyDom.getElementById(section.id)
|
||||
|
||||
if (target) {
|
||||
target.innerHTML = editor.getHTML()
|
||||
target.innerHTML = this.getHtmlForSave(editor)
|
||||
}
|
||||
|
||||
this.document.body = this.bodyDom.body.innerHTML
|
||||
@@ -200,7 +245,7 @@ export default {
|
||||
this.sectionRefs.forEach(({ section, editor }) => {
|
||||
const target = this.bodyDom.getElementById(section.id)
|
||||
|
||||
target.innerHTML = editor.getHTML()
|
||||
target.innerHTML = this.getHtmlForSave(editor)
|
||||
})
|
||||
|
||||
this.document.body = this.bodyDom.body.innerHTML
|
||||
@@ -209,6 +254,15 @@ export default {
|
||||
|
||||
this.$emit('update', this.document)
|
||||
},
|
||||
getHtmlForSave (editor) {
|
||||
this.renderHtmlForSaveRef.value = true
|
||||
|
||||
const result = editor.getHTML()
|
||||
|
||||
this.renderHtmlForSaveRef.value = false
|
||||
|
||||
return result
|
||||
},
|
||||
saveBody () {
|
||||
clearTimeout(this.saveTimer)
|
||||
|
||||
@@ -219,6 +273,43 @@ export default {
|
||||
body: JSON.stringify({ body: this.bodyDom.body.innerHTML }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
},
|
||||
syncVariablesSchema (existing, parsed, { disable = true } = {}) {
|
||||
for (const key of Object.keys(parsed)) {
|
||||
if (!existing[key]) {
|
||||
existing[key] = parsed[key]
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(existing)) {
|
||||
if (!parsed[key]) {
|
||||
if (disable) {
|
||||
existing[key].disabled = true
|
||||
} else {
|
||||
delete existing[key]
|
||||
}
|
||||
} else {
|
||||
delete existing[key].disabled
|
||||
|
||||
if (!existing[key].form_type) {
|
||||
existing[key].type = parsed[key].type
|
||||
}
|
||||
|
||||
if (parsed[key].items) {
|
||||
if (!existing[key].items) {
|
||||
existing[key].items = parsed[key].items
|
||||
} else if (existing[key].items.properties && parsed[key].items.properties) {
|
||||
this.syncVariablesSchema(existing[key].items.properties, parsed[key].items.properties, { disable })
|
||||
} else if (!existing[key].items.properties && !parsed[key].items.properties) {
|
||||
existing[key].items.type = parsed[key].items.type
|
||||
}
|
||||
}
|
||||
|
||||
if (existing[key].properties && parsed[key].properties) {
|
||||
this.syncVariablesSchema(existing[key].properties, parsed[key].properties, { disable })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ tiptapStylesheet.replaceSync(
|
||||
white-space: break-spaces;
|
||||
-webkit-font-variant-ligatures: none;
|
||||
font-variant-ligatures: none;
|
||||
font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */
|
||||
font-feature-settings: "liga" 0;
|
||||
}
|
||||
|
||||
.ProseMirror [contenteditable="false"] {
|
||||
@@ -89,7 +89,7 @@ img.ProseMirror-separator {
|
||||
.ProseMirror-focused .ProseMirror-gapcursor {
|
||||
display: block;
|
||||
}
|
||||
.variable-highlight {
|
||||
dynamic-variable {
|
||||
background-color: #fef3c7;
|
||||
}`)
|
||||
|
||||
@@ -533,7 +533,7 @@ function buildDecorations (doc) {
|
||||
const from = pos + match.index
|
||||
const to = from + match[0].length
|
||||
|
||||
decorations.push(Decoration.inline(from, to, { class: 'variable-highlight' }))
|
||||
decorations.push(Decoration.inline(from, to, { nodeName: 'dynamic-variable' }))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -584,7 +584,7 @@ const VariableHighlight = Extension.create({
|
||||
}
|
||||
})
|
||||
|
||||
export function buildEditor ({ dynamicAreaProps, attachmentsIndex, onFieldDrop, onFieldDestroy, editorOptions }) {
|
||||
export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlForSaveRef, onFieldDrop, onFieldDestroy, editorOptions }) {
|
||||
const FieldNode = Node.create({
|
||||
name: 'fieldNode',
|
||||
inline: true,
|
||||
@@ -617,11 +617,32 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, onFieldDrop,
|
||||
}]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['dynamic-field', {
|
||||
const attrs = {
|
||||
uuid: node.attrs.uuid,
|
||||
'area-uuid': node.attrs.areaUuid,
|
||||
style: `width: ${node.attrs.width}; height: ${node.attrs.height}; display: ${node.attrs.display}; vertical-align: ${node.attrs.verticalAlign};`
|
||||
}]
|
||||
}
|
||||
|
||||
if (!renderHtmlForSaveRef.value) {
|
||||
const fieldArea = dynamicAreaProps.findFieldArea(node.attrs.areaUuid)
|
||||
|
||||
if (fieldArea?.field && fieldArea?.area) {
|
||||
const field = JSON.parse(JSON.stringify(fieldArea.field))
|
||||
const area = JSON.parse(JSON.stringify(fieldArea.area))
|
||||
|
||||
delete field.areas
|
||||
delete field.uuid
|
||||
delete field.submitter_uuid
|
||||
delete area.uuid
|
||||
delete area.attachment_uuid
|
||||
|
||||
attrs['data-field'] = JSON.stringify(field)
|
||||
attrs['data-area'] = JSON.stringify(area)
|
||||
attrs['data-template-id'] = dynamicAreaProps.template.id
|
||||
}
|
||||
}
|
||||
|
||||
return ['dynamic-field', attrs]
|
||||
},
|
||||
addNodeView () {
|
||||
return ({ node, getPos, editor }) => {
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative bg-white select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute"
|
||||
:class="{ 'cursor-crosshair': isDrawMode && editable }"
|
||||
>
|
||||
<div
|
||||
v-if="isDrawMode && editable && cursorHighlightCoords"
|
||||
class="absolute pointer-events-none z-10 bg-black"
|
||||
:style="{ width: '1px', height: cursorHighlightCoords.height + 'px', left: cursorHighlightCoords.x + 'px', top: cursorHighlightCoords.y + 'px' }"
|
||||
/>
|
||||
<div :style="{ zoom: containerWidth / sectionWidthPx }">
|
||||
<section
|
||||
:id="section.id"
|
||||
ref="editorElement"
|
||||
dir="auto"
|
||||
:class="section.classList.value"
|
||||
:style="section.style.cssText"
|
||||
/>
|
||||
@@ -44,7 +51,10 @@
|
||||
:field="contextMenuField"
|
||||
:with-copy-to-all-pages="false"
|
||||
@close="closeContextMenu"
|
||||
@copy="onContextMenuCopy"
|
||||
@delete="onContextMenuDelete"
|
||||
@add-custom-field="$emit('add-custom-field', $event)"
|
||||
@set-draw="$emit('set-draw', $event)"
|
||||
@save="save"
|
||||
/>
|
||||
</Teleport>
|
||||
@@ -53,6 +63,7 @@
|
||||
|
||||
<script>
|
||||
import { shallowRef } from 'vue'
|
||||
import { DOMSerializer, Fragment } from '@tiptap/pm/model'
|
||||
import { v4 } from 'uuid'
|
||||
import FieldContextMenu from './field_context_menu.vue'
|
||||
import AreaTitle from './area_title.vue'
|
||||
@@ -72,6 +83,11 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
sectionRefs: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -100,19 +116,45 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
drawField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
drawFieldType: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
drawCustomField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
renderHtmlForSaveRef: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
drawOption: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
attachmentUuid: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update'],
|
||||
emits: ['update', 'draw', 'set-draw', 'add-custom-field'],
|
||||
data () {
|
||||
return {
|
||||
isAreaDrag: false,
|
||||
areaToolbarCoords: null,
|
||||
dynamicMenuCoords: null,
|
||||
contextMenu: null
|
||||
contextMenu: null,
|
||||
cursorHighlightCoords: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -140,6 +182,7 @@ export default {
|
||||
verification: { width: '150px', height: '80px' },
|
||||
image: { width: '200px', height: '100px' },
|
||||
date: { width: '100px', height: this.defaultHeight },
|
||||
datenow: { width: '100px', height: this.defaultHeight },
|
||||
text: { width: '120px', height: this.defaultHeight },
|
||||
cells: { width: '120px', height: this.defaultHeight },
|
||||
file: { width: '120px', height: this.defaultHeight },
|
||||
@@ -164,6 +207,9 @@ export default {
|
||||
isDraggingField () {
|
||||
return !!(this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField)
|
||||
},
|
||||
isDrawMode () {
|
||||
return !!(this.drawField || this.drawCustomField || this.drawFieldType)
|
||||
},
|
||||
selectedArea () {
|
||||
return this.selectedAreasRef.value[0]
|
||||
},
|
||||
@@ -192,45 +238,121 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.initEditor()
|
||||
this.editorRef.value = buildEditor({
|
||||
dynamicAreaProps: {
|
||||
template: this.template,
|
||||
t: this.t,
|
||||
selectedAreasRef: this.selectedAreasRef,
|
||||
getFieldTypeIndex: this.getFieldTypeIndex,
|
||||
findFieldArea: (areaUuid) => this.fieldAreaIndex[areaUuid],
|
||||
getZoom: () => this.zoom,
|
||||
onAreaContextMenu: this.onAreaContextMenu,
|
||||
onAreaResize: this.onAreaResize,
|
||||
onAreaDragStart: this.onAreaDragStart
|
||||
},
|
||||
attachmentsIndex: this.attachmentsIndex,
|
||||
onFieldDrop: this.onFieldDrop,
|
||||
onFieldDestroy: this.onFieldDestroy,
|
||||
renderHtmlForSaveRef: this.renderHtmlForSaveRef,
|
||||
editorOptions: {
|
||||
element: this.$refs.editorElement,
|
||||
editable: this.editable,
|
||||
content: this.section.innerHTML,
|
||||
onUpdate: (event) => this.$emit('update', event),
|
||||
onSelectionUpdate: this.onSelectionUpdate,
|
||||
onBlur: () => { this.dynamicMenuCoords = null }
|
||||
}
|
||||
})
|
||||
|
||||
this.editor.view.dom.addEventListener('paste', this.onEditorPaste, true)
|
||||
this.editor.view.dom.addEventListener('pointerdown', this.onEditorPointerDown, true)
|
||||
this.editor.view.dom.addEventListener('mousemove', this.onEditorMouseMove)
|
||||
this.editor.view.dom.addEventListener('mouseleave', this.onEditorMouseLeave)
|
||||
this.editor.view.dom.addEventListener('keydown', this.onEditorKeyDown)
|
||||
},
|
||||
beforeUnmount () {
|
||||
if (this.editor) {
|
||||
this.editor.view.dom.removeEventListener('paste', this.onEditorPaste, true)
|
||||
this.editor.view.dom.removeEventListener('pointerdown', this.onEditorPointerDown, true)
|
||||
this.editor.view.dom.removeEventListener('mousemove', this.onEditorMouseMove)
|
||||
this.editor.view.dom.removeEventListener('mouseleave', this.onEditorMouseLeave)
|
||||
this.editor.view.dom.removeEventListener('keydown', this.onEditorKeyDown)
|
||||
this.editor.destroy()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initEditor () {
|
||||
this.editorRef.value = buildEditor({
|
||||
dynamicAreaProps: {
|
||||
template: this.template,
|
||||
t: this.t,
|
||||
selectedAreasRef: this.selectedAreasRef,
|
||||
getFieldTypeIndex: this.getFieldTypeIndex,
|
||||
findFieldArea: (areaUuid) => this.fieldAreaIndex[areaUuid],
|
||||
getZoom: () => this.zoom,
|
||||
onAreaContextMenu: this.onAreaContextMenu,
|
||||
onAreaResize: this.onAreaResize,
|
||||
onAreaDragStart: this.onAreaDragStart
|
||||
},
|
||||
attachmentsIndex: this.attachmentsIndex,
|
||||
onFieldDrop: this.onFieldDrop,
|
||||
onFieldDestroy: this.onFieldDestroy,
|
||||
editorOptions: {
|
||||
element: this.$refs.editorElement,
|
||||
editable: this.editable,
|
||||
content: this.section.innerHTML,
|
||||
onUpdate: (event) => this.$emit('update', event),
|
||||
onSelectionUpdate: this.onSelectionUpdate,
|
||||
onBlur: () => { this.dynamicMenuCoords = null }
|
||||
}
|
||||
})
|
||||
},
|
||||
findAreaNodePos (areaUuid) {
|
||||
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
|
||||
|
||||
return this.editor.view.posAtDOM(el, 0)
|
||||
},
|
||||
getFieldInsertIndex (pos) {
|
||||
const view = this.editor.view
|
||||
const fields = this.template.fields || []
|
||||
|
||||
if (!fields.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let previousField = null
|
||||
|
||||
view.state.doc.nodesBetween(0, pos, (node, nodePos) => {
|
||||
if (node.type.name !== 'fieldNode') {
|
||||
return
|
||||
}
|
||||
|
||||
if (nodePos + node.nodeSize <= pos) {
|
||||
previousField = this.fieldAreaIndex[node.attrs.areaUuid]?.field || previousField
|
||||
}
|
||||
})
|
||||
|
||||
if (!previousField) {
|
||||
previousField = this.getPreviousSectionField()
|
||||
}
|
||||
|
||||
if (!previousField) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const previousFieldIndex = fields.indexOf(previousField)
|
||||
|
||||
return previousFieldIndex === -1 ? fields.length : previousFieldIndex + 1
|
||||
},
|
||||
insertFieldInTemplate (field, index) {
|
||||
const currentFieldIndex = this.template.fields.indexOf(field)
|
||||
|
||||
if (currentFieldIndex !== -1) {
|
||||
return currentFieldIndex
|
||||
}
|
||||
|
||||
this.template.fields.splice(index, 0, field)
|
||||
|
||||
return index
|
||||
},
|
||||
getLastFieldInSection (sectionRef) {
|
||||
let lastField = null
|
||||
|
||||
sectionRef.editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'fieldNode') {
|
||||
lastField = this.fieldAreaIndex[node.attrs.areaUuid]?.field || lastField
|
||||
}
|
||||
})
|
||||
|
||||
return lastField
|
||||
},
|
||||
getPreviousSectionField () {
|
||||
const sectionIndex = this.sectionRefs.indexOf(this)
|
||||
|
||||
for (let index = sectionIndex - 1; index >= 0; index -= 1) {
|
||||
const previousField = this.getLastFieldInSection(this.sectionRefs[index])
|
||||
|
||||
if (previousField) {
|
||||
return previousField
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
removeArea (area) {
|
||||
const { field } = this.fieldAreaIndex[area.uuid]
|
||||
const areaIndex = field.areas.indexOf(area)
|
||||
@@ -361,6 +483,220 @@ export default {
|
||||
closeContextMenu () {
|
||||
this.contextMenu = null
|
||||
},
|
||||
onEditorMouseLeave () {
|
||||
this.cursorHighlightCoords = null
|
||||
},
|
||||
onEditorKeyDown (event) {
|
||||
if (event.key === 'Escape') {
|
||||
this.editor.chain().setNodeSelection(0).blur().run()
|
||||
this.deselectArea()
|
||||
}
|
||||
},
|
||||
onEditorMouseMove (event) {
|
||||
if (!this.isDrawMode || !this.editable) {
|
||||
this.cursorHighlightCoords = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const view = this.editor?.view
|
||||
|
||||
if (!view) return
|
||||
|
||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
|
||||
|
||||
if (!pos) {
|
||||
this.cursorHighlightCoords = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const coords = view.coordsAtPos(pos.pos)
|
||||
const outerRect = this.$el.getBoundingClientRect()
|
||||
const lineHeight = coords.bottom - coords.top
|
||||
|
||||
this.cursorHighlightCoords = {
|
||||
x: coords.left - outerRect.left,
|
||||
y: coords.top - outerRect.top,
|
||||
height: lineHeight
|
||||
}
|
||||
},
|
||||
getFieldNode (areaUuid) {
|
||||
const pos = this.findAreaNodePos(areaUuid)
|
||||
|
||||
return this.editor.state.doc.nodeAt(pos)
|
||||
},
|
||||
serializeFieldNodeHtml (areaUuid) {
|
||||
const node = this.getFieldNode(areaUuid)
|
||||
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
|
||||
const serializer = DOMSerializer.fromSchema(this.editor.state.schema)
|
||||
const container = document.createElement('div')
|
||||
|
||||
container.appendChild(serializer.serializeFragment(Fragment.from(node)))
|
||||
|
||||
return container.innerHTML
|
||||
},
|
||||
async writeHtmlToClipboard ({ html, text }, clipboardData = null) {
|
||||
if (clipboardData) {
|
||||
clipboardData.setData('text/html', html)
|
||||
clipboardData.setData('text/plain', text || html)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.write && window.ClipboardItem) {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'text/html': new Blob([html], { type: 'text/html' }),
|
||||
'text/plain': new Blob([text || html], { type: 'text/plain' })
|
||||
})
|
||||
])
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text || html)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
async copyFieldAreaToClipboard (areaUuid, clipboardData = null) {
|
||||
const html = this.serializeFieldNodeHtml(areaUuid)
|
||||
|
||||
if (!html) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await this.writeHtmlToClipboard({ html, text: html }, clipboardData)
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Failed to copy dynamic field:', e)
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
async onContextMenuCopy () {
|
||||
await this.copyFieldAreaToClipboard(this.contextMenu.areaUuid)
|
||||
|
||||
this.closeContextMenu()
|
||||
},
|
||||
buildCopiedField (payload) {
|
||||
const field = JSON.parse(JSON.stringify(payload.field))
|
||||
const area = {
|
||||
...JSON.parse(JSON.stringify(payload.area)),
|
||||
uuid: v4(),
|
||||
attachment_uuid: this.attachmentUuid
|
||||
}
|
||||
|
||||
if (payload.templateId !== this.template.id) {
|
||||
delete field.conditions
|
||||
|
||||
if (field.preferences) {
|
||||
delete field.preferences.formula
|
||||
}
|
||||
}
|
||||
|
||||
const newField = {
|
||||
...field,
|
||||
uuid: v4(),
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
areas: [area]
|
||||
}
|
||||
|
||||
if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
|
||||
const oldOptionUuid = area.option_uuid
|
||||
const optionsMap = {}
|
||||
|
||||
newField.options = field.options.map((opt) => {
|
||||
const newUuid = v4()
|
||||
optionsMap[opt.uuid] = newUuid
|
||||
|
||||
return { ...opt, uuid: newUuid }
|
||||
})
|
||||
|
||||
area.option_uuid = optionsMap[oldOptionUuid] || newField.options[0]?.uuid
|
||||
}
|
||||
|
||||
return { field: newField, area }
|
||||
},
|
||||
onEditorPaste (event) {
|
||||
const clipboardData = event.clipboardData
|
||||
|
||||
if (!clipboardData) {
|
||||
return
|
||||
}
|
||||
|
||||
const html = clipboardData.getData('text/html')
|
||||
const text = clipboardData.getData('text/plain')
|
||||
const clipboardHtml = html || (text.includes('<dynamic-field') ? text : '')
|
||||
|
||||
if (!clipboardHtml || !clipboardHtml.includes('data-field=')) {
|
||||
return
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
|
||||
container.innerHTML = clipboardHtml
|
||||
|
||||
const fieldNodes = [...container.querySelectorAll('dynamic-field[data-field][data-area]')]
|
||||
|
||||
if (!fieldNodes.length) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const { selection } = this.editor.state
|
||||
const { from, to } = selection
|
||||
let insertIndex = this.getFieldInsertIndex(selection.node?.type.name === 'fieldNode' ? to : from)
|
||||
|
||||
let lastArea = null
|
||||
|
||||
fieldNodes.forEach((fieldNode) => {
|
||||
const fieldValue = fieldNode.dataset.field
|
||||
const areaValue = fieldNode.dataset.area
|
||||
const templateId = fieldNode.dataset.templateId
|
||||
|
||||
if (!fieldValue || !areaValue) {
|
||||
return
|
||||
}
|
||||
|
||||
const { field, area } = this.buildCopiedField({
|
||||
field: JSON.parse(fieldValue),
|
||||
area: JSON.parse(areaValue),
|
||||
templateId: Number(templateId)
|
||||
})
|
||||
|
||||
this.insertFieldInTemplate(field, insertIndex)
|
||||
insertIndex += 1
|
||||
|
||||
fieldNode.setAttribute('uuid', field.uuid)
|
||||
fieldNode.setAttribute('area-uuid', area.uuid)
|
||||
fieldNode.removeAttribute('data-field')
|
||||
fieldNode.removeAttribute('data-area')
|
||||
fieldNode.removeAttribute('data-template-id')
|
||||
|
||||
lastArea = area
|
||||
})
|
||||
|
||||
this.editor.chain().focus().insertContentAt({ from, to }, container.innerHTML).run()
|
||||
|
||||
if (lastArea) {
|
||||
this.editor.commands.setNodeSelection(this.findAreaNodePos(lastArea.uuid))
|
||||
}
|
||||
|
||||
this.closeContextMenu()
|
||||
this.save()
|
||||
},
|
||||
onContextMenuDelete () {
|
||||
const menu = this.contextMenu
|
||||
const fieldArea = this.fieldAreaIndex[menu.areaUuid]
|
||||
@@ -398,89 +734,167 @@ export default {
|
||||
|
||||
if (!pos) return false
|
||||
|
||||
const fieldType = draggedField.type || 'text'
|
||||
const dims = this.defaultSizes[fieldType] || this.defaultSizes.text
|
||||
const areaUuid = v4()
|
||||
|
||||
const existingField = this.fieldsDragFieldRef?.value
|
||||
|
||||
if (existingField) {
|
||||
if (!this.template.fields.includes(existingField)) {
|
||||
this.template.fields.push(existingField)
|
||||
}
|
||||
|
||||
existingField.areas = existingField.areas || []
|
||||
existingField.areas.push({ uuid: areaUuid, attachment_uuid: this.attachmentUuid })
|
||||
|
||||
const nodeType = view.state.schema.nodes.fieldNode
|
||||
const fieldNode = nodeType.create({
|
||||
uuid: existingField.uuid,
|
||||
areaUuid,
|
||||
width: dims.width,
|
||||
height: dims.height
|
||||
})
|
||||
|
||||
const tr = view.state.tr.insert(pos.pos, fieldNode)
|
||||
|
||||
view.dispatch(tr)
|
||||
} else {
|
||||
const newField = {
|
||||
name: draggedField.name || '',
|
||||
uuid: v4(),
|
||||
required: fieldType !== 'checkbox',
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
type: fieldType,
|
||||
areas: [{ uuid: areaUuid, attachment_uuid: this.attachmentUuid }]
|
||||
}
|
||||
|
||||
if (['select', 'multiple', 'radio'].includes(fieldType)) {
|
||||
if (draggedField.options?.length) {
|
||||
newField.options = draggedField.options.map((opt) => ({
|
||||
value: typeof opt === 'string' ? opt : opt.value,
|
||||
uuid: v4()
|
||||
}))
|
||||
} else {
|
||||
newField.options = [{ value: '', uuid: v4() }, { value: '', uuid: v4() }]
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldType === 'datenow') {
|
||||
newField.type = 'date'
|
||||
newField.readonly = true
|
||||
newField.default_value = '{{date}}'
|
||||
}
|
||||
|
||||
if (['stamp', 'heading', 'strikethrough'].includes(fieldType)) {
|
||||
newField.readonly = true
|
||||
|
||||
if (fieldType === 'strikethrough') {
|
||||
newField.default_value = true
|
||||
}
|
||||
}
|
||||
|
||||
this.template.fields.push(newField)
|
||||
|
||||
const nodeType = view.state.schema.nodes.fieldNode
|
||||
const fieldNode = nodeType.create({
|
||||
uuid: newField.uuid,
|
||||
areaUuid,
|
||||
width: dims.width,
|
||||
height: dims.height
|
||||
})
|
||||
|
||||
const tr = view.state.tr.insert(pos.pos, fieldNode)
|
||||
|
||||
view.dispatch(tr)
|
||||
}
|
||||
this.insertFieldAtRange({
|
||||
sourceField: draggedField,
|
||||
existingField: this.fieldsDragFieldRef?.value,
|
||||
from: pos.pos
|
||||
})
|
||||
|
||||
this.fieldsDragFieldRef.value = null
|
||||
this.customDragFieldRef.value = null
|
||||
|
||||
this.editor.chain().focus().setNodeSelection(pos.pos).run()
|
||||
return true
|
||||
},
|
||||
onEditorPointerDown (event) {
|
||||
if (!this.isDrawMode || !this.editable || this.isDraggingField) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.button === 2) {
|
||||
return
|
||||
}
|
||||
|
||||
const view = this.editor?.view
|
||||
|
||||
if (!view) {
|
||||
return
|
||||
}
|
||||
|
||||
const selection = view.state.selection
|
||||
const isTextRangeSelection = !selection.empty && !selection.node
|
||||
|
||||
let from = selection.from
|
||||
let to = selection.to
|
||||
|
||||
if (!isTextRangeSelection) {
|
||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
|
||||
|
||||
if (!pos) {
|
||||
return
|
||||
}
|
||||
|
||||
from = pos.pos
|
||||
to = pos.pos
|
||||
}
|
||||
|
||||
const sourceField = this.drawField || this.drawCustomField || { type: this.drawFieldType }
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (this.drawOption && this.drawField) {
|
||||
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
|
||||
|
||||
if (areaWithoutOption && !this.drawField.areas.find((a) => a.option_uuid === this.drawField.options[0].uuid)) {
|
||||
areaWithoutOption.option_uuid = this.drawField.options[0].uuid
|
||||
}
|
||||
}
|
||||
|
||||
const inserted = this.insertFieldAtRange({
|
||||
sourceField,
|
||||
existingField: this.drawField,
|
||||
optionUuid: this.drawOption?.uuid,
|
||||
from,
|
||||
to
|
||||
})
|
||||
|
||||
if (inserted) {
|
||||
this.$emit('draw', inserted)
|
||||
}
|
||||
},
|
||||
buildFieldArea ({ optionUuid = null } = {}) {
|
||||
const area = {
|
||||
uuid: v4(),
|
||||
attachment_uuid: this.attachmentUuid
|
||||
}
|
||||
|
||||
if (optionUuid) {
|
||||
area.option_uuid = optionUuid
|
||||
}
|
||||
|
||||
return area
|
||||
},
|
||||
buildNewField (sourceField, area) {
|
||||
const fieldType = sourceField?.type || 'text'
|
||||
const newField = {
|
||||
name: sourceField?.name || '',
|
||||
uuid: v4(),
|
||||
required: fieldType !== 'checkbox',
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
type: fieldType,
|
||||
areas: [area]
|
||||
}
|
||||
|
||||
if (['select', 'multiple', 'radio'].includes(fieldType)) {
|
||||
if (sourceField?.options?.length) {
|
||||
newField.options = sourceField.options.map((opt) => ({
|
||||
value: typeof opt === 'string' ? opt : opt.value,
|
||||
uuid: v4()
|
||||
}))
|
||||
} else {
|
||||
newField.options = [{ value: '', uuid: v4() }, { value: '', uuid: v4() }]
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldType === 'datenow') {
|
||||
newField.type = 'date'
|
||||
newField.readonly = true
|
||||
newField.default_value = '{{date}}'
|
||||
}
|
||||
|
||||
if (['stamp', 'heading', 'strikethrough'].includes(fieldType)) {
|
||||
newField.readonly = true
|
||||
|
||||
if (fieldType === 'strikethrough') {
|
||||
newField.default_value = true
|
||||
}
|
||||
}
|
||||
|
||||
return newField
|
||||
},
|
||||
insertFieldAtRange ({ sourceField, existingField = null, optionUuid = null, from, to = from }) {
|
||||
if (!sourceField) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fieldType = sourceField.type || existingField?.type || 'text'
|
||||
const dims = this.defaultSizes[fieldType]
|
||||
const area = this.buildFieldArea({ optionUuid })
|
||||
const insertIndex = this.getFieldInsertIndex(from)
|
||||
|
||||
let field = existingField
|
||||
const view = this.editor.view
|
||||
|
||||
if (field) {
|
||||
if (!this.template.fields.includes(field)) {
|
||||
this.insertFieldInTemplate(field, insertIndex)
|
||||
}
|
||||
|
||||
field.areas = field.areas || []
|
||||
field.areas.push(area)
|
||||
} else {
|
||||
field = this.buildNewField(sourceField, area)
|
||||
this.insertFieldInTemplate(field, insertIndex)
|
||||
}
|
||||
|
||||
const nodeType = view.state.schema.nodes.fieldNode
|
||||
const fieldNode = nodeType.create({
|
||||
uuid: field.uuid,
|
||||
areaUuid: area.uuid,
|
||||
width: dims.width,
|
||||
height: dims.height
|
||||
})
|
||||
|
||||
const tr = from !== to
|
||||
? view.state.tr.replaceWith(from, to, fieldNode)
|
||||
: view.state.tr.insert(from, fieldNode)
|
||||
|
||||
view.dispatch(tr)
|
||||
|
||||
this.editor.chain().focus().setNodeSelection(from).run()
|
||||
this.save()
|
||||
|
||||
return true
|
||||
return { area, field, pos: from }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,28 +22,30 @@
|
||||
class="border-base-300"
|
||||
>
|
||||
<label class="peer flex items-center py-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden peer"
|
||||
checked
|
||||
>
|
||||
<IconChevronDown
|
||||
class="hidden peer-checked:block"
|
||||
:width="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<IconChevronRight
|
||||
class="block peer-checked:hidden"
|
||||
:width="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<span class="w-5 flex justify-center items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden peer"
|
||||
checked
|
||||
>
|
||||
<IconChevronDown
|
||||
class="hidden peer-checked:block ml-0.5"
|
||||
:width="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<IconChevronRight
|
||||
class="block peer-checked:hidden ml-0.5"
|
||||
:width="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</span>
|
||||
<span class="ml-1">{{ key }}</span>
|
||||
<span
|
||||
v-if="node.type === 'array'"
|
||||
class="text-xs bg-base-200 rounded px-1 ml-1"
|
||||
>{{ t('list') }}</span>
|
||||
</label>
|
||||
<div class="hidden peer-has-[:checked]:block pl-3.5">
|
||||
<div class="hidden peer-has-[:checked]:block pl-5">
|
||||
<template
|
||||
v-for="[varNode, varPath] in nestedVariables(node, key)"
|
||||
:key="varPath"
|
||||
@@ -101,7 +103,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
isGroup (node) {
|
||||
return (node.type === 'object' && node.properties) || (node.type === 'array' && node.items?.properties)
|
||||
return (node.type === 'object' && node.properties) || (node.type === 'array' && node.items?.type === 'object' && node.items?.properties)
|
||||
},
|
||||
nestedVariables (node, groupKey) {
|
||||
const properties = node.type === 'array' ? node.items?.properties : node.properties
|
||||
|
||||
@@ -353,7 +353,26 @@ function extractConditionVariables (node, acc = []) {
|
||||
return acc
|
||||
}
|
||||
|
||||
const IRREGULAR_PLURALS = {
|
||||
people: 'person',
|
||||
men: 'man',
|
||||
women: 'woman',
|
||||
children: 'child',
|
||||
teeth: 'tooth',
|
||||
feet: 'foot',
|
||||
mice: 'mouse',
|
||||
geese: 'goose',
|
||||
oxen: 'ox',
|
||||
criteria: 'criterion',
|
||||
phenomena: 'phenomenon',
|
||||
alumni: 'alumnus',
|
||||
data: 'datum',
|
||||
media: 'medium'
|
||||
}
|
||||
|
||||
function singularize (word) {
|
||||
if (IRREGULAR_PLURALS[word]) return IRREGULAR_PLURALS[word]
|
||||
|
||||
if (word.endsWith('ies')) return word.slice(0, -3) + 'y'
|
||||
if (word.endsWith('ches') || word.endsWith('shes')) return word.slice(0, -2)
|
||||
if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes')) return word.slice(0, -2)
|
||||
@@ -513,6 +532,12 @@ function processOperators (operators, propertiesHash = {}, parentProperties = {}
|
||||
|
||||
assignNestedSchema(propertiesHash, parentProperties, op.variableName, { type: 'array', items: itemProperties })
|
||||
processOperators(op.children, propertiesHash, { ...parentProperties, [singularKey]: itemProperties })
|
||||
|
||||
if (itemProperties.type === 'object' && itemProperties.properties && Object.keys(itemProperties.properties).length === 0) {
|
||||
delete itemProperties.properties
|
||||
itemProperties.type = 'string'
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,7 +501,7 @@ export default {
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter', 'rebuild-variables-schema'],
|
||||
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter', 'rebuild-variables-schema', 'remove-field', 'remove-submitter'],
|
||||
data () {
|
||||
return {
|
||||
fieldPagesLoaded: null,
|
||||
@@ -805,6 +805,8 @@ export default {
|
||||
this.$emit('change-submitter', this.submitters[0])
|
||||
}
|
||||
|
||||
this.$emit('remove-submitter', submitter)
|
||||
|
||||
this.save()
|
||||
},
|
||||
removeField (field, save = true) {
|
||||
@@ -830,6 +832,8 @@ export default {
|
||||
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(area), 1)
|
||||
})
|
||||
|
||||
this.$emit('remove-field', field)
|
||||
|
||||
if (save) {
|
||||
this.save()
|
||||
}
|
||||
|
||||
@@ -274,13 +274,16 @@ export default {
|
||||
makeDynamic () {
|
||||
this.isMakeDynamicLoading = true
|
||||
|
||||
this.baseFetch(`/templates/${this.template.id}/dynamic_documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uuid: this.document.uuid }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(async (resp) => {
|
||||
Promise.all([
|
||||
this.baseFetch(`/templates/${this.template.id}/dynamic_documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uuid: this.document.uuid }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}),
|
||||
import(/* webpackChunkName: "dynamic-editor" */ './dynamic_document')
|
||||
]).then(async ([resp, _]) => {
|
||||
const dynamicDocument = await resp.json()
|
||||
|
||||
this.template.schema.find((item) => item.attachment_uuid === dynamicDocument.uuid).dynamic = true
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendTemplateArchivedWebhookRequestJob
|
||||
include Sidekiq::Job
|
||||
|
||||
sidekiq_options queue: :webhooks
|
||||
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(params = {})
|
||||
template = Template.find_by(id: params['template_id'])
|
||||
|
||||
return unless template
|
||||
|
||||
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
|
||||
|
||||
return unless webhook_url
|
||||
|
||||
attempt = params['attempt'].to_i
|
||||
|
||||
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.archived')
|
||||
|
||||
resp = SendWebhookRequest.call(webhook_url, event_type: 'template.archived',
|
||||
event_uuid: params['event_uuid'],
|
||||
record: template,
|
||||
attempt:,
|
||||
data: template.as_json(only: %i[id archived_at]))
|
||||
|
||||
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
|
||||
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
|
||||
SendTemplateArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
|
||||
**params,
|
||||
'attempt' => attempt + 1,
|
||||
'last_status' => resp&.status.to_i
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,8 @@ class ApplicationMailer < ActionMailer::Base
|
||||
layout 'mailer'
|
||||
|
||||
register_interceptor ActionMailerConfigsInterceptor
|
||||
register_interceptor HtmlToPlainTextInterceptor
|
||||
register_preview_interceptor HtmlToPlainTextInterceptor
|
||||
|
||||
register_observer ActionMailerEventsObserver
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ class Submission < ApplicationRecord
|
||||
|
||||
dynamic_count = template_schema&.count { |e| e['dynamic'] }.to_i
|
||||
|
||||
if template.variables_schema.blank?
|
||||
if variables_schema.blank?
|
||||
if dynamic_count > 0
|
||||
if dynamic_count == template_schema.size
|
||||
template_schema_dynamic_document_attachments
|
||||
|
||||
@@ -34,6 +34,7 @@ class WebhookUrl < ApplicationRecord
|
||||
submission.archived
|
||||
template.created
|
||||
template.updated
|
||||
template.archived
|
||||
].freeze
|
||||
|
||||
belongs_to :account
|
||||
|
||||
@@ -131,21 +131,23 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TO_DECLINE_KEY) %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('allow_to_decline_documents') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
<% if !Docuseal.multitenant? || can?(:manage, :disable_decline) %>
|
||||
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TO_DECLINE_KEY) %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('allow_to_decline_documents') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::FORM_PREFILL_SIGNATURE_KEY) %>
|
||||
|
||||
@@ -8,18 +8,20 @@
|
||||
<% variables_form = render 'variables_form', schema: @template.variables_schema if @template.variables_schema.present? && @template.variables_schema.any? { |_, v| !v['disabled'] } %>
|
||||
<%= render 'shared/turbo_modal_large', title: params[:selfsign] ? t('add_recipients') : t('add_new_recipients') do %>
|
||||
<% options = [only_detailed ? nil : [t('via_email'), 'email'], only_detailed ? nil : [t('via_phone'), 'phone'], [t('detailed'), 'detailed'], with_list ? [t('upload_list'), 'list'] : nil].compact %>
|
||||
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center px-2 mt-4 block">
|
||||
<div class="flex justify-center">
|
||||
<% options.each_with_index do |(label, value), index| %>
|
||||
<%= content_tag(value == 'list' ? 'span' : 'toggle-cookies', data: { value:, key: 'add_recipients_tab' }) do %>
|
||||
<%= radio_button_tag 'option', value, value == (only_detailed ? 'detailed' : default_tab), class: 'peer hidden', data: { action: 'change:toggle-visible#trigger' } %>
|
||||
<label for="option_<%= value %>" class="block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 peer-checked:bg-base-300 <%= 'hidden sm:inline-block' if value == 'list' %> <%= 'rounded-l-3xl' if index.zero? %> <%= 'rounded-r-3xl sm:rounded-r-none' if value == 'detailed' %> <%= 'rounded-r-3xl' if index == options.size - 1 %>">
|
||||
<%= label %>
|
||||
</label>
|
||||
<% if options.size > 1 %>
|
||||
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center px-2 mt-4 block">
|
||||
<div class="flex justify-center">
|
||||
<% options.each_with_index do |(label, value), index| %>
|
||||
<%= content_tag(value == 'list' ? 'span' : 'toggle-cookies', data: { value:, key: 'add_recipients_tab' }) do %>
|
||||
<%= radio_button_tag 'option', value, value == (only_detailed ? 'detailed' : default_tab), class: 'peer hidden', data: { action: 'change:toggle-visible#trigger' } %>
|
||||
<label for="option_<%= value %>" class="block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 peer-checked:bg-base-300 <%= 'hidden sm:inline-block' if value == 'list' %> <%= 'rounded-l-3xl' if index.zero? %> <%= 'rounded-r-3xl sm:rounded-r-none' if value == 'detailed' && index != options.size - 1 %> <%= 'rounded-r-3xl' if index == options.size - 1 %>">
|
||||
<%= label %>
|
||||
</label>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</toggle-visible>
|
||||
</div>
|
||||
</toggle-visible>
|
||||
<% end %>
|
||||
<div class="px-5 mb-5 mt-4">
|
||||
<% unless only_detailed %>
|
||||
<div id="email" class="<%= 'hidden' if default_tab != 'email' %>">
|
||||
|
||||
@@ -85,9 +85,9 @@
|
||||
<div class="mt-6">
|
||||
<h2 id="log" class="text-3xl font-bold"><%= t('events_log') %></h2>
|
||||
<div class="tabs border-b mt-4">
|
||||
<%= link_to t('all'), url_for(params: request.query_parameters.except('status')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status].blank? ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
|
||||
<%= link_to t('succeeded'), url_for(params: request.query_parameters.merge('status' => 'success')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'success' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
|
||||
<%= link_to t('failed'), url_for(params: request.query_parameters.merge('status' => 'error')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'error' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
|
||||
<%= link_to t('all'), url_for(params: request.query_parameters.except('status', 'page')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status].blank? ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
|
||||
<%= link_to t('succeeded'), url_for(params: request.query_parameters.except('page').merge('status' => 'success')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'success' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
|
||||
<%= link_to t('failed'), url_for(params: request.query_parameters.except('page').merge('status' => 'error')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'error' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
|
||||
</div>
|
||||
<% if @webhook_events.present? %>
|
||||
<div class="divide-y divide-base-300 rounded-lg">
|
||||
|
||||
@@ -88,6 +88,8 @@ Rails.application.configure do
|
||||
openssl_verify_mode: ENV['SMTP_SSL_VERIFY'] == 'false' ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER,
|
||||
authentication: ENV.fetch('SMTP_PASSWORD', nil).present? ? ENV.fetch('SMTP_AUTHENTICATION', 'plain') : nil,
|
||||
enable_starttls: ENV['SMTP_ENABLE_STARTTLS'] != 'false',
|
||||
ssl: ENV['SMTP_ENABLE_SSL'] == 'true',
|
||||
tls: ENV['SMTP_ENABLE_TLS'] == 'true',
|
||||
open_timeout: ENV.fetch('SMTP_OPEN_TIMEOUT', '15').to_i,
|
||||
read_timeout: ENV.fetch('SMTP_READ_TIMEOUT', '25').to_i
|
||||
}.compact
|
||||
|
||||
@@ -883,6 +883,13 @@ en: &en
|
||||
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} email address is awaiting confirmation. Follow the link in the email to confirm."
|
||||
please_confirm_your_email_address_using_the_link_below_: 'Please confirm your email address using the link below:'
|
||||
confirm_email: Confirm email
|
||||
please_verify_your_email_address_to_continue: Please verify your email address to continue.
|
||||
verification_code_sent_click_link_or_enter_code: A verification code has been sent to your email. Click the email link or enter the one time code to confirm your email.
|
||||
use_otp_code_to_verify_email_or_click_link_below_html: 'Use <b>%{code}</b> code to verify your email or click the link below:'
|
||||
verify_your_email: Verify your email
|
||||
your_email_has_been_confirmed: Your email has been confirmed.
|
||||
invalid_or_expired_verification_code: Invalid or expired verification code.
|
||||
didnt_receive_an_email: Did not receive an email?
|
||||
unconfirmed: Unconfirmed
|
||||
you_requested_to_reset_your_password_use_the_link_below_to_continue: You requested to reset your password. Use the link below to continue
|
||||
if_you_didnt_request_this_you_can_ignore_this_email: "If you didn't request this, please ignore this email."
|
||||
@@ -907,6 +914,9 @@ en: &en
|
||||
connect_to_docuseal_mcp: Connect to DocuSeal MCP
|
||||
add_the_following_to_your_mcp_client_configuration: Add the following to your MCP client configuration
|
||||
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Works with Claude Desktop, Cursor, Windsurf, VS Code, and any MCP-compatible client.
|
||||
your_email_address_has_been_changed: Your email address has been changed
|
||||
the_email_address_for_your_account_has_been_changed_to_new_email: The email address for your account has been changed to %{new_email}.
|
||||
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: If you did not make this change, please contact us by replying to this email.
|
||||
devise:
|
||||
confirmations:
|
||||
confirmed: Your email address has been successfully confirmed.
|
||||
@@ -1900,6 +1910,13 @@ es: &es
|
||||
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} está pendiente de confirmación. Sigue el enlace en el correo para confirmarla."
|
||||
please_confirm_your_email_address_using_the_link_below_: 'Por favor, confirma tu dirección de correo electrónico utilizando el enlace a continuación:'
|
||||
confirm_email: Confirmar correo
|
||||
please_verify_your_email_address_to_continue: Por favor, verifica tu dirección de correo electrónico para continuar.
|
||||
verification_code_sent_click_link_or_enter_code: Se ha enviado un código de verificación a tu correo. Puedes hacer clic en el enlace del correo o ingresar el código a continuación.
|
||||
use_otp_code_to_verify_email_or_click_link_below_html: 'Usa el código <b>%{code}</b> para verificar tu correo electrónico o haz clic en el enlace a continuación:'
|
||||
verify_your_email: Verificar tu correo electrónico
|
||||
your_email_has_been_confirmed: Tu correo electrónico ha sido confirmado.
|
||||
invalid_or_expired_verification_code: Código de verificación inválido o expirado.
|
||||
didnt_receive_an_email: "¿No recibiste un correo electrónico?"
|
||||
unconfirmed: No confirmado
|
||||
you_requested_to_reset_your_password_use_the_link_below_to_continue: Solicitaste restablecer tu contraseña. Usa el enlace a continuación para continuar.
|
||||
if_you_didnt_request_this_you_can_ignore_this_email: "Si no solicitaste esto, puedes ignorar este correo electrónico."
|
||||
@@ -1924,6 +1941,9 @@ es: &es
|
||||
connect_to_docuseal_mcp: Conectar a DocuSeal MCP
|
||||
add_the_following_to_your_mcp_client_configuration: Agregue lo siguiente a la configuración de su cliente MCP
|
||||
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funciona con Claude Desktop, Cursor, Windsurf, VS Code y cualquier cliente compatible con MCP.
|
||||
your_email_address_has_been_changed: Tu dirección de correo electrónico ha sido cambiada
|
||||
the_email_address_for_your_account_has_been_changed_to_new_email: La dirección de correo electrónico de tu cuenta ha sido cambiada a %{new_email}.
|
||||
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Si no realizaste este cambio, contáctanos respondiendo a este correo electrónico.
|
||||
devise:
|
||||
confirmations:
|
||||
confirmed: Tu dirección de correo electrónico ha sido confirmada correctamente.
|
||||
@@ -2918,6 +2938,13 @@ it: &it
|
||||
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} è in attesa di conferma. Segui il link nell'email per confermare."
|
||||
please_confirm_your_email_address_using_the_link_below_: 'Conferma il tuo indirizzo email utilizzando il link qui sotto:'
|
||||
confirm_email: Conferma email
|
||||
please_verify_your_email_address_to_continue: Verifica il tuo indirizzo email per continuare.
|
||||
verification_code_sent_click_link_or_enter_code: "È stato inviato un codice di verifica alla tua email. Puoi cliccare il link nell'email o inserire il codice qui sotto."
|
||||
use_otp_code_to_verify_email_or_click_link_below_html: 'Usa il codice <b>%{code}</b> per verificare la tua email o clicca il link qui sotto:'
|
||||
verify_your_email: Verifica la tua email
|
||||
your_email_has_been_confirmed: La tua email è stata confermata.
|
||||
invalid_or_expired_verification_code: Codice di verifica non valido o scaduto.
|
||||
didnt_receive_an_email: Non hai ricevuto un'email?
|
||||
unconfirmed: Non confermato
|
||||
you_requested_to_reset_your_password_use_the_link_below_to_continue: Hai richiesto di reimpostare la tua password. Usa il link qui sotto per continuare.
|
||||
if_you_didnt_request_this_you_can_ignore_this_email: "Se non hai richiesto questo, puoi ignorare questa email."
|
||||
@@ -2942,6 +2969,9 @@ it: &it
|
||||
connect_to_docuseal_mcp: Connetti a DocuSeal MCP
|
||||
add_the_following_to_your_mcp_client_configuration: Aggiungi quanto segue alla configurazione del tuo client MCP
|
||||
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funziona con Claude Desktop, Cursor, Windsurf, VS Code e qualsiasi client compatibile con MCP.
|
||||
your_email_address_has_been_changed: Il tuo indirizzo email è stato modificato
|
||||
the_email_address_for_your_account_has_been_changed_to_new_email: L'indirizzo email del tuo account è stato modificato in %{new_email}.
|
||||
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Se non hai effettuato questa modifica, contattaci rispondendo a questa email.
|
||||
devise:
|
||||
confirmations:
|
||||
confirmed: Il tuo indirizzo email è stato confermato con successo.
|
||||
@@ -3932,6 +3962,13 @@ fr: &fr
|
||||
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} est en attente de confirmation. Suivez le lien dans l'e-mail pour la confirmer."
|
||||
please_confirm_your_email_address_using_the_link_below_: 'Veuillez confirmer votre adresse e-mail en utilisant le lien ci-dessous :'
|
||||
confirm_email: "Confirmer l'e-mail"
|
||||
please_verify_your_email_address_to_continue: "Veuillez vérifier votre adresse e-mail pour continuer."
|
||||
verification_code_sent_click_link_or_enter_code: "Un code de vérification a été envoyé à votre adresse e-mail. Vous pouvez cliquer sur le lien dans l'e-mail ou saisir le code ci-dessous."
|
||||
use_otp_code_to_verify_email_or_click_link_below_html: "Utilisez le code <b>%{code}</b> pour vérifier votre e-mail ou cliquez sur le lien ci-dessous :"
|
||||
verify_your_email: "Vérifier votre e-mail"
|
||||
your_email_has_been_confirmed: "Votre adresse e-mail a été confirmée."
|
||||
invalid_or_expired_verification_code: "Code de vérification invalide ou expiré."
|
||||
didnt_receive_an_email: "Vous n'avez pas reçu d'e-mail ?"
|
||||
unconfirmed: Non confirmé
|
||||
you_requested_to_reset_your_password_use_the_link_below_to_continue: Vous avez demandé à réinitialiser votre mot de passe. Utilisez le lien ci-dessous pour continuer.
|
||||
if_you_didnt_request_this_you_can_ignore_this_email: "Si vous n'avez pas fait cette demande, veuillez ignorer cet e-mail."
|
||||
@@ -3956,6 +3993,9 @@ fr: &fr
|
||||
connect_to_docuseal_mcp: Se connecter à DocuSeal MCP
|
||||
add_the_following_to_your_mcp_client_configuration: Ajoutez ce qui suit à la configuration de votre client MCP
|
||||
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Fonctionne avec Claude Desktop, Cursor, Windsurf, VS Code et tout client compatible MCP.
|
||||
your_email_address_has_been_changed: Votre adresse e-mail a été modifiée
|
||||
the_email_address_for_your_account_has_been_changed_to_new_email: "L'adresse e-mail de votre compte a été modifiée en %{new_email}."
|
||||
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Si vous n'avez pas effectué ce changement, veuillez nous contacter en répondant à cet e-mail.
|
||||
devise:
|
||||
confirmations:
|
||||
confirmed: Votre adresse e-mail a été confirmée avec succès.
|
||||
@@ -4949,6 +4989,13 @@ pt: &pt
|
||||
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} está aguardando confirmação. Siga o link enviado para esse endereço de e-mail para confirmar."
|
||||
please_confirm_your_email_address_using_the_link_below_: 'Por favor, confirme seu endereço de e-mail usando o link abaixo:'
|
||||
confirm_email: Confirmar e-mail
|
||||
please_verify_your_email_address_to_continue: Verifique seu endereço de e-mail para continuar.
|
||||
verification_code_sent_click_link_or_enter_code: Um código de verificação foi enviado para seu e-mail. Você pode clicar no link do e-mail ou inserir o código abaixo.
|
||||
use_otp_code_to_verify_email_or_click_link_below_html: 'Use o código <b>%{code}</b> para verificar seu e-mail ou clique no link abaixo:'
|
||||
verify_your_email: Verificar seu e-mail
|
||||
your_email_has_been_confirmed: Seu e-mail foi confirmado.
|
||||
invalid_or_expired_verification_code: Código de verificação inválido ou expirado.
|
||||
didnt_receive_an_email: Não recebeu um e-mail?
|
||||
unconfirmed: Não confirmado
|
||||
you_requested_to_reset_your_password_use_the_link_below_to_continue: Você solicitou a redefinição da sua senha. Use o link abaixo para continuar.
|
||||
if_you_didnt_request_this_you_can_ignore_this_email: "Se você não solicitou isso, pode ignorar este e-mail."
|
||||
@@ -4973,6 +5020,9 @@ pt: &pt
|
||||
connect_to_docuseal_mcp: Conectar ao DocuSeal MCP
|
||||
add_the_following_to_your_mcp_client_configuration: Adicione o seguinte à configuração do seu cliente MCP
|
||||
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funciona com Claude Desktop, Cursor, Windsurf, VS Code e qualquer cliente compatível com MCP.
|
||||
your_email_address_has_been_changed: Seu endereço de e-mail foi alterado
|
||||
the_email_address_for_your_account_has_been_changed_to_new_email: O endereço de e-mail da sua conta foi alterado para %{new_email}.
|
||||
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Se você não fez essa alteração, entre em contato conosco respondendo a este e-mail.
|
||||
devise:
|
||||
confirmations:
|
||||
confirmed: Seu endereço de e-mail foi confirmado com sucesso.
|
||||
@@ -5966,6 +6016,13 @@ de: &de
|
||||
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} wartet auf Bestätigung. Folgen Sie dem Link in der E-Mail, um sie zu bestätigen."
|
||||
please_confirm_your_email_address_using_the_link_below_: 'Bitte bestätigen Sie Ihre E-Mail-Adresse über den folgenden Link:'
|
||||
confirm_email: E-Mail bestätigen
|
||||
please_verify_your_email_address_to_continue: Bitte bestätigen Sie Ihre E-Mail-Adresse, um fortzufahren.
|
||||
verification_code_sent_click_link_or_enter_code: Ein Verifizierungscode wurde an Ihre E-Mail gesendet. Sie können auf den Link in der E-Mail klicken oder den Code unten eingeben.
|
||||
use_otp_code_to_verify_email_or_click_link_below_html: 'Verwenden Sie den Code <b>%{code}</b>, um Ihre E-Mail zu bestätigen, oder klicken Sie auf den Link unten:'
|
||||
verify_your_email: E-Mail bestätigen
|
||||
your_email_has_been_confirmed: Ihre E-Mail-Adresse wurde bestätigt.
|
||||
invalid_or_expired_verification_code: Ungültiger oder abgelaufener Verifizierungscode.
|
||||
didnt_receive_an_email: Keine E-Mail erhalten?
|
||||
unconfirmed: Unbestätigt
|
||||
you_requested_to_reset_your_password_use_the_link_below_to_continue: Sie haben angefordert, Ihr Passwort zurückzusetzen. Verwenden Sie den untenstehenden Link, um fortzufahren.
|
||||
if_you_didnt_request_this_you_can_ignore_this_email: "Wenn Sie dies nicht angefordert haben, können Sie diese E-Mail ignorieren."
|
||||
@@ -5990,6 +6047,9 @@ de: &de
|
||||
connect_to_docuseal_mcp: Mit DocuSeal MCP verbinden
|
||||
add_the_following_to_your_mcp_client_configuration: Fügen Sie Folgendes zu Ihrer MCP-Client-Konfiguration hinzu
|
||||
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funktioniert mit Claude Desktop, Cursor, Windsurf, VS Code und jedem MCP-kompatiblen Client.
|
||||
your_email_address_has_been_changed: Ihre E-Mail-Adresse wurde geändert
|
||||
the_email_address_for_your_account_has_been_changed_to_new_email: Die E-Mail-Adresse Ihres Kontos wurde in %{new_email} geändert.
|
||||
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Wenn Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns bitte, indem Sie auf diese E-Mail antworten.
|
||||
devise:
|
||||
confirmations:
|
||||
confirmed: Ihre E-Mail-Adresse wurde erfolgreich bestätigt.
|
||||
@@ -7368,6 +7428,13 @@ nl: &nl
|
||||
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} wacht op bevestiging. Volg de link in de e-mail om te bevestigen."
|
||||
please_confirm_your_email_address_using_the_link_below_: 'Bevestig je e-mailadres via de onderstaande link:'
|
||||
confirm_email: E-mailadres bevestigen
|
||||
please_verify_your_email_address_to_continue: Bevestig je e-mailadres om door te gaan.
|
||||
verification_code_sent_click_link_or_enter_code: Er is een verificatiecode naar je e-mail gestuurd. Je kunt op de link in de e-mail klikken of de code hieronder invoeren.
|
||||
use_otp_code_to_verify_email_or_click_link_below_html: 'Gebruik code <b>%{code}</b> om je e-mail te verifiëren of klik op de link hieronder:'
|
||||
verify_your_email: Je e-mail verifiëren
|
||||
your_email_has_been_confirmed: Je e-mailadres is bevestigd.
|
||||
invalid_or_expired_verification_code: Ongeldige of verlopen verificatiecode.
|
||||
didnt_receive_an_email: Geen e-mail ontvangen?
|
||||
unconfirmed: Onbevestigd
|
||||
you_requested_to_reset_your_password_use_the_link_below_to_continue: Je hebt gevraagd je wachtwoord te resetten. Gebruik de onderstaande link om verder te gaan.
|
||||
if_you_didnt_request_this_you_can_ignore_this_email: "Als je dit niet hebt aangevraagd, kun je deze e-mail negeren."
|
||||
@@ -7392,6 +7459,9 @@ nl: &nl
|
||||
connect_to_docuseal_mcp: Verbinden met DocuSeal MCP
|
||||
add_the_following_to_your_mcp_client_configuration: Voeg het volgende toe aan uw MCP-clientconfiguratie
|
||||
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Werkt met Claude Desktop, Cursor, Windsurf, VS Code en elke MCP-compatibele client.
|
||||
your_email_address_has_been_changed: Je e-mailadres is gewijzigd
|
||||
the_email_address_for_your_account_has_been_changed_to_new_email: Het e-mailadres van je account is gewijzigd naar %{new_email}.
|
||||
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Als je deze wijziging niet hebt aangebracht, neem dan contact met ons op door op deze e-mail te antwoorden.
|
||||
devise:
|
||||
confirmations:
|
||||
confirmed: Je e-mailadres is succesvol bevestigd.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HtmlToPlainText
|
||||
module_function
|
||||
|
||||
def call(html, line_length = 65)
|
||||
return '' if html.nil? || html.strip.empty?
|
||||
|
||||
cleaned = html.gsub(%r{<!-- start text/html -->.*?<!-- end text/html -->}m, '')
|
||||
|
||||
doc = Nokogiri::HTML.fragment(cleaned)
|
||||
|
||||
doc.css('script').each(&:remove)
|
||||
|
||||
result = process_nodes(doc, line_length)
|
||||
|
||||
result.gsub!(/\r\n?/, "\n")
|
||||
result.gsub!(/[ \t]*\u00A0+[ \t]*/, ' ')
|
||||
result.gsub!(/\n[ \t]+/, "\n")
|
||||
result.gsub!(/[ \t]+\n/, "\n")
|
||||
result.gsub!(/\n{3,}/, "\n\n")
|
||||
|
||||
result = word_wrap(result, line_length)
|
||||
|
||||
result.gsub!(/\(([ \n])(http[^)]+)([\n ])\)/) do
|
||||
"#{"\n" if ::Regexp.last_match(1) == "\n"}( #{::Regexp.last_match(2)} )#{"\n" if ::Regexp.last_match(3) == "\n"}"
|
||||
end
|
||||
|
||||
result.strip
|
||||
end
|
||||
|
||||
def process_nodes(node, line_length)
|
||||
result = +''
|
||||
|
||||
node.children.each do |child|
|
||||
case child
|
||||
when Nokogiri::XML::Text
|
||||
result << child.text
|
||||
when Nokogiri::XML::Comment
|
||||
next
|
||||
when Nokogiri::XML::Element
|
||||
result << process_element(child, line_length)
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def process_element(node, line_length)
|
||||
case node.name
|
||||
when 'br'
|
||||
"\n"
|
||||
when 'p', 'div'
|
||||
inner = process_nodes(node, line_length)
|
||||
inner.strip.empty? ? '' : "#{inner}\n\n"
|
||||
when 'img'
|
||||
node['alt'] || ''
|
||||
when 'a'
|
||||
process_link(node, line_length)
|
||||
when /\Ah([1-6])\z/
|
||||
process_heading(node, ::Regexp.last_match(1).to_i, line_length)
|
||||
when 'li'
|
||||
inner = process_nodes(node, line_length)
|
||||
"* #{inner.strip}\n"
|
||||
else
|
||||
process_nodes(node, line_length)
|
||||
end
|
||||
end
|
||||
|
||||
def process_link(node, line_length)
|
||||
text = process_nodes(node, line_length).strip
|
||||
return '' if text.empty?
|
||||
|
||||
href = node['href']
|
||||
href = href.sub(/\Amailto:/i, '') if href
|
||||
|
||||
if href.nil? || text.casecmp(href.strip) == 0
|
||||
text
|
||||
else
|
||||
"#{text} ( #{href.strip} )"
|
||||
end
|
||||
end
|
||||
|
||||
def process_heading(node, level, line_length)
|
||||
text = +''
|
||||
node.children.each do |child|
|
||||
text << if child.name == 'br'
|
||||
"\n"
|
||||
else
|
||||
child.text
|
||||
end
|
||||
end
|
||||
text.strip!
|
||||
|
||||
hlength = text.each_line.map { |l| l.strip.length }.max || 0
|
||||
hlength = line_length if hlength > line_length
|
||||
|
||||
decorated = case level
|
||||
when 1
|
||||
"#{'*' * hlength}\n#{text}\n#{'*' * hlength}"
|
||||
when 2
|
||||
"#{'-' * hlength}\n#{text}\n#{'-' * hlength}"
|
||||
else
|
||||
"#{text}\n#{'-' * hlength}"
|
||||
end
|
||||
|
||||
"\n\n#{decorated}\n\n"
|
||||
end
|
||||
|
||||
def word_wrap(txt, line_length)
|
||||
txt.split("\n").map do |line|
|
||||
if line.length > line_length
|
||||
line.gsub(/(.{1,#{line_length}})(\s+|$)/, "\\1\n").strip
|
||||
else
|
||||
line
|
||||
end
|
||||
end.join("\n")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HtmlToPlainTextInterceptor
|
||||
module_function
|
||||
|
||||
def delivering_email(message)
|
||||
process(message)
|
||||
end
|
||||
|
||||
def previewing_email(message)
|
||||
process(message)
|
||||
end
|
||||
|
||||
def process(message)
|
||||
return message unless html_part(message)
|
||||
return message if message.text_part
|
||||
|
||||
add_text_part(message)
|
||||
|
||||
message
|
||||
end
|
||||
|
||||
def add_text_part(message)
|
||||
html = html_part(message).decoded
|
||||
text = HtmlToPlainText.call(html)
|
||||
|
||||
text_part = Mail::Part.new do
|
||||
content_type 'text/plain; charset=UTF-8'
|
||||
body text
|
||||
end
|
||||
|
||||
if pure_html_message?(message)
|
||||
message.body = nil
|
||||
message.content_type = 'multipart/alternative'
|
||||
message.add_part(text_part)
|
||||
message.add_part(Mail::Part.new do
|
||||
content_type 'text/html; charset=UTF-8'
|
||||
body html
|
||||
end)
|
||||
else
|
||||
alternative = Mail::Part.new(content_type: 'multipart/alternative')
|
||||
alternative.add_part(text_part)
|
||||
alternative.add_part(message.html_part)
|
||||
replace_part(message.parts, message.html_part, alternative)
|
||||
end
|
||||
end
|
||||
|
||||
def pure_html_message?(message)
|
||||
message.content_type.to_s.include?('text/html')
|
||||
end
|
||||
|
||||
def html_part(message)
|
||||
pure_html_message?(message) ? message : message.html_part
|
||||
end
|
||||
|
||||
def replace_part(parts, old_part, new_part)
|
||||
if (index = parts.index(old_part))
|
||||
parts[index] = new_part
|
||||
else
|
||||
parts.each do |part|
|
||||
replace_part(part.parts, old_part, new_part) if part.respond_to?(:parts)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+1
-13
@@ -3,8 +3,6 @@
|
||||
module Submissions
|
||||
DEFAULT_SUBMITTERS_ORDER = 'random'
|
||||
|
||||
PRELOAD_ALL_PAGES_AMOUNT = 200
|
||||
|
||||
module_function
|
||||
|
||||
def search(current_user, submissions, keyword, search_values: false, search_template: false)
|
||||
@@ -81,19 +79,9 @@ module Submissions
|
||||
def preload_with_pages(submission)
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: submission.schema_documents,
|
||||
associations: [:blob]
|
||||
associations: [:blob, { preview_images_attachments: :blob }]
|
||||
).call
|
||||
|
||||
total_pages =
|
||||
submission.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.schema_documents,
|
||||
associations: [{ preview_images_attachments: :blob }]
|
||||
).call
|
||||
end
|
||||
|
||||
submission
|
||||
end
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ module Submissions
|
||||
bold_italic: FONT_BOLD_NAME
|
||||
}.freeze
|
||||
|
||||
SIGN_REASON = 'Signed by %<name>s with DocuSeal.com'
|
||||
SIGN_REASON = 'Signed with DocuSeal.com'
|
||||
|
||||
RTL_REGEXP = TextUtils::RTL_REGEXP
|
||||
|
||||
@@ -236,9 +236,10 @@ module Submissions
|
||||
|
||||
page[:Annots] ||= []
|
||||
page[:Annots] = page[:Annots].try(:reject) do |e|
|
||||
next if e.is_a?(Integer) || e.is_a?(Symbol)
|
||||
next if e.is_a?(Integer) || e.is_a?(Symbol) || e.is_a?(HexaPDF::PDFArray)
|
||||
|
||||
e.present? && e[:A] && e[:A][:URI].to_s.starts_with?('file:///docuseal_field')
|
||||
e.present? && e[:A] && !e[:A].is_a?(HexaPDF::PDFArray) &&
|
||||
e[:A][:URI].to_s.starts_with?('file:///docuseal_field')
|
||||
end || page[:Annots]
|
||||
|
||||
width = page.box.width
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
module Submitters
|
||||
TRUE_VALUES = ['1', 'true', true].freeze
|
||||
PRELOAD_ALL_PAGES_AMOUNT = 200
|
||||
|
||||
FIELD_NAME_WEIGHTS = {
|
||||
'email' => 'A',
|
||||
|
||||
@@ -10,7 +10,7 @@ module Templates
|
||||
pdf.pages.flat_map.with_index do |page, index|
|
||||
(page[:Annots] || []).filter_map do |annot|
|
||||
next if annot.blank?
|
||||
next if annot.is_a?(Integer) || annot.is_a?(Symbol)
|
||||
next if annot.is_a?(Integer) || annot.is_a?(Symbol) || annot.is_a?(HexaPDF::PDFArray)
|
||||
next if annot[:A].blank? || annot[:A][:URI].blank?
|
||||
next unless annot[:Subtype] == :Link
|
||||
next if !annot[:A][:URI].starts_with?('https://') && !annot[:A][:URI].starts_with?('http://')
|
||||
|
||||
+2
-1
@@ -11,7 +11,8 @@ module WebhookUrls
|
||||
'submission.expired' => SendSubmissionExpiredWebhookRequestJob,
|
||||
'submission.archived' => SendSubmissionArchivedWebhookRequestJob,
|
||||
'template.created' => SendTemplateCreatedWebhookRequestJob,
|
||||
'template.updated' => SendTemplateUpdatedWebhookRequestJob
|
||||
'template.updated' => SendTemplateUpdatedWebhookRequestJob,
|
||||
'template.archived' => SendTemplateArchivedWebhookRequestJob
|
||||
}.freeze
|
||||
|
||||
EVENT_TYPE_ID_KEYS = {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe SendTemplateArchivedWebhookRequestJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
let(:template) { create(:template, account:, author: user) }
|
||||
let(:webhook_url) { create(:webhook_url, account:, events: ['template.archived']) }
|
||||
|
||||
before do
|
||||
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
|
||||
value: GenerateCertificate.call.transform_values(&:to_pem))
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
around do |example|
|
||||
freeze_time { example.run }
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, webhook_url.url).to_return(status: 200)
|
||||
end
|
||||
|
||||
it 'sends a webhook request' do
|
||||
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id,
|
||||
'event_uuid' => SecureRandom.uuid)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).with(
|
||||
body: {
|
||||
'event_type' => 'template.archived',
|
||||
'timestamp' => /.*/,
|
||||
'data' => template.reload.as_json(only: %i[id archived_at])
|
||||
},
|
||||
headers: {
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'DocuSeal.com Webhook'
|
||||
}
|
||||
).once
|
||||
end
|
||||
|
||||
it 'sends a webhook request with the secret' do
|
||||
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' })
|
||||
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id,
|
||||
'event_uuid' => SecureRandom.uuid)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).with(
|
||||
body: {
|
||||
'event_type' => 'template.archived',
|
||||
'timestamp' => /.*/,
|
||||
'data' => template.reload.as_json(only: %i[id archived_at])
|
||||
},
|
||||
headers: {
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'DocuSeal.com Webhook',
|
||||
'X-Secret-Header' => 'secret_value'
|
||||
}
|
||||
).once
|
||||
end
|
||||
|
||||
it "doesn't send a webhook request if the event is not in the webhook's events" do
|
||||
webhook_url.update!(events: ['template.updated'])
|
||||
|
||||
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id,
|
||||
'event_uuid' => SecureRandom.uuid)
|
||||
|
||||
expect(WebMock).not_to have_requested(:post, webhook_url.url)
|
||||
end
|
||||
|
||||
it 'sends again if the response status is 400 or higher' do
|
||||
stub_request(:post, webhook_url.url).to_return(status: 401)
|
||||
|
||||
event_uuid = SecureRandom.uuid
|
||||
|
||||
expect do
|
||||
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id,
|
||||
'event_uuid' => event_uuid)
|
||||
end.to change(described_class.jobs, :size).by(1)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).once
|
||||
|
||||
args = described_class.jobs.last['args'].first
|
||||
|
||||
expect(args['attempt']).to eq(1)
|
||||
expect(args['last_status']).to eq(401)
|
||||
expect(args['event_uuid']).to eq(event_uuid)
|
||||
expect(args['webhook_url_id']).to eq(webhook_url.id)
|
||||
expect(args['template_id']).to eq(template.id)
|
||||
end
|
||||
|
||||
it "doesn't send again if the max attempts is reached" do
|
||||
stub_request(:post, webhook_url.url).to_return(status: 401)
|
||||
|
||||
expect do
|
||||
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id,
|
||||
'event_uuid' => SecureRandom.uuid, 'attempt' => 11)
|
||||
end.not_to change(described_class.jobs, :size)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).once
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -23,7 +23,7 @@ RSpec.describe 'Sign In' do
|
||||
fill_in 'Password', with: 'wrong_password'
|
||||
click_button 'Sign In'
|
||||
|
||||
expect(page).to have_content('Invalid Email or password')
|
||||
expect(page).to have_content('Invalid email or password')
|
||||
expect(page).not_to have_content('Document Templates')
|
||||
end
|
||||
end
|
||||
@@ -51,7 +51,7 @@ RSpec.describe 'Sign In' do
|
||||
fill_in 'Two-Factor Code from Authenticator App', with: '123456'
|
||||
click_button 'Sign In'
|
||||
|
||||
expect(page).to have_content('Invalid Email or password')
|
||||
expect(page).to have_content('Invalid email or password')
|
||||
expect(page).not_to have_content('Document Templates')
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user