add dynamic documents

pull/556/merge
Pete Matsyburka 1 month ago
parent b50d982497
commit 37196ff89f

@ -36,6 +36,7 @@ COPY ./config/shakapacker.yml ./config/shakapacker.yml
COPY ./postcss.config.js ./postcss.config.js COPY ./postcss.config.js ./postcss.config.js
COPY ./tailwind.config.js ./tailwind.config.js COPY ./tailwind.config.js ./tailwind.config.js
COPY ./tailwind.form.config.js ./tailwind.form.config.js COPY ./tailwind.form.config.js ./tailwind.form.config.js
COPY ./tailwind.dynamic.config.js ./tailwind.dynamic.config.js
COPY ./tailwind.application.config.js ./tailwind.application.config.js COPY ./tailwind.application.config.js ./tailwind.application.config.js
COPY ./app/javascript ./app/javascript COPY ./app/javascript ./app/javascript
COPY ./app/views ./app/views COPY ./app/views ./app/views

@ -117,7 +117,7 @@ module Api
conditions: [%i[field_uuid value action operation]], conditions: [%i[field_uuid value action operation]],
options: [%i[value uuid]], options: [%i[value uuid]],
validation: %i[message pattern min max step], validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]]
} }
] ]

@ -6,9 +6,17 @@ class PreviewDocumentPageController < ActionController::API
FORMAT = Templates::ProcessDocument::FORMAT FORMAT = Templates::ProcessDocument::FORMAT
def show def show
attachment_uuid = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid], purpose: :attachment) result_data =
ApplicationRecord.signed_id_verifier.verified(params[:signed_key], purpose: :attachment)
attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid) if attachment_uuid attachment =
if result_data.is_a?(Array) && result_data.compact_blank.size == 2
attachment_id, attachment_uuid = result_data
ActiveStorage::Attachment.find_by(id: attachment_id, uuid: attachment_uuid)
elsif result_data
ActiveStorage::Attachment.find_by(uuid: result_data)
end
return head :not_found unless attachment return head :not_found unless attachment

@ -172,6 +172,8 @@ class StartFormController < ApplicationController
submitters: [submitter], submitters: [submitter],
source: :link) source: :link)
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submitter.submission)
submitter.account_id = submitter.submission.account_id submitter.account_id = submitter.submission.account_id
submitter submitter

@ -51,18 +51,7 @@ class SubmissionsController < ApplicationController
emails: params[:emails], emails: params[:emails],
params: params.merge('send_completed_email' => true)) params: params.merge('send_completed_email' => true))
else else
submissions_attrs = submissions_params[:submission].to_h.values create_submissions(@template, submissions_params, params)
submissions_attrs, _, new_fields =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, @template, add_fields: true)
Submissions.create_from_submitters(template: @template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
submissions_attrs:,
new_fields:,
params: params.merge('send_completed_email' => true))
end end
WebhookUrls.enqueue_events(submissions, 'submission.created') WebhookUrls.enqueue_events(submissions, 'submission.created')
@ -97,6 +86,21 @@ class SubmissionsController < ApplicationController
private private
def create_submissions(template, submissions_params, params)
submissions_attrs = submissions_params[:submission].to_h.values
submissions_attrs, _, new_fields =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, template, add_fields: true)
Submissions.create_from_submitters(template: template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
submissions_attrs:,
new_fields:,
params: params.merge('send_completed_email' => true))
end
def save_template_message(template, params) def save_template_message(template, params)
template.preferences['request_email_subject'] = params[:subject] if params[:subject].present? template.preferences['request_email_subject'] = params[:subject] if params[:subject].present?
template.preferences['request_email_body'] = params[:body] if params[:body].present? template.preferences['request_email_body'] = params[:body] if params[:body].present?

@ -16,7 +16,7 @@ class TemplateDocumentsController < ApplicationController
old_fields_hash = @template.fields.hash old_fields_hash = @template.fields.hash
documents = Templates::CreateAttachments.call(@template, params, extract_fields: true) documents, = Templates::CreateAttachments.call(@template, params, extract_fields: true)
schema = documents.map do |doc| schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base } { attachment_uuid: doc.uuid, name: doc.filename.base }
@ -27,7 +27,7 @@ class TemplateDocumentsController < ApplicationController
fields: old_fields_hash == @template.fields.hash ? nil : @template.fields, fields: old_fields_hash == @template.fields.hash ? nil : @template.fields,
submitters: old_fields_hash == @template.fields.hash ? nil : @template.submitters, submitters: old_fields_hash == @template.fields.hash ? nil : @template.submitters,
documents: documents.as_json( documents: documents.as_json(
methods: %i[metadata signed_uuid], methods: %i[metadata signed_key],
include: { include: {
preview_images: { methods: %i[url metadata filename] } preview_images: { methods: %i[url metadata filename] }
} }

@ -35,7 +35,7 @@ class TemplatesController < ApplicationController
@template_data = @template_data =
@template.as_json.merge( @template.as_json.merge(
documents: @template.schema_documents.as_json( documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid], methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } } include: { preview_images: { methods: %i[url metadata filename] } }
) )
).to_json ).to_json
@ -95,10 +95,11 @@ class TemplatesController < ApplicationController
def template_params def template_params
params.require(:template).permit( params.require(:template).permit(
:name, :name,
{ schema: [[:attachment_uuid, :google_drive_file_id, :name, { schema: [[:attachment_uuid, :google_drive_file_id, :name, :dynamic,
{ conditions: [%i[field_uuid value action operation]] }]], { conditions: [%i[field_uuid value action operation]] }]],
submitters: [%i[name uuid is_requester linked_to_uuid invite_via_field_uuid submitters: [%i[name uuid is_requester linked_to_uuid invite_via_field_uuid
invite_by_uuid optional_invite_by_uuid email order]], invite_by_uuid optional_invite_by_uuid email order]],
variables_schema: {},
fields: [[:uuid, :submitter_uuid, :name, :type, fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value, :required, :readonly, :default_value,
:title, :description, :prefillable, :title, :description, :prefillable,
@ -107,7 +108,7 @@ class TemplatesController < ApplicationController
conditions: [%i[field_uuid value action operation]], conditions: [%i[field_uuid value action operation]],
options: [%i[value uuid]], options: [%i[value uuid]],
validation: %i[message pattern min max step], validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] } areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]] }
) )
end end
end end

@ -9,6 +9,7 @@ class TemplatesDebugController < ApplicationController
schema_uuids = @template.schema.index_by { |e| e['attachment_uuid'] } schema_uuids = @template.schema.index_by { |e| e['attachment_uuid'] }
attachment = @template.documents.find { |a| schema_uuids[a.uuid] } attachment = @template.documents.find { |a| schema_uuids[a.uuid] }
if attachment
data = attachment.download data = attachment.download
unless attachment.image? unless attachment.image?
@ -17,12 +18,13 @@ class TemplatesDebugController < ApplicationController
fields = Templates::FindAcroFields.call(pdf, attachment, data) fields = Templates::FindAcroFields.call(pdf, attachment, data)
end end
fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank? # fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
attachment.metadata['pdf'] ||= {} attachment.metadata['pdf'] ||= {}
attachment.metadata['pdf']['fields'] = fields attachment.metadata['pdf']['fields'] = fields
@template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment])) @template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment]))
end
debug_file if DEBUG_FILE.present? debug_file if DEBUG_FILE.present?
@ -34,7 +36,7 @@ class TemplatesDebugController < ApplicationController
@template_data = @template_data =
@template.as_json.merge( @template.as_json.merge(
documents: @template.schema_documents.as_json( documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid], methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } } include: { preview_images: { methods: %i[url metadata filename] } }
) )
).to_json ).to_json
@ -58,9 +60,16 @@ class TemplatesDebugController < ApplicationController
params = { files: [file] } params = { files: [file] }
documents = Templates::CreateAttachments.call(@template, params) documents, dynamic_documents = Templates::CreateAttachments.call(@template, params,
dynamic: DEBUG_FILE.ends_with?('.docx'))
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } } schema = documents.map do |doc|
{
attachment_uuid: doc.uuid,
name: doc.filename.base,
dynamic: dynamic_documents.find { |e| e.uuid == doc.uuid }.present?
}
end
@template.update!(schema:) @template.update!(schema:)
end end

@ -12,7 +12,7 @@ class TemplatesPreviewController < ApplicationController
@template_data = @template_data =
@template.as_json.merge( @template.as_json.merge(
documents: @template.schema_documents.as_json( documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid], methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } } include: { preview_images: { methods: %i[url metadata filename] } }
) )
).to_json ).to_json

@ -12,7 +12,7 @@ class TemplatesUploadsController < ApplicationController
save_template!(@template, url_params) save_template!(@template, url_params)
documents = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true) documents, = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } } schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
if @template.fields.blank? if @template.fields.blank?

@ -160,6 +160,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
this.app = createApp(TemplateBuilder, { this.app = createApp(TemplateBuilder, {
template, template,
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')), customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
backgroundColor: '#faf7f5', backgroundColor: '#faf7f5',
locale: this.dataset.locale, locale: this.dataset.locale,
withPhone: this.dataset.withPhone === 'true', withPhone: this.dataset.withPhone === 'true',
@ -177,6 +178,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withSendButton: this.dataset.withSendButton !== 'false', withSendButton: this.dataset.withSendButton !== 'false',
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false', withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
withConditions: this.dataset.withConditions === 'true', withConditions: this.dataset.withConditions === 'true',
withDynamicDocuments: this.dataset.withDynamicDocuments === 'true',
withGoogleDrive: this.dataset.withGoogleDrive === 'true', withGoogleDrive: this.dataset.withGoogleDrive === 'true',
withReplaceAndCloneUpload: true, withReplaceAndCloneUpload: true,
withDownload: true, withDownload: true,

@ -31,144 +31,26 @@
/> />
</div> </div>
</div> </div>
<div <AreaTitle
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection" ref="title"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls" :area="area"
style="top: -25px; height: 25px"
@mousedown.stop
@pointerdown.stop
>
<FieldSubmitter
v-if="field.type != 'heading' && field.type != 'strikethrough'"
v-model="field.submitter_uuid"
class="border-r roles-dropdown"
:compact="true"
:editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
:allow-add-new="!defaultSubmitters.length"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
:submitters="template.submitters"
@update:model-value="save"
@click="selectedAreasRef.value = [area]"
/>
<FieldType
v-model="field.type"
:button-width="27"
:editable="editable && !defaultField"
:button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), save()]"
@click="selectedAreasRef.value = [area]"
/>
<span
v-if="field.type !== 'checkbox' || field.name"
ref="name"
:contenteditable="editable && !defaultField && field.type !== 'heading'"
dir="auto"
class="pr-1 cursor-text outline-none block"
style="min-width: 2px"
@paste.prevent="onPaste"
@keydown.enter.prevent="onNameEnter"
@focus="onNameFocus"
@blur="onNameBlur"
>{{ optionIndexText }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || defaultName }}</span>
<div
v-if="isSettingsFocus || isSelectInput || (isValueInput && field.type !== 'heading') || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
class="flex items-center ml-1.5"
>
<input
v-if="!isValueInput && !isSelectInput"
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
v-if="!isValueInput && !isSelectInput"
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>{{ t('required') }}</label>
<input
v-if="isValueInput || isSelectInput"
:id="`readonly-checkbox-${field.uuid}`"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
:checked="!(field.readonly ?? true)"
@change="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>
<label
v-if="isValueInput || isSelectInput"
:for="`readonly-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>{{ t('editable') }}</label>
<span
v-if="field.type !== 'payment' && !isValueInput"
class="dropdown dropdown-end field-area-settings-dropdown"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
ref="settingsButton"
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center"
style="height: 25px"
@focus="isSettingsFocus = true"
@blur="maybeBlurSettings"
>
<IconDotsVertical class="w-5 h-5" />
</label>
<ul
v-if="renderDropdown"
ref="settingsDropdown"
tabindex="0"
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
:style="{ backgroundColor: 'white' }"
@dragstart.prevent.stop
@click="closeDropdown"
@focusout="maybeBlurSettings"
>
<FieldSettings
v-if="isMobile"
:field="field" :field="field"
:template="template"
:selected-areas-ref="selectedAreasRef"
:get-field-type-index="getFieldTypeIndex"
:default-field="defaultField" :default-field="defaultField"
:editable="editable"
:background-color="'white'"
:with-required="false"
:with-areas="false"
:with-signature-id="withSignatureId" :with-signature-id="withSignatureId"
:with-prefillable="withPrefillable" :with-prefillable="withPrefillable"
@click-formula="isShowFormulaModal = true" :default-submitters="defaultSubmitters"
@click-font="isShowFontModal = true" :editable="editable"
@click-description="isShowDescriptionModal = true" :is-mobile="isMobile"
:is-value-input="isValueInput"
:is-select-input="isSelectInput"
@change="save"
@remove="$emit('remove')"
@scroll-to="$emit('scroll-to', $event)"
@add-custom-field="$emit('add-custom-field')" @add-custom-field="$emit('add-custom-field')"
@click-condition="isShowConditionsModal = true"
@save="save"
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
/> />
<div
v-else
class="whitespace-normal"
>
The dots menu is retired in favor of the field context menu. Right-click the field to access field settings. Double-click the field to set a default value.
</div>
</ul>
</span>
</div>
<button
v-else-if="editable"
class="pr-1"
:title="t('remove')"
@click.prevent="$emit('remove')"
>
<IconX width="14" />
</button>
</div>
<div <div
ref="touchValueTarget" ref="touchValueTarget"
class="flex h-full w-full field-area" class="flex h-full w-full field-area"
@ -333,85 +215,20 @@
@mousedown.stop="startResize" @mousedown.stop="startResize"
@touchstart="startTouchResize" @touchstart="startTouchResize"
/> />
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="save"
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="save"
@close="isShowFontModal = false"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="field"
:build-default-name="buildDefaultName"
:default-field="defaultField"
@save="save"
@close="isShowConditionsModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="save"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div> </div>
</template> </template>
<script> <script>
import FieldSubmitter from './field_submitter'
import FieldType from './field_type' import FieldType from './field_type'
import Field from './field' import Field from './field'
import FieldSettings from './field_settings' import AreaTitle from './area_title'
import FormulaModal from './formula_modal' import { IconCheck } from '@tabler/icons-vue'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconX, IconCheck, IconDotsVertical } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default { export default {
name: 'FieldArea', name: 'FieldArea',
components: { components: {
FieldType,
IconCheck, IconCheck,
FieldSettings, AreaTitle
FormulaModal,
FontModal,
IconDotsVertical,
DescriptionModal,
ConditionsModal,
FieldSubmitter,
IconX
}, },
inject: ['template', 'save', 't', 'isInlineSize', 'selectedAreasRef', 'isCmdKeyRef', 'getFieldTypeIndex'], inject: ['template', 'save', 't', 'isInlineSize', 'selectedAreasRef', 'isCmdKeyRef', 'getFieldTypeIndex'],
props: { props: {
@ -493,17 +310,10 @@ export default {
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field'], emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field'],
data () { data () {
return { return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowConditionsModal: false,
isContenteditable: false, isContenteditable: false,
isSettingsFocus: false,
isShowDescriptionModal: false,
isResize: false, isResize: false,
isDragged: false, isDragged: false,
isMoved: false, isMoved: false,
renderDropdown: false,
isNameFocus: false,
isHeadingSelected: false, isHeadingSelected: false,
textOverflowChars: 0, textOverflowChars: 0,
dragFrom: { x: 0, y: 0 } dragFrom: { x: 0, y: 0 }
@ -592,9 +402,6 @@ export default {
return (this.field.type === 'heading' && this.isHeadingSelected) || this.isContenteditable || return (this.field.type === 'heading' && this.isHeadingSelected) || this.isContenteditable ||
(this.inputMode && (['text', 'number'].includes(this.field.type) || (this.field.type === 'date' && this.field.default_value !== '{{date}}'))) (this.inputMode && (['text', 'number'].includes(this.field.type) || (this.field.type === 'date' && this.field.default_value !== '{{date}}')))
}, },
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
defaultName () { defaultName () {
return this.buildDefaultName(this.field) return this.buildDefaultName(this.field)
}, },
@ -616,13 +423,6 @@ export default {
italic: ['bold_italic', 'italic'].includes(this.field.preferences.font_type) italic: ['bold_italic', 'italic'].includes(this.field.preferences.font_type)
} }
}, },
optionIndexText () {
if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
} else {
return ''
}
},
cells () { cells () {
const cells = [] const cells = []
@ -705,9 +505,6 @@ export default {
}, },
methods: { methods: {
buildDefaultName: Field.methods.buildDefaultName, buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
buildAreaOptionValue (area) { buildAreaOptionValue (area) {
const option = this.optionsUuidIndex[area.option_uuid] const option = this.optionsUuidIndex[area.option_uuid]
@ -792,23 +589,6 @@ export default {
return number return number
} }
}, },
maybeBlurSettings (e) {
if (!e.relatedTarget || !this.$refs.settingsDropdown.contains(e.relatedTarget)) {
this.isSettingsFocus = false
}
},
onNameFocus (e) {
this.selectedAreasRef.value = [this.area]
this.isNameFocus = true
this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px'
if (!this.field.name) {
setTimeout(() => {
this.$refs.name.innerText = ' '
}, 1)
}
},
startResizeCell (e) { startResizeCell (e) {
this.$el.getRootNode().addEventListener('mousemove', this.onResizeCell) this.$el.getRootNode().addEventListener('mousemove', this.onResizeCell)
this.$el.getRootNode().addEventListener('mouseup', this.stopResizeCell) this.$el.getRootNode().addEventListener('mouseup', this.stopResizeCell)
@ -843,53 +623,6 @@ export default {
} }
} }
}, },
maybeUpdateOptions () {
delete this.field.default_value
if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
delete this.field.options
}
if (this.field.type === 'heading') {
this.field.readonly = true
}
if (this.field.type === 'strikethrough') {
this.field.readonly = true
this.field.default_value = true
}
if (['select', 'multiple', 'radio'].includes(this.field.type)) {
this.field.options ||= [{ value: '', uuid: v4() }]
}
(this.field.areas || []).forEach((area) => {
if (this.field.type === 'cells') {
area.cell_w = area.w * 2 / Math.floor(area.w / area.h)
} else {
delete area.cell_w
}
})
},
onNameBlur (e) {
if (e.relatedTarget === this.$refs.settingsButton) {
this.isSettingsFocus = true
}
const text = this.$refs.name.innerText.trim()
this.isNameFocus = false
this.$refs.name.style.minWidth = ''
if (text) {
this.field.name = text
} else {
this.field.name = ''
this.$refs.name.innerText = this.defaultName
}
this.save()
},
onDefaultValueBlur (e) { onDefaultValueBlur (e) {
const text = this.$refs.defaultValue.innerText.trim() const text = this.$refs.defaultValue.innerText.trim()
@ -927,9 +660,6 @@ export default {
this.$refs.defaultValue.blur() this.$refs.defaultValue.blur()
} }
}, },
onNameEnter (e) {
this.$refs.name.blur()
},
resize (e) { resize (e) {
if (e.target.id === 'mask') { if (e.target.id === 'mask') {
this.area.w = e.offsetX / e.target.clientWidth - this.area.x this.area.w = e.offsetX / e.target.clientWidth - this.area.x
@ -1124,7 +854,7 @@ export default {
this.selectedAreasRef.value = [this.area] this.selectedAreasRef.value = [this.area]
} }
this.$refs?.name?.blur() this.$refs?.title?.$refs?.name?.blur()
e.preventDefault() e.preventDefault()

@ -0,0 +1,404 @@
<template>
<div
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
style="top: -25px; height: 25px"
@mousedown.stop
@pointerdown.stop
>
<FieldSubmitter
v-if="field.type != 'heading' && field.type != 'strikethrough'"
v-model="field.submitter_uuid"
class="border-r roles-dropdown"
:compact="true"
:editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
:allow-add-new="!defaultSubmitters.length"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
:submitters="template.submitters"
@update:model-value="$emit('change')"
@click="selectedAreasRef.value = [area]"
/>
<FieldType
v-model="field.type"
:button-width="27"
:editable="editable && !defaultField"
:button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), $emit('change')]"
@click="selectedAreasRef.value = [area]"
/>
<span
v-if="field.type !== 'checkbox' || field.name"
ref="name"
:contenteditable="editable && !defaultField && field.type !== 'heading'"
dir="auto"
class="pr-1 cursor-text outline-none block"
style="min-width: 2px"
@paste.prevent="onPaste"
@keydown.enter.prevent="onNameEnter"
@focus="onNameFocus"
@blur="onNameBlur"
>{{ optionIndexText }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || defaultName }}</span>
<div
v-if="isSettingsFocus || isSelectInput || (isValueInput && field.type !== 'heading') || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
class="flex items-center ml-1.5"
>
<input
v-if="!isValueInput && !isSelectInput"
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
v-if="!isValueInput && !isSelectInput"
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>{{ t('required') }}</label>
<input
v-if="isValueInput || isSelectInput"
:id="`readonly-checkbox-${field.uuid}`"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
:checked="!(field.readonly ?? true)"
@change="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>
<label
v-if="isValueInput || isSelectInput"
:for="`readonly-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>{{ t('editable') }}</label>
<span
v-if="field.type !== 'payment' && !isValueInput"
class="dropdown dropdown-end field-area-settings-dropdown"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
ref="settingsButton"
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center"
style="height: 25px"
@focus="isSettingsFocus = true"
@blur="maybeBlurSettings"
>
<IconDotsVertical class="w-5 h-5" />
</label>
<ul
v-if="renderDropdown"
ref="settingsDropdown"
tabindex="0"
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
:style="{ backgroundColor: 'white' }"
@dragstart.prevent.stop
@click="closeDropdown"
@focusout="maybeBlurSettings"
>
<FieldSettings
v-if="isMobile"
:field="field"
:default-field="defaultField"
:editable="editable"
:background-color="'white'"
:with-required="false"
:with-areas="false"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@add-custom-field="$emit('add-custom-field')"
@click-condition="isShowConditionsModal = true"
@save="$emit('change')"
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
/>
<div
v-else
class="whitespace-normal"
>
The dots menu is retired in favor of the field context menu. Right-click the field to access field settings. Double-click the field to set a default value.
</div>
</ul>
</span>
</div>
<button
v-else-if="editable"
class="pr-1"
:title="t('remove')"
@click.prevent="$emit('remove')"
>
<IconX width="14" />
</button>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('change')"
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('change')"
@close="isShowFontModal = false"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="field"
:build-default-name="buildDefaultName"
:default-field="defaultField"
@save="$emit('change')"
@close="isShowConditionsModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('change')"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div>
</template>
<script>
import FieldSubmitter from './field_submitter'
import FieldType from './field_type'
import Field from './field'
import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconX, IconDotsVertical } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
name: 'AreaTitle',
components: {
FieldType,
FieldSettings,
FormulaModal,
FontModal,
IconDotsVertical,
DescriptionModal,
ConditionsModal,
FieldSubmitter,
IconX
},
inject: ['t'],
props: {
template: {
type: Object,
required: true
},
selectedAreasRef: {
type: Object,
required: true
},
getFieldTypeIndex: {
type: Function,
required: true
},
area: {
type: Object,
required: true
},
field: {
type: Object,
required: false,
default: null
},
defaultField: {
type: Object,
required: false,
default: null
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
withPrefillable: {
type: Boolean,
required: false,
default: false
},
defaultSubmitters: {
type: Array,
required: false,
default: () => []
},
editable: {
type: Boolean,
required: false,
default: true
},
isMobile: {
type: Boolean,
required: false,
default: false
},
isValueInput: {
type: Boolean,
required: false,
default: false
},
isSelectInput: {
type: Boolean,
required: false,
default: false
}
},
emits: ['remove', 'scroll-to', 'add-custom-field', 'change'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowConditionsModal: false,
isShowDescriptionModal: false,
isSettingsFocus: false,
renderDropdown: false,
isNameFocus: false
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
submitter () {
return this.template.submitters.find((s) => s.uuid === this.field.submitter_uuid)
},
isSelected () {
return this.selectedAreasRef.value.includes(this.area)
},
isInMultiSelection () {
return this.selectedAreasRef.value.length >= 2 && this.isSelected
},
optionIndexText () {
if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
} else {
return ''
}
},
defaultName () {
return this.buildDefaultName(this.field)
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
}
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
maybeBlurSettings (e) {
if (!e.relatedTarget || !this.$refs.settingsDropdown.contains(e.relatedTarget)) {
this.isSettingsFocus = false
}
},
onNameFocus (e) {
this.selectedAreasRef.value = [this.area]
this.isNameFocus = true
this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px'
if (!this.field.name) {
setTimeout(() => {
this.$refs.name.innerText = ' '
}, 1)
}
},
onNameBlur (e) {
if (e.relatedTarget === this.$refs.settingsButton) {
this.isSettingsFocus = true
}
const text = this.$refs.name.innerText.trim()
this.isNameFocus = false
this.$refs.name.style.minWidth = ''
if (text) {
this.field.name = text
} else {
this.field.name = ''
this.$refs.name.innerText = this.defaultName
}
this.$emit('change')
},
onNameEnter (e) {
this.$refs.name.blur()
},
onPaste (e) {
const text = (e.clipboardData || window.clipboardData).getData('text/plain')
const selection = this.$el.getRootNode().getSelection()
if (selection.rangeCount) {
selection.deleteFromDocument()
selection.getRangeAt(0).insertNode(document.createTextNode(text))
selection.collapseToEnd()
}
},
maybeUpdateOptions () {
delete this.field.default_value
if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
delete this.field.options
}
if (this.field.type === 'heading') {
this.field.readonly = true
}
if (this.field.type === 'strikethrough') {
this.field.readonly = true
this.field.default_value = true
}
if (['select', 'multiple', 'radio'].includes(this.field.type)) {
this.field.options ||= [{ value: '', uuid: v4() }]
}
(this.field.areas || []).forEach((area) => {
if (this.field.type === 'cells') {
area.cell_w = area.w * 2 / Math.floor(area.w / area.h)
} else {
delete area.cell_w
}
})
}
}
}
</script>

@ -81,7 +81,7 @@
/> />
<template v-else> <template v-else>
<form <form
v-if="withSignYourselfButton && undefinedSubmitters.length < 2" v-if="withSignYourselfButton && undefinedSubmitters.length < 2 && (!template.variables_schema || Object.keys(template.variables_schema).length === 0)"
target="_blank" target="_blank"
data-turbo="false" data-turbo="false"
class="inline" class="inline"
@ -274,6 +274,8 @@
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
:with-replace-button="withUploadButton" :with-replace-button="withUploadButton"
:editable="editable" :editable="editable"
:dynamic-documents="dynamicDocuments"
:with-dynamic-documents="withDynamicDocuments"
:template="template" :template="template"
@scroll-to="scrollIntoDocument(item)" @scroll-to="scrollIntoDocument(item)"
@remove="onDocumentRemove" @remove="onDocumentRemove"
@ -352,10 +354,20 @@
</template> </template>
<template v-else> <template v-else>
<template <template
v-for="document in sortedDocuments" v-for="(document, index) in sortedDocuments"
:key="document.uuid" :key="document.uuid"
> >
<DynamicDocument
v-if="template.schema[index].dynamic"
:ref="setDocumentRefs"
:editable="editable"
:document="dynamicDocuments.find((dynamicDocument) => dynamicDocument.uuid === document.uuid)"
:selected-submitter="selectedSubmitter"
:drag-field="dragField"
@update="onDynamicDocumentUpdate"
/>
<Document <Document
v-else
:ref="setDocumentRefs" :ref="setDocumentRefs"
:areas-index="fieldAreasIndex[document.uuid]" :areas-index="fieldAreasIndex[document.uuid]"
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
@ -505,6 +517,7 @@
@change-submitter="selectedSubmitter = $event" @change-submitter="selectedSubmitter = $event"
@drag-end="[dragField = null, $refs.dragPlaceholder.dragPlaceholder = null]" @drag-end="[dragField = null, $refs.dragPlaceholder.dragPlaceholder = null]"
@scroll-to-area="scrollToArea" @scroll-to-area="scrollToArea"
@rebuild-variables-schema="rebuildVariablesSchema"
/> />
</div> </div>
</div> </div>
@ -592,12 +605,13 @@ import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter' import FieldSubmitter from './field_submitter'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload } from '@tabler/icons-vue' import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload } from '@tabler/icons-vue'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import { ref, computed, toRaw } from 'vue' import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
import * as i18n from './i18n' import * as i18n from './i18n'
export default { export default {
name: 'TemplateBuilder', name: 'TemplateBuilder',
components: { components: {
DynamicDocument: defineAsyncComponent(() => import(/* webpackChunkName: "dynamic-editor" */ './dynamic_document')),
Upload, Upload,
DragPlaceholder, DragPlaceholder,
Document, Document,
@ -725,6 +739,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
dynamicDocuments: {
type: Array,
required: false,
default: () => []
},
customFields: { customFields: {
type: Array, type: Array,
required: false, required: false,
@ -846,6 +865,11 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
withDynamicDocuments: {
type: Boolean,
required: false,
default: false
},
withDocumentsList: { withDocumentsList: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -2520,7 +2544,7 @@ export default {
onDocumentReplace (data) { onDocumentReplace (data) {
const { replaceSchemaItem, schema, documents } = data const { replaceSchemaItem, schema, documents } = data
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
const { google_drive_file_id, ...cleanedReplaceSchemaItem } = replaceSchemaItem const { google_drive_file_id, dynamic, ...cleanedReplaceSchemaItem } = replaceSchemaItem
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...cleanedReplaceSchemaItem, ...schema[0] }) this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...cleanedReplaceSchemaItem, ...schema[0] })
this.template.documents.push(...documents) this.template.documents.push(...documents)
@ -2654,7 +2678,12 @@ export default {
} else { } else {
this.isSaving = true this.isSaving = true
this.save().then(() => { this.documentRefs.filter((ref) => ref.update).map((ref) => ref.update())
this.rebuildVariablesSchema({ disable: false })
const dynamicDocumentSaves = this.documentRefs.filter((ref) => ref.saveBody).map((ref) => ref.saveBody())
Promise.all([this.save(), ...dynamicDocumentSaves]).then(() => {
window.Turbo.visit(`/templates/${this.template.id}`) window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => { }).finally(() => {
this.isSaving = false this.isSaving = false
@ -2893,7 +2922,8 @@ export default {
name: this.template.name, name: this.template.name,
schema: this.template.schema, schema: this.template.schema,
submitters: this.template.submitters, submitters: this.template.submitters,
fields: this.template.fields fields: this.template.fields,
variables_schema: this.template.variables_schema
} }
}), }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
@ -2902,6 +2932,104 @@ export default {
this.onSave(this.template) this.onSave(this.template)
} }
}) })
},
onDynamicDocumentUpdate () {
this.rebuildVariablesSchema()
this.$nextTick(() => {
if (this.$el.closest('template-builder')) {
this.$el.closest('template-builder').dataset.dynamicDocuments = JSON.stringify(this.dynamicDocuments)
}
})
this.reconcileDynamicFields()
},
rebuildVariablesSchema ({ disable = true } = {}) {
const parsed = {}
const dynamicDocumentRef = this.documentRefs.find((e) => e.mergeSchemaProperties)
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]
}
} 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 })
}
}
}
},
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)
})
if (toRemove.length) {
this.save()
}
} }
} }
} }

@ -168,7 +168,7 @@ export default {
return this.previewImagesIndex[i] || reactive({ return this.previewImagesIndex[i] || reactive({
metadata: { ...lazyloadMetadata }, metadata: { ...lazyloadMetadata },
id: Math.random().toString(), id: Math.random().toString(),
url: this.basePreviewUrl + `/preview/${this.document.signed_uuid || this.document.uuid}/${i}.jpg` url: this.basePreviewUrl + `/preview/${this.document.signed_key || this.document.signed_uuid || this.document.uuid}/${i}.jpg`
}) })
}) })
}, },

@ -0,0 +1,282 @@
<template>
<span
class="items-center select-none cursor-pointer relative overflow-visible text-base-content/80 font-sans"
:class="[bgColorClass, iconOnlyField ? 'justify-center' : '']"
:draggable="editable"
:style="[nodeStyle]"
@mousedown="selectArea"
@click.stop
@dragstart="onDragStart"
@contextmenu.prevent.stop="onContextMenu"
>
<span
class="absolute inset-0 pointer-events-none border-solid"
:class="borderColorClass"
:style="{ borderWidth: (isSelected ? 1 : 0) + 'px' }"
/>
<component
:is="fieldIcons[field?.type || 'text']"
v-if="field && !field.default_value"
width="100%"
height="100%"
:stroke-width="1.5"
:class="iconOnlyField ? 'shrink min-h-0 max-h-full max-w-6 opacity-70 m-auto p-0.5' : 'shrink min-h-0 max-h-full max-w-4 opacity-70 mx-0.5 pl-0.5'"
/>
<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>
<span
v-else-if="field && !iconOnlyField"
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 opacity-70 font-normal pl-0.5"
>{{ displayLabel }}</span>
<span
class="absolute rounded-full bg-white border border-gray-400 shadow-md cursor-nwse-resize z-10"
:style="{ width: resizeHandleSize + 'px', height: resizeHandleSize + 'px', right: (-4 / zoom) + 'px', bottom: (-4 / zoom) + 'px' }"
@pointerdown.prevent.stop="onResizeStart"
/>
</span>
</template>
<script>
import FieldArea from './area'
import FieldType from './field_type'
export default {
name: 'DynamicArea',
props: {
fieldUuid: {
type: String,
required: true
},
areaUuid: {
type: String,
required: true
},
template: {
type: Object,
required: true
},
nodeStyle: {
type: Object,
required: true
},
selectedAreasRef: {
type: Object,
required: true
},
getPos: {
type: Function,
required: true
},
editor: {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
getZoom: {
type: Function,
required: true
},
onAreaContextMenu: {
type: Function,
required: true
},
onAreaResize: {
type: Function,
required: true
},
onAreaDragStart: {
type: Function,
required: true
},
t: {
type: Function,
required: true
},
findFieldArea: {
type: Function,
required: true
},
getFieldTypeIndex: {
type: Function,
required: true
}
},
data () {
return {
isResizing: false
}
},
computed: {
fieldArea () {
return this.findFieldArea(this.areaUuid)
},
area () {
return this.fieldArea?.area
},
field () {
return this.fieldArea?.field
},
fieldIcons: FieldArea.computed.fieldIcons,
fieldNames: FieldArea.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
borderColors () {
return [
'border-red-500/80',
'border-sky-500/80',
'border-emerald-500/80',
'border-yellow-300/80',
'border-purple-600/80',
'border-pink-500/80',
'border-cyan-500/80',
'border-orange-500/80',
'border-lime-500/80',
'border-indigo-500/80'
]
},
bgColors () {
return [
'bg-red-100',
'bg-sky-100',
'bg-emerald-100',
'bg-yellow-100',
'bg-purple-100',
'bg-pink-100',
'bg-cyan-100',
'bg-orange-100',
'bg-lime-100',
'bg-indigo-100'
]
},
isSelected () {
return this.selectedAreasRef.value.some((a) => a === this.area)
},
zoom () {
return this.getZoom()
},
submitterIndex () {
if (!this.field) return 0
const submitter = this.template.submitters.find((s) => s.uuid === this.field.submitter_uuid)
return submitter ? this.template.submitters.indexOf(submitter) : 0
},
borderColorClass () {
return this.borderColors[this.submitterIndex % this.borderColors.length]
},
bgColorClass () {
return this.bgColors[this.submitterIndex % this.bgColors.length]
},
resizeHandleSize () {
return this.zoom > 0 ? Math.round(10 / this.zoom) : 10
},
iconOnlyField () {
return ['radio', 'multiple', 'checkbox', 'initials'].includes(this.field?.type)
},
defaultName () {
if (!this.field) return 'text'
const typeIndex = this.getFieldTypeIndex(this.field)
return `${this.fieldLabels[this.field.type] || this.fieldNames[this.field.type] || this.field.type} ${typeIndex + 1}`
},
displayLabel () {
return this.field?.name || this.defaultName
}
},
methods: {
selectArea () {
this.editor.commands.setNodeSelection(this.getPos())
},
onDragStart (e) {
if (this.isResizing) {
e.preventDefault()
return
}
const pos = this.getPos()
if (pos == null) {
e.preventDefault()
return
}
const root = this.$el
const rect = root.getBoundingClientRect()
const zoom = this.zoom || 1
const clone = root.cloneNode(true)
clone.querySelector('[class*="cursor-nwse-resize"]')?.remove()
clone.style.cssText = `position:fixed;top:-1000px;width:${rect.width / zoom}px;height:${rect.height / zoom}px;display:${root.style.display};vertical-align:${root.style.verticalAlign};zoom:${zoom}`
document.body.appendChild(clone)
e.dataTransfer.setDragImage(clone, e.offsetX, e.offsetY)
requestAnimationFrame(() => clone.remove())
e.dataTransfer.effectAllowed = 'move'
this.onAreaDragStart()
},
onContextMenu (e) {
this.onAreaContextMenu(this.area, e)
},
onResizeStart (e) {
if (!this.editable) return
this.isResizing = true
this.selectArea()
const handle = e.target
handle.setPointerCapture(e.pointerId)
const startX = e.clientX
const startY = e.clientY
const startWidth = this.$el.offsetWidth
const startHeight = this.$el.offsetHeight
const onResizeMove = (e) => {
e.preventDefault()
this.nodeStyle.width = startWidth + (e.clientX - startX) / this.zoom + 'px'
this.nodeStyle.height = startHeight + (e.clientY - startY) / this.zoom + 'px'
this.onAreaResize(this.$el.getBoundingClientRect())
}
const onResizeEnd = () => {
if (!this.isResizing) return
this.isResizing = false
handle.removeEventListener('pointermove', onResizeMove)
handle.removeEventListener('pointerup', onResizeEnd)
const pos = this.getPos()
const tr = this.editor.view.state.tr.setNodeMarkup(pos, undefined, {
...this.editor.view.state.doc.nodeAt(pos)?.attrs,
width: this.nodeStyle.width,
height: this.nodeStyle.height
})
this.editor.view.dispatch(tr)
this.editor.commands.setNodeSelection(pos)
}
handle.addEventListener('pointermove', onResizeMove)
handle.addEventListener('pointerup', onResizeEnd)
}
}
}
</script>

@ -0,0 +1,225 @@
<template>
<div
ref="container"
class="relative"
style="container-type: inline-size;"
>
<div ref="shadow" />
<template
v-for="style in styles"
:key="style.innerText"
>
<Teleport
v-if="shadow"
:to="style.innerText.includes('@font-face {') ? 'head' : shadow"
>
<component :is="'style'">
{{ style.innerText }}
</component>
</Teleport>
</template>
<Teleport
v-if="shadow"
:to="shadow"
>
<DynamicSection
v-for="section in sections"
:ref="setSectionRefs"
:key="section.id"
:container="$refs.container"
:editable="editable"
:section="section"
:container-width="containerWidth"
:attachments-index="attachmentsIndex"
:selected-submitter="selectedSubmitter"
:drag-field="dragField"
:attachment-uuid="document.uuid"
@update="onSectionUpdate(section, $event)"
/>
</Teleport>
</div>
</template>
<script>
import DynamicSection from './dynamic_section.vue'
import { dynamicStylesheet, tiptapStylesheet } from './dynamic_editor.js'
import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js'
export default {
name: 'TemplateDynamicDocument',
components: {
DynamicSection
},
inject: ['baseFetch', 'template'],
props: {
document: {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
selectedSubmitter: {
type: Object,
required: false,
default: null
},
dragField: {
type: Object,
required: false,
default: null
}
},
emits: ['update'],
data () {
return {
containerWidth: 1040,
isMounted: false,
sectionRefs: []
}
},
computed: {
attachmentsIndex () {
return (this.document.attachments || []).reduce((acc, att) => {
acc[att.uuid] = att.url
return acc
}, {})
},
bodyDom () {
return new DOMParser().parseFromString(this.document.body, 'text/html')
},
headDom () {
return new DOMParser().parseFromString(this.document.head, 'text/html')
},
sections () {
return this.bodyDom.querySelectorAll('section')
},
styles () {
return this.headDom.querySelectorAll('style')
},
shadow () {
if (this.isMounted) {
return this.$refs.shadow.attachShadow({ mode: 'open' })
} else {
return null
}
}
},
mounted () {
this.isMounted = true
this.shadow.adoptedStyleSheets.push(dynamicStylesheet, tiptapStylesheet)
this.containerWidth = this.$refs.container.clientWidth
this.resizeObserver = new ResizeObserver(() => {
if (this.$refs.container) {
this.containerWidth = this.$refs.container.clientWidth
}
})
this.resizeObserver.observe(this.$refs.container)
window.addEventListener('beforeunload', this.onBeforeUnload)
},
beforeUnmount () {
window.removeEventListener('beforeunload', this.onBeforeUnload)
this.resizeObserver.unobserve(this.$refs.container)
},
beforeUpdate () {
this.sectionRefs = []
},
methods: {
mergeSchemaProperties,
setSectionRefs (ref) {
if (ref) {
this.sectionRefs.push(ref)
}
},
onBeforeUnload (event) {
if (this.saveTimer) {
event.preventDefault()
event.returnValue = ''
return ''
}
},
scrollToArea (area) {
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()
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
},
onSectionUpdate (section, { editor }) {
clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(async () => {
await this.updateSectionAndSave(section, editor)
delete this.saveTimer
}, 1000)
},
updateVariablesSchema () {
this.document.variables_schema = buildVariablesSchema(this.bodyDom.body)
},
updateSectionAndSave (section, editor) {
const target = this.bodyDom.getElementById(section.id)
if (target) {
target.innerHTML = editor.getHTML()
}
this.document.body = this.bodyDom.body.innerHTML
this.updateVariablesSchema()
this.$emit('update', this.document)
return this.saveBody()
},
updateAndSave () {
this.update()
return this.saveBody()
},
update () {
clearTimeout(this.saveTimer)
delete this.saveTimer
this.sectionRefs.forEach(({ section, editor }) => {
const target = this.bodyDom.getElementById(section.id)
target.innerHTML = editor.getHTML()
})
this.document.body = this.bodyDom.body.innerHTML
this.updateVariablesSchema()
this.$emit('update', this.document)
},
saveBody () {
clearTimeout(this.saveTimer)
delete this.saveTimer
return this.baseFetch(`/templates/${this.template.id}/dynamic_documents/${this.document.uuid}`, {
method: 'PUT',
body: JSON.stringify({ body: this.bodyDom.body.innerHTML }),
headers: { 'Content-Type': 'application/json' }
})
}
}
}
</script>

@ -0,0 +1,768 @@
import { Editor, Extension, Node, Mark } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import Document from '@tiptap/extension-document'
import Text from '@tiptap/extension-text'
import HardBreak from '@tiptap/extension-hard-break'
import History from '@tiptap/extension-history'
import Gapcursor from '@tiptap/extension-gapcursor'
import Dropcursor from '@tiptap/extension-dropcursor'
import { createApp, reactive } from 'vue'
import DynamicArea from './dynamic_area.vue'
import styles from './dynamic_styles.scss'
export const dynamicStylesheet = new CSSStyleSheet()
dynamicStylesheet.replaceSync(styles[0][1])
export const tiptapStylesheet = new CSSStyleSheet()
tiptapStylesheet.replaceSync(
`.ProseMirror {
position: relative;
}
.ProseMirror {
word-wrap: break-word;
white-space: pre-wrap;
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 */
}
.ProseMirror [contenteditable="false"] {
white-space: normal;
}
.ProseMirror [contenteditable="false"] [contenteditable="true"] {
white-space: pre-wrap;
}
.ProseMirror pre {
white-space: pre-wrap;
}
img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
width: 0 !important;
height: 0 !important;
}
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
margin: 0;
}
.ProseMirror-gapcursor:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-hideselection *::selection {
background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection * {
caret-color: transparent;
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
.variable-highlight {
background-color: #fef3c7;
}`)
function collectDomAttrs (dom) {
const attrs = {}
for (let i = 0; i < dom.attributes.length; i++) {
attrs[dom.attributes[i].name] = dom.attributes[i].value
}
return { htmlAttrs: attrs }
}
function collectSpanDomAttrs (dom) {
const result = collectDomAttrs(dom)
if (result.htmlAttrs.style) {
const temp = document.createElement('span')
temp.style.cssText = result.htmlAttrs.style
if (['bold', '700'].includes(temp.style.fontWeight)) {
temp.style.removeProperty('font-weight')
}
if (temp.style.fontStyle === 'italic') {
temp.style.removeProperty('font-style')
}
if (temp.style.textDecoration === 'underline') {
temp.style.removeProperty('text-decoration')
}
if (temp.style.cssText) {
result.htmlAttrs.style = temp.style.cssText
} else {
delete result.htmlAttrs.style
}
}
return result
}
function createBlockNode (name, tag, content) {
return Node.create({
name,
group: 'block',
content: content || 'block+',
addAttributes () {
return {
htmlAttrs: { default: {} }
}
},
parseHTML () {
return [{ tag, getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return [tag, node.attrs.htmlAttrs, 0]
}
})
}
const CustomParagraph = Node.create({
name: 'paragraph',
group: 'block',
content: 'inline*',
addAttributes () {
return {
htmlAttrs: { default: {} }
}
},
parseHTML () {
return [{ tag: 'p', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['p', node.attrs.htmlAttrs, 0]
}
})
const CustomHeading = Node.create({
name: 'heading',
group: 'block',
content: 'inline*',
addAttributes () {
return {
htmlAttrs: { default: {} },
level: { default: 1 }
}
},
parseHTML () {
return [1, 2, 3, 4, 5, 6].map((level) => ({
tag: `h${level}`,
getAttrs: (dom) => ({ ...collectDomAttrs(dom), level })
}))
},
renderHTML ({ node }) {
return [`h${node.attrs.level}`, node.attrs.htmlAttrs, 0]
}
})
const SectionNode = createBlockNode('section', 'section')
const ArticleNode = createBlockNode('article', 'article')
const DivNode = createBlockNode('div', 'div')
const BlockquoteNode = createBlockNode('blockquote', 'blockquote')
const PreNode = createBlockNode('pre', 'pre')
const OrderedListNode = createBlockNode('orderedList', 'ol', '(listItem | block)+')
const BulletListNode = createBlockNode('bulletList', 'ul', '(listItem | block)+')
const ListItemNode = Node.create({
name: 'listItem',
content: 'block+',
addAttributes () {
return {
htmlAttrs: { default: {} }
}
},
parseHTML () {
return [{ tag: 'li', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['li', node.attrs.htmlAttrs, 0]
}
})
const TableNode = Node.create({
name: 'table',
group: 'block',
content: '(colgroup | tableHead | tableBody | tableRow)+',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'table', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['table', node.attrs.htmlAttrs, 0]
}
})
const TableHead = Node.create({
name: 'tableHead',
content: 'tableRow+',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'thead', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['thead', node.attrs.htmlAttrs, 0]
}
})
const TableBody = Node.create({
name: 'tableBody',
content: 'tableRow+',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'tbody', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['tbody', node.attrs.htmlAttrs, 0]
}
})
const TableRow = Node.create({
name: 'tableRow',
content: '(tableCell | tableHeader)+',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'tr', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['tr', node.attrs.htmlAttrs, 0]
}
})
const TableCell = Node.create({
name: 'tableCell',
content: 'block*',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'td', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['td', node.attrs.htmlAttrs, 0]
}
})
const TableHeader = Node.create({
name: 'tableHeader',
content: 'block*',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'th', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['th', node.attrs.htmlAttrs, 0]
}
})
const ImageNode = Node.create({
name: 'image',
inline: true,
group: 'inline',
draggable: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'img', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['img', node.attrs.htmlAttrs]
}
})
const ColGroupNode = Node.create({
name: 'colgroup',
group: 'block',
content: 'col*',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'colgroup', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['colgroup', node.attrs.htmlAttrs, 0]
}
})
const ColNode = Node.create({
name: 'col',
group: 'block',
atom: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'col', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['col', node.attrs.htmlAttrs]
}
})
const CustomBold = Mark.create({
name: 'bold',
parseHTML () {
return [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }, { style: 'font-weight=700' }]
},
renderHTML () {
return ['strong', 0]
},
addCommands () {
return {
toggleBold: () => ({ commands }) => commands.toggleMark(this.name)
}
},
addKeyboardShortcuts () {
return {
'Mod-b': () => this.editor.commands.toggleBold()
}
}
})
const CustomItalic = Mark.create({
name: 'italic',
parseHTML () {
return [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }]
},
renderHTML () {
return ['em', 0]
},
addCommands () {
return {
toggleItalic: () => ({ commands }) => commands.toggleMark(this.name)
}
},
addKeyboardShortcuts () {
return {
'Mod-i': () => this.editor.commands.toggleItalic()
}
}
})
const CustomUnderline = Mark.create({
name: 'underline',
parseHTML () {
return [{ tag: 'u' }, { style: 'text-decoration=underline' }]
},
renderHTML () {
return ['u', 0]
},
addCommands () {
return {
toggleUnderline: () => ({ commands }) => commands.toggleMark(this.name)
}
},
addKeyboardShortcuts () {
return {
'Mod-u': () => this.editor.commands.toggleUnderline()
}
}
})
const CustomStrike = Mark.create({
name: 'strike',
parseHTML () {
return [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }]
},
renderHTML () {
return ['s', 0]
},
addCommands () {
return {
toggleStrike: () => ({ commands }) => commands.toggleMark(this.name)
}
},
addKeyboardShortcuts () {
return {
'Mod-Shift-s': () => this.editor.commands.toggleStrike()
}
}
})
const EmptySpanNode = Node.create({
name: 'emptySpan',
inline: true,
group: 'inline',
atom: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{
tag: 'span',
priority: 60,
getAttrs (dom) {
if (dom.childNodes.length === 0 && dom.attributes.length > 0) {
return collectDomAttrs(dom)
}
return false
}
}]
},
renderHTML ({ node }) {
return ['span', node.attrs.htmlAttrs]
}
})
const SpanMark = Mark.create({
name: 'span',
excludes: '',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'span', getAttrs: collectSpanDomAttrs }]
},
renderHTML ({ mark }) {
return ['span', mark.attrs.htmlAttrs, 0]
}
})
const LinkMark = Mark.create({
name: 'link',
excludes: '',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'a', getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return ['a', mark.attrs.htmlAttrs, 0]
}
})
const SubscriptMark = Mark.create({
name: 'subscript',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'sub', getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return ['sub', mark.attrs.htmlAttrs, 0]
}
})
const SuperscriptMark = Mark.create({
name: 'superscript',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'sup', getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return ['sup', mark.attrs.htmlAttrs, 0]
}
})
const TabHandler = Extension.create({
name: 'tabHandler',
addKeyboardShortcuts () {
return {
Tab: () => {
this.editor.commands.insertContent('\t')
return true
}
}
}
})
const variableHighlightKey = new PluginKey('variableHighlight')
function buildDecorations (doc) {
const decorations = []
const regex = /\[\[[^\]]*\]\]/g
doc.descendants((node, pos) => {
if (!node.isText) return
let match
while ((match = regex.exec(node.text)) !== null) {
const from = pos + match.index
const to = from + match[0].length
decorations.push(Decoration.inline(from, to, { class: 'variable-highlight' }))
}
})
return DecorationSet.create(doc, decorations)
}
const VariableHighlight = Extension.create({
name: 'variableHighlight',
addProseMirrorPlugins () {
return [
new Plugin({
key: variableHighlightKey,
state: {
init (_, { doc }) {
return buildDecorations(doc)
},
apply (tr, oldSet) {
if (tr.docChanged) {
return buildDecorations(tr.doc)
}
return oldSet
}
},
props: {
decorations (state) {
return this.getState(state)
},
handleTextInput (view, from, to, text) {
if (text !== '[') return false
const { state } = view
const charBefore = state.doc.textBetween(Math.max(from - 1, 0), from)
if (charBefore !== '[') return false
const tr = state.tr.insertText('[]]', from, to)
tr.setSelection(state.selection.constructor.create(tr.doc, from + 1))
view.dispatch(tr)
return true
}
}
})
]
}
})
export function buildEditor ({ dynamicAreaProps, attachmentsIndex, onFieldDrop, onFieldDestroy, editorOptions }) {
const FieldNode = Node.create({
name: 'fieldNode',
inline: true,
group: 'inline',
atom: true,
draggable: true,
addAttributes () {
return {
uuid: { default: null },
areaUuid: { default: null },
width: { default: '124px' },
height: { default: null },
verticalAlign: { default: 'text-bottom' },
display: { default: 'inline-flex' }
}
},
parseHTML () {
return [{
tag: 'dynamic-field',
getAttrs (dom) {
return {
uuid: dom.getAttribute('uuid'),
areaUuid: dom.getAttribute('area-uuid'),
width: dom.style.width,
height: dom.style.height,
display: dom.style.display,
verticalAlign: dom.style.verticalAlign
}
}
}]
},
renderHTML ({ node }) {
return ['dynamic-field', {
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};`
}]
},
addNodeView () {
return ({ node, getPos, editor }) => {
const dom = document.createElement('span')
const nodeStyle = reactive({
width: node.attrs.width,
height: node.attrs.height,
verticalAlign: node.attrs.verticalAlign,
display: node.attrs.display
})
dom.dataset.areaUuid = node.attrs.areaUuid
const shadow = dom.attachShadow({ mode: 'open' })
shadow.adoptedStyleSheets = [dynamicStylesheet]
const app = createApp(DynamicArea, {
fieldUuid: node.attrs.uuid,
areaUuid: node.attrs.areaUuid,
nodeStyle,
getPos,
editor,
editable: editorOptions.editable,
...dynamicAreaProps
})
app.mount(shadow)
return {
dom,
update (updatedNode) {
if (updatedNode.attrs.areaUuid === node.attrs.areaUuid) {
nodeStyle.width = updatedNode.attrs.width
nodeStyle.height = updatedNode.attrs.height
nodeStyle.verticalAlign = updatedNode.attrs.verticalAlign
nodeStyle.display = updatedNode.attrs.display
}
},
destroy () {
onFieldDestroy(node)
app.unmount()
}
}
}
}
})
const FieldDropPlugin = Extension.create({
name: 'fieldDrop',
addProseMirrorPlugins () {
return [
new Plugin({
key: new PluginKey('fieldDrop'),
props: {
handleDrop: onFieldDrop
}
})
]
}
})
const DynamicImageNode = ImageNode.extend({
renderHTML ({ node }) {
const { loading, ...attrs } = node.attrs.htmlAttrs
return ['img', attrs]
},
addNodeView () {
return ({ node }) => {
const dom = document.createElement('img')
const attrs = { ...node.attrs.htmlAttrs }
const blobUuid = attrs.src?.startsWith('blob:') && attrs.src.slice(5)
if (blobUuid && attachmentsIndex[blobUuid]) {
attrs.src = attachmentsIndex[blobUuid]
}
dom.setAttribute('loading', 'lazy')
Object.entries(attrs).forEach(([k, v]) => dom.setAttribute(k, v))
return { dom }
}
}
})
return new Editor({
extensions: [
Document,
Text,
HardBreak,
History,
Gapcursor,
Dropcursor,
CustomBold,
CustomItalic,
CustomUnderline,
CustomStrike,
CustomParagraph,
CustomHeading,
SectionNode,
ArticleNode,
DivNode,
BlockquoteNode,
PreNode,
OrderedListNode,
BulletListNode,
ListItemNode,
TableNode,
TableHead,
TableBody,
TableRow,
TableCell,
TableHeader,
ColGroupNode,
ColNode,
DynamicImageNode,
EmptySpanNode,
LinkMark,
SpanMark,
SubscriptMark,
SuperscriptMark,
VariableHighlight,
TabHandler,
FieldNode,
FieldDropPlugin
],
editorProps: {
attributes: {
style: 'outline: none'
}
},
parseOptions: {
preserveWhitespace: true
},
injectCSS: false,
...editorOptions
})
}

@ -0,0 +1,211 @@
<template>
<div
v-if="visible"
class="absolute z-10 flex items-center gap-0.5 px-1.5 py-1 bg-white border border-base-300 rounded-lg shadow select-none"
:style="{ top: (coords.top - 42) + 'px', left: coords.left + 'px', transform: 'translateX(-50%)' }"
@mousedown.prevent
>
<button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
:class="isBold ? 'bg-base-200' : 'bg-transparent'"
title="Bold"
@click="toggleBold"
>
<IconBold
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
:class="isItalic ? 'bg-base-200' : 'bg-transparent'"
title="Italic"
@click="toggleItalic"
>
<IconItalic
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
:class="isUnderline ? 'bg-base-200' : 'bg-transparent'"
title="Underline"
@click="toggleUnderline"
>
<IconUnderline
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
:class="isStrike ? 'bg-base-200' : 'bg-transparent'"
title="Strikethrough"
@click="toggleStrike"
>
<IconStrikethrough
:width="16"
:height="16"
/>
</button>
<div class="w-px h-5 bg-base-300 mx-1" />
<button
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent"
title="Wrap in variable"
@click="wrapVariable"
>
<IconBracketsContain
:width="16"
:height="16"
:stroke-width="1.6"
/>
<span class="px-0.5">
Variable
</span>
</button>
<div class="w-px h-5 bg-base-300 mx-1" />
<button
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent"
title="Wrap in condition"
@click="wrapCondition"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
class="tabler-icon tabler-icon-brackets-contain"
><path d="M7 4h-4v16h4" /><path d="M17 4h4v16h-4" />
<text
x="12"
y="16.5"
text-anchor="middle"
fill="currentColor"
stroke="none"
font-size="14"
font-weight="600"
font-family="ui-sans-serif, system-ui, sans-serif"
>if</text>
</svg>
<span class="px-0.5">
Condition
</span>
</button>
</div>
</template>
<script>
import { IconBold, IconItalic, IconUnderline, IconStrikethrough, IconBracketsContain } from '@tabler/icons-vue'
export default {
name: 'DynamicMenu',
components: {
IconBold,
IconItalic,
IconUnderline,
IconStrikethrough,
IconBracketsContain
},
props: {
editor: {
type: Object,
required: true
},
coords: {
type: Object,
required: false,
default: null
}
},
emits: ['add-variable', 'add-condition'],
data () {
return {
isMouseDown: false,
isBold: this.editor.isActive('bold'),
isItalic: this.editor.isActive('italic'),
isUnderline: this.editor.isActive('underline'),
isStrike: this.editor.isActive('strike')
}
},
computed: {
visible () {
return !!this.coords && !this.isMouseDown
}
},
mounted () {
this.editor.view.dom.addEventListener('mousedown', this.onMouseDown)
document.addEventListener('mouseup', this.onMouseUp)
this.editor.on('transaction', this.onTransaction)
},
beforeUnmount () {
if (!this.editor.isDestroyed) {
this.editor.view.dom.removeEventListener('mousedown', this.onMouseDown)
this.editor.off('transaction', this.onTransaction)
}
document.removeEventListener('mouseup', this.onMouseUp)
},
methods: {
toggleBold () {
this.editor.chain().focus().toggleBold().run()
},
toggleItalic () {
this.editor.chain().focus().toggleItalic().run()
},
toggleUnderline () {
this.editor.chain().focus().toggleUnderline().run()
},
toggleStrike () {
this.editor.chain().focus().toggleStrike().run()
},
wrapVariable () {
const { from, to } = this.editor.state.selection
const replacement = '[[variable]]'
const varFrom = from + 2
const varTo = varFrom + 8
this.editor.chain().focus()
.insertContentAt({ from, to }, replacement)
.setTextSelection({ from: varFrom, to: varTo })
.run()
this.$emit('add-variable')
},
wrapCondition () {
const { from, to } = this.editor.state.selection
const endText = '[[end]]'
const ifText = '[[if:variable]]'
this.editor.chain().focus()
.insertContentAt(to, endText)
.insertContentAt(from, ifText)
.setTextSelection({ from: from + 5, to: from + 13 })
.run()
this.$emit('add-condition')
},
onMouseDown () {
this.isMouseDown = true
},
onMouseUp () {
setTimeout(() => {
this.isMouseDown = false
}, 1)
},
onTransaction () {
this.isBold = this.editor.isActive('bold')
this.isItalic = this.editor.isActive('italic')
this.isUnderline = this.editor.isActive('underline')
this.isStrike = this.editor.isActive('strike')
}
}
}
</script>

@ -0,0 +1,487 @@
<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"
>
<div :style="{ zoom: containerWidth / sectionWidthPx }">
<section
:id="section.id"
ref="editorElement"
:class="section.classList.value"
:style="section.style.cssText"
/>
</div>
<Teleport
v-if="editor"
:to="container"
>
<div
v-if="areaToolbarCoords && selectedField && selectedArea && !isAreaDrag"
class="absolute z-10"
:style="{ left: areaToolbarCoords.left + 'px', top: areaToolbarCoords.top + 'px' }"
>
<AreaTitle
:area="selectedArea"
:field="selectedField"
:editable="editable"
:template="template"
:selected-areas-ref="selectedAreasRef"
:get-field-type-index="getFieldTypeIndex"
@remove="onRemoveSelectedArea"
@change="onSelectedAreaChange"
/>
</div>
<DynamicMenu
v-if="editable"
v-show="!selectedAreasRef.value.length"
:editor="editor"
:coords="dynamicMenuCoords"
@add-variable="dynamicMenuCoords = null"
@add-condition="dynamicMenuCoords = null"
/>
<FieldContextMenu
v-if="contextMenu && contextMenuField"
:context-menu="contextMenu"
:field="contextMenuField"
:with-copy-to-all-pages="false"
@close="closeContextMenu"
@delete="onContextMenuDelete"
@save="save"
/>
</Teleport>
</div>
</template>
<script>
import { shallowRef } from 'vue'
import { v4 } from 'uuid'
import FieldContextMenu from './field_context_menu.vue'
import AreaTitle from './area_title.vue'
import DynamicMenu from './dynamic_menu.vue'
import { buildEditor } from './dynamic_editor.js'
export default {
name: 'DynamicSection',
components: {
DynamicMenu,
FieldContextMenu,
AreaTitle
},
inject: ['template', 'save', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor'],
props: {
section: {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
container: {
type: Object,
required: true
},
containerWidth: {
type: Number,
required: true
},
attachmentsIndex: {
type: Object,
required: false,
default: () => ({})
},
selectedSubmitter: {
type: Object,
required: false,
default: null
},
dragField: {
type: Object,
required: false,
default: null
},
attachmentUuid: {
type: String,
required: false,
default: null
}
},
emits: ['update'],
data () {
return {
isAreaDrag: false,
areaToolbarCoords: null,
dynamicMenuCoords: null,
contextMenu: null
}
},
computed: {
defaultHeight () {
return CSS.supports('height', '1lh') ? '1lh' : '1em'
},
fieldAreaIndex () {
return (this.template.fields || []).reduce((acc, field) => {
field.areas?.forEach((area) => {
acc[area.uuid] = { area, field }
})
return acc
}, {})
},
defaultSizes () {
return {
checkbox: { width: '18px', height: '18px' },
radio: { width: '18px', height: '18px' },
multiple: { width: '18px', height: '18px' },
signature: { width: '140px', height: '50px' },
initials: { width: '40px', height: '32px' },
stamp: { width: '150px', height: '80px' },
kba: { width: '150px', height: '80px' },
verification: { width: '150px', height: '80px' },
image: { width: '200px', height: '100px' },
date: { width: '100px', height: this.defaultHeight },
text: { width: '120px', height: this.defaultHeight },
cells: { width: '120px', height: this.defaultHeight },
file: { width: '120px', height: this.defaultHeight },
payment: { width: '120px', height: this.defaultHeight },
number: { width: '80px', height: this.defaultHeight },
select: { width: '120px', height: this.defaultHeight },
phone: { width: '120px', height: this.defaultHeight }
}
},
editorRef: () => shallowRef(),
editor () {
return this.editorRef.value
},
sectionWidthPx () {
const pt = parseFloat(this.section.style.width)
return pt * (96 / 72)
},
zoom () {
return this.containerWidth / this.sectionWidthPx
},
isDraggingField () {
return !!(this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField)
},
selectedArea () {
return this.selectedAreasRef.value[0]
},
selectedField () {
if (this.selectedArea) {
return this.fieldAreaIndex[this.selectedArea.uuid]?.field
} else {
return null
}
},
contextMenuField () {
if (this.contextMenu?.areaUuid) {
return this.fieldAreaIndex[this.contextMenu.areaUuid].field
} else {
return null
}
}
},
watch: {
containerWidth () {
this.closeContextMenu()
if (this.dynamicMenuCoords && this.editor && !this.editor.state.selection.empty) {
this.$nextTick(() => this.setDynamicMenuCoords(this.editor))
}
}
},
mounted () {
this.initEditor()
},
beforeUnmount () {
if (this.editor) {
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)
},
removeArea (area) {
const { field } = this.fieldAreaIndex[area.uuid]
const areaIndex = field.areas.indexOf(area)
if (areaIndex !== -1) {
field.areas.splice(areaIndex, 1)
}
if (field.areas.length === 0) {
this.template.fields.splice(this.template.fields.indexOf(field), 1)
}
const pos = this.findAreaNodePos(area.uuid)
this.editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run()
this.save()
},
onSelectionUpdate ({ editor }) {
const { selection } = editor.state
if (selection.node?.type.name === 'fieldNode') {
const { areaUuid } = selection.node.attrs
const field = this.fieldAreaIndex[areaUuid]?.field
if (field) {
const area = field.areas.find((a) => a.uuid === areaUuid)
if (area) {
const dom = editor.view.nodeDOM(selection.from)
const areaEl = dom.shadowRoot.firstElementChild
if (areaEl) {
const rect = areaEl.getBoundingClientRect()
const containerRect = this.container.getBoundingClientRect()
this.areaToolbarCoords = {
left: rect.left - containerRect.left,
top: rect.top - containerRect.top
}
}
this.selectedAreasRef.value = [area]
}
}
} else {
this.areaToolbarCoords = null
this.selectedAreasRef.value = []
if (editor.state.selection.empty) {
this.dynamicMenuCoords = null
} else {
this.setDynamicMenuCoords(editor)
}
}
},
setDynamicMenuCoords (editor) {
const { from, to } = editor.state.selection
const view = editor.view
const start = view.coordsAtPos(from)
const end = view.coordsAtPos(to)
const containerRect = this.container.getBoundingClientRect()
const left = (start.left + end.right) / 2 - containerRect.left
this.dynamicMenuCoords = {
top: Math.min(start.top, end.top) - containerRect.top,
left: Math.max(80, Math.min(left, containerRect.width - 80))
}
},
onFieldDestroy (node) {
this.selectedAreasRef.value = []
const { areaUuid } = node.attrs
let nodeExistsInDoc = false
this.editor.state.doc.descendants((docNode) => {
if (docNode.attrs.areaUuid === areaUuid) {
nodeExistsInDoc = true
return false
}
})
if (nodeExistsInDoc) return
const fieldArea = this.fieldAreaIndex[areaUuid]
if (!fieldArea) return
const field = fieldArea.field
const areaIndex = field.areas.findIndex((a) => a.uuid === areaUuid)
if (areaIndex !== -1) {
field.areas.splice(areaIndex, 1)
}
if (!field.areas?.length) {
this.template.fields.splice(this.template.fields.indexOf(field), 1)
}
this.save()
},
onAreaResize (rect) {
const containerRect = this.container.getBoundingClientRect()
this.areaToolbarCoords = {
left: rect.left - containerRect.left,
top: rect.top - containerRect.top
}
},
onAreaDragStart () {
this.isAreaDrag = true
},
onAreaContextMenu (area, e) {
this.contextMenu = {
x: e.clientX,
y: e.clientY,
areaUuid: area.uuid
}
},
deselectArea () {
this.areaToolbarCoords = null
this.selectedAreasRef.value = []
},
closeContextMenu () {
this.contextMenu = null
},
onContextMenuDelete () {
const menu = this.contextMenu
const fieldArea = this.fieldAreaIndex[menu.areaUuid]
if (fieldArea) {
this.removeArea(fieldArea.area)
}
this.closeContextMenu()
this.deselectArea()
},
onRemoveSelectedArea () {
this.removeArea(this.selectedArea)
this.deselectArea()
this.save()
},
onSelectedAreaChange () {
this.save()
},
onFieldDrop (view, event, _slice, moved) {
this.isAreaDrag = false
if (moved) {
return
}
const draggedField = this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField
if (!draggedField) return false
event.preventDefault()
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
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.fieldsDragFieldRef.value = null
this.customDragFieldRef.value = null
this.editor.chain().focus().setNodeSelection(pos.pos).run()
this.save()
return true
}
}
}
</script>

@ -0,0 +1,22 @@
@config "../../../tailwind.dynamic.config.js";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: #e5e7eb;
}
::before,
::after {
--tw-content: '';
}
:host {
all: initial;
}

@ -0,0 +1,422 @@
<template>
<div class="group">
<div class="flex items-center justify-between py-1.5 px-0.5">
<div class="flex items-center space-x-1 min-w-0">
<FieldType
:model-value="formType"
:editable="editable"
:button-width="18"
:menu-classes="'mt-1.5'"
:menu-style="{ backgroundColor: dropdownBgColor }"
@update:model-value="onTypeChange"
/>
<span
class="truncate"
:title="path"
>{{ displayName }}</span>
<span
v-if="isArray"
class="text-xs bg-base-200 rounded px-1 flex-shrink-0"
>{{ t('list') }}</span>
</div>
<div
v-if="editable"
class="flex items-center flex-shrink-0"
>
<span
class="dropdown dropdown-end"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
tabindex="0"
:title="t('settings')"
class="cursor-pointer text-transparent group-hover:text-base-content"
>
<IconSettings
:width="18"
:stroke-width="1.6"
/>
</label>
<ul
v-if="renderDropdown"
tabindex="0"
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10"
:style="{ backgroundColor: dropdownBgColor }"
@click="closeDropdown"
>
<div
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="onTypeChange($event.target.value)"
>
<option
v-for="varType in variableTypes"
:key="varType"
:value="varType"
:selected="varType === formType"
>{{ t(varType) }}</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>{{ t('type') }}</label>
</div>
<div
v-if="formType === 'number'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[schema.format = $event.target.value, save()]"
>
<option
v-for="format in numberFormats"
:key="format"
:value="format"
:selected="format === schema.format || (format === 'none' && !schema.format)"
>{{ formatNumber(123456789.567, format) }}</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>{{ t('format') }}</label>
</div>
<div
v-if="['text', 'number'].includes(formType)"
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="schema.default_value"
:type="formType === 'number' ? 'number' : 'text'"
:placeholder="t('default_value')"
dir="auto"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
>
<label
v-if="schema.default_value"
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>{{ t('default_value') }}</label>
</div>
<div
v-if="formType === 'date'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
:value="schema.format || 'MM/DD/YYYY'"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[schema.format = $event.target.value, save()]"
>
<option
v-for="format in dateFormats"
:key="format"
:value="format"
>{{ formatDate(new Date(), format) }}</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>{{ t('format') }}</label>
</div>
<li
v-if="formType === 'date'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
:checked="schema.default_value === '{{date}}'"
type="checkbox"
class="toggle toggle-xs"
@change="[schema.default_value = $event.target.checked ? '{{date}}' : undefined, save()]"
>
<span class="label-text">{{ t('current_date') }}</span>
</label>
</li>
<div
v-if="['radio', 'select'].includes(formType)"
class="py-1.5 px-1 relative"
@click.stop
>
<select
dir="auto"
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent"
@change="[schema.default_value = $event.target.value || undefined, save()]"
>
<option
value=""
:selected="!schema.default_value"
>{{ t('none') }}</option>
<option
v-for="opt in (schema.options || [])"
:key="opt"
:value="opt"
:selected="schema.default_value === opt"
>{{ opt }}</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>{{ t('default_value') }}</label>
</div>
<li
v-if="formType === 'checkbox'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
:checked="schema.default_value === true"
type="checkbox"
class="toggle toggle-xs"
@change="[schema.default_value = $event.target.checked || undefined, save()]"
>
<span class="label-text">{{ t('checked') }}</span>
</label>
</li>
<li @click.stop>
<label class="cursor-pointer py-1.5">
<input
:checked="schema.required !== false"
type="checkbox"
class="toggle toggle-xs"
@change="[schema.required = $event.target.checked, save()]"
>
<span class="label-text">{{ t('required') }}</span>
</label>
</li>
</ul>
</span>
</div>
</div>
<div
v-if="['radio', 'select'].includes(formType) && schema.options"
ref="options"
class="pl-2 pr-1 pb-1.5 space-y-1.5"
>
<div
v-for="(option, index) in schema.options"
:key="index"
class="flex space-x-1.5 items-center"
>
<span class="text-sm w-3.5 select-none">{{ index + 1 }}.</span>
<input
:value="option"
class="w-full input input-primary input-xs text-sm bg-transparent"
type="text"
dir="auto"
:placeholder="`${t('option')} ${index + 1}`"
@blur="[schema.options.splice(index, 1, $event.target.value), save()]"
@keydown.enter="$event.target.value ? onOptionEnter(index, $event.target.value) : null"
>
<button
class="text-sm w-3.5"
tabindex="-1"
@click="[schema.options.splice(index, 1), save()]"
>
&times;
</button>
</div>
<button
class="text-center text-sm w-full pb-1"
@click="addOptionAndFocus((schema.options || []).length)"
>
+ {{ t('add_option') }}
</button>
</div>
</div>
</template>
<script>
import FieldType from './field_type'
import { IconSettings } from '@tabler/icons-vue'
export default {
name: 'DynamicVariable',
components: {
FieldType,
IconSettings
},
inject: ['t', 'save', 'backgroundColor'],
provide () {
return {
fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select']
}
},
props: {
path: {
type: String,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
groupKey: {
type: String,
default: ''
},
schema: {
type: Object,
required: true
},
isArray: {
type: Boolean,
default: false
}
},
data () {
return {
renderDropdown: false
}
},
computed: {
displayName () {
if (this.groupKey) {
const prefix = this.groupKey + (this.path.startsWith(this.groupKey + '[].') ? '[].' : '.')
return this.path.slice(prefix.length)
} else {
return this.path
}
},
dropdownBgColor () {
return ['', null, 'transparent'].includes(this.backgroundColor) ? 'white' : this.backgroundColor
},
schemaTypeToFormType () {
return { string: 'text', number: 'number', boolean: 'checkbox', date: 'date' }
},
formType () {
return this.schema.form_type || this.schemaTypeToFormType[this.schema.type] || 'text'
},
variableTypes () {
return ['text', 'number', 'date', 'checkbox', 'radio', 'select']
},
formTypeToSchemaType () {
return { text: 'string', number: 'number', date: 'date', checkbox: 'boolean', radio: 'string', select: 'string' }
},
numberFormats () {
return [
'none',
'usd',
'eur',
'gbp',
'comma',
'dot',
'space'
]
},
dateFormats () {
const formats = [
'MM/DD/YYYY',
'DD/MM/YYYY',
'YYYY-MM-DD',
'DD-MM-YYYY',
'DD.MM.YYYY',
'MMM D, YYYY',
'MMMM D, YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) {
formats.push('YYYY년 MM월 DD일')
}
if (this.schema.format && !formats.includes(this.schema.format)) {
formats.unshift(this.schema.format)
}
return formats
}
},
methods: {
onTypeChange (newType) {
this.schema.type = this.formTypeToSchemaType[newType] || 'string'
this.schema.form_type = newType
if (['radio', 'select'].includes(newType)) {
if (!this.schema.options || !this.schema.options.length) {
this.schema.options = ['', '']
}
} else {
delete this.schema.options
delete this.schema.default_value
delete this.schema.format
}
this.save()
},
onOptionEnter (index, value) {
this.schema.options.splice(index, 1, value)
this.schema.options.splice(index + 1, 0, '')
this.save()
this.$nextTick(() => {
this.$refs.options.querySelectorAll('input')[index + 1]?.focus()
})
},
addOptionAndFocus (index) {
if (!this.schema.options) {
this.schema.options = []
}
this.schema.options.splice(index, 0, '')
this.save()
this.$nextTick(() => {
this.$refs.options.querySelectorAll('input')[index]?.focus()
})
},
formatNumber (number, format) {
if (format === 'comma') {
return new Intl.NumberFormat('en-US').format(number)
} else if (format === 'usd') {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'gbp') {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'eur') {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'dot') {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else {
return number
}
},
formatDate (date, format) {
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
const dayFormats = { D: 'numeric', DD: '2-digit' }
const yearFormats = { YYYY: 'numeric', YY: '2-digit' }
const parts = new Intl.DateTimeFormat([], {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)]
}).formatToParts(date)
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
},
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
}
}
}
</script>

@ -0,0 +1,134 @@
<template>
<div>
<div
v-if="!schemaEntries.length"
class="text-center py-4 px-2"
>
<p class="font-medium">
{{ t('no_variables') }}
</p>
<p class="text-sm mt-1">
{{ t('no_variables_description') }}
</p>
</div>
<template v-else>
<template
v-for="([key, node], index) in schemaEntries"
:key="key"
>
<div v-if="isGroup(node)">
<hr
v-if="index > 0"
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="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">
<template
v-for="[varNode, varPath] in nestedVariables(node, key)"
:key="varPath"
>
<hr class="border-base-300">
<DynamicVariable
:path="varPath"
:group-key="key"
:editable="editable"
:schema="varNode"
/>
</template>
</div>
</div>
<template v-else>
<hr
v-if="index > 0"
class="border-base-300"
>
<DynamicVariable
:path="key"
:editable="editable"
:schema="node.type === 'array' && node.items ? node.items : node"
:is-array="node.type === 'array'"
/>
</template>
</template>
</template>
</div>
</template>
<script>
import DynamicVariable from './dynamic_variable'
import { IconChevronDown, IconChevronRight } from '@tabler/icons-vue'
export default {
name: 'DynamicVariables',
components: {
DynamicVariable,
IconChevronDown,
IconChevronRight
},
inject: ['t', 'template', 'save', 'backgroundColor'],
props: {
editable: {
type: Boolean,
required: false,
default: true
}
},
computed: {
schemaEntries () {
return Object.entries(this.template.variables_schema || {}).filter(([, node]) => !node.disabled)
}
},
methods: {
isGroup (node) {
return (node.type === 'object' && node.properties) || (node.type === 'array' && node.items?.properties)
},
nestedVariables (node, groupKey) {
const properties = node.type === 'array' ? node.items?.properties : node.properties
if (!properties) return []
const prefix = node.type === 'array' ? `${groupKey}[]` : groupKey
return this.collectLeafVariables(properties, prefix)
},
collectLeafVariables (properties, prefix) {
return Object.entries(properties).reduce((result, [key, node]) => {
if (node.disabled) return result
const path = `${prefix}.${key}`
if (node.type === 'object' && node.properties) {
result.push(...this.collectLeafVariables(node.properties, path))
} else if (node.type === 'array' && node.items?.properties) {
result.push(...this.collectLeafVariables(node.items.properties, `${path}[]`))
} else {
result.push([node, path])
}
return result
}, [])
}
}
}
</script>

@ -0,0 +1,559 @@
const KEYWORDS = ['if', 'else', 'for', 'end']
const TYPE_PRIORITY = { string: 3, number: 2, boolean: 1 }
const AND_OR_REGEXP = /\s+(AND|OR)\s+/i
const COMPARISON_OPERATORS_REGEXP = />=|<=|!=|==|>|<|=/
function buildTokens (elem, acc = []) {
if (elem.nodeType === Node.TEXT_NODE) {
if (elem.textContent) {
const text = elem.textContent
const re = /[[\]]/g
let match
let found = false
while ((match = re.exec(text)) !== null) {
found = true
acc.push({
elem,
value: match[0],
textLength: text.length,
index: match.index
})
}
if (!found) {
acc.push({ elem, value: '', textLength: 0, index: 0 })
}
}
} else {
for (const child of elem.childNodes) {
buildTokens(child, acc)
}
}
return acc
}
function tokensPair (cur, nxt) {
if (cur.elem === nxt.elem) {
return cur.elem.textContent.slice(cur.index + 1, nxt.index).trim() === ''
} else {
return cur.elem.textContent.slice(cur.index + 1).trim() === '' &&
nxt.elem.textContent.slice(0, nxt.index).trim() === ''
}
}
function buildTags (tokens) {
const normalized = []
for (let i = 0; i < tokens.length - 1; i++) {
const cur = tokens[i]
const nxt = tokens[i + 1]
if (cur.value === '[' && nxt.value === '[' && tokensPair(cur, nxt)) {
normalized.push(['open', cur])
} else if (cur.value === ']' && nxt.value === ']' && tokensPair(cur, nxt)) {
normalized.push(['close', nxt])
}
}
const tags = []
for (let i = 0; i < normalized.length - 1; i++) {
const [curOp, openToken] = normalized[i]
const [nxtOp, closeToken] = normalized[i + 1]
if (curOp === 'open' && nxtOp === 'close') {
tags.push({ openToken, closeToken, value: '' })
}
}
return tags
}
function findTextNodesInBranch (elements, toElem, acc) {
if (!elements || elements.length === 0) return acc
for (const elem of elements) {
if (elem.nodeType === Node.TEXT_NODE) {
acc.push(elem)
} else {
findTextNodesInBranch(Array.from(elem.childNodes), toElem, acc)
}
if (acc.length > 0 && acc[acc.length - 1] === toElem) return acc
}
return acc
}
function findTextNodesBetween (fromElem, toElem, acc = []) {
if (fromElem === toElem) return [fromElem]
let currentElement = fromElem
while (true) {
const parent = currentElement.parentNode
if (!parent) return acc
const children = Array.from(parent.childNodes)
const startIndex = children.indexOf(currentElement)
if (startIndex === -1) return acc
const elementsInBranch = children.slice(startIndex)
findTextNodesInBranch(elementsInBranch, toElem, acc)
if (acc.length > 0 && acc[acc.length - 1] === toElem) return acc
let p = elementsInBranch[0].parentNode
while (p && !p.nextSibling) {
p = p.parentNode
}
if (!p || !p.nextSibling) return acc
currentElement = p.nextSibling
}
}
function mapTagValues (tags) {
for (const tag of tags) {
const textNodes = findTextNodesBetween(tag.openToken.elem, tag.closeToken.elem)
for (const elem of textNodes) {
let part
if (tag.openToken.elem === elem && tag.closeToken.elem === elem) {
part = elem.textContent.slice(tag.openToken.index, tag.closeToken.index + 1)
} else if (tag.openToken.elem === elem) {
part = elem.textContent.slice(tag.openToken.index)
} else if (tag.closeToken.elem === elem) {
part = elem.textContent.slice(0, tag.closeToken.index + 1)
} else {
part = elem.textContent
}
tag.value += part
}
}
return tags
}
function parseTagTypeName (tagString) {
const val = tagString.replace(/[[\]]/g, '').trim()
const parts = val.split(':').map((s) => s.trim())
if (parts.length === 2 && KEYWORDS.includes(parts[0])) {
return [parts[0], parts[1]]
} else if (KEYWORDS.includes(val)) {
return [val, null]
} else {
return ['var', val]
}
}
function isSimpleVariable (str) {
const s = str.trim()
return !AND_OR_REGEXP.test(s) &&
!COMPARISON_OPERATORS_REGEXP.test(s) &&
!s.includes('(') &&
!s.includes('!') &&
!s.includes('&&') &&
!s.includes('||') &&
!s.startsWith('"') &&
!s.startsWith("'") &&
!/^-?\d/.test(s) &&
!/^(true|false)$/i.test(s)
}
function tokenizeCondition (str) {
const tokens = []
let pos = 0
str = str.trim()
while (pos < str.length) {
const rest = str.slice(pos)
let m
if ((m = rest.match(/^\s+/))) {
pos += m[0].length
} else if ((m = rest.match(/^(>=|<=|!=|==|>|<|=)/))) {
tokens.push({ type: 'operator', value: m[1] })
pos += m[1].length
} else if (rest[0] === '!') {
tokens.push({ type: 'not', value: '!' })
pos += 1
} else if (rest[0] === '(') {
tokens.push({ type: 'lparen', value: '(' })
pos += 1
} else if (rest[0] === ')') {
tokens.push({ type: 'rparen', value: ')' })
pos += 1
} else if (rest.startsWith('&&')) {
tokens.push({ type: 'and', value: 'AND' })
pos += 2
} else if ((m = rest.match(/^AND\b/i))) {
tokens.push({ type: 'and', value: 'AND' })
pos += 3
} else if (rest.startsWith('||')) {
tokens.push({ type: 'or', value: 'OR' })
pos += 2
} else if ((m = rest.match(/^OR\b/i))) {
tokens.push({ type: 'or', value: 'OR' })
pos += 2
} else if ((m = rest.match(/^"([^"]*)"/) || rest.match(/^'([^']*)'/))) {
tokens.push({ type: 'string', value: m[1] })
pos += m[0].length
} else if ((m = rest.match(/^(-?\d+\.?\d*)/))) {
tokens.push({ type: 'number', value: m[1].includes('.') ? parseFloat(m[1]) : parseInt(m[1], 10) })
pos += m[1].length
} else if ((m = rest.match(/^(true|false)\b/i))) {
tokens.push({ type: 'boolean', value: m[1].toLowerCase() === 'true' })
pos += m[1].length
} else if ((m = rest.match(/^([\p{L}_][\p{L}\p{N}_.]*)/u))) {
tokens.push({ type: 'variable', value: m[1] })
pos += m[1].length
} else {
pos += 1
}
}
return tokens
}
function parseOrExpr (tokens, pos) {
let left, right
;[left, pos] = parseAndExpr(tokens, pos)
while (pos < tokens.length && tokens[pos].type === 'or') {
pos += 1
;[right, pos] = parseAndExpr(tokens, pos)
left = { type: 'or', left, right }
}
return [left, pos]
}
function parseAndExpr (tokens, pos) {
let left, right
;[left, pos] = parsePrimary(tokens, pos)
while (pos < tokens.length && tokens[pos].type === 'and') {
pos += 1
;[right, pos] = parsePrimary(tokens, pos)
left = { type: 'and', left, right }
}
return [left, pos]
}
function parsePrimary (tokens, pos) {
if (pos >= tokens.length) return [null, pos]
if (tokens[pos].type === 'not') {
const [child, p] = parsePrimary(tokens, pos + 1)
return [{ type: 'not', child }, p]
}
if (tokens[pos].type === 'lparen') {
const [node, p] = parseOrExpr(tokens, pos + 1)
return [node, p < tokens.length && tokens[p].type === 'rparen' ? p + 1 : p]
}
return parseComparisonOrPresence(tokens, pos)
}
function parseComparisonOrPresence (tokens, pos) {
if (pos >= tokens.length || tokens[pos].type !== 'variable') return [null, pos]
const variableName = tokens[pos].value
pos += 1
if (pos < tokens.length && tokens[pos].type === 'operator') {
let operator = tokens[pos].value
if (operator === '=') operator = '=='
pos += 1
if (pos < tokens.length && ['string', 'number', 'variable', 'boolean'].includes(tokens[pos].type)) {
const valueToken = tokens[pos]
return [{
type: 'comparison',
variableName,
operator,
value: valueToken.value,
valueIsVariable: valueToken.type === 'variable'
}, pos + 1]
}
}
return [{ type: 'presence', variableName }, pos]
}
function parseCondition (conditionString) {
const stripped = conditionString.trim()
if (stripped.startsWith('!') && isSimpleVariable(stripped.slice(1))) {
return { type: 'not', child: { type: 'presence', variableName: stripped.slice(1) } }
}
if (isSimpleVariable(stripped)) {
return { type: 'presence', variableName: stripped }
}
const tokens = tokenizeCondition(stripped)
const [ast] = parseOrExpr(tokens, 0)
return ast
}
function extractConditionVariables (node, acc = []) {
if (!node) return acc
switch (node.type) {
case 'or':
case 'and':
extractConditionVariables(node.left, acc)
extractConditionVariables(node.right, acc)
break
case 'not':
extractConditionVariables(node.child, acc)
break
case 'comparison':
acc.push({
name: node.variableName,
type: node.valueIsVariable ? null : (typeof node.value === 'boolean' ? 'boolean' : (typeof node.value === 'number' ? 'number' : 'string'))
})
if (node.valueIsVariable) {
acc.push({ name: node.value, type: null })
}
break
case 'presence':
acc.push({ name: node.variableName, type: 'boolean' })
break
}
return acc
}
function singularize (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)
if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1)
return word
}
function buildOperators (tags) {
const operators = []
const stack = [{ children: operators, operator: null }]
for (const tag of tags) {
const [type, variableName] = parseTagTypeName(tag.value)
switch (type) {
case 'for':
case 'if': {
const operator = { type, variableName, tag, children: [] }
if (type === 'if') {
try {
operator.condition = parseCondition(variableName)
} catch (e) {
// ignore parse errors
}
}
stack[stack.length - 1].children.push(operator)
stack.push({ children: operator.children, operator })
break
}
case 'else': {
const current = stack[stack.length - 1]
if (current.operator && current.operator.type === 'if') {
current.operator.elseTag = tag
current.operator.elseChildren = []
current.children = current.operator.elseChildren
}
break
}
case 'end': {
const popped = stack.pop()
if (popped.operator) {
popped.operator.endTag = tag
}
break
}
case 'var':
stack[stack.length - 1].children.push({ type, variableName, tag })
break
}
}
return operators
}
function assignNestedSchema (propertiesHash, parentProperties, keyString, value) {
const keys = keyString.split('.')
const lastKey = keys.pop()
let currentLevel = null
if (keys.length > 0 && parentProperties[keys[0]]) {
currentLevel = keys.reduce((current, key) => {
if (!current[key]) current[key] = { type: 'object', properties: {} }
return current[key].properties
}, parentProperties)
}
if (!currentLevel) {
currentLevel = keys.reduce((current, key) => {
if (!current[key]) current[key] = { type: 'object', properties: {} }
return current[key].properties
}, propertiesHash)
}
currentLevel[lastKey] = value
}
function assignNestedSchemaWithPriority (propertiesHash, parentProperties, keyString, newType) {
const keys = keyString.split('.')
const lastKey = keys.pop()
let currentLevel = null
if (keys.length > 0 && parentProperties[keys[0]]) {
currentLevel = keys.reduce((current, key) => {
if (!current[key]) current[key] = { type: 'object', properties: {} }
return current[key].properties
}, parentProperties)
}
if (!currentLevel) {
currentLevel = keys.reduce((current, key) => {
if (!current[key]) current[key] = { type: 'object', properties: {} }
return current[key].properties
}, propertiesHash)
}
const existing = currentLevel[lastKey]
if (existing && (TYPE_PRIORITY[newType] || 0) <= (TYPE_PRIORITY[existing.type] || 0)) return
currentLevel[lastKey] = { type: newType }
}
function processConditionVariables (condition, propertiesHash, parentProperties) {
const variables = extractConditionVariables(condition)
for (const varInfo of variables) {
assignNestedSchemaWithPriority(propertiesHash, parentProperties, varInfo.name, varInfo.type || 'boolean')
}
}
function processOperators (operators, propertiesHash = {}, parentProperties = {}) {
if (!operators || operators.length === 0) return propertiesHash
for (const op of operators) {
switch (op.type) {
case 'var': {
if (!op.variableName.includes('.') && parentProperties[op.variableName]) {
const item = parentProperties[op.variableName]
if (item && item.type === 'object' && item.properties && Object.keys(item.properties).length === 0) {
delete item.properties
item.type = 'string'
}
} else {
assignNestedSchema(propertiesHash, parentProperties, op.variableName, { type: 'string' })
}
break
}
case 'if':
if (op.condition) {
processConditionVariables(op.condition, propertiesHash, parentProperties)
}
processOperators(op.children, propertiesHash, parentProperties)
processOperators(op.elseChildren, propertiesHash, parentProperties)
break
case 'for': {
const parts = op.variableName.split('.')
const singularKey = singularize(parts[parts.length - 1])
let itemProperties = parentProperties[singularKey]?.items
itemProperties = itemProperties || propertiesHash[parts[0]]?.items
itemProperties = itemProperties || { type: 'object', properties: {} }
assignNestedSchema(propertiesHash, parentProperties, op.variableName, { type: 'array', items: itemProperties })
processOperators(op.children, propertiesHash, { ...parentProperties, [singularKey]: itemProperties })
break
}
}
}
return propertiesHash
}
function mergeSchemaProperties (target, source) {
for (const key of Object.keys(source)) {
if (!target[key]) {
target[key] = source[key]
} else if (target[key].type === 'object' && source[key].type === 'object') {
if (!target[key].properties) target[key].properties = {}
if (source[key].properties) {
mergeSchemaProperties(target[key].properties, source[key].properties)
}
} else if (target[key].type === 'array' && source[key].type === 'array') {
if (source[key].items && source[key].items.properties) {
if (!target[key].items) {
target[key].items = source[key].items
} else if (target[key].items.properties) {
mergeSchemaProperties(target[key].items.properties, source[key].items.properties)
}
} else if (source[key].items && !target[key].items) {
target[key].items = source[key].items
}
} else if ((TYPE_PRIORITY[source[key].type] || 0) > (TYPE_PRIORITY[target[key].type] || 0)) {
target[key] = source[key]
}
}
return target
}
function buildVariablesSchema (dom) {
const tokens = buildTokens(dom)
const tags = mapTagValues(buildTags(tokens))
const operators = buildOperators(tags)
return processOperators(operators)
}
export { buildVariablesSchema, mergeSchemaProperties, buildOperators, buildTokens, buildTags, mapTagValues }

@ -516,7 +516,7 @@
</label> </label>
</li> </li>
<hr <hr
v-if="withCopyToAllPages || withAreas || withCustomFields" v-if="(withCopyToAllPages && canCopyToAllPages) || withAreas || withCustomFields"
class="pb-0.5 mt-0.5" class="pb-0.5 mt-0.5"
> >
<template v-if="withAreas"> <template v-if="withAreas">
@ -561,7 +561,7 @@
</li> </li>
</template> </template>
<li <li
v-if="withCopyToAllPages && field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)" v-if="withCopyToAllPages && canCopyToAllPages && field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)"
class="field-settings-copy-to-all-pages" class="field-settings-copy-to-all-pages"
> >
<a <a
@ -681,6 +681,15 @@ export default {
return acc return acc
}, {}) }, {})
}, },
canCopyToAllPages () {
const firstArea = this.field.areas[0]
if (firstArea) {
return firstArea.page !== null && firstArea.page !== undefined
} else {
return false
}
},
numberFormats () { numberFormats () {
return [ return [
'none', 'none',
@ -744,7 +753,7 @@ export default {
return ['text', 'number', 'cells', 'date', 'checkbox', 'select', 'radio', 'phone'] return ['text', 'number', 'cells', 'date', 'checkbox', 'select', 'radio', 'phone']
}, },
sortedAreas () { sortedAreas () {
return (this.field.areas || []).sort((a, b) => { return (this.field.areas || []).filter((e) => e.page !== null && e.page !== undefined).sort((a, b) => {
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid] return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
}) })
} }

@ -1,19 +1,42 @@
<template> <template>
<div :class="withStickySubmitters ? 'sticky top-0 z-[1]' : ''"> <div
:class="withStickySubmitters ? 'sticky top-0 z-[1]' : ''"
:style="withStickySubmitters ? { backgroundColor } : {}"
class="flex items-center gap-1"
>
<FieldSubmitter <FieldSubmitter
:model-value="selectedSubmitter.uuid" :model-value="selectedSubmitter.uuid"
class="roles-dropdown w-full rounded-lg roles-dropdown" class="roles-dropdown w-full rounded-lg roles-dropdown"
:style="withStickySubmitters ? { backgroundColor } : {}"
:submitters="submitters" :submitters="submitters"
:menu-style="{ overflow: 'auto', display: 'flex', flexDirection: 'row', maxHeight: 'calc(100vh - 120px)', backgroundColor: ['', null, 'transparent'].includes(backgroundColor) ? 'white' : backgroundColor }" :menu-style="{ overflow: 'auto', display: 'flex', flexDirection: 'row', maxHeight: 'calc(100vh - 120px)', backgroundColor: ['', null, 'transparent'].includes(backgroundColor) ? 'white' : backgroundColor }"
:editable="editable && !defaultSubmitters.length" :editable="editable && !defaultSubmitters.length"
@new-submitter="save" @new-submitter="save"
@remove="removeSubmitter" @remove="removeSubmitter"
@name-change="save" @name-change="save"
@update:model-value="$emit('change-submitter', submitters.find((s) => s.uuid === $event))" @update:model-value="[$emit('change-submitter', submitters.find((s) => s.uuid === $event)), isShowVariables = false]"
/>
<button
v-if="hasDynamicDocuments"
class="flex-shrink-0 rounded-md border hover:border-content flex items-center justify-center self-stretch"
:class="isShowVariables ? 'border-base-content bg-base-content text-base-100' : 'border-base-300'"
style="width: 44px"
:title="t('variables')"
@click.prevent="toggleVariables"
>
<IconBracketsContain
:width="22"
:height="22"
:stroke-width="1.6"
/> />
</button>
</div> </div>
<DynamicVariables
v-if="isShowVariables"
:editable="editable"
class="mt-1"
/>
<div <div
v-if="!isShowVariables"
ref="fields" ref="fields"
class="fields mt-2" class="fields mt-2"
:class="{ 'mb-1': !withCustomFields || !customFields.length }" :class="{ 'mb-1': !withCustomFields || !customFields.length }"
@ -42,7 +65,7 @@
@set-draw="$emit('set-draw', $event)" @set-draw="$emit('set-draw', $event)"
/> />
</div> </div>
<div v-if="submitterDefaultFields.length && editable"> <div v-if="!isShowVariables && submitterDefaultFields.length && editable">
<hr class="mb-2"> <hr class="mb-2">
<template v-if="isShowFieldSearch"> <template v-if="isShowFieldSearch">
<input <input
@ -110,7 +133,7 @@
</div> </div>
</div> </div>
<div <div
v-if="editable && withCustomFields && (customFields.length || newCustomField)" v-if="!isShowVariables && editable && withCustomFields && (customFields.length || newCustomField)"
class="tabs w-full mb-1.5" class="tabs w-full mb-1.5"
> >
<a <a
@ -127,7 +150,7 @@
>{{ t('custom') }}</a> >{{ t('custom') }}</a>
</div> </div>
<div <div
v-if="showCustomTab && editable && (customFields.length || newCustomField)" v-if="!isShowVariables && showCustomTab && editable && (customFields.length || newCustomField)"
ref="customFields" ref="customFields"
class="custom-fields" class="custom-fields"
@dragover.prevent="onCustomFieldDragover" @dragover.prevent="onCustomFieldDragover"
@ -195,7 +218,7 @@
</div> </div>
</div> </div>
<div <div
v-if="editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField))" v-if="!isShowVariables && editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField))"
id="field-types-grid" id="field-types-grid"
class="grid grid-cols-3 gap-1 pb-2 fields-grid" class="grid grid-cols-3 gap-1 pb-2 fields-grid"
> >
@ -283,7 +306,7 @@
</template> </template>
</div> </div>
<div <div
v-if="fields.length < 4 && editable && withHelp && !showTourStartForm" v-if="!isShowVariables && fields.length < 4 && editable && withHelp && !showTourStartForm"
class="text-xs p-2 border border-base-200 rounded" class="text-xs p-2 border border-base-200 rounded"
> >
<ul class="list-disc list-outside ml-3"> <ul class="list-disc list-outside ml-3">
@ -299,7 +322,7 @@
</ul> </ul>
</div> </div>
<div <div
v-if="withFieldsDetection && editable && fields.length < 2" v-if="!isShowVariables && withFieldsDetection && editable && fields.length < 2 && !template.schema.some((item) => item.dynamic)"
class="my-2" class="my-2"
> >
<button <button
@ -336,7 +359,7 @@
</button> </button>
</div> </div>
<div <div
v-show="fields.length < 4 && editable && withHelp && showTourStartForm" v-show="!isShowVariables && fields.length < 4 && editable && withHelp && showTourStartForm"
class="rounded py-2 px-4 w-full border border-dashed border-base-300" class="rounded py-2 px-4 w-full border border-dashed border-base-300"
> >
<div class="text-center text-sm"> <div class="text-center text-sm">
@ -359,7 +382,8 @@ import Field from './field'
import CustomField from './custom_field' import CustomField from './custom_field'
import FieldType from './field_type' import FieldType from './field_type'
import FieldSubmitter from './field_submitter' import FieldSubmitter from './field_submitter'
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles } from '@tabler/icons-vue' import { defineAsyncComponent } from 'vue'
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles, IconBracketsContain } from '@tabler/icons-vue'
import IconDrag from './icon_drag' import IconDrag from './icon_drag'
import { v4 } from 'uuid' import { v4 } from 'uuid'
@ -374,7 +398,9 @@ export default {
IconInnerShadowTop, IconInnerShadowTop,
FieldSubmitter, FieldSubmitter,
IconDrag, IconDrag,
IconLock IconLock,
IconBracketsContain,
DynamicVariables: defineAsyncComponent(() => import(/* webpackChunkName: "dynamic-editor" */ './dynamic_variables'))
}, },
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'baseFetch', 'selectedAreasRef', 'getFieldTypeIndex'], inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'baseFetch', 'selectedAreasRef', 'getFieldTypeIndex'],
props: { props: {
@ -475,7 +501,7 @@ export default {
default: false 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'], 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'],
data () { data () {
return { return {
fieldPagesLoaded: null, fieldPagesLoaded: null,
@ -483,12 +509,16 @@ export default {
newCustomField: null, newCustomField: null,
showCustomTab: false, showCustomTab: false,
defaultFieldsSearch: '', defaultFieldsSearch: '',
customFieldsSearch: '' customFieldsSearch: '',
isShowVariables: false
} }
}, },
computed: { computed: {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons, fieldIcons: FieldType.computed.fieldIcons,
hasDynamicDocuments () {
return this.template.schema.some((item) => item.dynamic)
},
numberOfPages () { numberOfPages () {
return this.template.documents.reduce((acc, doc) => { return this.template.documents.reduce((acc, doc) => {
return acc + doc.metadata?.pdf?.number_of_pages || doc.preview_images.length return acc + doc.metadata?.pdf?.number_of_pages || doc.preview_images.length
@ -556,6 +586,10 @@ export default {
} }
}, },
methods: { methods: {
toggleVariables () {
this.$emit('rebuild-variables-schema')
this.isShowVariables = !this.isShowVariables
},
onDragstart (event, field) { onDragstart (event, field) {
this.removeDragOverlay(event) this.removeDragOverlay(event)
@ -582,6 +616,10 @@ export default {
delete customField.prefillable delete customField.prefillable
delete customField.conditions delete customField.conditions
if (Array.isArray(customField.areas)) {
customField.areas = customField.areas.filter((area) => area.page !== null && area.page !== undefined)
}
customField.areas?.forEach((area) => { customField.areas?.forEach((area) => {
delete area.attachment_uuid delete area.attachment_uuid
delete area.page delete area.page

@ -49,6 +49,10 @@ const en = {
with_logo: 'With logo', with_logo: 'With logo',
unchecked: 'Unchecked', unchecked: 'Unchecked',
price: 'Price', price: 'Price',
type: 'Type',
list: 'list',
no_variables: 'No variables yet',
no_variables_description: 'Add [[variable]] marks to your document to create dynamic content variables.',
type_value: 'Type value', type_value: 'Type value',
equal: 'Equal', equal: 'Equal',
not_equal: 'Not equal', not_equal: 'Not equal',
@ -73,6 +77,7 @@ const en = {
up: 'Up', up: 'Up',
down: 'Down', down: 'Down',
checked: 'Checked', checked: 'Checked',
current_date: 'Current date',
save: 'Save', save: 'Save',
cancel: 'Cancel', cancel: 'Cancel',
any: 'Any', any: 'Any',
@ -104,6 +109,7 @@ const en = {
option: 'Option', option: 'Option',
options: 'Options', options: 'Options',
condition: 'Condition', condition: 'Condition',
make_dynamic: 'Make dynamic',
first_party: 'First Party', first_party: 'First Party',
second_party: 'Second Party', second_party: 'Second Party',
third_party: 'Third Party', third_party: 'Third Party',
@ -244,6 +250,10 @@ const es = {
search_field: 'Campo de búsqueda', search_field: 'Campo de búsqueda',
field_not_found: 'Campo no encontrado', field_not_found: 'Campo no encontrado',
clear: 'Borrar', clear: 'Borrar',
type: 'Tipo',
list: 'lista',
no_variables: 'Aún sin variables',
no_variables_description: 'Agregue marcas [[variable]] a su documento para crear variables de contenido dinámico.',
type_value: 'Escriba valor', type_value: 'Escriba valor',
align: 'Alinear', align: 'Alinear',
resize: 'Redimensionar', resize: 'Redimensionar',
@ -277,6 +287,7 @@ const es = {
remove_condition: 'Eliminar condición', remove_condition: 'Eliminar condición',
add_condition: 'Agregar condición', add_condition: 'Agregar condición',
condition: 'Condición', condition: 'Condición',
make_dynamic: 'Hacer dinámico',
formula: 'Fórmula', formula: 'Fórmula',
edit: 'Editar', edit: 'Editar',
settings: 'Configuración', settings: 'Configuración',
@ -286,6 +297,7 @@ const es = {
are_you_sure_: '¿Estás seguro?', are_you_sure_: '¿Estás seguro?',
sign_yourself: 'Firma tú mismo', sign_yourself: 'Firma tú mismo',
checked: 'Seleccionado', checked: 'Seleccionado',
current_date: 'Fecha actual',
send: 'Enviar', send: 'Enviar',
remove: 'Eliminar', remove: 'Eliminar',
save: 'Guardar', save: 'Guardar',
@ -475,6 +487,10 @@ const it = {
with_logo: 'Con logo', with_logo: 'Con logo',
unchecked: 'Non selezionato', unchecked: 'Non selezionato',
price: 'Prezzo', price: 'Prezzo',
type: 'Tipo',
list: 'lista',
no_variables: 'Ancora nessuna variabile',
no_variables_description: 'Aggiungi marcatori [[variable]] al documento per creare variabili di contenuto dinamico.',
type_value: 'Inserisci valore', type_value: 'Inserisci valore',
equal: 'Uguale', equal: 'Uguale',
not_equal: 'Non uguale', not_equal: 'Non uguale',
@ -499,6 +515,7 @@ const it = {
up: 'Su', up: 'Su',
down: 'Giù', down: 'Giù',
checked: 'Selezionato', checked: 'Selezionato',
current_date: 'Data corrente',
save: 'Salva', save: 'Salva',
cancel: 'Annulla', cancel: 'Annulla',
any: 'Qualsiasi', any: 'Qualsiasi',
@ -530,6 +547,7 @@ const it = {
option: 'Opzione', option: 'Opzione',
options: 'Opzioni', options: 'Opzioni',
condition: 'Condizione', condition: 'Condizione',
make_dynamic: 'Rendi dinamico',
first_party: 'Prima parte', first_party: 'Prima parte',
second_party: 'Seconda parte', second_party: 'Seconda parte',
third_party: 'Terza parte', third_party: 'Terza parte',
@ -670,6 +688,10 @@ const pt = {
search_field: 'Campo de busca', search_field: 'Campo de busca',
field_not_found: 'Campo não encontrado', field_not_found: 'Campo não encontrado',
clear: 'Limpar', clear: 'Limpar',
type: 'Tipo',
list: 'lista',
no_variables: 'Ainda sem variáveis',
no_variables_description: 'Adicione marcações [[variable]] ao documento para criar variáveis de conteúdo dinâmico.',
type_value: 'Digite valor', type_value: 'Digite valor',
add_all_required_fields_to_continue: 'Adicione todos os campos obrigatórios para continuar', add_all_required_fields_to_continue: 'Adicione todos os campos obrigatórios para continuar',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'O PDF carregado contém campos. Manter ou removê-los?', uploaded_pdf_contains_form_fields_keep_or_remove_them: 'O PDF carregado contém campos. Manter ou removê-los?',
@ -703,6 +725,7 @@ const pt = {
select_value_: 'Selecionar valor...', select_value_: 'Selecionar valor...',
remove_condition: 'Remover condição', remove_condition: 'Remover condição',
condition: 'Condição', condition: 'Condição',
make_dynamic: 'Tornar dinâmico',
formula: 'Fórmula', formula: 'Fórmula',
edit: 'Editar', edit: 'Editar',
settings: 'Configurações', settings: 'Configurações',
@ -712,6 +735,7 @@ const pt = {
are_you_sure_: 'Tem certeza?', are_you_sure_: 'Tem certeza?',
sign_yourself: 'Assine você mesmo', sign_yourself: 'Assine você mesmo',
checked: 'Marcado', checked: 'Marcado',
current_date: 'Data atual',
send: 'Enviar', send: 'Enviar',
remove: 'Remover', remove: 'Remover',
save: 'Salvar', save: 'Salvar',
@ -901,6 +925,10 @@ const fr = {
with_logo: 'Avec logo', with_logo: 'Avec logo',
unchecked: 'Décoché', unchecked: 'Décoché',
price: 'Prix', price: 'Prix',
type: 'Type',
list: 'liste',
no_variables: 'Pas encore de variables',
no_variables_description: 'Ajoutez des balises [[variable]] à votre document pour créer des variables de contenu dynamique.',
type_value: 'Saisir une valeur', type_value: 'Saisir une valeur',
equal: 'Égal', equal: 'Égal',
not_equal: 'Différent', not_equal: 'Différent',
@ -925,6 +953,7 @@ const fr = {
up: 'Haut', up: 'Haut',
down: 'Bas', down: 'Bas',
checked: 'Coché', checked: 'Coché',
current_date: 'Date du jour',
save: 'Enregistrer', save: 'Enregistrer',
cancel: 'Annuler', cancel: 'Annuler',
any: "N'importe lequel", any: "N'importe lequel",
@ -956,6 +985,7 @@ const fr = {
option: 'Option', option: 'Option',
options: 'Options', options: 'Options',
condition: 'Condition', condition: 'Condition',
make_dynamic: 'Rendre dynamique',
first_party: 'Première partie', first_party: 'Première partie',
second_party: 'Deuxième partie', second_party: 'Deuxième partie',
third_party: 'Troisième partie', third_party: 'Troisième partie',
@ -1114,6 +1144,10 @@ const de = {
with_logo: 'Mit Logo', with_logo: 'Mit Logo',
unchecked: 'Nicht markiert', unchecked: 'Nicht markiert',
price: 'Preis', price: 'Preis',
type: 'Typ',
list: 'Liste',
no_variables: 'Noch keine Variablen',
no_variables_description: 'Fügen Sie [[variable]]-Markierungen zu Ihrem Dokument hinzu, um dynamische Inhaltsvariablen zu erstellen.',
type_value: 'Wert eingeben', type_value: 'Wert eingeben',
equal: 'Gleich', equal: 'Gleich',
not_equal: 'Ungleich', not_equal: 'Ungleich',
@ -1138,6 +1172,7 @@ const de = {
up: 'Nach oben', up: 'Nach oben',
down: 'Nach unten', down: 'Nach unten',
checked: 'Markiert', checked: 'Markiert',
current_date: 'Aktuelles Datum',
save: 'Speichern', save: 'Speichern',
cancel: 'Abbrechen', cancel: 'Abbrechen',
any: 'Beliebig', any: 'Beliebig',
@ -1169,6 +1204,7 @@ const de = {
option: 'Option', option: 'Option',
options: 'Optionen', options: 'Optionen',
condition: 'Bedingung', condition: 'Bedingung',
make_dynamic: 'Dynamisch machen',
first_party: 'Erste Partei', first_party: 'Erste Partei',
second_party: 'Zweite Partei', second_party: 'Zweite Partei',
third_party: 'Dritte Partei', third_party: 'Dritte Partei',
@ -1327,6 +1363,10 @@ const nl = {
with_logo: 'Met logo', with_logo: 'Met logo',
unchecked: 'Niet aangevinkt', unchecked: 'Niet aangevinkt',
price: 'Prijs', price: 'Prijs',
type: 'Type',
list: 'lijst',
no_variables: 'Nog geen variabelen',
no_variables_description: 'Voeg [[variable]]-markeringen toe aan uw document om dynamische inhoudsvariabelen te maken.',
type_value: 'Typ waarde', type_value: 'Typ waarde',
equal: 'Gelijk aan', equal: 'Gelijk aan',
not_equal: 'Niet gelijk aan', not_equal: 'Niet gelijk aan',
@ -1351,6 +1391,7 @@ const nl = {
up: 'Omhoog', up: 'Omhoog',
down: 'Omlaag', down: 'Omlaag',
checked: 'Aangevinkt', checked: 'Aangevinkt',
current_date: 'Huidige datum',
save: 'Opslaan', save: 'Opslaan',
cancel: 'Annuleren', cancel: 'Annuleren',
any: 'Elke', any: 'Elke',
@ -1382,6 +1423,7 @@ const nl = {
option: 'Optie', option: 'Optie',
options: 'Opties', options: 'Opties',
condition: 'Voorwaarde', condition: 'Voorwaarde',
make_dynamic: 'Dynamisch maken',
first_party: 'Eerste partij', first_party: 'Eerste partij',
second_party: 'Tweede partij', second_party: 'Tweede partij',
third_party: 'Derde partij', third_party: 'Derde partij',

@ -18,11 +18,10 @@
> >
<div <div
style="width: 26px" style="width: 26px"
class="flex flex-col justify-between group-hover:opacity-100" class="flex flex-col"
:class="{'opacity-0': !item.conditions?.length }"
> >
<div>
<button <button
v-if="item.conditions?.length"
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button" class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
@click.stop="isShowConditionsModal = true" @click.stop="isShowConditionsModal = true"
> >
@ -32,7 +31,6 @@
/> />
</button> </button>
</div> </div>
</div>
<div class=""> <div class="">
<ReplaceButton <ReplaceButton
v-if="withReplaceButton" v-if="withReplaceButton"
@ -44,36 +42,91 @@
/> />
</div> </div>
<div <div
class="flex flex-col justify-between opacity-0 group-hover:opacity-100" class="flex flex-col justify-between"
> >
<div> <span
<button class="dropdown dropdown-end group-hover:opacity-100 has-[label:focus]:opacity-100"
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button" :class="{ 'dropdown-open': isMakeDynamicLoading, 'opacity-0': !isMakeDynamicLoading }"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
tabindex="0"
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button px-0"
style="width: 24px; height: 24px" style="width: 24px; height: 24px"
@click.stop="$emit('remove', item)" @click.stop
> >
&times; <IconDotsVertical
</button> :width="16"
</div> :height="16"
<div :stroke-width="1.6"
class="flex flex-col space-y-1" />
</label>
<ul
v-if="renderDropdown"
tabindex="0"
class="mt-1.5 dropdown-content p-1 shadow-lg rounded-lg border border-neutral-200 z-50 bg-white"
style="min-width: 170px"
@click="closeDropdown"
> >
<li>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="isShowConditionsModal = true; closeDropdown()"
>
<span class="flex items-center space-x-2">
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</span>
<span <span
:data-tip="t('reorder_fields')" v-if="item.conditions?.length"
class="tooltip tooltip-left before:text-xs" class="bg-neutral-200 rounded px-1 leading-3"
style="font-size: 9px;"
>{{ item.conditions.length }}</span>
</button>
</li>
<li v-if="!item.dynamic">
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm whitespace-nowrap"
@click.stop="$emit('reorder', item); closeDropdown()"
> >
<IconSortDescending2 class="w-4 h-4" />
<span>{{ t('reorder_fields') }}</span>
</button>
</li>
<li v-if="withDynamicDocuments && !item.dynamic && document.metadata?.original_uuid">
<button <button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button" class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm whitespace-nowrap"
@click.stop="$emit('reorder', item)" :disabled="isMakeDynamicLoading"
@click.stop="makeDynamic"
> >
<IconSortDescending2 <IconInnerShadowTop
:width="18" v-if="isMakeDynamicLoading"
:height="18" class="w-4 h-4 animate-spin"
:stroke-width="1.6"
/> />
<IconBolt
v-else
class="w-4 h-4"
/>
<span>{{ t('make_dynamic') }}</span>
</button> </button>
</li>
<hr class="my-1 border-neutral-200">
<li>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm text-red-600"
@click.stop="$emit('remove', item); closeDropdown()"
>
<IconTrashX class="w-4 h-4" />
<span>{{ t('remove') }}</span>
</button>
</li>
</ul>
</span> </span>
<template v-if="withArrows"> <div
v-if="withArrows"
class="flex flex-col space-y-1 opacity-0 group-hover:opacity-100"
>
<button <button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button" class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
style="width: 24px; height: 24px" style="width: 24px; height: 24px"
@ -88,7 +141,6 @@
> >
&darr; &darr;
</button> </button>
</template>
</div> </div>
</div> </div>
</div> </div>
@ -129,7 +181,7 @@
<script> <script>
import Contenteditable from './contenteditable' import Contenteditable from './contenteditable'
import Upload from './upload' import Upload from './upload'
import { IconRouteAltLeft, IconSortDescending2 } from '@tabler/icons-vue' import { IconRouteAltLeft, IconSortDescending2, IconDotsVertical, IconTrashX, IconBolt, IconInnerShadowTop } from '@tabler/icons-vue'
import ConditionsModal from './conditions_modal' import ConditionsModal from './conditions_modal'
import ReplaceButton from './replace' import ReplaceButton from './replace'
import GoogleDriveDocumentSettings from './google_drive_document_settings' import GoogleDriveDocumentSettings from './google_drive_document_settings'
@ -140,13 +192,17 @@ export default {
name: 'DocumentPreview', name: 'DocumentPreview',
components: { components: {
Contenteditable, Contenteditable,
IconInnerShadowTop,
IconRouteAltLeft, IconRouteAltLeft,
ConditionsModal, ConditionsModal,
ReplaceButton, ReplaceButton,
GoogleDriveDocumentSettings, GoogleDriveDocumentSettings,
IconSortDescending2 IconSortDescending2,
IconDotsVertical,
IconTrashX,
IconBolt
}, },
inject: ['t', 'getFieldTypeIndex'], inject: ['t', 'getFieldTypeIndex', 'baseFetch'],
props: { props: {
item: { item: {
type: Object, type: Object,
@ -175,16 +231,27 @@ export default {
required: true, required: true,
default: true default: true
}, },
dynamicDocuments: {
type: Array,
required: true
},
withDynamicDocuments: {
type: Boolean,
required: false,
default: false
},
withArrows: { withArrows: {
type: Boolean, type: Boolean,
required: false, required: false,
default: true default: true
} }
}, },
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'reorder'], emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'reorder', 'make-dynamic'],
data () { data () {
return { return {
isShowConditionsModal: false isShowConditionsModal: false,
isMakeDynamicLoading: false,
renderDropdown: false
} }
}, },
computed: { computed: {
@ -200,6 +267,32 @@ export default {
methods: { methods: {
upload: Upload.methods.upload, upload: Upload.methods.upload,
buildDefaultName: Field.methods.buildDefaultName, buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
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) => {
const dynamicDocument = await resp.json()
if (dynamicDocument.uuid) {
this.dynamicDocuments.push(dynamicDocument)
}
this.template.schema.find((item) => item.attachment_uuid === dynamicDocument.uuid).dynamic = true
this.$emit('change')
}).finally(() => {
this.isMakeDynamicLoading = false
})
},
onUpdateName (value) { onUpdateName (value) {
this.item.name = value this.item.name = value

@ -1,21 +0,0 @@
# frozen_string_literal: true
class GenerateAttachmentPreviewJob
include Sidekiq::Job
InvalidFormat = Class.new(StandardError)
sidekiq_options queue: :images
def perform(params = {})
attachment = ActiveStorage::Attachment.find(params['attachment_id'])
if attachment.content_type == Templates::ProcessDocument::PDF_CONTENT_TYPE
Templates::ProcessDocument.generate_pdf_preview_images(attachment, attachment.download)
elsif attachment.image?
Templates::ProcessDocument.generate_preview_image(attachment, attachment.download)
else
raise InvalidFormat, attachment.id
end
end
end

@ -13,7 +13,7 @@
# Indexes # Indexes
# #
# index_document_generation_events_on_submitter_id (submitter_id) # index_document_generation_events_on_submitter_id (submitter_id)
# index_document_generation_events_on_submitter_id_and_event_name (submitter_id,event_name) UNIQUE WHERE ((event_name)::text = ANY ((ARRAY['start'::character varying, 'complete'::character varying])::text[])) # index_document_generation_events_on_submitter_id_and_event_name (submitter_id,event_name) UNIQUE WHERE ((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text]))
# #
# Foreign Keys # Foreign Keys
# #

@ -0,0 +1,36 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: dynamic_documents
#
# id :bigint not null, primary key
# body :text not null
# head :text
# sha1 :text not null
# uuid :uuid not null
# created_at :datetime not null
# updated_at :datetime not null
# template_id :bigint not null
#
# Indexes
#
# index_dynamic_documents_on_template_id (template_id)
#
# Foreign Keys
#
# fk_rails_... (template_id => templates.id)
#
class DynamicDocument < ApplicationRecord
belongs_to :template
has_many_attached :attachments
has_many :versions, class_name: 'DynamicDocumentVersion', dependent: :destroy
before_validation :set_sha1
def set_sha1
self.sha1 = Digest::SHA1.hexdigest(body)
end
end

@ -0,0 +1,30 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: dynamic_document_versions
#
# id :bigint not null, primary key
# areas :text not null
# sha1 :string not null
# created_at :datetime not null
# updated_at :datetime not null
# dynamic_document_id :bigint not null
#
# Indexes
#
# idx_on_dynamic_document_id_sha1_3503adf557 (dynamic_document_id,sha1) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (dynamic_document_id => dynamic_documents.id)
#
class DynamicDocumentVersion < ApplicationRecord
belongs_to :dynamic_document
has_one_attached :document
attribute :areas, :string, default: -> { [] }
serialize :areas, coder: JSON
end

@ -20,7 +20,7 @@
# #
# index_email_events_on_account_id_and_event_datetime (account_id,event_datetime) # index_email_events_on_account_id_and_event_datetime (account_id,event_datetime)
# index_email_events_on_email (email) # index_email_events_on_email (email)
# index_email_events_on_email_event_types (email) WHERE ((event_type)::text = ANY ((ARRAY['bounce'::character varying, 'soft_bounce'::character varying, 'permanent_bounce'::character varying, 'complaint'::character varying, 'soft_complaint'::character varying])::text[])) # index_email_events_on_email_event_types (email) WHERE ((event_type)::text = ANY (ARRAY[('bounce'::character varying)::text, ('soft_bounce'::character varying)::text, ('permanent_bounce'::character varying)::text, ('complaint'::character varying)::text, ('soft_complaint'::character varying)::text]))
# index_email_events_on_emailable (emailable_type,emailable_id) # index_email_events_on_emailable (emailable_type,emailable_id)
# index_email_events_on_message_id (message_id) # index_email_events_on_message_id (message_id)
# #

@ -12,7 +12,7 @@
# #
# Indexes # Indexes
# #
# index_lock_events_on_event_name_and_key (event_name,key) UNIQUE WHERE ((event_name)::text = ANY ((ARRAY['start'::character varying, 'complete'::character varying])::text[])) # index_lock_events_on_event_name_and_key (event_name,key) UNIQUE WHERE ((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text]))
# index_lock_events_on_key (key) # index_lock_events_on_key (key)
# #
class LockEvent < ApplicationRecord class LockEvent < ApplicationRecord

@ -10,7 +10,7 @@
# name :text # name :text
# preferences :text not null # preferences :text not null
# slug :string not null # slug :string not null
# source :text not null # source :string not null
# submitters_order :string not null # submitters_order :string not null
# template_fields :text # template_fields :text
# template_schema :text # template_schema :text
@ -75,6 +75,17 @@ class Submission < ApplicationRecord
->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) }, ->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) },
through: :template, source: :documents_attachments through: :template, source: :documents_attachments
has_many :template_schema_static_documents,
->(e) { where(uuid: e.template_schema.reject { |s| s['dynamic'] }.pluck('attachment_uuid')) },
through: :template, source: :documents_attachments
has_many :template_schema_dynamic_document_versions,
->(e) { where(sha1: e.template_schema.select { |s| s['dynamic'] }.pluck('dynamic_document_sha1')) },
through: :template, source: :dynamic_document_versions
has_many :template_schema_dynamic_document_attachments,
through: :template_schema_dynamic_document_versions, source: :document_attachment
scope :active, -> { where(archived_at: nil) } scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) } scope :archived, -> { where.not(archived_at: nil) }
scope :pending, lambda { scope :pending, lambda {
@ -110,13 +121,51 @@ class Submission < ApplicationRecord
end end
def schema_documents def schema_documents
if template_id? return documents_attachments unless template_id?
dynamic_count = template_schema&.count { |e| e['dynamic'] }.to_i
if template.variables_schema.blank?
if dynamic_count > 0
if dynamic_count == template_schema.size
template_schema_dynamic_document_attachments
else
template_schema_dynamic_and_static_document_attachments
end
else
template_schema_documents template_schema_documents
end
elsif dynamic_count > 0 && dynamic_count != template_schema.size
template_schema_submission_dynamic_and_static_document_attachments
else else
documents_attachments documents_attachments
end end
end end
def template_schema_submission_dynamic_and_static_document_attachments
@template_schema_submission_dynamic_and_static_document_attachments ||=
ActiveStorage::Attachment.where(
ActiveStorage::Attachment.arel_table[:id].in(
template_schema_static_documents.select(:id).arel.union(
:all,
documents_attachments.select(:id).arel
)
)
)
end
def template_schema_dynamic_and_static_document_attachments
@template_schema_dynamic_and_static_document_attachments ||=
ActiveStorage::Attachment.where(
ActiveStorage::Attachment.arel_table[:id].in(
template_schema_static_documents.select(:id).arel.union(
:all,
template_schema_dynamic_document_attachments.select(:id).arel
)
)
)
end
def fields_uuid_index def fields_uuid_index
@fields_uuid_index ||= (template_fields || template.fields).index_by { |f| f['uuid'] } @fields_uuid_index ||= (template_fields || template.fields).index_by { |f| f['uuid'] }
end end

@ -20,7 +20,7 @@
# index_submission_events_on_created_at (created_at) # index_submission_events_on_created_at (created_at)
# index_submission_events_on_submission_id (submission_id) # index_submission_events_on_submission_id (submission_id)
# index_submission_events_on_submitter_id (submitter_id) # index_submission_events_on_submitter_id (submitter_id)
# index_submissions_events_on_sms_event_types (account_id,created_at) WHERE ((event_type)::text = ANY ((ARRAY['send_sms'::character varying, 'send_2fa_sms'::character varying])::text[])) # index_submissions_events_on_sms_event_types (account_id,created_at) WHERE ((event_type)::text = ANY (ARRAY[('send_sms'::character varying)::text, ('send_2fa_sms'::character varying)::text]))
# #
# Foreign Keys # Foreign Keys
# #

@ -70,6 +70,12 @@ class Template < ApplicationRecord
has_many :submissions, dependent: :destroy has_many :submissions, dependent: :destroy
has_many :template_sharings, dependent: :destroy has_many :template_sharings, dependent: :destroy
has_many :template_accesses, dependent: :destroy has_many :template_accesses, dependent: :destroy
has_many :dynamic_documents, dependent: :destroy
has_many :dynamic_document_versions, through: :dynamic_documents, source: :versions
has_many :schema_dynamic_documents, lambda { |e|
where(uuid: e.schema.select { |e| e['dynamic'] }.pluck('attachment_uuid'))
}, class_name: 'DynamicDocument', dependent: :destroy, inverse_of: :template
scope :active, -> { where(archived_at: nil) } scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) } scope :archived, -> { where.not(archived_at: nil) }

@ -3,8 +3,8 @@
<% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %> <% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %>
<dynamic-list class="space-y-4"> <dynamic-list class="space-y-4">
<div class="space-y-4"> <div class="space-y-4">
<div class="card card-compact bg-base-300/40" data-targets="dynamic-list.items"> <div class="<%= 'p-4 rounded-xl bg-base-300/40' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
<div class="card-body"> <div>
<div class="absolute right-4 top-5"> <div class="absolute right-4 top-5">
<a href="#" data-action="click:dynamic-list#removeItem" class="<%= submitters.size == 1 ? 'right-2 top-1' : '-top-3' %> relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white"> <a href="#" data-action="click:dynamic-list#removeItem" class="<%= submitters.size == 1 ? 'right-2 top-1' : '-top-3' %> relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
<%= svg_icon('trash', class: 'w-4 h-4') %> <%= svg_icon('trash', class: 'w-4 h-4') %>
@ -86,13 +86,14 @@
</div> </div>
</div> </div>
</div> </div>
<% if params[:selfsign].blank? && local_assigns[:prefillable_fields].blank? && local_assigns[:recipient_form_fields].blank? %> <% if params[:selfsign].blank? && local_assigns[:prefillable_fields].blank? && local_assigns[:recipient_form_fields].blank? && local_assigns[:variables_form].blank? %>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem"> <a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %> <%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span><%= t('add_new') %></span> <span><%= t('add_new') %></span>
</a> </a>
<% end %> <% end %>
</dynamic-list> </dynamic-list>
<%= local_assigns[:variables_form] %>
<div> <div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %> <%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<%= render 'send_email', f:, template: %> <%= render 'send_email', f:, template: %>

@ -1,6 +1,27 @@
<%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %> <%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %> <% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %>
<% if submitters.size == 1 %> <% if submitters.size == 1 && local_assigns[:variables_form] %>
<% item = submitters.first %>
<div class="grid gap-1">
<submitter-item class="grid md:grid-cols-2 gap-1">
<div class="form-control">
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<submitters-autocomplete data-field="email">
<linked-input data-target-id="<%= "email_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'email', multiple: true, name: 'submission[1][submitters][][email]', autocomplete: 'off', class: 'base-input !h-10 w-full', placeholder: t('email'), required: true, value: item['email'].presence || (params[:selfsign] || item['is_requester'] ? current_user.email : ''), id: "email_#{item['uuid']}" %>
</linked-input>
</submitters-autocomplete>
</div>
<div class="form-control flex">
<submitters-autocomplete data-field="name">
<linked-input data-target-id="<%= "email_name_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="base-input !h-10 w-full" placeholder="<%= "#{t('name')} (#{t('optional')})" %>" value="<%= params[:selfsign] || item['is_requester'] ? current_user.full_name : '' %>" dir="auto" id="email_name_<%= item['uuid'] %>">
</linked-input>
</submitters-autocomplete>
</div>
</submitter-item>
</div>
<% elsif submitters.size == 1 %>
<submitter-item class="form-control"> <submitter-item class="form-control">
<emails-textarea data-bulk-enabled="<%= Docuseal.demo? || !Docuseal.multitenant? || can?(:manage, :bulk_send) %>" data-limit="<%= Docuseal.multitenant? ? (can?(:manage, :bulk_send) ? 40 : 1) : nil %>"> <emails-textarea data-bulk-enabled="<%= Docuseal.demo? || !Docuseal.multitenant? || can?(:manage, :bulk_send) %>" data-limit="<%= Docuseal.multitenant? ? (can?(:manage, :bulk_send) ? 40 : 1) : nil %>">
<submitters-autocomplete data-field="email" class="block relative"> <submitters-autocomplete data-field="email" class="block relative">
@ -13,8 +34,8 @@
<% else %> <% else %>
<dynamic-list class="space-y-4"> <dynamic-list class="space-y-4">
<div class="space-y-4"> <div class="space-y-4">
<div class="card card-compact bg-base-300/40" data-targets="dynamic-list.items"> <div class="<%= 'p-4 rounded-xl bg-base-300/40' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
<div class="card-body"> <div>
<div class="absolute right-4 top-5"> <div class="absolute right-4 top-5">
<a href="#" data-action="click:dynamic-list#removeItem" class="-top-3 relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white"> <a href="#" data-action="click:dynamic-list#removeItem" class="-top-3 relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
<%= svg_icon('trash', class: 'w-4 h-4') %> <%= svg_icon('trash', class: 'w-4 h-4') %>
@ -38,7 +59,7 @@
</div> </div>
</div> </div>
</div> </div>
<% if params[:selfsign].blank? %> <% if params[:selfsign].blank? && local_assigns[:variables_form].blank? %>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem"> <a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %> <%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span><%= t('add_new') %></span> <span><%= t('add_new') %></span>
@ -46,6 +67,7 @@
<% end %> <% end %>
</dynamic-list> </dynamic-list>
<% end %> <% end %>
<%= local_assigns[:variables_form] %>
<div> <div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %> <%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<%= render 'send_email', f:, template: %> <%= render 'send_email', f:, template: %>

@ -2,8 +2,8 @@
<% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %> <% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %>
<dynamic-list class="space-y-4"> <dynamic-list class="space-y-4">
<div class="space-y-4"> <div class="space-y-4">
<div class="card card-compact bg-base-300/40" data-targets="dynamic-list.items"> <div class="<%= 'p-4 rounded-xl bg-base-300/40' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
<div class="card-body"> <div>
<div class="absolute right-4 top-5"> <div class="absolute right-4 top-5">
<a href="#" data-action="click:dynamic-list#removeItem" class="<%= submitters.size == 1 ? 'right-2 top-1' : '-top-3' %> relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white"> <a href="#" data-action="click:dynamic-list#removeItem" class="<%= submitters.size == 1 ? 'right-2 top-1' : '-top-3' %> relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
<%= svg_icon('trash', class: 'w-4 h-4') %> <%= svg_icon('trash', class: 'w-4 h-4') %>
@ -49,13 +49,14 @@
</div> </div>
</div> </div>
</div> </div>
<% if params[:selfsign].blank? %> <% if params[:selfsign].blank? && local_assigns[:variables_form].blank? %>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem"> <a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %> <%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span><%= t('add_new') %></span> <span><%= t('add_new') %></span>
</a> </a>
<% end %> <% end %>
</dynamic-list> </dynamic-list>
<%= local_assigns[:variables_form] %>
<div> <div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %> <%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<%= render 'send_sms', f:, checked: true %> <%= render 'send_sms', f:, checked: true %>

@ -4,8 +4,10 @@
<% default_tab = cookies.permanent[:add_recipients_tab].presence || 'email' %> <% default_tab = cookies.permanent[:add_recipients_tab].presence || 'email' %>
<% recipient_form_fields = Accounts.load_recipient_form_fields(current_account) if prefillable_fields.blank? %> <% recipient_form_fields = Accounts.load_recipient_form_fields(current_account) if prefillable_fields.blank? %>
<% only_detailed = require_phone_2fa || require_email_2fa || prefillable_fields.present? || recipient_form_fields.present? %> <% only_detailed = require_phone_2fa || require_email_2fa || prefillable_fields.present? || recipient_form_fields.present? %>
<% with_list = @template.variables_schema.blank? %>
<% 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 %> <%= 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'], [t('upload_list'), 'list']].compact %> <% 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"> <toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center px-2 mt-4 block">
<div class="flex justify-center"> <div class="flex justify-center">
<% options.each_with_index do |(label, value), index| %> <% options.each_with_index do |(label, value), index| %>
@ -21,18 +23,20 @@
<div class="px-5 mb-5 mt-4"> <div class="px-5 mb-5 mt-4">
<% unless only_detailed %> <% unless only_detailed %>
<div id="email" class="<%= 'hidden' if default_tab != 'email' %>"> <div id="email" class="<%= 'hidden' if default_tab != 'email' %>">
<%= render 'email_form', template: @template %> <%= render 'email_form', template: @template, variables_form: %>
</div> </div>
<div id="phone" class="<%= 'hidden' if default_tab != 'phone' %>"> <div id="phone" class="<%= 'hidden' if default_tab != 'phone' %>">
<%= render 'phone_form', template: @template %> <%= render 'phone_form', template: @template, variables_form: %>
</div> </div>
<% end %> <% end %>
<div id="detailed" class="<%= 'hidden' if !only_detailed && default_tab != 'detailed' %>"> <div id="detailed" class="<%= 'hidden' if !only_detailed && default_tab != 'detailed' %>">
<%= render 'detailed_form', template: @template, require_phone_2fa:, require_email_2fa:, prefillable_fields:, recipient_form_fields: %> <%= render 'detailed_form', template: @template, require_phone_2fa:, require_email_2fa:, prefillable_fields:, recipient_form_fields:, variables_form: %>
</div> </div>
<% if with_list %>
<div id="list" class="hidden"> <div id="list" class="hidden">
<%= render 'list_form', template: @template %> <%= render 'list_form', template: @template %>
</div> </div>
<% end %>
<%= render 'submissions/error' %> <%= render 'submissions/error' %>
</div> </div>
<%= content_for(:modal_extra) %> <%= content_for(:modal_extra) %>

@ -104,7 +104,7 @@
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %> <% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.first&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %> <% lazyload_metadata = document.preview_images.first&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %> <% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %> <% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}.jpg")) %>
<page-container id="<%= "page-#{document.uuid}-#{index}" %>" class="block before:border before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:rounded relative mb-4" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>"> <page-container id="<%= "page-#{document.uuid}-#{index}" %>" class="block before:border before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:rounded relative mb-4" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" class="rounded" height="<%= height %>"> <img loading="lazy" src="<%= page.url %>" width="<%= width %>" class="rounded" height="<%= height %>">
<div class="top-0 bottom-0 left-0 right-0 absolute"> <div class="top-0 bottom-0 left-0 right-0 absolute">

@ -65,7 +65,7 @@
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %> <% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.last&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %> <% lazyload_metadata = document.preview_images.last&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %> <% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %> <% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}.jpg")) %>
<page-container class="block relative my-4 shadow-md" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>"> <page-container class="block relative my-4 shadow-md" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" height="<%= height %>"> <img loading="lazy" src="<%= page.url %>" width="<%= width %>" height="<%= height %>">
<div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute"> <div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute">

@ -49,9 +49,9 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<%= link_to template_share_link_path(template), class: 'absolute md:relative bottom-0 right-0 btn btn-xs md:btn-sm whitespace-nowrap btn-neutral text-white mt-1 px-2', data: { turbo_frame: :modal } do %> <%= link_to template_share_link_path(template), class: "absolute md:relative bottom-0 right-0 btn btn-xs md:btn-sm whitespace-nowrap btn-neutral text-white mt-1 px-2 #{'btn-disabled text-base-100' if template.variables_schema.present?}", data: { turbo_frame: :modal } do %>
<span class="flex items-center justify-center space-x-2"> <span class="flex items-center justify-center space-x-2">
<%= svg_icon('link', class: 'w-4 h-4 md:w-6 md:h-6 text-white') %> <%= svg_icon('link', class: 'w-4 h-4 md:w-6 md:h-6') %>
<span><%= t('link') %></span> <span><%= t('link') %></span>
</span> </span>
<% end %> <% end %>

@ -94,7 +94,7 @@
<span class="mr-1"><%= t('send_to_recipients') %></span> <span class="mr-1"><%= t('send_to_recipients') %></span>
<% end %> <% end %>
<% end %> <% end %>
<% if Templates.filter_undefined_submitters(@template.submitters).size == 1 %> <% if Templates.filter_undefined_submitters(@template.submitters).size == 1 && @template.variables_schema.blank? %>
<%= button_to start_form_path(@template.slug), params: { selfsign: true }, method: :put, class: 'white-button w-full', form: { style: 'display: inline', target: '_blank', data: { turbo: false } } do %> <%= button_to start_form_path(@template.slug), params: { selfsign: true }, method: :put, class: 'white-button w-full', form: { style: 'display: inline', target: '_blank', data: { turbo: false } } do %>
<%= svg_icon('writing', class: 'w-6 h-6') %> <%= svg_icon('writing', class: 'w-6 h-6') %>
<span class="mr-1"><%= t('sign_it_yourself') %></span> <span class="mr-1"><%= t('sign_it_yourself') %></span>

@ -152,7 +152,7 @@ Rails.application.configure do
template_id: params[:template_id], template_id: params[:template_id],
submission_id: params[:submission_id], submission_id: params[:submission_id],
submitter_id: params[:submitter_id], submitter_id: params[:submitter_id],
sig: (params[:signed_uuid] || params[:signed_id]).to_s.split('--').first, sig: (params[:signed_key] || params[:signed_uuid] || params[:signed_id]).to_s.split('--').first,
slug: (params[:slug] || slug: (params[:slug] ||
params[:submitter_slug] || params[:submitter_slug] ||
params[:submission_slug] || params[:submission_slug] ||

@ -8,6 +8,10 @@ ActiveSupport.on_load(:active_storage_attachment) do
def signed_uuid def signed_uuid
@signed_uuid ||= ApplicationRecord.signed_id_verifier.generate(uuid, expires_in: 6.hours, purpose: :attachment) @signed_uuid ||= ApplicationRecord.signed_id_verifier.generate(uuid, expires_in: 6.hours, purpose: :attachment)
end end
def signed_key
@signed_key ||= ApplicationRecord.signed_id_verifier.generate([id, uuid], expires_in: 6.hours, purpose: :attachment)
end
end end
# rubocop:disable Metrics/BlockLength # rubocop:disable Metrics/BlockLength

@ -29,6 +29,7 @@ en: &en
pro: Pro pro: Pro
thanks: Thanks thanks: Thanks
private: Private private: Private
_variables: Variables
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Make all newly created templates private to their creator and admins by default. make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Make all newly created templates private to their creator and admins by default.
create_templates_with_admin_access_by_default: Create templates with admin access by default create_templates_with_admin_access_by_default: Create templates with admin access by default
require_email_2fa: Require email 2FA require_email_2fa: Require email 2FA
@ -1049,6 +1050,7 @@ es: &es
stripe_account_has_been_connected: La cuenta de Stripe ha sido conectada. stripe_account_has_been_connected: La cuenta de Stripe ha sido conectada.
re_connect_stripe: Volver a conectar Stripe re_connect_stripe: Volver a conectar Stripe
private: Privado private: Privado
_variables: Variables
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Hacer que todas las plantillas recién creadas sean privadas para su creador y los administradores por defecto. make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Hacer que todas las plantillas recién creadas sean privadas para su creador y los administradores por defecto.
create_templates_with_admin_access_by_default: Crear plantillas con acceso de administrador por defecto create_templates_with_admin_access_by_default: Crear plantillas con acceso de administrador por defecto
require_email_2fa: Requerir 2FA por correo electrónico require_email_2fa: Requerir 2FA por correo electrónico
@ -2051,6 +2053,7 @@ it: &it
stripe_account_has_been_connected: L'account Stripe è stato collegato. stripe_account_has_been_connected: L'account Stripe è stato collegato.
re_connect_stripe: Ricollega Stripe re_connect_stripe: Ricollega Stripe
private: Privato private: Privato
_variables: Variabili
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Rendere tutte le nuove template private per il creatore e gli amministratori per impostazione predefinita. make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Rendere tutte le nuove template private per il creatore e gli amministratori per impostazione predefinita.
create_templates_with_admin_access_by_default: Crea modelli con accesso amministratore per impostazione predefinita create_templates_with_admin_access_by_default: Crea modelli con accesso amministratore per impostazione predefinita
require_email_2fa: Richiedi 2FA email require_email_2fa: Richiedi 2FA email
@ -3054,6 +3057,7 @@ fr: &fr
stripe_account_has_been_connected: Le compte Stripe a été connecté. stripe_account_has_been_connected: Le compte Stripe a été connecté.
re_connect_stripe: Reconnecter Stripe re_connect_stripe: Reconnecter Stripe
private: Privé private: Privé
_variables: Variables
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Rendre tous les nouveaux modèles privés pour leur créateur et les administrateurs par défaut. make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Rendre tous les nouveaux modèles privés pour leur créateur et les administrateurs par défaut.
create_templates_with_admin_access_by_default: Créer des modèles avec un accès administrateur par défaut create_templates_with_admin_access_by_default: Créer des modèles avec un accès administrateur par défaut
require_email_2fa: Exiger la 2FA par email require_email_2fa: Exiger la 2FA par email
@ -4053,6 +4057,7 @@ pt: &pt
stripe_account_has_been_connected: Conta Stripe foi conectada. stripe_account_has_been_connected: Conta Stripe foi conectada.
re_connect_stripe: Reconectar Stripe re_connect_stripe: Reconectar Stripe
private: Privado private: Privado
_variables: Variáveis
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Tornar todos os modelos recém-criados privados para seu criador e administradores por padrão. make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Tornar todos os modelos recém-criados privados para seu criador e administradores por padrão.
create_templates_with_admin_access_by_default: Criar modelos com acesso de administrador por padrão create_templates_with_admin_access_by_default: Criar modelos com acesso de administrador por padrão
require_email_2fa: Exigir 2FA por email require_email_2fa: Exigir 2FA por email
@ -5041,6 +5046,7 @@ de: &de
pro: Pro pro: Pro
thanks: Danke thanks: Danke
private: Privat private: Privat
_variables: Variablen
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Alle neu erstellten Vorlagen standardmäßig nur für ihren Ersteller und Administratoren sichtbar machen. make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Alle neu erstellten Vorlagen standardmäßig nur für ihren Ersteller und Administratoren sichtbar machen.
create_templates_with_admin_access_by_default: Vorlagen standardmäßig mit Administratorzugriff erstellen create_templates_with_admin_access_by_default: Vorlagen standardmäßig mit Administratorzugriff erstellen
require_email_2fa: E-Mail 2FA erforderlich require_email_2fa: E-Mail 2FA erforderlich
@ -6431,6 +6437,7 @@ nl: &nl
pro: Pro pro: Pro
thanks: Bedankt thanks: Bedankt
private: Privé private: Privé
_variables: Variabelen
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Maak alle nieuw aangemaakte sjablonen standaard privé voor hun maker en admins. make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Maak alle nieuw aangemaakte sjablonen standaard privé voor hun maker en admins.
create_templates_with_admin_access_by_default: Sjablonen standaard met admin-toegang maken create_templates_with_admin_access_by_default: Sjablonen standaard met admin-toegang maken
require_email_2fa: E-mail 2FA vereist require_email_2fa: E-mail 2FA vereist

@ -111,7 +111,7 @@ Rails.application.routes.draw do
resources :prefillable_fields, only: %i[create], controller: 'templates_prefillable_fields' resources :prefillable_fields, only: %i[create], controller: 'templates_prefillable_fields'
resources :submissions_export, only: %i[index new] resources :submissions_export, only: %i[index new]
end end
resources :preview_document_page, only: %i[show], path: '/preview/:signed_uuid' resources :preview_document_page, only: %i[show], path: '/preview/:signed_key'
resource :blobs_proxy, only: %i[show], path: '/file/:signed_uuid/*filename', resource :blobs_proxy, only: %i[show], path: '/file/:signed_uuid/*filename',
controller: 'api/active_storage_blobs_proxy' controller: 'api/active_storage_blobs_proxy'
resource :blobs_proxy, only: %i[show], path: '/blobs_proxy/:signed_uuid/*filename', resource :blobs_proxy, only: %i[show], path: '/blobs_proxy/:signed_uuid/*filename',

@ -14,7 +14,7 @@ const configs = generateWebpackConfig({
concatenateModules: !process.env.BUNDLE_ANALYZE, concatenateModules: !process.env.BUNDLE_ANALYZE,
splitChunks: { splitChunks: {
chunks (chunk) { chunks (chunk) {
return chunk.name !== 'rollbar' return chunk.name !== 'rollbar' && chunk.name !== 'dynamic-editor'
}, },
cacheGroups: { cacheGroups: {
default: false, default: false,
@ -39,8 +39,14 @@ const configs = generateWebpackConfig({
].filter(Boolean) ].filter(Boolean)
}) })
configs.module.rules[3].exclude = /dynamic_styles\.scss$/
configs.module = merge({ configs.module = merge({
rules: [ rules: [
{
test: /dynamic_styles\.scss$/,
use: ['css-loader', 'postcss-loader', 'sass-loader']
},
{ {
test: /\.vue$/, test: /\.vue$/,
use: [{ use: [{

@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateDynamicDocuments < ActiveRecord::Migration[8.1]
def change
create_table :dynamic_documents do |t|
t.uuid :uuid, null: false
t.references :template, null: false, foreign_key: true, index: true
t.text :body, null: false
t.text :head
t.text :sha1, null: false
t.timestamps
end
end
end

@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateDynamicDocumentVersions < ActiveRecord::Migration[8.1]
def change
create_table :dynamic_document_versions do |t|
t.references :dynamic_document, null: false, foreign_key: true, index: false
t.string :sha1, null: false
t.text :areas, null: false
t.timestamps
end
add_index :dynamic_document_versions, %i[dynamic_document_id sha1], unique: true
end
end

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_11_25_194305) do ActiveRecord::Schema[8.1].define(version: 2026_02_16_162053) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "btree_gin" enable_extension "btree_gin"
enable_extension "plpgsql" enable_extension "plpgsql"
@ -168,6 +168,26 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_25_194305) do
t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id" t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id"
end end
create_table "dynamic_document_versions", force: :cascade do |t|
t.text "areas", null: false
t.datetime "created_at", null: false
t.bigint "dynamic_document_id", null: false
t.string "sha1", null: false
t.datetime "updated_at", null: false
t.index ["dynamic_document_id", "sha1"], name: "idx_on_dynamic_document_id_sha1_3503adf557", unique: true
end
create_table "dynamic_documents", force: :cascade do |t|
t.text "body", null: false
t.datetime "created_at", null: false
t.text "head"
t.text "sha1", null: false
t.bigint "template_id", null: false
t.datetime "updated_at", null: false
t.uuid "uuid", null: false
t.index ["template_id"], name: "index_dynamic_documents_on_template_id"
end
create_table "email_events", force: :cascade do |t| create_table "email_events", force: :cascade do |t|
t.bigint "account_id", null: false t.bigint "account_id", null: false
t.string "emailable_type", null: false t.string "emailable_type", null: false
@ -507,6 +527,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_25_194305) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "document_generation_events", "submitters" add_foreign_key "document_generation_events", "submitters"
add_foreign_key "dynamic_documents", "templates"
add_foreign_key "email_events", "accounts" add_foreign_key "email_events", "accounts"
add_foreign_key "email_messages", "accounts" add_foreign_key "email_messages", "accounts"
add_foreign_key "email_messages", "users", column: "author_id" add_foreign_key "email_messages", "users", column: "author_id"

@ -47,6 +47,7 @@ module Params
email_format(params, :bcc_completed, message: 'bcc_completed email is invalid') email_format(params, :bcc_completed, message: 'bcc_completed email is invalid')
email_format(params, :reply_to, message: 'reply_to email is invalid') email_format(params, :reply_to, message: 'reply_to email is invalid')
type(params, :message, Hash) type(params, :message, Hash)
type(params, :variables, Hash)
type(params, :submitters, Array) type(params, :submitters, Array)
in_path(params, :message, skip_blank: true) do |message_params| in_path(params, :message, skip_blank: true) do |message_params|

@ -37,7 +37,7 @@ Puma::Plugin.create do
wait_for_redis! wait_for_redis!
configs = Sidekiq.configure_embed do |config| configs = Sidekiq.configure_embed do |config|
config.logger.level = Logger::INFO config.logger.level = Rails.env.development? ? Logger::DEBUG : Logger::INFO
sidekiq_config = YAML.load_file('config/sidekiq.yml') sidekiq_config = YAML.load_file('config/sidekiq.yml')
sidekiq_config['queues'] << 'fields' if ENV['DEMO'] == 'true' sidekiq_config['queues'] << 'fields' if ENV['DEMO'] == 'true'
config.queues = sidekiq_config['queues'] config.queues = sidekiq_config['queues']

@ -80,10 +80,8 @@ module Submissions
def preload_with_pages(submission) def preload_with_pages(submission)
ActiveRecord::Associations::Preloader.new( ActiveRecord::Associations::Preloader.new(
records: [submission], records: submission.schema_documents,
associations: [ associations: [:blob]
submission.template_id? ? { template_schema_documents: :blob } : { documents_attachments: :blob }
]
).call ).call
total_pages = total_pages =
@ -92,7 +90,7 @@ module Submissions
if total_pages < PRELOAD_ALL_PAGES_AMOUNT if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new( ActiveRecord::Associations::Preloader.new(
records: submission.schema_documents, records: submission.schema_documents,
associations: [:blob, { preview_images_attachments: :blob }] associations: [{ preview_images_attachments: :blob }]
).call ).call
end end
@ -117,6 +115,8 @@ module Submissions
preferences:, preferences:,
sent_at: mark_as_sent ? Time.current : nil) sent_at: mark_as_sent ? Time.current : nil)
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submission)
submission.save! submission.save!
if submission.expire_at? if submission.expire_at?

@ -71,6 +71,7 @@ module Submissions
preferences: preferences.merge(submission_preferences)) preferences: preferences.merge(submission_preferences))
end end
maybe_set_dynamic_documents(submission)
maybe_set_template_fields(submission, attrs[:submitters], with_template:, new_fields:) maybe_set_template_fields(submission, attrs[:submitters], with_template:, new_fields:)
if submission.submitters.size > template.submitters.size if submission.submitters.size > template.submitters.size
@ -97,6 +98,44 @@ module Submissions
submissions submissions
end end
def maybe_set_dynamic_documents(submission)
return submission unless submission.template_id?
template = submission.template
return submission if template.variables_schema.present? ||
submission.variables_schema.present?
areas_index = {}
submission.template_schema = []
template.schema.each do |item|
if item['dynamic']
dynamic_document = template.schema_dynamic_documents.find { |e| e.uuid == item['attachment_uuid'] }
dynamic_document_version = DynamicDocuments::EnsureVersionGenerated.call(dynamic_document)
dynamic_document_version.areas.each { |area| areas_index[area['uuid']] = area }
submission.template_schema << item.deep_dup.merge('dynamic_document_sha1' => dynamic_document.sha1)
else
submission.template_schema << item.deep_dup
end
end
submission.template_fields = template.fields.deep_dup
submission.template_fields.each do |field|
field['areas'].to_a.each do |area|
dynamic_area = areas_index[area['uuid']]
area.merge!(dynamic_area) if dynamic_area
end
end
submission
end
def maybe_enqueue_expire_at(submissions) def maybe_enqueue_expire_at(submissions)
submissions.each do |submission| submissions.each do |submission|
next unless submission.expire_at? next unless submission.expire_at?
@ -159,7 +198,8 @@ module Submissions
end end
if template_fields != (submission.template_fields || submission.template.fields) || new_fields.present? || if template_fields != (submission.template_fields || submission.template.fields) || new_fields.present? ||
submitters_attrs.any? { |e| e[:completed].present? } || !with_template || submission.variables.present? submitters_attrs.any? { |e| e[:completed].present? } || !with_template || submission.variables.present? ||
submission.template&.variables_schema.present?
submission.template_fields = new_fields ? new_fields + template_fields : template_fields submission.template_fields = new_fields ? new_fields + template_fields : template_fields
submission.template_schema = submission.template.schema if submission.template_schema.blank? submission.template_schema = submission.template.schema if submission.template_schema.blank?
submission.variables_schema = submission.template.variables_schema if submission.template && submission.variables_schema = submission.template.variables_schema if submission.template &&

@ -10,6 +10,7 @@ module Templates
template.external_id = external_id template.external_id = external_id
template.shared_link = original_template.shared_link template.shared_link = original_template.shared_link
template.variables_schema = original_template.variables_schema
template.author = author template.author = author
template.name = name.presence || "#{original_template.name} (#{I18n.t('clone')})" template.name = name.presence || "#{original_template.name} (#{I18n.t('clone')})"

@ -36,11 +36,9 @@ module Templates
next unless new_attachment_uuid next unless new_attachment_uuid
new_document = new_document =
template.documents_attachments.new( template.documents_attachments.new(uuid: new_attachment_uuid, blob_id: document.blob_id)
uuid: new_attachment_uuid,
blob_id: document.blob_id
)
maybe_clone_dynamic_document(template, original_template, new_document, document)
clone_document_preview_images_attachments(document:, new_document:) clone_document_preview_images_attachments(document:, new_document:)
new_document new_document
@ -51,6 +49,32 @@ module Templates
attachments attachments
end end
def maybe_clone_dynamic_document(template, original_template, document, original_document)
schema_item = original_template.schema.find { |e| e['attachment_uuid'] == original_document.uuid }
return unless schema_item
return unless schema_item['dynamic']
dynamic_document = original_template.dynamic_documents.find { |e| e.uuid == original_document.uuid }
return unless dynamic_document
new_dynamic_document = template.dynamic_documents.new(
uuid: document.uuid,
body: dynamic_document.body,
head: dynamic_document.head
)
dynamic_document.attachments_attachments.each do |attachment|
new_dynamic_document.attachments_attachments.new(
uuid: attachment.uuid,
blob_id: attachment.blob_id
)
end
new_dynamic_document
end
def clone_document_preview_images_attachments(document:, new_document:) def clone_document_preview_images_attachments(document:, new_document:)
document.preview_images_attachments.each do |preview_image| document.preview_images_attachments.each do |preview_image|
new_document.preview_images_attachments.new(blob_id: preview_image.blob_id) new_document.preview_images_attachments.new(blob_id: preview_image.blob_id)

@ -24,10 +24,18 @@ module Templates
module_function module_function
def call(template, params, extract_fields: false) def call(template, params, extract_fields: false, dynamic: false)
extract_zip_files(params[:files].presence || params[:file]).flat_map do |file| documents = []
handle_file_types(template, file, params, extract_fields:) dynamic_documents = []
extract_zip_files(params[:files].presence || params[:file]).each do |file|
docs, dynamic_docs = handle_file_types(template, file, params, extract_fields:, dynamic:)
documents.push(*docs)
dynamic_documents.push(*dynamic_docs)
end end
[documents, dynamic_documents]
end end
def handle_pdf_or_image(template, file, document_data = nil, params = {}, extract_fields: false) def handle_pdf_or_image(template, file, document_data = nil, params = {}, extract_fields: false)
@ -108,12 +116,12 @@ module Templates
extracted_files extracted_files
end end
def handle_file_types(template, file, params, extract_fields:) def handle_file_types(template, file, params, extract_fields:, dynamic: false)
if file.content_type.include?('image') || file.content_type == PDF_CONTENT_TYPE if file.content_type.include?('image') || file.content_type == PDF_CONTENT_TYPE
return handle_pdf_or_image(template, file, file.read, params, extract_fields:) return [handle_pdf_or_image(template, file, file.read, params, extract_fields:), []]
end end
raise InvalidFileType, file.content_type raise InvalidFileType, "#{file.content_type}/#{dynamic}"
end end
end end
end end

@ -6,7 +6,7 @@ module Templates
# rubocop:disable Metrics # rubocop:disable Metrics
def call(template, params = {}, extract_fields: false) def call(template, params = {}, extract_fields: false)
documents = Templates::CreateAttachments.call(template, params, extract_fields:) documents, = Templates::CreateAttachments.call(template, params, extract_fields:)
submitter = template.submitters.first submitter = template.submitters.first
documents.each_with_index do |document, index| documents.each_with_index do |document, index|

@ -17,7 +17,10 @@
"@tiptap/core": "^3.19.0", "@tiptap/core": "^3.19.0",
"@tiptap/extension-bold": "^3.19.0", "@tiptap/extension-bold": "^3.19.0",
"@tiptap/extension-document": "^3.19.0", "@tiptap/extension-document": "^3.19.0",
"@tiptap/extension-dropcursor": "^3.19.0",
"@tiptap/extension-gapcursor": "^3.19.0",
"@tiptap/extension-hard-break": "^3.19.0", "@tiptap/extension-hard-break": "^3.19.0",
"@tiptap/extension-history": "^3.19.0",
"@tiptap/extension-italic": "^3.19.0", "@tiptap/extension-italic": "^3.19.0",
"@tiptap/extension-link": "^3.19.0", "@tiptap/extension-link": "^3.19.0",
"@tiptap/extension-paragraph": "^3.19.0", "@tiptap/extension-paragraph": "^3.19.0",

@ -0,0 +1,18 @@
const path = require('path')
module.exports = {
content: [
path.resolve(__dirname, 'app/javascript/template_builder/dynamic_area.vue'),
path.resolve(__dirname, 'app/javascript/template_builder/dynamic_section.vue')
],
theme: {
extend: {
colors: {
'base-100': '#faf7f5',
'base-200': '#efeae6',
'base-300': '#e7e2df',
'base-content': '#291334'
}
}
}
}

@ -1787,11 +1787,26 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.19.0.tgz#dfa6889cff748d489e0bc1028918bf4571372ba5" resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.19.0.tgz#dfa6889cff748d489e0bc1028918bf4571372ba5"
integrity sha512-AOf0kHKSFO0ymjVgYSYDncRXTITdTcrj1tqxVazrmO60KNl1Rc2dAggDvIVTEBy5NvceF0scc7q3sE/5ZtVV7A== integrity sha512-AOf0kHKSFO0ymjVgYSYDncRXTITdTcrj1tqxVazrmO60KNl1Rc2dAggDvIVTEBy5NvceF0scc7q3sE/5ZtVV7A==
"@tiptap/extension-dropcursor@^3.19.0":
version "3.19.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.19.0.tgz#fbef441944842f23fe0a35154b519103166a4848"
integrity sha512-sf3dEZXiLvsGqVK2maUIzXY6qtYYCvBumag7+VPTMGQ0D4hiZ1X/4ukt4+6VXDg5R2WP1CoIt/QvUetUjWNhbQ==
"@tiptap/extension-gapcursor@^3.19.0":
version "3.19.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.19.0.tgz#64e5462a4ab2f0bd110738410dcbf3597d76349f"
integrity sha512-w7DACS4oSZaDWjz7gropZHPc9oXqC9yERZTcjWxyORuuIh1JFf0TRYspleK+OK28plK/IftojD/yUDn1MTRhvA==
"@tiptap/extension-hard-break@^3.19.0": "@tiptap/extension-hard-break@^3.19.0":
version "3.19.0" version "3.19.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.19.0.tgz#7120524cec9ed4b957963693cb4c57cbecbaecf8" resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.19.0.tgz#7120524cec9ed4b957963693cb4c57cbecbaecf8"
integrity sha512-lAmQraYhPS5hafvCl74xDB5+bLuNwBKIEsVoim35I0sDJj5nTrfhaZgMJ91VamMvT+6FF5f1dvBlxBxAWa8jew== integrity sha512-lAmQraYhPS5hafvCl74xDB5+bLuNwBKIEsVoim35I0sDJj5nTrfhaZgMJ91VamMvT+6FF5f1dvBlxBxAWa8jew==
"@tiptap/extension-history@^3.19.0":
version "3.19.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-3.19.0.tgz#dc3a52f4eb16b7ac077c28284684b558c03adf00"
integrity sha512-hhN5nL7Pqcd9iomzeUUMKnmSQieKNlJ2FUgf2dHUqqvn4pWvcHA6P6UwdDNhuKFivSJNNMqtajkOvO4bYq1KPw==
"@tiptap/extension-italic@^3.19.0": "@tiptap/extension-italic@^3.19.0":
version "3.19.0" version "3.19.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.19.0.tgz#af2a9c095ec846e379041f3e17e1dd101a5a4bf8" resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.19.0.tgz#af2a9c095ec846e379041f3e17e1dd101a5a4bf8"
@ -6030,9 +6045,9 @@ prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transfor
prosemirror-model "^1.21.0" prosemirror-model "^1.21.0"
prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.41.4: prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.41.4:
version "1.41.5" version "1.41.6"
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.5.tgz#3e152d14af633f2f5a73aba24e6130c63f643b2b" resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.6.tgz#949d0407a91e36f6024db2191b8d3058dfd18838"
integrity sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA== integrity sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==
dependencies: dependencies:
prosemirror-model "^1.20.0" prosemirror-model "^1.20.0"
prosemirror-state "^1.0.0" prosemirror-state "^1.0.0"

Loading…
Cancel
Save