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,