PluginProbe
The Innovative Form Builder – IvyForms / 0.6
The Innovative Form Builder – IvyForms v0.6
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 / useUnsavedChangesStore.ts

useUnsavedChangesStore.ts in The Innovative Form Builder – IvyForms 0.6, at frontend/src/stores/useUnsavedChangesStore.ts

418 lines 12.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { ref, computed } from 'vue'
2 import type { ComputedRef, Ref } from 'vue'
3 import { defineStore } from 'pinia'
4 import { useActionEntityStore } from '@/stores/actionEntityStore'
5 import isEqual from 'lodash/isEqual'
6 import { useLabels } from '@/composables/useLabels'
7 import type { RecaptchaSettings } from '@/types/recaptcha/recaptcha-interface'
8 import type { RecaptchaType } from '@/types/recaptcha/recaptcha-type'
9
10 // Define a type for form fields
11 export interface FormField {
12 id?: string
13 type: string
14 label?: string
15 required?: boolean
16 [key: string]: unknown
17 }
18
19 // Precise entity types
20 export interface FormBuilderState {
21 name: string | null
22 description: string | null
23 published: boolean
24 showTitle: boolean
25 showDescription: boolean
26 storeEntries: boolean
27 integrationSettings: {
28 wpdatatables: {
29 enabled: boolean
30 }
31 }
32 fields: FormField[]
33 }
34
35 export interface ConfirmationBuilderState {
36 url: string
37 message: string
38 type: string
39 enabled: boolean
40 showForm: boolean
41 page: string
42 }
43
44 export interface NotificationBuilderState {
45 name: string
46 sender: string
47 receiver: string
48 enabled: boolean
49 subject: string
50 message: string
51 }
52
53 export type UnsavedChangesState =
54 | FormBuilderState
55 | ConfirmationBuilderState
56 | NotificationBuilderState
57 | RecaptchaSettings
58
59 export type EntityPageName =
60 | 'formBuilder'
61 | 'confirmationBuilder'
62 | 'notificationBuilder'
63 | 'settingsBuilder'
64
65 export type UnsavedEntityKey = 'formBuilder' | 'confirmationBuilder' | 'notificationBuilder'
66
67 export const useUnsavedChangesStore = defineStore('unsavedChanges', () => {
68 const { getLabel } = useLabels()
69 // Store initial states for different entities
70 const initialStates = ref<Record<string, UnsavedChangesState>>({})
71
72 // Action entity store for dialogs
73 const actionEntityStore = useActionEntityStore()
74
75 /**
76 * Initialize state tracking for an entity
77 * @param entityPageName - entity name
78 * @param initialState - entity initial state
79 */
80 const initializeState = (entityPageName: EntityPageName, initialState: UnsavedChangesState) => {
81 initialStates.value[entityPageName] = { ...initialState }
82 }
83
84 /**
85 * Checks if the entity has unsaved changes.
86 * @param entityPageName - entity name
87 * @param getCurrentState - function that returns the current state
88 * @returns ComputedRef<boolean>
89 */
90 const hasUnsavedChanges = (
91 entityPageName: EntityPageName,
92 getCurrentState: () => UnsavedChangesState,
93 ): ComputedRef<boolean> => {
94 return computed(() => {
95 if (!initialStates.value[entityPageName]) return false
96 return !isEqual(getCurrentState(), initialStates.value[entityPageName])
97 })
98 }
99
100 /**
101 * Resets unsaved changes by updating the initial state to the current state.
102 * @param entityPageName - entity name
103 * @param getCurrentState - function that returns the current state
104 */
105 const resetUnsavedChanges = (
106 entityPageName: EntityPageName,
107 getCurrentState: () => UnsavedChangesState,
108 ) => {
109 if (initialStates.value[entityPageName]) {
110 initialStates.value[entityPageName] = { ...getCurrentState() }
111 }
112 }
113
114 /**
115 * Gets the initial state for an entity.
116 * @param entityPageName - entity name
117 * @returns UnsavedChangesState | null
118 */
119 const getInitialState = (entityPageName: EntityPageName): UnsavedChangesState | null => {
120 return initialStates.value[entityPageName] || null
121 }
122
123 /**
124 * Handle admin menu click with unsaved changes check
125 * @param event - click event
126 * @param hasChanges - boolean or function that returns boolean
127 * @param onConfirm - callback for confirmation
128 */
129 const onAdminMenuClick = (
130 event: Event,
131 hasChanges: boolean | (() => boolean),
132 onConfirm?: () => void,
133 ) => {
134 const mouseEvent = event as MouseEvent
135 const target = mouseEvent.target as HTMLElement
136 const link = target.closest('a')
137
138 if (!link) return
139
140 const hasUnsaved = typeof hasChanges === 'function' ? hasChanges() : hasChanges
141
142 if (!hasUnsaved) {
143 return
144 }
145
146 mouseEvent.preventDefault()
147 actionEntityStore.showUnsavedChangesDialog(() => {
148 if (onConfirm) {
149 onConfirm()
150 } else {
151 window.location.href = link.href
152 }
153 })
154 }
155
156 // Define EventEmitter interface for type safety compatible with mitt
157 interface EventEmitter {
158 on: (type: string, handler: (event: unknown) => void) => void
159 off: (type: string, handler: (event: unknown) => void) => void
160 }
161
162 /**
163 * Integration with event bus (mitt) instead of direct DOM listener
164 * @param emitter - event bus instance
165 * @param hasChanges - boolean or function that returns boolean
166 * @param onConfirm - callback for confirmation
167 * @returns cleanup function
168 */
169 const setupAdminMenuListener = (
170 emitter: EventEmitter,
171 hasChanges: boolean | (() => boolean),
172 onConfirm?: () => void,
173 ) => {
174 const handler = (event: unknown) => onAdminMenuClick(event as Event, hasChanges, onConfirm)
175 emitter.on('adminMenuClick', handler)
176 return () => emitter.off('adminMenuClick', handler)
177 }
178
179 // Define entity initial states centrally to avoid duplication
180 const entityInitialStates = {
181 formBuilder: {
182 name: null,
183 description: null,
184 published: true,
185 showTitle: false,
186 showDescription: false,
187 storeEntries: false,
188 integrationSettings: {
189 wpdatatables: {
190 enabled: false,
191 },
192 },
193 fields: [],
194 },
195 confirmationBuilder: {
196 url: '',
197 message: '',
198 enabled: true,
199 showForm: false,
200 page: '',
201 },
202 notificationBuilder: {
203 name: getLabel('new_notification'),
204 sender: '',
205 receiver: '',
206 enabled: false,
207 subject: '',
208 message: '',
209 },
210 settingsBuilder: {
211 recaptchaType: 'v2' as RecaptchaType,
212 recaptchaSiteKey: '',
213 recaptchaSecretKey: '',
214 recaptchaLanguage: '',
215 },
216 }
217
218 // Lock variable for race condition prevention
219 let entityStatesInitLocked = false
220
221 /**
222 * Initialize all entity states with predefined configurations
223 */
224 const initializeAllEntityStates = async () => {
225 if (entityStatesInitLocked) return
226 entityStatesInitLocked = true
227 try {
228 // Initialize all entity states using the centralized definitions
229 Object.entries(entityInitialStates).forEach(([key, state]) => {
230 initializeState(key as EntityPageName, state as UnsavedChangesState)
231 })
232 } finally {
233 entityStatesInitLocked = false
234 }
235 }
236
237 /**
238 * Returns the initial state for a specific entity.
239 * Throws an error if the entity is unknown.
240 * @param entityPageName - The entity name
241 * @returns UnsavedChangesState
242 * @throws Error if entityPageName is not recognized
243 */
244 const getEntityInitialState = (entityPageName: EntityPageName): UnsavedChangesState => {
245 if (!(entityPageName in entityInitialStates)) {
246 throw new Error(`${getLabel('unknown_entity_page_name')} ${entityPageName}`)
247 }
248 return entityInitialStates[entityPageName] as UnsavedChangesState
249 }
250
251 /**
252 * Creates a complete entity manager with all necessary functions.
253 * Uses generic types for stateRefs for better type safety.
254 * @template T
255 * @param entityPageName - The entity name
256 * @param stateRefs - Reactive references for the entity
257 * @returns Entity manager
258 */
259 const createEntityManager = <T extends UnsavedChangesState>(
260 entityPageName: EntityPageName,
261 stateRefs: Record<keyof T, Ref<T[keyof T]>>,
262 ) => {
263 // Initialize entity state
264 initializeEntityState(entityPageName)
265
266 // Create getCurrentState function
267 const getCurrentState = createGetCurrentStateFunction(entityPageName, stateRefs)
268
269 return {
270 getCurrentState,
271 hasUnsavedChanges: () => hasUnsavedChanges(entityPageName, getCurrentState),
272 resetUnsavedChanges: () => resetEntityUnsavedChanges(entityPageName, getCurrentState),
273 resetToInitialState: () => resetEntityToInitialState(entityPageName, stateRefs),
274 getInitialState: () => getEntityInitialState(entityPageName),
275 entityPageName,
276 }
277 }
278
279 /**
280 * Reset unsaved changes for a specific entity using its getCurrentState function
281 */
282 const resetEntityUnsavedChanges = (
283 entityPageName: EntityPageName,
284 getCurrentState: () => UnsavedChangesState,
285 ) => {
286 resetUnsavedChanges(entityPageName, getCurrentState)
287 }
288
289 /**
290 * Reset entity to its initial state values
291 */
292 const resetEntityToInitialState = (
293 entityPageName: EntityPageName,
294 stateRefs: Record<string, Ref<unknown>>,
295 ) => {
296 const initialState = getEntityInitialState(entityPageName)
297
298 Object.keys(initialState).forEach((key) => {
299 if (stateRefs[key]) {
300 if (key === 'fields' && entityPageName === 'formBuilder') {
301 // Deep cloning for fields
302 stateRefs[key].value = structuredClone(initialState[key])
303 } else if (Array.isArray(stateRefs[key].value) && Array.isArray(initialState[key])) {
304 stateRefs[key].value = structuredClone(initialState[key])
305 } else if (
306 typeof stateRefs[key].value === 'object' &&
307 stateRefs[key].value !== null &&
308 typeof initialState[key] === 'object' &&
309 initialState[key] !== null
310 ) {
311 stateRefs[key].value = structuredClone(initialState[key])
312 } else {
313 stateRefs[key].value = initialState[key]
314 }
315 }
316 })
317
318 // Update the stored initial state to match the reset
319 initializeState(entityPageName, initialState)
320 }
321
322 /**
323 * Create standardized getCurrentState function for an entity
324 */
325 const createGetCurrentStateFunction = (
326 entityPageName: EntityPageName,
327 stateRefs: Record<string, Ref<unknown>>,
328 ) => {
329 return () => {
330 const entityConfig = getEntityInitialState(entityPageName)
331 const result: Record<string, unknown> = {}
332
333 Object.keys(entityConfig).forEach((key) => {
334 if (stateRefs[key]) {
335 if (key === 'fields' && entityPageName === 'formBuilder') {
336 // Special handling for fields array in form builder
337 result[key] = Array.isArray(stateRefs[key].value)
338 ? [...stateRefs[key].value]
339 : stateRefs[key].value
340 } else {
341 result[key] = stateRefs[key].value
342 }
343 }
344 })
345
346 return result as unknown as UnsavedChangesState
347 }
348 }
349
350 /**
351 * Initialize single entity state with predefined configuration
352 */
353 const initializeEntityState = (entityPageName: EntityPageName) => {
354 const initialState = getEntityInitialState(entityPageName)
355 initializeState(entityPageName, initialState)
356 }
357
358 /**
359 * Create unsaved changes handlers for an entity
360 */
361 const createEntityHandlers = (entityPageName: EntityPageName) => {
362 return {
363 initializeState: (initialState: UnsavedChangesState) =>
364 initializeState(entityPageName, initialState),
365 hasUnsavedChanges: (getCurrentState: () => UnsavedChangesState) =>
366 hasUnsavedChanges(entityPageName, getCurrentState),
367 resetUnsavedChanges: (getCurrentState: () => UnsavedChangesState) =>
368 resetUnsavedChanges(entityPageName, getCurrentState),
369 getInitialState: () => getInitialState(entityPageName),
370 getCurrentState: () => {
371 throw new Error(getLabel('get_current_state_err'))
372 },
373 }
374 }
375
376 const anyUnsaved = (
377 getters: Partial<Record<UnsavedEntityKey, () => UnsavedChangesState>>,
378 entities: UnsavedEntityKey[] = ['formBuilder', 'confirmationBuilder', 'notificationBuilder'],
379 ) => {
380 return entities.some((e) => {
381 const getter = getters[e]
382 if (!getter) return false
383 return hasUnsavedChanges(e, getter).value
384 })
385 }
386
387 const confirmNavigationIfUnsaved = (
388 getters: Partial<Record<UnsavedEntityKey, () => UnsavedChangesState>>,
389 onProceed: () => void,
390 entities: UnsavedEntityKey[] = ['formBuilder', 'confirmationBuilder', 'notificationBuilder'],
391 ) => {
392 if (!anyUnsaved(getters, entities)) {
393 onProceed()
394 return
395 }
396 actionEntityStore.showUnsavedChangesDialog(onProceed)
397 }
398
399 return {
400 initializeState,
401 hasUnsavedChanges,
402 resetUnsavedChanges,
403 getInitialState,
404 onAdminMenuClick,
405 setupAdminMenuListener,
406 createEntityHandlers,
407 initializeAllEntityStates,
408 getEntityInitialState,
409 initializeEntityState,
410 createGetCurrentStateFunction,
411 resetEntityToInitialState,
412 resetEntityUnsavedChanges,
413 createEntityManager,
414 anyUnsaved,
415 confirmNavigationIfUnsaved,
416 }
417 })
418