diff --git a/Gemfile.lock b/Gemfile.lock index f8f8b1ee..9215b4b2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -77,24 +77,27 @@ GEM annotaterb (4.14.0) arabic-letter-connector (0.1.1) ast (2.4.3) - aws-eventstream (1.3.0) - aws-partitions (1.1027.0) - aws-sdk-core (3.214.0) + aws-eventstream (1.4.0) + aws-partitions (1.1197.0) + aws-sdk-core (3.240.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.96.0) - aws-sdk-core (~> 3, >= 3.210.0) + logger + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.176.1) - aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-s3 (1.208.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sdk-secretsmanager (1.110.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sigv4 (1.10.1) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) azure-storage-blob (2.0.3) azure-storage-common (~> 2.0) diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index acb95259..f10cb1f0 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -53,14 +53,15 @@ class SubmissionsController < ApplicationController else submissions_attrs = submissions_params[:submission].to_h.values - submissions_attrs, = - Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, @template) + 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 diff --git a/app/javascript/application.js b/app/javascript/application.js index 1320946e..86a77b85 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -52,6 +52,7 @@ import AutosizeField from './elements/autosize_field' import GoogleDriveFilePicker from './elements/google_drive_file_picker' import OpenModal from './elements/open_modal' import BarChart from './elements/bar_chart' +import FieldCondition from './elements/field_condition' import * as TurboInstantClick from './lib/turbo_instant_click' @@ -142,6 +143,7 @@ safeRegisterElement('autosize-field', AutosizeField) safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker) safeRegisterElement('open-modal', OpenModal) safeRegisterElement('bar-chart', BarChart) +safeRegisterElement('field-condition', FieldCondition) safeRegisterElement('template-builder', class extends HTMLElement { connectedCallback () { diff --git a/app/javascript/elements/field_condition.js b/app/javascript/elements/field_condition.js new file mode 100644 index 00000000..609d9955 --- /dev/null +++ b/app/javascript/elements/field_condition.js @@ -0,0 +1,151 @@ +export default class extends HTMLElement { + connectedCallback () { + this.targetId = this.dataset.targetId + this.fieldId = this.dataset.fieldId + this.action = (this.dataset.action || '').trim() + this.expectedValue = this.dataset.value + + this.targetEl = document.getElementById(this.targetId) + this.sourceEl = document.getElementById(this.fieldId) + + this.bindListeners() + + this.evaluateAndApply() + } + + disconnectedCallback () { + this.unbindListeners() + } + + bindListeners () { + this.eventsFor(this.sourceEl).forEach((ev) => { + this.sourceEl.addEventListener(ev, this.evaluateAndApply) + }) + } + + unbindListeners () { + this.eventsFor(this.sourceEl).forEach((ev) => { + this.sourceEl.removeEventListener(ev, this.evaluateAndApply) + }) + } + + eventsFor (el) { + if (!el) return [] + + const tag = el.tagName.toLowerCase() + + if (tag === 'textarea') return ['input'] + if (tag === 'input') return ['input', 'change'] + + return ['change'] + } + + evaluateAndApply = () => { + const fieldConditions = document.querySelectorAll(`field-condition[data-target-id="${this.targetId}"]`) + + const result = [...fieldConditions].reduce((acc, cond) => { + if (cond.dataset.operation === 'or') { + acc.push(acc.pop() || cond.checkCondition()) + } else { + acc.push(cond.checkCondition()) + } + + return acc + }, []) + + this.apply(!result.includes(false)) + } + + checkCondition () { + const action = this.action + const actual = this.getSourceValue() + const expected = this.expectedValue + + if (action === 'empty' || action === 'unchecked') return this.isEmpty(actual) + if (action === 'not_empty' || action === 'checked') return !this.isEmpty(actual) + + if (action === 'equal') { + const list = Array.isArray(actual) ? actual : [actual] + return list.filter((v) => v !== null && v !== undefined).map(String).includes(String(expected)) + } + + if (action === 'contains') return this.contains(actual, expected) + + if (action === 'not_equal') { + const list = Array.isArray(actual) ? actual : [actual] + return !list.filter((v) => v !== null && v !== undefined).map(String).includes(String(expected)) + } + + if (action === 'does_not_contain') return !this.contains(actual, expected) + + return true + } + + getSourceValue () { + const el = this.sourceEl + + if (!el) return + + const tag = el.tagName.toLowerCase() + const type = (el.getAttribute('type') || '').toLowerCase() + + if (tag === 'select') return el.value + if (tag === 'textarea') return el.value + if (tag === 'input' && type === 'checkbox') return el.checked ? (el.value || '1') : null + if (tag === 'input') return el.value + + return el.value ?? null + } + + isEmpty (obj) { + if (obj == null) return true + + if (Array.isArray(obj)) { + return obj.length === 0 + } + + if (typeof obj === 'string') { + return obj.trim().length === 0 + } + + if (typeof obj === 'object') { + return Object.keys(obj).length === 0 + } + + if (obj === false) { + return true + } + + return false + } + + contains (actual, expected) { + if (expected === null || expected === undefined) return false + + const exp = String(expected) + + if (Array.isArray(actual)) return actual.filter((v) => v !== null && v !== undefined).map(String).includes(exp) + + if (typeof actual === 'string') return actual.includes(exp) + + return actual !== null && actual !== undefined && String(actual) === exp + } + + apply (passed) { + const controls = this.targetEl.matches('input, select, textarea, button') + ? [this.targetEl] + : Array.from(this.targetEl.querySelectorAll('input, select, textarea, button')) + + if (passed) { + this.targetEl.style.display = '' + this.targetEl.labels.forEach((label) => { label.style.display = '' }) + + controls.forEach((c) => (c.disabled = false)) + } else { + this.targetEl.style.display = 'none' + this.targetEl.labels.forEach((label) => { label.style.display = 'none' }) + + controls.forEach((c) => (c.disabled = true)) + } + } +} diff --git a/app/javascript/form.js b/app/javascript/form.js index b8253f86..1fbd54c8 100644 --- a/app/javascript/form.js +++ b/app/javascript/form.js @@ -37,6 +37,7 @@ safeRegisterElement('submission-form', class extends HTMLElement { withSignatureId: this.dataset.withSignatureId === 'true', requireSigningReason: this.dataset.requireSigningReason === 'true', withConfetti: this.dataset.withConfetti !== 'false', + withFieldLabels: this.dataset.withFieldLabels !== 'false', withDisclosure: this.dataset.withDisclosure === 'true', reuseSignature: this.dataset.reuseSignature !== 'false', withTypedSignature: this.dataset.withTypedSignature !== 'false', diff --git a/app/javascript/submission_form/area.vue b/app/javascript/submission_form/area.vue index d3a4eaf6..c39cb16d 100644 --- a/app/javascript/submission_form/area.vue +++ b/app/javascript/submission_form/area.vue @@ -560,7 +560,7 @@ export default { return style }, isNarrow () { - return this.area.h > 0 && (this.area.w / this.area.h) > 6 + return this.area.h > 0 && ((this.area.w * this.pageWidth) / (this.area.h * this.pageHeight)) > 4.5 } }, watch: { diff --git a/app/javascript/submission_form/form.vue b/app/javascript/submission_form/form.vue index 66b04b64..b793d6ae 100644 --- a/app/javascript/submission_form/form.vue +++ b/app/javascript/submission_form/form.vue @@ -8,7 +8,7 @@ :scroll-el="scrollEl" :with-signature-id="withSignatureId" :attachments-index="attachmentsIndex" - :with-label="!isAnonymousChecboxes && showFieldNames" + :with-label="withFieldLabels && !isAnonymousChecboxes && showFieldNames" :current-step="currentStepFields" :scroll-padding="scrollPadding" @focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]" @@ -737,6 +737,11 @@ export default { required: false, default: true }, + withFieldLabels: { + type: Boolean, + required: false, + default: true + }, withConfetti: { type: Boolean, required: false, diff --git a/app/javascript/submission_form/phone_step.vue b/app/javascript/submission_form/phone_step.vue index d80ac512..b854c384 100644 --- a/app/javascript/submission_form/phone_step.vue +++ b/app/javascript/submission_form/phone_step.vue @@ -100,7 +100,7 @@
- + {{ index + 1 }}.
@@ -363,7 +372,8 @@ export default { isShowFontModal: false, isShowConditionsModal: false, isShowDescriptionModal: false, - renderDropdown: false + renderDropdown: false, + optionDragRef: null } }, computed: { @@ -450,15 +460,17 @@ export default { closeDropdown () { this.$el.getRootNode().activeElement.blur() }, - addOption () { + addOptionAt (index) { this.isExpandOptions = true - this.field.options.push({ value: '', uuid: v4() }) + const insertAt = index ?? this.field.options.length + + this.field.options.splice(insertAt, 0, { value: '', uuid: v4() }) this.$nextTick(() => { const inputs = this.$refs.options.querySelectorAll('input') - inputs[inputs.length - 1]?.focus() + inputs[insertAt]?.focus() }) this.save() @@ -515,6 +527,70 @@ export default { this.isNameFocus = false this.save() + }, + onOptionDragstart (event, option) { + this.optionDragRef = option + + const root = this.$el.getRootNode() + const hiddenEl = document.createElement('div') + + hiddenEl.style.width = '1px' + hiddenEl.style.height = '1px' + hiddenEl.style.opacity = '0' + hiddenEl.style.position = 'fixed' + + root.querySelector('#docuseal_modal_container')?.appendChild(hiddenEl) + event.dataTransfer?.setDragImage(hiddenEl, 0, 0) + + setTimeout(() => { hiddenEl.remove() }, 1000) + + event.dataTransfer.effectAllowed = 'move' + }, + onOptionDragover (e) { + if (!this.optionDragRef) return + + e.preventDefault() + e.stopPropagation() + + const targetRow = e.target.closest('[data-option-uuid]') + + if (!targetRow) return + + const dragRow = this.$refs.options?.querySelector(`[data-option-uuid="${this.optionDragRef.uuid}"]`) + + if (!dragRow) return + if (targetRow === dragRow) return + + const rows = Array.from(this.$refs.options.querySelectorAll('[data-option-uuid]')) + + const currentIndex = rows.indexOf(dragRow) + const targetIndex = rows.indexOf(targetRow) + + if (currentIndex < targetIndex) { + targetRow.after(dragRow) + } else { + targetRow.before(dragRow) + } + }, + reorderOptions (e) { + if (!this.optionDragRef) return + + e.preventDefault() + e.stopPropagation() + + const rows = Array.from(this.$refs.options.querySelectorAll('[data-option-uuid]')) + + const newOrder = rows + .map((el) => this.field.options.find((opt) => opt.uuid === el.dataset.optionUuid)) + .filter(Boolean) + + if (newOrder.length === this.field.options.length) { + this.field.options.splice(0, this.field.options.length, ...newOrder) + + this.save() + } + + this.optionDragRef = null } } } diff --git a/app/javascript/template_builder/fields.vue b/app/javascript/template_builder/fields.vue index 2bee841a..20c33a3a 100644 --- a/app/javascript/template_builder/fields.vue +++ b/app/javascript/template_builder/fields.vue @@ -17,7 +17,7 @@ ref="fields" class="fields mb-1 mt-2" @dragover.prevent="onFieldDragover" - @drop="reorderFields" + @drop="fieldsDragFieldRef.value ? reorderFields() : null" >
<% submitters.each_with_index do |item, index| %> - <% prefillable_fields = local_assigns[:prefillable_fields].to_a.select { |f| f['submitter_uuid'] == item['uuid'] } %> + <% prefillable_fields = local_assigns[:prefillable_fields].to_a.select { |f| f['submitter_uuid'] == item['uuid'] }.presence %> + <% prefillable_fields ||= local_assigns[:recipient_form_fields].presence %> <% if submitters.size > 1 %>
- <% if params[:selfsign].blank? && local_assigns[:prefillable_fields].blank? %> + <% if params[:selfsign].blank? && local_assigns[:prefillable_fields].blank? && local_assigns[:recipient_form_fields].blank? %> <%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %> <%= t('add_new') %> diff --git a/app/views/submissions/_value.html.erb b/app/views/submissions/_value.html.erb index 5a8e3d22..4213d728 100644 --- a/app/views/submissions/_value.html.erb +++ b/app/views/submissions/_value.html.erb @@ -7,7 +7,7 @@ <% font_size_px = (field.dig('preferences', 'font_size').presence || Submissions::GenerateResultAttachments::FONT_SIZE).to_i * local_assigns.fetch(:font_scale) { 1000.0 / PdfUtils::US_LETTER_W } %> <%= "background: #{bg_color}; " if bg_color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)"> <% if field['type'] == 'signature' %> - <% is_narrow = area['h']&.positive? && (area['w'].to_f / area['h']) > 6 %> + <% is_narrow = area['h'].positive? && ((area['w'] * local_assigns[:page_width]).to_f / (area['h'] * local_assigns[:page_height])) > 4.5 %>
diff --git a/app/views/submissions/new.html.erb b/app/views/submissions/new.html.erb index e70b181f..46088dbb 100644 --- a/app/views/submissions/new.html.erb +++ b/app/views/submissions/new.html.erb @@ -1,7 +1,8 @@ <% require_phone_2fa = @template.preferences['require_phone_2fa'] == true %> <% require_email_2fa = @template.preferences['require_email_2fa'] == true %> <% prefillable_fields = @template.fields.select { |f| f['prefillable'] } %> -<% only_detailed = require_phone_2fa || require_email_2fa || prefillable_fields.present? %> +<% 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? %> <%= 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 %> @@ -26,7 +27,7 @@
<% end %>
- <%= render 'detailed_form', template: @template, require_phone_2fa:, require_email_2fa:, prefillable_fields: %> + <%= render 'detailed_form', template: @template, require_phone_2fa:, require_email_2fa:, prefillable_fields:, recipient_form_fields: %>