From 91814ca10575bc062032846a84cb388f0d1e4ffe Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 26 Jan 2026 11:11:19 +0200 Subject: [PATCH] custom fields --- .../account_custom_fields_controller.rb | 33 +++ app/javascript/application.js | 2 + app/javascript/template_builder/area.vue | 4 +- app/javascript/template_builder/builder.vue | 185 +++++++++++- .../template_builder/contenteditable.vue | 77 ++++- .../template_builder/custom_field.vue | 267 ++++++++++++++++++ app/javascript/template_builder/document.vue | 9 +- .../template_builder/drag_placeholder.vue | 14 + app/javascript/template_builder/field.vue | 36 ++- .../template_builder/field_settings.vue | 88 ++++-- app/javascript/template_builder/fields.vue | 229 ++++++++++++++- app/javascript/template_builder/i18n.js | 16 +- app/javascript/template_builder/page.vue | 189 +++++++++---- .../template_builder/payment_settings.vue | 37 ++- app/models/account_config.rb | 1 + app/views/templates/edit.html.erb | 2 +- config/routes.rb | 1 + 17 files changed, 1069 insertions(+), 121 deletions(-) create mode 100644 app/controllers/account_custom_fields_controller.rb create mode 100644 app/javascript/template_builder/custom_field.vue diff --git a/app/controllers/account_custom_fields_controller.rb b/app/controllers/account_custom_fields_controller.rb new file mode 100644 index 00000000..74e27ea0 --- /dev/null +++ b/app/controllers/account_custom_fields_controller.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class AccountCustomFieldsController < ApplicationController + before_action :load_account_config, only: :create + + def create + authorize!(:create, Template) + + @account_config.update!(account_config_params) + + render json: @account_config.value + end + + private + + def load_account_config + @account_config = + AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY) + end + + def account_config_params + params.permit( + value: [[:uuid, :name, :type, + :required, :readonly, :default_value, + :title, :description, + { preferences: {}, + default_value: [], + options: [%i[value uuid]], + validation: %i[message pattern min max step], + areas: [%i[x y w h cell_w option_uuid]] }]] + ) + end +end diff --git a/app/javascript/application.js b/app/javascript/application.js index 4b5988c9..49cd9516 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -155,6 +155,7 @@ safeRegisterElement('template-builder', class extends HTMLElement { this.app = createApp(TemplateBuilder, { template: reactive(JSON.parse(this.dataset.template)), + customFields: reactive(JSON.parse(this.dataset.customFields || '[]')), backgroundColor: '#faf7f5', locale: this.dataset.locale, withPhone: this.dataset.withPhone === 'true', @@ -164,6 +165,7 @@ safeRegisterElement('template-builder', class extends HTMLElement { withFieldsDetection: this.dataset.withFieldsDetection === 'true', editable: this.dataset.editable !== 'false', authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content, + withCustomFields: true, withPayment: this.dataset.withPayment === 'true', isPaymentConnected: this.dataset.isPaymentConnected === 'true', withFormula: this.dataset.withFormula === 'true', diff --git a/app/javascript/template_builder/area.vue b/app/javascript/template_builder/area.vue index 664acf96..7f87edbe 100644 --- a/app/javascript/template_builder/area.vue +++ b/app/javascript/template_builder/area.vue @@ -145,7 +145,9 @@ @click-formula="isShowFormulaModal = true" @click-font="isShowFontModal = true" @click-description="isShowDescriptionModal = true" + @add-custom-field="$emit('add-custom-field')" @click-condition="isShowConditionsModal = true" + @save="save" @scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]" /> @@ -471,7 +473,7 @@ export default { default: false } }, - emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to'], + emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field'], data () { return { isShowFormulaModal: false, diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 33bc748b..f029cc57 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -20,8 +20,9 @@ /> @@ -362,7 +363,7 @@ :is-drag="!!dragField" :input-mode="inputMode" :default-fields="[...defaultRequiredFields, ...defaultFields]" - :allow-draw="!onlyDefinedFields || drawField" + :allow-draw="!onlyDefinedFields || drawField || drawCustomField" :with-signature-id="withSignatureId" :with-prefillable="withPrefillable" :data-document-uuid="document.uuid" @@ -371,14 +372,16 @@ :with-field-placeholder="withFieldPlaceholder" :draw-field="drawField" :draw-field-type="drawFieldType" + :draw-custom-field="drawCustomField" :editable="editable" :base-url="baseUrl" :with-fields-detection="withFieldsDetection" - @draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]" + @draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', drawCustomField = null, showDrawField = false]" @drop-field="onDropfield" @remove-area="removeArea" @paste-field="pasteField" @copy-field="copyField" + @add-custom-field="addCustomField" @copy-selected-areas="copySelectedAreas" @delete-selected-areas="deleteSelectedAreas" @align-selected-areas="alignSelectedAreas" @@ -436,15 +439,15 @@ v-if="withFieldsList && !isMobile" id="fields_list_container" class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block fields-list-container" - :class="drawField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'" + :class="drawField || drawCustomField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'" >
-

+

{{ t('draw_strikethrough_the_document') }}

@@ -458,10 +461,10 @@ {{ t('cancel') }} {{ t('or_add_field_without_drawing') }} @@ -477,6 +480,8 @@ :with-help="withHelp" :default-submitters="defaultSubmitters" :draw-field-type="drawFieldType" + :custom-fields="customFields" + :with-custom-fields="withCustomFields" :with-fields-search="withFieldsSearch" :default-fields="[...defaultRequiredFields, ...defaultFields]" :template="template" @@ -493,6 +498,7 @@ @set-draw="[drawField = $event.field, drawOption = $event.option]" @select-submitter="selectedSubmitter = $event" @set-draw-type="[drawFieldType = $event, showDrawField = true]" + @set-draw-custom-field="[drawCustomField = $event, showDrawField = true]" @set-drag="dragField = $event" @set-drag-placeholder="$refs.dragPlaceholder.dragPlaceholder = $event" @change-submitter="selectedSubmitter = $event" @@ -632,10 +638,12 @@ export default { isPaymentConnected: this.isPaymentConnected, withFormula: this.withFormula, withConditions: this.withConditions, + withCustomFields: this.withCustomFields, isInlineSize: this.isInlineSize, defaultDrawFieldType: this.defaultDrawFieldType, selectedAreasRef: computed(() => this.selectedAreasRef), fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef), + customDragFieldRef: computed(() => this.customDragFieldRef), isSelectModeRef: computed(() => this.isSelectModeRef), isCmdKeyRef: computed(() => this.isCmdKeyRef) } @@ -705,6 +713,16 @@ export default { required: false, default: false }, + withCustomFields: { + type: Boolean, + required: false, + default: false + }, + customFields: { + type: Array, + required: false, + default: () => [] + }, withAddPageButton: { type: Boolean, required: false, @@ -902,6 +920,7 @@ export default { pendingFieldAttachmentUuids: [], drawField: null, drawFieldType: null, + drawCustomField: null, drawOption: null, dragField: null, isDragFile: false @@ -912,6 +931,7 @@ export default { isSelectModeRef: () => ref(false), isCmdKeyRef: () => ref(false), fieldsDragFieldRef: () => ref(), + customDragFieldRef: () => ref(), selectedAreasRef: () => ref([]), language () { return this.locale.split('-')[0].toLowerCase() @@ -1062,6 +1082,30 @@ export default { }, methods: { toRaw, + addCustomField (field) { + return this.$refs.fields.addCustomField(field) + }, + addCustomFieldWithoutDraw () { + const customField = this.drawCustomField + + const field = JSON.parse(JSON.stringify(customField)) + + field.uuid = v4() + field.submitter_uuid = this.selectedSubmitter.uuid + field.areas = [] + + if (field.options?.length) { + field.options = field.options.map(opt => ({ ...opt, uuid: v4() })) + } + + delete field.conditions + + this.insertField(field) + this.save() + + this.drawCustomField = null + this.showDrawField = false + }, toggleSelectMode () { this.isSelectModeRef.value = !this.isSelectModeRef.value @@ -1514,6 +1558,7 @@ export default { clearDrawField () { this.drawField = null this.drawOption = null + this.drawCustomField = null this.showDrawField = false if (!this.withSelectedFieldType) { @@ -1951,6 +1996,10 @@ export default { } }, onDraw ({ area, isTooSmall }) { + if (this.drawCustomField) { + return this.onDrawCustomField(area) + } + if (this.drawField) { if (this.drawOption) { const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid) @@ -2061,6 +2110,10 @@ export default { return } + if (this.customDragFieldRef.value) { + return this.dropCustomField(area) + } + const field = this.fieldsDragFieldRef.value || { name: '', uuid: v4(), @@ -2153,6 +2206,122 @@ export default { }) } }, + dropCustomField (area) { + const customField = this.customDragFieldRef.value + const customAreas = customField.areas || [] + + const field = JSON.parse(JSON.stringify(customField)) + + field.uuid = v4() + field.submitter_uuid = this.selectedSubmitter.uuid + field.areas = [] + + if (field.options?.length) { + field.options = field.options.map(opt => ({ ...opt, uuid: v4() })) + } + + delete field.conditions + + const dropX = (area.x - 6) / area.maskW + const dropY = area.y / area.maskH + + if (customAreas.length > 0) { + const refArea = customAreas[0] + + customAreas.forEach((customArea) => { + const fieldArea = { + x: dropX + (customArea.x - refArea.x), + y: dropY + (customArea.y - refArea.y) - (customArea.h / 2), + w: customArea.w, + h: customArea.h, + page: area.page, + attachment_uuid: area.attachment_uuid + } + + if (customArea.cell_w) { + fieldArea.cell_w = customArea.cell_w + } + + if (customArea.option_uuid && field.options?.length) { + const optionIndex = customField.options.findIndex(o => o.uuid === customArea.option_uuid) + if (optionIndex !== -1) { + fieldArea.option_uuid = field.options[optionIndex].uuid + } + } + + field.areas.push(fieldArea) + }) + } else { + const fieldArea = { + x: dropX, + y: dropY, + page: area.page, + attachment_uuid: area.attachment_uuid + } + + this.assignDropAreaSize(fieldArea, field, area) + + field.areas.push(fieldArea) + } + + this.selectedAreasRef.value = [field.areas[0]] + + this.insertField(field) + this.save() + + document.activeElement?.blur() + }, + onDrawCustomField (area) { + const customField = this.drawCustomField + const customAreas = customField.areas || [] + + const field = JSON.parse(JSON.stringify(customField)) + + field.uuid = v4() + field.submitter_uuid = this.selectedSubmitter.uuid + field.areas = [] + + if (field.options?.length) { + field.options = field.options.map(opt => ({ ...opt, uuid: v4() })) + } + + delete field.conditions + + const firstArea = { + x: area.x, + y: area.y, + w: area.w || customAreas[0]?.w, + h: area.h || customAreas[0]?.h, + page: area.page, + attachment_uuid: area.attachment_uuid + } + + if (!firstArea.w || !firstArea.h) { + if (customAreas[0]) { + firstArea.w = customAreas[0].w + firstArea.h = customAreas[0].h + } else { + this.setDefaultAreaSize(firstArea, field.type) + } + + firstArea.x -= firstArea.w / 2 + firstArea.y -= firstArea.h / 2 + } + + if (field.options?.length) { + firstArea.option_uuid = field.options[0].uuid + } + + field.areas.push(firstArea) + + this.selectedAreasRef.value = [field.areas[0]] + + this.insertField(field) + this.save() + + this.drawCustomField = null + this.showDrawField = false + }, assignDropAreaSize (fieldArea, field, area) { const fieldType = field.type || 'text' diff --git a/app/javascript/template_builder/contenteditable.vue b/app/javascript/template_builder/contenteditable.vue index b6dcd909..1ee20ddb 100644 --- a/app/javascript/template_builder/contenteditable.vue +++ b/app/javascript/template_builder/contenteditable.vue @@ -6,18 +6,24 @@ {{ value }} @@ -28,7 +34,7 @@ :class="{ invisible: !editable, 'absolute top-1/2 -translate-y-1/2': !iconInline || floatIcon, 'inline align-bottom': iconInline, 'left-0': floatIcon }" :width="iconWidth + 4" :stroke-width="iconStrokeWidth" - @click="[focusContenteditable(), selectOnEditClick && selectContent()]" + @click="clickEdit" />

@@ -49,6 +55,16 @@ export default { required: false, default: '' }, + placeholder: { + type: String, + required: false, + default: '' + }, + withButton: { + type: Boolean, + required: false, + default: true + }, iconInline: { type: Boolean, required: false, @@ -74,6 +90,16 @@ export default { required: false, default: false }, + editableOnButton: { + type: Boolean, + required: false, + default: false + }, + minWidth: { + type: String, + required: false, + default: '2px' + }, editable: { type: Boolean, required: false, @@ -85,21 +111,34 @@ export default { default: 2 } }, - emits: ['update:model-value', 'focus', 'blur'], + emits: ['update:model-value', 'focus', 'blur', 'click-contenteditable'], data () { return { + isEditable: false, + inputValue: '', value: '' } }, + computed: { + isEmpty () { + return !this.inputValue.replace(/\u200B/g, '').replace(/\u00A0/g, ' ').trim() + } + }, watch: { modelValue: { handler (value) { - this.value = value + this.value = value || '' }, immediate: true } }, + mounted () { + this.updateInputValue() + }, methods: { + updateInputValue () { + this.inputValue = this.$refs.contenteditable?.textContent || '' + }, onPaste (e) { const text = (e.clipboardData || window.clipboardData).getData('text/plain') @@ -110,6 +149,20 @@ export default { selection.getRangeAt(0).insertNode(document.createTextNode(text)) selection.collapseToEnd() } + + this.updateInputValue() + }, + clickEdit (e) { + this.focusContenteditable() + + if (this.selectOnEditClick) { + this.selectContent() + } + }, + setText (text) { + this.$refs.contenteditable.innerText = text + + this.updateInputValue() }, selectContent () { const el = this.$refs.contenteditable @@ -129,10 +182,18 @@ export default { this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue this.$emit('update:model-value', this.value) this.$emit('blur', e) + + this.isEditable = false }, 1) }, focusContenteditable () { - this.$refs.contenteditable.focus() + this.isEditable = true + + this.$nextTick(() => { + this.$refs.contenteditable.focus() + + this.updateInputValue() + }) }, blurContenteditable () { this.$refs.contenteditable.blur() diff --git a/app/javascript/template_builder/custom_field.vue b/app/javascript/template_builder/custom_field.vue new file mode 100644 index 00000000..db4b9135 --- /dev/null +++ b/app/javascript/template_builder/custom_field.vue @@ -0,0 +1,267 @@ + + + diff --git a/app/javascript/template_builder/document.vue b/app/javascript/template_builder/document.vue index 5d5135c5..040f6edb 100644 --- a/app/javascript/template_builder/document.vue +++ b/app/javascript/template_builder/document.vue @@ -19,6 +19,7 @@ :default-submitters="defaultSubmitters" :draw-field="drawField" :draw-field-type="drawFieldType" + :draw-custom-field="drawCustomField" :selected-submitter="selectedSubmitter" :total-pages="sortedPreviewImages.length" :image="image" @@ -28,6 +29,7 @@ @remove-area="$emit('remove-area', $event)" @copy-field="$emit('copy-field', $event)" @paste-field="$emit('paste-field', { ...$event, attachment_uuid: document.uuid })" + @add-custom-field="$emit('add-custom-field', $event)" @copy-selected-areas="$emit('copy-selected-areas')" @delete-selected-areas="$emit('delete-selected-areas')" @align-selected-areas="$emit('align-selected-areas', $event)" @@ -115,6 +117,11 @@ export default { required: false, default: null }, + drawCustomField: { + type: Object, + required: false, + default: null + }, baseUrl: { type: String, required: false, @@ -131,7 +138,7 @@ export default { default: false } }, - emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas', 'autodetect-fields'], + emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas', 'autodetect-fields', 'add-custom-field'], data () { return { pageRefs: [] diff --git a/app/javascript/template_builder/drag_placeholder.vue b/app/javascript/template_builder/drag_placeholder.vue index 71a1236c..177372fa 100644 --- a/app/javascript/template_builder/drag_placeholder.vue +++ b/app/javascript/template_builder/drag_placeholder.vue @@ -8,6 +8,13 @@ class="fixed z-20 pointer-events-none" :editable="false" /> +
import Field from './field' +import CustomField from './custom_field' import IconDrag from './icon_drag' import FieldType from './field_type' @@ -64,6 +72,7 @@ export default { name: 'DragPlaceholder', components: { Field, + CustomField, IconDrag }, inject: ['t', 'backgroundColor'], @@ -87,6 +96,11 @@ export default { type: Boolean, required: false, default: false + }, + isCustom: { + type: Boolean, + required: false, + default: false } }, data () { diff --git a/app/javascript/template_builder/field.vue b/app/javascript/template_builder/field.vue index eed4fb82..a6804499 100644 --- a/app/javascript/template_builder/field.vue +++ b/app/javascript/template_builder/field.vue @@ -1,6 +1,6 @@