| 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 |
|