From d07872707018ba12999ff33c9f762a9be50a0bb4 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sun, 21 Dec 2025 12:17:32 +0200 Subject: [PATCH] recipient fields condition --- app/javascript/application.js | 2 + app/javascript/elements/field_condition.js | 151 ++++++++++++++++++ app/views/submissions/_detailed_form.html.erb | 16 +- lib/submitters/normalize_values.rb | 3 +- 4 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 app/javascript/elements/field_condition.js 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/views/submissions/_detailed_form.html.erb b/app/views/submissions/_detailed_form.html.erb index 31b969bc..44e87732 100644 --- a/app/views/submissions/_detailed_form.html.erb +++ b/app/views/submissions/_detailed_form.html.erb @@ -60,17 +60,23 @@ <% end %> <% prefillable_fields.each do |field| %> + <% field_id = "detailed_field_#{index}_#{field['uuid'] || field['name'].parameterize}" %> <% if field['type'] == 'checkbox' %> -