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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.18.0",
3
+ "version": "0.25.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -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
- * ## Behavior Matrix
82
+ * ## toQuery - Virtual Filter to Query Mapping
82
83
  *
83
- * | Type | Mode `manager` | Mode `local` |
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: [{ label: 'Active', value: 'active' }],
99
- * local_filter: (item, value) => value === 'active' ? !item.returned_at : true
100
- * })
101
- *
102
- * // Search on related/computed fields (in local mode)
103
- * list.setSearch({
104
- * placeholder: 'Search by book...',
105
- * local_search: (item, query) => booksMap[item.book_id]?.title.toLowerCase().includes(query)
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 API endpoints (for filters with optionsEndpoint)
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
- const entityConfig = entityFilters[entityName]
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
- // Load options from API endpoints if configured
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
- // Build options array with "All" option first
628
- let finalOptions = [{ label: 'All', value: null }]
629
-
630
- // Add null option if configured
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
- // Apply custom local_filter callbacks (UI-specific post-filters)
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
- // Apply custom local_search callback
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 custom local_search callback)
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
- // Add filter values (skip filters with custom local_filter callback)
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 filters with custom local_filter - they're applied in filteredItems
1038
+ // Skip local_filter hacks - applied in filteredItems computed
819
1039
  if (typeof filterDef?.local_filter === 'function') continue
820
- filters[name] = value
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
  /**