Partial submissions (#705)

* Implement partial form submissions feature

* Add status filtering for form submissions

* Add Partial Submission in Analytics

* improve partial submission

* fix lint

* Add type checking for submission ID in form submission job

* on form stats Partial Submissions only if enable

* Partial Submissions is PRO Feature

* Partial Submissions is PRO Feature

* improvement migration

* Update form submission status labels to 'Submitted' and 'In Progress'

* start partial sync when dataFormValue update

* badge size xs

* Refactor partial submission hash management

* Refactor partial form submission handling in PublicFormController

* fix submissiona

* Refactor form submission ID handling and metadata processing

- Improve submission ID extraction and decoding across controllers
- Add robust handling for submission hash and ID conversion
- Enhance metadata processing in StoreFormSubmissionJob
- Simplify submission storage logic with clearer metadata extraction
- Minor UI improvements in FormSubmissions and OpenTable components

* Enhance form submission settings UI with advanced partial submission options

- Restructure partial submissions toggle with more descriptive label
- Add advanced submission options section with Pro tag
- Improve help text for partial submissions feature
- Update ProTag with more detailed upgrade modal description

* Refactor partial form submission sync mechanism

- Improve partial submission synchronization in usePartialSubmission composable
- Replace interval-based sync with Vue's reactive watch
- Add robust handling for different form data input patterns
- Implement onBeforeUnmount hook for final sync attempt
- Enhance data synchronization reliability and performance

* Improve partial form submission validation and synchronization

* fix lint

* Refactor submission identifier processing in PublicFormController

- Updated the docblock for the method responsible for processing submission identifiers to clarify its functionality. The method now explicitly states that it converts a submission hash or string ID into a numeric submission_id, ensuring consistent internal storage format.

These changes aim to improve code documentation and enhance understanding of the method's purpose and behavior.

* Enhance Form Logic Condition Checker to Exclude Partial Submissions

- Updated the query in FormLogicConditionChecker to exclude submissions with a status of 'partial', ensuring that only complete submissions are processed.
- Minor formatting adjustment in the docblock of PublicFormController for improved clarity.

These changes aim to refine submission handling and enhance the accuracy of form logic evaluations.

* Partial Submission Test

* Refactor FormSubmissionController and PartialSubmissionTest for Consistency

- Updated the `FormSubmissionController` to improve code consistency by adjusting the formatting of anonymous functions in the `filter` and `first` methods.
- Modified `PartialSubmissionTest` to simplify the `Storage::fake()` method call, removing the unnecessary 'local' parameter for better clarity.

These changes aim to enhance code readability and maintainability across the form submission handling and testing components.

* Enhance FormSubmissionController and EditSubmissionTest for Clarity

- Added validation to the `FormSubmissionController` by introducing `$submissionData = $request->validated();` to ensure that only validated data is processed for form submissions.
- Improved code readability in the `FormSubmissionController` by adjusting the formatting of anonymous functions in the `filter` and `first` methods.
- Removed unnecessary blank lines in the `EditSubmissionTest` to streamline the test setup.

These changes aim to enhance data integrity during form submissions and improve overall code clarity and maintainability.

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Chirag Chhatrala
2025-04-28 21:03:55 +05:30
committed by GitHub
parent 89885d418e
commit ff1a4d17d8
23 changed files with 784 additions and 92 deletions

View File

@@ -190,7 +190,8 @@ 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 { 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'
@@ -225,6 +226,7 @@ export default {
authenticated: computed(() => authStore.check),
isIframe: useIsIframe(),
pendingSubmission: pendingSubmission(props.form),
partialSubmission: usePartialSubmission(props.form),
confetti: useConfetti()
}
},
@@ -298,13 +300,18 @@ export default {
if (form.busy) return
this.loading = true
if (this.form?.enable_partial_submissions) {
this.partialSubmission.stopSync()
}
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: {
@@ -342,6 +349,10 @@ export default {
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)

View File

@@ -123,7 +123,8 @@ import draggable from 'vuedraggable'
import OpenFormButton from './OpenFormButton.vue'
import CaptchaInput from '~/components/forms/components/CaptchaInput.vue'
import OpenFormField from './OpenFormField.vue'
import {pendingSubmission} from "~/composables/forms/pendingSubmission.js"
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'
@@ -191,6 +192,7 @@ export default {
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,
// Used for admin previews
@@ -203,6 +205,7 @@ export default {
data() {
return {
isAutoSubmit: false,
partialSubmissionStarted: false,
isInitialLoad: true,
// Flag to prevent recursion in field group updates
isUpdatingFieldGroups: false,
@@ -354,10 +357,15 @@ export default {
},
dataFormValue: {
deep: true,
handler() {
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
}
}
},
@@ -391,34 +399,35 @@ export default {
this.submitForm()
}
},
beforeUnmount() {
if (!this.adminPreview && this.form?.enable_partial_submissions) {
this.partialSubmission.stopSync()
}
},
methods: {
async submitForm() {
this.dataForm.busy = true
try {
if (!await this.nextPage()) {
this.dataForm.busy = false
return
}
if (!this.isAutoSubmit && this.formPageIndex !== this.fieldGroups.length - 1) {
this.dataForm.busy = false
return
}
if (this.form.use_captcha && import.meta.client) {
this.$refs.captcha?.reset()
// Process payment if needed
if (!await this.doPayment()) {
return false // Payment failed or was required but not completed
}
this.dataForm.busy = false
// 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
}
// Stop timer and get completion time (from main)
this.$refs['form-timer'].stopTimer()
this.dataForm.completion_time = this.$refs['form-timer'].completionTime
// Add validation strategy check
// Add submission hash for partial submissions (from HEAD)
if (this.form?.enable_partial_submissions) {
this.dataForm.submission_hash = this.partialSubmission.getSubmissionHash()
}
// Add validation strategy check (from main)
if (!this.formModeStrategy.validation.validateOnSubmit) {
this.$emit('submit', this.dataForm, this.onSubmissionFailure)
return
@@ -427,6 +436,7 @@ export default {
this.$emit('submit', this.dataForm, this.onSubmissionFailure)
} catch (error) {
this.handleValidationError(error)
} finally {
this.dataForm.busy = false
}
},
@@ -666,13 +676,14 @@ export default {
}
},
async doPayment() {
// Use the stripeElements from setup instead of calling useStripeElements
const { state: stripeState, processPayment, isCardPopulated, isReadyForPayment } = this.stripeElements
// 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) {

View File

@@ -118,7 +118,12 @@ export default {
borderColor: "rgba(16, 185, 129, 1)",
data: [],
},
],
].concat(this.form.enable_partial_submissions ? [{
label: "Partial Submissions",
backgroundColor: "rgba(255, 193, 7, 1)",
borderColor: "rgba(255, 193, 7, 1)",
data: [],
}] : []),
},
chartOptions: {
scales: {
@@ -172,6 +177,9 @@ export default {
this.chartData.labels = Object.keys(statsData.views)
this.chartData.datasets[0].data = statsData.views
this.chartData.datasets[1].data = statsData.submissions
if (this.form.enable_partial_submissions) {
this.chartData.datasets[2].data = statsData.partial_submissions
}
this.isLoading = false
}
}).catch((error) => {

View File

@@ -42,6 +42,14 @@
</VForm>
</div>
<div class="font-semibold flex gap-2">
<USelectMenu
class="w-32"
v-if="form.enable_partial_submissions"
v-model="selectedStatus"
:options="statusList"
value-attribute="value"
option-attribute="label"
/>
<UButton
size="sm"
color="white"
@@ -125,6 +133,12 @@ export default {
}),
displayColumns: {},
wrapColumns: {},
statusList: [
{ label: 'All', value: 'all' },
{ label: 'Submitted', value: 'completed' },
{ label: 'In Progress', value: 'partial' }
],
selectedStatus: 'all',
}
},
@@ -147,7 +161,11 @@ export default {
filteredData() {
if (!this.tableData) return []
const filteredData = clonedeep(this.tableData)
let filteredData = clonedeep(this.tableData)
if (this.selectedStatus !== 'all') {
filteredData = filteredData.filter((submission) => submission.status === this.selectedStatus)
}
if (this.searchForm.search === '' || this.searchForm.search === null) {
return filteredData
@@ -170,6 +188,9 @@ export default {
},
'searchForm.search'() {
this.dataChanged()
},
'selectedStatus'() {
this.dataChanged()
}
},

View File

@@ -31,7 +31,7 @@
v-if="hasPaymentBlock"
color="primary"
variant="subtle"
title="You have a payment block in your form. so can't disable auto save"
title="Must be enabled with a payment block."
class="max-w-md"
/>
@@ -76,6 +76,31 @@
</div>
</div>
<!-- Advanced Submission Settings -->
<h4 class="font-semibold mt-4 border-t pt-4">
Advanced Submission Options <pro-tag />
</h4>
<p class="text-gray-500 text-sm mb-4">
Configure advanced options for form submissions and data collection.
</p>
<ToggleSwitchInput
name="enable_partial_submissions"
:form="form"
help="Capture incomplete form submissions to analyze user drop-off points and collect partial data even when users don't complete the entire form."
>
<template #label>
<span class="text-sm">
Collect partial submissions
</span>
<ProTag
class="ml-1"
upgrade-modal-title="Upgrade to collect partial submissions"
upgrade-modal-description="Capture valuable data from incomplete form submissions. Analyze where users drop off and collect partial information even when they don't complete the entire form."
/>
</template>
</ToggleSwitchInput>
<!-- Post-Submission Behavior -->
<h4 class="font-semibold mt-4 border-t pt-4">
After Submission <pro-tag

View File

@@ -28,6 +28,18 @@
{{ col.name }}
</p>
</resizable-th>
<th
v-if="hasStatus"
class="n-table-cell p-0 relative"
:class="{ 'border-r': hasActions }"
style="width: 100px"
>
<p
class="bg-gray-50 dark:bg-notion-dark truncate sticky top-0 border-b border-gray-200 dark:border-gray-800 px-4 py-2 text-gray-500 font-semibold tracking-wider uppercase text-xs"
>
Status
</p>
</th>
<th
v-if="hasActions"
class="n-table-cell p-0 relative"
@@ -86,6 +98,19 @@
:value="row[col.id]"
/>
</td>
<td
v-if="hasStatus"
class="n-table-cell border-gray-100 dark:border-gray-900 text-sm p-2 border-b"
:class="{ 'border-r': hasActions }"
style="width: 100px"
>
<UBadge
:label="row.status === 'partial' ? 'In Progress' : 'Submitted'"
:color="row.status === 'partial' ? 'yellow' : 'green'"
variant="soft"
size="xs"
/>
</td>
<td
v-if="hasActions"
class="n-table-cell border-gray-100 dark:border-gray-900 text-sm p-2 border-b"
@@ -229,6 +254,9 @@ export default {
hasActions() {
return !this.workspace.is_readonly
},
hasStatus() {
return this.form.is_pro && (this.form.enable_partial_submissions ?? false)
},
formData() {
return [...this.data].sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
}