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

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