diff --git a/app/javascript/template_builder/area.vue b/app/javascript/template_builder/area.vue index bbf1a357..85be8e92 100644 --- a/app/javascript/template_builder/area.vue +++ b/app/javascript/template_builder/area.vue @@ -113,7 +113,7 @@
{{ formatNumber(field.default_value, field.preferences?.format) }} + >{{ formatNumber(displayValue, field.preferences?.format) }} @@ -183,12 +183,12 @@ :contenteditable="isValueInput" class="whitespace-pre-wrap outline-none empty:before:content-[attr(placeholder)] before:text-base-content/30" :class="{ 'cursor-text': isValueInput }" - :placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value'))" + :placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (isConditionMatch ? (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value')) : '')" @blur="onDefaultValueBlur" @focus="selectedAreasRef.value = [area]" @paste.prevent="onPaste" @keydown.enter="onDefaultValueEnter" - >{{ field.default_value }} + >{{ displayValue }}
({}) + }, + formulaValuesIndex: { + type: Object, + required: false, + default: () => ({}) + }, isDraw: { type: Boolean, required: false, @@ -323,11 +333,27 @@ export default { fieldNames: FieldType.computed.fieldNames, fieldLabels: FieldType.computed.fieldLabels, fieldIcons: FieldType.computed.fieldIcons, + isConditionMatch () { + return !this.inputMode || this.conditionalFieldIndex[this.field.uuid] !== false + }, + displayValue () { + if (this.field.preferences?.formula && this.field.type !== 'payment') { + const computed = this.formulaValuesIndex[this.field.uuid] + + if (computed != null) { + return computed + } + } + + return this.field.default_value + }, bgClasses () { if (this.field.type === 'heading') { return 'bg-gray-50' } else if (this.field.type === 'strikethrough') { return 'bg-transparent' + } else if (!this.isConditionMatch) { + return 'bg-gray-100' } else { return this.bgColors[this.submitterIndex % this.bgColors.length] } @@ -337,6 +363,8 @@ export default { return '' } else if (this.field.type === 'strikethrough') { return 'border-dashed border-gray-300' + } else if (!this.isConditionMatch) { + return 'border-gray-300' } else { return this.borderColors[this.submitterIndex % this.borderColors.length] } @@ -390,7 +418,7 @@ export default { return this.basePageWidth / 612.0 }, isDefaultValuePresent () { - return this.field?.default_value || this.field?.default_value === 0 + return this.field?.default_value || this.field?.default_value === 0 || this.displayValue || this.displayValue === 0 }, isSelectInput () { return this.inputMode && (this.field.type === 'select' || (this.field.type === 'radio' && this.field.areas?.length < 2)) @@ -399,6 +427,8 @@ export default { return this.inputMode && (this.field.type === 'checkbox' || (['radio', 'multiple'].includes(this.field.type) && this.area.option_uuid)) }, isValueInput () { + if (this.inputMode && this.field.preferences?.formula) return false + 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}}'))) }, @@ -511,7 +541,7 @@ export default { return option?.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}` }, maybeToggleDefaultValue () { - if (!this.editable || this.isCmdKeyRef.value) { + if (!this.editable || this.isCmdKeyRef.value || this.field.preferences?.formula) { return } @@ -559,6 +589,10 @@ export default { } }, focusValueInput (e) { + if (this.inputMode && this.field.type === 'number' && !this.isContenteditable && !this.field.preferences?.formula) { + this.isContenteditable = true + } + this.$nextTick(() => { if (this.$refs.defaultValue && this.$refs.defaultValue !== document.activeElement) { this.$refs.defaultValue.focus() @@ -624,6 +658,12 @@ export default { } }, onDefaultValueBlur (e) { + if (this.field.preferences?.formula) { + this.isContenteditable = false + + return + } + const text = this.$refs.defaultValue.innerText.trim() this.isContenteditable = false diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 26ae49bc..66b3cf86 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -381,6 +381,8 @@ :document="document" :is-drag="!!dragField" :input-mode="inputMode" + :conditional-field-index="conditionalFieldIndex" + :formula-values-index="formulaValuesIndex" :default-fields="[...defaultRequiredFields, ...defaultFields]" :allow-draw="!onlyDefinedFields || drawField || drawCustomField" :with-signature-id="withSignatureId" @@ -619,6 +621,16 @@ import { v4 } from 'uuid' import { ref, computed, toRaw, defineAsyncComponent } from 'vue' import * as i18n from './i18n' +const 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 +} + export default { name: 'TemplateBuilder', components: { @@ -971,7 +983,8 @@ export default { drawCustomField: null, drawOption: null, dragField: null, - isDragFile: false + isDragFile: false, + isMathLoaded: false } }, computed: { @@ -1052,6 +1065,43 @@ export default { return map }, + fieldsUuidIndex () { + return this.template.fields.reduce((acc, f) => { + acc[f.uuid] = f + + return acc + }, {}) + }, + conditionalFieldIndex () { + if (!this.inputMode) return {} + + const cache = {} + + return this.template.fields.reduce((acc, f) => { + acc[f.uuid] = this.checkFieldConditions(f, cache) + + return acc + }, {}) + }, + formulaValuesIndex () { + const formulaFields = this.template.fields.filter((f) => f.preferences?.formula && f.type !== 'payment' && this.hasFormulaDependencyValue(f)) + + if (!formulaFields.length) return {} + + if (!this.isMathLoaded) { + this.loadCalculator() + + return {} + } + + return formulaFields.reduce((acc, f) => { + if (this.conditionalFieldIndex[f.uuid] !== false) { + acc[f.uuid] = this.calculateFormula(f) + } + + return acc + }, {}) + }, isAllRequiredFieldsAdded () { return !this.defaultRequiredFields?.some((f) => { return !this.template.fields?.some((field) => field.name === f.name) @@ -1190,6 +1240,130 @@ export default { toRaw, applyCustomFieldAttributes: Fields.methods.applyCustomFieldAttributes, buildExistingFields: Fields.methods.buildExistingFields, + async loadCalculator () { + if (this.math) return + + const { Calculator } = await import('../submission_form/calculator') + + this.math = new Calculator() + this.isMathLoaded = true + }, + optionValue (option, index) { + if (option.value) { + return option.value + } else { + return `${this.t('option')} ${index + 1}` + } + }, + checkFieldConditions (field, cache = {}) { + const cacheKey = field.uuid || field.attachment_uuid + + if (cache[cacheKey] !== undefined) { + return cache[cacheKey] + } + + if (field.conditions?.length) { + const result = field.conditions.reduce((acc, cond) => { + if (cond.operation === 'or') { + acc.push(acc.pop() || this.checkFieldCondition(cond, cache)) + } else { + acc.push(this.checkFieldCondition(cond, cache)) + } + + return acc + }, []) + + cache[cacheKey] = !result.includes(false) + } else { + cache[cacheKey] = true + } + + return cache[cacheKey] + }, + checkFieldCondition (condition, cache = {}) { + const field = this.fieldsUuidIndex[condition.field_uuid] + + if (['not_empty', 'checked', 'equal', 'contains', 'greater_than', 'less_than'].includes(condition.action) && field && !this.checkFieldConditions(field, cache)) { + return false + } + + const defaultValue = !field || isEmpty(field.default_value) ? null : field.default_value + + if (['empty', 'unchecked'].includes(condition.action)) { + return isEmpty(defaultValue) + } else if (['not_empty', 'checked'].includes(condition.action)) { + return !isEmpty(defaultValue) + } else if (field?.type === 'number' && ['equal', 'not_equal', 'greater_than', 'less_than'].includes(condition.action)) { + const value = defaultValue + + if (isEmpty(value) || isEmpty(condition.value)) return false + + const actual = parseFloat(value) + const expected = parseFloat(condition.value) + + if (Number.isNaN(actual) || Number.isNaN(expected)) return false + + if (condition.action === 'equal') return Math.abs(actual - expected) < Number.EPSILON + if (condition.action === 'not_equal') return Math.abs(actual - expected) > Number.EPSILON + if (condition.action === 'greater_than') return actual > expected + if (condition.action === 'less_than') return actual < expected + + return false + } else if (['equal', 'contains'].includes(condition.action) && field) { + if (field.options) { + const option = field.options.find((o) => o.uuid === condition.value) + + if (option) { + const values = [defaultValue].flat() + + return values.includes(this.optionValue(option, field.options.indexOf(option))) + } else { + return false + } + } else { + return [defaultValue].flat().includes(condition.value) + } + } else if (['not_equal', 'does_not_contain'].includes(condition.action) && field) { + if (field.options) { + const option = field.options.find((o) => o.uuid === condition.value) + + if (option) { + const values = [defaultValue].flat() + + return !values.includes(this.optionValue(option, field.options.indexOf(option))) + } else { + return false + } + } else { + return false + } + } else { + return true + } + }, + normalizeFormula (formula, depth = 0) { + if (depth > 10) return formula + + return formula.replace(/{{(.*?)}}/g, (match, uuid) => { + if (this.fieldsUuidIndex[uuid]?.preferences?.formula) { + return `(${this.normalizeFormula(this.fieldsUuidIndex[uuid].preferences.formula, depth + 1)})` + } else { + return match + } + }) + }, + calculateFormula (field) { + const transformedFormula = this.normalizeFormula(field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => { + return this.fieldsUuidIndex[uuid]?.default_value || 0.0 + }) + + return this.math.evaluate(transformedFormula.toLowerCase()) + }, + hasFormulaDependencyValue (field) { + const normalized = this.normalizeFormula(field.preferences.formula) + + return [...normalized.matchAll(/{{(.*?)}}/g)].some(([, uuid]) => !isEmpty(this.fieldsUuidIndex[uuid]?.default_value)) + }, addCustomField (field) { return this.$refs.fields.addCustomField(field) }, diff --git a/app/javascript/template_builder/document.vue b/app/javascript/template_builder/document.vue index 19a524c9..ee9a0836 100644 --- a/app/javascript/template_builder/document.vue +++ b/app/javascript/template_builder/document.vue @@ -5,6 +5,8 @@ :key="image.id" :ref="setPageRefs" :input-mode="inputMode" + :conditional-field-index="conditionalFieldIndex" + :formula-values-index="formulaValuesIndex" :number="index" :editable="editable" :data-page="index" @@ -64,6 +66,16 @@ export default { required: false, default: false }, + conditionalFieldIndex: { + type: Object, + required: false, + default: () => ({}) + }, + formulaValuesIndex: { + type: Object, + required: false, + default: () => ({}) + }, areasIndex: { type: Object, required: false, diff --git a/app/javascript/template_builder/page.vue b/app/javascript/template_builder/page.vue index 8580198c..9f1ce2d3 100644 --- a/app/javascript/template_builder/page.vue +++ b/app/javascript/template_builder/page.vue @@ -37,6 +37,8 @@ :ref="setAreaRefs" :area="item.area" :input-mode="inputMode" + :conditional-field-index="conditionalFieldIndex" + :formula-values-index="formulaValuesIndex" :page-width="width" :page-height="height" :field="item.field" @@ -180,6 +182,16 @@ export default { required: false, default: false }, + conditionalFieldIndex: { + type: Object, + required: false, + default: () => ({}) + }, + formulaValuesIndex: { + type: Object, + required: false, + default: () => ({}) + }, defaultFields: { type: Array, required: false,