@quazardous/qdadm 2.4.3 → 2.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.4.3",
3
+ "version": "2.4.5",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -54,9 +54,28 @@ export function useDirtyState<T extends FormState = FormState>(
54
54
  const dirty = ref(false)
55
55
  const dirtyFields = ref<string[]>([])
56
56
  const initialSnapshot = ref<string | null>(null)
57
- const initialState = ref<T | null>(null)
57
+ // Per-key snapshot strings, precomputed once at takeSnapshot() (#1194):
58
+ // checkDirty runs on every keystroke (deep watch) — re-stringifying the
59
+ // initial side each time doubled the per-key work, and the old
60
+ // JSON.parse(JSON.stringify(state)) deep clone tripled the snapshot cost.
61
+ let initialFieldSnapshots: Map<string, string> | null = null
58
62
  const ready = ref(false)
59
63
 
64
+ /** Resolve the form object inside the state (common 'form'/'formData' patterns). */
65
+ function resolveForm(state: T): Record<string, unknown> {
66
+ return (
67
+ (state.form as Record<string, unknown>) ||
68
+ (state.formData as Record<string, unknown>) ||
69
+ (state as Record<string, unknown>)
70
+ )
71
+ }
72
+
73
+ /** Stable stringify — JSON.stringify(undefined) is undefined, normalize it. */
74
+ function stringifyField(value: unknown): string {
75
+ const s = JSON.stringify(value)
76
+ return s === undefined ? 'undefined' : s
77
+ }
78
+
60
79
  function getFormSnapshot(): string {
61
80
  return JSON.stringify(getState())
62
81
  }
@@ -71,8 +90,12 @@ export function useDirtyState<T extends FormState = FormState>(
71
90
  nextTick(() => {
72
91
  const state = getState()
73
92
  initialSnapshot.value = JSON.stringify(state)
74
- // Store raw state for field-level comparison
75
- initialState.value = JSON.parse(JSON.stringify(state)) as T
93
+ const form = resolveForm(state)
94
+ const fieldSnapshots = new Map<string, string>()
95
+ for (const key in form) {
96
+ fieldSnapshots.set(key, stringifyField(form[key]))
97
+ }
98
+ initialFieldSnapshots = fieldSnapshots
76
99
  ready.value = true
77
100
  })
78
101
  }
@@ -84,25 +107,15 @@ export function useDirtyState<T extends FormState = FormState>(
84
107
  const currentSnapshot = getFormSnapshot()
85
108
  dirty.value = currentSnapshot !== initialSnapshot.value
86
109
 
87
- // Calculate dirty fields
88
- if (dirty.value && initialState.value) {
89
- const current = getState()
110
+ // Calculate dirty fields — current side only is stringified; the
111
+ // initial side comes from the cached per-key snapshots (#1194).
112
+ if (dirty.value && initialFieldSnapshots) {
113
+ const currentForm = resolveForm(getState())
90
114
  const changed: string[] = []
91
115
 
92
- // Compare form fields if state has a 'form' or 'formData' key (common patterns)
93
- const initial =
94
- (initialState.value.form as Record<string, unknown>) ||
95
- (initialState.value.formData as Record<string, unknown>) ||
96
- (initialState.value as Record<string, unknown>)
97
- const currentForm =
98
- (current.form as Record<string, unknown>) ||
99
- (current.formData as Record<string, unknown>) ||
100
- (current as Record<string, unknown>)
101
-
102
116
  for (const key in currentForm) {
103
- const initialVal = JSON.stringify(initial[key])
104
- const currentVal = JSON.stringify(currentForm[key])
105
- if (initialVal !== currentVal) {
117
+ const currentVal = stringifyField(currentForm[key])
118
+ if (initialFieldSnapshots.get(key) !== currentVal) {
106
119
  changed.push(key)
107
120
  }
108
121
  }
@@ -120,7 +133,7 @@ export function useDirtyState<T extends FormState = FormState>(
120
133
  dirty.value = false
121
134
  dirtyFields.value = []
122
135
  initialSnapshot.value = null
123
- initialState.value = null
136
+ initialFieldSnapshots = null
124
137
  ready.value = false
125
138
  }
126
139
 
@@ -0,0 +1,153 @@
1
+ /**
2
+ * useListAlterHooks — the list:alter / filter:alter hook wiring of
3
+ * useListPage (#1195, KPI-8).
4
+ *
5
+ * Mechanical extraction: snapshots the list config, runs the global and
6
+ * entity-scoped alter hooks, and applies the altered config back onto the
7
+ * live state (columns, filters, actions via the shared registry, header
8
+ * actions). useListPage composes it back in; behavior is unchanged.
9
+ */
10
+ import type { Ref } from 'vue'
11
+ import type {
12
+ ActionConfig,
13
+ ColumnConfig,
14
+ FilterConfig,
15
+ HeaderActionConfig,
16
+ ResolvedAction,
17
+ } from './useListPage.types'
18
+ import type { UseActionRegistryReturn } from './useActionRegistry'
19
+
20
+ /** Minimal HookRegistry view (what the alter flow actually uses). */
21
+ export interface AlterHooksLike {
22
+ alter: (name: string, value: unknown) => Promise<unknown>
23
+ hasHook: (name: string) => boolean
24
+ }
25
+
26
+ /** Dependencies injected by useListPage. */
27
+ export interface UseListAlterHooksDeps {
28
+ hooks: AlterHooksLike | null | undefined
29
+ entity: string
30
+ manager: unknown
31
+ columnsMap: Ref<Map<string, ColumnConfig>>
32
+ filtersMap: Ref<Map<string, FilterConfig>>
33
+ filterValues: Ref<Record<string, unknown>>
34
+ headerActionsMap: Ref<Map<string, HeaderActionConfig>>
35
+ actionRegistry: UseActionRegistryReturn<ActionConfig, unknown, ResolvedAction>
36
+ }
37
+
38
+ export interface UseListAlterHooksReturn {
39
+ invokeListAlterHook: () => Promise<void>
40
+ invokeFilterAlterHook: () => Promise<void>
41
+ }
42
+
43
+ export function useListAlterHooks(deps: UseListAlterHooksDeps): UseListAlterHooksReturn {
44
+ const {
45
+ hooks,
46
+ entity,
47
+ manager,
48
+ columnsMap,
49
+ filtersMap,
50
+ filterValues,
51
+ headerActionsMap,
52
+ actionRegistry,
53
+ } = deps
54
+ const actionsMap = actionRegistry.actionsMap
55
+
56
+ async function invokeListAlterHook(): Promise<void> {
57
+ if (!hooks) return
58
+
59
+ const configSnapshot = {
60
+ entity,
61
+ columns: Array.from(columnsMap.value.values()),
62
+ filters: Array.from(filtersMap.value.values()),
63
+ actions: Array.from(actionsMap.value.values()),
64
+ headerActions: Array.from(headerActionsMap.value.values()),
65
+ }
66
+
67
+ // Include entity context in the snapshot for hooks
68
+ const configWithContext = { ...configSnapshot, entity, manager }
69
+
70
+ let alteredConfig = await hooks.alter('list:alter', configWithContext)
71
+
72
+ const entityHookName = `${entity}:list:alter`
73
+ if (hooks.hasHook(entityHookName)) {
74
+ alteredConfig = await hooks.alter(entityHookName, alteredConfig)
75
+ }
76
+
77
+ applyAlteredConfig(
78
+ alteredConfig as {
79
+ columns?: ColumnConfig[]
80
+ filters?: FilterConfig[]
81
+ actions?: ActionConfig[]
82
+ headerActions?: HeaderActionConfig[]
83
+ }
84
+ )
85
+ }
86
+
87
+ async function invokeFilterAlterHook(): Promise<void> {
88
+ if (!hooks) return
89
+
90
+ const filterSnapshot = {
91
+ entity,
92
+ filters: Array.from(filtersMap.value.values()),
93
+ }
94
+
95
+ let alteredFilters = (await hooks.alter('filter:alter', filterSnapshot)) as typeof filterSnapshot
96
+
97
+ const entityHookName = `${entity}:filter:alter`
98
+ if (hooks.hasHook(entityHookName)) {
99
+ alteredFilters = (await hooks.alter(entityHookName, alteredFilters)) as typeof filterSnapshot
100
+ }
101
+
102
+ if (alteredFilters.filters) {
103
+ filtersMap.value.clear()
104
+ for (const filter of alteredFilters.filters) {
105
+ filtersMap.value.set(filter.name, filter)
106
+ if (filterValues.value[filter.name] === undefined) {
107
+ filterValues.value[filter.name] = filter.default ?? null
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ function applyAlteredConfig(alteredConfig: {
114
+ columns?: ColumnConfig[]
115
+ filters?: FilterConfig[]
116
+ actions?: ActionConfig[]
117
+ headerActions?: HeaderActionConfig[]
118
+ }): void {
119
+ if (alteredConfig.columns) {
120
+ columnsMap.value.clear()
121
+ for (const col of alteredConfig.columns) {
122
+ columnsMap.value.set(col.field, col)
123
+ }
124
+ }
125
+
126
+ if (alteredConfig.filters) {
127
+ filtersMap.value.clear()
128
+ for (const filter of alteredConfig.filters) {
129
+ filtersMap.value.set(filter.name, filter)
130
+ if (filterValues.value[filter.name] === undefined) {
131
+ filterValues.value[filter.name] = filter.default ?? null
132
+ }
133
+ }
134
+ }
135
+
136
+ if (alteredConfig.actions) {
137
+ // Route through the registry so the order list stays in sync (#1193)
138
+ actionRegistry.clear()
139
+ for (const action of alteredConfig.actions) {
140
+ actionRegistry.add(action)
141
+ }
142
+ }
143
+
144
+ if (alteredConfig.headerActions) {
145
+ headerActionsMap.value.clear()
146
+ for (const action of alteredConfig.headerActions) {
147
+ headerActionsMap.value.set(action.name, action)
148
+ }
149
+ }
150
+ }
151
+
152
+ return { invokeListAlterHook, invokeFilterAlterHook }
153
+ }
@@ -0,0 +1,421 @@
1
+ /**
2
+ * useListFilters — the filter subsystem of useListPage (#1195, KPI-8).
3
+ *
4
+ * Mechanical extraction: owns the filter state (filtersMap/filterValues),
5
+ * the three option-source modes (optionsEntity / optionsEndpoint /
6
+ * optionsFromCache), session persistence, URL sync and registry auto-load.
7
+ * useListPage composes it back in; behavior is unchanged.
8
+ */
9
+ import { ref, computed, type Ref, type ComputedRef } from 'vue'
10
+ import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
11
+ import { FilterQuery, type QueryOrchestratorLike } from '../query/FilterQuery'
12
+ import type { FilterConfig, SearchConfig } from './useListPage.types'
13
+ import {
14
+ SMART_FILTER_THRESHOLD,
15
+ clearSessionFilters,
16
+ setSessionFilters,
17
+ snakeToTitle,
18
+ } from './useListPage.utils'
19
+
20
+ /** Dependencies injected by useListPage. */
21
+ export interface UseListFiltersDeps {
22
+ /** Entity name used for the registry lookup. */
23
+ entityName: string
24
+ /** EntityManager (needs .request for optionsEndpoint mode). */
25
+ manager: { request: (method: string, endpoint: string) => Promise<unknown> }
26
+ /** Orchestrator, forwarded to FilterQuery for optionsEntity mode. */
27
+ orchestrator: QueryOrchestratorLike | null | undefined
28
+ /** Loaded list items (source for optionsFromCache mode). */
29
+ items: Ref<unknown[]>
30
+ /** Current page ref — filter changes reset it to 1. */
31
+ page: Ref<number>
32
+ /** Search query ref (shared with the search subsystem). */
33
+ searchQuery: Ref<string>
34
+ route: RouteLocationNormalizedLoaded
35
+ router: Router
36
+ /** Session-restored filter values (already stripped of _search). */
37
+ savedFilters: Record<string, unknown> | null
38
+ persistFilters: boolean
39
+ syncUrlParams: boolean
40
+ autoLoadFilters: boolean
41
+ filterSessionKey: string
42
+ /** Entity filters registry (injected by the consuming app). */
43
+ entityFilters: Record<string, { search?: SearchConfig; filters?: FilterConfig[] }>
44
+ /** Thunks — resolved lazily, the targets are declared later in useListPage. */
45
+ loadItems: () => void
46
+ setSearch: (searchCfg: Partial<SearchConfig>) => void
47
+ invokeFilterAlterHook: () => Promise<void>
48
+ }
49
+
50
+ export interface UseListFiltersReturn {
51
+ filtersMap: Ref<Map<string, FilterConfig>>
52
+ filterValues: Ref<Record<string, unknown>>
53
+ filters: ComputedRef<FilterConfig[]>
54
+ hasActiveFilters: ComputedRef<boolean>
55
+ addFilter: (name: string, filterConfig: Omit<FilterConfig, 'name'>) => void
56
+ removeFilter: (name: string) => void
57
+ setFilterValue: (name: string, value: unknown) => void
58
+ updateFilters: (newValues: Record<string, unknown>) => void
59
+ onFiltersChanged: () => void
60
+ clearFilters: () => void
61
+ isFilterAtDefault: (name: string) => boolean
62
+ initFromRegistry: () => void
63
+ loadFilterOptions: () => Promise<void>
64
+ updateCacheBasedFilters: () => Promise<void>
65
+ restoreFilters: () => void
66
+ }
67
+
68
+ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
69
+ const {
70
+ entityName,
71
+ manager,
72
+ orchestrator,
73
+ items,
74
+ page,
75
+ searchQuery,
76
+ route,
77
+ router,
78
+ savedFilters,
79
+ persistFilters,
80
+ syncUrlParams,
81
+ autoLoadFilters,
82
+ filterSessionKey,
83
+ entityFilters,
84
+ loadItems,
85
+ setSearch,
86
+ invokeFilterAlterHook,
87
+ } = deps
88
+
89
+ const filtersMap = ref<Map<string, FilterConfig>>(new Map())
90
+ const filterValues = ref<Record<string, unknown>>(savedFilters || {})
91
+
92
+ function addFilter(name: string, filterConfig: Omit<FilterConfig, 'name'>): void {
93
+ filtersMap.value.set(name, {
94
+ name,
95
+ type: 'select',
96
+ placeholder: name,
97
+ ...filterConfig,
98
+ })
99
+ if (filterValues.value[name] === undefined) {
100
+ filterValues.value[name] = filterConfig.default ?? null
101
+ }
102
+ }
103
+
104
+ function removeFilter(name: string): void {
105
+ filtersMap.value.delete(name)
106
+ delete filterValues.value[name]
107
+ }
108
+
109
+ function setFilterValue(name: string, value: unknown): void {
110
+ filterValues.value = { ...filterValues.value, [name]: value }
111
+ }
112
+
113
+ function updateFilters(newValues: Record<string, unknown>): void {
114
+ filterValues.value = { ...filterValues.value, ...newValues }
115
+ onFiltersChanged()
116
+ }
117
+
118
+ function onFiltersChanged(): void {
119
+ page.value = 1
120
+ loadItems()
121
+ if (persistFilters) {
122
+ const toPersist: Record<string, unknown> = {}
123
+ for (const [name, value] of Object.entries(filterValues.value)) {
124
+ const filterDef = filtersMap.value.get(name)
125
+ if (
126
+ filterDef?.persist !== false &&
127
+ value !== null &&
128
+ value !== undefined &&
129
+ value !== ''
130
+ ) {
131
+ toPersist[name] = value
132
+ }
133
+ }
134
+ if (searchQuery.value) {
135
+ toPersist._search = searchQuery.value
136
+ }
137
+ setSessionFilters(filterSessionKey, toPersist)
138
+ }
139
+ if (syncUrlParams) {
140
+ const query = { ...route.query } as Record<string, string>
141
+ for (const [name, value] of Object.entries(filterValues.value)) {
142
+ if (value !== null && value !== undefined && value !== '') {
143
+ query[name] = String(value)
144
+ } else {
145
+ delete query[name]
146
+ }
147
+ }
148
+ if (searchQuery.value) {
149
+ query.search = searchQuery.value
150
+ } else {
151
+ delete query.search
152
+ }
153
+ router.replace({ query })
154
+ }
155
+ }
156
+
157
+ function clearFilters(): void {
158
+ const cleared: Record<string, unknown> = {}
159
+ for (const [key, filterDef] of filtersMap.value.entries()) {
160
+ cleared[key] = filterDef.default ?? null
161
+ }
162
+ filterValues.value = cleared
163
+ searchQuery.value = ''
164
+ if (persistFilters) {
165
+ clearSessionFilters(filterSessionKey)
166
+ }
167
+ if (syncUrlParams) {
168
+ const query = { ...route.query } as Record<string, string>
169
+ for (const key of filtersMap.value.keys()) {
170
+ delete query[key]
171
+ }
172
+ delete query.search
173
+ router.replace({ query })
174
+ }
175
+ page.value = 1
176
+ loadItems()
177
+ }
178
+
179
+ const filters = computed(() => Array.from(filtersMap.value.values()))
180
+
181
+ /**
182
+ * Check if a filter is at its default value
183
+ * Used for styling: default = blue (info), modified = orange (warning)
184
+ */
185
+ function isFilterAtDefault(name: string): boolean {
186
+ const filterDef = filtersMap.value.get(name)
187
+ if (!filterDef) return true
188
+ const currentValue = filterValues.value[name]
189
+ const defaultValue = filterDef.default ?? null
190
+ return currentValue === defaultValue
191
+ }
192
+
193
+ /**
194
+ * Check if any filter is NOT at its default value (or search is active)
195
+ * Useful to show a "clear filters" button only when needed
196
+ */
197
+ const hasActiveFilters = computed(() => {
198
+ if (searchQuery.value) return true
199
+ for (const [name, filterDef] of filtersMap.value.entries()) {
200
+ const currentValue = filterValues.value[name]
201
+ const defaultValue = filterDef.default ?? null
202
+ if (currentValue !== defaultValue) return true
203
+ }
204
+ return false
205
+ })
206
+
207
+ function initFromRegistry(): void {
208
+ if (!autoLoadFilters) return
209
+
210
+ const entityConfig = entityFilters[entityName]
211
+ if (!entityConfig) return
212
+
213
+ if (entityConfig.search) {
214
+ setSearch(entityConfig.search)
215
+ }
216
+
217
+ if (entityConfig.filters) {
218
+ for (const filterDef of entityConfig.filters) {
219
+ addFilter(filterDef.name, filterDef)
220
+ }
221
+ }
222
+ }
223
+
224
+ async function loadFilterOptions(): Promise<void> {
225
+ // Process filters configured directly via addFilter() (smart filter modes)
226
+ for (const [filterName, filterDef] of filtersMap.value) {
227
+ if (filterDef.options && filterDef.options.length > 1) continue
228
+ if (filterDef.optionsFromCache) continue
229
+
230
+ try {
231
+ let rawOptions: Array<{ label: string; value: unknown }> | null = null
232
+
233
+ // Mode 1: optionsEntity - fetch from related EntityManager via FilterQuery
234
+ if (filterDef.optionsEntity) {
235
+ const filterQuery = new FilterQuery({
236
+ source: 'entity',
237
+ entity: filterDef.optionsEntity,
238
+ label: filterDef.optionLabel || 'name',
239
+ value: filterDef.optionValue || 'id',
240
+ })
241
+
242
+ rawOptions = (await filterQuery.getOptions(orchestrator)) as Array<{
243
+ label: string
244
+ value: unknown
245
+ }>
246
+ filterDef._filterQuery = filterQuery
247
+ }
248
+ // Mode 2: optionsEndpoint - fetch from API endpoint
249
+ else if (filterDef.optionsEndpoint) {
250
+ const endpoint =
251
+ filterDef.optionsEndpoint === true
252
+ ? `distinct/${filterName}`
253
+ : filterDef.optionsEndpoint
254
+ const response = await manager.request('GET', endpoint as string)
255
+ const data = Array.isArray(response)
256
+ ? response
257
+ : ((response as Record<string, unknown>)?.items as unknown[]) || []
258
+ rawOptions = data.map((opt) => {
259
+ if (typeof opt === 'object' && opt !== null) {
260
+ const o = opt as Record<string, unknown>
261
+ return {
262
+ label: (o.label || o.name || String(o.value ?? o.id)) as string,
263
+ value: o.value ?? o.id,
264
+ }
265
+ }
266
+ return { label: snakeToTitle(String(opt)), value: opt }
267
+ })
268
+ }
269
+
270
+ if (rawOptions !== null) {
271
+ const cacheOptions = filterDef.cacheOptions ?? 'auto'
272
+ let shouldCache = cacheOptions === true
273
+
274
+ if (cacheOptions === 'auto') {
275
+ shouldCache = rawOptions.length <= SMART_FILTER_THRESHOLD
276
+ }
277
+
278
+ const componentType =
279
+ filterDef.component || (shouldCache ? 'dropdown' : 'autocomplete')
280
+ const allLabel =
281
+ filterDef.allLabel || filterDef.placeholder || `All ${snakeToTitle(filterName)}`
282
+ let finalOptions = [{ label: allLabel, value: null as unknown }, ...rawOptions]
283
+
284
+ if (typeof filterDef.processor === 'function') {
285
+ finalOptions = filterDef.processor(finalOptions)
286
+ }
287
+
288
+ const updatedFilter: FilterConfig = {
289
+ ...filterDef,
290
+ options: finalOptions,
291
+ type: componentType,
292
+ _cacheOptions: shouldCache,
293
+ _optionsLoaded: shouldCache,
294
+ }
295
+ delete updatedFilter.optionLabel
296
+ delete updatedFilter.optionValue
297
+ filtersMap.value.set(filterName, updatedFilter)
298
+ }
299
+ } catch (error) {
300
+ console.warn(`[qdadm] Failed to load options for filter "${filterName}":`, error)
301
+ }
302
+ }
303
+
304
+ // Invoke filter:alter hooks after all options are loaded
305
+ await invokeFilterAlterHook()
306
+
307
+ // Trigger Vue reactivity
308
+ filtersMap.value = new Map(filtersMap.value)
309
+ }
310
+
311
+ async function updateCacheBasedFilters(): Promise<void> {
312
+ if (items.value.length === 0) return
313
+
314
+ let hasChanges = false
315
+
316
+ for (const [filterName, filterDef] of filtersMap.value) {
317
+ if (!filterDef.optionsFromCache) continue
318
+ if (filterDef._optionsLoaded) continue
319
+ // Skip if filter has an explicit query property (advanced usage)
320
+ if (filterDef.query) continue
321
+
322
+ const currentValue = filterValues.value[filterName]
323
+ if (currentValue !== null && currentValue !== undefined && currentValue !== '') {
324
+ continue
325
+ }
326
+
327
+ const fieldName =
328
+ typeof filterDef.optionsFromCache === 'string' ? filterDef.optionsFromCache : filterName
329
+
330
+ const filterQuery = new FilterQuery({
331
+ source: 'field',
332
+ field: fieldName,
333
+ })
334
+
335
+ // Provide a minimal mock manager with cached data for field-based filtering
336
+ filterQuery.setParentManager({
337
+ _cache: items.value,
338
+ list: async () => ({ items: items.value }),
339
+ })
340
+
341
+ const rawOptions = (await filterQuery.getOptions()) as Array<{ label: string; value: unknown }>
342
+
343
+ const cacheOptions = filterDef.cacheOptions ?? 'auto'
344
+ let shouldCache = true
345
+
346
+ if (cacheOptions === 'auto') {
347
+ shouldCache = rawOptions.length <= SMART_FILTER_THRESHOLD
348
+ } else if (cacheOptions === false) {
349
+ shouldCache = false
350
+ }
351
+
352
+ const componentType =
353
+ filterDef.component ||
354
+ (rawOptions.length <= SMART_FILTER_THRESHOLD ? 'dropdown' : 'autocomplete')
355
+
356
+ const allLabel =
357
+ filterDef.allLabel || filterDef.placeholder || `All ${snakeToTitle(filterName)}`
358
+ let finalOptions = [
359
+ { label: allLabel, value: null as unknown },
360
+ ...rawOptions.map((opt) => ({
361
+ label: snakeToTitle(String(opt.label)),
362
+ value: opt.value,
363
+ })),
364
+ ]
365
+
366
+ if (typeof filterDef.processor === 'function') {
367
+ finalOptions = filterDef.processor(finalOptions)
368
+ }
369
+
370
+ const updatedFilter: FilterConfig = {
371
+ ...filterDef,
372
+ options: finalOptions,
373
+ type: componentType,
374
+ _cacheOptions: shouldCache,
375
+ _optionsLoaded: true,
376
+ _filterQuery: filterQuery,
377
+ }
378
+
379
+ filtersMap.value.set(filterName, updatedFilter)
380
+ hasChanges = true
381
+ }
382
+
383
+ if (hasChanges) {
384
+ filtersMap.value = new Map(filtersMap.value)
385
+ }
386
+ }
387
+
388
+ function restoreFilters(): void {
389
+ for (const key of filtersMap.value.keys()) {
390
+ if (route.query[key] !== undefined) {
391
+ let value: unknown = route.query[key]
392
+ if (value === 'true') value = true
393
+ else if (value === 'false') value = false
394
+ else if (value === 'null') value = null
395
+ else if (!isNaN(Number(value)) && value !== '') value = Number(value)
396
+ filterValues.value[key] = value
397
+ }
398
+ }
399
+ if (route.query.search) {
400
+ searchQuery.value = route.query.search as string
401
+ }
402
+ }
403
+
404
+ return {
405
+ filtersMap,
406
+ filterValues,
407
+ filters,
408
+ hasActiveFilters,
409
+ addFilter,
410
+ removeFilter,
411
+ setFilterValue,
412
+ updateFilters,
413
+ onFiltersChanged,
414
+ clearFilters,
415
+ isFilterAtDefault,
416
+ initFromRegistry,
417
+ loadFilterOptions,
418
+ updateCacheBasedFilters,
419
+ restoreFilters,
420
+ }
421
+ }