Refactor form rendering (#747)
* Update Dependencies and Refactor Form Components - Upgraded various Sentry-related packages in `package-lock.json` to version 9.15.0, ensuring compatibility with the latest features and improvements. - Refactored `FormProgressbar.vue` to utilize `config` for color and visibility settings instead of `form`, enhancing flexibility in form management. - Removed the `FormTimer.vue` component as it was deemed unnecessary, streamlining the form component structure. - Updated `OpenCompleteForm.vue` and `OpenForm.vue` to integrate `formManager`, improving the overall management of form states and properties. - Enhanced `UrlFormPrefill.vue` to utilize `formManager` for better handling of pre-filled URL generation. These changes aim to improve the maintainability and performance of the form components while ensuring they leverage the latest dependency updates. * Refactor Form Components to Utilize Composables and Improve State Management - Updated `FormProgressbar.vue` to access `config.value` and `data.value` for better reactivity. - Refactored `OpenCompleteForm.vue` to use `useFormManager` for managing form state, enhancing clarity and maintainability. - Modified `OpenForm.vue` to leverage `structure.value` and `config.value`, improving the handling of form properties and structure. - Enhanced `UrlFormPrefill.vue` to initialize `useFormManager` within the setup function, streamlining form management. - Updated `FormManager.js` and `FormStructureService.js` to improve validation logic and state management, ensuring better performance and reliability. These changes aim to enhance the overall maintainability and performance of the form components by leveraging composables for state management and improving the reactivity of form properties. * Refactor Form Components to Enhance State Management and Structure - Updated `FormProgressbar.vue` to utilize `props.formManager?.config` and `structureService` for improved reactivity and data handling. - Refactored `OpenCompleteForm.vue` to initialize `formManager` outside of `onMounted`, enhancing SSR compatibility and clarity in form state management. - Modified `OpenForm.vue` to leverage `formManager.form` for data binding, streamlining the handling of form properties. - Updated `OpenFormField.vue` to utilize `formManager` directly for field logic checks, improving maintainability and consistency. - Removed obsolete `FormManager.js`, `FormInitializationService.js`, `FormPaymentService.js`, `FormStructureService.js`, `FormSubmissionService.js`, and `FormTimerService.js` files to simplify the codebase and reduce complexity. These changes aim to enhance the maintainability and performance of the form components by leveraging composables for state management and improving the reactivity of form properties. * Enhance OpenCompleteForm and OpenForm Components with Auto-Submit and State Management Improvements - Updated `OpenCompleteForm.vue` to include an auto-submit feature that triggers form submission when the `auto_submit` parameter is present in the URL. This improves user experience by streamlining the submission process. - Refactored `OpenForm.vue` to remove the loader display logic, simplifying the component structure and enhancing clarity. - Enhanced `pendingSubmission.js` to include a `clear` method for better management of pending submissions. - Modified `Form.js` to ensure that the submission payload correctly merges additional data, improving the robustness of form submissions. - Updated `useFormInitialization.js` to handle pending submissions and URL parameters more effectively, ensuring a smoother user experience. These changes aim to improve the overall functionality and maintainability of the form components by enhancing state management and user interaction capabilities. * Enhance Partial Submission Functionality and Sync Mechanism - Updated `usePartialSubmission.js` to improve the synchronization mechanism by increasing the debounce time from 1 second to 2 seconds, reducing the frequency of sync operations. - Introduced a new `syncImmediately` function to allow immediate synchronization during critical events such as page unload, enhancing data reliability. - Refactored the `syncToServer` function to handle computed ref patterns for form data more effectively, ensuring accurate data submission. - Modified event handlers to utilize the new immediate sync functionality, improving responsiveness during visibility changes and window blur events. - Enhanced the `stopSync` function to perform a final sync before stopping, ensuring no data is lost during component unmounting. These changes aim to improve the reliability and performance of partial submissions, ensuring that user data is consistently synchronized with the server during critical interactions. * Refactor OpenFormField Component to Use Composition API and Enhance Field Logic - Converted `OpenFormField.vue` to utilize the Composition API with `<script setup>`, improving readability and maintainability. - Defined props using `defineProps` for better type safety and clarity. - Refactored computed properties and methods to leverage the new setup structure, enhancing performance and organization. - Updated `FormFieldEdit.vue` to ensure the structure service is used for setting the page for the selected field, improving navigation consistency. - Enhanced `useFormStructure.js` with additional validation for field indices and introduced a new `setPageForField` method to manage page navigation more effectively. - Modified `working_form.js` to streamline the management of the current page index and ensure proper integration with the structure service. These changes aim to improve the overall structure and functionality of the form components, enhancing user experience and maintainability. * Enhance Captcha Handling and Refactor OpenForm Component - Added error handling for resetting hCaptcha and reCAPTCHA in `HCaptchaV2.vue` and `RecaptchaV2.vue`, improving user experience by providing fallback mechanisms when the reset fails. - Refactored `OpenForm.vue` to replace the `CaptchaInput` component with `CaptchaWrapper`, streamlining the captcha integration and enhancing maintainability. - Removed obsolete captcha registration logic from `useFormManager.js` and `useFormValidation.js`, simplifying the form management process and improving code clarity. These changes aim to improve the reliability and user experience of captcha handling within the form components, ensuring smoother interactions and better error management. * Refactor PaymentInput Component to Enhance Stripe Elements Integration - Replaced the `useStripeElements` composable with a new `createStripeElements` function, allowing for lazy initialization of Stripe elements based on provided props. - Introduced a local instance for Stripe elements, improving fallback handling when payment data is not available. - Updated computed properties to ensure proper access to Stripe state and methods, enhancing reliability in payment processing. - Modified the `CaptchaWrapper` component to remove the dark mode prop, simplifying its interface. - Refactored `OpenCompleteForm`, `OpenForm`, and `OpenFormField` components to derive theme and dark mode settings directly from `formManager`, improving consistency across form components. These changes aim to streamline the integration of Stripe elements, enhance maintainability, and improve the overall user experience in payment processing within the form components. * Enhance Payment Input and Form Components for Improved Stripe Integration - Updated the `PaymentInput.client.vue` component to provide more informative messages during payment preview, including detailed error messages for missing configurations. - Refactored the handling of Stripe elements to ensure proper initialization and state management, improving reliability in payment processing. - Enhanced the `OpenCompleteForm.vue` and `OpenFormField.vue` components to streamline payment data retrieval and submission processes. - Improved error handling in `OpenCompleteForm.vue` to provide clearer feedback on submission issues. - Refactored `useStripeElements.js` to support lazy initialization of Stripe elements with an optional account ID, enhancing flexibility in payment configurations. These changes aim to improve the user experience during payment processing by providing clearer feedback and ensuring robust integration with Stripe elements. * Refactor FormPaymentController and Update Payment Intent Route - Updated the `FormPaymentController` to utilize `CreatePaymentIntentRequest` for improved request validation and handling. - Changed the `createIntent` method to accept a POST request instead of GET, aligning with RESTful practices for creating resources. - Enhanced the payment intent creation logic to use a description from the payment block if available, improving clarity in payment processing. - Modified the `useFormPayment` composable to reflect the change to a POST request, ensuring proper API interaction and logging. These changes aim to enhance the payment processing flow by improving request handling and aligning with best practices for API design. * Refactor UrlFormPrefill Component to Utilize Composition API - Converted `UrlFormPrefill.vue` to use the Composition API with `<script setup>`, enhancing readability and maintainability. - Defined props using `defineProps` for better type safety and clarity. - Refactored the initialization of `formManager` to streamline setup and improve logging during form management. - Updated the URL generation method to utilize reactive references, ensuring better state management and performance. - Removed obsolete props and methods, simplifying the component structure. These changes aim to improve the overall structure and functionality of the `UrlFormPrefill` component, enhancing user experience and maintainability. * Enhance OpenCompleteForm and FormManager with Improved State Management and Debugging - Added `workingFormStore` to `OpenCompleteForm.vue` for better integration with the working form state. - Introduced a watcher in `OpenCompleteForm.vue` to share the structure service with the working form store when in admin edit context, enhancing form management capabilities. - Updated `useFormManager.js` to include a watcher for `currentPage`, improving debugging by logging page changes. - Modified payment processing logic in `useFormManager.js` to conditionally skip payment validation in non-LIVE modes, enhancing flexibility during development. - Refactored `useFormStructure.js` to eliminate unnecessary calls to `toValue`, improving performance and clarity in state management. - Adjusted `useFormValidation.js` to directly access `managerState.currentPage`, streamlining error handling during form validation. These changes aim to improve the overall functionality and maintainability of the form components by enhancing state management, debugging capabilities, and flexibility in payment processing. * Refactor Form Components for Improved Logic and State Management - Updated `OpenForm.vue` to simplify the conditional rendering of the previous button by removing the loading state check, enhancing clarity in button visibility logic. - Refactored `useFormInitialization.js` to streamline the `updateSpecialFields` function by removing the fields parameter and directly iterating over `formConfig.value.properties`, improving code readability and maintainability. - Modified `useFormManager.js` to eliminate the passing of fields in the initialization options, simplifying the initialization process. - Enhanced `useFormPayment.js` to check for existing payment intent IDs directly in the form data, improving the payment processing logic and reducing redundant checks. - Updated `useFormStructure.js` to include a computed property for `currentPage`, enhancing the state management of the form structure. - Refactored `working_form.js` to replace the structure service's current page access, improving the integration with the form state. These changes aim to enhance the overall functionality and maintainability of the form components by improving state management and simplifying logic across various form-related files. * Refactor Form Components and Improve Submission Logic - Updated `OpenCompleteForm.vue` to enhance the submission logic by directly using `submissionId` from the variable instead of the route query, improving clarity and reducing dependency on route parameters. - Modified `triggerSubmit` function in `OpenCompleteForm.vue` to streamline the handling of submission results, ensuring that `submittedData` is set directly from the result and simplifying the conditional checks for `submission_id` and `is_first_submission`. - Removed the `pendingSubmission.js` file as it was no longer needed, consolidating the submission handling logic within the relevant components and composables. - Enhanced `usePartialSubmission.js` to improve the management of submission hashes and ensure that the service integrates seamlessly with the new structure, prioritizing local storage for hash retrieval. - Updated `useFormInitialization.js` to improve error handling and ensure that the form resets correctly when loading submissions fails, enhancing user experience. - Refactored `useFormManager.js` to instantiate the new `usePendingSubmission` service, ensuring that local storage handling is properly integrated into the form management process. These changes aim to improve the overall functionality, maintainability, and user experience of the form components by streamlining submission logic and enhancing state management. * Refactor FormProgressbar Component for Improved Logic and Clarity - Updated the `FormProgressbar.vue` component to simplify the condition for displaying the progress bar by directly using the `showProgressBar` computed property instead of accessing it through the `config` object. - Refactored the computed properties to ensure they directly reference the necessary values from `formManager`, enhancing clarity and maintainability. - Modified the logic for calculating progress to utilize `config.value?.properties` instead of `structureService`, streamlining the progress calculation process. These changes aim to enhance the overall functionality and maintainability of the `FormProgressbar` component by improving the clarity of the progress display logic and ensuring accurate progress calculations. * Refactor OpenCompleteForm and Index Page for Improved Logic and Clarity - Updated `OpenCompleteForm.vue` to remove unnecessary margin from the password protected message, enhancing the visual layout. - Added `addPasswordError` function to `defineExpose` in `OpenCompleteForm.vue`, allowing better error handling for password validation. - Refactored the usage of the translation function in `index.vue` to destructure `t` from `useI18n`, improving code clarity and consistency. These changes aim to enhance the overall functionality and maintainability of the form components by streamlining error handling and improving the clarity of the code structure. * Enhance Form Submission Logic and Validation Rules - Updated `PublicFormController.php` to dispatch the job for handling form submissions asynchronously, improving performance and responsiveness. - Modified `AnswerFormRequest.php` to add validation rules for `completion_time` and make `submission_id` nullable, enhancing data integrity and flexibility. - Added debugging output in `StoreFormSubmissionJob.php` to log form data and completion time, aiding in troubleshooting and monitoring. These changes aim to improve the overall functionality and maintainability of the form submission process by optimizing job handling and enhancing validation mechanisms. * Update ESLint Configuration and Add Vue Plugin - Modified `.eslintrc.cjs` to ensure proper formatting by removing an unnecessary trailing comma. - Updated `package.json` and `package-lock.json` to include `eslint-plugin-vue` version 10.1.0, enhancing linting capabilities for Vue components. These changes aim to improve code quality and maintainability by ensuring consistent linting rules and support for Vue-specific linting features. * Enhance User Management Tests and Form Components - Updated `UserManagementTest.php` to include validation for Google reCAPTCHA by adding the `g-recaptcha-response` parameter in registration tests, ensuring comprehensive coverage of user registration scenarios. - Modified `FormPaymentTest.php` to change the HTTP method from GET to POST for creating payment intents, aligning with RESTful practices and improving the accuracy of test cases. - Enhanced `FlatSelectInput.vue` to support slot-based rendering for selected options and options, improving flexibility in how options are displayed and selected. - Refactored `OpenCompleteForm.vue` to correct indentation in the form submission logic, enhancing code readability. - Updated `FormSubmissionSettings.vue` to replace the `select-input` component with `flat-select-input`, improving consistency in form component usage. - Enhanced `useFormManager.js` to add logging for form submissions and handle postMessage communication for iframe integration, improving debugging and integration capabilities. These changes aim to improve the robustness of the testing suite and enhance the functionality and maintainability of form components by ensuring proper validation and consistent component usage. * Refactor ESLint Configuration and Improve Error Handling in Components - Deleted the obsolete `.eslintrc.cjs` file to streamline ESLint configuration management. - Updated `eslint.config.cjs` to include ignores for `.nuxt/**`, `node_modules/**`, and `dist/**`, enhancing linting efficiency. - Refactored error handling in various components (e.g., `DateInput.vue`, `FlatSelectInput.vue`, `CaptchaInput.vue`, etc.) by removing the error variable in catch blocks, simplifying the code and maintaining functionality. - Improved the logic in `FormBlockLogicEditor.vue` to check for non-empty logic objects, enhancing validation accuracy. These changes aim to improve code quality and maintainability by optimizing ESLint configurations and enhancing error handling across components. * Fix Logic in useFormInitialization for Matrix Field Prefill Handling - Updated the `updateSpecialFields` function in `useFormInitialization.js` to correct the logic for handling matrix fields. The condition now checks the form directly instead of using a separate `formData` parameter, ensuring that prefill data is applied correctly when the field is empty. These changes aim to enhance the accuracy of form initialization by ensuring proper handling of matrix fields during the form setup process. * Add findFirstPageWithError function to useFormValidation for improved error handling - Introduced the `findFirstPageWithError` function in `useFormValidation.js` to identify the index of the first page containing validation errors. This function checks for existing errors and iterates through the nested array of field groups to determine if any page has errors, returning the appropriate index or -1 if no errors are found. These changes aim to enhance the form validation process by providing a clear mechanism to locate pages with validation issues, improving user experience during form submissions. * Enhance OpenCompleteForm Logic and Add Confetti Feature - Updated `OpenCompleteForm.vue` to improve conditional rendering by changing `v-if` to `v-else-if` for better clarity in form submission states. - Introduced a new computed property `shouldDisplayForm` to centralize the logic for determining form visibility based on submission status and admin controls. - Modified `useFormManager.js` to include a confetti effect upon successful form submission, enhancing user engagement during the submission process. These changes aim to improve the user experience by refining form visibility logic and adding a celebratory feature upon successful submissions. * Refactor Form Submission Job and Storage File Logic - Updated `StoreFormSubmissionJob.php` to streamline the constructor by combining the constructor body into a single line for improved readability. - Removed debugging output in `StoreFormSubmissionJob.php` to clean up the code and enhance performance. - Simplified the constructor in `StorageFile.php` by consolidating it into a single line, improving code clarity. - Enhanced the logic in `StorageFile.php` to utilize a file name parser for checking file existence, ensuring more accurate file handling. These changes aim to improve code readability and maintainability by simplifying constructors and enhancing file handling logic. * Refactor Constructors in StoreFormSubmissionJob and StorageFile - Updated the constructors in `StoreFormSubmissionJob.php` and `StorageFile.php` to include an explicit body, enhancing code clarity and consistency in constructor definitions. - Improved readability by ensuring a uniform structure across class constructors. These changes aim to improve code maintainability and readability by standardizing the constructor format in the affected classes. --------- Co-authored-by: Chirag Chhatrala <chirag.chhatrala@gmail.com>
This commit is contained in:
14
client/components/open/forms/CaptchaWrapper.vue
Normal file
14
client/components/open/forms/CaptchaWrapper.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<!-- No changes to template section -->
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
formManager: { type: Object, required: true },
|
||||
theme: { type: Object, required: false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* No changes to style section */
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<template v-if="form.show_progress_bar">
|
||||
<template v-if="showProgressBar">
|
||||
<div
|
||||
v-if="isIframe"
|
||||
class="mb-4 p-2"
|
||||
@@ -8,7 +8,7 @@
|
||||
<div
|
||||
class="h-full transition-all duration-300 rounded-r-full"
|
||||
:class="{ 'w-0': formProgress === 0 }"
|
||||
:style="{ width: formProgress + '%', background: form.color }"
|
||||
:style="{ width: formProgress + '%', background: config?.color }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -20,7 +20,7 @@
|
||||
<div
|
||||
class="h-full transition-all duration-300"
|
||||
:class="{ 'w-0': formProgress === 0 }"
|
||||
:style="{ width: formProgress + '%', background: form.color }"
|
||||
:style="{ width: formProgress + '%', background: config?.color }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,28 +31,31 @@
|
||||
import { useIsIframe } from '~/composables/useIsIframe'
|
||||
|
||||
const props = defineProps({
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
formData: {
|
||||
formManager: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const config = computed(() => props.formManager?.config.value)
|
||||
const form = computed(() => props.formManager?.form)
|
||||
const showProgressBar = computed(() => {
|
||||
return config.value?.show_progress_bar
|
||||
})
|
||||
|
||||
|
||||
const isIframe = useIsIframe()
|
||||
|
||||
const formProgress = computed(() => {
|
||||
const requiredFields = props.fields.filter(field => field.required)
|
||||
const allFields = config.value?.properties ?? []
|
||||
const requiredFields = allFields.filter(field => field?.required)
|
||||
|
||||
if (requiredFields.length === 0) {
|
||||
return 100
|
||||
}
|
||||
const completedFields = requiredFields.filter(field => ![null, undefined, ''].includes(props.formData[field.id]))
|
||||
|
||||
const currentFormData = form.value.data() || {}
|
||||
const completedFields = requiredFields.filter(field => field && ![null, undefined, ''].includes(currentFormData[field.id]))
|
||||
const progress = (completedFields.length / requiredFields.length) * 100
|
||||
return Math.round(progress)
|
||||
})
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
<template></template>
|
||||
|
||||
<script setup>
|
||||
import { pendingSubmission as pendingSubmissionFun } from "~/composables/forms/pendingSubmission.js"
|
||||
|
||||
const props = defineProps({
|
||||
form: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const pendingSubmission = pendingSubmissionFun(props.form)
|
||||
const startTime = ref(null)
|
||||
const completionTime = ref(parseInt(pendingSubmission.getTimer() ?? null))
|
||||
const timer = ref(null)
|
||||
|
||||
watch(() => completionTime.value, () => {
|
||||
if (completionTime.value) {
|
||||
pendingSubmission.setTimer(completionTime.value)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const startTimer = () => {
|
||||
if (!startTime.value) {
|
||||
startTime.value = parseInt(pendingSubmission.getTimer() ?? 1)
|
||||
completionTime.value = startTime.value
|
||||
timer.value = setInterval(() => {
|
||||
completionTime.value += 1
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const stopTimer = () => {
|
||||
if (timer.value) {
|
||||
clearInterval(timer.value)
|
||||
timer.value = null
|
||||
startTime.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const resetTimer = () => {
|
||||
stopTimer()
|
||||
completionTime.value = null
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
completionTime,
|
||||
startTimer,
|
||||
stopTimer,
|
||||
resetTimer
|
||||
})
|
||||
</script>
|
||||
@@ -3,380 +3,399 @@
|
||||
v-if="form"
|
||||
class="open-complete-form"
|
||||
:dir="form?.layout_rtl ? 'rtl' : 'ltr'"
|
||||
:style="{ '--font-family': form.font_family, 'direction': form?.layout_rtl ? 'rtl' : 'ltr' }"
|
||||
:style="{ '--font-family': form.font_family, 'direction': form?.layout_rtl ? 'rtl' : 'ltr', '--form-color': form.color }"
|
||||
>
|
||||
<link
|
||||
v-if="formModeStrategy.display.showFontLink && form.font_family"
|
||||
rel="stylesheet"
|
||||
:href="getFontUrl"
|
||||
>
|
||||
|
||||
<div v-if="isPublicFormPage && form.is_password_protected">
|
||||
<p class="form-description mb-4 text-gray-700 dark:text-gray-300 px-2">
|
||||
{{ $t('forms.password_protected') }}
|
||||
</p>
|
||||
<div class="form-group flex flex-wrap w-full">
|
||||
<div class="relative mb-3 w-full px-2">
|
||||
<text-input
|
||||
:theme="theme"
|
||||
:form="passwordForm"
|
||||
name="password"
|
||||
native-type="password"
|
||||
label="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center w-full text-center">
|
||||
<open-form-button
|
||||
:theme="theme"
|
||||
:color="form.color"
|
||||
class="my-4"
|
||||
@click="passwordEntered"
|
||||
<ClientOnly>
|
||||
<Teleport to="head">
|
||||
<link
|
||||
v-if="showFontLink && form.font_family"
|
||||
:key="form.font_family"
|
||||
:href="getFontUrl"
|
||||
rel="stylesheet"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
>
|
||||
{{ $t('forms.submit') }}
|
||||
</open-form-button>
|
||||
</Teleport>
|
||||
</ClientOnly>
|
||||
|
||||
<v-transition name="fade" mode="out-in">
|
||||
<!-- Auto-submit loading state -->
|
||||
<div v-if="isAutoSubmit" key="auto-submit" class="text-center p-6">
|
||||
<Loader class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-transition name="fade">
|
||||
<div
|
||||
v-if="!form.is_password_protected && form.password && !hidePasswordDisabledMsg"
|
||||
class="m-2 my-4 flex flex-grow items-end p-4 rounded-md dark:text-yellow-500 bg-yellow-50 dark:bg-yellow-600/20 dark:border-yellow-500"
|
||||
>
|
||||
<p class="mb-0 text-yellow-600 dark:text-yellow-600 text-sm">
|
||||
We disabled the password protection for this form because you are an owner of it.
|
||||
</p>
|
||||
<UButton
|
||||
color="yellow"
|
||||
size="xs"
|
||||
@click="hidePasswordDisabledMsg = true"
|
||||
>
|
||||
Close
|
||||
</ubutton>
|
||||
</div>
|
||||
</v-transition>
|
||||
|
||||
<div
|
||||
v-if="isPublicFormPage && (form.is_closed || form.visibility=='closed')"
|
||||
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 dark:bg-yellow-600/20 border-yellow-500 dark:border-yellow-500/20"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<div
|
||||
class="mb-0 py-2 px-4 text-yellow-600"
|
||||
v-html="form.closed_text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isPublicFormPage && form.max_number_of_submissions_reached"
|
||||
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 dark:bg-yellow-600/20 border-yellow-500 dark:border-yellow-500/20"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<div
|
||||
class="mb-0 py-2 px-4 text-yellow-600 dark:text-yellow-600"
|
||||
v-html="form.max_submissions_reached_text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form-cleanings
|
||||
v-if="formModeStrategy.display.showFormCleanings"
|
||||
:hideable="true"
|
||||
class="mb-4 mx-2"
|
||||
:form="form"
|
||||
:specify-form-owner="true"
|
||||
/>
|
||||
|
||||
<v-transition name="fade">
|
||||
<div
|
||||
v-if="!submitted"
|
||||
key="form"
|
||||
>
|
||||
<open-form
|
||||
v-if="form && !form.is_closed"
|
||||
:form="form"
|
||||
:loading="loading"
|
||||
:fields="form.properties"
|
||||
:theme="theme"
|
||||
:dark-mode="darkMode"
|
||||
:mode="mode"
|
||||
@submit="submitForm"
|
||||
>
|
||||
<template #submit-btn="{submitForm: handleSubmit}">
|
||||
<!-- Main form content -->
|
||||
<div v-else key="form-content">
|
||||
<div v-if="isPublicFormPage && form.is_password_protected">
|
||||
<p class="form-description text-gray-700 dark:text-gray-300 px-2">
|
||||
{{ t('forms.password_protected') }}
|
||||
</p>
|
||||
<div class="form-group flex flex-wrap w-full">
|
||||
<div class="relative w-full px-2">
|
||||
<text-input
|
||||
:theme="theme"
|
||||
:form="passwordForm"
|
||||
name="password"
|
||||
native-type="password"
|
||||
label="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center w-full text-center">
|
||||
<open-form-button
|
||||
:loading="loading"
|
||||
:theme="theme"
|
||||
:color="form.color"
|
||||
class="mt-2 px-8 mx-1"
|
||||
:class="submitButtonClass"
|
||||
@click.prevent="handleSubmit"
|
||||
class="my-4"
|
||||
@click="passwordEntered"
|
||||
>
|
||||
{{ form.submit_button_text }}
|
||||
{{ t('forms.submit') }}
|
||||
</open-form-button>
|
||||
</template>
|
||||
</open-form>
|
||||
<p
|
||||
v-if="!form.no_branding"
|
||||
class="text-center w-full mt-2"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!form.is_password_protected && form.password && !hidePasswordDisabledMsg"
|
||||
class="m-2 my-4">
|
||||
<UAlert
|
||||
:close-button="{ icon: 'i-heroicons-x-mark-20-solid', color: 'gray', variant: 'link', padded: false }"
|
||||
color="yellow"
|
||||
variant="subtle"
|
||||
icon="i-material-symbols-info-outline"
|
||||
@close="hidePasswordDisabledMsg = true"
|
||||
title="Password protection has been disabled since you are the owner of this form."
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
v-if="isPublicFormPage && (form.is_closed || form.visibility=='closed')"
|
||||
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 dark:bg-yellow-600/20 border-yellow-500 dark:border-yellow-500/20"
|
||||
>
|
||||
<a
|
||||
href="https://opnform.com?utm_source=form&utm_content=powered_by"
|
||||
class="text-gray-400 hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-500 cursor-pointer hover:underline text-xs"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('forms.powered_by') }} <span class="font-semibold">{{ $t('app.name') }}</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
key="submitted"
|
||||
class="px-2"
|
||||
>
|
||||
<TextBlock
|
||||
v-if="form.submitted_text"
|
||||
class="form-description text-gray-700 dark:text-gray-300 whitespace-pre-wrap"
|
||||
:content="form.submitted_text"
|
||||
:mentions-allowed="true"
|
||||
<div class="flex-grow">
|
||||
<div
|
||||
class="mb-0 py-2 px-4 text-yellow-600"
|
||||
v-html="form.closed_text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="isPublicFormPage && form.max_number_of_submissions_reached"
|
||||
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 dark:bg-yellow-600/20 border-yellow-500 dark:border-yellow-500/20"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<div
|
||||
class="mb-0 py-2 px-4 text-yellow-600 dark:text-yellow-600"
|
||||
v-html="form.max_submissions_reached_text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form-cleanings
|
||||
v-if="showFormCleanings"
|
||||
:hideable="true"
|
||||
class="mb-4 mx-2"
|
||||
:form="form"
|
||||
:form-data="submittedData"
|
||||
:specify-form-owner="true"
|
||||
/>
|
||||
<open-form-button
|
||||
v-if="form.re_fillable"
|
||||
:theme="theme"
|
||||
:color="form.color"
|
||||
class="my-4"
|
||||
@click="restart"
|
||||
>
|
||||
{{ form.re_fill_button_text }}
|
||||
</open-form-button>
|
||||
<p
|
||||
v-if="form.editable_submissions && submissionId"
|
||||
class="mt-5"
|
||||
>
|
||||
<a
|
||||
target="_parent"
|
||||
:href="form.share_url+'?submission_id='+submissionId"
|
||||
class="text-nt-blue hover:underline"
|
||||
|
||||
<v-transition name="fade" v-if="form && !form.is_password_protected">
|
||||
<div
|
||||
v-if="!isFormSubmitted"
|
||||
key="form"
|
||||
>
|
||||
{{ form.editable_submissions_button_text }}
|
||||
</a>
|
||||
</p>
|
||||
<p
|
||||
v-if="!form.no_branding"
|
||||
class="mt-5"
|
||||
>
|
||||
<a
|
||||
target="_parent"
|
||||
href="https://opnform.com/?utm_source=form&utm_content=create_form_free"
|
||||
class="text-nt-blue hover:underline"
|
||||
<open-form
|
||||
v-if="formManager && form && shouldDisplayForm"
|
||||
:form-manager="formManager"
|
||||
:theme="theme"
|
||||
@submit="triggerSubmit"
|
||||
>
|
||||
<template #submit-btn="{loading}">
|
||||
<open-form-button
|
||||
:loading="loading || isProcessing"
|
||||
:theme="theme"
|
||||
:color="form.color"
|
||||
class="mt-2 px-8 mx-1"
|
||||
:class="submitButtonClass"
|
||||
@click.prevent="triggerSubmit"
|
||||
>
|
||||
{{ form.submit_button_text }}
|
||||
</open-form-button>
|
||||
</template>
|
||||
</open-form>
|
||||
<p
|
||||
v-if="!form.no_branding"
|
||||
class="text-center w-full mt-2"
|
||||
>
|
||||
<a
|
||||
href="https://opnform.com?utm_source=form&utm_content=powered_by"
|
||||
class="text-gray-400 hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-500 cursor-pointer hover:underline text-xs"
|
||||
target="_blank"
|
||||
>
|
||||
{{ t('forms.powered_by') }} <span class="font-semibold">{{ t('app.name') }}</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
key="submitted"
|
||||
class="px-2"
|
||||
>
|
||||
{{ $t('forms.create_form_free') }}
|
||||
</a>
|
||||
</p>
|
||||
<TextBlock
|
||||
v-if="form.submitted_text"
|
||||
class="form-description text-gray-700 dark:text-gray-300 whitespace-pre-wrap"
|
||||
:content="form.submitted_text"
|
||||
:mentions-allowed="true"
|
||||
:form="form"
|
||||
:form-data="submittedData"
|
||||
/>
|
||||
<open-form-button
|
||||
v-if="form.re_fillable"
|
||||
:theme="theme"
|
||||
:color="form.color"
|
||||
class="my-4"
|
||||
@click="restart"
|
||||
>
|
||||
{{ form.re_fill_button_text }}
|
||||
</open-form-button>
|
||||
<p
|
||||
v-if="form.editable_submissions && submissionId"
|
||||
class="mt-5"
|
||||
>
|
||||
<a
|
||||
target="_parent"
|
||||
:href="form.share_url+'?submission_id='+submissionId"
|
||||
class="text-nt-blue hover:underline"
|
||||
>
|
||||
{{ form.editable_submissions_button_text }}
|
||||
</a>
|
||||
</p>
|
||||
<p
|
||||
v-if="!form.no_branding"
|
||||
class="mt-5"
|
||||
>
|
||||
<a
|
||||
target="_parent"
|
||||
href="https://opnform.com/?utm_source=form&utm_content=create_form_free"
|
||||
class="text-nt-blue hover:underline"
|
||||
>
|
||||
{{ t('forms.create_form_free') }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</v-transition>
|
||||
<FirstSubmissionModal
|
||||
:show="showFirstSubmissionModal"
|
||||
:form="form"
|
||||
@close="showFirstSubmissionModal=false"
|
||||
/>
|
||||
</div>
|
||||
</v-transition>
|
||||
<FirstSubmissionModal
|
||||
:show="showFirstSubmissionModal"
|
||||
:form="form"
|
||||
@close="showFirstSubmissionModal=false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { useFormManager } from '~/lib/forms/composables/useFormManager'
|
||||
import { FormMode } from "~/lib/forms/FormModeStrategy.js"
|
||||
import ThemeBuilder from "~/lib/forms/themes/ThemeBuilder.js"
|
||||
import OpenForm from './OpenForm.vue'
|
||||
import OpenFormButton from './OpenFormButton.vue'
|
||||
import FormCleanings from '../../pages/forms/show/FormCleanings.vue'
|
||||
import VTransition from '~/components/global/transitions/VTransition.vue'
|
||||
import { pendingSubmission } from "~/composables/forms/pendingSubmission.js"
|
||||
import { usePartialSubmission } from "~/composables/forms/usePartialSubmission.js"
|
||||
import clonedeep from "clone-deep"
|
||||
import ThemeBuilder from "~/lib/forms/themes/ThemeBuilder.js"
|
||||
import FirstSubmissionModal from '~/components/open/forms/components/FirstSubmissionModal.vue'
|
||||
import { FormMode, createFormModeStrategy } from "~/lib/forms/FormModeStrategy.js"
|
||||
import TextBlock from '~/components/forms/TextBlock.vue'
|
||||
import { useForm } from '~/composables/useForm'
|
||||
import { useAlert } from '~/composables/useAlert'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useIsIframe } from '~/composables/useIsIframe'
|
||||
import Loader from '~/components/global/Loader.vue'
|
||||
|
||||
export default {
|
||||
components: { VTransition, OpenFormButton, OpenForm, FormCleanings, FirstSubmissionModal },
|
||||
|
||||
props: {
|
||||
form: { type: Object, required: true },
|
||||
mode: {
|
||||
type: String,
|
||||
default: FormMode.LIVE,
|
||||
validator: (value) => Object.values(FormMode).includes(value)
|
||||
},
|
||||
submitButtonClass: { type: String, default: '' },
|
||||
darkMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
const props = defineProps({
|
||||
form: { type: Object, required: true },
|
||||
mode: {
|
||||
type: String,
|
||||
default: FormMode.LIVE,
|
||||
validator: (value) => Object.values(FormMode).includes(value)
|
||||
},
|
||||
submitButtonClass: { type: String, default: '' },
|
||||
darkMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
emits: ['submitted', 'password-entered', 'restarted'],
|
||||
const emit = defineEmits(['submitted', 'password-entered', 'restarted'])
|
||||
|
||||
setup(props) {
|
||||
const { setLocale } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
return {
|
||||
setLocale,
|
||||
authStore,
|
||||
authenticated: computed(() => authStore.check),
|
||||
isIframe: useIsIframe(),
|
||||
pendingSubmission: pendingSubmission(props.form),
|
||||
partialSubmission: usePartialSubmission(props.form),
|
||||
confetti: useConfetti()
|
||||
}
|
||||
},
|
||||
const { t, setLocale } = useI18n()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const alert = useAlert()
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
submitted: false,
|
||||
passwordForm: useForm({
|
||||
password: null
|
||||
}),
|
||||
hidePasswordDisabledMsg: false,
|
||||
submissionId: false,
|
||||
submittedData: null,
|
||||
showFirstSubmissionModal: false
|
||||
}
|
||||
},
|
||||
const passwordForm = useForm({ password: null })
|
||||
const hidePasswordDisabledMsg = ref(false)
|
||||
const submissionId = ref(route.query.submission_id || null)
|
||||
const submittedData = ref(null)
|
||||
const showFirstSubmissionModal = ref(false)
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Gets the comprehensive strategy based on the form mode
|
||||
*/
|
||||
formModeStrategy() {
|
||||
return createFormModeStrategy(this.mode)
|
||||
},
|
||||
isEmbedPopup () {
|
||||
return import.meta.client && window.location.href.includes('popup=true')
|
||||
},
|
||||
theme () {
|
||||
return new ThemeBuilder(this.form.theme, {
|
||||
size: this.form.size,
|
||||
borderRadius: this.form.border_radius
|
||||
}).getAllComponents()
|
||||
},
|
||||
isPublicFormPage () {
|
||||
return this.$route.name === 'forms-slug'
|
||||
},
|
||||
getFontUrl() {
|
||||
if(!this.form || !this.form.font_family) return null
|
||||
const family = this.form?.font_family.replace(/ /g, '+')
|
||||
return `https://fonts.googleapis.com/css?family=${family}:wght@400,500,700,800,900&display=swap`
|
||||
},
|
||||
isFormOwner() {
|
||||
return this.authenticated && this.form && this.form.creator_id === this.authStore.user.id
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'form.language': {
|
||||
handler(newLanguage) {
|
||||
if (newLanguage && typeof newLanguage === 'string') {
|
||||
this.setLocale(newLanguage)
|
||||
} else {
|
||||
this.setLocale('en') // Default to English if invalid locale
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.setLocale('en')
|
||||
},
|
||||
// Check for auto_submit parameter during setup
|
||||
const isAutoSubmit = ref(import.meta.client && window.location.href.includes('auto_submit=true'))
|
||||
|
||||
methods: {
|
||||
submitForm (form, onFailure) {
|
||||
// Check if we should perform actual submission based on the mode
|
||||
if (!this.formModeStrategy.validation.performActualSubmission) {
|
||||
this.submitted = true
|
||||
this.$emit('submitted', true)
|
||||
return
|
||||
}
|
||||
// Create a reactive reference directly from the prop
|
||||
const darkModeRef = toRef(props, 'darkMode')
|
||||
|
||||
if (form.busy) return
|
||||
this.loading = true
|
||||
// Add back the local theme computation
|
||||
const theme = computed(() => {
|
||||
return new ThemeBuilder(props.form.theme, {
|
||||
size: props.form.size,
|
||||
borderRadius: props.form.border_radius
|
||||
}).getAllComponents()
|
||||
})
|
||||
|
||||
if (this.form?.enable_partial_submissions) {
|
||||
this.partialSubmission.stopSync()
|
||||
}
|
||||
let formManager = null
|
||||
if (props.form) {
|
||||
formManager = useFormManager(props.form, props.mode, {
|
||||
darkMode: darkModeRef
|
||||
})
|
||||
formManager.initialize({
|
||||
submissionId: submissionId,
|
||||
urlParams: import.meta.client ? new URLSearchParams(window.location.search) : null,
|
||||
})
|
||||
}
|
||||
|
||||
form.post('/forms/' + this.form.slug + '/answer').then((data) => {
|
||||
this.submittedData = form.data()
|
||||
useAmplitude().logEvent('form_submission', {
|
||||
workspace_id: this.form.workspace_id,
|
||||
form_id: this.form.id
|
||||
})
|
||||
|
||||
const payload = clonedeep({
|
||||
type: 'form-submitted',
|
||||
form: {
|
||||
slug: this.form.slug,
|
||||
id: this.form.id,
|
||||
redirect_target_url: (this.form.is_pro && data.redirect && data.redirect_url) ? data.redirect_url : null
|
||||
},
|
||||
submission_data: form.data(),
|
||||
completion_time: form['completion_time']
|
||||
// Share the structure service with the working form store only when in admin edit context
|
||||
watch(() => formManager?.strategy?.value?.admin?.showAdminControls, (showAdminControls) => {
|
||||
if (workingFormStore && formManager?.structure && showAdminControls) {
|
||||
workingFormStore.setStructureService(formManager.structure)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Add a watcher to update formManager's darkMode whenever darkModeRef changes
|
||||
watch(darkModeRef, (newDarkMode) => {
|
||||
if (formManager) {
|
||||
formManager.setDarkMode(newDarkMode)
|
||||
}
|
||||
})
|
||||
|
||||
// If auto_submit is true, trigger submit after component is mounted
|
||||
onMounted(() => {
|
||||
if (isAutoSubmit.value && formManager) {
|
||||
// Using nextTick to ensure form is fully rendered and initialized
|
||||
nextTick(() => {
|
||||
triggerSubmit()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const isPublicFormPage = computed(() => {
|
||||
return route.name === 'forms-slug'
|
||||
})
|
||||
|
||||
const getFontUrl = computed(() => {
|
||||
if(!props.form?.font_family) return null
|
||||
const family = props.form.font_family.replace(/ /g, '+')
|
||||
return `https://fonts.googleapis.com/css?family=${family}:wght@400,500,700,800,900&display=swap`
|
||||
})
|
||||
|
||||
const isFormOwner = computed(() => {
|
||||
return authStore.check && props.form && props.form.creator_id === authStore.user.id
|
||||
})
|
||||
|
||||
const isFormSubmitted = computed(() => formManager?.state.isSubmitted ?? false)
|
||||
const isProcessing = computed(() => formManager?.state.isProcessing ?? false)
|
||||
const showFormCleanings = computed(() => formManager?.strategy.value.display.showFormCleanings ?? false)
|
||||
const showFontLink = computed(() => formManager?.strategy.value.display.showFontLink ?? false)
|
||||
const shouldDisplayForm = computed(() => {
|
||||
return (!props.form.is_closed && !props.form.max_number_of_submissions_reached) || formManager?.strategy?.value.admin?.showAdminControls
|
||||
})
|
||||
|
||||
watch(() => props.form.language, (newLanguage) => {
|
||||
if (newLanguage && typeof newLanguage === 'string') {
|
||||
setLocale(newLanguage)
|
||||
} else {
|
||||
setLocale('en')
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
setLocale('en')
|
||||
})
|
||||
|
||||
const handleScrollToError = () => {
|
||||
if (import.meta.server) return
|
||||
|
||||
nextTick(() => {
|
||||
const firstErrorElement = document.querySelector('.form-group [error], .form-group .has-error')
|
||||
if (firstErrorElement) {
|
||||
const headerOffset = 60 // Offset for fixed headers, adjust as needed
|
||||
const elementPosition = firstErrorElement.getBoundingClientRect().top
|
||||
const offsetPosition = elementPosition + window.scrollY - headerOffset
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (this.isIframe) {
|
||||
window.parent.postMessage(payload, '*')
|
||||
}
|
||||
window.postMessage(payload, '*')
|
||||
this.pendingSubmission.remove()
|
||||
this.pendingSubmission.removeTimer()
|
||||
const triggerSubmit = async () => {
|
||||
if (!formManager || isProcessing.value) return
|
||||
|
||||
if (data.redirect && data.redirect_url) {
|
||||
window.location.href = data.redirect_url
|
||||
formManager.submit()
|
||||
.then(result => {
|
||||
if (result) {
|
||||
submittedData.value = result || {}
|
||||
|
||||
if (result?.submission_id) {
|
||||
submissionId.value = result.submission_id
|
||||
}
|
||||
|
||||
if (data.submission_id) {
|
||||
this.submissionId = data.submission_id
|
||||
if (isFormOwner.value && !useIsIframe() && result?.is_first_submission) {
|
||||
showFirstSubmissionModal.value = true
|
||||
}
|
||||
if (this.isFormOwner && !this.isIframe && data?.is_first_submission) {
|
||||
this.showFirstSubmissionModal = true
|
||||
}
|
||||
this.loading = false
|
||||
this.submitted = true
|
||||
this.$emit('submitted', true)
|
||||
|
||||
// If enabled display confetti
|
||||
if (this.form.confetti_on_submission) {
|
||||
this.confetti.play()
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (this.form?.enable_partial_submissions) {
|
||||
this.partialSubmission.startSync()
|
||||
}
|
||||
|
||||
console.error(error)
|
||||
if (error.response && error.data) {
|
||||
useAlert().formValidationError(error.data)
|
||||
}
|
||||
this.loading = false
|
||||
onFailure()
|
||||
})
|
||||
},
|
||||
restart () {
|
||||
this.submitted = false
|
||||
this.$emit('restarted', true)
|
||||
},
|
||||
passwordEntered () {
|
||||
if (this.passwordForm.password !== '' && this.passwordForm.password !== null) {
|
||||
this.$emit('password-entered', this.passwordForm.password)
|
||||
} else {
|
||||
this.addPasswordError(this.$t('forms.password_required'))
|
||||
|
||||
emit('submitted', true)
|
||||
} else {
|
||||
console.warn('Form submission failed via composable, but no error thrown?')
|
||||
alert.error(t('forms.submission_error'))
|
||||
}
|
||||
},
|
||||
addPasswordError (msg) {
|
||||
this.passwordForm.errors.set('password', msg)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.response && error.response.status === 422 && error.data) {
|
||||
useAlert().formValidationError(error.data)
|
||||
} else if (error.message) {
|
||||
useAlert().error(error.message)
|
||||
}
|
||||
handleScrollToError()
|
||||
}).finally(() => {
|
||||
isAutoSubmit.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const restart = async () => {
|
||||
if (!formManager) return
|
||||
await formManager.restart()
|
||||
submittedData.value = null
|
||||
submissionId.value = null
|
||||
emit('restarted', true)
|
||||
}
|
||||
|
||||
const passwordEntered = () => {
|
||||
if (passwordForm.password) {
|
||||
emit('password-entered', passwordForm.password)
|
||||
} else {
|
||||
addPasswordError(t('forms.password_required'))
|
||||
}
|
||||
}
|
||||
|
||||
const addPasswordError = (msg) => {
|
||||
passwordForm.errors.set('password', msg)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
addPasswordError
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
<template>
|
||||
<div v-if="isAutoSubmit">
|
||||
<p class="text-center p-4">
|
||||
<Loader class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
v-else-if="dataForm"
|
||||
:style="computedStyle"
|
||||
v-if="form"
|
||||
@submit.prevent=""
|
||||
>
|
||||
<FormTimer
|
||||
ref="form-timer"
|
||||
:form="form"
|
||||
/>
|
||||
<FormProgressbar
|
||||
:form="form"
|
||||
:fields="fields"
|
||||
:form-data="dataFormValue"
|
||||
:form-manager="formManager"
|
||||
:theme="theme"
|
||||
/>
|
||||
<transition
|
||||
name="fade"
|
||||
@@ -37,51 +26,36 @@
|
||||
ghost-class="ghost-item"
|
||||
filter=".not-draggable"
|
||||
:animation="200"
|
||||
:disabled="!formModeStrategy.admin.allowDragging"
|
||||
:disabled="!allowDragging"
|
||||
@change="handleDragDropped"
|
||||
>
|
||||
<template #item="{element}">
|
||||
<open-form-field
|
||||
:field="element"
|
||||
:show-hidden="showHidden"
|
||||
:form="form"
|
||||
:data-form="dataForm"
|
||||
:data-form-value="dataFormValue"
|
||||
:form-manager="formManager"
|
||||
:theme="theme"
|
||||
:dark-mode="darkMode"
|
||||
:mode="mode"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Captcha -->
|
||||
|
||||
<ClientOnly>
|
||||
<div v-if="form.use_captcha && isLastPage && hasCaptchaProviders && isCaptchaProviderAvailable" class="mb-3 px-2 mt-4 mx-auto w-max">
|
||||
<CaptchaInput
|
||||
ref="captcha"
|
||||
:provider="form.captcha_provider"
|
||||
:form="dataForm"
|
||||
:language="form.language"
|
||||
:dark-mode="darkMode"
|
||||
/>
|
||||
</div>
|
||||
<template #fallback>
|
||||
<USkeleton class="h-[78px] w-[304px]" />
|
||||
</template>
|
||||
</ClientOnly>
|
||||
<!-- Replace Captcha with CaptchaWrapper -->
|
||||
<CaptchaWrapper
|
||||
v-if="form.use_captcha"
|
||||
:form-manager="formManager"
|
||||
:theme="theme"
|
||||
/>
|
||||
|
||||
<!-- Submit, Next and previous buttons -->
|
||||
<div class="flex flex-wrap justify-center w-full">
|
||||
<open-form-button
|
||||
v-if="formPageIndex>0 && previousFieldsPageBreak && !loading"
|
||||
v-if="formPageIndex>0 && previousFieldsPageBreak"
|
||||
native-type="button"
|
||||
:color="form.color"
|
||||
:theme="theme"
|
||||
class="mt-2 px-8 mx-1"
|
||||
@click="previousPage"
|
||||
@click="handlePreviousClick"
|
||||
>
|
||||
{{ previousFieldsPageBreak.previous_btn_text }}
|
||||
</open-form-button>
|
||||
@@ -89,8 +63,7 @@
|
||||
<slot
|
||||
v-if="isLastPage"
|
||||
name="submit-btn"
|
||||
:submit-form="submitForm"
|
||||
:loading="dataForm.busy"
|
||||
:loading="form.busy"
|
||||
/>
|
||||
<open-form-button
|
||||
v-else-if="currentFieldsPageBreak"
|
||||
@@ -98,8 +71,8 @@
|
||||
:color="form.color"
|
||||
:theme="theme"
|
||||
class="mt-2 px-8 mx-1"
|
||||
:loading="dataForm.busy"
|
||||
@click.stop="nextPage"
|
||||
:loading="form.busy"
|
||||
@click.stop="handleNextClick"
|
||||
>
|
||||
{{ currentFieldsPageBreak.next_btn_text }}
|
||||
</open-form-button>
|
||||
@@ -107,7 +80,7 @@
|
||||
{{ $t('forms.wrong_form_structure') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="paymentBlock"
|
||||
v-if="hasPaymentBlock"
|
||||
class="mt-6 flex justify-center w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 flex text-center max-w-md">
|
||||
@@ -118,761 +91,76 @@
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import draggable from 'vuedraggable'
|
||||
import OpenFormButton from './OpenFormButton.vue'
|
||||
import CaptchaInput from '~/components/forms/components/CaptchaInput.vue'
|
||||
import CaptchaWrapper from '~/components/forms/components/CaptchaWrapper.vue'
|
||||
import OpenFormField from './OpenFormField.vue'
|
||||
import { pendingSubmission } from "~/composables/forms/pendingSubmission.js"
|
||||
import { usePartialSubmission } from "~/composables/forms/usePartialSubmission.js"
|
||||
import FormLogicPropertyResolver from "~/lib/forms/FormLogicPropertyResolver.js"
|
||||
import CachedDefaultTheme from "~/lib/forms/themes/CachedDefaultTheme.js"
|
||||
import FormTimer from './FormTimer.vue'
|
||||
import FormProgressbar from './FormProgressbar.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { FormMode, createFormModeStrategy } from "~/lib/forms/FormModeStrategy.js"
|
||||
import clonedeep from 'clone-deep'
|
||||
import { provideStripeElements } from '~/composables/useStripeElements'
|
||||
import { useWorkingFormStore } from '~/stores/working_form'
|
||||
|
||||
export default {
|
||||
name: 'OpenForm',
|
||||
components: {draggable, OpenFormField, OpenFormButton, CaptchaInput, FormTimer, FormProgressbar},
|
||||
props: {
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
theme: {
|
||||
type: Object, default: () => {
|
||||
const theme = inject("theme", null)
|
||||
if (theme) {
|
||||
return theme.value
|
||||
}
|
||||
return CachedDefaultTheme.getInstance()
|
||||
}
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
defaultDataForm: { type: [Object, null] },
|
||||
mode: {
|
||||
type: String,
|
||||
default: FormMode.LIVE,
|
||||
validator: (value) => Object.values(FormMode).includes(value)
|
||||
},
|
||||
darkMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['submit'],
|
||||
setup(props) {
|
||||
const recordsStore = useRecordsStore()
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
const dataForm = ref(useForm())
|
||||
const config = useRuntimeConfig()
|
||||
const props = defineProps({
|
||||
formManager: { type: Object, required: true },
|
||||
theme: { type: Object, required: true }
|
||||
})
|
||||
|
||||
// Provide Stripe elements to be used by child components
|
||||
const stripeElements = provideStripeElements()
|
||||
|
||||
const hasCaptchaProviders = computed(() => {
|
||||
return config.public.hCaptchaSiteKey || config.public.recaptchaSiteKey
|
||||
})
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
|
||||
return {
|
||||
dataForm,
|
||||
recordsStore,
|
||||
workingFormStore,
|
||||
stripeElements,
|
||||
isIframe: useIsIframe(),
|
||||
draggingNewBlock: computed(() => workingFormStore.draggingNewBlock),
|
||||
pendingSubmission: import.meta.client ? pendingSubmission(props.form) : { get: () => ({}), set: () => {} },
|
||||
partialSubmission: import.meta.client ? usePartialSubmission(props.form, dataForm) : { startSync: () => {}, stopSync: () => {} },
|
||||
formPageIndex: storeToRefs(workingFormStore).formPageIndex,
|
||||
// Derive everything from formManager
|
||||
const state = computed(() => props.formManager.state)
|
||||
const form = computed(() => props.formManager.config.value)
|
||||
const formPageIndex = computed(() => props.formManager.state.currentPage)
|
||||
const strategy = computed(() => props.formManager.strategy.value)
|
||||
const structure = computed(() => props.formManager.structure)
|
||||
|
||||
// Used for admin previews
|
||||
selectedFieldIndex: computed(() => workingFormStore.selectedFieldIndex),
|
||||
showEditFieldSidebar: computed(() => workingFormStore.showEditFieldSidebar),
|
||||
hasCaptchaProviders
|
||||
}
|
||||
},
|
||||
const hasPaymentBlock = computed(() => {
|
||||
return structure.value?.currentPageHasPaymentBlock.value ?? false
|
||||
})
|
||||
|
||||
data() {
|
||||
return {
|
||||
isAutoSubmit: false,
|
||||
partialSubmissionStarted: false,
|
||||
isInitialLoad: true,
|
||||
// Flag to prevent recursion in field group updates
|
||||
isUpdatingFieldGroups: false,
|
||||
}
|
||||
},
|
||||
const currentFields = computed(() => {
|
||||
return structure.value?.getPageFields(state.value.currentPage) ?? []
|
||||
})
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Create field groups (or Page) using page breaks if any
|
||||
*/
|
||||
fieldGroups() {
|
||||
if (!this.fields) return []
|
||||
const groups = []
|
||||
let currentGroup = []
|
||||
this.fields.forEach((field) => {
|
||||
if (field.type === 'nf-page-break' && this.isFieldHidden(field)) return
|
||||
currentGroup.push(field)
|
||||
if (field.type === 'nf-page-break') {
|
||||
groups.push(currentGroup)
|
||||
currentGroup = []
|
||||
}
|
||||
})
|
||||
groups.push(currentGroup)
|
||||
return groups
|
||||
},
|
||||
/**
|
||||
* Gets the comprehensive strategy based on the form mode
|
||||
*/
|
||||
formModeStrategy() {
|
||||
return createFormModeStrategy(this.mode)
|
||||
},
|
||||
/**
|
||||
* Determines if hidden fields should be shown based on the mode
|
||||
*/
|
||||
showHidden() {
|
||||
return this.formModeStrategy.display.showHiddenFields
|
||||
},
|
||||
/**
|
||||
* Determines if the form is in admin preview mode
|
||||
*/
|
||||
isAdminPreview() {
|
||||
return this.formModeStrategy.admin.showAdminControls
|
||||
},
|
||||
currentFields: {
|
||||
get() {
|
||||
return this.fieldGroups[this.formPageIndex]
|
||||
},
|
||||
set(val) {
|
||||
// On re-order from the form, set the new order
|
||||
// Add the previous groups and next to val, and set the properties on working form
|
||||
const newFields = []
|
||||
this.fieldGroups.forEach((group, index) => {
|
||||
if (index < this.formPageIndex) {
|
||||
newFields.push(...group)
|
||||
} else if (index === this.formPageIndex) {
|
||||
newFields.push(...val)
|
||||
} else {
|
||||
newFields.push(...group)
|
||||
}
|
||||
})
|
||||
// set the properties on working_form store
|
||||
this.workingFormStore.setProperties(newFields)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Returns the page break block for the current group of fields
|
||||
*/
|
||||
currentFieldsPageBreak() {
|
||||
// Last block from current group
|
||||
if (!this.currentFields?.length) return null
|
||||
const block = this.currentFields[this.currentFields.length - 1]
|
||||
if (block && block.type === 'nf-page-break') return block
|
||||
return null
|
||||
},
|
||||
previousFieldsPageBreak() {
|
||||
if (this.formPageIndex === 0) return null
|
||||
const previousFields = this.fieldGroups[this.formPageIndex - 1]
|
||||
const block = previousFields[previousFields.length - 1]
|
||||
if (block && block.type === 'nf-page-break') return block
|
||||
return null
|
||||
},
|
||||
/**
|
||||
* Returns true if we're on the last page
|
||||
* @returns {boolean}xs
|
||||
*/
|
||||
isLastPage() {
|
||||
return this.formPageIndex === (this.fieldGroups.length - 1)
|
||||
},
|
||||
isPublicFormPage() {
|
||||
return this.$route.name === 'forms-slug'
|
||||
},
|
||||
dataFormValue() {
|
||||
// For get values instead of Id for select/multi select options
|
||||
const data = this.dataForm.data()
|
||||
const selectionFields = this.fields.filter((field) => {
|
||||
return ['select', 'multi_select'].includes(field.type)
|
||||
})
|
||||
selectionFields.forEach((field) => {
|
||||
if (data[field.id] !== undefined && data[field.id] !== null && Array.isArray(data[field.id])) {
|
||||
data[field.id] = data[field.id].map(option_nfid => {
|
||||
const tmpop = field[field.type].options.find((op) => {
|
||||
return (op.id === option_nfid)
|
||||
})
|
||||
return (tmpop) ? tmpop.name : option_nfid
|
||||
})
|
||||
}
|
||||
})
|
||||
return data
|
||||
},
|
||||
computedStyle() {
|
||||
return {
|
||||
'--form-color': this.form.color
|
||||
}
|
||||
},
|
||||
paymentBlock() {
|
||||
return (this.currentFields) ? this.currentFields.find(field => field.type === 'payment') : null
|
||||
},
|
||||
isCaptchaProviderAvailable() {
|
||||
const config = useRuntimeConfig()
|
||||
if (this.form.captcha_provider === 'recaptcha') {
|
||||
return !!config.public.recaptchaSiteKey
|
||||
} else if (this.form.captcha_provider === 'hcaptcha') {
|
||||
return !!config.public.hCaptchaSiteKey
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
const isLastPage = computed(() => {
|
||||
const result = structure.value?.isLastPage.value ?? true
|
||||
return result
|
||||
})
|
||||
|
||||
watch: {
|
||||
// Monitor only critical changes that require full reinitialization
|
||||
'form.database_id': function() {
|
||||
// Only reinitialize when database changes
|
||||
this.initForm()
|
||||
},
|
||||
'fields.length': function() {
|
||||
// Only reinitialize when fields are added or removed
|
||||
this.updateFieldGroupsSafely()
|
||||
},
|
||||
// Watch for changes to individual field properties
|
||||
'fields': {
|
||||
deep: true,
|
||||
handler() {
|
||||
// Skip update if only triggered by internal fieldGroups changes
|
||||
if (this.isUpdatingFieldGroups) return
|
||||
|
||||
// Safely update field groups without causing recursive updates
|
||||
this.updateFieldGroupsSafely()
|
||||
}
|
||||
},
|
||||
dataFormValue: {
|
||||
deep: true,
|
||||
handler(newValue, oldValue) {
|
||||
if (this.isPublicFormPage && this.form && this.form.auto_save) {
|
||||
this.pendingSubmission.set(this.dataFormValue)
|
||||
}
|
||||
// Start partial submission sync on first form change
|
||||
if (!this.adminPreview && this.form?.enable_partial_submissions && oldValue && Object.keys(oldValue).length > 0 && !this.partialSubmissionStarted) {
|
||||
this.partialSubmission.startSync()
|
||||
this.partialSubmissionStarted = true
|
||||
}
|
||||
}
|
||||
},
|
||||
const currentFieldsPageBreak = computed(() =>
|
||||
structure.value?.currentPageBreak.value
|
||||
)
|
||||
const previousFieldsPageBreak = computed(() =>
|
||||
structure.value?.previousPageBreak.value
|
||||
)
|
||||
|
||||
// These watchers ensure the form shows the correct page for the field being edited in admin preview
|
||||
selectedFieldIndex: {
|
||||
handler(newIndex) {
|
||||
if (this.isAdminPreview && this.showEditFieldSidebar) {
|
||||
this.setPageForField(newIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
showEditFieldSidebar: {
|
||||
handler(newValue) {
|
||||
if (this.isAdminPreview && newValue) {
|
||||
this.setPageForField(this.selectedFieldIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
this.initForm()
|
||||
},
|
||||
mounted() {
|
||||
nextTick(() => {
|
||||
if (this.$refs['form-timer']) {
|
||||
this.$refs['form-timer'].startTimer()
|
||||
}
|
||||
})
|
||||
if (import.meta.client && window.location.href.includes('auto_submit=true')) {
|
||||
this.isAutoSubmit = true
|
||||
this.submitForm()
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (!this.adminPreview && this.form?.enable_partial_submissions) {
|
||||
this.partialSubmission.stopSync()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submitForm() {
|
||||
try {
|
||||
// Process payment if needed
|
||||
if (!await this.doPayment()) {
|
||||
return false // Payment failed or was required but not completed
|
||||
}
|
||||
this.dataForm.busy = false
|
||||
const allowDragging = computed(() => strategy.value.admin.allowDragging)
|
||||
const draggingNewBlock = computed(() => workingFormStore.draggingNewBlock)
|
||||
|
||||
// Add submission_id for editable submissions (from main)
|
||||
if (this.form.editable_submissions && this.form.submission_id) {
|
||||
this.dataForm.submission_id = this.form.submission_id
|
||||
}
|
||||
const handlePreviousClick = () => {
|
||||
props.formManager.previousPage()
|
||||
if (import.meta.client) window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// Stop timer and get completion time (from main)
|
||||
this.$refs['form-timer'].stopTimer()
|
||||
this.dataForm.completion_time = this.$refs['form-timer'].completionTime
|
||||
const handleNextClick = async () => {
|
||||
await props.formManager.nextPage()
|
||||
if (import.meta.client) window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// Add submission hash for partial submissions (from HEAD)
|
||||
if (this.form?.enable_partial_submissions) {
|
||||
this.dataForm.submission_hash = this.partialSubmission.getSubmissionHash()
|
||||
}
|
||||
const handleDragDropped = (data) => {
|
||||
if (!structure.value) return
|
||||
|
||||
// Add validation strategy check (from main)
|
||||
if (!this.formModeStrategy.validation.validateOnSubmit) {
|
||||
this.$emit('submit', this.dataForm, this.onSubmissionFailure)
|
||||
return
|
||||
}
|
||||
const getAbsoluteIndex = (relativeIndex) => {
|
||||
return structure.value.getTargetDropIndex(relativeIndex, state.value.currentPage)
|
||||
}
|
||||
|
||||
this.$emit('submit', this.dataForm, this.onSubmissionFailure)
|
||||
} catch (error) {
|
||||
this.handleValidationError(error)
|
||||
} finally {
|
||||
this.dataForm.busy = false
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handle form submission failure
|
||||
*/
|
||||
onSubmissionFailure() {
|
||||
this.$refs['form-timer'].startTimer()
|
||||
this.isAutoSubmit = false
|
||||
this.dataForm.busy = false
|
||||
|
||||
if (this.fieldGroups.length > 1) {
|
||||
this.showFirstPageWithError()
|
||||
}
|
||||
this.scrollToFirstError()
|
||||
},
|
||||
showFirstPageWithError() {
|
||||
for (let i = 0; i < this.fieldGroups.length; i++) {
|
||||
if (this.fieldGroups[i].some(field => this.dataForm.errors.has(field.id))) {
|
||||
this.formPageIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollToFirstError() {
|
||||
if (import.meta.server) return
|
||||
const firstErrorElement = document.querySelector('.has-error')
|
||||
if (firstErrorElement) {
|
||||
window.scroll({
|
||||
top: window.scrollY + firstErrorElement.getBoundingClientRect().top - 60,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
},
|
||||
async getSubmissionData() {
|
||||
if (!this.form || !this.form.editable_submissions || !this.form.submission_id) {
|
||||
return null
|
||||
}
|
||||
await this.recordsStore.loadRecord(
|
||||
opnFetch('/forms/' + this.form.slug + '/submissions/' + this.form.submission_id).then((data) => {
|
||||
return {submission_id: this.form.submission_id, id: this.form.submission_id, ...data.data}
|
||||
}).catch((error) => {
|
||||
if (error?.data?.errors) {
|
||||
useAlert().formValidationError(error.data)
|
||||
} else {
|
||||
useAlert().error(error?.data?.message || 'Something went wrong')
|
||||
}
|
||||
return null
|
||||
})
|
||||
)
|
||||
return this.recordsStore.getByKey(this.form.submission_id)
|
||||
},
|
||||
|
||||
/**
|
||||
* Form initialization
|
||||
*/
|
||||
async initForm() {
|
||||
// Only do a full initialization when necessary
|
||||
// Store current page index and form data to avoid overwriting existing values
|
||||
const currentFormData = this.dataForm ? clonedeep(this.dataForm.data()) : {}
|
||||
|
||||
// Handle special cases first
|
||||
if (this.defaultDataForm) {
|
||||
// If we have default data form, initialize with that
|
||||
await nextTick(() => {
|
||||
this.dataForm.resetAndFill(this.defaultDataForm)
|
||||
})
|
||||
this.updateFieldGroupsSafely()
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize the field groups without resetting form data
|
||||
this.updateFieldGroupsSafely()
|
||||
|
||||
// Check if we need to handle form submission states
|
||||
if (await this.checkForEditableSubmission()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.checkForPendingSubmission()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Standard initialization with default values
|
||||
this.initFormWithDefaultValues(currentFormData)
|
||||
},
|
||||
|
||||
checkForEditableSubmission() {
|
||||
return this.tryInitFormFromEditableSubmission()
|
||||
},
|
||||
|
||||
checkForPendingSubmission() {
|
||||
if (this.tryInitFormFromPendingSubmission()) {
|
||||
this.updateFieldGroupsSafely()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
async tryInitFormFromEditableSubmission() {
|
||||
if (this.isPublicFormPage && this.form.editable_submissions) {
|
||||
const submissionId = useRoute().query?.submission_id
|
||||
if (submissionId) {
|
||||
this.form.submission_id = submissionId
|
||||
const data = await this.getSubmissionData()
|
||||
if (data) {
|
||||
this.dataForm.resetAndFill(data)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
tryInitFormFromPendingSubmission() {
|
||||
if (!this.pendingSubmission || !this.isPublicFormPage || !this.form.auto_save) {
|
||||
return false
|
||||
}
|
||||
|
||||
const pendingData = this.pendingSubmission.get()
|
||||
if (pendingData && Object.keys(pendingData).length !== 0) {
|
||||
this.updatePendingDataFields(pendingData)
|
||||
this.dataForm.resetAndFill(pendingData)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
updatePendingDataFields(pendingData) {
|
||||
this.fields.forEach(field => {
|
||||
if (field.type === 'date' && field.prefill_today) {
|
||||
pendingData[field.id] = new Date().toISOString()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
initFormWithDefaultValues(currentFormData = {}) {
|
||||
// Only set page 0 on first load, otherwise maintain current position
|
||||
if (this.formPageIndex === undefined || this.isInitialLoad) {
|
||||
this.formPageIndex = 0
|
||||
this.isInitialLoad = false
|
||||
}
|
||||
|
||||
// Initialize form data with default values
|
||||
const formData = { ...currentFormData }
|
||||
const urlPrefill = this.isPublicFormPage ? new URLSearchParams(window.location.search) : null
|
||||
|
||||
this.fields.forEach(field => {
|
||||
if (field.type.startsWith('nf-') && !['nf-page-body-input', 'nf-page-logo', 'nf-page-cover'].includes(field.type)) return
|
||||
|
||||
this.handleUrlPrefill(field, formData, urlPrefill)
|
||||
this.handleDefaultPrefill(field, formData)
|
||||
})
|
||||
|
||||
// Reset form with new data
|
||||
this.dataForm.resetAndFill(formData)
|
||||
},
|
||||
handleUrlPrefill(field, formData, urlPrefill) {
|
||||
if (!urlPrefill) return
|
||||
|
||||
const prefillValue = (() => {
|
||||
const val = urlPrefill.get(field.id)
|
||||
try {
|
||||
return typeof val === 'string' && val.startsWith('{') ? JSON.parse(val) : val
|
||||
} catch (e) {
|
||||
return val
|
||||
}
|
||||
})()
|
||||
const arrayPrefillValue = urlPrefill.getAll(field.id + '[]')
|
||||
|
||||
if (typeof prefillValue === 'object' && prefillValue !== null) {
|
||||
formData[field.id] = { ...prefillValue }
|
||||
} else if (prefillValue !== null) {
|
||||
formData[field.id] = field.type === 'checkbox' ? this.parseBooleanValue(prefillValue) : prefillValue
|
||||
} else if (arrayPrefillValue.length > 0) {
|
||||
formData[field.id] = arrayPrefillValue
|
||||
}
|
||||
},
|
||||
parseBooleanValue(value) {
|
||||
return value === 'true' || value === '1'
|
||||
},
|
||||
handleDefaultPrefill(field, formData) {
|
||||
if (field.type === 'date' && field.prefill_today) {
|
||||
formData[field.id] = new Date().toISOString()
|
||||
} else if (field.type === 'matrix' && !formData[field.id]) {
|
||||
formData[field.id] = {...field.prefill}
|
||||
} else if (!(field.id in formData)) {
|
||||
formData[field.id] = field.prefill
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Page Navigation
|
||||
*/
|
||||
previousPage() {
|
||||
this.formPageIndex--
|
||||
this.scrollToTop()
|
||||
},
|
||||
async nextPage() {
|
||||
if (!this.formModeStrategy.validation.validateOnNextPage) {
|
||||
if (!this.isLastPage) {
|
||||
this.formPageIndex++
|
||||
}
|
||||
this.scrollToTop()
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
this.dataForm.busy = true
|
||||
const fieldsToValidate = this.currentFields
|
||||
.filter(f => f.type !== 'payment')
|
||||
.map(f => f.id)
|
||||
|
||||
// Validate non-payment fields first
|
||||
if (fieldsToValidate.length > 0) {
|
||||
await this.dataForm.validate('POST', `/forms/${this.form.slug}/answer`, {}, fieldsToValidate)
|
||||
}
|
||||
|
||||
// Process payment if needed
|
||||
if (!await this.doPayment()) {
|
||||
return false // Payment failed or was required but not completed
|
||||
}
|
||||
|
||||
// If validation and payment are successful, proceed
|
||||
if (!this.isLastPage) {
|
||||
this.formPageIndex++
|
||||
this.scrollToTop()
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
this.handleValidationError(error)
|
||||
return false
|
||||
} finally {
|
||||
this.dataForm.busy = false
|
||||
}
|
||||
},
|
||||
async doPayment() {
|
||||
// Check if there's a payment block in the current step
|
||||
if (!this.paymentBlock) {
|
||||
return true // No payment needed for this step
|
||||
}
|
||||
this.dataForm.busy = true
|
||||
|
||||
// Use the stripeElements from setup instead of calling useStripeElements
|
||||
const { state: stripeState, processPayment, isCardPopulated, isReadyForPayment } = this.stripeElements
|
||||
|
||||
// Skip if payment is already processed in the stripe state
|
||||
if (stripeState.intentId) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Skip if payment ID already exists in the form data
|
||||
const paymentFieldValue = this.dataFormValue[this.paymentBlock.id]
|
||||
if (paymentFieldValue && typeof paymentFieldValue === 'string' && paymentFieldValue.startsWith('pi_')) {
|
||||
// If we have a valid payment intent ID in the form data, sync it to the stripe state
|
||||
stripeState.intentId = paymentFieldValue
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for the stripe object itself, not just the ready flag
|
||||
if (stripeState.isStripeInstanceReady && !stripeState.stripe) {
|
||||
stripeState.isStripeInstanceReady = false
|
||||
}
|
||||
|
||||
// Only process payment if required or card has data
|
||||
const shouldProcessPayment = this.paymentBlock.required || isCardPopulated.value
|
||||
|
||||
if (shouldProcessPayment) {
|
||||
// If not ready yet, try a brief wait
|
||||
if (!isReadyForPayment.value) {
|
||||
try {
|
||||
this.dataForm.busy = true
|
||||
|
||||
// Just wait a second to see if state updates (it should be reactive now)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// Check if we're ready now
|
||||
if (!isReadyForPayment.value) {
|
||||
// Provide detailed diagnostics
|
||||
let errorMsg = 'Payment system not ready. '
|
||||
const details = []
|
||||
|
||||
if (!stripeState.stripeAccountId) {
|
||||
details.push('No Stripe account connected')
|
||||
}
|
||||
|
||||
if (!stripeState.isStripeInstanceReady) {
|
||||
details.push('Stripe.js not initialized')
|
||||
}
|
||||
|
||||
if (!stripeState.isCardElementReady) {
|
||||
details.push('Card element not initialized')
|
||||
}
|
||||
|
||||
errorMsg += details.join(', ') + '. Please refresh and try again.'
|
||||
useAlert().error(errorMsg)
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
return false
|
||||
} finally {
|
||||
this.dataForm.busy = false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.dataForm.busy = true
|
||||
const result = await processPayment(this.form.slug, this.paymentBlock.required)
|
||||
|
||||
if (!result.success) {
|
||||
// Handle payment error
|
||||
if (result.error?.message) {
|
||||
this.dataForm.errors.set(this.paymentBlock.id, result.error.message)
|
||||
useAlert().error(result.error.message)
|
||||
} else {
|
||||
useAlert().error('Payment processing failed. Please try again.')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Payment successful
|
||||
if (result.paymentIntent?.status === 'succeeded') {
|
||||
useAlert().success('Thank you! Your payment is successful.')
|
||||
return true
|
||||
}
|
||||
|
||||
// Fallback error
|
||||
useAlert().error('Something went wrong with the payment. Please try again.')
|
||||
return false
|
||||
} catch (error) {
|
||||
useAlert().error(error?.message || 'Payment failed')
|
||||
return false
|
||||
} finally {
|
||||
this.dataForm.busy = false
|
||||
}
|
||||
}
|
||||
|
||||
return true // Payment not required or no card data
|
||||
},
|
||||
scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
},
|
||||
handleValidationError(error) {
|
||||
console.error(error)
|
||||
if (error?.data) {
|
||||
useAlert().formValidationError(error.data)
|
||||
}
|
||||
this.dataForm.busy = false
|
||||
},
|
||||
isFieldHidden(field) {
|
||||
return (new FormLogicPropertyResolver(field, this.dataFormValue)).isHidden()
|
||||
},
|
||||
getTargetFieldIndex(currentFieldPageIndex) {
|
||||
return this.formPageIndex > 0
|
||||
? this.fieldGroups.slice(0, this.formPageIndex).reduce((sum, group) => sum + group.length, 0) + currentFieldPageIndex
|
||||
: currentFieldPageIndex
|
||||
},
|
||||
handleDragDropped(data) {
|
||||
if (data.added) {
|
||||
const targetIndex = this.getTargetFieldIndex(data.added.newIndex)
|
||||
this.workingFormStore.addBlock(data.added.element, targetIndex, false)
|
||||
}
|
||||
if (data.moved) {
|
||||
const oldTargetIndex = this.getTargetFieldIndex(data.moved.oldIndex)
|
||||
const newTargetIndex = this.getTargetFieldIndex(data.moved.newIndex)
|
||||
this.workingFormStore.moveField(oldTargetIndex, newTargetIndex)
|
||||
}
|
||||
},
|
||||
setPageForField(fieldIndex) {
|
||||
if (fieldIndex === -1) return
|
||||
|
||||
let currentIndex = 0
|
||||
for (let i = 0; i < this.fieldGroups.length; i++) {
|
||||
currentIndex += this.fieldGroups[i].length
|
||||
if (currentIndex > fieldIndex) {
|
||||
this.formPageIndex = i
|
||||
return
|
||||
}
|
||||
}
|
||||
this.formPageIndex = this.fieldGroups.length - 1
|
||||
},
|
||||
|
||||
// New method for updating field groups
|
||||
updateFieldGroups() {
|
||||
if (!this.fields || this.fields.length === 0) return
|
||||
|
||||
// Preserve the current page index if possible
|
||||
const currentPageIndex = this.formPageIndex
|
||||
|
||||
// Use a local variable instead of directly modifying computed property
|
||||
// We'll use this to determine totalPages and currentPageIndex
|
||||
const calculatedGroups = this.fields.reduce((groups, field, index) => {
|
||||
// If the field is a page break, start a new group
|
||||
if (field.type === 'nf-page-break' && index !== 0) {
|
||||
groups.push([])
|
||||
}
|
||||
// Add the field to the current group
|
||||
if (groups.length === 0) groups.push([])
|
||||
groups[groups.length - 1].push(field)
|
||||
return groups
|
||||
}, [])
|
||||
|
||||
// If we don't have any groups (shouldn't happen), create a default group
|
||||
if (calculatedGroups.length === 0) {
|
||||
calculatedGroups.push([])
|
||||
}
|
||||
|
||||
// Update page navigation
|
||||
const totalPages = calculatedGroups.length
|
||||
|
||||
// Try to maintain the current page index if valid
|
||||
if (currentPageIndex !== undefined && currentPageIndex < totalPages) {
|
||||
this.formPageIndex = currentPageIndex
|
||||
} else {
|
||||
this.formPageIndex = 0
|
||||
}
|
||||
|
||||
// Force a re-render of the component, which will update fieldGroups computed property
|
||||
this.$forceUpdate()
|
||||
},
|
||||
|
||||
// Helper method to prevent recursive updates
|
||||
updateFieldGroupsSafely() {
|
||||
// Set flag to prevent recursive updates
|
||||
this.isUpdatingFieldGroups = true
|
||||
|
||||
// Call the actual update method
|
||||
this.updateFieldGroups()
|
||||
|
||||
// Clear the flag after a short delay to allow Vue to process the update
|
||||
this.$nextTick(() => {
|
||||
this.isUpdatingFieldGroups = false
|
||||
})
|
||||
}
|
||||
if (data.added) {
|
||||
const targetIndex = getAbsoluteIndex(data.added.newIndex)
|
||||
workingFormStore.addBlock(data.added.element, targetIndex, false)
|
||||
}
|
||||
if (data.moved) {
|
||||
const oldTargetIndex = getAbsoluteIndex(data.moved.oldIndex)
|
||||
const newTargetIndex = getAbsoluteIndex(data.moved.newIndex)
|
||||
workingFormStore.moveField(oldTargetIndex, newTargetIndex)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -147,294 +147,257 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {computed} from 'vue'
|
||||
<script setup>
|
||||
import FormLogicPropertyResolver from "~/lib/forms/FormLogicPropertyResolver.js"
|
||||
import CachedDefaultTheme from "~/lib/forms/themes/CachedDefaultTheme.js"
|
||||
import {default as _has} from 'lodash/has'
|
||||
import { default as _has } from 'lodash/has'
|
||||
import { FormMode, createFormModeStrategy } from "~/lib/forms/FormModeStrategy.js"
|
||||
import { useWorkingFormStore } from '~/stores/working_form'
|
||||
|
||||
export default {
|
||||
name: 'OpenFormField',
|
||||
components: {},
|
||||
props: {
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
dataForm: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
dataFormValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
theme: {
|
||||
type: Object, default: () => {
|
||||
const theme = inject("theme", null)
|
||||
if (theme) {
|
||||
return theme.value
|
||||
// Define props
|
||||
const props = defineProps({
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
formManager: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
theme: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// Derive everything from formManager
|
||||
const form = computed(() => props.formManager?.config?.value || {})
|
||||
const dataForm = computed(() => props.formManager?.form || {})
|
||||
const darkMode = computed(() => props.formManager?.darkMode?.value || false)
|
||||
const showHidden = computed(() => props.formManager?.strategy?.value?.display?.showHiddenFields || false)
|
||||
|
||||
// Setup stores and reactive state
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
const selectedFieldIndex = computed(() => workingFormStore.selectedFieldIndex)
|
||||
const showEditFieldSidebar = computed(() => workingFormStore.showEditFieldSidebar)
|
||||
const strategy = computed(() => props.formManager?.strategy?.value || createFormModeStrategy(FormMode.LIVE))
|
||||
const isAdminPreview = computed(() => strategy.value?.admin?.showAdminControls || false)
|
||||
|
||||
// Computed properties
|
||||
const getFieldComponents = computed(() => {
|
||||
const field = props.field
|
||||
if (field.type === 'text' && field.multi_lines) {
|
||||
return 'TextAreaInput'
|
||||
}
|
||||
if (field.type === 'url' && field.file_upload) {
|
||||
return 'FileInput'
|
||||
}
|
||||
if (['select', 'multi_select'].includes(field.type) && field.without_dropdown) {
|
||||
return 'FlatSelectInput'
|
||||
}
|
||||
if (field.type === 'checkbox' && field.use_toggle_switch) {
|
||||
return 'ToggleSwitchInput'
|
||||
}
|
||||
if (field.type === 'signature') {
|
||||
return 'SignatureInput'
|
||||
}
|
||||
if (field.type === 'phone_number' && !field.use_simple_text_input) {
|
||||
return 'PhoneInput'
|
||||
}
|
||||
|
||||
return {
|
||||
text: 'TextInput',
|
||||
rich_text: 'RichTextAreaInput',
|
||||
number: 'TextInput',
|
||||
rating: 'RatingInput',
|
||||
scale: 'ScaleInput',
|
||||
slider: 'SliderInput',
|
||||
select: 'SelectInput',
|
||||
multi_select: 'SelectInput',
|
||||
date: 'DateInput',
|
||||
files: 'FileInput',
|
||||
checkbox: 'CheckboxInput',
|
||||
url: 'TextInput',
|
||||
email: 'TextInput',
|
||||
phone_number: 'TextInput',
|
||||
matrix: 'MatrixInput',
|
||||
barcode: 'BarcodeInput',
|
||||
payment: 'PaymentInput'
|
||||
}[field.type]
|
||||
})
|
||||
|
||||
const isPublicFormPage = computed(() => useRoute().name === 'forms-slug')
|
||||
|
||||
const isFieldHidden = computed(() => !showHidden.value && shouldBeHidden.value)
|
||||
|
||||
const shouldBeHidden = computed(() =>
|
||||
(new FormLogicPropertyResolver(props.field, dataForm.value)).isHidden()
|
||||
)
|
||||
|
||||
const isFieldRequired = computed(() =>
|
||||
(new FormLogicPropertyResolver(props.field, dataForm.value)).isRequired()
|
||||
)
|
||||
|
||||
const isFieldDisabled = computed(() =>
|
||||
(new FormLogicPropertyResolver(props.field, dataForm.value)).isDisabled()
|
||||
)
|
||||
|
||||
const beingEdited = computed(() =>
|
||||
isAdminPreview.value &&
|
||||
showEditFieldSidebar.value &&
|
||||
form.value.properties.findIndex((item) => item.id === props.field.id) === selectedFieldIndex.value
|
||||
)
|
||||
|
||||
// Methods
|
||||
function editFieldOptions() {
|
||||
if (!isAdminPreview.value) return
|
||||
workingFormStore.openSettingsForField(props.field)
|
||||
}
|
||||
|
||||
function setFieldAsSelected() {
|
||||
if (!isAdminPreview.value || !workingFormStore.showEditFieldSidebar) return
|
||||
workingFormStore.openSettingsForField(props.field)
|
||||
}
|
||||
|
||||
function openAddFieldSidebar() {
|
||||
if (!isAdminPreview.value) return
|
||||
workingFormStore.openAddFieldSidebar(props.field)
|
||||
}
|
||||
|
||||
function removeField() {
|
||||
if (!isAdminPreview.value) return
|
||||
workingFormStore.removeField(props.field)
|
||||
}
|
||||
|
||||
function getFieldWidthClasses(field) {
|
||||
if (!field.width || field.width === 'full') return 'col-span-full'
|
||||
else if (field.width === '1/2') {
|
||||
return 'sm:col-span-6 col-span-full'
|
||||
} else if (field.width === '1/3') {
|
||||
return 'sm:col-span-4 col-span-full'
|
||||
} else if (field.width === '2/3') {
|
||||
return 'sm:col-span-8 col-span-full'
|
||||
} else if (field.width === '1/4') {
|
||||
return 'sm:col-span-3 col-span-full'
|
||||
} else if (field.width === '3/4') {
|
||||
return 'sm:col-span-9 col-span-full'
|
||||
}
|
||||
}
|
||||
|
||||
function getFieldAlignClasses(field) {
|
||||
if (!field.align || field.align === 'left') return 'text-left'
|
||||
else if (field.align === 'right') {
|
||||
return 'text-right'
|
||||
} else if (field.align === 'center') {
|
||||
return 'text-center'
|
||||
} else if (field.align === 'justify') {
|
||||
return 'text-justify'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the right input component options for the field/options
|
||||
*/
|
||||
function inputProperties(field) {
|
||||
const inputProperties = {
|
||||
key: field.id,
|
||||
name: field.id,
|
||||
form: dataForm.value,
|
||||
label: (field.hide_field_name) ? null : field.name + ((shouldBeHidden.value) ? ' (Hidden Field)' : ''),
|
||||
color: form.value.color,
|
||||
placeholder: field.placeholder,
|
||||
help: field.help,
|
||||
helpPosition: (field.help_position) ? field.help_position : 'below_input',
|
||||
uppercaseLabels: form.value.uppercase_labels == 1 || form.value.uppercase_labels == true,
|
||||
theme: props.theme || CachedDefaultTheme.getInstance(),
|
||||
maxCharLimit: (field.max_char_limit) ? parseInt(field.max_char_limit) : null,
|
||||
showCharLimit: field.show_char_limit || false,
|
||||
isDark: darkMode.value,
|
||||
locale: (form.value?.language) ? form.value.language : 'en'
|
||||
}
|
||||
|
||||
|
||||
if (field.type === 'matrix') {
|
||||
inputProperties.rows = field.rows
|
||||
inputProperties.columns = field.columns
|
||||
}
|
||||
|
||||
if (field.type === 'barcode') {
|
||||
inputProperties.decoders = field.decoders
|
||||
}
|
||||
|
||||
if (['select','multi_select'].includes(field.type) && !isFieldRequired.value) {
|
||||
inputProperties.clearable = true
|
||||
}
|
||||
|
||||
if (['select', 'multi_select'].includes(field.type)) {
|
||||
inputProperties.options = (_has(field, field.type))
|
||||
? field[field.type].options.map(option => {
|
||||
return {
|
||||
name: option.name,
|
||||
value: option.name
|
||||
}
|
||||
return CachedDefaultTheme.getInstance()
|
||||
}
|
||||
},
|
||||
showHidden: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
darkMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: FormMode.LIVE
|
||||
})
|
||||
: []
|
||||
inputProperties.multiple = (field.type === 'multi_select')
|
||||
inputProperties.allowCreation = (field.allow_creation === true)
|
||||
inputProperties.searchable = (inputProperties.options.length > 4)
|
||||
} else if (field.type === 'date') {
|
||||
inputProperties.dateFormat = field.date_format
|
||||
inputProperties.timeFormat = field.time_format
|
||||
if (field.with_time) {
|
||||
inputProperties.withTime = true
|
||||
}
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
return {
|
||||
workingFormStore,
|
||||
currentWorkspace: computed(() => useWorkspacesStore().getCurrent),
|
||||
selectedFieldIndex: computed(() => workingFormStore.selectedFieldIndex),
|
||||
showEditFieldSidebar: computed(() => workingFormStore.showEditFieldSidebar),
|
||||
formModeStrategy: computed(() => createFormModeStrategy(props.mode)),
|
||||
isAdminPreview: computed(() => createFormModeStrategy(props.mode).admin.showAdminControls)
|
||||
if (field.date_range) {
|
||||
inputProperties.dateRange = true
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Get the right input component for the field/options combination
|
||||
*/
|
||||
getFieldComponents() {
|
||||
const field = this.field
|
||||
if (field.type === 'text' && field.multi_lines) {
|
||||
return 'TextAreaInput'
|
||||
}
|
||||
if (field.type === 'url' && field.file_upload) {
|
||||
return 'FileInput'
|
||||
}
|
||||
if (['select', 'multi_select'].includes(field.type) && field.without_dropdown) {
|
||||
return 'FlatSelectInput'
|
||||
}
|
||||
if (field.type === 'checkbox' && field.use_toggle_switch) {
|
||||
return 'ToggleSwitchInput'
|
||||
}
|
||||
if (field.type === 'signature') {
|
||||
return 'SignatureInput'
|
||||
}
|
||||
if (field.type === 'phone_number' && !field.use_simple_text_input) {
|
||||
return 'PhoneInput'
|
||||
}
|
||||
|
||||
return {
|
||||
text: 'TextInput',
|
||||
rich_text: 'RichTextAreaInput',
|
||||
number: 'TextInput',
|
||||
rating: 'RatingInput',
|
||||
scale: 'ScaleInput',
|
||||
slider: 'SliderInput',
|
||||
select: 'SelectInput',
|
||||
multi_select: 'SelectInput',
|
||||
date: 'DateInput',
|
||||
files: 'FileInput',
|
||||
checkbox: 'CheckboxInput',
|
||||
url: 'TextInput',
|
||||
email: 'TextInput',
|
||||
phone_number: 'TextInput',
|
||||
matrix: 'MatrixInput',
|
||||
barcode: 'BarcodeInput',
|
||||
payment: 'PaymentInput'
|
||||
}[field.type]
|
||||
},
|
||||
isPublicFormPage() {
|
||||
return this.$route.name === 'forms-slug'
|
||||
},
|
||||
isFieldHidden() {
|
||||
return !this.showHidden && this.shouldBeHidden
|
||||
},
|
||||
shouldBeHidden() {
|
||||
return (new FormLogicPropertyResolver(this.field, this.dataFormValue)).isHidden()
|
||||
},
|
||||
isFieldRequired() {
|
||||
return (new FormLogicPropertyResolver(this.field, this.dataFormValue)).isRequired()
|
||||
},
|
||||
isFieldDisabled() {
|
||||
return (new FormLogicPropertyResolver(this.field, this.dataFormValue)).isDisabled()
|
||||
},
|
||||
beingEdited() {
|
||||
return this.isAdminPreview && this.showEditFieldSidebar && this.form.properties.findIndex((item) => {
|
||||
return item.id === this.field.id
|
||||
}) === this.selectedFieldIndex
|
||||
},
|
||||
selectionFieldsOptions() {
|
||||
// For auto update hidden options
|
||||
let fieldsOptions = []
|
||||
|
||||
if (['select', 'multi_select', 'status'].includes(this.field.type)) {
|
||||
fieldsOptions = [...this.field[this.field.type].options]
|
||||
if (this.field.hidden_options && this.field.hidden_options.length > 0) {
|
||||
fieldsOptions = fieldsOptions.filter((option) => {
|
||||
return this.field.hidden_options.indexOf(option.id) < 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return fieldsOptions
|
||||
},
|
||||
fieldSideBarOpened() {
|
||||
return this.isAdminPreview && (this.form && this.selectedFieldIndex !== null) ? (this.form.properties[this.selectedFieldIndex] && this.showEditFieldSidebar) : false
|
||||
if (field.disable_past_dates) {
|
||||
inputProperties.disablePastDates = true
|
||||
} else if (field.disable_future_dates) {
|
||||
inputProperties.disableFutureDates = true
|
||||
}
|
||||
},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted() {
|
||||
},
|
||||
|
||||
methods: {
|
||||
editFieldOptions() {
|
||||
if (!this.formModeStrategy.admin.showAdminControls) return
|
||||
this.workingFormStore.openSettingsForField(this.field)
|
||||
},
|
||||
setFieldAsSelected () {
|
||||
if (!this.formModeStrategy.admin.showAdminControls || !this.workingFormStore.showEditFieldSidebar) return
|
||||
this.workingFormStore.openSettingsForField(this.field)
|
||||
},
|
||||
openAddFieldSidebar() {
|
||||
if (!this.formModeStrategy.admin.showAdminControls) return
|
||||
this.workingFormStore.openAddFieldSidebar(this.field)
|
||||
},
|
||||
removeField () {
|
||||
if (!this.formModeStrategy.admin.showAdminControls) return
|
||||
this.workingFormStore.removeField(this.field)
|
||||
},
|
||||
getFieldWidthClasses(field) {
|
||||
if (!field.width || field.width === 'full') return 'col-span-full'
|
||||
else if (field.width === '1/2') {
|
||||
return 'sm:col-span-6 col-span-full'
|
||||
} else if (field.width === '1/3') {
|
||||
return 'sm:col-span-4 col-span-full'
|
||||
} else if (field.width === '2/3') {
|
||||
return 'sm:col-span-8 col-span-full'
|
||||
} else if (field.width === '1/4') {
|
||||
return 'sm:col-span-3 col-span-full'
|
||||
} else if (field.width === '3/4') {
|
||||
return 'sm:col-span-9 col-span-full'
|
||||
} else if (field.type === 'files' || (field.type === 'url' && field.file_upload)) {
|
||||
inputProperties.multiple = (field.multiple !== undefined && field.multiple)
|
||||
inputProperties.cameraUpload = (field.camera_upload !== undefined && field.camera_upload)
|
||||
let maxFileSize = (form.value?.workspace && form.value?.workspace.max_file_size) ? form.value?.workspace?.max_file_size : 10
|
||||
if (field?.max_file_size > 0) {
|
||||
maxFileSize = Math.min(field.max_file_size, maxFileSize)
|
||||
}
|
||||
inputProperties.mbLimit = maxFileSize
|
||||
inputProperties.accept = (form.value.is_pro && field.allowed_file_types) ? field.allowed_file_types : ''
|
||||
} else if (field.type === 'rating') {
|
||||
inputProperties.numberOfStars = parseInt(field.rating_max_value) ?? 5
|
||||
} else if (field.type === 'scale') {
|
||||
inputProperties.minScale = parseFloat(field.scale_min_value) ?? 1
|
||||
inputProperties.maxScale = parseFloat(field.scale_max_value) ?? 5
|
||||
inputProperties.stepScale = parseFloat(field.scale_step_value) ?? 1
|
||||
} else if (field.type === 'slider') {
|
||||
inputProperties.minSlider = parseInt(field.slider_min_value) ?? 0
|
||||
inputProperties.maxSlider = parseInt(field.slider_max_value) ?? 50
|
||||
inputProperties.stepSlider = parseInt(field.slider_step_value) ?? 5
|
||||
} else if (field.type === 'number' || (field.type === 'phone_number' && field.use_simple_text_input)) {
|
||||
inputProperties.pattern = '/d*'
|
||||
} else if (field.type === 'phone_number' && !field.use_simple_text_input) {
|
||||
inputProperties.unavailableCountries = field.unavailable_countries ?? []
|
||||
} else if (field.type === 'text' && field.secret_input) {
|
||||
inputProperties.nativeType = 'password'
|
||||
} else if (field.type === 'payment') {
|
||||
inputProperties.direction = form.value.layout_rtl ? 'rtl' : 'ltr'
|
||||
inputProperties.currency = field.currency
|
||||
inputProperties.amount = field.amount
|
||||
inputProperties.oauthProviderId = field.stripe_account_id
|
||||
|
||||
// Get paymentData from formManager if available
|
||||
if (props.formManager?.payment) {
|
||||
try {
|
||||
inputProperties.paymentData = props.formManager.payment.getPaymentData(field)
|
||||
} catch (error) {
|
||||
console.error("Error getting payment data:", error)
|
||||
}
|
||||
},
|
||||
getFieldAlignClasses(field) {
|
||||
if (!field.align || field.align === 'left') return 'text-left'
|
||||
else if (field.align === 'right') {
|
||||
return 'text-right'
|
||||
} else if (field.align === 'center') {
|
||||
return 'text-center'
|
||||
} else if (field.align === 'justify') {
|
||||
return 'text-justify'
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get the right input component options for the field/options
|
||||
*/
|
||||
inputProperties(field) {
|
||||
const inputProperties = {
|
||||
key: field.id,
|
||||
name: field.id,
|
||||
form: this.dataForm,
|
||||
label: (field.hide_field_name) ? null : field.name + ((this.shouldBeHidden) ? ' (Hidden Field)' : ''),
|
||||
color: this.form.color,
|
||||
placeholder: field.placeholder,
|
||||
help: field.help,
|
||||
helpPosition: (field.help_position) ? field.help_position : 'below_input',
|
||||
uppercaseLabels: this.form.uppercase_labels == 1 || this.form.uppercase_labels == true,
|
||||
theme: this.theme,
|
||||
maxCharLimit: (field.max_char_limit) ? parseInt(field.max_char_limit) : null,
|
||||
showCharLimit: field.show_char_limit || false,
|
||||
isDark: this.darkMode,
|
||||
locale: (this.form?.language) ? this.form.language : 'en'
|
||||
}
|
||||
|
||||
if (field.type === 'matrix') {
|
||||
inputProperties.rows = field.rows
|
||||
inputProperties.columns = field.columns
|
||||
}
|
||||
|
||||
if (field.type === 'barcode') {
|
||||
inputProperties.decoders = field.decoders
|
||||
}
|
||||
|
||||
if (['select','multi_select'].includes(field.type) && !this.isFieldRequired) {
|
||||
inputProperties.clearable = true
|
||||
}
|
||||
|
||||
if (['select', 'multi_select'].includes(field.type)) {
|
||||
inputProperties.options = (_has(field, field.type))
|
||||
? field[field.type].options.map(option => {
|
||||
return {
|
||||
name: option.name,
|
||||
value: option.name
|
||||
}
|
||||
})
|
||||
: []
|
||||
inputProperties.multiple = (field.type === 'multi_select')
|
||||
inputProperties.allowCreation = (field.allow_creation === true)
|
||||
inputProperties.searchable = (inputProperties.options.length > 4)
|
||||
} else if (field.type === 'date') {
|
||||
inputProperties.dateFormat = field.date_format
|
||||
inputProperties.timeFormat = field.time_format
|
||||
if (field.with_time) {
|
||||
inputProperties.withTime = true
|
||||
}
|
||||
if (field.date_range) {
|
||||
inputProperties.dateRange = true
|
||||
}
|
||||
if (field.disable_past_dates) {
|
||||
inputProperties.disablePastDates = true
|
||||
} else if (field.disable_future_dates) {
|
||||
inputProperties.disableFutureDates = true
|
||||
}
|
||||
} else if (field.type === 'files' || (field.type === 'url' && field.file_upload)) {
|
||||
inputProperties.multiple = (field.multiple !== undefined && field.multiple)
|
||||
inputProperties.cameraUpload = (field.camera_upload !== undefined && field.camera_upload)
|
||||
let maxFileSize = (this.form?.workspace && this.form?.workspace.max_file_size) ? this.form?.workspace?.max_file_size : 10
|
||||
if (field?.max_file_size > 0) {
|
||||
maxFileSize = Math.min(field.max_file_size, maxFileSize)
|
||||
}
|
||||
inputProperties.mbLimit = maxFileSize
|
||||
inputProperties.accept = (this.form.is_pro && field.allowed_file_types) ? field.allowed_file_types : ''
|
||||
} else if (field.type === 'rating') {
|
||||
inputProperties.numberOfStars = parseInt(field.rating_max_value) ?? 5
|
||||
} else if (field.type === 'scale') {
|
||||
inputProperties.minScale = parseFloat(field.scale_min_value) ?? 1
|
||||
inputProperties.maxScale = parseFloat(field.scale_max_value) ?? 5
|
||||
inputProperties.stepScale = parseFloat(field.scale_step_value) ?? 1
|
||||
} else if (field.type === 'slider') {
|
||||
inputProperties.minSlider = parseInt(field.slider_min_value) ?? 0
|
||||
inputProperties.maxSlider = parseInt(field.slider_max_value) ?? 50
|
||||
inputProperties.stepSlider = parseInt(field.slider_step_value) ?? 5
|
||||
} else if (field.type === 'number' || (field.type === 'phone_number' && field.use_simple_text_input)) {
|
||||
inputProperties.pattern = '/d*'
|
||||
} else if (field.type === 'phone_number' && !field.use_simple_text_input) {
|
||||
inputProperties.unavailableCountries = field.unavailable_countries ?? []
|
||||
} else if (field.type === 'text' && field.secret_input) {
|
||||
inputProperties.nativeType = 'password'
|
||||
} else if (field.type === 'payment') {
|
||||
inputProperties.direction = this.form.layout_rtl ? 'rtl' : 'ltr'
|
||||
inputProperties.currency = field.currency
|
||||
inputProperties.amount = field.amount
|
||||
inputProperties.oauthProviderId = field.stripe_account_id
|
||||
}
|
||||
|
||||
return inputProperties
|
||||
}
|
||||
}
|
||||
|
||||
return inputProperties
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modal
|
||||
:show="show"
|
||||
compact-header
|
||||
;backdrop-blur="true"
|
||||
:backdrop-blur="true"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<template #title>
|
||||
|
||||
@@ -150,7 +150,7 @@ function coverPictureSrc(val) {
|
||||
try {
|
||||
// Is valid url
|
||||
new URL(val)
|
||||
} catch (_) {
|
||||
} catch {
|
||||
// Is file
|
||||
return URL.createObjectURL(val)
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
class="w-full"
|
||||
:class="{'flex flex-wrap gap-x-4':submissionOptions.submissionMode === 'redirect'}"
|
||||
>
|
||||
<select-input
|
||||
<flat-select-input
|
||||
:form="submissionOptions"
|
||||
name="submissionMode"
|
||||
class="w-full max-w-xs"
|
||||
@@ -150,7 +150,7 @@
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</select-input>
|
||||
</flat-select-input>
|
||||
<template v-if="submissionOptions.submissionMode === 'redirect'">
|
||||
<MentionInput
|
||||
name="redirect_url"
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
|
||||
import { defineComponent } from "vue"
|
||||
import QueryBuilder from "query-builder-vue-3"
|
||||
import ColumnCondition from "./ColumnCondition.vue"
|
||||
|
||||
@@ -190,7 +190,7 @@ export default {
|
||||
field.id !== this.field.id &&
|
||||
_has(field, "logic") &&
|
||||
field.logic !== null &&
|
||||
field.logic !== {}
|
||||
Object.keys(field.logic || {}).length > 0
|
||||
)
|
||||
})
|
||||
.map((field) => {
|
||||
|
||||
@@ -128,7 +128,9 @@ const field = computed(() => {
|
||||
// This prevents page jumps when editing field properties
|
||||
onMounted(() => {
|
||||
if (selectedFieldIndex.value !== null) {
|
||||
workingFormStore.setPageForField(selectedFieldIndex.value)
|
||||
if (workingFormStore.structureService) {
|
||||
workingFormStore.structureService.setPageForField(selectedFieldIndex.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ const save = () => {
|
||||
.catch((error) => {
|
||||
try {
|
||||
alert.error(error.data.message)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
alert.error("An error occurred while saving the integration")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,11 +15,11 @@ import { default as _has } from 'lodash/has'
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
// eslint-disable-next-line vue/require-prop-types
|
||||
|
||||
property: {
|
||||
required: true
|
||||
},
|
||||
// eslint-disable-next-line vue/require-prop-types
|
||||
|
||||
value: {
|
||||
required: true
|
||||
}
|
||||
@@ -45,13 +45,13 @@ export default {
|
||||
if (this.property?.with_time) {
|
||||
try {
|
||||
return format(new Date(val), dateFormat + (timeFormat == 12 ? ' p':' HH:mm'))
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
try {
|
||||
return format(new Date(val), dateFormat)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user