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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.18.0",
3
+ "version": "0.26.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
@@ -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 API endpoints (for filters with optionsEndpoint)
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
- const entityConfig = entityFilters[entityName]
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 API endpoints if configured
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
- // Build options array with "All" option first
628
- let finalOptions = [{ label: 'All', value: null }]
629
-
630
- // Add null option if configured
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 sessionFilters = persistFilters ? getSessionFilters(filterSessionKey) : null
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
- // Apply custom local_filter callbacks (UI-specific post-filters)
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
- // Apply custom local_search callback
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 custom local_search callback)
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
- // Add filter values (skip filters with custom local_filter callback)
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 filters with custom local_filter - they're applied in filteredItems
1051
+ // Skip local_filter hacks - applied in filteredItems computed
819
1052
  if (typeof filterDef?.local_filter === 'function') continue
820
- filters[name] = value
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
  /**