feat: enhance DeesTable with server-side search and Lucene filtering capabilities
This commit is contained in:
		| @@ -28,6 +28,9 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> { | ||||
|   }) | ||||
|   public value: boolean = false; | ||||
|  | ||||
|   @property({ type: Boolean }) | ||||
|   public indeterminate: boolean = false; | ||||
|  | ||||
|  | ||||
|   constructor() { | ||||
|     super(); | ||||
| @@ -166,7 +169,15 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> { | ||||
|                     </svg> | ||||
|                   </span> | ||||
|                 ` | ||||
|               : html``} | ||||
|               : this.indeterminate | ||||
|                 ? html` | ||||
|                     <span class="checkmark"> | ||||
|                       <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||||
|                         <path d="M5 12H19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/> | ||||
|                       </svg> | ||||
|                     </span> | ||||
|                   ` | ||||
|                 : html``} | ||||
|           </div> | ||||
|           <div class="label-container"> | ||||
|             ${this.label ? html`<div class="checkbox-label">${this.label}</div>` : ''} | ||||
|   | ||||
| @@ -42,7 +42,9 @@ export function getViewData<T>( | ||||
|   sortKey?: string, | ||||
|   sortDir?: 'asc' | 'desc' | null, | ||||
|   filterText?: string, | ||||
|   columnFilters?: Record<string, string> | ||||
|   columnFilters?: Record<string, string>, | ||||
|   filterMode: 'table' | 'data' = 'table', | ||||
|   lucenePredicate?: (row: T) => boolean | ||||
| ): T[] { | ||||
|   let arr = data.slice(); | ||||
|   const ft = (filterText || '').trim().toLowerCase(); | ||||
| @@ -52,23 +54,39 @@ export function getViewData<T>( | ||||
|     arr = arr.filter((row) => { | ||||
|       // column filters (AND across columns) | ||||
|       for (const k of cfKeys) { | ||||
|         const col = effectiveColumns.find((c) => String(c.key) === k); | ||||
|         if (!col || col.hidden || col.filterable === false) continue; | ||||
|         const val = getCellValue(row, col); | ||||
|         const s = String(val ?? '').toLowerCase(); | ||||
|         const needle = String(cf[k]).toLowerCase(); | ||||
|         if (!s.includes(needle)) return false; | ||||
|       } | ||||
|       // global filter (OR across visible columns) | ||||
|       if (ft) { | ||||
|         let any = false; | ||||
|         for (const col of effectiveColumns) { | ||||
|           if (col.hidden) continue; | ||||
|         if (filterMode === 'data') { | ||||
|           // raw object check for that key | ||||
|           const val = (row as any)[k]; | ||||
|           const s = String(val ?? '').toLowerCase(); | ||||
|           const needle = String(cf[k]).toLowerCase(); | ||||
|           if (!s.includes(needle)) return false; | ||||
|         } else { | ||||
|           const col = effectiveColumns.find((c) => String(c.key) === k); | ||||
|           if (!col || col.hidden || col.filterable === false) continue; | ||||
|           const val = getCellValue(row, col); | ||||
|           const s = String(val ?? '').toLowerCase(); | ||||
|           if (s.includes(ft)) { | ||||
|             any = true; | ||||
|             break; | ||||
|           const needle = String(cf[k]).toLowerCase(); | ||||
|           if (!s.includes(needle)) return false; | ||||
|         } | ||||
|       } | ||||
|       // global filter (OR across visible columns) or lucene predicate | ||||
|       if (ft) { | ||||
|         if (lucenePredicate) { | ||||
|           if (!lucenePredicate(row)) return false; | ||||
|           return true; | ||||
|         } | ||||
|         let any = false; | ||||
|         if (filterMode === 'data') { | ||||
|           for (const val of Object.values(row as any)) { | ||||
|             const s = String(val ?? '').toLowerCase(); | ||||
|             if (s.includes(ft)) { any = true; break; } | ||||
|           } | ||||
|         } else { | ||||
|           for (const col of effectiveColumns) { | ||||
|             if (col.hidden) continue; | ||||
|             const val = getCellValue(row, col); | ||||
|             const s = String(val ?? '').toLowerCase(); | ||||
|             if (s.includes(ft)) { any = true; break; } | ||||
|           } | ||||
|         } | ||||
|         if (!any) return false; | ||||
|   | ||||
| @@ -537,6 +537,48 @@ export const demoFunc = () => html` | ||||
|           dataName="employees" | ||||
|         ></dees-table> | ||||
|       </div> | ||||
|  | ||||
|       <div class="demo-section" | ||||
|         @searchRequest=${async (e: CustomEvent) => { | ||||
|           const { query } = e.detail || { query: '' }; | ||||
|           const table = document.getElementById('serverSearchDemo') as any; | ||||
|           const baseData = [ | ||||
|             { id: 1, name: 'Alice', city: 'Berlin', title: 'Engineer' }, | ||||
|             { id: 2, name: 'Bob', city: 'Paris', title: 'Designer' }, | ||||
|             { id: 3, name: 'Charlie', city: 'London', title: 'Manager' }, | ||||
|             { id: 4, name: 'Diana', city: 'Madrid', title: 'Engineer' }, | ||||
|             { id: 5, name: 'Ethan', city: 'Rome', title: 'Support' }, | ||||
|           ]; | ||||
|           // Simulate async request | ||||
|           await new Promise((r) => setTimeout(r, 300)); | ||||
|           const q = String(query || '').toLowerCase(); | ||||
|           const filtered = q | ||||
|             ? baseData.filter((r) => Object.values(r).some((v) => String(v).toLowerCase().includes(q))) | ||||
|             : baseData; | ||||
|           table.data = filtered; | ||||
|         }} | ||||
|       > | ||||
|         <h2 class="demo-title">Server Search (New)</h2> | ||||
|         <p class="demo-description">Select Server mode, type a query, and watch the table fetch simulated results.</p> | ||||
|         <dees-table | ||||
|           id="serverSearchDemo" | ||||
|           heading1="People (Server Search)" | ||||
|           heading2="Click Search, choose Server mode, and type" | ||||
|           .columns=${[ | ||||
|             { key: 'name', header: 'Name' }, | ||||
|             { key: 'city', header: 'City' }, | ||||
|             { key: 'title', header: 'Title' }, | ||||
|           ]} | ||||
|           .data=${[ | ||||
|             { id: 1, name: 'Alice', city: 'Berlin', title: 'Engineer' }, | ||||
|             { id: 2, name: 'Bob', city: 'Paris', title: 'Designer' }, | ||||
|             { id: 3, name: 'Charlie', city: 'London', title: 'Manager' }, | ||||
|             { id: 4, name: 'Diana', city: 'Madrid', title: 'Engineer' }, | ||||
|             { id: 5, name: 'Ethan', city: 'Rome', title: 'Support' }, | ||||
|           ]} | ||||
|           dataName="people" | ||||
|         ></dees-table> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| `; | ||||
|   | ||||
| @@ -13,6 +13,7 @@ import { | ||||
|   getCellValue as getCellValueFn, | ||||
|   getViewData as getViewDataFn, | ||||
| } from './data.js'; | ||||
| import { compileLucenePredicate } from './lucene.js'; | ||||
|  | ||||
| export type { Column, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js'; | ||||
|  | ||||
| @@ -173,6 +174,12 @@ export class DeesTable<T> extends DeesElement { | ||||
|   public showColumnFilters: boolean = false; | ||||
|   @property({ type: Boolean, reflect: true, attribute: 'sticky-header' }) | ||||
|   public stickyHeader: boolean = false; | ||||
|  | ||||
|   // search row state | ||||
|   @property({ type: String }) | ||||
|   public searchMode: 'table' | 'data' | 'server' = 'table'; | ||||
|   private __searchTextSub?: { unsubscribe?: () => void }; | ||||
|   private __searchModeSub?: { unsubscribe?: () => void }; | ||||
|    | ||||
|   // selection (Phase 1) | ||||
|   @property({ type: String }) | ||||
| @@ -194,6 +201,23 @@ export class DeesTable<T> extends DeesElement { | ||||
|       ? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data) | ||||
|       : computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data); | ||||
|  | ||||
|     const lucenePred = compileLucenePredicate<T>( | ||||
|       this.filterText, | ||||
|       this.searchMode === 'data' ? 'data' : 'table', | ||||
|       effectiveColumns | ||||
|     ); | ||||
|  | ||||
|     const viewData = getViewDataFn( | ||||
|       this.data, | ||||
|       effectiveColumns, | ||||
|       this.sortKey, | ||||
|       this.sortDir, | ||||
|       this.filterText, | ||||
|       this.columnFilters, | ||||
|       this.searchMode === 'data' ? 'data' : 'table', | ||||
|       lucenePred || undefined | ||||
|     ); | ||||
|     (this as any)._lastViewData = viewData; | ||||
|     return html` | ||||
|       <div class="mainbox"> | ||||
|         <!-- the heading part --> | ||||
| @@ -270,10 +294,11 @@ export class DeesTable<T> extends DeesElement { | ||||
|                             ${this.selectionMode === 'multi' | ||||
|                               ? html` | ||||
|                                   <dees-input-checkbox | ||||
|                                     .value=${this.areAllSelected()} | ||||
|                                     .value=${this.areAllVisibleSelected()} | ||||
|                                     .indeterminate=${this.isVisibleSelectionIndeterminate()} | ||||
|                                     @newValue=${(e: CustomEvent<boolean>) => { | ||||
|                                       e.stopPropagation(); | ||||
|                                       this.setSelectAll(e.detail === true); | ||||
|                                       this.setSelectVisible(e.detail === true); | ||||
|                                     }} | ||||
|                                   ></dees-input-checkbox> | ||||
|                                 ` | ||||
| @@ -327,7 +352,7 @@ export class DeesTable<T> extends DeesElement { | ||||
|                     : html``} | ||||
|                 </thead> | ||||
|                 <tbody> | ||||
|                   ${getViewDataFn(this.data, effectiveColumns, this.sortKey, this.sortDir, this.filterText, this.columnFilters).map((itemArg, rowIndex) => { | ||||
|                   ${viewData.map((itemArg, rowIndex) => { | ||||
|                     const getTr = (elementArg: HTMLElement): HTMLElement => { | ||||
|                       if (elementArg.tagName === 'TR') { | ||||
|                         return elementArg; | ||||
| @@ -528,6 +553,53 @@ export class DeesTable<T> extends DeesElement { | ||||
|         console.log(this.dataActions); | ||||
|         this.requestUpdate(); | ||||
|       }; | ||||
|       // wire search inputs | ||||
|       this.wireSearchInputs(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private __debounceTimer?: any; | ||||
|   private debounceRun(fn: () => void, ms = 200) { | ||||
|     if (this.__debounceTimer) clearTimeout(this.__debounceTimer); | ||||
|     this.__debounceTimer = setTimeout(fn, ms); | ||||
|   } | ||||
|  | ||||
|   private wireSearchInputs() { | ||||
|     const searchTextEl: any = this.shadowRoot?.querySelector('.searchGrid dees-input-text'); | ||||
|     const searchModeEl: any = this.shadowRoot?.querySelector('.searchGrid dees-input-multitoggle'); | ||||
|     if (searchTextEl && !this.__searchTextSub) { | ||||
|       this.__searchTextSub = searchTextEl.changeSubject.subscribe((el: any) => { | ||||
|         const val: string = el?.value ?? ''; | ||||
|         this.debounceRun(() => { | ||||
|           if (this.searchMode === 'server') { | ||||
|             this.dispatchEvent( | ||||
|               new CustomEvent('searchRequest', { | ||||
|                 detail: { query: val, mode: 'server' }, | ||||
|                 bubbles: true, | ||||
|               }) | ||||
|             ); | ||||
|           } else { | ||||
|             this.setFilterText(val); | ||||
|           } | ||||
|         }); | ||||
|       }); | ||||
|     } | ||||
|     if (searchModeEl && !this.__searchModeSub) { | ||||
|       this.__searchModeSub = searchModeEl.changeSubject.subscribe((el: any) => { | ||||
|         const mode: string = el?.selectedOption || el?.value || 'table'; | ||||
|         if (mode === 'table' || mode === 'data' || mode === 'server') { | ||||
|           this.searchMode = mode as any; | ||||
|           // When switching modes, re-apply current text input | ||||
|           const val: string = searchTextEl?.value ?? ''; | ||||
|           this.debounceRun(() => { | ||||
|             if (this.searchMode === 'server') { | ||||
|               this.dispatchEvent(new CustomEvent('searchRequest', { detail: { query: val, mode: 'server' }, bubbles: true })); | ||||
|             } else { | ||||
|               this.setFilterText(val); | ||||
|             } | ||||
|           }); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -677,25 +749,31 @@ export class DeesTable<T> extends DeesElement { | ||||
|     this.requestUpdate(); | ||||
|   } | ||||
|  | ||||
|   private areAllSelected(): boolean { | ||||
|     return this.data.length > 0 && this.selectedIds.size === this.data.length; | ||||
|   } | ||||
|  | ||||
|   private toggleSelectAll() { | ||||
|     if (this.areAllSelected()) { | ||||
|       this.selectedIds.clear(); | ||||
|     } else { | ||||
|       this.selectedIds = new Set(this.data.map((r) => this.getRowId(r))); | ||||
|   private areAllVisibleSelected(): boolean { | ||||
|     const view: T[] = (this as any)._lastViewData || []; | ||||
|     if (view.length === 0) return false; | ||||
|     for (const r of view) { | ||||
|       if (!this.selectedIds.has(this.getRowId(r))) return false; | ||||
|     } | ||||
|     this.emitSelectionChange(); | ||||
|     this.requestUpdate(); | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
|   private setSelectAll(checked: boolean) { | ||||
|   private isVisibleSelectionIndeterminate(): boolean { | ||||
|     const view: T[] = (this as any)._lastViewData || []; | ||||
|     if (view.length === 0) return false; | ||||
|     let count = 0; | ||||
|     for (const r of view) { | ||||
|       if (this.selectedIds.has(this.getRowId(r))) count++; | ||||
|     } | ||||
|     return count > 0 && count < view.length; | ||||
|   } | ||||
|  | ||||
|   private setSelectVisible(checked: boolean) { | ||||
|     const view: T[] = (this as any)._lastViewData || []; | ||||
|     if (checked) { | ||||
|       this.selectedIds = new Set(this.data.map((r) => this.getRowId(r))); | ||||
|       for (const r of view) this.selectedIds.add(this.getRowId(r)); | ||||
|     } else { | ||||
|       this.selectedIds.clear(); | ||||
|       for (const r of view) this.selectedIds.delete(this.getRowId(r)); | ||||
|     } | ||||
|     this.emitSelectionChange(); | ||||
|     this.requestUpdate(); | ||||
|   | ||||
							
								
								
									
										158
									
								
								ts_web/elements/dees-table/lucene.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										158
									
								
								ts_web/elements/dees-table/lucene.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,158 @@ | ||||
| import type { Column } from './types.js'; | ||||
|  | ||||
| type FilterMode = 'table' | 'data'; | ||||
|  | ||||
| export type RowPredicate<T> = (row: T) => boolean; | ||||
|  | ||||
| interface Term { | ||||
|   field?: string; // if undefined, match across all fields | ||||
|   value?: string; // lowercased string | ||||
|   negate?: boolean; | ||||
|   range?: { lower: string; upper: string; inclusive: boolean }; | ||||
| } | ||||
|  | ||||
| interface Clause { | ||||
|   terms: Term[]; // AND across terms | ||||
| } | ||||
|  | ||||
| interface LuceneQuery { | ||||
|   clauses: Clause[]; // OR across clauses | ||||
| } | ||||
|  | ||||
| function stripQuotes(s: string): string { | ||||
|   if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { | ||||
|     return s.slice(1, -1); | ||||
|   } | ||||
|   return s; | ||||
| } | ||||
|  | ||||
| function splitByOr(input: string): string[] { | ||||
|   return input.split(/\s+OR\s+/i).map((s) => s.trim()).filter(Boolean); | ||||
| } | ||||
|  | ||||
| function splitByAnd(input: string): string[] { | ||||
|   return input.split(/\s+AND\s+/i).map((s) => s.trim()).filter(Boolean); | ||||
| } | ||||
|  | ||||
| function parseTerm(raw: string): Term | null { | ||||
|   if (!raw) return null; | ||||
|   let negate = false; | ||||
|   // handle NOT prefix or leading '-' | ||||
|   const notMatch = raw.match(/^\s*(NOT\s+|-)\s*(.*)$/i); | ||||
|   if (notMatch) { | ||||
|     negate = true; | ||||
|     raw = notMatch[2]; | ||||
|   } | ||||
|   // range: field:[lower TO upper] | ||||
|   const rangeMatch = raw.match(/^([^:\s]+)\s*:\s*\[(.*?)\s+TO\s+(.*?)\]$/i); | ||||
|   if (rangeMatch) { | ||||
|     return { | ||||
|       field: rangeMatch[1], | ||||
|       negate, | ||||
|       range: { lower: stripQuotes(rangeMatch[2]).toLowerCase(), upper: stripQuotes(rangeMatch[3]).toLowerCase(), inclusive: true }, | ||||
|     }; | ||||
|   } | ||||
|   // field:value (value may be quoted) | ||||
|   const m = raw.match(/^([^:\s]+)\s*:\s*("[^"]*"|'[^']*'|[^"'\s]+)$/); | ||||
|   if (m) { | ||||
|     return { field: m[1], value: stripQuotes(m[2]).toLowerCase(), negate }; | ||||
|   } | ||||
|   // plain term | ||||
|   if (raw.length > 0) { | ||||
|     return { value: stripQuotes(raw).toLowerCase(), negate }; | ||||
|   } | ||||
|   return null; | ||||
| } | ||||
|  | ||||
| function parseLucene(input: string): LuceneQuery | null { | ||||
|   if (!input) return null; | ||||
|   const clauses = splitByOr(input).map((clauseStr) => { | ||||
|     const terms = splitByAnd(clauseStr) | ||||
|       .map(parseTerm) | ||||
|       .filter((t): t is Term => !!t && !!t.value); | ||||
|     return { terms } as Clause; | ||||
|   }).filter((c) => c.terms.length > 0); | ||||
|   if (clauses.length === 0) return null; | ||||
|   return { clauses }; | ||||
| } | ||||
|  | ||||
| export function compileLucenePredicate<T>( | ||||
|   input: string, | ||||
|   mode: FilterMode, | ||||
|   columns: Column<T>[] | ||||
| ): RowPredicate<T> | null { | ||||
|   const ast = parseLucene(input); | ||||
|   if (!ast) return null; | ||||
|   const colMap = new Map<string, Column<T>>( | ||||
|     columns.map((c) => [String(c.key), c]) | ||||
|   ); | ||||
|   const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); | ||||
|   const coerce = (s: any) => { | ||||
|     const str = String(s ?? '').toLowerCase(); | ||||
|     const num = Number(str); | ||||
|     const date = Date.parse(str); | ||||
|     if (!Number.isNaN(num) && str.trim() !== '') return { t: 'n' as const, v: num }; | ||||
|     if (!Number.isNaN(date)) return { t: 'd' as const, v: date }; | ||||
|     return { t: 's' as const, v: str }; | ||||
|   }; | ||||
|   const inRange = (val: any, lower: string, upper: string) => { | ||||
|     const a = coerce(val); | ||||
|     const lo = coerce(lower); | ||||
|     const up = coerce(upper); | ||||
|     // if types differ, compare string forms | ||||
|     if (a.t !== lo.t || a.t !== up.t) { | ||||
|       const as = String(val ?? '').toLowerCase(); | ||||
|       return cmp(as, lower) >= 0 && cmp(as, upper) <= 0; | ||||
|     } | ||||
|     return a.v >= (lo.v as number) && a.v <= (up.v as number); | ||||
|   }; | ||||
|   return (row: T) => { | ||||
|     for (const clause of ast.clauses) { | ||||
|       let clauseOk = true; | ||||
|       for (const term of clause.terms) { | ||||
|         let ok = false; | ||||
|         if (term.range && term.field) { | ||||
|           // range compare on field | ||||
|           if (mode === 'data') { | ||||
|             ok = inRange((row as any)[term.field], term.range.lower, term.range.upper); | ||||
|           } else { | ||||
|             const col = colMap.get(term.field); | ||||
|             if (!col || col.hidden) { ok = false; } else { | ||||
|               const val = col.value ? col.value(row) : (row as any)[col.key as any]; | ||||
|               ok = inRange(val, term.range.lower, term.range.upper); | ||||
|             } | ||||
|           } | ||||
|         } else if (term.field && term.value != null) { | ||||
|           if (mode === 'data') { | ||||
|             const s = String((row as any)[term.field] ?? '').toLowerCase(); | ||||
|             ok = s.includes(term.value); | ||||
|           } else { | ||||
|             const col = colMap.get(term.field); | ||||
|             if (!col || col.hidden === true) { ok = false; } | ||||
|             else { | ||||
|               const val = col.value ? col.value(row) : (row as any)[col.key as any]; | ||||
|               const s = String(val ?? '').toLowerCase(); | ||||
|               ok = s.includes(term.value); | ||||
|             } | ||||
|           } | ||||
|         } else if (term.value != null) { | ||||
|           // search across all visible/raw fields | ||||
|           if (mode === 'data') { | ||||
|             ok = Object.values(row as any).some((v) => String(v ?? '').toLowerCase().includes(term.value!)); | ||||
|           } else { | ||||
|             ok = columns.some((col) => { | ||||
|               if (col.hidden) return false; | ||||
|               const val = col.value ? col.value(row) : (row as any)[col.key as any]; | ||||
|               const s = String(val ?? '').toLowerCase(); | ||||
|               return s.includes(term.value!); | ||||
|             }); | ||||
|           } | ||||
|         } | ||||
|         if (term.negate) ok = !ok; | ||||
|         if (!ok) { clauseOk = false; break; } | ||||
|       } | ||||
|       if (clauseOk) return true; | ||||
|     } | ||||
|     return false; | ||||
|   }; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user