import { defineStore } from 'pinia' import { ref, computed, nextTick, watch } from 'vue' import { useLabels } from '@/composables/useLabels' import IvyMessage from '@/views/_components/message/ivyMessage.ts' import { useRouter } from 'vue-router' import { useUnsavedChangesStore } from '@/stores/useUnsavedChangesStore' import { useApiClient } from '@/composables/useApiClient' import { useGlobalState } from '@/stores/useGlobalState' import type { Field } from '@/types/field' import type { FormStyleSettings } from '@/types/form-style' import { normalizeFieldsForBuilder, denormalizeFieldsForApi } from '@/utils/fieldsNormalization' import { buildNewField } from '@/utils/addFieldUtil.ts' import { duplicateFieldForStore } from '@/utils/fieldUtils' import { useFieldWidth } from '@/composables/useFieldWidth' import type { DropZone } from '@/types/grid-layout' import { DEFAULT_FORM_STYLES } from '@/constants/defaultFormStyles' import { getErrorMessage } from '@/utils/errorHandling' const cloneDefaultStyleSettings = (): FormStyleSettings => JSON.parse(JSON.stringify(DEFAULT_FORM_STYLES)) as FormStyleSettings const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) const mergeObjectsDeep = (target: Record, source: Record) => { Object.keys(source).forEach((key) => { const sourceValue = source[key] if (sourceValue === undefined) { return } if (isPlainObject(sourceValue) && isPlainObject(target[key])) { mergeObjectsDeep(target[key] as Record, sourceValue) return } target[key] = sourceValue }) } const mergeStyleSettingsWithDefaults = ( incoming: Partial | null | undefined, ): FormStyleSettings => { const merged = cloneDefaultStyleSettings() if (incoming && isPlainObject(incoming)) { mergeObjectsDeep(merged as Record, incoming as Record) } return merged } export const useFormBuilder = defineStore('formBuilderData', () => { const formId = ref(null) const showSampleView = ref(false) const isEditing = ref(false) const fields = ref([]) const counterFields = ref(0) const selectedField = ref(null) const activeTab = ref('addField') const description = ref(null) const name = ref('Blank form') const published = ref(true) const showTitle = ref(false) const showDescription = ref(false) const storeEntries = ref(false) // Flag to indicate the form was loaded specifically for viewing an entry const loadedForEntry = ref(false) const integrationSettings = ref<{ wpdatatables: { enabled: boolean } [key: string]: Record }>({ wpdatatables: { enabled: true }, }) const styleSettings = ref(mergeStyleSettingsWithDefaults(undefined)) const isEditingSettingGeneral = ref(false) const confirmationId = ref(null) const router = useRouter() const isFormLoading = ref(true) const isDraggingFromPanel = ref(false) const { getLabel } = useLabels() const { request } = useApiClient() // Submit button settings const submitButtonSettings = ref({ label: getLabel('submit'), position: 'default' as 'default' | 'left' | 'center' | 'right', }) const isSubmitButtonSelected = ref(false) const unsavedChangesStore = useUnsavedChangesStore() // Flag to suppress change tracking during load/reset operations let suppressDirtyTracking = false /** * Mark the form builder as having unsaved changes * Called automatically by watchers when state changes */ const markDirty = () => { if (!suppressDirtyTracking && isEditing.value) { unsavedChangesStore.markDirty('formBuilder') } } /** * Mark the form builder as clean (no unsaved changes) * Called after successful save operations */ const markClean = () => { unsavedChangesStore.markClean('formBuilder') } // Watch all tracked state for changes and mark dirty watch( [name, description, published, showTitle, showDescription, storeEntries], () => markDirty(), { deep: false }, ) watch(integrationSettings, () => markDirty(), { deep: true }) watch(submitButtonSettings, () => markDirty(), { deep: true }) watch(fields, () => markDirty(), { deep: true }) const selectField = (fieldIndex: number) => { const field = fields.value.find((field) => field.fieldIndex === fieldIndex) || null // If selecting the same field, still ensure the tab is set to options if (selectedField.value?.fieldIndex === fieldIndex) { activeTab.value = 'options' return } selectedField.value = field isSubmitButtonSelected.value = false activeTab.value = 'options' } const selectSubmitButton = () => { selectedField.value = null isSubmitButtonSelected.value = true activeTab.value = 'options' } const deselectSubmitButton = () => { isSubmitButtonSelected.value = false } const updateSubmitButtonSettings = (key: 'label' | 'position', value: string) => { if ( key === 'position' && (value === 'default' || value === 'left' || value === 'center' || value === 'right') ) { submitButtonSettings.value.position = value } else if (key === 'label') { submitButtonSettings.value.label = value } } function deselectField(event: Event) { event.stopPropagation() const clickedElement = event.target as HTMLElement const fieldElements = document.querySelectorAll('.ivyforms-form-builder__list-group__item') const submitButtonElement = document.querySelector( '.ivyforms-form-builder__submit-button-wrapper', ) let clickedInsideField = false fieldElements.forEach((el) => { if (el.contains(clickedElement)) { clickedInsideField = true } }) // Check if clicked inside submit button if (submitButtonElement && submitButtonElement.contains(clickedElement)) { clickedInsideField = true } if (!clickedInsideField) { selectedField.value = null isSubmitButtonSelected.value = false } } const isSelected = (fieldIndex: number) => { return selectedField.value?.fieldIndex === fieldIndex } // Only update the selectedField object and the canonical entry in fields[] const updateSelectedField = (key: string, value: unknown) => { const sel = selectedField.value as Record | null if (sel) { sel[key] = value } const index = fields.value.findIndex((f) => f.fieldIndex === selectedField.value?.fieldIndex) if (index !== -1) { const target = fields.value[index] as Record if (target !== sel) { target[key] = value } } } function addField(fieldData: Partial & { type: string }) { const newField = buildNewField(fieldData, counterFields.value, getLabel) // If rowIndex, columnIndex, and width are already specified (e.g., from click-to-add), use them if ( fieldData.rowIndex !== undefined && fieldData.columnIndex !== undefined && fieldData.width !== undefined ) { newField.rowIndex = fieldData.rowIndex newField.columnIndex = fieldData.columnIndex newField.width = fieldData.width } else { // Auto-calculate position: add to last row if space available, otherwise create new row const lastRow = Math.max(0, ...fields.value.map((f) => f.rowIndex ?? 0)) const fieldsInLastRow = fields.value.filter((f) => (f.rowIndex ?? 0) === lastRow) let rowIndex = lastRow let columnIndex = fieldsInLastRow.length // If last row is full (5 fields), create a new row if (fieldsInLastRow.length >= 5) { rowIndex = lastRow + 1 columnIndex = 0 } newField.rowIndex = rowIndex newField.columnIndex = columnIndex newField.width = 100 // Will be auto-calculated by useFieldWidth } fields.value.push(newField) updateFieldPositions() // Scroll to the newly added field after DOM update nextTick(() => { const fieldElement = document.querySelector( `[data-field-index="${newField.fieldIndex}"]`, ) as HTMLElement if (fieldElement) { fieldElement.scrollIntoView({ behavior: 'smooth', block: 'center', }) } }).catch(() => { // Ignore nextTick errors }) } const updateFieldPositions = () => { fields.value.forEach((field, fieldIndex) => { field.position = fieldIndex + 1 }) } // Grid layout utilities const { groupFieldsByRow, distributeWidthsEvenly, distributeWidthsProportionally, canAddToRow, getAutoWidth, } = useFieldWidth() // Computed property for grouped fields const fieldsByRow = computed(() => groupFieldsByRow(fields.value)) /** * Move a field to a specific position in the grid * @param fieldIndex - The fieldIndex of the field to move * @param dropZone - The target drop zone (rowIndex, columnIndex, position) */ const moveFieldToPosition = (fieldIndex: number, dropZone: DropZone) => { const field = fields.value.find((f) => f.fieldIndex === fieldIndex) if (!field) return const targetRowIndex = dropZone.rowIndex const targetColumnIndex = dropZone.columnIndex const position = dropZone.position // Handle different drop positions if (position === 'above') { // Insert in a new row above fields.value.forEach((f) => { if ((f.rowIndex ?? 0) >= targetRowIndex) { f.rowIndex = (f.rowIndex ?? 0) + 1 } }) field.rowIndex = targetRowIndex field.columnIndex = 0 } else if (position === 'below') { // Insert in a new row below fields.value.forEach((f) => { if ((f.rowIndex ?? 0) > targetRowIndex) { f.rowIndex = (f.rowIndex ?? 0) + 1 } }) field.rowIndex = targetRowIndex + 1 field.columnIndex = 0 } else if (position === 'left') { // Insert to the left in the same row const fieldsInRow = fields.value.filter((f) => (f.rowIndex ?? 0) === targetRowIndex) if (fieldsInRow.length >= 5) { return } // Shift fields to the right fields.value.forEach((f) => { if ((f.rowIndex ?? 0) === targetRowIndex && (f.columnIndex ?? 0) >= targetColumnIndex) { f.columnIndex = (f.columnIndex ?? 0) + 1 } }) field.rowIndex = targetRowIndex field.columnIndex = targetColumnIndex } else if (position === 'right') { // Insert to the right in the same row const fieldsInRow = fields.value.filter((f) => (f.rowIndex ?? 0) === targetRowIndex) if (fieldsInRow.length >= 5) { return } // Shift fields to the right fields.value.forEach((f) => { if ((f.rowIndex ?? 0) === targetRowIndex && (f.columnIndex ?? 0) > targetColumnIndex) { f.columnIndex = (f.columnIndex ?? 0) + 1 } }) field.rowIndex = targetRowIndex field.columnIndex = targetColumnIndex + 1 } // Recalculate widths for affected rows recalculateRowWidths(field.rowIndex ?? 0) if (position === 'above' || position === 'below') { // Also recalculate the original row if it still exists const originalRow = fields.value.filter((f) => (f.rowIndex ?? 0) === targetRowIndex) if (originalRow.length > 0) { recalculateRowWidths(targetRowIndex) } } updateFieldPositions() } /** * Recalculate widths for all fields in a row * Uses proportional distribution to maintain relative field sizes when adding/removing fields * @param rowIndex - The row to recalculate * @param isAddingField - Whether a new field is being added (affects distribution strategy) */ const recalculateRowWidths = (rowIndex: number, isAddingField: boolean = false) => { const fieldsInRow = fields.value.filter((f) => (f.rowIndex ?? 0) === rowIndex) if (fieldsInRow.length === 0) return // Check if fields have custom widths (not standard auto widths) const hasCustomWidths = fieldsInRow.some((f) => { const expectedAutoWidth = getAutoWidth(fieldsInRow.length) const actualWidth = f.width ?? 100 // Allow 1% tolerance for rounding return Math.abs(actualWidth - expectedAutoWidth) > 1 }) let updatedFields: Field[] if (isAddingField && hasCustomWidths && fieldsInRow.length > 1) { // When adding to a row with custom widths, use proportional distribution // This maintains the relative proportions of existing fields updatedFields = distributeWidthsProportionally(fieldsInRow, 0) } else { // Otherwise, distribute evenly updatedFields = distributeWidthsEvenly(fieldsInRow) } updatedFields.forEach((updatedField) => { const field = fields.value.find((f) => f.fieldIndex === updatedField.fieldIndex) if (field) { field.width = updatedField.width } }) } const increaseCounter = () => { counterFields.value++ return counterFields.value } /** * Apply beforeSaveFields hooks to allow Pro or other extensions to modify fields * @param fieldsArray - The fields array to process * @returns Processed fields array after all hooks have been applied */ const applyBeforeSaveFieldsHooks = (fieldsArray: Field[]): Field[] => { let fieldsToSave = [...fieldsArray] if (typeof window !== 'undefined') { const w = window as Window & { IvyForms?: { hooks?: { beforeSaveFields?: Array<(fields: Field[]) => Field[]> } } } const hooks = w.IvyForms?.hooks?.beforeSaveFields || [] hooks.forEach((hook) => { try { fieldsToSave = hook(fieldsToSave) as Field[] } catch (error) { console.error(getLabel('error_in_before_save_field_hook'), error) } }) } return fieldsToSave } // Load existing form data if formId is provided const loadForm = async (id: string) => { isFormLoading.value = true const globalState = useGlobalState() // Suppress dirty tracking during load suppressDirtyTracking = true try { const { data, error, status } = await request(`form/${id}/`, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }) if (!error && status === 200 && data?.data?.data) { formId.value = id isEditing.value = true isEditingSettingGeneral.value = true const responseData = data.data.data name.value = responseData.name ?? getLabel('blank_form') description.value = responseData.description || '' published.value = responseData.published || false showTitle.value = responseData.showTitle || false showDescription.value = responseData.showDescription || false storeEntries.value = responseData.storeEntries // Load integration settings from the response if (responseData.integrationSettings) { integrationSettings.value = responseData.integrationSettings } // Load submit button settings from the response if (responseData.formActionButtons?.submitButtonSettings) { submitButtonSettings.value = responseData.formActionButtons.submitButtonSettings } // Load style settings from the response if (responseData.styleSettings) { styleSettings.value = mergeStyleSettingsWithDefaults(responseData.styleSettings) } else { // Use default styles if none are saved styleSettings.value = mergeStyleSettingsWithDefaults(undefined) } confirmationId.value = responseData.confirmationId || null const rawFields = (responseData.fields || []) as Field[] fields.value = normalizeFieldsForBuilder(rawFields) // Sort fields by row and column for proper grid display fields.value.sort((a, b) => { const rowA = a.rowIndex ?? 0 const rowB = b.rowIndex ?? 0 if (rowA !== rowB) return rowA - rowB return (a.columnIndex ?? 0) - (b.columnIndex ?? 0) }) if (name.value) { globalState.setPageTitle(name.value) } // Mark form builder as being edited and clean (just loaded) unsavedChangesStore.startEditing('formBuilder') markClean() counterFields.value = fields.value.length ? Math.max(...fields.value.map((f) => f.fieldIndex)) : 0 } else { const errorMessage = getErrorMessage(error) IvyMessage({ message: `${getLabel('failed_to_tab_form')} ${errorMessage}`, type: 'error', }) console.error(getLabel('failed_to_tab_form'), errorMessage) } } catch (error) { const errorMessage = getErrorMessage(error) IvyMessage({ message: `${getLabel('error_loading_form')} ${errorMessage}`, type: 'error', }) console.error(getLabel('error_loading_form'), error) } finally { isFormLoading.value = false // Wait for watchers to fire before re-enabling dirty tracking await nextTick() suppressDirtyTracking = false } } const updateForm = async () => { if (!formId.value) return if (!name.value) { name.value = `${getLabel('form')} #${formId.value}` } // Allow Pro or other extensions to modify fields before saving const fieldsToSave = applyBeforeSaveFieldsHooks(fields.value) const denormalizedFields = denormalizeFieldsForApi(fieldsToSave) const formData = { name: name.value ?? '', description: description.value || '', published: published.value || false, showTitle: showTitle.value || false, showDescription: showDescription.value || false, storeEntries: storeEntries.value, integrationSettings: integrationSettings.value, styleSettings: styleSettings.value, fields: denormalizedFields, formActionButtons: { submitButtonSettings: submitButtonSettings.value, }, } try { const { error, status } = await request(`form/update/${formId.value}/`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, data: formData, }) if (!error && status === 200) { IvyMessage({ message: getLabel('form_updated'), type: 'success', }) markClean() } else { const errorMessage = getErrorMessage(error) IvyMessage({ message: `${getLabel('failed_to_update_form')} ${errorMessage}`, type: 'error', }) console.error(getLabel('failed_to_update_form'), errorMessage) } } catch (error) { const errorMessage = getErrorMessage(error) IvyMessage({ message: `${getLabel('error_updating_form')} ${errorMessage}`, type: 'error', }) console.error(getLabel('error_updating_form'), error) } } // Save form using WordPress REST API const saveForm = async (options?: { template_id?: string }) => { // Allow Pro or other extensions to modify fields before saving const fieldsToSave = applyBeforeSaveFieldsHooks(fields.value) const denormalizedFields = denormalizeFieldsForApi(fieldsToSave) const formData = { name: name.value ?? '', description: description.value || '', showTitle: showTitle.value || false, published: published.value || false, showDescription: showDescription.value || false, storeEntries: storeEntries.value, integrationSettings: integrationSettings.value, styleSettings: styleSettings.value, fields: denormalizedFields, formActionButtons: { submitButtonSettings: submitButtonSettings.value, }, ...(options?.template_id && { template_id: options.template_id }), } try { const { data, error, status } = await request('form/add/', { method: 'POST', headers: { 'Content-Type': 'application/json', }, data: formData, }) if (!error && status === 200 && data?.data?.data?.id) { const responseData = data.data.data formId.value = responseData.id confirmationId.value = responseData.confirmationId isEditing.value = true isEditingSettingGeneral.value = true // Start editing tracking for the newly saved form unsavedChangesStore.startEditing('formBuilder') await router.push({ hash: `manage/${formId.value}` }) IvyMessage({ message: getLabel('form_saved'), type: 'success', }) markClean() } else { const errorMessage = getErrorMessage(error) IvyMessage({ message: `${getLabel('failed_to_save_form')} ${errorMessage}`, type: 'error', }) } } catch (error) { const errorMessage = getErrorMessage(error) IvyMessage({ message: `${getLabel('error_save_form')} ${errorMessage}`, type: 'error', }) } } const saveFormSettings = async () => { // ...existing code... const fieldsToSave = applyBeforeSaveFieldsHooks(fields.value) const denormalizedFields = denormalizeFieldsForApi(fieldsToSave) const formData = { name: name.value ?? '', description: description.value || '', published: published.value || false, showTitle: showTitle.value || false, showDescription: showDescription.value || false, storeEntries: storeEntries.value, fields: denormalizedFields, formActionButtons: { submitButtonSettings: submitButtonSettings.value, }, } try { const { data, error, status } = await request('form/add/', { method: 'POST', headers: { 'Content-Type': 'application/json', }, data: formData, }) if (!error && status === 200 && data?.data?.data?.id) { formId.value = data.data.data.id isEditing.value = true isEditingSettingGeneral.value = true // Start editing tracking for the newly saved form unsavedChangesStore.startEditing('formBuilder') await router.push({ hash: `/settings/general/manage/${formId.value}` }) IvyMessage({ message: getLabel('form_saved'), type: 'success', }) markClean() } else { const errorMessage = getErrorMessage(error) IvyMessage({ message: `${getLabel('failed_to_save_form')} ${errorMessage}`, type: 'error', }) } } catch (error) { const errorMessage = getErrorMessage(error) IvyMessage({ message: `${getLabel('error_save_form')} ${errorMessage}`, type: 'error', }) } } const moveFieldUp = (fieldIndex: number) => { const currentIndex = fields.value.findIndex((f) => f.fieldIndex === fieldIndex) if (currentIndex > 0) { const temp = fields.value[currentIndex] fields.value[currentIndex] = fields.value[currentIndex - 1] fields.value[currentIndex - 1] = temp updateFieldPositions() // Keep the field selected after moving if (selectedField.value?.fieldIndex === fieldIndex) { selectedField.value = temp } // Scroll the field into view if it moved off-screen setTimeout(() => { const fieldElement = document.querySelector( `[data-field-index="${fieldIndex}"]`, ) as HTMLElement if (fieldElement) { fieldElement.style.scrollMargin = '20px' fieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) } }, 100) } } const moveFieldDown = (fieldIndex: number) => { const currentIndex = fields.value.findIndex((f) => f.fieldIndex === fieldIndex) if (currentIndex < fields.value.length - 1) { const temp = fields.value[currentIndex] fields.value[currentIndex] = fields.value[currentIndex + 1] fields.value[currentIndex + 1] = temp updateFieldPositions() // Keep the field selected after moving if (selectedField.value?.fieldIndex === fieldIndex) { selectedField.value = temp } // Scroll the field into view if it moved off-screen setTimeout(() => { const fieldElement = document.querySelector( `[data-field-index="${fieldIndex}"]`, ) as HTMLElement if (fieldElement) { fieldElement.style.scrollMargin = '20px' fieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) } }, 100) } } const moveFieldLeft = (fieldIndex: number) => { const field = fields.value.find((f) => f.fieldIndex === fieldIndex) if (!field || field.columnIndex === undefined) return // Get all fields in the same row, sorted by columnIndex const fieldsInRow = fields.value .filter((f) => (f.rowIndex ?? 0) === (field.rowIndex ?? 0)) .sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0)) const currentPosition = fieldsInRow.findIndex((f) => f.fieldIndex === fieldIndex) // Can't move left if already first if (currentPosition <= 0) return // Swap columnIndex with field to the left const leftField = fieldsInRow[currentPosition - 1] const tempColumnIndex = field.columnIndex field.columnIndex = leftField.columnIndex leftField.columnIndex = tempColumnIndex updateFieldPositions() // Keep the field selected after moving if (selectedField.value?.fieldIndex === fieldIndex) { selectedField.value = field } } const moveFieldRight = (fieldIndex: number) => { const field = fields.value.find((f) => f.fieldIndex === fieldIndex) if (!field || field.columnIndex === undefined) return // Get all fields in the same row, sorted by columnIndex const fieldsInRow = fields.value .filter((f) => (f.rowIndex ?? 0) === (field.rowIndex ?? 0)) .sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0)) const currentPosition = fieldsInRow.findIndex((f) => f.fieldIndex === fieldIndex) // Can't move right if already last if (currentPosition >= fieldsInRow.length - 1) return // Swap columnIndex with field to the right const rightField = fieldsInRow[currentPosition + 1] const tempColumnIndex = field.columnIndex field.columnIndex = rightField.columnIndex rightField.columnIndex = tempColumnIndex updateFieldPositions() // Keep the field selected after moving if (selectedField.value?.fieldIndex === fieldIndex) { selectedField.value = field } } const deleteField = (fieldIndex: number) => { const i = fields.value.findIndex((f) => f.fieldIndex === fieldIndex) if (i !== -1) { const deletedField = fields.value[i] const rowIndex = deletedField.rowIndex ?? 0 // Get remaining fields in row BEFORE deletion const remainingFields = fields.value.filter( (f) => f.rowIndex === rowIndex && f.fieldIndex !== fieldIndex, ) // Reset remaining fields to even distribution and update columnIndex if (remainingFields.length > 0) { const evenWidth = remainingFields.length >= 5 ? 20 : Math.round((100 / remainingFields.length) * 10) / 10 // Sort by columnIndex to maintain left-to-right order remainingFields.sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0)) remainingFields.forEach((f, index) => { f.width = evenWidth f.columnIndex = index // Reassign columnIndex sequentially from 0 }) } fields.value.splice(i, 1) if (selectedField.value?.fieldIndex === fieldIndex) { selectedField.value = null } updateFieldPositions() } } const duplicateField = (fieldIndex: number) => { const original = fields.value.find((f) => f.fieldIndex === fieldIndex) if (!original) return // Check if current row has space const currentRowIndex = original.rowIndex ?? 0 const fieldsInCurrentRow = fields.value.filter((f) => (f.rowIndex ?? 0) === currentRowIndex) const newIndex = increaseCounter() const copy = duplicateFieldForStore(original, newIndex, getLabel) const insertAt = fields.value.findIndex((f) => f.fieldIndex === fieldIndex) // If field is alone in a row OR row is full, create new row below if (fieldsInCurrentRow.length === 1 || fieldsInCurrentRow.length >= 5) { // Create new row below copy.rowIndex = currentRowIndex + 1 copy.columnIndex = 0 copy.width = 100 // Shift all rows below down by 1 fields.value.forEach((f) => { if ((f.rowIndex ?? 0) > currentRowIndex) { f.rowIndex = (f.rowIndex ?? 0) + 1 } }) fields.value.splice(insertAt + 1, 0, copy) } else { // Add to same row (multi-field row with space) copy.rowIndex = currentRowIndex copy.columnIndex = original.columnIndex! + 1 // Shift column indexes for fields to the right fields.value.forEach((f) => { if ( (f.rowIndex ?? 0) === currentRowIndex && (f.columnIndex ?? 0) > (original.columnIndex ?? 0) ) { f.columnIndex = (f.columnIndex ?? 0) + 1 } }) fields.value.splice(insertAt + 1, 0, copy) // Reset all fields in row to even distribution const fieldsInRow = fields.value.filter((f) => (f.rowIndex ?? 0) === currentRowIndex) const evenWidth = fieldsInRow.length >= 5 ? 20 : Math.round((100 / fieldsInRow.length) * 10) / 10 fieldsInRow.forEach((f) => { f.width = evenWidth }) } updateFieldPositions() // Select the newly duplicated field selectedField.value = copy activeTab.value = 'options' } const resetForm = () => { // Suppress dirty tracking during reset suppressDirtyTracking = true formId.value = null isEditing.value = false fields.value = [] counterFields.value = 0 selectedField.value = null activeTab.value = 'addField' description.value = null name.value = 'Blank form' published.value = true showTitle.value = true showDescription.value = false storeEntries.value = true styleSettings.value = mergeStyleSettingsWithDefaults(undefined) isEditingSettingGeneral.value = false confirmationId.value = null isFormLoading.value = false submitButtonSettings.value = { label: getLabel('submit'), position: 'default', } isSubmitButtonSelected.value = false // Stop editing and clear dirty flag unsavedChangesStore.stopEditing('formBuilder') // Re-enable dirty tracking suppressDirtyTracking = false } const updateFieldOptions = (key: string, value: unknown) => { if (!selectedField.value) return const index = fields.value.findIndex((f) => f.fieldIndex === selectedField.value?.fieldIndex) if (index !== -1) { // Mutate the existing object instead of creating a new one // This keeps the reference intact for selectedField const target = fields.value[index] as Record target[key] = value } } const updateStyleSettings = (path: string, value: unknown) => { const keys = path.split('.') let current: Record = styleSettings.value as Record // Ensure quickMode object exists if (keys[0] === 'quickMode' && !current.quickMode) { current.quickMode = {} } // Ensure advancedMode object exists if (keys[0] === 'advancedMode' && !current.advancedMode) { current.advancedMode = {} } for (let i = 0; i < keys.length - 1; i++) { if (!current[keys[i]]) { current[keys[i]] = {} } current = current[keys[i]] as Record } current[keys[keys.length - 1]] = value } const resetStyleToDefault = () => { styleSettings.value = mergeStyleSettingsWithDefaults(undefined) } return { formId, loadedForEntry, fields, isEditing, selectedField, activeTab, counterFields, description, name, published, showTitle, showDescription, storeEntries, integrationSettings, styleSettings, isEditingSettingGeneral, confirmationId, isFormLoading, isDraggingFromPanel, markDirty, markClean, saveForm, loadForm, updateForm, increaseCounter, addField, selectField, deselectField, isSelected, updateSelectedField, updateFieldOptions, updateStyleSettings, resetStyleToDefault, updateFieldPositions, moveFieldUp, moveFieldDown, moveFieldLeft, moveFieldRight, deleteField, duplicateField, saveFormSettings, resetForm, // Grid layout methods fieldsByRow, moveFieldToPosition, recalculateRowWidths, canAddToRow, getAutoWidth, // Submit button submitButtonSettings, isSubmitButtonSelected, selectSubmitButton, deselectSubmitButton, updateSubmitButtonSettings, showSampleView, } })