From 3b798c12fd2c095fc7b15284761b2e4c55679b96 Mon Sep 17 00:00:00 2001 From: Julien Nahum Date: Mon, 18 Dec 2023 10:35:00 +0100 Subject: [PATCH] WI --- client/components/forms/TextInput.vue | 3 +- .../components/forms/components/VSelect.vue | 2 +- client/components/forms/useFormInput.js | 2 +- .../open/forms/OpenCompleteForm.vue | 2 +- client/components/open/forms/OpenForm.vue | 5 +- .../components/open/forms/OpenFormButton.vue | 2 +- .../components/open/forms/OpenFormField.vue | 5 +- .../pages/forms/show/UrlFormPrefill.vue | 2 +- .../pages/templates/SingleTemplate.vue | 31 +- .../pages/templates/TemplateTags.vue | 2 + .../pages/templates/TemplatesList.vue | 137 ++++---- client/lib/forms/FormLogicConditionChecker.js | 325 ++++++++++++++++++ client/lib/forms/FormLogicPropertyResolver.js | 60 ++++ client/lib/forms/FormPropertyLogicRule.js | 123 +++++++ client/{config => lib/forms}/form-themes.js | 0 client/mixins/forms/input.js | 2 +- client/nuxt.config.ts | 26 +- client/pages/forms/show-public.vue | 5 +- client/pages/templates/[slug].vue | 80 +++-- .../{templates.vue => templates/index.vue} | 21 +- client/stores/templates.js | 10 +- 21 files changed, 657 insertions(+), 188 deletions(-) create mode 100644 client/lib/forms/FormLogicConditionChecker.js create mode 100644 client/lib/forms/FormLogicPropertyResolver.js create mode 100644 client/lib/forms/FormPropertyLogicRule.js rename client/{config => lib/forms}/form-themes.js (100%) rename client/pages/{templates.vue => templates/index.vue} (75%) diff --git a/client/components/forms/TextInput.vue b/client/components/forms/TextInput.vue index a431bebc..da7d6197 100644 --- a/client/components/forms/TextInput.vue +++ b/client/components/forms/TextInput.vue @@ -7,7 +7,7 @@ import Collapsible from '~/components/global/transitions/Collapsible.vue' -import { themes } from '../../../config/form-themes' +import { themes } from '../../../lib/forms/form-themes.js' import TextInput from '../TextInput.vue' import debounce from 'debounce' import Fuse from 'fuse.js' diff --git a/client/components/forms/useFormInput.js b/client/components/forms/useFormInput.js index 6c721997..68a5f197 100644 --- a/client/components/forms/useFormInput.js +++ b/client/components/forms/useFormInput.js @@ -1,5 +1,5 @@ import { ref, computed, watch } from 'vue' -import { themes } from '~/config/form-themes.js' +import { themes } from '~/lib/forms/form-themes.js' export const inputProps = { id: { type: String, default: null }, diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue index 92269018..4f45943a 100644 --- a/client/components/open/forms/OpenCompleteForm.vue +++ b/client/components/open/forms/OpenCompleteForm.vue @@ -111,7 +111,7 @@ import Form from 'vform' import OpenForm from './OpenForm.vue' import OpenFormButton from './OpenFormButton.vue' -import { themes } from '~/config/form-themes.js' +import { themes } from '~/lib/forms/form-themes.js' import VButton from '~/components/global/VButton.vue' import VTransition from '~/components/global/transitions/VTransition.vue' import FormPendingSubmissionKey from '../../../mixins/forms/form-pending-submission-key.js' diff --git a/client/components/open/forms/OpenForm.vue b/client/components/open/forms/OpenForm.vue index 52b5352a..f14446a7 100644 --- a/client/components/open/forms/OpenForm.vue +++ b/client/components/open/forms/OpenForm.vue @@ -62,12 +62,9 @@ diff --git a/client/lib/forms/FormLogicConditionChecker.js b/client/lib/forms/FormLogicConditionChecker.js new file mode 100644 index 00000000..bfce0fc1 --- /dev/null +++ b/client/lib/forms/FormLogicConditionChecker.js @@ -0,0 +1,325 @@ +export function conditionsMet (conditions, formData) { + if (conditions === undefined || conditions === null) { + return false + } + + // If it's not a group, just a single condition + if (conditions.operatorIdentifier === undefined) { + return propertyConditionMet(conditions.value, conditions.value ? formData[conditions.value.property_meta.id] : null) + } + + if (conditions.operatorIdentifier === 'and') { + let isvalid = true + conditions.children.forEach(childrenCondition => { + if (!conditionsMet(childrenCondition, formData)) { + isvalid = false + } + }) + return isvalid + } else if (conditions.operatorIdentifier === 'or') { + let isvalid = false + conditions.children.forEach(childrenCondition => { + if (conditionsMet(childrenCondition, formData)) { + isvalid = true + } + }) + return isvalid + } + + throw new Error('Unexcepted operatorIdentifier:' + conditions.operatorIdentifier) +} + +function propertyConditionMet (propertyCondition, value) { + if (!propertyCondition) { + return false + } + switch (propertyCondition.property_meta.type) { + case 'text': + case 'url': + case 'email': + case 'phone_number': + return textConditionMet(propertyCondition, value) + case 'number': + return numberConditionMet(propertyCondition, value) + case 'checkbox': + return checkboxConditionMet(propertyCondition, value) + case 'select': + return selectConditionMet(propertyCondition, value) + case 'date': + return dateConditionMet(propertyCondition, value) + case 'multi_select': + return multiSelectConditionMet(propertyCondition, value) + case 'files': + return filesConditionMet(propertyCondition, value) + } + return false +} + +function checkEquals (condition, fieldValue) { + return condition.value === fieldValue +} + +function checkContains (condition, fieldValue) { + return (fieldValue) ? fieldValue.includes(condition.value) : false +} + +function checkListContains (condition, fieldValue) { + if (!fieldValue) return false + + if (Array.isArray(condition.value)) { + return condition.value.every(r => fieldValue.includes(r)) + } else { + return fieldValue.includes(condition.value) + } +} + +function checkStartsWith (condition, fieldValue) { + return fieldValue.startsWith(condition.value) +} + +function checkendsWith (condition, fieldValue) { + return fieldValue && fieldValue.endsWith(condition.value) +} + +function checkIsEmpty (condition, fieldValue) { + return (!fieldValue || fieldValue.length === 0) +} + +function checkGreaterThan (condition, fieldValue) { + return (condition.value && fieldValue && parseFloat(fieldValue) > parseFloat(condition.value)) +} + +function checkGreaterThanEqual (condition, fieldValue) { + return (condition.value && fieldValue && parseFloat(fieldValue) >= parseFloat(condition.value)) +} + +function checkLessThan (condition, fieldValue) { + return (condition.value && fieldValue && parseFloat(fieldValue) < parseFloat(condition.value)) +} + +function checkLessThanEqual (condition, fieldValue) { + return (condition.value && fieldValue && parseFloat(fieldValue) <= parseFloat(condition.value)) +} + +function checkBefore (condition, fieldValue) { + return (condition.value && fieldValue && fieldValue > condition.value) +} + +function checkAfter (condition, fieldValue) { + return (condition.value && fieldValue && fieldValue < condition.value) +} + +function checkOnOrBefore (condition, fieldValue) { + return (condition.value && fieldValue && fieldValue >= condition.value) +} + +function checkOnOrAfter (condition, fieldValue) { + return (condition.value && fieldValue && fieldValue <= condition.value) +} + +function checkPastWeek (condition, fieldValue) { + if (!fieldValue) return false + const fieldDate = new Date(fieldValue) + const today = new Date() + return (fieldDate <= today && fieldDate >= new Date(today.getFullYear(), today.getMonth(), today.getDate() - 7)) +} + +function checkPastMonth (condition, fieldValue) { + if (!fieldValue) return false + const fieldDate = new Date(fieldValue) + const today = new Date() + return (fieldDate <= today && fieldDate >= new Date(today.getFullYear(), today.getMonth() - 1, today.getDate())) +} + +function checkPastYear (condition, fieldValue) { + if (!fieldValue) return false + const fieldDate = new Date(fieldValue) + const today = new Date() + return (fieldDate <= today && fieldDate >= new Date(today.getFullYear() - 1, today.getMonth(), today.getDate())) +} + +function checkNextWeek (condition, fieldValue) { + if (!fieldValue) return false + const fieldDate = new Date(fieldValue) + const today = new Date() + return (fieldDate >= today && fieldDate <= new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7)) +} + +function checkNextMonth (condition, fieldValue) { + if (!fieldValue) return false + const fieldDate = new Date(fieldValue) + const today = new Date() + return (fieldDate >= today && fieldDate <= new Date(today.getFullYear(), today.getMonth() + 1, today.getDate())) +} + +function checkNextYear (condition, fieldValue) { + if (!fieldValue) return false + const fieldDate = new Date(fieldValue) + const today = new Date() + return (fieldDate >= today && fieldDate <= new Date(today.getFullYear() + 1, today.getMonth(), today.getDate())) +} + +function checkLength (condition, fieldValue, operator = '===') { + if(!fieldValue || fieldValue.length === 0) return false; + switch (operator) { + case '===': + return fieldValue.length === parseInt(condition.value) + case '!==': + return fieldValue.length !== parseInt(condition.value) + case '>': + return fieldValue.length > parseInt(condition.value) + case '>=': + return fieldValue.length >= parseInt(condition.value) + case '<': + return fieldValue.length < parseInt(condition.value) + case '<=': + return fieldValue.length <= parseInt(condition.value) + } + return false +} + +function textConditionMet (propertyCondition, value) { + switch (propertyCondition.operator) { + case 'equals': + return checkEquals(propertyCondition, value) + case 'does_not_equal': + return !checkEquals(propertyCondition, value) + case 'contains': + return checkContains(propertyCondition, value) + case 'does_not_contain': + return !checkContains(propertyCondition, value) + case 'starts_with': + return checkStartsWith(propertyCondition, value) + case 'ends_with': + return checkendsWith(propertyCondition, value) + case 'is_empty': + return checkIsEmpty(propertyCondition, value) + case 'is_not_empty': + return !checkIsEmpty(propertyCondition, value) + case 'content_length_equals': + return checkLength(propertyCondition, value, '===') + case 'content_length_does_not_equal': + return checkLength(propertyCondition, value, '!==') + case 'content_length_greater_than': + return checkLength(propertyCondition, value, '>') + case 'content_length_greater_than_or_equal_to': + return checkLength(propertyCondition, value, '>=') + case 'content_length_less_than': + return checkLength(propertyCondition, value, '<') + case 'content_length_less_than_or_equal_to': + return checkLength(propertyCondition, value, '<=') + } + return false +} + +function numberConditionMet (propertyCondition, value) { + switch (propertyCondition.operator) { + case 'equals': + return checkEquals(propertyCondition, value) + case 'does_not_equal': + return !checkEquals(propertyCondition, value) + case 'greater_than': + return checkGreaterThan(propertyCondition, value) + case 'less_than': + return checkLessThan(propertyCondition, value) + case 'greater_than_or_equal_to': + return checkGreaterThanEqual(propertyCondition, value) + case 'less_than_or_equal_to': + return checkLessThanEqual(propertyCondition, value) + case 'is_empty': + return checkIsEmpty(propertyCondition, value) + case 'is_not_empty': + return checkIsEmpty(propertyCondition, value) + case 'content_length_equals': + return checkLength(propertyCondition, value, '===') + case 'content_length_does_not_equal': + return checkLength(propertyCondition, value, '!==') + case 'content_length_greater_than': + return checkLength(propertyCondition, value, '>') + case 'content_length_greater_than_or_equal_to': + return checkLength(propertyCondition, value, '>=') + case 'content_length_less_than': + return checkLength(propertyCondition, value, '<') + case 'content_length_less_than_or_equal_to': + return checkLength(propertyCondition, value, '<=') + } + return false +} + +function checkboxConditionMet (propertyCondition, value) { + switch (propertyCondition.operator) { + case 'equals': + return checkEquals(propertyCondition, value) + case 'does_not_equal': + return !checkEquals(propertyCondition, value) + } + return false +} + +function selectConditionMet (propertyCondition, value) { + switch (propertyCondition.operator) { + case 'equals': + return checkEquals(propertyCondition, value) + case 'does_not_equal': + return !checkEquals(propertyCondition, value) + case 'is_empty': + return checkIsEmpty(propertyCondition, value) + case 'is_not_empty': + return !checkIsEmpty(propertyCondition, value) + } + return false +} + +function dateConditionMet (propertyCondition, value) { + switch (propertyCondition.operator) { + case 'equals': + return checkEquals(propertyCondition, value) + case 'before': + return checkBefore(propertyCondition, value) + case 'after': + return checkAfter(propertyCondition, value) + case 'on_or_before': + return checkOnOrBefore(propertyCondition, value) + case 'on_or_after': + return checkOnOrAfter(propertyCondition, value) + case 'is_empty': + return checkIsEmpty(propertyCondition, value) + case 'past_week': + return checkPastWeek(propertyCondition, value) + case 'past_month': + return checkPastMonth(propertyCondition, value) + case 'past_year': + return checkPastYear(propertyCondition, value) + case 'next_week': + return checkNextWeek(propertyCondition, value) + case 'next_month': + return checkNextMonth(propertyCondition, value) + case 'next_year': + return checkNextYear(propertyCondition, value) + } + return false +} + +function multiSelectConditionMet (propertyCondition, value) { + switch (propertyCondition.operator) { + case 'contains': + return checkListContains(propertyCondition, value) + case 'does_not_contain': + return !checkListContains(propertyCondition, value) + case 'is_empty': + return checkIsEmpty(propertyCondition, value) + case 'is_not_empty': + return !checkIsEmpty(propertyCondition, value) + } + return false +} + +function filesConditionMet (propertyCondition, value) { + switch (propertyCondition.operator) { + case 'is_empty': + return checkIsEmpty(propertyCondition, value) + case 'is_not_empty': + return !checkIsEmpty(propertyCondition, value) + } + return false +} diff --git a/client/lib/forms/FormLogicPropertyResolver.js b/client/lib/forms/FormLogicPropertyResolver.js new file mode 100644 index 00000000..a2f12898 --- /dev/null +++ b/client/lib/forms/FormLogicPropertyResolver.js @@ -0,0 +1,60 @@ +import { conditionsMet } from './FormLogicConditionChecker' +class FormLogicPropertyResolver { + conditionsMet = conditionsMet; + property = null; + formData = null; + logic = false; + + constructor (property, formData) { + this.property = property + this.formData = formData + this.logic = (property.logic !== undefined) ? property.logic : false + } + + isHidden () { + if (!this.logic) { + return this.property.hidden + } + + const conditionsMet = this.conditionsMet(this.logic.conditions, this.formData) + if (conditionsMet && this.property.hidden && this.logic.actions.length > 0 && this.logic.actions.includes('show-block')) { + return false + } else if (conditionsMet && !this.property.hidden && this.logic.actions.length > 0 && this.logic.actions.includes('hide-block')) { + return true + } else { + return this.property.hidden + } + } + + isRequired () { + if (!this.logic) { + return this.property.required + } + + const conditionsMet = this.conditionsMet(this.logic.conditions, this.formData) + if (conditionsMet && this.property.required && this.logic.actions.length > 0 && this.logic.actions.includes('make-it-optional')) { + return false + } else if (conditionsMet && !this.property.required && this.logic.actions.length > 0 && this.logic.actions.includes('require-answer')) { + return true + } else { + return this.property.required + } + } + + isDisabled () { + if (!this.logic) { + return this.property.disabled + } + + const conditionsMet = this.conditionsMet(this.logic.conditions, this.formData) + if (conditionsMet && this.property.disabled && this.logic.actions.length > 0 && this.logic.actions.includes('enable-block')) { + return false + } else if (conditionsMet && !this.property.disabled && this.logic.actions.length > 0 && this.logic.actions.includes('disable-block')) { + return true + } else { + return this.property.disabled + } + } +} + +export default FormLogicPropertyResolver diff --git a/client/lib/forms/FormPropertyLogicRule.js b/client/lib/forms/FormPropertyLogicRule.js new file mode 100644 index 00000000..f7ce4b65 --- /dev/null +++ b/client/lib/forms/FormPropertyLogicRule.js @@ -0,0 +1,123 @@ +import OpenFilters from '../../data/open_filters.json' +class FormPropertyLogicRule { + property = null + logic = null + isConditionCorrect = true + isActionCorrect = true + ACTIONS_VALUES = [ + 'show-block', + 'hide-block', + 'make-it-optional', + 'require-answer', + 'enable-block', + 'disable-block' + ] + CONDITION_MAPPING = OpenFilters + + constructor (property) { + this.property = property + this.logic = (property.logic !== undefined && property.logic) ? property.logic : null + } + + isValid () { + if (this.logic && this.logic['conditions']) { + this.checkConditions(this.logic['conditions']) + this.checkActions((this.logic && this.logic['actions']) ? this.logic['actions'] : null) + } + + return this.isConditionCorrect && this.isActionCorrect + } + + checkConditions (conditions) { + if (conditions && conditions['operatorIdentifier']) { + if ((conditions['operatorIdentifier'] !== 'and') && (conditions['operatorIdentifier'] !== 'or')) { + this.isConditionCorrect = false + return + } + + if (conditions['operatorIdentifier']['children'] !== undefined || !Array.isArray(conditions['children'])) { + this.isConditionCorrect = false + return + } + + conditions['children'].forEach(childrenCondition => { + this.checkConditions(childrenCondition) + }) + } else if (conditions && conditions['identifier']) { + this.checkBaseCondition(conditions) + } + } + + checkBaseCondition (condition) { + if (condition['value'] === undefined || + condition['value']['property_meta'] === undefined || + condition['value']['property_meta']['type'] === undefined || + condition['value']['operator'] === undefined || + condition['value']['value'] === undefined + ) { + this.isConditionCorrect = false + return + } + + const typeField = condition['value']['property_meta']['type'] + const operator = condition['value']['operator'] + const value = condition['value']['value'] + + if (this.CONDITION_MAPPING[typeField] === undefined || + this.CONDITION_MAPPING[typeField]['comparators'][operator] === undefined + ) { + this.isConditionCorrect = false + return + } + + const type = this.CONDITION_MAPPING[typeField]['comparators'][operator]['expected_type'] + if (Array.isArray(type)) { + let foundCorrectType = false + type.forEach(subtype => { + if (this.valueHasCorrectType(subtype, value)) { + foundCorrectType = true + } + }) + if (!foundCorrectType) { + this.isConditionCorrect = false + } + } else { + if (!this.valueHasCorrectType(type, value)) { + this.isConditionCorrect = false + } + } + } + + valueHasCorrectType (type, value) { + if ( + (type === 'string' && typeof value !== 'string') || + (type === 'boolean' && typeof value !== 'boolean') || + (type === 'number' && typeof value !== 'number') || + (type === 'object' && !Array.isArray(value)) + ) { + return false + } + return true + } + + checkActions (conditions) { + if (Array.isArray(conditions) && conditions.length > 0) { + conditions.forEach(val => { + if (this.ACTIONS_VALUES.indexOf(val) === -1 || + (['nf-text', 'nf-code', 'nf-page-break', 'nf-divider', 'nf-image'].indexOf(this.property["type"]) > -1 && ['hide-block', 'show-block'].indexOf(val) === -1) || + (this.property["hidden"] !== undefined && this.property["hidden"] && ['show-block', 'require-answer'].indexOf(val) === -1) || + (this.property["required"] !== undefined && this.property["required"] && ['make-it-optional', 'hide-block', 'disable-block'].indexOf(val) === -1) || + (this.property["disabled"] !== undefined && this.property["disabled"] && ['enable-block', 'require-answer', 'make-it-optional'].indexOf(val) === -1) + ) { + this.isActionCorrect = false + return + } + }) + } else { + this.isActionCorrect = false + } + } + +} + +export default FormPropertyLogicRule \ No newline at end of file diff --git a/client/config/form-themes.js b/client/lib/forms/form-themes.js similarity index 100% rename from client/config/form-themes.js rename to client/lib/forms/form-themes.js diff --git a/client/mixins/forms/input.js b/client/mixins/forms/input.js index b7a7d028..781fc689 100644 --- a/client/mixins/forms/input.js +++ b/client/mixins/forms/input.js @@ -1,4 +1,4 @@ -import { themes } from '~/config/form-themes.js' +import { themes } from '~/lib/forms/form-themes.js' export default { props: { diff --git a/client/nuxt.config.ts b/client/nuxt.config.ts index c704711b..c509daad 100644 --- a/client/nuxt.config.ts +++ b/client/nuxt.config.ts @@ -11,6 +11,16 @@ const modules = [ if (opnformConfig.sentry_dsn) { modules.push('@nuxtjs/sentry') } + +const cachedRoutes = [ + '/', + '/ai-form-builder', + '/login', + '/register', + '/privacy-policy', + '/terms-conditions', +].reduce((acc, curr) => (acc[curr] = {swr: 60 * 60}, acc), {}); + export default defineNuxtConfig({ devtools: {enabled: true}, css: ['~/scss/app.scss'], @@ -39,17 +49,11 @@ export default defineNuxtConfig({ path: '~/components/global', pathPrefix: false, }, + { + path: '~/components/pages', + pathPrefix: false, + }, '~/components', ], - routeRules: { - '/ai-form-builder': { - swr: 60 * 60 - }, - '/privacy-policy': { - swr: 60 * 60 - }, - '/terms-conditions': { - swr: 60 * 60 - }, - } + routeRules: { ... cachedRoutes} }) diff --git a/client/pages/forms/show-public.vue b/client/pages/forms/show-public.vue index 73d8f1a6..3c5271a4 100644 --- a/client/pages/forms/show-public.vue +++ b/client/pages/forms/show-public.vue @@ -54,7 +54,6 @@ import { computed } from 'vue' import { useFormsStore } from '../../stores/forms' import { useRecordsStore } from '../../stores/records' import OpenCompleteForm from '../../components/open/forms/OpenCompleteForm.vue' -import Cookies from 'js-cookie' import sha256 from 'js-sha256' import SeoMeta from '../../mixins/seo-meta.js' @@ -169,7 +168,9 @@ export default { methods: { passwordEntered (password) { - Cookies.set('password-' + this.form.slug, sha256(password), { expires: 7, sameSite: 'None', secure: true }) + if (process.client) { + useCookie('password-' + this.form.slug, {maxAge: { expires: 60*60*7}}).value = sha256(password) + } loadForm(this.formSlug).then(() => { if (this.form.is_password_protected) { this.$refs['open-complete-form'].addPasswordError('Invalid password.') diff --git a/client/pages/templates/[slug].vue b/client/pages/templates/[slug].vue index c28d49de..0f43c851 100644 --- a/client/pages/templates/[slug].vue +++ b/client/pages/templates/[slug].vue @@ -26,7 +26,7 @@
- +

We could not find this template. @@ -65,7 +65,7 @@ Template Preview

@@ -73,7 +73,8 @@
- + Use this template
@@ -91,7 +92,7 @@
-
+