PluginProbe
The Innovative Form Builder – IvyForms / 0.7
The Innovative Form Builder – IvyForms v0.7
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.7, at frontend/src/stores/useFormBuilder.ts

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