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

1,535 lines 49.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 { useMultiPageStore } from '@/stores/useMultiPageStore'
10 import type { PageNavigationSettings } from '@/stores/useMultiPageStore'
11 import type { Field } from '@/types/field'
12 import type { FormStyleSettings } from '@/types/form-style'
13 import { normalizeFieldsForBuilder, denormalizeFieldsForApi } from '@/utils/fieldsNormalization'
14 import { buildNewField } from '@/utils/addFieldUtil.ts'
15 import { duplicateFieldForStore } from '@/utils/fieldUtils'
16 import { useFieldWidth } from '@/composables/useFieldWidth'
17 import type { DropZone } from '@/types/grid-layout'
18 import type { FormPage } from '@/types/form-page'
19 import { DEFAULT_FORM_STYLES } from '@/constants/defaultFormStyles'
20 import { getErrorMessage } from '@/utils/errorHandling'
21
22 const cloneDefaultStyleSettings = (): FormStyleSettings =>
23 JSON.parse(JSON.stringify(DEFAULT_FORM_STYLES)) as FormStyleSettings
24
25 const isPlainObject = (value: unknown): value is Record<string, unknown> =>
26 typeof value === 'object' && value !== null && !Array.isArray(value)
27
28 const mergeObjectsDeep = (target: Record<string, unknown>, source: Record<string, unknown>) => {
29 Object.keys(source).forEach((key) => {
30 const sourceValue = source[key]
31
32 if (sourceValue === undefined) {
33 return
34 }
35
36 if (isPlainObject(sourceValue) && isPlainObject(target[key])) {
37 mergeObjectsDeep(target[key] as Record<string, unknown>, sourceValue)
38 return
39 }
40
41 target[key] = sourceValue
42 })
43 }
44
45 const mergeStyleSettingsWithDefaults = (
46 incoming: Partial<FormStyleSettings> | null | undefined,
47 ): FormStyleSettings => {
48 const merged = cloneDefaultStyleSettings()
49
50 if (incoming && isPlainObject(incoming)) {
51 mergeObjectsDeep(merged as Record<string, unknown>, incoming as Record<string, unknown>)
52 }
53
54 return merged
55 }
56
57 export const useFormBuilder = defineStore('formBuilderData', () => {
58 const multiPageStore = useMultiPageStore()
59 const formId = ref<string | null>(null)
60 const showSampleView = ref(false)
61 const isEditing = ref(false)
62 const fields = ref<Field[]>([])
63 const counterFields = ref<number>(0)
64 const selectedField = ref<Field | null>(null)
65 const activeTab = ref<string>('addField')
66 const description = ref<string | null>(null)
67 const name = ref<string | null>('Blank form')
68 const published = ref(true)
69 const showTitle = ref(false)
70 const showDescription = ref(false)
71 const storeEntries = ref(false)
72 // Flag to indicate the form was loaded specifically for viewing an entry
73 const loadedForEntry = ref<boolean>(false)
74 const integrationSettings = ref<{
75 wpdatatables: { enabled: boolean }
76 [key: string]: Record<string, unknown>
77 }>({
78 wpdatatables: { enabled: true },
79 })
80 const styleSettings = ref<FormStyleSettings>(mergeStyleSettingsWithDefaults(undefined))
81 const isEditingSettingGeneral = ref(false)
82 const confirmationId = ref<string | null>(null)
83 const router = useRouter()
84 const isFormLoading = ref(true)
85 const isDraggingFromPanel = ref(false)
86
87 const { getLabel } = useLabels()
88 const { request } = useApiClient()
89
90 // Submit button settings
91 const submitButtonSettings = ref({
92 label: getLabel('submit'),
93 position: 'default' as 'default' | 'left' | 'center' | 'right',
94 })
95 const isSubmitButtonSelected = ref(false)
96
97 const unsavedChangesStore = useUnsavedChangesStore()
98
99 // Flag to suppress change tracking during load/reset operations
100 let suppressDirtyTracking = false
101
102 /**
103 * Mark the form builder as having unsaved changes
104 * Called automatically by watchers when state changes
105 */
106 const markDirty = () => {
107 if (!suppressDirtyTracking && isEditing.value) {
108 unsavedChangesStore.markDirty('formBuilder')
109 }
110 }
111
112 /**
113 * Mark the form builder as clean (no unsaved changes)
114 * Called after successful save operations
115 */
116 const markClean = () => {
117 unsavedChangesStore.markClean('formBuilder')
118 }
119
120 // Watch all tracked state for changes and mark dirty
121 watch(
122 [name, description, published, showTitle, showDescription, storeEntries],
123 () => markDirty(),
124 { deep: false },
125 )
126
127 watch(integrationSettings, () => markDirty(), { deep: true })
128 watch(submitButtonSettings, () => markDirty(), { deep: true })
129 watch(fields, () => markDirty(), { deep: true })
130 watch(
131 () => multiPageStore.pages,
132 () => markDirty(),
133 { deep: true },
134 )
135 watch(
136 () => multiPageStore.progressIndicator,
137 () => markDirty(),
138 { deep: true },
139 )
140 watch(
141 () => multiPageStore.pageNavigationSettings,
142 () => markDirty(),
143 { deep: true },
144 )
145
146 const selectField = (fieldIndex: number) => {
147 const field = fields.value.find((field) => field.fieldIndex === fieldIndex) || null
148
149 // If selecting the same field, still ensure the tab is set to options
150 if (selectedField.value?.fieldIndex === fieldIndex) {
151 activeTab.value = 'options'
152 return
153 }
154
155 selectedField.value = field
156 isSubmitButtonSelected.value = false
157 multiPageStore.deselectAll()
158 activeTab.value = 'options'
159 }
160
161 const selectSubmitButton = () => {
162 selectedField.value = null
163 isSubmitButtonSelected.value = true
164 multiPageStore.deselectAll()
165 activeTab.value = 'options'
166 }
167
168 const deselectSubmitButton = () => {
169 isSubmitButtonSelected.value = false
170 }
171
172 const selectProgressIndicator = () => {
173 selectedField.value = null
174 isSubmitButtonSelected.value = false
175 multiPageStore.selectProgressIndicator()
176 activeTab.value = 'options'
177 }
178
179 const deselectProgressIndicator = () => {
180 multiPageStore.deselectProgressIndicator()
181 }
182
183 const selectPageNavigation = () => {
184 selectedField.value = null
185 isSubmitButtonSelected.value = false
186 multiPageStore.selectPageNavigation()
187 activeTab.value = 'options'
188 }
189
190 const deselectPageNavigation = () => {
191 multiPageStore.deselectPageNavigation()
192 }
193
194 const selectNavigationButton = () => {
195 selectedField.value = null
196 isSubmitButtonSelected.value = false
197 multiPageStore.selectNavigationButton()
198 activeTab.value = 'options'
199 }
200
201 const deselectNavigationButton = () => {
202 multiPageStore.deselectNavigationButton()
203 }
204
205 const updateSubmitButtonSettings = (key: 'label' | 'position', value: string) => {
206 if (
207 key === 'position' &&
208 (value === 'default' || value === 'left' || value === 'center' || value === 'right')
209 ) {
210 submitButtonSettings.value.position = value
211 } else if (key === 'label') {
212 submitButtonSettings.value.label = value
213 }
214 }
215
216 function deselectField(event: Event) {
217 event.stopPropagation()
218 const clickedElement = event.target as HTMLElement
219 const fieldElements = document.querySelectorAll('.ivyforms-form-builder__list-group__item')
220 const submitButtonElement = document.querySelector(
221 '.ivyforms-form-builder__submit-button-wrapper',
222 )
223 const clickedInsideMultiPageControl = !!clickedElement.closest(
224 '.ivyforms-form-builder__progress-indicator-wrapper, .ivyforms-form-builder__submit-button-wrapper',
225 )
226 let clickedInsideField = false
227
228 fieldElements.forEach((el) => {
229 if (el.contains(clickedElement)) {
230 clickedInsideField = true
231 }
232 })
233
234 // Check if clicked inside submit button
235 if (submitButtonElement && submitButtonElement.contains(clickedElement)) {
236 clickedInsideField = true
237 }
238
239 if (clickedInsideMultiPageControl) {
240 clickedInsideField = true
241 }
242
243 if (!clickedInsideField) {
244 selectedField.value = null
245 isSubmitButtonSelected.value = false
246 multiPageStore.deselectAll()
247 }
248 }
249
250 const isSelected = (fieldIndex: number) => {
251 return selectedField.value?.fieldIndex === fieldIndex
252 }
253
254 // Only update the selectedField object and the canonical entry in fields[]
255 const updateSelectedField = (key: string, value: unknown) => {
256 const sel = selectedField.value as Record<string, unknown> | null
257 if (sel) {
258 sel[key] = value
259 }
260 const index = fields.value.findIndex((f) => f.fieldIndex === selectedField.value?.fieldIndex)
261 if (index !== -1) {
262 const target = fields.value[index] as Record<string, unknown>
263 if (target !== sel) {
264 target[key] = value
265 }
266 }
267 }
268
269 function addField(fieldData: Partial<Field> & { type: string }) {
270 const newField = buildNewField(fieldData, counterFields.value, getLabel)
271
272 // Assign to current page
273 newField.pageId = multiPageStore.currentPageId
274
275 // If rowIndex, columnIndex, and width are already specified (e.g., from click-to-add), use them
276 if (
277 fieldData.rowIndex !== undefined &&
278 fieldData.columnIndex !== undefined &&
279 fieldData.width !== undefined
280 ) {
281 newField.rowIndex = fieldData.rowIndex
282 newField.columnIndex = fieldData.columnIndex
283 newField.width = fieldData.width
284 } else {
285 // Auto-calculate position: add to last row if space available, otherwise create new row
286 // Use current page fields only for calculating row position
287 const currentPageFieldsList = fields.value.filter(
288 (f) => f.pageId === multiPageStore.currentPageId,
289 )
290 const lastRow =
291 currentPageFieldsList.length > 0
292 ? Math.max(0, ...currentPageFieldsList.map((f) => f.rowIndex ?? 0))
293 : 0
294 const fieldsInLastRow = currentPageFieldsList.filter((f) => (f.rowIndex ?? 0) === lastRow)
295
296 let rowIndex = lastRow
297 let columnIndex = fieldsInLastRow.length
298
299 // If last row is full (5 fields), create a new row
300 if (fieldsInLastRow.length >= 5) {
301 rowIndex = lastRow + 1
302 columnIndex = 0
303 }
304
305 newField.rowIndex = rowIndex
306 newField.columnIndex = columnIndex
307 newField.width = 100 // Will be auto-calculated by useFieldWidth
308 }
309
310 fields.value.push(newField)
311 updateFieldPositions()
312
313 // Scroll to the newly added field after DOM update
314 nextTick(() => {
315 const fieldElement = document.querySelector(
316 `[data-field-index="${newField.fieldIndex}"]`,
317 ) as HTMLElement
318 if (fieldElement) {
319 fieldElement.scrollIntoView({
320 behavior: 'smooth',
321 block: 'center',
322 })
323 }
324 }).catch(() => {
325 // Ignore nextTick errors
326 })
327 }
328
329 const updateFieldPositions = () => {
330 fields.value.forEach((field, fieldIndex) => {
331 field.position = fieldIndex + 1
332 })
333 }
334
335 // Grid layout utilities
336 const {
337 groupFieldsByRow,
338 distributeWidthsEvenly,
339 distributeWidthsProportionally,
340 canAddToRow,
341 getAutoWidth,
342 } = useFieldWidth()
343
344 // Computed property for grouped fields
345 const fieldsByRow = computed(() => groupFieldsByRow(fields.value))
346
347 /**
348 * Move a field to a specific position in the grid
349 * @param fieldIndex - The fieldIndex of the field to move
350 * @param dropZone - The target drop zone (rowIndex, columnIndex, position)
351 */
352 const moveFieldToPosition = (fieldIndex: number, dropZone: DropZone) => {
353 const field = fields.value.find((f) => f.fieldIndex === fieldIndex)
354 if (!field) return
355
356 const targetRowIndex = dropZone.rowIndex
357 const targetColumnIndex = dropZone.columnIndex
358 const position = dropZone.position
359
360 // Handle different drop positions
361 if (position === 'above') {
362 // Insert in a new row above
363 fields.value.forEach((f) => {
364 if ((f.rowIndex ?? 0) >= targetRowIndex) {
365 f.rowIndex = (f.rowIndex ?? 0) + 1
366 }
367 })
368 field.rowIndex = targetRowIndex
369 field.columnIndex = 0
370 } else if (position === 'below') {
371 // Insert in a new row below
372 fields.value.forEach((f) => {
373 if ((f.rowIndex ?? 0) > targetRowIndex) {
374 f.rowIndex = (f.rowIndex ?? 0) + 1
375 }
376 })
377 field.rowIndex = targetRowIndex + 1
378 field.columnIndex = 0
379 } else if (position === 'left') {
380 // Insert to the left in the same row
381 const fieldsInRow = fields.value.filter((f) => (f.rowIndex ?? 0) === targetRowIndex)
382 if (fieldsInRow.length >= 5) {
383 return
384 }
385 // Shift fields to the right
386 fields.value.forEach((f) => {
387 if ((f.rowIndex ?? 0) === targetRowIndex && (f.columnIndex ?? 0) >= targetColumnIndex) {
388 f.columnIndex = (f.columnIndex ?? 0) + 1
389 }
390 })
391 field.rowIndex = targetRowIndex
392 field.columnIndex = targetColumnIndex
393 } else if (position === 'right') {
394 // Insert to the right in the same row
395 const fieldsInRow = fields.value.filter((f) => (f.rowIndex ?? 0) === targetRowIndex)
396 if (fieldsInRow.length >= 5) {
397 return
398 }
399 // Shift fields to the right
400 fields.value.forEach((f) => {
401 if ((f.rowIndex ?? 0) === targetRowIndex && (f.columnIndex ?? 0) > targetColumnIndex) {
402 f.columnIndex = (f.columnIndex ?? 0) + 1
403 }
404 })
405 field.rowIndex = targetRowIndex
406 field.columnIndex = targetColumnIndex + 1
407 }
408
409 // Recalculate widths for affected rows
410 recalculateRowWidths(field.rowIndex ?? 0)
411 if (position === 'above' || position === 'below') {
412 // Also recalculate the original row if it still exists
413 const originalRow = fields.value.filter((f) => (f.rowIndex ?? 0) === targetRowIndex)
414 if (originalRow.length > 0) {
415 recalculateRowWidths(targetRowIndex)
416 }
417 }
418
419 updateFieldPositions()
420 }
421
422 /**
423 * Recalculate widths for all fields in a row
424 * Uses proportional distribution to maintain relative field sizes when adding/removing fields
425 * @param rowIndex - The row to recalculate
426 * @param isAddingField - Whether a new field is being added (affects distribution strategy)
427 */
428 const recalculateRowWidths = (rowIndex: number, isAddingField: boolean = false) => {
429 const fieldsInRow = fields.value.filter((f) => (f.rowIndex ?? 0) === rowIndex)
430
431 if (fieldsInRow.length === 0) return
432
433 // Check if fields have custom widths (not standard auto widths)
434 const hasCustomWidths = fieldsInRow.some((f) => {
435 const expectedAutoWidth = getAutoWidth(fieldsInRow.length)
436 const actualWidth = f.width ?? 100
437 // Allow 1% tolerance for rounding
438 return Math.abs(actualWidth - expectedAutoWidth) > 1
439 })
440
441 let updatedFields: Field[]
442
443 if (isAddingField && hasCustomWidths && fieldsInRow.length > 1) {
444 // When adding to a row with custom widths, use proportional distribution
445 // This maintains the relative proportions of existing fields
446 updatedFields = distributeWidthsProportionally(fieldsInRow, 0)
447 } else {
448 // Otherwise, distribute evenly
449 updatedFields = distributeWidthsEvenly(fieldsInRow)
450 }
451
452 updatedFields.forEach((updatedField) => {
453 const field = fields.value.find((f) => f.fieldIndex === updatedField.fieldIndex)
454 if (field) {
455 field.width = updatedField.width
456 }
457 })
458 }
459
460 const increaseCounter = () => {
461 counterFields.value++
462 return counterFields.value
463 }
464
465 /**
466 * Apply beforeSaveFields hooks to allow Pro or other extensions to modify fields
467 * @param fieldsArray - The fields array to process
468 * @returns Processed fields array after all hooks have been applied
469 */
470 const applyBeforeSaveFieldsHooks = (fieldsArray: Field[]): Field[] => {
471 let fieldsToSave = [...fieldsArray]
472
473 if (typeof window !== 'undefined') {
474 const w = window as Window & {
475 IvyForms?: {
476 hooks?: {
477 beforeSaveFields?: Array<(fields: Field[]) => Field[]>
478 }
479 }
480 }
481 const hooks = w.IvyForms?.hooks?.beforeSaveFields || []
482 hooks.forEach((hook) => {
483 try {
484 fieldsToSave = hook(fieldsToSave) as Field[]
485 } catch (error) {
486 console.error(getLabel('error_in_before_save_field_hook'), error)
487 }
488 })
489 }
490
491 return fieldsToSave
492 }
493
494 const getDenormalizedFieldsForSave = (): Field[] => {
495 const fieldsToSave = applyBeforeSaveFieldsHooks(fields.value)
496 return denormalizeFieldsForApi(fieldsToSave)
497 }
498
499 const buildPagesWithFields = (denormalizedFields: Field[]) => {
500 return multiPageStore.pages.map((page) => {
501 const pageFields = denormalizedFields.filter(
502 (field) =>
503 field.settings &&
504 typeof field.settings === 'object' &&
505 (field.settings as Record<string, unknown>).pageId === page.id,
506 )
507
508 return { ...page, fields: pageFields }
509 })
510 }
511
512 /**
513 * Apply formActionButtons hooks to allow Pro or other extensions to extend formActionButtons
514 * @param formActionButtons - The formActionButtons object to process
515 * @returns Processed formActionButtons after all hooks have been applied
516 */
517 const applyFormActionButtonsHook = (
518 formActionButtons: Record<string, unknown>,
519 ): Record<string, unknown> => {
520 let result = { ...formActionButtons }
521
522 if (typeof window !== 'undefined') {
523 const w = window as Window & {
524 IvyForms?: {
525 hooks?: {
526 extendFormActionButtons?: Array<
527 (buttons: Record<string, unknown>) => Record<string, unknown>
528 >
529 }
530 }
531 }
532 const hooks = w.IvyForms?.hooks?.extendFormActionButtons || []
533
534 hooks.forEach((hook) => {
535 try {
536 result = hook(result)
537 } catch (error) {
538 console.error('Error in extendFormActionButtons hook:', error)
539 }
540 })
541 }
542
543 return result
544 }
545
546 const isProPluginActive = () => {
547 if (typeof window === 'undefined') {
548 return false
549 }
550
551 const w = window as Window & {
552 ivyFormsProScripts?: Record<string, string>
553 IvyFormsProScripts?: Record<string, string>
554 IvyForms?: { pro?: unknown }
555 }
556
557 const proScriptsLoaded =
558 w.ivyFormsProScripts !== undefined || w.IvyFormsProScripts !== undefined
559
560 return proScriptsLoaded && !!w.IvyForms?.pro
561 }
562
563 const getDefaultProgressIndicatorSettings = () => ({
564 type: 'progress-bar' as const,
565 showPageTitles: true,
566 hidePageNumbers: false,
567 hideConnectingLines: false,
568 })
569
570 type ProgressIndicatorSettings = {
571 type: 'progress-bar' | 'rootline' | 'none'
572 showPageTitles: boolean
573 hidePageNumbers: boolean
574 hideConnectingLines: boolean
575 }
576
577 const sanitizeProgressIndicator = (value: unknown): ProgressIndicatorSettings => {
578 if (!value || typeof value !== 'object' || Array.isArray(value)) {
579 return getDefaultProgressIndicatorSettings()
580 }
581
582 const indicator = value as Record<string, unknown>
583 const indicatorType =
584 indicator.type === 'progress-bar' ||
585 indicator.type === 'rootline' ||
586 indicator.type === 'none'
587 ? indicator.type
588 : 'progress-bar'
589
590 return {
591 type: indicatorType,
592 showPageTitles: Boolean(indicator.showPageTitles ?? true),
593 hidePageNumbers: Boolean(indicator.hidePageNumbers ?? false),
594 hideConnectingLines: Boolean(indicator.hideConnectingLines ?? false),
595 }
596 }
597
598 const getRawFieldPageId = (field: Field): string | null => {
599 if (typeof field.pageId === 'string' && field.pageId.trim() !== '') {
600 return field.pageId
601 }
602 if (field.settings && typeof field.settings === 'object' && !Array.isArray(field.settings)) {
603 const pid = (field.settings as Record<string, unknown>).pageId
604 if (typeof pid === 'string' && pid.trim() !== '') {
605 return pid
606 }
607 }
608 return null
609 }
610
611 const sortPageIds = (ids: string[]): string[] => {
612 return [...ids].sort((a, b) => {
613 const ma = /^page_(\d+)$/.exec(a)
614 const mb = /^page_(\d+)$/.exec(b)
615 if (ma && mb) {
616 return Number(ma[1]) - Number(mb[1])
617 }
618 if (ma) {
619 return -1
620 }
621 if (mb) {
622 return 1
623 }
624 return a.localeCompare(b)
625 })
626 }
627
628 /** After Lite-only load, all fields may sit on page_1 while extra tabs keep random ids — ensure rows exist and order tabs so the page that holds fields is reachable. */
629 const ensureEachFieldHasPageId = (fieldsInput: Field[]): Field[] => {
630 return fieldsInput.map((field) => {
631 const existing = getRawFieldPageId(field)
632 if (existing) {
633 if (field.pageId !== existing) {
634 return {
635 ...field,
636 pageId: existing,
637 }
638 }
639 return field
640 }
641 const fallback = 'page_1'
642 return {
643 ...field,
644 pageId: fallback,
645 settings:
646 field.settings && typeof field.settings === 'object' && !Array.isArray(field.settings)
647 ? { ...(field.settings as Record<string, unknown>), pageId: fallback }
648 : field.settings,
649 }
650 })
651 }
652
653 const reconcileProPagesWithFields = (rawPages: unknown, fieldsInput: Field[]): FormPage[] => {
654 const parsed: FormPage[] = Array.isArray(rawPages)
655 ? rawPages
656 .filter((p): p is Record<string, unknown> => p !== null && typeof p === 'object')
657 .map((p) => ({
658 id: typeof p.id === 'string' && p.id ? p.id : '',
659 label: typeof p.label === 'string' && p.label.trim() !== '' ? p.label : 'Page',
660 closable: Boolean(p.closable),
661 }))
662 .filter((p) => p.id !== '')
663 : []
664
665 const pageMap = new Map<string, FormPage>()
666 for (const p of parsed) {
667 pageMap.set(p.id, p)
668 }
669
670 const referenced = new Set<string>()
671 for (const f of fieldsInput) {
672 const id = typeof f.pageId === 'string' && f.pageId ? f.pageId : getRawFieldPageId(f)
673 if (id) {
674 referenced.add(id)
675 }
676 }
677
678 for (const id of referenced) {
679 if (!pageMap.has(id)) {
680 const m = /^page_(\d+)$/.exec(id)
681 const label = m ? `Page ${m[1]}` : id
682 pageMap.set(id, {
683 id,
684 label,
685 closable: id !== 'page_1',
686 })
687 }
688 }
689
690 if (pageMap.size === 0) {
691 return [{ id: 'page_1', label: 'Page 1', closable: false }]
692 }
693
694 const sortedRef = sortPageIds([...referenced])
695 const orderedIds: string[] = []
696 for (const id of sortedRef) {
697 if (pageMap.has(id)) {
698 orderedIds.push(id)
699 }
700 }
701 const rest = sortPageIds([...pageMap.keys()].filter((id) => !orderedIds.includes(id)))
702 orderedIds.push(...rest)
703
704 return orderedIds.map((id) => pageMap.get(id) as FormPage)
705 }
706
707 const normalizeLoadedMultiPageData = (
708 responseData: Record<string, unknown>,
709 rawFields: Field[],
710 ) => {
711 const rawPages = Array.isArray(responseData.pages) ? responseData.pages : []
712 const rawFormActionButtons =
713 responseData.formActionButtons && typeof responseData.formActionButtons === 'object'
714 ? (responseData.formActionButtons as Record<string, unknown>)
715 : {}
716
717 if (!isProPluginActive()) {
718 const firstPage =
719 rawPages.length > 0 && rawPages[0] && typeof rawPages[0] === 'object'
720 ? (rawPages[0] as Record<string, unknown>)
721 : {}
722
723 const firstPageId = 'page_1'
724 const firstPageLabel =
725 typeof firstPage.label === 'string' && firstPage.label.trim() !== ''
726 ? firstPage.label
727 : 'Page 1'
728
729 const singlePage = [{ id: firstPageId, label: firstPageLabel, closable: false }]
730
731 const normalizedFields = rawFields.map((field) => {
732 const nextField: Field = { ...field, pageId: firstPageId }
733 if (field.settings && typeof field.settings === 'object') {
734 nextField.settings = {
735 ...(field.settings as Record<string, unknown>),
736 pageId: firstPageId,
737 }
738 }
739 return nextField
740 })
741
742 return {
743 pages: singlePage,
744 progressIndicator: getDefaultProgressIndicatorSettings(),
745 pageNavigationSettings: {},
746 fields: normalizedFields,
747 }
748 }
749
750 const progressIndicator = sanitizeProgressIndicator(responseData.progressIndicator)
751
752 const pageNavigationSettings =
753 rawFormActionButtons.pageNavigationSettings &&
754 typeof rawFormActionButtons.pageNavigationSettings === 'object' &&
755 !Array.isArray(rawFormActionButtons.pageNavigationSettings)
756 ? (rawFormActionButtons.pageNavigationSettings as Record<string, PageNavigationSettings>)
757 : {}
758
759 const fieldsWithPageIds = ensureEachFieldHasPageId(rawFields)
760 const pages = reconcileProPagesWithFields(rawPages, fieldsWithPageIds)
761
762 return {
763 pages,
764 progressIndicator,
765 pageNavigationSettings,
766 fields: fieldsWithPageIds,
767 }
768 }
769
770 // Load existing form data if formId is provided
771 const loadForm = async (id: string) => {
772 isFormLoading.value = true
773 const globalState = useGlobalState()
774
775 // Suppress dirty tracking during load
776 suppressDirtyTracking = true
777
778 try {
779 const { data, error, status } = await request(`form/${id}/`, {
780 method: 'GET',
781 headers: {
782 'Content-Type': 'application/json',
783 },
784 })
785 if (!error && status === 200 && data?.data?.data) {
786 formId.value = id
787 isEditing.value = true
788 isEditingSettingGeneral.value = true
789 const responseData = data.data.data
790 name.value = responseData.name ?? getLabel('blank_form')
791 description.value = responseData.description || ''
792 published.value = responseData.published || false
793 showTitle.value = responseData.showTitle || false
794 showDescription.value = responseData.showDescription || false
795 storeEntries.value = responseData.storeEntries
796
797 // Load integration settings from the response
798 if (responseData.integrationSettings) {
799 integrationSettings.value = responseData.integrationSettings
800 }
801
802 // Load submit button settings from the response
803 if (responseData.formActionButtons?.submitButtonSettings) {
804 submitButtonSettings.value = responseData.formActionButtons.submitButtonSettings
805 }
806
807 // Load style settings from the response
808 if (responseData.styleSettings) {
809 styleSettings.value = mergeStyleSettingsWithDefaults(responseData.styleSettings)
810 } else {
811 // Use default styles if none are saved
812 styleSettings.value = mergeStyleSettingsWithDefaults(undefined)
813 }
814
815 confirmationId.value = responseData.confirmationId || null
816
817 const rawFields = (responseData.fields || []) as Field[]
818 const normalizedMultiPageData = normalizeLoadedMultiPageData(
819 responseData as Record<string, unknown>,
820 rawFields,
821 )
822
823 fields.value = normalizeFieldsForBuilder(normalizedMultiPageData.fields)
824
825 multiPageStore.hydrate({
826 pages: normalizedMultiPageData.pages,
827 progressIndicator: normalizedMultiPageData.progressIndicator,
828 pageNavigationSettings: normalizedMultiPageData.pageNavigationSettings,
829 })
830
831 // Sort fields by row and column for proper grid display
832 fields.value.sort((a, b) => {
833 const rowA = a.rowIndex ?? 0
834 const rowB = b.rowIndex ?? 0
835 if (rowA !== rowB) return rowA - rowB
836 return (a.columnIndex ?? 0) - (b.columnIndex ?? 0)
837 })
838
839 if (name.value) {
840 globalState.setPageTitle(name.value)
841 }
842
843 // Mark form builder as being edited and clean (just loaded)
844 unsavedChangesStore.startEditing('formBuilder')
845 markClean()
846
847 counterFields.value = fields.value.length
848 ? Math.max(...fields.value.map((f) => f.fieldIndex))
849 : 0
850 } else {
851 const errorMessage = getErrorMessage(error)
852 IvyMessage({
853 message: `${getLabel('failed_to_tab_form')} ${errorMessage}`,
854 type: 'error',
855 })
856 console.error(getLabel('failed_to_tab_form'), errorMessage)
857 }
858 } catch (error) {
859 const errorMessage = getErrorMessage(error)
860 IvyMessage({
861 message: `${getLabel('error_loading_form')} ${errorMessage}`,
862 type: 'error',
863 })
864 console.error(getLabel('error_loading_form'), error)
865 } finally {
866 isFormLoading.value = false
867 // Wait for watchers to fire before re-enabling dirty tracking
868 await nextTick()
869 suppressDirtyTracking = false
870 }
871 }
872
873 const updateForm = async () => {
874 if (!formId.value) return
875
876 if (!name.value) {
877 name.value = `${getLabel('form')} #${formId.value}`
878 }
879
880 const denormalizedFields = getDenormalizedFieldsForSave()
881 const pagesWithFields = buildPagesWithFields(denormalizedFields)
882
883 // Convert all Proxy objects to plain objects for proper JSON serialization
884 // This is critical because Vue reactive Proxy objects don't serialize properly
885 const plainIntegrationSettings = integrationSettings.value
886 ? JSON.parse(JSON.stringify(integrationSettings.value))
887 : {}
888 const plainProgressIndicator = multiPageStore.progressIndicator
889 ? JSON.parse(JSON.stringify(multiPageStore.progressIndicator))
890 : {}
891 const plainSubmitButtonSettings = submitButtonSettings.value
892 ? JSON.parse(JSON.stringify(submitButtonSettings.value))
893 : {}
894 const plainPageNavigationSettings = multiPageStore.pageNavigationSettings
895 ? JSON.parse(JSON.stringify(multiPageStore.pageNavigationSettings))
896 : {}
897 const plainStyleSettings = styleSettings.value
898 ? JSON.parse(JSON.stringify(styleSettings.value))
899 : {}
900
901 const formData = {
902 name: name.value ?? '',
903 description: description.value || '',
904 published: published.value || false,
905 showTitle: showTitle.value || false,
906 showDescription: showDescription.value || false,
907 storeEntries: storeEntries.value,
908 integrationSettings: plainIntegrationSettings,
909 styleSettings: plainStyleSettings,
910 fields: denormalizedFields,
911 pages: pagesWithFields,
912 progressIndicator: plainProgressIndicator,
913 formActionButtons: applyFormActionButtonsHook({
914 submitButtonSettings: plainSubmitButtonSettings,
915 pageNavigationSettings: plainPageNavigationSettings,
916 }),
917 }
918
919 try {
920 const { error, status } = await request(`form/update/${formId.value}/`, {
921 method: 'POST',
922 headers: {
923 'Content-Type': 'application/json',
924 },
925 data: formData,
926 })
927 if (!error && status === 200) {
928 IvyMessage({
929 message: getLabel('form_updated'),
930 type: 'success',
931 })
932 markClean()
933 } else {
934 const errorMessage = getErrorMessage(error)
935 IvyMessage({
936 message: `${getLabel('failed_to_update_form')} ${errorMessage}`,
937 type: 'error',
938 })
939 console.error(getLabel('failed_to_update_form'), errorMessage)
940 }
941 } catch (error) {
942 const errorMessage = getErrorMessage(error)
943 IvyMessage({
944 message: `${getLabel('error_updating_form')} ${errorMessage}`,
945 type: 'error',
946 })
947 console.error(getLabel('error_updating_form'), error)
948 }
949 }
950
951 // Save form using WordPress REST API
952 const saveForm = async (options?: { template_id?: string }) => {
953 const denormalizedFields = getDenormalizedFieldsForSave()
954 const pagesWithFields = buildPagesWithFields(denormalizedFields)
955
956 // Convert all Proxy objects to plain objects for proper JSON serialization
957 const plainIntegrationSettings = integrationSettings.value
958 ? JSON.parse(JSON.stringify(integrationSettings.value))
959 : {}
960 const plainProgressIndicator = multiPageStore.progressIndicator
961 ? JSON.parse(JSON.stringify(multiPageStore.progressIndicator))
962 : {}
963 const plainSubmitButtonSettings = submitButtonSettings.value
964 ? JSON.parse(JSON.stringify(submitButtonSettings.value))
965 : {}
966 const plainPageNavigationSettings = multiPageStore.pageNavigationSettings
967 ? JSON.parse(JSON.stringify(multiPageStore.pageNavigationSettings))
968 : {}
969 const plainStyleSettings = styleSettings.value
970 ? JSON.parse(JSON.stringify(styleSettings.value))
971 : {}
972
973 const formData = {
974 name: name.value ?? '',
975 description: description.value || '',
976 showTitle: showTitle.value || false,
977 published: published.value || false,
978 showDescription: showDescription.value || false,
979 storeEntries: storeEntries.value,
980 integrationSettings: plainIntegrationSettings,
981 styleSettings: plainStyleSettings,
982 fields: denormalizedFields,
983 pages: pagesWithFields,
984 progressIndicator: plainProgressIndicator,
985 formActionButtons: applyFormActionButtonsHook({
986 submitButtonSettings: plainSubmitButtonSettings,
987 pageNavigationSettings: plainPageNavigationSettings,
988 }),
989 ...(options?.template_id && { template_id: options.template_id }),
990 }
991
992 try {
993 const { data, error, status } = await request('form/add/', {
994 method: 'POST',
995 headers: {
996 'Content-Type': 'application/json',
997 },
998 data: formData,
999 })
1000 if (!error && status === 200 && data?.data?.data?.id) {
1001 const responseData = data.data.data
1002 formId.value = responseData.id
1003 confirmationId.value = responseData.confirmationId
1004 isEditing.value = true
1005 isEditingSettingGeneral.value = true
1006 // Start editing tracking for the newly saved form
1007 unsavedChangesStore.startEditing('formBuilder')
1008 await router.push({ hash: `manage/${formId.value}` })
1009 IvyMessage({
1010 message: getLabel('form_saved'),
1011 type: 'success',
1012 })
1013 markClean()
1014 } else {
1015 const errorMessage = getErrorMessage(error)
1016 IvyMessage({
1017 message: `${getLabel('failed_to_save_form')} ${errorMessage}`,
1018 type: 'error',
1019 })
1020 }
1021 } catch (error) {
1022 const errorMessage = getErrorMessage(error)
1023 IvyMessage({
1024 message: `${getLabel('error_save_form')} ${errorMessage}`,
1025 type: 'error',
1026 })
1027 }
1028 }
1029
1030 const saveFormSettings = async () => {
1031 // ...existing code...
1032 const denormalizedFields = getDenormalizedFieldsForSave()
1033 const pagesWithFields = buildPagesWithFields(denormalizedFields)
1034
1035 // Convert all Proxy objects to plain objects for proper JSON serialization
1036 const plainProgressIndicator = multiPageStore.progressIndicator
1037 ? JSON.parse(JSON.stringify(multiPageStore.progressIndicator))
1038 : {}
1039 const plainSubmitButtonSettings = submitButtonSettings.value
1040 ? JSON.parse(JSON.stringify(submitButtonSettings.value))
1041 : {}
1042 const plainPageNavigationSettings = multiPageStore.pageNavigationSettings
1043 ? JSON.parse(JSON.stringify(multiPageStore.pageNavigationSettings))
1044 : {}
1045
1046 const formData = {
1047 name: name.value ?? '',
1048 description: description.value || '',
1049 published: published.value || false,
1050 showTitle: showTitle.value || false,
1051 showDescription: showDescription.value || false,
1052 storeEntries: storeEntries.value,
1053 fields: denormalizedFields,
1054 pages: pagesWithFields,
1055 progressIndicator: plainProgressIndicator,
1056 formActionButtons: applyFormActionButtonsHook({
1057 submitButtonSettings: plainSubmitButtonSettings,
1058 pageNavigationSettings: plainPageNavigationSettings,
1059 }),
1060 }
1061 try {
1062 const { data, error, status } = await request('form/add/', {
1063 method: 'POST',
1064 headers: {
1065 'Content-Type': 'application/json',
1066 },
1067 data: formData,
1068 })
1069 if (!error && status === 200 && data?.data?.data?.id) {
1070 formId.value = data.data.data.id
1071 isEditing.value = true
1072 isEditingSettingGeneral.value = true
1073 // Start editing tracking for the newly saved form
1074 unsavedChangesStore.startEditing('formBuilder')
1075 await router.push({ hash: `/settings/general/manage/${formId.value}` })
1076 IvyMessage({
1077 message: getLabel('form_saved'),
1078 type: 'success',
1079 })
1080 markClean()
1081 } else {
1082 const errorMessage = getErrorMessage(error)
1083 IvyMessage({
1084 message: `${getLabel('failed_to_save_form')} ${errorMessage}`,
1085 type: 'error',
1086 })
1087 }
1088 } catch (error) {
1089 const errorMessage = getErrorMessage(error)
1090 IvyMessage({
1091 message: `${getLabel('error_save_form')} ${errorMessage}`,
1092 type: 'error',
1093 })
1094 }
1095 }
1096
1097 const moveFieldUp = (fieldIndex: number) => {
1098 const currentIndex = fields.value.findIndex((f) => f.fieldIndex === fieldIndex)
1099 if (currentIndex > 0) {
1100 const temp = fields.value[currentIndex]
1101 fields.value[currentIndex] = fields.value[currentIndex - 1]
1102 fields.value[currentIndex - 1] = temp
1103 updateFieldPositions()
1104
1105 // Keep the field selected after moving
1106 if (selectedField.value?.fieldIndex === fieldIndex) {
1107 selectedField.value = temp
1108 }
1109
1110 // Scroll the field into view if it moved off-screen
1111 setTimeout(() => {
1112 const fieldElement = document.querySelector(
1113 `[data-field-index="${fieldIndex}"]`,
1114 ) as HTMLElement
1115 if (fieldElement) {
1116 fieldElement.style.scrollMargin = '20px'
1117 fieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
1118 }
1119 }, 100)
1120 }
1121 }
1122
1123 const moveFieldDown = (fieldIndex: number) => {
1124 const currentIndex = fields.value.findIndex((f) => f.fieldIndex === fieldIndex)
1125 if (currentIndex < fields.value.length - 1) {
1126 const temp = fields.value[currentIndex]
1127 fields.value[currentIndex] = fields.value[currentIndex + 1]
1128 fields.value[currentIndex + 1] = temp
1129 updateFieldPositions()
1130
1131 // Keep the field selected after moving
1132 if (selectedField.value?.fieldIndex === fieldIndex) {
1133 selectedField.value = temp
1134 }
1135
1136 // Scroll the field into view if it moved off-screen
1137 setTimeout(() => {
1138 const fieldElement = document.querySelector(
1139 `[data-field-index="${fieldIndex}"]`,
1140 ) as HTMLElement
1141 if (fieldElement) {
1142 fieldElement.style.scrollMargin = '20px'
1143 fieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
1144 }
1145 }, 100)
1146 }
1147 }
1148
1149 const moveFieldLeft = (fieldIndex: number) => {
1150 const field = fields.value.find((f) => f.fieldIndex === fieldIndex)
1151 if (!field || field.columnIndex === undefined) return
1152
1153 // Get all fields in the same row, sorted by columnIndex
1154 const fieldsInRow = fields.value
1155 .filter((f) => (f.rowIndex ?? 0) === (field.rowIndex ?? 0))
1156 .sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0))
1157
1158 const currentPosition = fieldsInRow.findIndex((f) => f.fieldIndex === fieldIndex)
1159
1160 // Can't move left if already first
1161 if (currentPosition <= 0) return
1162
1163 // Swap columnIndex with field to the left
1164 const leftField = fieldsInRow[currentPosition - 1]
1165 const tempColumnIndex = field.columnIndex
1166 field.columnIndex = leftField.columnIndex
1167 leftField.columnIndex = tempColumnIndex
1168
1169 updateFieldPositions()
1170
1171 // Keep the field selected after moving
1172 if (selectedField.value?.fieldIndex === fieldIndex) {
1173 selectedField.value = field
1174 }
1175 }
1176
1177 const moveFieldRight = (fieldIndex: number) => {
1178 const field = fields.value.find((f) => f.fieldIndex === fieldIndex)
1179 if (!field || field.columnIndex === undefined) return
1180
1181 // Get all fields in the same row, sorted by columnIndex
1182 const fieldsInRow = fields.value
1183 .filter((f) => (f.rowIndex ?? 0) === (field.rowIndex ?? 0))
1184 .sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0))
1185
1186 const currentPosition = fieldsInRow.findIndex((f) => f.fieldIndex === fieldIndex)
1187
1188 // Can't move right if already last
1189 if (currentPosition >= fieldsInRow.length - 1) return
1190
1191 // Swap columnIndex with field to the right
1192 const rightField = fieldsInRow[currentPosition + 1]
1193 const tempColumnIndex = field.columnIndex
1194 field.columnIndex = rightField.columnIndex
1195 rightField.columnIndex = tempColumnIndex
1196
1197 updateFieldPositions()
1198
1199 // Keep the field selected after moving
1200 if (selectedField.value?.fieldIndex === fieldIndex) {
1201 selectedField.value = field
1202 }
1203 }
1204
1205 const deleteField = (fieldIndex: number) => {
1206 const i = fields.value.findIndex((f) => f.fieldIndex === fieldIndex)
1207 if (i !== -1) {
1208 const deletedField = fields.value[i]
1209 const rowIndex = deletedField.rowIndex ?? 0
1210
1211 // Get remaining fields in row BEFORE deletion
1212 const remainingFields = fields.value.filter(
1213 (f) => f.rowIndex === rowIndex && f.fieldIndex !== fieldIndex,
1214 )
1215
1216 // Reset remaining fields to even distribution and update columnIndex
1217 if (remainingFields.length > 0) {
1218 const evenWidth =
1219 remainingFields.length >= 5 ? 20 : Math.round((100 / remainingFields.length) * 10) / 10
1220
1221 // Sort by columnIndex to maintain left-to-right order
1222 remainingFields.sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0))
1223
1224 remainingFields.forEach((f, index) => {
1225 f.width = evenWidth
1226 f.columnIndex = index // Reassign columnIndex sequentially from 0
1227 })
1228 }
1229
1230 fields.value.splice(i, 1)
1231
1232 if (selectedField.value?.fieldIndex === fieldIndex) {
1233 selectedField.value = null
1234 }
1235
1236 updateFieldPositions()
1237 }
1238 }
1239
1240 const duplicateField = (fieldIndex: number) => {
1241 const original = fields.value.find((f) => f.fieldIndex === fieldIndex)
1242 if (!original) return
1243
1244 // Check if current row has space
1245 const currentRowIndex = original.rowIndex ?? 0
1246 const fieldsInCurrentRow = fields.value.filter((f) => (f.rowIndex ?? 0) === currentRowIndex)
1247
1248 const newIndex = increaseCounter()
1249 const copy = duplicateFieldForStore(original, newIndex, getLabel)
1250 const insertAt = fields.value.findIndex((f) => f.fieldIndex === fieldIndex)
1251
1252 // If field is alone in a row OR row is full, create new row below
1253 if (fieldsInCurrentRow.length === 1 || fieldsInCurrentRow.length >= 5) {
1254 // Create new row below
1255 copy.rowIndex = currentRowIndex + 1
1256 copy.columnIndex = 0
1257 copy.width = 100
1258
1259 // Shift all rows below down by 1
1260 fields.value.forEach((f) => {
1261 if ((f.rowIndex ?? 0) > currentRowIndex) {
1262 f.rowIndex = (f.rowIndex ?? 0) + 1
1263 }
1264 })
1265
1266 fields.value.splice(insertAt + 1, 0, copy)
1267 } else {
1268 // Add to same row (multi-field row with space)
1269 copy.rowIndex = currentRowIndex
1270 copy.columnIndex = original.columnIndex! + 1
1271
1272 // Shift column indexes for fields to the right
1273 fields.value.forEach((f) => {
1274 if (
1275 (f.rowIndex ?? 0) === currentRowIndex &&
1276 (f.columnIndex ?? 0) > (original.columnIndex ?? 0)
1277 ) {
1278 f.columnIndex = (f.columnIndex ?? 0) + 1
1279 }
1280 })
1281
1282 fields.value.splice(insertAt + 1, 0, copy)
1283
1284 // Reset all fields in row to even distribution
1285 const fieldsInRow = fields.value.filter((f) => (f.rowIndex ?? 0) === currentRowIndex)
1286 const evenWidth =
1287 fieldsInRow.length >= 5 ? 20 : Math.round((100 / fieldsInRow.length) * 10) / 10
1288 fieldsInRow.forEach((f) => {
1289 f.width = evenWidth
1290 })
1291 }
1292
1293 updateFieldPositions()
1294
1295 // Select the newly duplicated field
1296 selectedField.value = copy
1297 activeTab.value = 'options'
1298 }
1299
1300 const resetForm = () => {
1301 // Suppress dirty tracking during reset
1302 suppressDirtyTracking = true
1303
1304 formId.value = null
1305 isEditing.value = false
1306 fields.value = []
1307 counterFields.value = 0
1308 selectedField.value = null
1309 activeTab.value = 'addField'
1310 description.value = null
1311 name.value = 'Blank form'
1312 published.value = true
1313 showTitle.value = true
1314 showDescription.value = false
1315 storeEntries.value = true
1316 styleSettings.value = mergeStyleSettingsWithDefaults(undefined)
1317 isEditingSettingGeneral.value = false
1318 confirmationId.value = null
1319 isFormLoading.value = false
1320 multiPageStore.reset()
1321 submitButtonSettings.value = {
1322 label: getLabel('submit'),
1323 position: 'default',
1324 }
1325 isSubmitButtonSelected.value = false
1326
1327 // Stop editing and clear dirty flag
1328 unsavedChangesStore.stopEditing('formBuilder')
1329
1330 // Re-enable dirty tracking
1331 suppressDirtyTracking = false
1332 }
1333
1334 const updateFieldOptions = (key: string, value: unknown) => {
1335 if (!selectedField.value) return
1336 const index = fields.value.findIndex((f) => f.fieldIndex === selectedField.value?.fieldIndex)
1337 if (index !== -1) {
1338 // Mutate the existing object instead of creating a new one
1339 // This keeps the reference intact for selectedField
1340 const target = fields.value[index] as Record<string, unknown>
1341 target[key] = value
1342 }
1343 }
1344
1345 const updateProgressIndicator = multiPageStore.updateProgressIndicator
1346
1347 const addPage = multiPageStore.addPage
1348
1349 const addPageAfter = multiPageStore.addPageAfter
1350
1351 const deletePage = (pageId: string) => {
1352 multiPageStore.deletePage(
1353 pageId,
1354 (pid) => {
1355 fields.value = fields.value.filter((f) => f.pageId !== pid)
1356 },
1357 (pid) => {
1358 if (selectedField.value?.pageId === pid) {
1359 selectedField.value = null
1360 }
1361 },
1362 )
1363 }
1364
1365 const duplicatePage = (pageId: string) => {
1366 multiPageStore.duplicatePage(pageId, fields.value, increaseCounter, (field) => {
1367 fields.value.push(field)
1368 })
1369 }
1370
1371 const renamePage = multiPageStore.renamePage
1372
1373 const updatePage = multiPageStore.updatePage
1374
1375 const reorderPages = multiPageStore.reorderPages
1376
1377 const getPageNavigationSettings = multiPageStore.getPageNavigationSettings
1378
1379 const updatePageNavigationSettings = multiPageStore.updatePageNavigationSettings
1380
1381 // Computed fields for the current page
1382 const currentPageFields = computed(() => {
1383 return fields.value.filter((f) => {
1384 // Prefer explicit field.pageId; fallback to settings.pageId if present
1385 const field = f as { pageId?: string; settings?: { pageId?: string } }
1386 const byProp = field.pageId === multiPageStore.currentPageId
1387 const bySettings =
1388 field.settings &&
1389 typeof field.settings === 'object' &&
1390 field.settings.pageId === multiPageStore.currentPageId
1391 return byProp || bySettings
1392 })
1393 })
1394
1395 const pages = computed({
1396 get: () => multiPageStore.pages,
1397 set: (value) => multiPageStore.setPages(value),
1398 })
1399
1400 const currentPageId = computed({
1401 get: () => multiPageStore.currentPageId,
1402 set: (value) => multiPageStore.setCurrentPageId(value),
1403 })
1404
1405 const progressIndicator = computed({
1406 get: () => multiPageStore.progressIndicator,
1407 set: (value) => multiPageStore.setProgressIndicator(value),
1408 })
1409
1410 const pageNavigationSettings = computed({
1411 get: () => multiPageStore.pageNavigationSettings,
1412 set: (value) => multiPageStore.setPageNavigationSettings(value),
1413 })
1414
1415 const isProgressIndicatorSelected = computed(() => multiPageStore.isProgressIndicatorSelected)
1416 const isPageNavigationSelected = computed(() => multiPageStore.isPageNavigationSelected)
1417 const isNavigationButtonSelected = computed(() => multiPageStore.isNavigationButtonSelected)
1418
1419 const updateStyleSettings = (path: string, value: unknown) => {
1420 const keys = path.split('.')
1421 let current: Record<string, unknown> = styleSettings.value as Record<string, unknown>
1422
1423 // Ensure quickMode object exists
1424 if (keys[0] === 'quickMode' && !current.quickMode) {
1425 current.quickMode = {}
1426 }
1427
1428 // Ensure advancedMode object exists
1429 if (keys[0] === 'advancedMode' && !current.advancedMode) {
1430 current.advancedMode = {}
1431 }
1432
1433 for (let i = 0; i < keys.length - 1; i++) {
1434 if (!current[keys[i]]) {
1435 current[keys[i]] = {}
1436 }
1437 current = current[keys[i]] as Record<string, unknown>
1438 }
1439
1440 current[keys[keys.length - 1]] = value
1441 }
1442
1443 const resetStyleToDefault = () => {
1444 styleSettings.value = mergeStyleSettingsWithDefaults(undefined)
1445 }
1446
1447 return {
1448 formId,
1449 loadedForEntry,
1450 fields,
1451 isEditing,
1452 selectedField,
1453 activeTab,
1454 counterFields,
1455 description,
1456 name,
1457 published,
1458 showTitle,
1459 showDescription,
1460 storeEntries,
1461 integrationSettings,
1462 styleSettings,
1463 isEditingSettingGeneral,
1464 confirmationId,
1465 isFormLoading,
1466 isDraggingFromPanel,
1467 markDirty,
1468 markClean,
1469 // Multi-page
1470 pages,
1471 currentPageId,
1472 currentPageFields,
1473 progressIndicator,
1474 updateProgressIndicator,
1475 addPage,
1476 addPageAfter,
1477 deletePage,
1478 duplicatePage,
1479 copyPage: duplicatePage, // Alias for copyPage
1480 renamePage,
1481 updatePage,
1482 reorderPages,
1483 // Page navigation
1484 pageNavigationSettings,
1485 getPageNavigationSettings,
1486 updatePageNavigationSettings,
1487 saveForm,
1488 loadForm,
1489 updateForm,
1490 increaseCounter,
1491 addField,
1492 selectField,
1493 deselectField,
1494 isSelected,
1495 updateSelectedField,
1496 updateFieldOptions,
1497 updateStyleSettings,
1498 resetStyleToDefault,
1499 updateFieldPositions,
1500 moveFieldUp,
1501 moveFieldDown,
1502 moveFieldLeft,
1503 moveFieldRight,
1504 deleteField,
1505 duplicateField,
1506 saveFormSettings,
1507 resetForm,
1508 // Grid layout methods
1509 fieldsByRow,
1510 moveFieldToPosition,
1511 recalculateRowWidths,
1512 canAddToRow,
1513 getAutoWidth,
1514 // Submit button
1515 submitButtonSettings,
1516 isSubmitButtonSelected,
1517 selectSubmitButton,
1518 deselectSubmitButton,
1519 updateSubmitButtonSettings,
1520 // Progress indicator selection
1521 isProgressIndicatorSelected,
1522 selectProgressIndicator,
1523 deselectProgressIndicator,
1524 // Page navigation selection
1525 isPageNavigationSelected,
1526 selectPageNavigation,
1527 deselectPageNavigation,
1528 // Navigation buttons
1529 isNavigationButtonSelected,
1530 selectNavigationButton,
1531 deselectNavigationButton,
1532 showSampleView,
1533 }
1534 })
1535