diff --git a/app/javascript/application.js b/app/javascript/application.js index dc2df889..a23ce174 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -90,6 +90,7 @@ window.customElements.define('template-builder', class extends HTMLElement { withLogo: this.dataset.withLogo !== 'false', editable: this.dataset.editable !== 'false', withPayment: this.dataset.withPayment === 'true', + withFormula: this.dataset.withFormula === 'true', currencies: (this.dataset.currencies || '').split(',').filter(Boolean), acceptFileTypes: this.dataset.acceptFileTypes, isDirectUpload: this.dataset.isDirectUpload === 'true' diff --git a/app/javascript/submission_form/form.vue b/app/javascript/submission_form/form.vue index 3a5d6c84..89324a80 100644 --- a/app/javascript/submission_form/form.vue +++ b/app/javascript/submission_form/form.vue @@ -8,6 +8,11 @@ :current-step="currentStepFields" @focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']" /> + import FieldAreas from './areas' +import FormulaFieldAreas from './formula_areas' import ImageStep from './image_step' import SignatureStep from './signature_step' import InitialsStep from './initials_step' @@ -444,6 +450,7 @@ export default { IconArrowsDiagonal, TextStep, NumberStep, + FormulaFieldAreas, PhoneStep, PaymentStep, IconArrowsDiagonalMinimize2, @@ -636,6 +643,9 @@ export default { return acc }, []) }, + formulaFields () { + return this.fields.filter((f) => f.preferences?.formula) + }, attachmentsIndex () { return this.attachments.reduce((acc, a) => { acc[a.uuid] = a @@ -785,7 +795,7 @@ export default { this.scrollIntoField(step[0]) } - this.$refs.form.querySelector('input[type="date"], input[type="text"], select')?.focus() + this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], select')?.focus() if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) { this.$refs.form.querySelector('input[type="file"]')?.click() diff --git a/app/javascript/submission_form/formula_areas.vue b/app/javascript/submission_form/formula_areas.vue new file mode 100644 index 00000000..51632a0e --- /dev/null +++ b/app/javascript/submission_form/formula_areas.vue @@ -0,0 +1,97 @@ + + + + + + + + + + + diff --git a/app/javascript/submission_form/number_step.vue b/app/javascript/submission_form/number_step.vue index 9b8ae752..701a561f 100644 --- a/app/javascript/submission_form/number_step.vue +++ b/app/javascript/submission_form/number_step.vue @@ -59,7 +59,7 @@ export default { default: true }, modelValue: { - type: String, + type: [String, Number], required: false, default: '' } diff --git a/app/javascript/template_builder/area.vue b/app/javascript/template_builder/area.vue index e60d606e..6ce118b4 100644 --- a/app/javascript/template_builder/area.vue +++ b/app/javascript/template_builder/area.vue @@ -206,9 +206,11 @@ export default { } }, computed: { - defaultName: Field.computed.defaultName, fieldNames: FieldType.computed.fieldNames, fieldIcons: FieldType.computed.fieldIcons, + defaultName () { + return this.buildDefaultName(this.field, this.template.fields) + }, optionIndexText () { if (this.area.option_uuid && this.field.options) { return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.` @@ -312,6 +314,7 @@ export default { } }, methods: { + buildDefaultName: Field.methods.buildDefaultName, onNameFocus (e) { this.selectedAreaRef.value = this.area diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index a1d8951a..227ce948 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -273,6 +273,7 @@ @select="startFieldDraw($event)" /> + @@ -321,6 +322,7 @@ export default { backgroundColor: this.backgroundColor, withPhone: this.withPhone, withPayment: this.withPayment, + withFormula: this.withFormula, defaultDrawFieldType: this.defaultDrawFieldType, selectedAreaRef: computed(() => this.selectedAreaRef) } @@ -449,6 +451,11 @@ export default { required: false, default: false }, + withFormula: { + type: Boolean, + required: false, + default: false + }, onlyDefinedFields: { type: Boolean, required: false, diff --git a/app/javascript/template_builder/field.vue b/app/javascript/template_builder/field.vue index 586b8d1b..81a66407 100644 --- a/app/javascript/template_builder/field.vue +++ b/app/javascript/template_builder/field.vue @@ -65,6 +65,17 @@ :stroke-width="1.6" /> + + + {{ t('required') }} + + + + + {{ t('formula') }} + + + + + + @@ -366,7 +407,8 @@ import Contenteditable from './contenteditable' import FieldType from './field_type' import PaymentSettings from './payment_settings' -import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue' +import FormulaModal from './formula_modal' +import { IconMathFunction, IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue' import { v4 } from 'uuid' export default { @@ -377,11 +419,13 @@ export default { IconShape, PaymentSettings, IconNewSection, + FormulaModal, IconTrashX, + IconMathFunction, IconCopy, FieldType }, - inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't'], + inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't', 'withFormula'], props: { field: { type: Object, @@ -403,11 +447,15 @@ export default { return { isNameFocus: false, showPaymentModal: false, + isShowFormulaModal: false, renderDropdown: false } }, computed: { fieldNames: FieldType.computed.fieldNames, + modalContainerEl () { + return this.$el.getRootNode().querySelector('#docuseal_modal_container') + }, dateFormats () { return [ 'MM/DD/YYYY', @@ -422,22 +470,7 @@ export default { ] }, defaultName () { - if (this.field.type === 'payment' && this.field.preferences?.price) { - const { price, currency } = this.field.preferences || {} - - const formattedPrice = new Intl.NumberFormat([], { - style: 'currency', - currency - }).format(price) - - return `${this.fieldNames[this.field.type]} ${formattedPrice}` - } else { - const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field) - - const suffix = { multiple: this.t('select'), radio: this.t('group') }[this.field.type] || this.t('field') - - return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}` - } + return this.buildDefaultName(this.field, this.template.fields) }, areas () { return this.field.areas || [] @@ -452,6 +485,24 @@ export default { } }, methods: { + buildDefaultName (field, fields) { + if (field.type === 'payment' && field.preferences?.price) { + const { price, currency } = field.preferences || {} + + const formattedPrice = new Intl.NumberFormat([], { + style: 'currency', + currency + }).format(price) + + return `${this.fieldNames[field.type]} ${formattedPrice}` + } else { + const typeIndex = fields.filter((f) => f.type === field.type).indexOf(field) + + const suffix = { multiple: this.t('select'), radio: this.t('group') }[field.type] || this.t('field') + + return `${this.fieldNames[field.type]} ${suffix} ${typeIndex + 1}` + } + }, formatDate (date, format) { const monthFormats = { M: 'numeric', diff --git a/app/javascript/template_builder/formula_modal.vue b/app/javascript/template_builder/formula_modal.vue new file mode 100644 index 00000000..144750e2 --- /dev/null +++ b/app/javascript/template_builder/formula_modal.vue @@ -0,0 +1,216 @@ + + + + + + + {{ t('formula') }} + + × + + + + + + {{ f.name || buildDefaultName(f, template.fields) }} + + + + + + + + + + + + + + - + + + * + + + / + + + % + + + ^ + + + round(n, d) + + + abs(n) + + + + + + {{ t('save') }} + + + + + + + diff --git a/app/javascript/template_builder/i18n.js b/app/javascript/template_builder/i18n.js index 48b772c2..1604faf4 100644 --- a/app/javascript/template_builder/i18n.js +++ b/app/javascript/template_builder/i18n.js @@ -13,6 +13,7 @@ const en = { cancel: 'Cancel', any: 'Any', drawn: 'Drawn', + formula: 'Formula', typed: 'Typed', draw_field_on_the_document: 'Draw {field} field on the document', click_to_upload: 'Click to upload', diff --git a/app/views/submit_form/show.html.erb b/app/views/submit_form/show.html.erb index e1a2dd44..55692853 100644 --- a/app/views/submit_form/show.html.erb +++ b/app/views/submit_form/show.html.erb @@ -24,6 +24,7 @@ <% value = values[field['uuid']].presence || (field['readonly'] ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submitter.submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %> <% next if value.blank? %> <% next if !field['readonly'] && field['submitter_uuid'] == @submitter.uuid %> + <% next if field.dig('preferences', 'formula') && field['submitter_uuid'] == @submitter.uuid %> <%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submitter.account.locale %> <% end %> diff --git a/lib/submitters/submit_values.rb b/lib/submitters/submit_values.rb index cce31f29..9fcf73ed 100644 --- a/lib/submitters/submit_values.rb +++ b/lib/submitters/submit_values.rb @@ -37,6 +37,7 @@ module Submitters submitter.ip = request.remote_ip submitter.ua = request.user_agent submitter.values = merge_default_values(submitter) + submitter.values = merge_formula_values(submitter) end ApplicationRecord.transaction do @@ -92,6 +93,24 @@ module Submitters default_values.compact_blank.merge(submitter.values) end + def merge_formula_values(submitter) + computed_values = submitter.submission.template_fields.each_with_object({}) do |field, acc| + next if field['submitter_uuid'] != submitter.uuid + + formula = field.dig('preferences', 'formula') + + next if formula.blank? + + acc[field['uuid']] = calculate_formula_value(formula, submitter.values.merge(acc.compact_blank)) + end + + submitter.values.merge(computed_values.compact_blank) + end + + def calculate_formula_value(_formula, _values) + 0 + end + def template_default_value_for_submitter(value, submitter, with_time: false) return if value.blank? return if submitter.blank? diff --git a/package.json b/package.json index 73c8a3e1..ac726944 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "css-loader": "^6.7.3", "css-minimizer-webpack-plugin": "^5.0.0", "daisyui": "^3.2.1", + "mathjs": "^12.4.0", "mini-css-extract-plugin": "^2.7.5", "postcss": "^8.4.23", "postcss-import": "^15.1.0", diff --git a/yarn.lock b/yarn.lock index c696ad67..363f7b18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -904,6 +904,13 @@ dependencies: regenerator-runtime "^0.13.11" +"@babel/runtime@^7.23.9": + version "7.23.9" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7" + integrity sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.18.10", "@babel/template@^7.20.7": version "7.20.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8" @@ -2062,6 +2069,11 @@ commondir@^1.0.1: resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== +complex.js@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/complex.js/-/complex.js-2.1.1.tgz#0675dac8e464ec431fb2ab7d30f41d889fb25c31" + integrity sha512-8njCHOTtFFLtegk6zQo0kkVX1rngygb/KQI6z1qZxlFI3scluC+LVTCFbrkWjBv4vvLlbQ9t88IPMC6k95VTTg== + compressible@~2.0.16: version "2.0.18" resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" @@ -2337,6 +2349,11 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: dependencies: ms "2.1.2" +decimal.js@^10.4.3: + version "10.4.3" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" + integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== + deep-is@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" @@ -2578,6 +2595,11 @@ escape-html@~1.0.3: resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== +escape-latex@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/escape-latex/-/escape-latex-1.2.0.tgz#07c03818cf7dac250cce517f4fda1b001ef2bca1" + integrity sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw== + escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" @@ -3002,6 +3024,11 @@ forwarded@0.2.0: resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== +fraction.js@4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.4.tgz#b2bac8249a610c3396106da97c5a71da75b94b1c" + integrity sha512-pwiTgt0Q7t+GHZA4yaLjObx4vXmmdcS0iSJ19o8d/goUGgItX9UZWKWNnLHehxviD8wU2IWRsnR8cD5+yOJP2Q== + fraction.js@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950" @@ -3553,6 +3580,11 @@ isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== +javascript-natural-sort@^0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz#f9e2303d4507f6d74355a73664d1440fb5a0ef59" + integrity sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw== + jest-util@^29.5.0: version "29.5.0" resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.5.0.tgz#24a4d3d92fc39ce90425311b23c27a6e0ef16b8f" @@ -3768,6 +3800,21 @@ make-dir@^3.0.2: dependencies: semver "^6.0.0" +mathjs@^12.4.0: + version "12.4.0" + resolved "https://registry.yarnpkg.com/mathjs/-/mathjs-12.4.0.tgz#875c2ec19e5be69885b29769f78bbb37220322b6" + integrity sha512-4Moy0RNjwMSajEkGGxNUyMMC/CZAcl87WBopvNsJWB4E4EFebpTedr+0/rhqmnOSTH3Wu/3WfiWiw6mqiaHxVw== + dependencies: + "@babel/runtime" "^7.23.9" + complex.js "^2.1.1" + decimal.js "^10.4.3" + escape-latex "^1.2.0" + fraction.js "4.3.4" + javascript-natural-sort "^0.7.1" + seedrandom "^3.0.5" + tiny-emitter "^2.1.0" + typed-function "^4.1.1" + mdn-data@2.0.28: version "2.0.28" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.28.tgz#5ec48e7bef120654539069e1ae4ddc81ca490eba" @@ -4589,6 +4636,11 @@ regenerator-runtime@^0.13.11: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== +regenerator-runtime@^0.14.0: + version "0.14.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" + integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== + regenerator-transform@^0.15.1: version "0.15.1" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.1.tgz#f6c4e99fc1b4591f780db2586328e4d9a9d8dc56" @@ -4749,6 +4801,11 @@ schema-utils@^4.0.0: ajv-formats "^2.1.1" ajv-keywords "^5.1.0" +seedrandom@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-3.0.5.tgz#54edc85c95222525b0c7a6f6b3543d8e0b3aa0a7" + integrity sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg== + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -5170,6 +5227,11 @@ thunky@^1.0.2: resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA== +tiny-emitter@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" + integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== + to-fast-properties@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" @@ -5236,6 +5298,11 @@ typed-array-length@^1.0.4: for-each "^0.3.3" is-typed-array "^1.1.9" +typed-function@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/typed-function/-/typed-function-4.1.1.tgz#38ce3cae31f4f513bcb263563fdad27b2afa73e8" + integrity sha512-Pq1DVubcvibmm8bYcMowjVnnMwPVMeh0DIdA8ad8NZY2sJgapANJmiigSUwlt+EgXxpfIv8MWrQXTIzkfYZLYQ== + unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"