PluginProbe
The Innovative Form Builder – IvyForms / 0.9.1
The Innovative Form Builder – IvyForms v0.9.1
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / frontend / src / stores / useFormBuilder.ts

useFormBuilder.ts in The Innovative Form Builder – IvyForms 0.9.1, at frontend/src/stores/useFormBuilder.ts

939 lines 30.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { defineStore } from 'pinia'
2 import { ref, computed, nextTick, watch } from 'vue'
3 import { useLabels } from '@/composables/useLabels'
4 import IvyMessage from '@/views/_components/message/ivyMessage.ts'
5 import { useRouter } from 'vue-router'
6 import { useUnsavedChangesStore } from '@/stores/useUnsavedChangesStore'
7 import { useApiClient } from '@/composables/useApiClient'
8 import { useGlobalState } from '@/stores/useGlobalState'
9 import type { Field } from '@/types/field'
10 import { normalizeFieldsForBuilder, denormalizeFieldsForApi } from '@/utils/fieldsNormalization'
11 import { buildNewField } from '@/utils/addFieldUtil.ts'
12 import { duplicateFieldForStore } from '@/utils/fieldUtils'
13 import { useFieldWidth } from '@/composables/useFieldWidth'
14 import type { DropZone } from '@/types/grid-layout'
15 import { getErrorMessage } from '@/utils/errorHandling'
16
17 export const useFormBuilder = defineStore('formBuilderData', () => {
18 const formId = ref<string | null>(null)
19 const isEditing = ref(false)
20 const fields = ref<Field[]>([])
21 const counterFields = ref<number>(0)
22 const selectedField = ref<Field | null>(null)
23 const activeTab = ref<string>('addField')
24 const description = ref<string | null>(null)
25 const name = ref<string | null>('Blank form')
26 const published = ref(true)
27 const showTitle = ref(false)
28 const showDescription = ref(false)
29 const storeEntries = ref(false)
30 // Flag to indicate the form was loaded specifically for viewing an entry
31 const loadedForEntry = ref<boolean>(false)
32 const integrationSettings = ref<{
33 wpdatatables: { enabled: boolean }
34 [key: string]: Record<string, unknown>
35 }>({
36 wpdatatables: { enabled: true },
37 })
38 const isEditingSettingGeneral = ref(false)
39 const confirmationId = ref<string | null>(null)
40 const router = useRouter()
41 const isFormLoading = ref(true)
42 const isDraggingFromPanel = ref(false)
43
44 const { getLabel } = useLabels()
45 const { request } = useApiClient()
46
47 // Submit button settings
48 const submitButtonSettings = ref({
49 label: getLabel('submit'),
50 position: 'default' as 'default' | 'left' | 'center' | 'right',
51 })
52 const isSubmitButtonSelected = ref(false)
53 const unsavedChangesStore = useUnsavedChangesStore()
54
55 // Flag to suppress change tracking during load/reset operations
56 let suppressDirtyTracking = false
57
58 /**
59 * Mark the form builder as having unsaved changes
60 * Called automatically by watchers when state changes
61 */
62 const markDirty = () => {
63 if (!suppressDirtyTracking && isEditing.value) {
64 unsavedChangesStore.markDirty('formBuilder')
65 }
66 }
67
68 /**
69 * Mark the form builder as clean (no unsaved changes)
70 * Called after successful save operations
71 */
72 const markClean = () => {
73 unsavedChangesStore.markClean('formBuilder')
74 }
75
76 // Watch all tracked state for changes and mark dirty
77 watch(
78 [name, description, published, showTitle, showDescription, storeEntries],
79 () => markDirty(),
80 { deep: false },
81 )
82
83 watch(integrationSettings, () => markDirty(), { deep: true })
84 watch(submitButtonSettings, () => markDirty(), { deep: true })
85 watch(fields, () => markDirty(), { deep: true })
86
87 const selectField = (fieldIndex: number) => {
88 const field = fields.value.find((field) => field.fieldIndex === fieldIndex) || null
89
90 // If selecting the same field, still ensure the tab is set to options
91 if (selectedField.value?.fieldIndex === fieldIndex) {
92 activeTab.value = 'options'
93 return
94 }
95
96 selectedField.value = field
97 isSubmitButtonSelected.value = false
98 activeTab.value = 'options'
99 }
100
101 const selectSubmitButton = () => {
102 selectedField.value = null
103 isSubmitButtonSelected.value = true
104 activeTab.value = 'options'
105 }
106
107 const deselectSubmitButton = () => {
108 isSubmitButtonSelected.value = false
109 }
110
111 const updateSubmitButtonSettings = (key: 'label' | 'position', value: string) => {
112 if (
113 key === 'position' &&
114 (value === 'default' || value === 'left' || value === 'center' || value === 'right')
115 ) {
116 submitButtonSettings.value.position = value
117 } else if (key === 'label') {
118 submitButtonSettings.value.label = value
119 }
120 }
121
122 function deselectField(event: Event) {
123 event.stopPropagation()
124 const clickedElement = event.target as HTMLElement
125 const fieldElements = document.querySelectorAll('.ivyforms-form-builder__list-group__item')
126 const submitButtonElement = document.querySelector(
127 '.ivyforms-form-builder__submit-button-wrapper',
128 )
129 let clickedInsideField = false
130
131 fieldElements.forEach((el) => {
132 if (el.contains(clickedElement)) {
133 clickedInsideField = true
134 }
135 })
136
137 // Check if clicked inside submit button
138 if (submitButtonElement && submitButtonElement.contains(clickedElement)) {
139 clickedInsideField = true
140 }
141
142 if (!clickedInsideField) {
143 selectedField.value = null
144 isSubmitButtonSelected.value = false
145 }
146 }
147
148 const isSelected = (fieldIndex: number) => {
149 return selectedField.value?.fieldIndex === fieldIndex
150 }
151
152 // Only update the selectedField object and the canonical entry in fields[]
153 const updateSelectedField = (key: string, value: unknown) => {
154 const sel = selectedField.value as Record<string, unknown> | null
155 if (sel) {
156 sel[key] = value
157 }
158 const index = fields.value.findIndex((f) => f.fieldIndex === selectedField.value?.fieldIndex)
159 if (index !== -1) {
160 const target = fields.value[index] as Record<string, unknown>
161 if (target !== sel) {
162 target[key] = value
163 }
164 }
165 }
166
167 function addField(fieldData: Partial<Field> & { type: string }) {
168 const newField = buildNewField(fieldData, counterFields.value, getLabel)
169
170 // If rowIndex, columnIndex, and width are already specified (e.g., from click-to-add), use them
171 if (
172 fieldData.rowIndex !== undefined &&
173 fieldData.columnIndex !== undefined &&
174 fieldData.width !== undefined
175 ) {
176 newField.rowIndex = fieldData.rowIndex
177 newField.columnIndex = fieldData.columnIndex
178 newField.width = fieldData.width
179 } else {
180 // Auto-calculate position: add to last row if space available, otherwise create new row
181 const lastRow = Math.max(0, ...fields.value.map((f) => f.rowIndex ?? 0))
182 const fieldsInLastRow = fields.value.filter((f) => (f.rowIndex ?? 0) === lastRow)
183
184 let rowIndex = lastRow
185 let columnIndex = fieldsInLastRow.length
186
187 // If last row is full (5 fields), create a new row
188 if (fieldsInLastRow.length >= 5) {
189 rowIndex = lastRow + 1
190 columnIndex = 0
191 }
192
193 newField.rowIndex = rowIndex
194 newField.columnIndex = columnIndex
195 newField.width = 100 // Will be auto-calculated by useFieldWidth
196 }
197
198 fields.value.push(newField)
199 updateFieldPositions()
200
201 // Scroll to the newly added field after DOM update
202 nextTick(() => {
203 const fieldElement = document.querySelector(
204 `[data-field-index="${newField.fieldIndex}"]`,
205 ) as HTMLElement
206 if (fieldElement) {
207 fieldElement.scrollIntoView({
208 behavior: 'smooth',
209 block: 'center',
210 })
211 }
212 }).catch(() => {
213 // Ignore nextTick errors
214 })
215 }
216
217 const updateFieldPositions = () => {
218 fields.value.forEach((field, fieldIndex) => {
219 field.position = fieldIndex + 1
220 })
221 }
222
223 // Grid layout utilities
224 const {
225 groupFieldsByRow,
226 distributeWidthsEvenly,
227 distributeWidthsProportionally,
228 canAddToRow,
229 getAutoWidth,
230 } = useFieldWidth()
231
232 // Computed property for grouped fields
233 const fieldsByRow = computed(() => groupFieldsByRow(fields.value))
234
235 /**
236 * Move a field to a specific position in the grid
237 * @param fieldIndex - The fieldIndex of the field to move
238 * @param dropZone - The target drop zone (rowIndex, columnIndex, position)
239 */
240 const moveFieldToPosition = (fieldIndex: number, dropZone: DropZone) => {
241 const field = fields.value.find((f) => f.fieldIndex === fieldIndex)
242 if (!field) return
243
244 const targetRowIndex = dropZone.rowIndex
245 const targetColumnIndex = dropZone.columnIndex
246 const position = dropZone.position
247
248 // Handle different drop positions
249 if (position === 'above') {
250 // Insert in a new row above
251 fields.value.forEach((f) => {
252 if ((f.rowIndex ?? 0) >= targetRowIndex) {
253 f.rowIndex = (f.rowIndex ?? 0) + 1
254 }
255 })
256 field.rowIndex = targetRowIndex
257 field.columnIndex = 0
258 } else if (position === 'below') {
259 // Insert in a new row below
260 fields.value.forEach((f) => {
261 if ((f.rowIndex ?? 0) > targetRowIndex) {
262 f.rowIndex = (f.rowIndex ?? 0) + 1
263 }
264 })
265 field.rowIndex = targetRowIndex + 1
266 field.columnIndex = 0
267 } else if (position === 'left') {
268 // Insert to the left in the same row
269 const fieldsInRow = fields.value.filter((f) => (f.rowIndex ?? 0) === targetRowIndex)
270 if (fieldsInRow.length >= 5) {
271 return
272 }
273 // Shift fields to the right
274 fields.value.forEach((f) => {
275 if ((f.rowIndex ?? 0) === targetRowIndex && (f.columnIndex ?? 0) >= targetColumnIndex) {
276 f.columnIndex = (f.columnIndex ?? 0) + 1
277 }
278 })
279 field.rowIndex = targetRowIndex
280 field.columnIndex = targetColumnIndex
281 } else if (position === 'right') {
282 // Insert to the right in the same row
283 const fieldsInRow = fields.value.filter((f) => (f.rowIndex ?? 0) === targetRowIndex)
284 if (fieldsInRow.length >= 5) {
285 return
286 }
287 // Shift fields to the right
288 fields.value.forEach((f) => {
289 if ((f.rowIndex ?? 0) === targetRowIndex && (f.columnIndex ?? 0) > targetColumnIndex) {
290 f.columnIndex = (f.columnIndex ?? 0) + 1
291 }
292 })
293 field.rowIndex = targetRowIndex
294 field.columnIndex = targetColumnIndex + 1
295 }
296
297 // Recalculate widths for affected rows
298 recalculateRowWidths(field.rowIndex ?? 0)
299 if (position === 'above' || position === 'below') {
300 // Also recalculate the original row if it still exists
301 const originalRow = fields.value.filter((f) => (f.rowIndex ?? 0) === targetRowIndex)
302 if (originalRow.length > 0) {
303 recalculateRowWidths(targetRowIndex)
304 }
305 }
306
307 updateFieldPositions()
308 }
309
310 /**
311 * Recalculate widths for all fields in a row
312 * Uses proportional distribution to maintain relative field sizes when adding/removing fields
313 * @param rowIndex - The row to recalculate
314 * @param isAddingField - Whether a new field is being added (affects distribution strategy)
315 */
316 const recalculateRowWidths = (rowIndex: number, isAddingField: boolean = false) => {
317 const fieldsInRow = fields.value.filter((f) => (f.rowIndex ?? 0) === rowIndex)
318
319 if (fieldsInRow.length === 0) return
320
321 // Check if fields have custom widths (not standard auto widths)
322 const hasCustomWidths = fieldsInRow.some((f) => {
323 const expectedAutoWidth = getAutoWidth(fieldsInRow.length)
324 const actualWidth = f.width ?? 100
325 // Allow 1% tolerance for rounding
326 return Math.abs(actualWidth - expectedAutoWidth) > 1
327 })
328
329 let updatedFields: Field[]
330
331 if (isAddingField && hasCustomWidths && fieldsInRow.length > 1) {
332 // When adding to a row with custom widths, use proportional distribution
333 // This maintains the relative proportions of existing fields
334 updatedFields = distributeWidthsProportionally(fieldsInRow, 0)
335 } else {
336 // Otherwise, distribute evenly
337 updatedFields = distributeWidthsEvenly(fieldsInRow)
338 }
339
340 updatedFields.forEach((updatedField) => {
341 const field = fields.value.find((f) => f.fieldIndex === updatedField.fieldIndex)
342 if (field) {
343 field.width = updatedField.width
344 }
345 })
346 }
347
348 const increaseCounter = () => {
349 counterFields.value++
350 return counterFields.value
351 }
352
353 /**
354 * Apply beforeSaveFields hooks to allow Pro or other extensions to modify fields
355 * @param fieldsArray - The fields array to process
356 * @returns Processed fields array after all hooks have been applied
357 */
358 const applyBeforeSaveFieldsHooks = (fieldsArray: Field[]): Field[] => {
359 let fieldsToSave = [...fieldsArray]
360
361 if (typeof window !== 'undefined') {
362 const w = window as Window & {
363 IvyForms?: {
364 hooks?: {
365 beforeSaveFields?: Array<(fields: Field[]) => Field[]>
366 }
367 }
368 }
369 const hooks = w.IvyForms?.hooks?.beforeSaveFields || []
370 hooks.forEach((hook) => {
371 try {
372 fieldsToSave = hook(fieldsToSave) as Field[]
373 } catch (error) {
374 console.error(getLabel('error_in_before_save_field_hook'), error)
375 }
376 })
377 }
378
379 return fieldsToSave
380 }
381
382 // Load existing form data if formId is provided
383 const loadForm = async (id: string) => {
384 isFormLoading.value = true
385 const globalState = useGlobalState()
386
387 // Suppress dirty tracking during load
388 suppressDirtyTracking = true
389
390 try {
391 const { data, error, status } = await request(`form/${id}/`, {
392 method: 'GET',
393 headers: {
394 'Content-Type': 'application/json',
395 },
396 })
397 if (!error && status === 200 && data?.data?.data) {
398 formId.value = id
399 isEditing.value = true
400 isEditingSettingGeneral.value = true
401 const responseData = data.data.data
402 name.value = responseData.name ?? getLabel('blank_form')
403 description.value = responseData.description || ''
404 published.value = responseData.published || false
405 showTitle.value = responseData.showTitle || false
406 showDescription.value = responseData.showDescription || false
407 storeEntries.value = responseData.storeEntries
408
409 // Load integration settings from the response
410 if (responseData.integrationSettings) {
411 integrationSettings.value = responseData.integrationSettings
412 }
413
414 // Load submit button settings from the response
415 if (responseData.formActionButtons?.submitButtonSettings) {
416 submitButtonSettings.value = responseData.formActionButtons.submitButtonSettings
417 }
418
419 confirmationId.value = responseData.confirmationId || null
420
421 const rawFields = (responseData.fields || []) as Field[]
422
423 fields.value = normalizeFieldsForBuilder(rawFields)
424
425 // Sort fields by row and column for proper grid display
426 fields.value.sort((a, b) => {
427 const rowA = a.rowIndex ?? 0
428 const rowB = b.rowIndex ?? 0
429 if (rowA !== rowB) return rowA - rowB
430 return (a.columnIndex ?? 0) - (b.columnIndex ?? 0)
431 })
432
433 if (name.value) {
434 globalState.setPageTitle(name.value)
435 }
436
437 // Mark form builder as being edited and clean (just loaded)
438 unsavedChangesStore.startEditing('formBuilder')
439 markClean()
440
441 counterFields.value = fields.value.length
442 ? Math.max(...fields.value.map((f) => f.fieldIndex))
443 : 0
444 } else {
445 const errorMessage = getErrorMessage(error)
446 IvyMessage({
447 message: `${getLabel('failed_to_tab_form')} ${errorMessage}`,
448 type: 'error',
449 })
450 console.error(getLabel('failed_to_tab_form'), errorMessage)
451 }
452 } catch (error) {
453 const errorMessage = getErrorMessage(error)
454 IvyMessage({
455 message: `${getLabel('error_loading_form')} ${errorMessage}`,
456 type: 'error',
457 })
458 console.error(getLabel('error_loading_form'), error)
459 } finally {
460 isFormLoading.value = false
461 // Wait for watchers to fire before re-enabling dirty tracking
462 await nextTick()
463 suppressDirtyTracking = false
464 }
465 }
466
467 const updateForm = async () => {
468 if (!formId.value) return
469
470 if (!name.value) {
471 name.value = `${getLabel('form')} #${formId.value}`
472 }
473
474 // Allow Pro or other extensions to modify fields before saving
475 const fieldsToSave = applyBeforeSaveFieldsHooks(fields.value)
476
477 const denormalizedFields = denormalizeFieldsForApi(fieldsToSave)
478
479 const formData = {
480 name: name.value ?? '',
481 description: description.value || '',
482 published: published.value || false,
483 showTitle: showTitle.value || false,
484 showDescription: showDescription.value || false,
485 storeEntries: storeEntries.value,
486 integrationSettings: integrationSettings.value,
487 fields: denormalizedFields,
488 formActionButtons: {
489 submitButtonSettings: submitButtonSettings.value,
490 },
491 }
492
493 try {
494 const { error, status } = await request(`form/update/${formId.value}/`, {
495 method: 'POST',
496 headers: {
497 'Content-Type': 'application/json',
498 },
499 data: formData,
500 })
501 if (!error && status === 200) {
502 IvyMessage({
503 message: getLabel('form_updated'),
504 type: 'success',
505 })
506 markClean()
507 } else {
508 const errorMessage = getErrorMessage(error)
509 IvyMessage({
510 message: `${getLabel('failed_to_update_form')} ${errorMessage}`,
511 type: 'error',
512 })
513 console.error(getLabel('failed_to_update_form'), errorMessage)
514 }
515 } catch (error) {
516 const errorMessage = getErrorMessage(error)
517 IvyMessage({
518 message: `${getLabel('error_updating_form')} ${errorMessage}`,
519 type: 'error',
520 })
521 console.error(getLabel('error_updating_form'), error)
522 }
523 }
524
525 // Save form using WordPress REST API
526 const saveForm = async (options?: { template_id?: string }) => {
527 // Allow Pro or other extensions to modify fields before saving
528 const fieldsToSave = applyBeforeSaveFieldsHooks(fields.value)
529
530 const denormalizedFields = denormalizeFieldsForApi(fieldsToSave)
531
532 const formData = {
533 name: name.value ?? '',
534 description: description.value || '',
535 showTitle: showTitle.value || false,
536 published: published.value || false,
537 showDescription: showDescription.value || false,
538 storeEntries: storeEntries.value,
539 integrationSettings: integrationSettings.value,
540 fields: denormalizedFields,
541 formActionButtons: {
542 submitButtonSettings: submitButtonSettings.value,
543 },
544 ...(options?.template_id && { template_id: options.template_id }),
545 }
546
547 try {
548 const { data, error, status } = await request('form/add/', {
549 method: 'POST',
550 headers: {
551 'Content-Type': 'application/json',
552 },
553 data: formData,
554 })
555 if (!error && status === 200 && data?.data?.data?.id) {
556 const responseData = data.data.data
557 formId.value = responseData.id
558 confirmationId.value = responseData.confirmationId
559 isEditing.value = true
560 isEditingSettingGeneral.value = true
561 // Start editing tracking for the newly saved form
562 unsavedChangesStore.startEditing('formBuilder')
563 await router.push({ hash: `manage/${formId.value}` })
564 IvyMessage({
565 message: getLabel('form_saved'),
566 type: 'success',
567 })
568 markClean()
569 } else {
570 const errorMessage = getErrorMessage(error)
571 IvyMessage({
572 message: `${getLabel('failed_to_save_form')} ${errorMessage}`,
573 type: 'error',
574 })
575 }
576 } catch (error) {
577 const errorMessage = getErrorMessage(error)
578 IvyMessage({
579 message: `${getLabel('error_save_form')} ${errorMessage}`,
580 type: 'error',
581 })
582 }
583 }
584
585 const saveFormSettings = async () => {
586 // ...existing code...
587 const fieldsToSave = applyBeforeSaveFieldsHooks(fields.value)
588
589 const denormalizedFields = denormalizeFieldsForApi(fieldsToSave)
590
591 const formData = {
592 name: name.value ?? '',
593 description: description.value || '',
594 published: published.value || false,
595 showTitle: showTitle.value || false,
596 showDescription: showDescription.value || false,
597 storeEntries: storeEntries.value,
598 fields: denormalizedFields,
599 formActionButtons: {
600 submitButtonSettings: submitButtonSettings.value,
601 },
602 }
603 try {
604 const { data, error, status } = await request('form/add/', {
605 method: 'POST',
606 headers: {
607 'Content-Type': 'application/json',
608 },
609 data: formData,
610 })
611 if (!error && status === 200 && data?.data?.data?.id) {
612 formId.value = data.data.data.id
613 isEditing.value = true
614 isEditingSettingGeneral.value = true
615 // Start editing tracking for the newly saved form
616 unsavedChangesStore.startEditing('formBuilder')
617 await router.push({ hash: `/settings/general/manage/${formId.value}` })
618 IvyMessage({
619 message: getLabel('form_saved'),
620 type: 'success',
621 })
622 markClean()
623 } else {
624 const errorMessage = getErrorMessage(error)
625 IvyMessage({
626 message: `${getLabel('failed_to_save_form')} ${errorMessage}`,
627 type: 'error',
628 })
629 }
630 } catch (error) {
631 const errorMessage = getErrorMessage(error)
632 IvyMessage({
633 message: `${getLabel('error_save_form')} ${errorMessage}`,
634 type: 'error',
635 })
636 }
637 }
638
639 const moveFieldUp = (fieldIndex: number) => {
640 const currentIndex = fields.value.findIndex((f) => f.fieldIndex === fieldIndex)
641 if (currentIndex > 0) {
642 const temp = fields.value[currentIndex]
643 fields.value[currentIndex] = fields.value[currentIndex - 1]
644 fields.value[currentIndex - 1] = temp
645 updateFieldPositions()
646
647 // Keep the field selected after moving
648 if (selectedField.value?.fieldIndex === fieldIndex) {
649 selectedField.value = temp
650 }
651
652 // Scroll the field into view if it moved off-screen
653 setTimeout(() => {
654 const fieldElement = document.querySelector(
655 `[data-field-index="${fieldIndex}"]`,
656 ) as HTMLElement
657 if (fieldElement) {
658 fieldElement.style.scrollMargin = '20px'
659 fieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
660 }
661 }, 100)
662 }
663 }
664
665 const moveFieldDown = (fieldIndex: number) => {
666 const currentIndex = fields.value.findIndex((f) => f.fieldIndex === fieldIndex)
667 if (currentIndex < fields.value.length - 1) {
668 const temp = fields.value[currentIndex]
669 fields.value[currentIndex] = fields.value[currentIndex + 1]
670 fields.value[currentIndex + 1] = temp
671 updateFieldPositions()
672
673 // Keep the field selected after moving
674 if (selectedField.value?.fieldIndex === fieldIndex) {
675 selectedField.value = temp
676 }
677
678 // Scroll the field into view if it moved off-screen
679 setTimeout(() => {
680 const fieldElement = document.querySelector(
681 `[data-field-index="${fieldIndex}"]`,
682 ) as HTMLElement
683 if (fieldElement) {
684 fieldElement.style.scrollMargin = '20px'
685 fieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
686 }
687 }, 100)
688 }
689 }
690
691 const moveFieldLeft = (fieldIndex: number) => {
692 const field = fields.value.find((f) => f.fieldIndex === fieldIndex)
693 if (!field || field.columnIndex === undefined) return
694
695 // Get all fields in the same row, sorted by columnIndex
696 const fieldsInRow = fields.value
697 .filter((f) => (f.rowIndex ?? 0) === (field.rowIndex ?? 0))
698 .sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0))
699
700 const currentPosition = fieldsInRow.findIndex((f) => f.fieldIndex === fieldIndex)
701
702 // Can't move left if already first
703 if (currentPosition <= 0) return
704
705 // Swap columnIndex with field to the left
706 const leftField = fieldsInRow[currentPosition - 1]
707 const tempColumnIndex = field.columnIndex
708 field.columnIndex = leftField.columnIndex
709 leftField.columnIndex = tempColumnIndex
710
711 updateFieldPositions()
712
713 // Keep the field selected after moving
714 if (selectedField.value?.fieldIndex === fieldIndex) {
715 selectedField.value = field
716 }
717 }
718
719 const moveFieldRight = (fieldIndex: number) => {
720 const field = fields.value.find((f) => f.fieldIndex === fieldIndex)
721 if (!field || field.columnIndex === undefined) return
722
723 // Get all fields in the same row, sorted by columnIndex
724 const fieldsInRow = fields.value
725 .filter((f) => (f.rowIndex ?? 0) === (field.rowIndex ?? 0))
726 .sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0))
727
728 const currentPosition = fieldsInRow.findIndex((f) => f.fieldIndex === fieldIndex)
729
730 // Can't move right if already last
731 if (currentPosition >= fieldsInRow.length - 1) return
732
733 // Swap columnIndex with field to the right
734 const rightField = fieldsInRow[currentPosition + 1]
735 const tempColumnIndex = field.columnIndex
736 field.columnIndex = rightField.columnIndex
737 rightField.columnIndex = tempColumnIndex
738
739 updateFieldPositions()
740
741 // Keep the field selected after moving
742 if (selectedField.value?.fieldIndex === fieldIndex) {
743 selectedField.value = field
744 }
745 }
746
747 const deleteField = (fieldIndex: number) => {
748 const i = fields.value.findIndex((f) => f.fieldIndex === fieldIndex)
749 if (i !== -1) {
750 const deletedField = fields.value[i]
751 const rowIndex = deletedField.rowIndex ?? 0
752
753 // Get remaining fields in row BEFORE deletion
754 const remainingFields = fields.value.filter(
755 (f) => f.rowIndex === rowIndex && f.fieldIndex !== fieldIndex,
756 )
757
758 // Reset remaining fields to even distribution and update columnIndex
759 if (remainingFields.length > 0) {
760 const evenWidth =
761 remainingFields.length >= 5 ? 20 : Math.round((100 / remainingFields.length) * 10) / 10
762
763 // Sort by columnIndex to maintain left-to-right order
764 remainingFields.sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0))
765
766 remainingFields.forEach((f, index) => {
767 f.width = evenWidth
768 f.columnIndex = index // Reassign columnIndex sequentially from 0
769 })
770 }
771
772 fields.value.splice(i, 1)
773
774 if (selectedField.value?.fieldIndex === fieldIndex) {
775 selectedField.value = null
776 }
777
778 updateFieldPositions()
779 }
780 }
781
782 const duplicateField = (fieldIndex: number) => {
783 const original = fields.value.find((f) => f.fieldIndex === fieldIndex)
784 if (!original) return
785
786 // Check if current row has space
787 const currentRowIndex = original.rowIndex ?? 0
788 const fieldsInCurrentRow = fields.value.filter((f) => (f.rowIndex ?? 0) === currentRowIndex)
789
790 const newIndex = increaseCounter()
791 const copy = duplicateFieldForStore(original, newIndex, getLabel)
792 const insertAt = fields.value.findIndex((f) => f.fieldIndex === fieldIndex)
793
794 // If field is alone in a row OR row is full, create new row below
795 if (fieldsInCurrentRow.length === 1 || fieldsInCurrentRow.length >= 5) {
796 // Create new row below
797 copy.rowIndex = currentRowIndex + 1
798 copy.columnIndex = 0
799 copy.width = 100
800
801 // Shift all rows below down by 1
802 fields.value.forEach((f) => {
803 if ((f.rowIndex ?? 0) > currentRowIndex) {
804 f.rowIndex = (f.rowIndex ?? 0) + 1
805 }
806 })
807
808 fields.value.splice(insertAt + 1, 0, copy)
809 } else {
810 // Add to same row (multi-field row with space)
811 copy.rowIndex = currentRowIndex
812 copy.columnIndex = original.columnIndex! + 1
813
814 // Shift column indexes for fields to the right
815 fields.value.forEach((f) => {
816 if (
817 (f.rowIndex ?? 0) === currentRowIndex &&
818 (f.columnIndex ?? 0) > (original.columnIndex ?? 0)
819 ) {
820 f.columnIndex = (f.columnIndex ?? 0) + 1
821 }
822 })
823
824 fields.value.splice(insertAt + 1, 0, copy)
825
826 // Reset all fields in row to even distribution
827 const fieldsInRow = fields.value.filter((f) => (f.rowIndex ?? 0) === currentRowIndex)
828 const evenWidth =
829 fieldsInRow.length >= 5 ? 20 : Math.round((100 / fieldsInRow.length) * 10) / 10
830 fieldsInRow.forEach((f) => {
831 f.width = evenWidth
832 })
833 }
834
835 updateFieldPositions()
836
837 // Select the newly duplicated field
838 selectedField.value = copy
839 activeTab.value = 'options'
840 }
841
842 const resetForm = () => {
843 // Suppress dirty tracking during reset
844 suppressDirtyTracking = true
845
846 formId.value = null
847 isEditing.value = false
848 fields.value = []
849 counterFields.value = 0
850 selectedField.value = null
851 activeTab.value = 'addField'
852 description.value = null
853 name.value = 'Blank form'
854 published.value = true
855 showTitle.value = true
856 showDescription.value = false
857 storeEntries.value = true
858 isEditingSettingGeneral.value = false
859 confirmationId.value = null
860 isFormLoading.value = false
861 submitButtonSettings.value = {
862 label: getLabel('submit'),
863 position: 'default',
864 }
865 isSubmitButtonSelected.value = false
866
867 // Stop editing and clear dirty flag
868 unsavedChangesStore.stopEditing('formBuilder')
869
870 // Re-enable dirty tracking
871 suppressDirtyTracking = false
872 }
873
874 const updateFieldOptions = (key: string, value: unknown) => {
875 if (!selectedField.value) return
876 const index = fields.value.findIndex((f) => f.fieldIndex === selectedField.value?.fieldIndex)
877 if (index !== -1) {
878 // Mutate the existing object instead of creating a new one
879 // This keeps the reference intact for selectedField
880 const target = fields.value[index] as Record<string, unknown>
881 target[key] = value
882 }
883 }
884
885 return {
886 formId,
887 loadedForEntry,
888 fields,
889 isEditing,
890 selectedField,
891 activeTab,
892 counterFields,
893 description,
894 name,
895 published,
896 showTitle,
897 showDescription,
898 storeEntries,
899 integrationSettings,
900 isEditingSettingGeneral,
901 confirmationId,
902 isFormLoading,
903 isDraggingFromPanel,
904 markDirty,
905 markClean,
906 saveForm,
907 loadForm,
908 updateForm,
909 increaseCounter,
910 addField,
911 selectField,
912 deselectField,
913 isSelected,
914 updateSelectedField,
915 updateFieldOptions,
916 updateFieldPositions,
917 moveFieldUp,
918 moveFieldDown,
919 moveFieldLeft,
920 moveFieldRight,
921 deleteField,
922 duplicateField,
923 saveFormSettings,
924 resetForm,
925 // Grid layout methods
926 fieldsByRow,
927 moveFieldToPosition,
928 recalculateRowWidths,
929 canAddToRow,
930 getAutoWidth,
931 // Submit button
932 submitButtonSettings,
933 isSubmitButtonSelected,
934 selectSubmitButton,
935 deselectSubmitButton,
936 updateSubmitButtonSettings,
937 }
938 })
939