diff --git a/Gemfile.lock b/Gemfile.lock index de5ce993..3e602949 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -179,7 +179,7 @@ GEM dotenv (3.2.0) drb (2.2.3) email_typo (0.2.3) - erb (6.0.2) + erb (6.0.4) erb_lint (0.9.0) activesupport better_html (>= 2.0.1) diff --git a/app/controllers/api/active_storage_blobs_proxy_controller.rb b/app/controllers/api/active_storage_blobs_proxy_controller.rb index a542c637..8ade86c6 100644 --- a/app/controllers/api/active_storage_blobs_proxy_controller.rb +++ b/app/controllers/api/active_storage_blobs_proxy_controller.rb @@ -33,9 +33,15 @@ module Api else http_cache_forever public: true do response.headers['Accept-Ranges'] = 'bytes' - response.headers['Content-Length'] = blob.byte_size.to_s - send_blob_stream blob, disposition: params[:disposition] + if request.head? + response.headers['Content-Type'] = blob.content_type_for_serving + head :ok + else + send_blob_stream blob, disposition: params[:disposition] + end + + response.headers['Content-Length'] = blob.byte_size.to_s end end end @@ -57,8 +63,6 @@ module Api return if !require_ttl && !require_auth end - Rollbar.error('Blob unauthorized') if defined?(Rollbar) - raise CanCan::AccessDenied end end diff --git a/app/controllers/api/submitters_controller.rb b/app/controllers/api/submitters_controller.rb index 71dad23a..fbaf3477 100644 --- a/app/controllers/api/submitters_controller.rb +++ b/app/controllers/api/submitters_controller.rb @@ -161,9 +161,7 @@ module Api submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter) end - submitter.values = submitter.values.transform_values do |v| - v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v - end + submitter.values = Submitters::SubmitValues.replace_current_date_placeholders(submitter) end submitter @@ -205,10 +203,15 @@ module Api submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms') submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to') + if submitter_preferences.key?('require_phone_2fa') submitter.preferences['require_phone_2fa'] = submitter_preferences['require_phone_2fa'] end + if submitter_preferences.key?('require_email_2fa') + submitter.preferences['require_email_2fa'] = submitter_preferences['require_email_2fa'] + end + if submitter_preferences.key?('go_to_last') submitter.preferences['go_to_last'] = submitter_preferences['go_to_last'] end diff --git a/app/controllers/preview_document_page_controller.rb b/app/controllers/preview_document_page_controller.rb index d0b69f8d..2befe3bf 100644 --- a/app/controllers/preview_document_page_controller.rb +++ b/app/controllers/preview_document_page_controller.rb @@ -41,7 +41,7 @@ class PreviewDocumentPageController < ActionController::API end def find_or_create_document_tempfile_path(attachment) - file_path = "#{Dir.tmpdir}/#{attachment.uuid}" + file_path = "#{Dir.tmpdir}/attachment-#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}" File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f| f.flock(File::LOCK_EX) diff --git a/app/controllers/reveal_access_token_controller.rb b/app/controllers/reveal_access_token_controller.rb index c8959afd..eef24b50 100644 --- a/app/controllers/reveal_access_token_controller.rb +++ b/app/controllers/reveal_access_token_controller.rb @@ -1,6 +1,14 @@ # frozen_string_literal: true class RevealAccessTokenController < ApplicationController + rate_limit to: 4, within: 1.minute, only: %i[create], by: -> { current_user.id }, with: lambda { + Rollbar.error('Rate limit api key') if defined?(Rollbar) + + render turbo_stream: turbo_stream.replace(:modal, template: 'reveal_access_token/show', + locals: { error_message: I18n.t(:too_many_attempts) }), + status: :unprocessable_content + } + def show authorize!(:manage, current_user.access_token) end diff --git a/app/controllers/send_submission_email_controller.rb b/app/controllers/send_submission_email_controller.rb index 3c4fd9c9..3ef58ee9 100644 --- a/app/controllers/send_submission_email_controller.rb +++ b/app/controllers/send_submission_email_controller.rb @@ -15,13 +15,20 @@ class SendSubmissionEmailController < ApplicationController @submitter = find_completed_submitter return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter + + @submitter = + Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase) + elsif params[:submission_slug] + submission = Submission.find_by(slug: params[:submission_slug]) @embed_cors_account = @submitter.account set_embed_cors_headers - RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes) + if @submitter + RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes) - SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter) + SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter) + end respond_to do |f| f.html { render :success } diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index 2adb18b3..f32e1e1e 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class TemplatesController < ApplicationController + TEMPLATE_FIELDS = %i[id author_id folder_id external_id name slug + schema fields submitters variables_schema preferences + shared_link source archived_at created_at updated_at].freeze + load_and_authorize_resource :template def show @@ -33,10 +37,11 @@ class TemplatesController < ApplicationController ).call @template_data = - @template.as_json.merge( + @template.as_json(only: TEMPLATE_FIELDS).merge( documents: @template.schema_documents.as_json( + only: %i[id uuid], methods: %i[metadata signed_key], - include: { preview_images: { methods: %i[url metadata filename] } } + include: { preview_images: { only: %i[id], methods: %i[url metadata filename] } } ) ).to_json diff --git a/app/controllers/testing_accounts_controller.rb b/app/controllers/testing_accounts_controller.rb index 6c6cf3d1..44274eef 100644 --- a/app/controllers/testing_accounts_controller.rb +++ b/app/controllers/testing_accounts_controller.rb @@ -3,7 +3,7 @@ class TestingAccountsController < ApplicationController skip_authorization_check only: :destroy - def show + def create authorize!(:manage, current_account) authorize!(:manage, current_user) diff --git a/app/javascript/application.js b/app/javascript/application.js index 920cba82..ff617caa 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, @@ -180,6 +181,7 @@ safeRegisterElement('template-builder', class extends HTMLElement { withConditions: this.dataset.withConditions === 'true', withDynamicDocuments: this.dataset.withDynamicDocuments === 'true', withGoogleDrive: this.dataset.withGoogleDrive === 'true', + pagePreviewFormat: this.dataset.pagePreviewFormat || '.jpg', withReplaceAndCloneUpload: true, withDownload: true, currencies: (this.dataset.currencies || '').split(',').filter(Boolean), diff --git a/app/javascript/draw.js b/app/javascript/draw.js index 3b95b5ea..30d493a3 100644 --- a/app/javascript/draw.js +++ b/app/javascript/draw.js @@ -11,6 +11,7 @@ window.customElements.define('draw-signature', class extends HTMLElement { this.resizeObserver = new ResizeObserver(() => { requestAnimationFrame(() => { if (!this.canvas) return + if (!this.canvas.parentNode?.clientWidth) return const { width, height } = this.canvas @@ -89,7 +90,7 @@ window.customElements.define('draw-signature', class extends HTMLElement { } redrawCanvas (oldWidth, oldHeight) { - if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) { + if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 0) { const sx = this.canvas.width / oldWidth const sy = this.canvas.height / oldHeight diff --git a/app/javascript/elements/signature_form.js b/app/javascript/elements/signature_form.js index 5fc9af48..aa60b6fb 100644 --- a/app/javascript/elements/signature_form.js +++ b/app/javascript/elements/signature_form.js @@ -14,6 +14,7 @@ export default targetable(class extends HTMLElement { this.resizeObserver = new ResizeObserver(() => { requestAnimationFrame(() => { if (!this.canvas) return + if (!this.canvas.parentNode?.clientWidth) return const { width, height } = this.canvas @@ -80,7 +81,7 @@ export default targetable(class extends HTMLElement { } redrawCanvas (oldWidth, oldHeight) { - if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) { + if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 0) { const sx = this.canvas.width / oldWidth const sy = this.canvas.height / oldHeight diff --git a/app/javascript/submission_form/appears_on.vue b/app/javascript/submission_form/appears_on.vue index 48bdb028..219ef193 100644 --- a/app/javascript/submission_form/appears_on.vue +++ b/app/javascript/submission_form/appears_on.vue @@ -42,10 +42,25 @@ export default { const areas = {} this.field.areas?.forEach((area) => { - areas[area.attachment_uuid + area.page] ||= area + areas[area.attachment_uuid] ||= [] + areas[area.attachment_uuid].push(area) }) - return Object.values(areas).slice(0, 6) + const sortedAreas = Object.values(areas).reduce((acc, group) => { + const seen = {} + const sortedGroup = [...group].sort((a, b) => a.page - b.page) + + sortedGroup.forEach((area) => { + if (!seen[area.page]) { + seen[area.page] = true + acc.push(area) + } + }) + + return acc + }, []) + + return sortedAreas.slice(0, 6) } } } diff --git a/app/javascript/submission_form/area.vue b/app/javascript/submission_form/area.vue index e3138254..69a453ac 100644 --- a/app/javascript/submission_form/area.vue +++ b/app/javascript/submission_form/area.vue @@ -527,7 +527,8 @@ export default { try { return this.formatDate( this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue), - this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY') + this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'), + { withTimePlaceholders: this.modelValue === '{{date}}' } ) } catch { return this.modelValue @@ -646,36 +647,55 @@ export default { return number } }, - formatDate (date, format) { - const monthFormats = { - M: 'numeric', - MM: '2-digit', - MMM: 'short', - MMMM: 'long' - } + formatDate (date, format, { withTimePlaceholders = false } = {}) { + const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' } + const dayFormats = { D: 'numeric', DD: '2-digit' } + const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' } + const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' } + const minuteFormats = { m: 'numeric', mm: '2-digit' } + const secondFormats = { s: 'numeric', ss: '2-digit' } + + const hasTime = /[HhAasz]/.test(format) - const dayFormats = { - D: 'numeric', - DD: '2-digit' + const opts = { + day: dayFormats[format.match(/D+/)], + month: monthFormats[format.match(/M+/)], + year: yearFormats[format.match(/Y+/)] } - const yearFormats = { - YYYY: 'numeric', - YYY: 'numeric', - YY: '2-digit' + if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false } + if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true } + if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true + if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]] + if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]] + if (/z/.test(format)) opts.timeZoneName = 'short' + if (!hasTime) opts.timeZone = 'UTC' + + const partTypes = { + M: 'month', + D: 'day', + Y: 'year', + H: 'hour', + h: 'hour', + m: 'minute', + s: 'second', + z: 'timeZoneName', + A: 'dayPeriod', + a: 'dayPeriod' } - const parts = new Intl.DateTimeFormat([], { - day: dayFormats[format.match(/D+/)], - month: monthFormats[format.match(/M+/)], - year: yearFormats[format.match(/Y+/)], - timeZone: 'UTC' - }).formatToParts(date) + const parts = new Intl.DateTimeFormat([], opts).formatToParts(date) + + return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => { + if (withTimePlaceholders && /^(HH|hh|H|h|mm|m|ss|s|A|a)$/.test(token)) return '--' + + const value = parts.find((p) => p.type === partTypes[token[0]])?.value - return format - .replace(/D+/, parts.find((p) => p.type === 'day').value) - .replace(/M+/, parts.find((p) => p.type === 'month').value) - .replace(/Y+/, parts.find((p) => p.type === 'year').value) + if (token === 'A') return (value || '').toUpperCase() + if (token === 'a') return (value || '').toLowerCase() + + return value + }) }, updateMultipleSelectValue (value) { if (this.modelValue?.includes(value)) { diff --git a/app/javascript/submission_form/date_step.vue b/app/javascript/submission_form/date_step.vue index ba4a8dce..245573da 100644 --- a/app/javascript/submission_form/date_step.vue +++ b/app/javascript/submission_form/date_step.vue @@ -56,12 +56,18 @@ class="base-input !text-2xl text-center w-full" :required="field.required" :aria-describedby="field.description ? field.uuid + '-desc' : undefined" - type="date" - :name="`values[${field.uuid}]`" + :type="inputType" + :name="formatType === 'datetime' ? undefined : `values[${field.uuid}]`" @keydown.enter="onEnter" @focus="$emit('focus')" @paste="onPaste" > + @@ -97,14 +103,19 @@ export default { }, emits: ['update:model-value', 'focus', 'submit'], computed: { - dateNowString () { - const today = new Date() + formatType () { + const format = this.field.preferences?.format || '' - const yyyy = today.getFullYear() - const mm = String(today.getMonth() + 1).padStart(2, '0') - const dd = String(today.getDate()).padStart(2, '0') + if (/[HhAasz]/.test(format)) return 'datetime' + if (format && !/[Dd]/.test(format)) return 'month' - return `${yyyy}-${mm}-${dd}` + return 'date' + }, + inputType () { + return { datetime: 'datetime-local', month: 'month', date: 'date' }[this.formatType] + }, + dateNowString () { + return this.formatDateValue(new Date()) }, validationMin () { if (this.field.validation?.min) { @@ -121,6 +132,8 @@ export default { } }, withToday () { + if (this.formatType === 'datetime') return false + const todayDate = new Date().setHours(0, 0, 0, 0) if (this.validationMin) { @@ -137,9 +150,25 @@ export default { }, value: { set (value) { + if (this.formatType === 'datetime' && value) { + const d = new Date(value) + + if (!isNaN(d)) { + this.$emit('update:model-value', d.toISOString()) + + return + } + } + this.$emit('update:model-value', value) }, get () { + if (this.formatType === 'datetime') { + const d = new Date(this.modelValue) + + return isNaN(d) ? '' : this.formatDateValue(d) + } + return this.modelValue } } @@ -163,20 +192,32 @@ export default { const parsedDate = new Date(pasteData) - if (!isNaN(parsedDate)) { - const inputEl = this.$refs.input - - inputEl.valueAsDate = new Date(parsedDate.getTime() - parsedDate.getTimezoneOffset() * 60000) + if (isNaN(parsedDate)) return - inputEl.dispatchEvent(new Event('input', { bubbles: true })) - } + this.setInputValue(parsedDate) }, setCurrentDate () { + this.setInputValue(new Date()) + }, + setInputValue (date) { const inputEl = this.$refs.input - inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000) + if (this.formatType === 'date') { + inputEl.valueAsDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000) + } else { + inputEl.value = this.formatDateValue(date) + } inputEl.dispatchEvent(new Event('input', { bubbles: true })) + }, + formatDateValue (date) { + const pad = (n) => String(n).padStart(2, '0') + const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + + if (this.formatType === 'month') return ymd.slice(0, 7) + if (this.formatType === 'datetime') return `${ymd}T${pad(date.getHours())}:${pad(date.getMinutes())}` + + return ymd } } } diff --git a/app/javascript/submission_form/signature_step.vue b/app/javascript/submission_form/signature_step.vue index c1f19be9..e568f59c 100644 --- a/app/javascript/submission_form/signature_step.vue +++ b/app/javascript/submission_form/signature_step.vue @@ -544,6 +544,7 @@ export default { this.resizeObserver = new ResizeObserver(() => { requestAnimationFrame(() => { if (!this.$refs.canvas) return + if (!this.$refs.canvas.parentNode?.clientWidth) return const { width, height } = this.$refs.canvas @@ -586,7 +587,7 @@ export default { redrawCanvas (oldWidth, oldHeight) { const canvas = this.$refs.canvas - if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) { + if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && canvas.width > 0 && canvas.height > 0) { const sx = canvas.width / oldWidth const sy = canvas.height / oldHeight diff --git a/app/javascript/template_builder/area.vue b/app/javascript/template_builder/area.vue index bbf1a357..021f7dd7 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) }} - {{ t('signing_date') }} + {{ /[HhAasz]/.test(field.preferences?.format || '') ? t('signing_date_and_time') : t('signing_date') }}
{{ 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 4830f601..b805af92 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -381,6 +381,9 @@ :document="document" :is-drag="!!dragField" :input-mode="inputMode" + :conditional-field-index="conditionalFieldIndex" + :formula-values-index="formulaValuesIndex" + :page-preview-format="pagePreviewFormat" :default-fields="[...defaultRequiredFields, ...defaultFields]" :allow-draw="!onlyDefinedFields || drawField || drawCustomField" :with-signature-id="withSignatureId" @@ -504,11 +507,14 @@ :with-custom-fields="withCustomFields" :with-fields-search="withFieldsSearch" :default-fields="[...defaultRequiredFields, ...defaultFields]" + :with-custom-fields-tab="withCustomFieldsTab" :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" @@ -617,6 +623,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: { @@ -654,6 +670,7 @@ export default { locale: this.locale, baseFetch: this.baseFetch, fieldTypes: this.fieldTypes, + dateFormats: this.dateFormats, backgroundColor: this.backgroundColor, withPhone: this.withPhone, withVerification: this.withVerification, @@ -738,6 +755,11 @@ export default { required: false, default: false }, + withDetectExistingFields: { + type: Boolean, + required: false, + default: false + }, withCustomFields: { type: Boolean, required: false, @@ -778,6 +800,11 @@ export default { required: false, default: () => [] }, + withCustomFieldsTab: { + type: Boolean, + required: false, + default: false + }, withSelectedFieldType: { type: Boolean, required: false, @@ -798,6 +825,11 @@ export default { required: false, default: () => [] }, + dateFormats: { + type: Array, + required: false, + default: () => [] + }, defaultSubmitters: { type: Array, required: false, @@ -808,6 +840,11 @@ export default { required: false, default: () => [] }, + pagePreviewFormat: { + type: String, + required: false, + default: '.jpg' + }, acceptFileTypes: { type: String, required: false, @@ -953,6 +990,7 @@ export default { isLoadingBlankPage: false, isSaving: false, isDetectingPageFields: false, + detectFieldsQueue: [], detectingAnalyzingProgress: null, detectingFieldsAddedCount: null, selectedSubmitter: null, @@ -963,7 +1001,8 @@ export default { drawCustomField: null, drawOption: null, dragField: null, - isDragFile: false + isDragFile: false, + isMathLoaded: false } }, computed: { @@ -973,6 +1012,13 @@ export default { fieldsDragFieldRef: () => ref(), customDragFieldRef: () => ref(), selectedAreasRef: () => ref([]), + attachmentUuidsIndex () { + return this.template.schema.reduce((acc, e, index) => { + acc[e.attachment_uuid] = index + + return acc + }, {}) + }, language () { return this.locale.split('-')[0].toLowerCase() }, @@ -1003,6 +1049,8 @@ export default { return isMobileSafariIos || /android|iphone|ipad/i.test(navigator.userAgent) }, defaultDateFormat () { + if (this.dateFormats.length) return this.dateFormats[0] + const isUsBrowser = Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') const isUsTimezone = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/) @@ -1044,6 +1092,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) @@ -1052,6 +1137,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) @@ -1147,6 +1265,132 @@ export default { }, methods: { 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) }, @@ -1426,32 +1670,25 @@ export default { this.save() } }, - findFieldInsertIndex (field) { - if (!field.areas?.length) return -1 - - const area = field.areas[0] - - const attachmentUuidsIndex = this.template.schema.reduce((acc, e, index) => { - acc[e.attachment_uuid] = index + compareAreas (a, b) { + const aAttIdx = this.attachmentUuidsIndex[a.attachment_uuid] + const bAttIdx = this.attachmentUuidsIndex[b.attachment_uuid] - return acc - }, {}) + if (aAttIdx !== bAttIdx) return aAttIdx - bAttIdx + if (a.page !== b.page) return a.page - b.page - const compareAreas = (a, b) => { - const aAttIdx = attachmentUuidsIndex[a.attachment_uuid] - const bAttIdx = attachmentUuidsIndex[b.attachment_uuid] + const aY = a.y + a.h + const bY = b.y + b.h - if (aAttIdx !== bAttIdx) return aAttIdx - bAttIdx - if (a.page !== b.page) return a.page - b.page + if (Math.abs(aY - bY) < 0.01) return a.x - b.x + if (a.h < b.h ? a.y >= b.y && aY <= bY : b.y >= a.y && bY <= aY) return a.x - b.x - const aY = a.y + a.h - const bY = b.y + b.h - - if (Math.abs(aY - bY) < 0.01) return a.x - b.x - if (a.h < b.h ? a.y >= b.y && aY <= bY : b.y >= a.y && bY <= aY) return a.x - b.x + return aY - bY + }, + findFieldInsertIndex (field) { + if (!field.areas?.length) return -1 - return aY - bY - } + const area = field.areas[0] let closestBeforeIndex = -1 let closestBeforeArea = null @@ -1461,15 +1698,15 @@ export default { this.template.fields.forEach((f, index) => { if (f.submitter_uuid === field.submitter_uuid) { (f.areas || []).forEach((a) => { - const cmp = compareAreas(a, area) + const cmp = this.compareAreas(a, area) if (cmp < 0) { - if (!closestBeforeArea || (compareAreas(a, closestBeforeArea) > 0 && closestBeforeIndex < index)) { + if (!closestBeforeArea || (this.compareAreas(a, closestBeforeArea) > 0 && closestBeforeIndex < index)) { closestBeforeIndex = index closestBeforeArea = a } } else { - if (!closestAfterArea || (compareAreas(a, closestAfterArea) < 0 && closestAfterIndex > index)) { + if (!closestAfterArea || (this.compareAreas(a, closestAfterArea) < 0 && closestAfterIndex > index)) { closestAfterIndex = index closestAfterArea = a } @@ -1492,6 +1729,41 @@ export default { this.template.fields.push(field) } }, + insertArea (field, area) { + field.areas ||= [] + + const insertIndex = field.areas.findIndex((a) => this.compareAreas(a, area) > 0) + + if (insertIndex === -1) { + field.areas.push(area) + } else { + field.areas.splice(insertIndex, 0, area) + } + }, + 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 || [] + field.areas.forEach((area) => this.insertArea(existingField, area)) + } else { + const customField = this.detectCustomFieldsIndex[indexKey] || this.detectCustomFieldsIndex[nameKey] + + if (customField) this.applyCustomFieldAttributes(field, customField) + + this.insertField(field) + } + }, closeDropdown () { document.activeElement.blur() }, @@ -1988,7 +2260,7 @@ export default { fieldUuidIndex[field.uuid] = newField - newField.areas.push(newArea) + this.insertArea(newField, newArea) newAreas.push(newArea) if (['radio', 'multiple'].includes(field.type) && field.options?.length) { @@ -2101,17 +2373,7 @@ export default { area.y -= area.h / 2 } - this.drawField.areas ||= [] - - const insertBeforeAreaIndex = this.drawField.areas.findIndex((a) => { - return a.attachment_uuid === area.attachment_uuid && a.page > area.page - }) - - if (insertBeforeAreaIndex !== -1) { - this.drawField.areas.splice(insertBeforeAreaIndex, 0, area) - } else { - this.drawField.areas.push(area) - } + this.insertArea(this.drawField, area) if (this.template.fields.indexOf(this.drawField) === -1) { this.insertField(this.drawField) @@ -2252,9 +2514,7 @@ export default { delete field.height } - field.areas ||= [] - - field.areas.push(fieldArea) + this.insertArea(field, fieldArea) if (this.selectedAreasRef.value.length < 2) { this.selectedAreasRef.value = [fieldArea] @@ -2324,7 +2584,7 @@ export default { } } - field.areas.push(fieldArea) + this.insertArea(field, fieldArea) }) } else { const fieldArea = { @@ -2783,6 +3043,12 @@ export default { }) }, detectFieldsForPage ({ page, attachmentUuid }) { + if (this.isDetectingPageFields) { + this.detectFieldsQueue.push({ page, attachmentUuid }) + + return + } + this.isDetectingPageFields = true this.detectingAnalyzingProgress = null this.detectingFieldsAddedCount = null @@ -2821,7 +3087,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') @@ -2850,7 +3120,7 @@ export default { if (!f.submitter_uuid) { f.submitter_uuid = this.template.submitters[0].uuid } - this.insertField(f) + this.insertDetectedField(f) }) totalFieldsAdded += errorFields.length @@ -2879,7 +3149,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) { @@ -2917,7 +3187,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) { @@ -2935,7 +3205,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) { @@ -2968,6 +3238,10 @@ export default { setTimeout(() => { this.detectingFieldsAddedCount = null }, 1000) + + if (this.detectFieldsQueue.length) { + this.detectFieldsForPage(this.detectFieldsQueue.shift()) + } }) }, save ({ force } = { force: false }) { diff --git a/app/javascript/template_builder/document.vue b/app/javascript/template_builder/document.vue index 19a524c9..8a96825c 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, @@ -138,6 +150,11 @@ export default { required: false, default: false }, + pagePreviewFormat: { + type: String, + required: false, + default: '.jpg' + }, withFieldsDetection: { type: Boolean, required: false, @@ -168,7 +185,7 @@ export default { return this.previewImagesIndex[i] || reactive({ metadata: { ...lazyloadMetadata }, id: Math.random().toString(), - url: this.basePreviewUrl + `/preview/${this.document.signed_key || this.document.signed_uuid || this.document.uuid}/${i}.jpg` + url: this.basePreviewUrl + `/preview/${this.document.signed_key || this.document.signed_uuid || this.document.uuid}/${i}${this.pagePreviewFormat}` }) }) }, diff --git a/app/javascript/template_builder/dynamic_variable.vue b/app/javascript/template_builder/dynamic_variable.vue index aaac22db..d0ad3ac2 100644 --- a/app/javascript/template_builder/dynamic_variable.vue +++ b/app/javascript/template_builder/dynamic_variable.vue @@ -119,7 +119,7 @@ @change="[schema.format = $event.target.value, save()]" > @@ -248,7 +248,7 @@ export default { FieldType, IconSettings }, - inject: ['t', 'save', 'backgroundColor'], + inject: ['t', 'save', 'backgroundColor', 'dateFormats'], provide () { return { fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select'] @@ -318,20 +318,23 @@ export default { 'space' ] }, - dateFormats () { - const formats = [ - 'MM/DD/YYYY', - 'DD/MM/YYYY', - 'YYYY-MM-DD', - 'DD-MM-YYYY', - 'DD.MM.YYYY', - 'MMM D, YYYY', - 'MMMM D, YYYY', - 'D MMM YYYY', - 'D MMMM YYYY' - ] + availableDateFormats () { + const formats = this.dateFormats.length + ? [...this.dateFormats] + : [ + 'MM/DD/YYYY', + 'DD/MM/YYYY', + 'YYYY-MM-DD', + 'DD-MM-YYYY', + 'DD.MM.YYYY', + 'MMM D, YYYY', + 'MMMM D, YYYY', + 'MMMM YYYY', + 'D MMM YYYY', + 'D MMMM YYYY' + ] - if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) { + if (!this.dateFormats.length && (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko'))) { formats.push('YYYY년 MM월 DD일') } @@ -401,18 +404,47 @@ export default { formatDate (date, format) { const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' } const dayFormats = { D: 'numeric', DD: '2-digit' } - const yearFormats = { YYYY: 'numeric', YY: '2-digit' } + const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' } + const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' } + const minuteFormats = { m: 'numeric', mm: '2-digit' } + const secondFormats = { s: 'numeric', ss: '2-digit' } - const parts = new Intl.DateTimeFormat([], { + const opts = { day: dayFormats[format.match(/D+/)], month: monthFormats[format.match(/M+/)], year: yearFormats[format.match(/Y+/)] - }).formatToParts(date) + } + + if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false } + if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true } + if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true + if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]] + if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]] + if (/z/.test(format)) opts.timeZoneName = 'short' + + const partTypes = { + M: 'month', + D: 'day', + Y: 'year', + H: 'hour', + h: 'hour', + m: 'minute', + s: 'second', + z: 'timeZoneName', + A: 'dayPeriod', + a: 'dayPeriod' + } - return format - .replace(/D+/, parts.find((p) => p.type === 'day').value) - .replace(/M+/, parts.find((p) => p.type === 'month').value) - .replace(/Y+/, parts.find((p) => p.type === 'year').value) + const parts = new Intl.DateTimeFormat([], opts).formatToParts(date) + + return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => { + const value = parts.find((p) => p.type === partTypes[token[0]])?.value + + if (token === 'A') return (value || '').toUpperCase() + if (token === 'a') return (value || '').toLowerCase() + + return value + }) }, closeDropdown () { this.$el.getRootNode().activeElement.blur() diff --git a/app/javascript/template_builder/field.vue b/app/javascript/template_builder/field.vue index 00f6d5d7..8ffacee8 100644 --- a/app/javascript/template_builder/field.vue +++ b/app/javascript/template_builder/field.vue @@ -345,7 +345,7 @@ export default { IconMathFunction, FieldType }, - inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex'], + inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex', 'dateFormats'], props: { field: { type: Object, @@ -428,7 +428,8 @@ export default { if (this.field.type === 'date') { this.field.preferences.format ||= - ({ 'de-DE': 'DD.MM.YYYY' }[this.locale] || ((Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') || new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/)) ? 'MM/DD/YYYY' : 'DD/MM/YYYY')) + this.dateFormats[0] || + ({ 'de-DE': 'DD.MM.YYYY' }[this.locale] || ((Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') || new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/)) ? 'MM/DD/YYYY' : 'DD/MM/YYYY')) } }, methods: { diff --git a/app/javascript/template_builder/field_context_menu.vue b/app/javascript/template_builder/field_context_menu.vue index 9635f189..388ee80e 100644 --- a/app/javascript/template_builder/field_context_menu.vue +++ b/app/javascript/template_builder/field_context_menu.vue @@ -514,7 +514,7 @@ export default { ContextSubmenu, ContextModal }, - inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies'], + inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats'], props: { contextMenu: { type: Object, @@ -580,7 +580,7 @@ export default { fieldNames: FieldType.computed.fieldNames, fieldLabels: FieldType.computed.fieldLabels, validationOptions: FieldSettings.computed.validations, - dateFormats: FieldSettings.computed.dateFormats, + availableDateFormats: FieldSettings.computed.availableDateFormats, numberFormats: FieldSettings.computed.numberFormats, prefillableFieldTypes: FieldSettings.computed.prefillableFieldTypes, verificationMethods: FieldSettings.computed.verificationMethods, @@ -686,7 +686,7 @@ export default { }, formatOptions () { switch (this.field.type) { - case 'date': return this.dateFormats.map(f => ({ value: f, label: this.formatDate(new Date(), f) })) + case 'date': return this.availableDateFormats.map(f => ({ value: f, label: this.formatDate(new Date(), f) })) case 'number': return this.numberFormats.map(f => ({ value: f, label: this.formatNumber(123456789.567, f) })) case 'signature': return this.signatureFormats.map(f => ({ value: f, label: this.t(f) })) default: return [] diff --git a/app/javascript/template_builder/field_settings.vue b/app/javascript/template_builder/field_settings.vue index 378ab048..4b5a71f0 100644 --- a/app/javascript/template_builder/field_settings.vue +++ b/app/javascript/template_builder/field_settings.vue @@ -290,7 +290,7 @@ @change="$emit('save')" >