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

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