From f739bb608eaee57c2f214f889dc686a0e6b1e818 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 16 Sep 2025 15:46:44 +0000 Subject: [PATCH] feat: enhance DeesTable with server-side search and Lucene filtering capabilities --- ts_web/elements/dees-input-checkbox.ts | 13 +- ts_web/elements/dees-table/data.ts | 50 ++++-- ts_web/elements/dees-table/dees-table.demo.ts | 42 +++++ ts_web/elements/dees-table/dees-table.ts | 112 +++++++++++-- ts_web/elements/dees-table/lucene.ts | 158 ++++++++++++++++++ 5 files changed, 341 insertions(+), 34 deletions(-) create mode 100644 ts_web/elements/dees-table/lucene.ts diff --git a/ts_web/elements/dees-input-checkbox.ts b/ts_web/elements/dees-input-checkbox.ts index 7431d4c..d4f8f29 100644 --- a/ts_web/elements/dees-input-checkbox.ts +++ b/ts_web/elements/dees-input-checkbox.ts @@ -28,6 +28,9 @@ export class DeesInputCheckbox extends DeesInputBase { }) public value: boolean = false; + @property({ type: Boolean }) + public indeterminate: boolean = false; + constructor() { super(); @@ -166,7 +169,15 @@ export class DeesInputCheckbox extends DeesInputBase { ` - : html``} + : this.indeterminate + ? html` + + + + + + ` + : html``}
${this.label ? html`
${this.label}
` : ''} diff --git a/ts_web/elements/dees-table/data.ts b/ts_web/elements/dees-table/data.ts index 8d3eb28..e813bd6 100644 --- a/ts_web/elements/dees-table/data.ts +++ b/ts_web/elements/dees-table/data.ts @@ -42,7 +42,9 @@ export function getViewData( sortKey?: string, sortDir?: 'asc' | 'desc' | null, filterText?: string, - columnFilters?: Record + columnFilters?: Record, + 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( 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; diff --git a/ts_web/elements/dees-table/dees-table.demo.ts b/ts_web/elements/dees-table/dees-table.demo.ts index 7df868c..6510a2f 100644 --- a/ts_web/elements/dees-table/dees-table.demo.ts +++ b/ts_web/elements/dees-table/dees-table.demo.ts @@ -537,6 +537,48 @@ export const demoFunc = () => html` dataName="employees" >
+ +
{ + 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; + }} + > +

Server Search (New)

+

Select Server mode, type a query, and watch the table fetch simulated results.

+ +
`; diff --git a/ts_web/elements/dees-table/dees-table.ts b/ts_web/elements/dees-table/dees-table.ts index 249e0ff..f34ec91 100644 --- a/ts_web/elements/dees-table/dees-table.ts +++ b/ts_web/elements/dees-table/dees-table.ts @@ -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 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 extends DeesElement { ? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data) : computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data); + const lucenePred = compileLucenePredicate( + 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`
@@ -270,10 +294,11 @@ export class DeesTable extends DeesElement { ${this.selectionMode === 'multi' ? html` ) => { e.stopPropagation(); - this.setSelectAll(e.detail === true); + this.setSelectVisible(e.detail === true); }} > ` @@ -327,7 +352,7 @@ export class DeesTable extends DeesElement { : html``} - ${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 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 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(); diff --git a/ts_web/elements/dees-table/lucene.ts b/ts_web/elements/dees-table/lucene.ts new file mode 100644 index 0000000..db97435 --- /dev/null +++ b/ts_web/elements/dees-table/lucene.ts @@ -0,0 +1,158 @@ +import type { Column } from './types.js'; + +type FilterMode = 'table' | 'data'; + +export type RowPredicate = (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( + input: string, + mode: FilterMode, + columns: Column[] +): RowPredicate | null { + const ast = parseLucene(input); + if (!ast) return null; + const colMap = new Map>( + 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; + }; +}