From 053abbf31bec796a0689a7ae9c6b62f57935837b Mon Sep 17 00:00:00 2001 From: Julien Nahum Date: Wed, 7 May 2025 17:15:56 +0200 Subject: [PATCH] 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 ` \ No newline at end of file diff --git a/client/components/forms/PaymentInput.client.vue b/client/components/forms/PaymentInput.client.vue index 0efe856c..68fa2c73 100644 --- a/client/components/forms/PaymentInput.client.vue +++ b/client/components/forms/PaymentInput.client.vue @@ -44,10 +44,17 @@ @@ -144,7 +160,12 @@ @@ -156,7 +177,6 @@ import InputWrapper from './components/InputWrapper.vue' import { loadStripe } from '@stripe/stripe-js' import { StripeElements, StripeElement } from 'vue-stripe-js' import stripeCurrencies from "~/data/stripe_currencies.json" -import { useStripeElements } from '~/composables/useStripeElements' import { useAlert } from '~/composables/useAlert' import { useFeatureFlag } from '~/composables/useFeatureFlag' @@ -168,20 +188,12 @@ const props = defineProps({ oauthProviderId: { type: [String, Number], default: null }, isAdminPreview: { type: Boolean, default: false }, color: { type: String, default: '#000000' }, - isDark: { type: Boolean, default: false } + isDark: { type: Boolean, default: false }, + paymentData: { type: Object, default: null } }) const emit = defineEmits([]) const { compVal, hasError, inputWrapperProps } = useFormInput(props, { emit }) -const stripeElements = useStripeElements() -const { - state: stripeState, - prepareStripeState, - setStripeInstance, - setElementsInstance, - setCardElement, - setBillingDetails -} = stripeElements || {} const route = useRoute() const alert = useAlert() @@ -189,23 +201,31 @@ const alert = useAlert() const publishableKey = computed(() => { return useFeatureFlag('billing.stripe_publishable_key', '') }) + const card = ref(null) const stripeElementsRef = ref(null) const cardHolderName = ref('') const cardHolderEmail = ref('') const isCardFocused = ref(false) - -// Keep the flag for Stripe.js loading but remove manual instance creation const isStripeJsLoaded = ref(false) +// Get Stripe elements from paymentData +const stripeElements = computed(() => props.paymentData?.stripeElements) +const stripeState = computed(() => stripeElements.value?.state || {}) +const setStripeInstance = computed(() => stripeElements.value?.setStripeInstance) +const setElementsInstance = computed(() => stripeElements.value?.setElementsInstance) +const setCardElement = computed(() => stripeElements.value?.setCardElement) +const setBillingDetails = computed(() => stripeElements.value?.setBillingDetails) +const prepareStripeState = computed(() => stripeElements.value?.prepareStripeState) + // Computed to determine if we should show success state const showSuccessState = computed(() => { - return stripeState?.intentId || (compVal.value && isPaymentIntentId(compVal.value)) + return stripeState.value?.intentId || (compVal.value && isPaymentIntentId(compVal.value)) }) // Computed to determine if we should always show preview message in editor const shouldShowPreviewMessage = computed(() => { - return props.isAdminPreview && (!formSlug.value || !stripeState || !stripeElements) + return props.isAdminPreview && stripeState.value?.showPreviewMessage }) // Helper function to check if a string looks like a Stripe payment intent ID @@ -216,87 +236,120 @@ const isPaymentIntentId = (value) => { // Initialize Stripe.js if needed onMounted(async () => { try { - // Validate publishable key - if (!publishableKey.value || typeof publishableKey.value !== 'string' || publishableKey.value.trim() === '') { - if (stripeState) { - stripeState.isLoadingAccount = false - stripeState.hasAccountLoadingError = true - stripeState.errorMessage = 'Missing Stripe configuration. Please check your settings.' - } - return - } + console.debug('[PaymentInput] Mounting with:', { + oauthProviderId: props.oauthProviderId, + hasPaymentData: !!props.paymentData, + publishableKey: publishableKey.value, + stripeElementsInstance: !!stripeElements.value + }) - // We'll check if Stripe is already available globally - if (typeof window !== 'undefined' && !window.Stripe) { + // Initialize Stripe.js globally first if needed + if (typeof window !== 'undefined' && !window.Stripe && publishableKey.value) { + console.debug('[PaymentInput] Loading Stripe.js with key:', publishableKey.value) await loadStripe(publishableKey.value) isStripeJsLoaded.value = true - } else { + } else if (typeof window !== 'undefined' && window.Stripe) { isStripeJsLoaded.value = true } + console.debug('[PaymentInput] Stripe.js loaded status:', isStripeJsLoaded.value) - // If stripeElements or stripeState is not available, we need to handle that - if (!stripeElements || !stripeState) { - console.warn('Stripe elements provider not found or not properly initialized.') + // Skip initialization if missing essential data + if (!props.oauthProviderId || !props.paymentData || !publishableKey.value) { + console.debug('[PaymentInput] Skipping initialization - missing requirements:', { + oauthProviderId: props.oauthProviderId, + paymentData: !!props.paymentData, + publishableKey: !!publishableKey.value + }) + + // Set error state if publishable key is missing + if (!publishableKey.value && stripeState.value) { + stripeState.value.hasAccountLoadingError = true + stripeState.value.errorMessage = 'Missing Stripe configuration. Please check your settings.' + } return } // If compVal already contains a payment intent ID, sync it to stripeState - if (compVal.value && isPaymentIntentId(compVal.value) && stripeState) { - stripeState.intentId = compVal.value + if (compVal.value && isPaymentIntentId(compVal.value) && stripeState.value) { + console.debug('[PaymentInput] Syncing existing payment intent:', compVal.value) + stripeState.value.intentId = compVal.value } - // For unsaved forms in admin preview, show the preview message - if (props.isAdminPreview && !formSlug.value && stripeState) { - stripeState.isLoadingAccount = false - stripeState.showPreviewMessage = true - return - } - - // Fetch account but don't manually create Stripe instance + // Fetch account details from the API, even in preview mode const slug = formSlug.value - if (slug && props.oauthProviderId && prepareStripeState) { - const result = await prepareStripeState(slug, props.oauthProviderId, props.isAdminPreview) + if (slug && props.oauthProviderId && prepareStripeState.value) { + console.debug('[PaymentInput] Preparing Stripe state with:', { + slug, + oauthProviderId: props.oauthProviderId, + isAdminPreview: props.isAdminPreview + }) + const result = await prepareStripeState.value(slug, props.oauthProviderId, props.isAdminPreview) + console.debug('[PaymentInput] Stripe state preparation result:', result) if (!result.success && result.message && !result.requiresSave) { + // Show error only if it's not the "Save the form" message alert.error(result.message) } - } else if (props.isAdminPreview && stripeState) { - // If we're in admin preview and any required parameter is missing, show preview message - stripeState.isLoadingAccount = false - stripeState.showPreviewMessage = true } } catch (error) { + console.error('[PaymentInput] Stripe initialization error:', error) + if (stripeState.value) { + stripeState.value.hasAccountLoadingError = true + stripeState.value.errorMessage = 'Failed to initialize Stripe. Please refresh and try again.' + } alert.error('Failed to initialize Stripe. Please refresh and try again.') } }) // Watch for provider ID changes watch(() => props.oauthProviderId, async (newVal, oldVal) => { - if (newVal && newVal !== oldVal && prepareStripeState) { + if (newVal && newVal !== oldVal && prepareStripeState.value) { const slug = formSlug.value if (slug) { - await prepareStripeState(slug, newVal, props.isAdminPreview) + await prepareStripeState.value(slug, newVal, props.isAdminPreview) } } }) -// Update onStripeReady to always use the stripe instance from the component +// Update onStripeReady to use the computed methods const onStripeReady = ({ stripe, elements }) => { + console.debug('[PaymentInput] onStripeReady called with:', { + hasStripe: !!stripe, + hasElements: !!elements, + setStripeInstance: !!setStripeInstance.value, + setElementsInstance: !!setElementsInstance.value + }) + if (!stripe) { + console.warn('[PaymentInput] No Stripe instance in onStripeReady') return } - if (setStripeInstance) { - setStripeInstance(stripe) + if (setStripeInstance.value) { + console.debug('[PaymentInput] Setting Stripe instance') + setStripeInstance.value(stripe) + } else { + console.warn('[PaymentInput] No setStripeInstance method available') } - if (elements && setElementsInstance) { - setElementsInstance(elements) + if (elements && setElementsInstance.value) { + console.debug('[PaymentInput] Setting Elements instance') + setElementsInstance.value(elements) + } else { + console.warn('[PaymentInput] Missing elements or setElementsInstance') } } -const onStripeError = (_error) => { - alert.error('Failed to load payment component. Please check configuration or refresh.') +const onStripeError = (error) => { + console.error('[PaymentInput] Stripe initialization error:', error) + const errorMessage = error?.message || 'Failed to load payment component' + + alert.error('Failed to load payment component. ' + errorMessage) + + if (stripeState.value) { + stripeState.value.hasAccountLoadingError = true + stripeState.value.errorMessage = errorMessage + '. Please check configuration or refresh.' + } } // Card focus/blur event handlers @@ -309,30 +362,41 @@ const onCardBlur = () => { } const onCardReady = (_element) => { - if (card.value?.stripeElement) { - if (setCardElement) { - setCardElement(card.value.stripeElement) - } + console.debug('[PaymentInput] Card ready:', { + hasCardRef: !!card.value, + hasStripeElement: !!card.value?.stripeElement, + hasSetCardElement: !!setCardElement.value + }) + + if (card.value?.stripeElement && setCardElement.value) { + console.debug('[PaymentInput] Setting card element') + setCardElement.value(card.value.stripeElement) + } else { + console.warn('[PaymentInput] Cannot set card element - missing dependencies') } } // Billing details watch(cardHolderName, (newValue) => { - setBillingDetails({ name: newValue }) + if (setBillingDetails.value) { + setBillingDetails.value({ name: newValue }) + } }) watch(cardHolderEmail, (newValue) => { - setBillingDetails({ email: newValue }) + if (setBillingDetails.value) { + setBillingDetails.value({ email: newValue }) + } }) // Payment intent sync -watch(() => stripeState?.intentId, (newValue) => { +watch(() => stripeState.value?.intentId, (newValue) => { if (newValue) compVal.value = newValue }) watch(compVal, (newValue) => { - if (newValue && stripeState && newValue !== stripeState.intentId) { - stripeState.intentId = newValue + if (newValue && stripeState.value && newValue !== stripeState.value.intentId) { + stripeState.value.intentId = newValue } }, { immediate: true }) @@ -351,7 +415,6 @@ const currencySymbol = computed(() => { }) const cardOptions = computed(() => { - // Extract placeholder color from theme const darkPlaceholderColor = props.theme.default?.input?.includes('dark:placeholder-gray-500') ? '#6B7280' : '#9CA3AF' const lightPlaceholderColor = props.theme.default?.input?.includes('placeholder-gray-400') ? '#9CA3AF' : '#A0AEC0' @@ -378,7 +441,6 @@ const cardOptions = computed(() => { }) const formSlug = computed(() => { - // Return the slug from route params regardless of route name if (route.params && route.params.slug) { return route.params.slug } @@ -393,7 +455,9 @@ const resetCard = async () => { if (stripeElementsRef.value?.elements) { card.value.stripeElement.mount(stripeElementsRef.value.elements) - setCardElement(card.value.stripeElement) + if (setCardElement.value) { + setCardElement.value(card.value.stripeElement) + } } else { console.error('Cannot remount card, Stripe Elements instance not found.') } @@ -403,14 +467,21 @@ const resetCard = async () => { // Add watcher to check when stripeElementsRef becomes available for fallback access watch(() => stripeElementsRef.value, async (newRef) => { if (newRef) { + console.debug('[PaymentInput] StripeElementsRef updated:', { + hasInstance: !!newRef.instance, + hasElements: !!newRef.elements + }) + // If @ready event hasn't fired, try accessing the instance directly - if (newRef.instance && setStripeInstance && !stripeState.isStripeInstanceReady) { - setStripeInstance(newRef.instance) + if (newRef.instance && setStripeInstance.value && !stripeState.value?.isStripeInstanceReady) { + console.debug('[PaymentInput] Setting Stripe instance from ref') + setStripeInstance.value(newRef.instance) } - if (newRef.elements && setElementsInstance) { - setElementsInstance(newRef.elements) + if (newRef.elements && setElementsInstance.value) { + console.debug('[PaymentInput] Setting Elements instance from ref') + setElementsInstance.value(newRef.elements) } } }, { immediate: true }) - \ No newline at end of file + diff --git a/client/components/forms/TextInput.vue b/client/components/forms/TextInput.vue index 159a1a0d..d5a50999 100644 --- a/client/components/forms/TextInput.vue +++ b/client/components/forms/TextInput.vue @@ -84,7 +84,7 @@ export default { if (props.nativeType !== "file") return const file = event.target.files[0] - // eslint-disable-next-line vue/no-mutating-props + props.form[props.name] = file } diff --git a/client/components/forms/components/CaptchaInput.vue b/client/components/forms/components/CaptchaInput.vue index 7b2ab978..145c023f 100644 --- a/client/components/forms/components/CaptchaInput.vue +++ b/client/components/forms/components/CaptchaInput.vue @@ -138,7 +138,7 @@ const resizeIframe = (height) => { try { window.parentIFrame?.size(height) - } catch (e) { + } catch { // Silently handle error } } diff --git a/client/components/forms/components/CaptchaWrapper.vue b/client/components/forms/components/CaptchaWrapper.vue new file mode 100644 index 00000000..ab277887 --- /dev/null +++ b/client/components/forms/components/CaptchaWrapper.vue @@ -0,0 +1,83 @@ + + + \ No newline at end of file diff --git a/client/components/forms/components/HCaptchaV2.vue b/client/components/forms/components/HCaptchaV2.vue index 18d2d4bf..803245c4 100644 --- a/client/components/forms/components/HCaptchaV2.vue +++ b/client/components/forms/components/HCaptchaV2.vue @@ -148,7 +148,7 @@ const renderHcaptcha = async () => { 'open-callback': () => emit('opened'), 'close-callback': () => emit('closed') }) - } catch (error) { + } catch { scriptLoadPromise = null // Reset promise on error } } @@ -162,7 +162,7 @@ onBeforeUnmount(() => { if (window.hcaptcha && widgetId !== null) { try { window.hcaptcha.remove(widgetId) - } catch (e) { + } catch { // Silently handle error } } @@ -179,6 +179,17 @@ onBeforeUnmount(() => { // Expose reset method that properly reloads the captcha defineExpose({ reset: async () => { + if (window.hcaptcha && widgetId !== null) { + try { + // Use the official API to reset the captcha widget + window.hcaptcha.reset(widgetId) + return true + } catch (error) { + console.error('Error resetting hCaptcha, falling back to re-render', error) + } + } + + // Fall back to full re-render if reset fails or hcaptcha isn't available cleanupHcaptcha() await renderHcaptcha() } diff --git a/client/components/forms/components/RecaptchaV2.vue b/client/components/forms/components/RecaptchaV2.vue index 88507787..3f2adc40 100644 --- a/client/components/forms/components/RecaptchaV2.vue +++ b/client/components/forms/components/RecaptchaV2.vue @@ -129,7 +129,7 @@ const renderRecaptcha = async () => { } } }) - } catch (error) { + } catch { scriptLoadPromise = null // Reset promise on error } } @@ -143,7 +143,7 @@ onBeforeUnmount(() => { if (window.grecaptcha && widgetId !== null) { try { window.grecaptcha.reset(widgetId) - } catch (e) { + } catch { // Silently handle error } } @@ -164,16 +164,15 @@ defineExpose({ try { // Try simple reset first window.grecaptcha.reset(widgetId) - } catch (e) { - // If simple reset fails, do a full cleanup and reload - cleanupRecaptcha() - await renderRecaptcha() + return true + } catch (error) { + console.error('Error resetting reCAPTCHA, falling back to re-render', error) } - } else { - // If no widget exists, do a full reload - cleanupRecaptcha() - await renderRecaptcha() } + + // If simple reset fails or no widget exists, do a full reload + cleanupRecaptcha() + await renderRecaptcha() } }) \ No newline at end of file diff --git a/client/components/open/forms/CaptchaWrapper.vue b/client/components/open/forms/CaptchaWrapper.vue new file mode 100644 index 00000000..4705ce1a --- /dev/null +++ b/client/components/open/forms/CaptchaWrapper.vue @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/client/components/open/forms/FormProgressbar.vue b/client/components/open/forms/FormProgressbar.vue index 3a58be22..602e1b53 100644 --- a/client/components/open/forms/FormProgressbar.vue +++ b/client/components/open/forms/FormProgressbar.vue @@ -1,5 +1,5 @@