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:
Julien Nahum
2025-05-07 17:15:56 +02:00
committed by GitHub
parent 6b03808d36
commit 053abbf31b
66 changed files with 5413 additions and 3352 deletions

View 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>

View File

@@ -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)
})

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -2,7 +2,7 @@
<modal
:show="show"
compact-header
;backdrop-blur="true"
:backdrop-blur="true"
@close="$emit('close')"
>
<template #title>

View File

@@ -150,7 +150,7 @@ function coverPictureSrc(val) {
try {
// Is valid url
new URL(val)
} catch (_) {
} catch {
// Is file
return URL.createObjectURL(val)
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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) => {

View File

@@ -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)
}
}
})

View File

@@ -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")
}
})

View File

@@ -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 ''
}
}