From 1e2c752937f4b62911919d7137cdc643f4fbeb4b Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 22 Apr 2026 10:27:18 +0300 Subject: [PATCH] detect existing fields --- app/javascript/application.js | 1 + app/javascript/template_builder/builder.vue | 80 ++++++++++++++++-- app/javascript/template_builder/fields.vue | 91 ++++++++++++++++++++- 3 files changed, 164 insertions(+), 8 deletions(-) diff --git a/app/javascript/application.js b/app/javascript/application.js index 920cba82..29bc52dd 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -169,6 +169,7 @@ safeRegisterElement('template-builder', class extends HTMLElement { withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null, withLogo: this.dataset.withLogo !== 'false', withFieldsDetection: this.dataset.withFieldsDetection === 'true', + withDetectExistingFields: this.dataset.withDetectExistingFields === 'true', editable: this.dataset.editable !== 'false', authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content, withCustomFields: true, diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index ae710b18..26ae49bc 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -506,9 +506,11 @@ :default-fields="[...defaultRequiredFields, ...defaultFields]" :template="template" :default-required-fields="defaultRequiredFields" + :detect-custom-fields-index="detectCustomFieldsIndex" :field-types="fieldTypes" :with-sticky-submitters="withStickySubmitters" :with-fields-detection="withFieldsDetection" + :with-detect-existing-fields="withDetectExistingFields" :with-signature-id="withSignatureId" :with-prefillable="withPrefillable" :only-defined-fields="onlyDefinedFields" @@ -738,6 +740,11 @@ export default { required: false, default: false }, + withDetectExistingFields: { + type: Boolean, + required: false, + default: false + }, withCustomFields: { type: Boolean, required: false, @@ -1053,6 +1060,39 @@ export default { selectedField () { return this.template.fields.find((f) => f.areas?.includes(this.lastSelectedArea)) }, + detectFieldsIndex () { + const submittersByUuid = {} + + this.template.submitters.forEach((s) => { + submittersByUuid[s.uuid] = s + }) + + const index = {} + + this.template.fields.forEach((f) => { + if (!f.name) return + + const role = submittersByUuid[f.submitter_uuid]?.name + const key = [f.name, role].filter(Boolean).join(':').toLowerCase() + + if (!index[key]) index[key] = f + }) + + return index + }, + detectCustomFieldsIndex () { + const index = {} + + ;[...this.customFields, ...this.defaultRequiredFields, ...this.defaultFields].forEach((c) => { + if (!c.name) return + + const key = [c.name, c.role].filter(Boolean).join(':').toLowerCase() + + if (!index[key]) index[key] = c + }) + + return index + }, sortedDocuments () { return this.template.schema.map((item) => { return this.template.documents.find(doc => doc.uuid === item.attachment_uuid) @@ -1148,6 +1188,8 @@ export default { }, methods: { toRaw, + applyCustomFieldAttributes: Fields.methods.applyCustomFieldAttributes, + buildExistingFields: Fields.methods.buildExistingFields, addCustomField (field) { return this.$refs.fields.addCustomField(field) }, @@ -1493,6 +1535,30 @@ export default { this.template.fields.push(field) } }, + insertDetectedField (field) { + if (!this.withDetectExistingFields || !field.name) { + this.insertField(field) + + return + } + + const role = this.template.submitters.find((s) => s.uuid === field.submitter_uuid)?.name + const nameKey = field.name.toLowerCase() + const indexKey = [field.name, role].filter(Boolean).join(':').toLowerCase() + + const existingField = this.detectFieldsIndex[indexKey] + + if (existingField) { + existingField.areas = existingField.areas || [] + existingField.areas.push(...(field.areas || [])) + } else { + const customField = this.detectCustomFieldsIndex[indexKey] || this.detectCustomFieldsIndex[nameKey] + + if (customField) this.applyCustomFieldAttributes(field, customField) + + this.insertField(field) + } + }, closeDropdown () { document.activeElement.blur() }, @@ -2828,7 +2894,11 @@ export default { this.baseFetch(`/templates/${this.template.id}/detect_fields`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ attachment_uuid: attachmentUuid, page }) + body: JSON.stringify({ + attachment_uuid: attachmentUuid, + page, + ...(this.withDetectExistingFields ? { fields: this.buildExistingFields() } : {}) + }) }).then(async (response) => { const reader = response.body.getReader() const decoder = new TextDecoder('utf-8') @@ -2857,7 +2927,7 @@ export default { if (!f.submitter_uuid) { f.submitter_uuid = this.template.submitters[0].uuid } - this.insertField(f) + this.insertDetectedField(f) }) totalFieldsAdded += errorFields.length @@ -2886,7 +2956,7 @@ export default { const nonOverlappingFields = filterNonOverlappingFields(finalFields) - nonOverlappingFields.forEach((f) => this.insertField(f)) + nonOverlappingFields.forEach((f) => this.insertDetectedField(f)) totalFieldsAdded += nonOverlappingFields.length if (nonOverlappingFields.length) { @@ -2924,7 +2994,7 @@ export default { const nonOverlappingFields = filterNonOverlappingFields(finalFields) - nonOverlappingFields.forEach((f) => this.insertField(f)) + nonOverlappingFields.forEach((f) => this.insertDetectedField(f)) totalFieldsAdded += nonOverlappingFields.length if (nonOverlappingFields.length) { @@ -2942,7 +3012,7 @@ export default { const nonOverlappingFields = filterNonOverlappingFields(finalFields) - nonOverlappingFields.forEach((f) => this.insertField(f)) + nonOverlappingFields.forEach((f) => this.insertDetectedField(f)) totalFieldsAdded += nonOverlappingFields.length if (nonOverlappingFields.length) { diff --git a/app/javascript/template_builder/fields.vue b/app/javascript/template_builder/fields.vue index 7155414d..0ff497c9 100644 --- a/app/javascript/template_builder/fields.vue +++ b/app/javascript/template_builder/fields.vue @@ -428,6 +428,11 @@ export default { required: false, default: false }, + withDetectExistingFields: { + type: Boolean, + required: false, + default: false + }, withSignatureId: { type: Boolean, required: false, @@ -495,6 +500,11 @@ export default { type: Object, required: true }, + detectCustomFieldsIndex: { + type: Object, + required: false, + default: () => ({}) + }, showTourStartForm: { type: Boolean, required: false, @@ -656,6 +666,78 @@ export default { this.customFields.splice(0, this.customFields.length, ...fields) }) }, + buildExistingFields () { + const existing = [] + const seen = new Set() + + const submittersByUuid = this.template.submitters.reduce((acc, s) => { + acc[s.uuid] = s + + return acc + }, {}) + + const add = (field, role) => { + if (!field?.name) return + + const key = field.name.toLowerCase() + ':' + (role || '').toLowerCase() + + if (seen.has(key)) return + + seen.add(key) + + const item = { name: field.name, type: field.type || 'text' } + + if (role) item.role = role + + const optionValues = Array.isArray(field.options) + ? field.options.map((o) => (typeof o === 'string' ? o : o?.value)).filter(Boolean) + : [] + + if (optionValues.length) item.options = optionValues + + existing.push(item) + } + + this.template.fields.forEach((f) => add(f, submittersByUuid[f.submitter_uuid]?.name)) + this.defaultRequiredFields.forEach((f) => add(f, f.role)) + this.defaultFields.forEach((f) => add(f, f.role)) + this.customFields.forEach((f) => add(f, f.role)) + + return existing + }, + enrichDetectedField (field) { + if (!this.withDetectExistingFields || !field.name) return field + + const role = this.template.submitters.find((s) => s.uuid === field.submitter_uuid)?.name + const nameKey = field.name.toLowerCase() + const indexKey = [field.name, role].filter(Boolean).join(':').toLowerCase() + + const customField = this.detectCustomFieldsIndex[indexKey] || this.detectCustomFieldsIndex[nameKey] + + if (customField) this.applyCustomFieldAttributes(field, customField) + + return field + }, + applyCustomFieldAttributes (field, customField) { + const skipKeys = new Set(['uuid', 'areas', 'submitter_uuid', 'conditions', 'prefillable', 'role']) + + Object.entries(customField).forEach(([key, value]) => { + if (skipKeys.has(key)) return + if (value === null || value === undefined) return + + if (key === 'options') { + if (Array.isArray(value) && !Array.isArray(field.options)) { + field.options = value.map((o) => ( + typeof o === 'string' ? { value: o, uuid: v4() } : { ...o, uuid: v4() } + )) + } + } else if (value && typeof value === 'object' && !Array.isArray(value)) { + field[key] = JSON.parse(JSON.stringify(value)) + } else { + field[key] = value + } + }) + }, detectFields () { const fields = [] @@ -665,7 +747,10 @@ export default { method: 'POST', headers: { 'Content-Type': 'application/json' - } + }, + ...(this.withDetectExistingFields + ? { body: JSON.stringify({ fields: this.buildExistingFields() }) } + : {}) }).then(async (response) => { const reader = response.body.getReader() const decoder = new TextDecoder('utf-8') @@ -687,7 +772,7 @@ export default { if (data.error) { if ((data.fields || fields).length) { - this.template.fields = data.fields || fields + this.template.fields = (data.fields || fields).map((f) => this.enrichDetectedField(f)) this.save() } else { @@ -705,7 +790,7 @@ export default { this.$emit('select-submitter', this.template.submitters[0]) } - this.template.fields = data.fields || fields + this.template.fields = (data.fields || fields).map((f) => this.enrichDetectedField(f)) this.save()