qdadm 0.18.0 → 0.25.0
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 +1 -1
- package/src/composables/useListPageBuilder.js +285 -46
- package/src/entity/EntityManager.js +293 -24
- package/src/entity/storage/ApiStorage.js +19 -2
- package/src/entity/storage/LocalStorage.js +25 -2
- package/src/entity/storage/MemoryStorage.js +28 -0
- package/src/entity/storage/MockApiStorage.js +25 -2
- package/src/entity/storage/SdkStorage.js +17 -2
- package/src/entity/storage/index.js +105 -0
- package/src/index.js +3 -0
- package/src/query/FilterQuery.js +277 -0
- package/src/query/QueryExecutor.js +332 -0
- package/src/query/index.js +8 -0
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import { useRouter, useRoute } from 'vue-router'
|
|
|
3
3
|
import { useToast } from 'primevue/usetoast'
|
|
4
4
|
import { useConfirm } from 'primevue/useconfirm'
|
|
5
5
|
import { useHooks } from './useHooks.js'
|
|
6
|
+
import { FilterQuery } from '../query/FilterQuery.js'
|
|
6
7
|
|
|
7
8
|
// Cookie utilities for pagination persistence
|
|
8
9
|
const COOKIE_NAME = 'qdadm_pageSize'
|
|
@@ -78,34 +79,26 @@ function setSessionFilters(key, filters) {
|
|
|
78
79
|
*
|
|
79
80
|
* Threshold priority: config `autoFilterThreshold` > `manager.localFilterThreshold` > 100
|
|
80
81
|
*
|
|
81
|
-
* ##
|
|
82
|
+
* ## toQuery - Virtual Filter to Query Mapping
|
|
82
83
|
*
|
|
83
|
-
*
|
|
84
|
-
* |-----------------------------|-----------------|---------------------------|
|
|
85
|
-
* | Filter standard | Manager handles | item[field] === value |
|
|
86
|
-
* | Filter + local_filter | Manager handles | local_filter(item, value) |
|
|
87
|
-
* | Filter + local_filter:false | Manager handles | (skipped) |
|
|
88
|
-
* | Search standard | Manager handles | field.includes(query) |
|
|
89
|
-
* | Search + local_search | Manager handles | local_search(item, query) |
|
|
90
|
-
* | Search + local_search:false | Manager handles | (skipped) |
|
|
91
|
-
*
|
|
92
|
-
* - `local_filter`/`local_search`: callback = HOW to filter locally
|
|
93
|
-
* - `local_filter: false` / `local_search: false`: manager only, skip in local mode
|
|
84
|
+
* Use `toQuery` for virtual filters that map to real entity fields:
|
|
94
85
|
*
|
|
95
86
|
* ```js
|
|
96
|
-
* // Virtual filter (use callback in local mode)
|
|
97
87
|
* list.addFilter('status', {
|
|
98
|
-
* options: [
|
|
99
|
-
*
|
|
100
|
-
* }
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
88
|
+
* options: [
|
|
89
|
+
* { label: 'Active', value: 'active' },
|
|
90
|
+
* { label: 'Returned', value: 'returned' }
|
|
91
|
+
* ],
|
|
92
|
+
* toQuery: (value) => {
|
|
93
|
+
* if (value === 'active') return { returned_at: null }
|
|
94
|
+
* if (value === 'returned') return { returned_at: { $ne: null } }
|
|
95
|
+
* return {}
|
|
96
|
+
* }
|
|
106
97
|
* })
|
|
107
98
|
* ```
|
|
108
99
|
*
|
|
100
|
+
* The query is processed by QueryExecutor (local cache) or sent to API.
|
|
101
|
+
*
|
|
109
102
|
* ## Basic Usage
|
|
110
103
|
*
|
|
111
104
|
* ```js
|
|
@@ -597,20 +590,135 @@ export function useListPageBuilder(config = {}) {
|
|
|
597
590
|
}
|
|
598
591
|
}
|
|
599
592
|
|
|
593
|
+
// Smart filter auto-discovery threshold
|
|
594
|
+
const SMART_FILTER_THRESHOLD = 50
|
|
595
|
+
|
|
600
596
|
/**
|
|
601
|
-
* Load filter options from
|
|
597
|
+
* Load filter options from various sources (smart filter modes)
|
|
598
|
+
*
|
|
599
|
+
* Smart filter modes (in priority order):
|
|
600
|
+
* 1. `optionsEntity` - fetch from related EntityManager
|
|
601
|
+
* 2. `optionsEndpoint` - fetch from API endpoint (true = auto, string = custom URL)
|
|
602
|
+
* 3. `optionsFromCache` - extract from items cache (handled separately via watcher)
|
|
603
|
+
*
|
|
604
|
+
* Cache options behavior (`cacheOptions`):
|
|
605
|
+
* - `true`: Cache options, use dropdown (default for small datasets)
|
|
606
|
+
* - `false`: No cache, use autocomplete (default for large datasets)
|
|
607
|
+
* - `'auto'` (default): First load decides based on count (≤50 → cache, >50 → no cache)
|
|
608
|
+
*
|
|
609
|
+
* Component selection (`component`):
|
|
610
|
+
* - `'dropdown'`: PrimeVue Select (default when cached)
|
|
611
|
+
* - `'autocomplete'`: PrimeVue AutoComplete (default when not cached)
|
|
612
|
+
* - Explicit `component` prop overrides auto-selection
|
|
613
|
+
*
|
|
602
614
|
* After loading, invokes filter:alter and {entity}:filter:alter hooks.
|
|
603
615
|
*/
|
|
604
616
|
async function loadFilterOptions() {
|
|
605
|
-
|
|
617
|
+
// Process filters configured directly via addFilter() (smart filter modes)
|
|
618
|
+
for (const [filterName, filterDef] of filtersMap.value) {
|
|
619
|
+
// Skip if explicit options already provided (not smart filter)
|
|
620
|
+
if (filterDef.options?.length > 1) continue
|
|
621
|
+
// Skip optionsFromCache - handled by watcher
|
|
622
|
+
if (filterDef.optionsFromCache) continue
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
let rawOptions = null
|
|
626
|
+
|
|
627
|
+
// Mode 1: optionsEntity - fetch from related EntityManager via FilterQuery
|
|
628
|
+
if (filterDef.optionsEntity) {
|
|
629
|
+
// Create FilterQuery from legacy optionsEntity config (T279)
|
|
630
|
+
// This centralizes option resolution through FilterQuery while maintaining
|
|
631
|
+
// backward compatibility with existing optionsEntity/optionLabel/optionValue syntax
|
|
632
|
+
//
|
|
633
|
+
// Note: processor is NOT passed to FilterQuery.transform because the existing
|
|
634
|
+
// behavior applies processor AFTER adding "All X" option. FilterQuery.transform
|
|
635
|
+
// would apply it before. For backward compatibility, we handle processor manually.
|
|
636
|
+
const filterQuery = new FilterQuery({
|
|
637
|
+
source: 'entity',
|
|
638
|
+
entity: filterDef.optionsEntity,
|
|
639
|
+
label: filterDef.optionLabel || 'name',
|
|
640
|
+
value: filterDef.optionValue || 'id'
|
|
641
|
+
// transform: intentionally not set - processor applied after "All X" is added
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
// Get options via FilterQuery.getOptions()
|
|
645
|
+
rawOptions = await filterQuery.getOptions(orchestrator)
|
|
646
|
+
|
|
647
|
+
// Store the FilterQuery instance on filterDef for potential cache invalidation
|
|
648
|
+
filterDef._filterQuery = filterQuery
|
|
649
|
+
}
|
|
650
|
+
// Mode 2: optionsEndpoint - fetch from API endpoint
|
|
651
|
+
else if (filterDef.optionsEndpoint) {
|
|
652
|
+
const endpoint = filterDef.optionsEndpoint === true
|
|
653
|
+
? `distinct/${filterName}`
|
|
654
|
+
: filterDef.optionsEndpoint
|
|
655
|
+
const response = await manager.request('GET', endpoint)
|
|
656
|
+
const data = Array.isArray(response) ? response : response?.items || []
|
|
657
|
+
rawOptions = data.map(opt => {
|
|
658
|
+
// Handle both primitive values and objects
|
|
659
|
+
if (typeof opt === 'object' && opt !== null) {
|
|
660
|
+
return {
|
|
661
|
+
label: opt.label || opt.name || String(opt.value ?? opt.id),
|
|
662
|
+
value: opt.value ?? opt.id
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
// Primitive value
|
|
666
|
+
return { label: snakeToTitle(String(opt)), value: opt }
|
|
667
|
+
})
|
|
668
|
+
}
|
|
606
669
|
|
|
607
|
-
|
|
670
|
+
// Apply options if loaded
|
|
671
|
+
if (rawOptions !== null) {
|
|
672
|
+
// Log filter options for validation
|
|
673
|
+
console.log('[filterquery] Options loaded for:', filterName, '(count:', rawOptions.length, ')')
|
|
674
|
+
|
|
675
|
+
// Determine cache behavior
|
|
676
|
+
const cacheOptions = filterDef.cacheOptions ?? 'auto'
|
|
677
|
+
let shouldCache = cacheOptions === true
|
|
678
|
+
|
|
679
|
+
// Auto-discovery: >50 items → no cache + autocomplete
|
|
680
|
+
if (cacheOptions === 'auto') {
|
|
681
|
+
shouldCache = rawOptions.length <= SMART_FILTER_THRESHOLD
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Determine component type (explicit override or based on cache)
|
|
685
|
+
// Use 'type' for ListPage.vue compatibility
|
|
686
|
+
const componentType = filterDef.component || (shouldCache ? 'dropdown' : 'autocomplete')
|
|
687
|
+
|
|
688
|
+
// Generate "All X" label from placeholder or filterName
|
|
689
|
+
const allLabel = filterDef.allLabel || filterDef.placeholder || `All ${snakeToTitle(filterName)}`
|
|
690
|
+
let finalOptions = [{ label: allLabel, value: null }, ...rawOptions]
|
|
691
|
+
|
|
692
|
+
// Apply processor callback if configured
|
|
693
|
+
if (typeof filterDef.processor === 'function') {
|
|
694
|
+
finalOptions = filterDef.processor(finalOptions)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Options are normalized to { label, value } - remove source-specific field mappings
|
|
698
|
+
// so ListPage.vue uses defaults (optionLabel='label', optionValue='value')
|
|
699
|
+
const updatedFilter = {
|
|
700
|
+
...filterDef,
|
|
701
|
+
options: finalOptions,
|
|
702
|
+
type: componentType,
|
|
703
|
+
_cacheOptions: shouldCache,
|
|
704
|
+
_optionsLoaded: shouldCache // Only mark as loaded if caching
|
|
705
|
+
}
|
|
706
|
+
delete updatedFilter.optionLabel
|
|
707
|
+
delete updatedFilter.optionValue
|
|
708
|
+
filtersMap.value.set(filterName, updatedFilter)
|
|
709
|
+
}
|
|
710
|
+
} catch (error) {
|
|
711
|
+
console.warn(`[qdadm] Failed to load options for filter "${filterName}":`, error)
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Legacy: Load options from registry (entityFilters)
|
|
716
|
+
const entityConfig = entityFilters[entityName]
|
|
608
717
|
if (entityConfig?.filters) {
|
|
609
718
|
for (const filterDef of entityConfig.filters) {
|
|
610
719
|
if (!filterDef.optionsEndpoint) continue
|
|
611
720
|
|
|
612
721
|
try {
|
|
613
|
-
// Use manager.request for custom endpoints, or get another manager
|
|
614
722
|
const optionsManager = filterDef.optionsEntity
|
|
615
723
|
? orchestrator.get(filterDef.optionsEntity)
|
|
616
724
|
: manager
|
|
@@ -624,18 +732,14 @@ export function useListPageBuilder(config = {}) {
|
|
|
624
732
|
rawOptions = Array.isArray(rawOptions) ? rawOptions : rawOptions?.items || []
|
|
625
733
|
}
|
|
626
734
|
|
|
627
|
-
//
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
735
|
+
// Generate "All X" label from placeholder or filterName
|
|
736
|
+
const filterName = filterDef.field || filterDef.name || 'Items'
|
|
737
|
+
const allLabel = filterDef.allLabel || filterDef.placeholder || `All ${snakeToTitle(filterName)}`
|
|
738
|
+
let finalOptions = [{ label: allLabel, value: null }]
|
|
631
739
|
if (filterDef.includeNull) {
|
|
632
740
|
finalOptions.push(filterDef.includeNull)
|
|
633
741
|
}
|
|
634
742
|
|
|
635
|
-
// Map fetched options to standard { label, value } format
|
|
636
|
-
// optionLabelField/optionValueField specify which API fields to use
|
|
637
|
-
// labelMap: { value: 'Label' } for custom value-to-label mapping
|
|
638
|
-
// labelFallback: function(value) for unknown values (default: snakeToTitle)
|
|
639
743
|
const labelField = filterDef.optionLabelField || 'label'
|
|
640
744
|
const valueField = filterDef.optionValueField || 'value'
|
|
641
745
|
const labelMap = filterDef.labelMap || {}
|
|
@@ -643,7 +747,6 @@ export function useListPageBuilder(config = {}) {
|
|
|
643
747
|
|
|
644
748
|
const mappedOptions = rawOptions.map(opt => {
|
|
645
749
|
const value = opt[valueField] ?? opt.id ?? opt
|
|
646
|
-
// Priority: labelMap > API label field > fallback function
|
|
647
750
|
let label = labelMap[value]
|
|
648
751
|
if (!label) {
|
|
649
752
|
label = opt[labelField] || opt.name
|
|
@@ -656,7 +759,6 @@ export function useListPageBuilder(config = {}) {
|
|
|
656
759
|
|
|
657
760
|
finalOptions = [...finalOptions, ...mappedOptions]
|
|
658
761
|
|
|
659
|
-
// Update filter options in map
|
|
660
762
|
const existing = filtersMap.value.get(filterDef.name)
|
|
661
763
|
if (existing) {
|
|
662
764
|
filtersMap.value.set(filterDef.name, { ...existing, options: finalOptions })
|
|
@@ -674,6 +776,121 @@ export function useListPageBuilder(config = {}) {
|
|
|
674
776
|
filtersMap.value = new Map(filtersMap.value)
|
|
675
777
|
}
|
|
676
778
|
|
|
779
|
+
/**
|
|
780
|
+
* Update filter options from cache (optionsFromCache mode)
|
|
781
|
+
* Called when items.value changes
|
|
782
|
+
*
|
|
783
|
+
* IMPORTANT: Options are only extracted once (on first load with unfiltered data).
|
|
784
|
+
* This prevents options from disappearing when filtering reduces the visible items.
|
|
785
|
+
*
|
|
786
|
+
* Cache behavior for optionsFromCache:
|
|
787
|
+
* - Always cached by nature (extracted from loaded items)
|
|
788
|
+
* - Component type follows same rules: ≤50 → dropdown, >50 → autocomplete
|
|
789
|
+
* - Explicit `component` prop overrides auto-selection
|
|
790
|
+
*
|
|
791
|
+
* Implementation uses FilterQuery with source='field' internally (T281).
|
|
792
|
+
* This centralizes option resolution logic while maintaining backward compatibility.
|
|
793
|
+
*/
|
|
794
|
+
async function updateCacheBasedFilters() {
|
|
795
|
+
if (items.value.length === 0) return
|
|
796
|
+
|
|
797
|
+
let hasChanges = false
|
|
798
|
+
|
|
799
|
+
for (const [filterName, filterDef] of filtersMap.value) {
|
|
800
|
+
// Skip if no optionsFromCache config
|
|
801
|
+
if (!filterDef.optionsFromCache) continue
|
|
802
|
+
|
|
803
|
+
// Skip if filter already has explicit query property (advanced usage)
|
|
804
|
+
if (filterDef.query) continue
|
|
805
|
+
|
|
806
|
+
// Skip if options already loaded (prevents options disappearing when filtering)
|
|
807
|
+
if (filterDef._optionsLoaded) continue
|
|
808
|
+
|
|
809
|
+
// Skip if this filter is currently active - wait for unfiltered data to extract all options
|
|
810
|
+
// This ensures we capture all possible values, not just those visible with current filter
|
|
811
|
+
const currentValue = filterValues.value[filterName]
|
|
812
|
+
if (currentValue !== null && currentValue !== undefined && currentValue !== '') {
|
|
813
|
+
continue
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Determine field name: explicit string or use filter name
|
|
817
|
+
const fieldName = typeof filterDef.optionsFromCache === 'string'
|
|
818
|
+
? filterDef.optionsFromCache
|
|
819
|
+
: filterName
|
|
820
|
+
|
|
821
|
+
// Create FilterQuery with source='field' from optionsFromCache config (T281)
|
|
822
|
+
// This centralizes unique value extraction through FilterQuery
|
|
823
|
+
// Note: processor is NOT passed to FilterQuery.transform because the old API
|
|
824
|
+
// called processor AFTER adding "All X" and applying snakeToTitle to labels.
|
|
825
|
+
// We preserve this behavior by applying processor after our own post-processing.
|
|
826
|
+
const filterQuery = new FilterQuery({
|
|
827
|
+
source: 'field',
|
|
828
|
+
field: fieldName
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
// Set parentManager reference - FilterQuery expects object with _cache array
|
|
832
|
+
// We wrap items.value to match the expected interface
|
|
833
|
+
filterQuery.setParentManager({ _cache: items.value })
|
|
834
|
+
|
|
835
|
+
// Get options via FilterQuery.getOptions()
|
|
836
|
+
const rawOptions = await filterQuery.getOptions()
|
|
837
|
+
|
|
838
|
+
// Determine cache behavior (optionsFromCache is always cached)
|
|
839
|
+
const cacheOptions = filterDef.cacheOptions ?? 'auto'
|
|
840
|
+
let shouldCache = true // optionsFromCache is inherently cached
|
|
841
|
+
|
|
842
|
+
// Auto-discovery for component type: >50 items → autocomplete
|
|
843
|
+
if (cacheOptions === 'auto') {
|
|
844
|
+
shouldCache = rawOptions.length <= SMART_FILTER_THRESHOLD
|
|
845
|
+
} else if (cacheOptions === false) {
|
|
846
|
+
shouldCache = false
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Determine component type (explicit override or based on count)
|
|
850
|
+
const componentType = filterDef.component || (rawOptions.length <= SMART_FILTER_THRESHOLD ? 'dropdown' : 'autocomplete')
|
|
851
|
+
|
|
852
|
+
// Log filter options for validation (optionsFromCache mode)
|
|
853
|
+
console.log('[filterquery] Options loaded for:', filterName, '(count:', rawOptions.length, ')')
|
|
854
|
+
|
|
855
|
+
// Build options with "All X" label
|
|
856
|
+
// Note: FilterQuery with source='field' returns { label: fieldValue, value: fieldValue }
|
|
857
|
+
// We just need to add the "All X" option at the beginning
|
|
858
|
+
const allLabel = filterDef.allLabel || filterDef.placeholder || `All ${snakeToTitle(filterName)}`
|
|
859
|
+
let finalOptions = [
|
|
860
|
+
{ label: allLabel, value: null },
|
|
861
|
+
...rawOptions.map(opt => ({
|
|
862
|
+
label: snakeToTitle(String(opt.label)),
|
|
863
|
+
value: opt.value
|
|
864
|
+
}))
|
|
865
|
+
]
|
|
866
|
+
|
|
867
|
+
// Apply processor callback if configured (backward compatibility)
|
|
868
|
+
// This matches the original behavior where processor runs AFTER snakeToTitle and "All X" option
|
|
869
|
+
if (typeof filterDef.processor === 'function') {
|
|
870
|
+
finalOptions = filterDef.processor(finalOptions)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Store the FilterQuery instance on filterDef for potential cache invalidation
|
|
874
|
+
const updatedFilter = {
|
|
875
|
+
...filterDef,
|
|
876
|
+
options: finalOptions,
|
|
877
|
+
type: componentType,
|
|
878
|
+
_cacheOptions: shouldCache,
|
|
879
|
+
_optionsLoaded: true,
|
|
880
|
+
_filterQuery: filterQuery
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Mark as loaded to prevent re-extraction on filter changes
|
|
884
|
+
filtersMap.value.set(filterName, updatedFilter)
|
|
885
|
+
hasChanges = true
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Trigger Vue reactivity only if there were changes
|
|
889
|
+
if (hasChanges) {
|
|
890
|
+
filtersMap.value = new Map(filtersMap.value)
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
677
894
|
/**
|
|
678
895
|
* Restore filter values from URL query params (priority) or session storage
|
|
679
896
|
*/
|
|
@@ -751,25 +968,24 @@ export function useListPageBuilder(config = {}) {
|
|
|
751
968
|
}
|
|
752
969
|
|
|
753
970
|
// ============ CACHE MODE ============
|
|
754
|
-
// EntityManager handles caching and filtering automatically via query()
|
|
755
|
-
// This computed applies any custom local_filter callbacks on top
|
|
756
971
|
const fromCache = ref(false)
|
|
757
972
|
|
|
973
|
+
// HACK: local_filter / local_search are escape hatches for edge cases.
|
|
974
|
+
// They filter items AFTER the manager returns data (post-filter in Vue computed).
|
|
975
|
+
// Prefer toQuery() for virtual filters - it works with QueryExecutor properly.
|
|
976
|
+
// Only use local_filter/local_search for truly computed values or external lookups.
|
|
758
977
|
const filteredItems = computed(() => {
|
|
759
978
|
let result = [...items.value]
|
|
760
979
|
|
|
761
|
-
//
|
|
980
|
+
// local_filter: post-filter hack for edge cases
|
|
762
981
|
for (const [name, value] of Object.entries(filterValues.value)) {
|
|
763
982
|
if (value === null || value === undefined || value === '') continue
|
|
764
983
|
const filterDef = filtersMap.value.get(name)
|
|
765
|
-
|
|
766
|
-
// Only apply if there's a custom local_filter callback
|
|
767
984
|
if (typeof filterDef?.local_filter !== 'function') continue
|
|
768
|
-
|
|
769
985
|
result = result.filter(item => filterDef.local_filter(item, value))
|
|
770
986
|
}
|
|
771
987
|
|
|
772
|
-
//
|
|
988
|
+
// local_search: post-filter hack for external lookups
|
|
773
989
|
if (searchQuery.value && typeof searchConfig.value.local_search === 'function') {
|
|
774
990
|
const query = searchQuery.value.toLowerCase()
|
|
775
991
|
result = result.filter(item => searchConfig.value.local_search(item, query))
|
|
@@ -805,19 +1021,33 @@ export function useListPageBuilder(config = {}) {
|
|
|
805
1021
|
params.sort_order = sortOrder.value === 1 ? 'asc' : 'desc'
|
|
806
1022
|
}
|
|
807
1023
|
|
|
808
|
-
// Add search param (skip if
|
|
1024
|
+
// Add search param (skip if local_search hack is used)
|
|
809
1025
|
if (searchQuery.value && typeof searchConfig.value.local_search !== 'function') {
|
|
810
1026
|
params.search = searchQuery.value
|
|
1027
|
+
// Pass searchFields override if configured via setSearch({ fields: [...] })
|
|
1028
|
+
if (searchConfig.value.fields?.length > 0) {
|
|
1029
|
+
params.searchFields = searchConfig.value.fields
|
|
1030
|
+
}
|
|
811
1031
|
}
|
|
812
1032
|
|
|
813
|
-
//
|
|
1033
|
+
// Build filters object for manager
|
|
814
1034
|
const filters = {}
|
|
815
1035
|
for (const [name, value] of Object.entries(filterValues.value)) {
|
|
816
1036
|
if (value === null || value === undefined || value === '') continue
|
|
817
1037
|
const filterDef = filtersMap.value.get(name)
|
|
818
|
-
// Skip
|
|
1038
|
+
// Skip local_filter hacks - applied in filteredItems computed
|
|
819
1039
|
if (typeof filterDef?.local_filter === 'function') continue
|
|
820
|
-
|
|
1040
|
+
|
|
1041
|
+
// Support toQuery for virtual filters with query abstraction
|
|
1042
|
+
// toQuery(value) returns MongoDB-like query object, e.g., { returned_at: { $ne: null } }
|
|
1043
|
+
if (typeof filterDef?.toQuery === 'function') {
|
|
1044
|
+
const query = filterDef.toQuery(value)
|
|
1045
|
+
if (query && typeof query === 'object') {
|
|
1046
|
+
Object.assign(filters, query)
|
|
1047
|
+
}
|
|
1048
|
+
} else {
|
|
1049
|
+
filters[name] = value
|
|
1050
|
+
}
|
|
821
1051
|
}
|
|
822
1052
|
|
|
823
1053
|
// Auto-add parent filter from route config
|
|
@@ -1001,6 +1231,15 @@ export function useListPageBuilder(config = {}) {
|
|
|
1001
1231
|
// Note: filterValues changes are handled directly in updateFilters() and clearFilters()
|
|
1002
1232
|
// to avoid relying on watch reactivity which can be unreliable with object mutations
|
|
1003
1233
|
|
|
1234
|
+
// Watch items for optionsFromCache filters
|
|
1235
|
+
watch(items, async () => {
|
|
1236
|
+
try {
|
|
1237
|
+
await updateCacheBasedFilters()
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
console.warn('[qdadm] Failed to update cache-based filters:', error)
|
|
1240
|
+
}
|
|
1241
|
+
})
|
|
1242
|
+
|
|
1004
1243
|
// ============ LIST:ALTER HOOK ============
|
|
1005
1244
|
|
|
1006
1245
|
/**
|