qdadm 0.18.0 → 0.26.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 +301 -49
- 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
|
|
@@ -515,7 +508,7 @@ export function useListPageBuilder(config = {}) {
|
|
|
515
508
|
function onFiltersChanged() {
|
|
516
509
|
page.value = 1
|
|
517
510
|
loadItems()
|
|
518
|
-
// Persist filters to session storage
|
|
511
|
+
// Persist filters + search to session storage
|
|
519
512
|
if (persistFilters) {
|
|
520
513
|
const toPersist = {}
|
|
521
514
|
for (const [name, value] of Object.entries(filterValues.value)) {
|
|
@@ -524,6 +517,10 @@ export function useListPageBuilder(config = {}) {
|
|
|
524
517
|
toPersist[name] = value
|
|
525
518
|
}
|
|
526
519
|
}
|
|
520
|
+
// Also persist search query
|
|
521
|
+
if (searchQuery.value) {
|
|
522
|
+
toPersist._search = searchQuery.value
|
|
523
|
+
}
|
|
527
524
|
setSessionFilters(filterSessionKey, toPersist)
|
|
528
525
|
}
|
|
529
526
|
// Sync to URL query params
|
|
@@ -597,20 +594,135 @@ export function useListPageBuilder(config = {}) {
|
|
|
597
594
|
}
|
|
598
595
|
}
|
|
599
596
|
|
|
597
|
+
// Smart filter auto-discovery threshold
|
|
598
|
+
const SMART_FILTER_THRESHOLD = 50
|
|
599
|
+
|
|
600
600
|
/**
|
|
601
|
-
* Load filter options from
|
|
601
|
+
* Load filter options from various sources (smart filter modes)
|
|
602
|
+
*
|
|
603
|
+
* Smart filter modes (in priority order):
|
|
604
|
+
* 1. `optionsEntity` - fetch from related EntityManager
|
|
605
|
+
* 2. `optionsEndpoint` - fetch from API endpoint (true = auto, string = custom URL)
|
|
606
|
+
* 3. `optionsFromCache` - extract from items cache (handled separately via watcher)
|
|
607
|
+
*
|
|
608
|
+
* Cache options behavior (`cacheOptions`):
|
|
609
|
+
* - `true`: Cache options, use dropdown (default for small datasets)
|
|
610
|
+
* - `false`: No cache, use autocomplete (default for large datasets)
|
|
611
|
+
* - `'auto'` (default): First load decides based on count (≤50 → cache, >50 → no cache)
|
|
612
|
+
*
|
|
613
|
+
* Component selection (`component`):
|
|
614
|
+
* - `'dropdown'`: PrimeVue Select (default when cached)
|
|
615
|
+
* - `'autocomplete'`: PrimeVue AutoComplete (default when not cached)
|
|
616
|
+
* - Explicit `component` prop overrides auto-selection
|
|
617
|
+
*
|
|
602
618
|
* After loading, invokes filter:alter and {entity}:filter:alter hooks.
|
|
603
619
|
*/
|
|
604
620
|
async function loadFilterOptions() {
|
|
605
|
-
|
|
621
|
+
// Process filters configured directly via addFilter() (smart filter modes)
|
|
622
|
+
for (const [filterName, filterDef] of filtersMap.value) {
|
|
623
|
+
// Skip if explicit options already provided (not smart filter)
|
|
624
|
+
if (filterDef.options?.length > 1) continue
|
|
625
|
+
// Skip optionsFromCache - handled by watcher
|
|
626
|
+
if (filterDef.optionsFromCache) continue
|
|
627
|
+
|
|
628
|
+
try {
|
|
629
|
+
let rawOptions = null
|
|
630
|
+
|
|
631
|
+
// Mode 1: optionsEntity - fetch from related EntityManager via FilterQuery
|
|
632
|
+
if (filterDef.optionsEntity) {
|
|
633
|
+
// Create FilterQuery from legacy optionsEntity config (T279)
|
|
634
|
+
// This centralizes option resolution through FilterQuery while maintaining
|
|
635
|
+
// backward compatibility with existing optionsEntity/optionLabel/optionValue syntax
|
|
636
|
+
//
|
|
637
|
+
// Note: processor is NOT passed to FilterQuery.transform because the existing
|
|
638
|
+
// behavior applies processor AFTER adding "All X" option. FilterQuery.transform
|
|
639
|
+
// would apply it before. For backward compatibility, we handle processor manually.
|
|
640
|
+
const filterQuery = new FilterQuery({
|
|
641
|
+
source: 'entity',
|
|
642
|
+
entity: filterDef.optionsEntity,
|
|
643
|
+
label: filterDef.optionLabel || 'name',
|
|
644
|
+
value: filterDef.optionValue || 'id'
|
|
645
|
+
// transform: intentionally not set - processor applied after "All X" is added
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
// Get options via FilterQuery.getOptions()
|
|
649
|
+
rawOptions = await filterQuery.getOptions(orchestrator)
|
|
650
|
+
|
|
651
|
+
// Store the FilterQuery instance on filterDef for potential cache invalidation
|
|
652
|
+
filterDef._filterQuery = filterQuery
|
|
653
|
+
}
|
|
654
|
+
// Mode 2: optionsEndpoint - fetch from API endpoint
|
|
655
|
+
else if (filterDef.optionsEndpoint) {
|
|
656
|
+
const endpoint = filterDef.optionsEndpoint === true
|
|
657
|
+
? `distinct/${filterName}`
|
|
658
|
+
: filterDef.optionsEndpoint
|
|
659
|
+
const response = await manager.request('GET', endpoint)
|
|
660
|
+
const data = Array.isArray(response) ? response : response?.items || []
|
|
661
|
+
rawOptions = data.map(opt => {
|
|
662
|
+
// Handle both primitive values and objects
|
|
663
|
+
if (typeof opt === 'object' && opt !== null) {
|
|
664
|
+
return {
|
|
665
|
+
label: opt.label || opt.name || String(opt.value ?? opt.id),
|
|
666
|
+
value: opt.value ?? opt.id
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// Primitive value
|
|
670
|
+
return { label: snakeToTitle(String(opt)), value: opt }
|
|
671
|
+
})
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Apply options if loaded
|
|
675
|
+
if (rawOptions !== null) {
|
|
676
|
+
// Log filter options for validation
|
|
677
|
+
console.log('[filterquery] Options loaded for:', filterName, '(count:', rawOptions.length, ')')
|
|
678
|
+
|
|
679
|
+
// Determine cache behavior
|
|
680
|
+
const cacheOptions = filterDef.cacheOptions ?? 'auto'
|
|
681
|
+
let shouldCache = cacheOptions === true
|
|
682
|
+
|
|
683
|
+
// Auto-discovery: >50 items → no cache + autocomplete
|
|
684
|
+
if (cacheOptions === 'auto') {
|
|
685
|
+
shouldCache = rawOptions.length <= SMART_FILTER_THRESHOLD
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Determine component type (explicit override or based on cache)
|
|
689
|
+
// Use 'type' for ListPage.vue compatibility
|
|
690
|
+
const componentType = filterDef.component || (shouldCache ? 'dropdown' : 'autocomplete')
|
|
691
|
+
|
|
692
|
+
// Generate "All X" label from placeholder or filterName
|
|
693
|
+
const allLabel = filterDef.allLabel || filterDef.placeholder || `All ${snakeToTitle(filterName)}`
|
|
694
|
+
let finalOptions = [{ label: allLabel, value: null }, ...rawOptions]
|
|
695
|
+
|
|
696
|
+
// Apply processor callback if configured
|
|
697
|
+
if (typeof filterDef.processor === 'function') {
|
|
698
|
+
finalOptions = filterDef.processor(finalOptions)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Options are normalized to { label, value } - remove source-specific field mappings
|
|
702
|
+
// so ListPage.vue uses defaults (optionLabel='label', optionValue='value')
|
|
703
|
+
const updatedFilter = {
|
|
704
|
+
...filterDef,
|
|
705
|
+
options: finalOptions,
|
|
706
|
+
type: componentType,
|
|
707
|
+
_cacheOptions: shouldCache,
|
|
708
|
+
_optionsLoaded: shouldCache // Only mark as loaded if caching
|
|
709
|
+
}
|
|
710
|
+
delete updatedFilter.optionLabel
|
|
711
|
+
delete updatedFilter.optionValue
|
|
712
|
+
filtersMap.value.set(filterName, updatedFilter)
|
|
713
|
+
}
|
|
714
|
+
} catch (error) {
|
|
715
|
+
console.warn(`[qdadm] Failed to load options for filter "${filterName}":`, error)
|
|
716
|
+
}
|
|
717
|
+
}
|
|
606
718
|
|
|
607
|
-
// Load options from
|
|
719
|
+
// Legacy: Load options from registry (entityFilters)
|
|
720
|
+
const entityConfig = entityFilters[entityName]
|
|
608
721
|
if (entityConfig?.filters) {
|
|
609
722
|
for (const filterDef of entityConfig.filters) {
|
|
610
723
|
if (!filterDef.optionsEndpoint) continue
|
|
611
724
|
|
|
612
725
|
try {
|
|
613
|
-
// Use manager.request for custom endpoints, or get another manager
|
|
614
726
|
const optionsManager = filterDef.optionsEntity
|
|
615
727
|
? orchestrator.get(filterDef.optionsEntity)
|
|
616
728
|
: manager
|
|
@@ -624,18 +736,14 @@ export function useListPageBuilder(config = {}) {
|
|
|
624
736
|
rawOptions = Array.isArray(rawOptions) ? rawOptions : rawOptions?.items || []
|
|
625
737
|
}
|
|
626
738
|
|
|
627
|
-
//
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
739
|
+
// Generate "All X" label from placeholder or filterName
|
|
740
|
+
const filterName = filterDef.field || filterDef.name || 'Items'
|
|
741
|
+
const allLabel = filterDef.allLabel || filterDef.placeholder || `All ${snakeToTitle(filterName)}`
|
|
742
|
+
let finalOptions = [{ label: allLabel, value: null }]
|
|
631
743
|
if (filterDef.includeNull) {
|
|
632
744
|
finalOptions.push(filterDef.includeNull)
|
|
633
745
|
}
|
|
634
746
|
|
|
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
747
|
const labelField = filterDef.optionLabelField || 'label'
|
|
640
748
|
const valueField = filterDef.optionValueField || 'value'
|
|
641
749
|
const labelMap = filterDef.labelMap || {}
|
|
@@ -643,7 +751,6 @@ export function useListPageBuilder(config = {}) {
|
|
|
643
751
|
|
|
644
752
|
const mappedOptions = rawOptions.map(opt => {
|
|
645
753
|
const value = opt[valueField] ?? opt.id ?? opt
|
|
646
|
-
// Priority: labelMap > API label field > fallback function
|
|
647
754
|
let label = labelMap[value]
|
|
648
755
|
if (!label) {
|
|
649
756
|
label = opt[labelField] || opt.name
|
|
@@ -656,7 +763,6 @@ export function useListPageBuilder(config = {}) {
|
|
|
656
763
|
|
|
657
764
|
finalOptions = [...finalOptions, ...mappedOptions]
|
|
658
765
|
|
|
659
|
-
// Update filter options in map
|
|
660
766
|
const existing = filtersMap.value.get(filterDef.name)
|
|
661
767
|
if (existing) {
|
|
662
768
|
filtersMap.value.set(filterDef.name, { ...existing, options: finalOptions })
|
|
@@ -674,6 +780,121 @@ export function useListPageBuilder(config = {}) {
|
|
|
674
780
|
filtersMap.value = new Map(filtersMap.value)
|
|
675
781
|
}
|
|
676
782
|
|
|
783
|
+
/**
|
|
784
|
+
* Update filter options from cache (optionsFromCache mode)
|
|
785
|
+
* Called when items.value changes
|
|
786
|
+
*
|
|
787
|
+
* IMPORTANT: Options are only extracted once (on first load with unfiltered data).
|
|
788
|
+
* This prevents options from disappearing when filtering reduces the visible items.
|
|
789
|
+
*
|
|
790
|
+
* Cache behavior for optionsFromCache:
|
|
791
|
+
* - Always cached by nature (extracted from loaded items)
|
|
792
|
+
* - Component type follows same rules: ≤50 → dropdown, >50 → autocomplete
|
|
793
|
+
* - Explicit `component` prop overrides auto-selection
|
|
794
|
+
*
|
|
795
|
+
* Implementation uses FilterQuery with source='field' internally (T281).
|
|
796
|
+
* This centralizes option resolution logic while maintaining backward compatibility.
|
|
797
|
+
*/
|
|
798
|
+
async function updateCacheBasedFilters() {
|
|
799
|
+
if (items.value.length === 0) return
|
|
800
|
+
|
|
801
|
+
let hasChanges = false
|
|
802
|
+
|
|
803
|
+
for (const [filterName, filterDef] of filtersMap.value) {
|
|
804
|
+
// Skip if no optionsFromCache config
|
|
805
|
+
if (!filterDef.optionsFromCache) continue
|
|
806
|
+
|
|
807
|
+
// Skip if filter already has explicit query property (advanced usage)
|
|
808
|
+
if (filterDef.query) continue
|
|
809
|
+
|
|
810
|
+
// Skip if options already loaded (prevents options disappearing when filtering)
|
|
811
|
+
if (filterDef._optionsLoaded) continue
|
|
812
|
+
|
|
813
|
+
// Skip if this filter is currently active - wait for unfiltered data to extract all options
|
|
814
|
+
// This ensures we capture all possible values, not just those visible with current filter
|
|
815
|
+
const currentValue = filterValues.value[filterName]
|
|
816
|
+
if (currentValue !== null && currentValue !== undefined && currentValue !== '') {
|
|
817
|
+
continue
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Determine field name: explicit string or use filter name
|
|
821
|
+
const fieldName = typeof filterDef.optionsFromCache === 'string'
|
|
822
|
+
? filterDef.optionsFromCache
|
|
823
|
+
: filterName
|
|
824
|
+
|
|
825
|
+
// Create FilterQuery with source='field' from optionsFromCache config (T281)
|
|
826
|
+
// This centralizes unique value extraction through FilterQuery
|
|
827
|
+
// Note: processor is NOT passed to FilterQuery.transform because the old API
|
|
828
|
+
// called processor AFTER adding "All X" and applying snakeToTitle to labels.
|
|
829
|
+
// We preserve this behavior by applying processor after our own post-processing.
|
|
830
|
+
const filterQuery = new FilterQuery({
|
|
831
|
+
source: 'field',
|
|
832
|
+
field: fieldName
|
|
833
|
+
})
|
|
834
|
+
|
|
835
|
+
// Set parentManager reference - FilterQuery expects object with _cache array
|
|
836
|
+
// We wrap items.value to match the expected interface
|
|
837
|
+
filterQuery.setParentManager({ _cache: items.value })
|
|
838
|
+
|
|
839
|
+
// Get options via FilterQuery.getOptions()
|
|
840
|
+
const rawOptions = await filterQuery.getOptions()
|
|
841
|
+
|
|
842
|
+
// Determine cache behavior (optionsFromCache is always cached)
|
|
843
|
+
const cacheOptions = filterDef.cacheOptions ?? 'auto'
|
|
844
|
+
let shouldCache = true // optionsFromCache is inherently cached
|
|
845
|
+
|
|
846
|
+
// Auto-discovery for component type: >50 items → autocomplete
|
|
847
|
+
if (cacheOptions === 'auto') {
|
|
848
|
+
shouldCache = rawOptions.length <= SMART_FILTER_THRESHOLD
|
|
849
|
+
} else if (cacheOptions === false) {
|
|
850
|
+
shouldCache = false
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Determine component type (explicit override or based on count)
|
|
854
|
+
const componentType = filterDef.component || (rawOptions.length <= SMART_FILTER_THRESHOLD ? 'dropdown' : 'autocomplete')
|
|
855
|
+
|
|
856
|
+
// Log filter options for validation (optionsFromCache mode)
|
|
857
|
+
console.log('[filterquery] Options loaded for:', filterName, '(count:', rawOptions.length, ')')
|
|
858
|
+
|
|
859
|
+
// Build options with "All X" label
|
|
860
|
+
// Note: FilterQuery with source='field' returns { label: fieldValue, value: fieldValue }
|
|
861
|
+
// We just need to add the "All X" option at the beginning
|
|
862
|
+
const allLabel = filterDef.allLabel || filterDef.placeholder || `All ${snakeToTitle(filterName)}`
|
|
863
|
+
let finalOptions = [
|
|
864
|
+
{ label: allLabel, value: null },
|
|
865
|
+
...rawOptions.map(opt => ({
|
|
866
|
+
label: snakeToTitle(String(opt.label)),
|
|
867
|
+
value: opt.value
|
|
868
|
+
}))
|
|
869
|
+
]
|
|
870
|
+
|
|
871
|
+
// Apply processor callback if configured (backward compatibility)
|
|
872
|
+
// This matches the original behavior where processor runs AFTER snakeToTitle and "All X" option
|
|
873
|
+
if (typeof filterDef.processor === 'function') {
|
|
874
|
+
finalOptions = filterDef.processor(finalOptions)
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Store the FilterQuery instance on filterDef for potential cache invalidation
|
|
878
|
+
const updatedFilter = {
|
|
879
|
+
...filterDef,
|
|
880
|
+
options: finalOptions,
|
|
881
|
+
type: componentType,
|
|
882
|
+
_cacheOptions: shouldCache,
|
|
883
|
+
_optionsLoaded: true,
|
|
884
|
+
_filterQuery: filterQuery
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Mark as loaded to prevent re-extraction on filter changes
|
|
888
|
+
filtersMap.value.set(filterName, updatedFilter)
|
|
889
|
+
hasChanges = true
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Trigger Vue reactivity only if there were changes
|
|
893
|
+
if (hasChanges) {
|
|
894
|
+
filtersMap.value = new Map(filtersMap.value)
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
677
898
|
/**
|
|
678
899
|
* Restore filter values from URL query params (priority) or session storage
|
|
679
900
|
*/
|
|
@@ -696,8 +917,17 @@ export function useListPageBuilder(config = {}) {
|
|
|
696
917
|
searchQuery.value = route.query.search
|
|
697
918
|
}
|
|
698
919
|
|
|
699
|
-
// Priority 2: Session storage (only for filters not in URL)
|
|
700
|
-
const
|
|
920
|
+
// Priority 2: Session storage (only for filters/search not in URL)
|
|
921
|
+
const sessionData = persistFilters ? getSessionFilters(filterSessionKey) : null
|
|
922
|
+
|
|
923
|
+
// Extract search from session (stored as _search)
|
|
924
|
+
if (sessionData?._search && !route.query.search) {
|
|
925
|
+
searchQuery.value = sessionData._search
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Remove _search from session data before merging with filters
|
|
929
|
+
const sessionFilters = sessionData ? { ...sessionData } : null
|
|
930
|
+
if (sessionFilters) delete sessionFilters._search
|
|
701
931
|
|
|
702
932
|
// Merge: URL takes priority over session
|
|
703
933
|
const restoredFilters = { ...sessionFilters, ...urlFilters }
|
|
@@ -751,25 +981,24 @@ export function useListPageBuilder(config = {}) {
|
|
|
751
981
|
}
|
|
752
982
|
|
|
753
983
|
// ============ CACHE MODE ============
|
|
754
|
-
// EntityManager handles caching and filtering automatically via query()
|
|
755
|
-
// This computed applies any custom local_filter callbacks on top
|
|
756
984
|
const fromCache = ref(false)
|
|
757
985
|
|
|
986
|
+
// HACK: local_filter / local_search are escape hatches for edge cases.
|
|
987
|
+
// They filter items AFTER the manager returns data (post-filter in Vue computed).
|
|
988
|
+
// Prefer toQuery() for virtual filters - it works with QueryExecutor properly.
|
|
989
|
+
// Only use local_filter/local_search for truly computed values or external lookups.
|
|
758
990
|
const filteredItems = computed(() => {
|
|
759
991
|
let result = [...items.value]
|
|
760
992
|
|
|
761
|
-
//
|
|
993
|
+
// local_filter: post-filter hack for edge cases
|
|
762
994
|
for (const [name, value] of Object.entries(filterValues.value)) {
|
|
763
995
|
if (value === null || value === undefined || value === '') continue
|
|
764
996
|
const filterDef = filtersMap.value.get(name)
|
|
765
|
-
|
|
766
|
-
// Only apply if there's a custom local_filter callback
|
|
767
997
|
if (typeof filterDef?.local_filter !== 'function') continue
|
|
768
|
-
|
|
769
998
|
result = result.filter(item => filterDef.local_filter(item, value))
|
|
770
999
|
}
|
|
771
1000
|
|
|
772
|
-
//
|
|
1001
|
+
// local_search: post-filter hack for external lookups
|
|
773
1002
|
if (searchQuery.value && typeof searchConfig.value.local_search === 'function') {
|
|
774
1003
|
const query = searchQuery.value.toLowerCase()
|
|
775
1004
|
result = result.filter(item => searchConfig.value.local_search(item, query))
|
|
@@ -805,19 +1034,33 @@ export function useListPageBuilder(config = {}) {
|
|
|
805
1034
|
params.sort_order = sortOrder.value === 1 ? 'asc' : 'desc'
|
|
806
1035
|
}
|
|
807
1036
|
|
|
808
|
-
// Add search param (skip if
|
|
1037
|
+
// Add search param (skip if local_search hack is used)
|
|
809
1038
|
if (searchQuery.value && typeof searchConfig.value.local_search !== 'function') {
|
|
810
1039
|
params.search = searchQuery.value
|
|
1040
|
+
// Pass searchFields override if configured via setSearch({ fields: [...] })
|
|
1041
|
+
if (searchConfig.value.fields?.length > 0) {
|
|
1042
|
+
params.searchFields = searchConfig.value.fields
|
|
1043
|
+
}
|
|
811
1044
|
}
|
|
812
1045
|
|
|
813
|
-
//
|
|
1046
|
+
// Build filters object for manager
|
|
814
1047
|
const filters = {}
|
|
815
1048
|
for (const [name, value] of Object.entries(filterValues.value)) {
|
|
816
1049
|
if (value === null || value === undefined || value === '') continue
|
|
817
1050
|
const filterDef = filtersMap.value.get(name)
|
|
818
|
-
// Skip
|
|
1051
|
+
// Skip local_filter hacks - applied in filteredItems computed
|
|
819
1052
|
if (typeof filterDef?.local_filter === 'function') continue
|
|
820
|
-
|
|
1053
|
+
|
|
1054
|
+
// Support toQuery for virtual filters with query abstraction
|
|
1055
|
+
// toQuery(value) returns MongoDB-like query object, e.g., { returned_at: { $ne: null } }
|
|
1056
|
+
if (typeof filterDef?.toQuery === 'function') {
|
|
1057
|
+
const query = filterDef.toQuery(value)
|
|
1058
|
+
if (query && typeof query === 'object') {
|
|
1059
|
+
Object.assign(filters, query)
|
|
1060
|
+
}
|
|
1061
|
+
} else {
|
|
1062
|
+
filters[name] = value
|
|
1063
|
+
}
|
|
821
1064
|
}
|
|
822
1065
|
|
|
823
1066
|
// Auto-add parent filter from route config
|
|
@@ -1001,6 +1244,15 @@ export function useListPageBuilder(config = {}) {
|
|
|
1001
1244
|
// Note: filterValues changes are handled directly in updateFilters() and clearFilters()
|
|
1002
1245
|
// to avoid relying on watch reactivity which can be unreliable with object mutations
|
|
1003
1246
|
|
|
1247
|
+
// Watch items for optionsFromCache filters
|
|
1248
|
+
watch(items, async () => {
|
|
1249
|
+
try {
|
|
1250
|
+
await updateCacheBasedFilters()
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
console.warn('[qdadm] Failed to update cache-based filters:', error)
|
|
1253
|
+
}
|
|
1254
|
+
})
|
|
1255
|
+
|
|
1004
1256
|
// ============ LIST:ALTER HOOK ============
|
|
1005
1257
|
|
|
1006
1258
|
/**
|