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

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