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 @@