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

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