@kodaris/krubble-components 1.0.56 → 1.0.58

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.
@@ -359,7 +359,7 @@
359
359
  "type": {
360
360
  "text": "object"
361
361
  },
362
- "default": "{ equals: { key: 'equals', type: 'comparison', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Equals' }, n_equals: { key: 'n_equals', type: 'comparison', dataTypes: ['string', 'number', 'date', 'boolean'], label: \"Doesn't equal\" }, contains: { key: 'contains', type: 'comparison', dataTypes: ['string'], label: 'Contains' }, n_contains: { key: 'n_contains', type: 'comparison', dataTypes: ['string'], label: \"Doesn't contain\" }, starts_with: { key: 'starts_with', type: 'comparison', dataTypes: ['string'], label: 'Starts with' }, ends_with: { key: 'ends_with', type: 'comparison', dataTypes: ['string'], label: 'Ends with' }, less_than: { key: 'less_than', type: 'comparison', dataTypes: ['number', 'date'], label: 'Less than' }, less_than_equal: { key: 'less_than_equal', type: 'comparison', dataTypes: ['number', 'date'], label: 'Less than or equal' }, greater_than: { key: 'greater_than', type: 'comparison', dataTypes: ['number', 'date'], label: 'Greater than' }, greater_than_equal: { key: 'greater_than_equal', type: 'comparison', dataTypes: ['number', 'date'], label: 'Greater than or equal' }, between: { key: 'between', type: 'range', dataTypes: ['number', 'date'], label: 'Between' }, in: { key: 'in', type: 'list', dataTypes: ['string', 'number'], label: 'In' }, empty: { key: 'empty', type: 'nil', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Empty' }, n_empty: { key: 'n_empty', type: 'nil', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Not empty' }, }",
362
+ "default": "{ equals: { key: 'equals', type: 'comparison', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Equals' }, n_equals: { key: 'n_equals', type: 'comparison', dataTypes: ['string', 'number', 'date', 'boolean'], label: \"Doesn't equal\" }, contains: { key: 'contains', type: 'comparison', dataTypes: ['string'], label: 'Contains' }, n_contains: { key: 'n_contains', type: 'comparison', dataTypes: ['string'], label: \"Doesn't contain\" }, starts_with: { key: 'starts_with', type: 'comparison', dataTypes: ['string'], label: 'Starts with' }, ends_with: { key: 'ends_with', type: 'comparison', dataTypes: ['string'], label: 'Ends with' }, less_than: { key: 'less_than', type: 'comparison', dataTypes: ['number', 'date'], label: 'Less than' }, less_than_equal: { key: 'less_than_equal', type: 'comparison', dataTypes: ['number', 'date'], label: 'Less than or equal' }, greater_than: { key: 'greater_than', type: 'comparison', dataTypes: ['number', 'date'], label: 'Greater than' }, greater_than_equal: { key: 'greater_than_equal', type: 'comparison', dataTypes: ['number', 'date'], label: 'Greater than or equal' }, between: { key: 'between', type: 'range', dataTypes: ['number', 'date'], label: 'Between' }, in: { key: 'in', type: 'list', dataTypes: ['string', 'number'], label: 'In' }, n_in: { key: 'n_in', type: 'list', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Not In' }, empty: { key: 'empty', type: 'nil', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Empty' }, n_empty: { key: 'n_empty', type: 'nil', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Not empty' }, }",
363
363
  "description": "Data-driven operator metadata map"
364
364
  },
365
365
  {
@@ -448,7 +448,7 @@
448
448
  "name": "val"
449
449
  }
450
450
  ],
451
- "description": "Returns true if the value array contains the given value. Only applies to 'in' operator."
451
+ "description": "Returns true if the value array contains the given value. Only applies to 'in' and 'n_in' operators."
452
452
  },
453
453
  {
454
454
  "kind": "method",
@@ -458,7 +458,7 @@
458
458
  "name": "val"
459
459
  }
460
460
  ],
461
- "description": "Adds or removes a value from the 'in' list and rebuilds text/kql."
461
+ "description": "Adds or removes a value from the 'in' or 'n_in' list and rebuilds text/kql."
462
462
  },
463
463
  {
464
464
  "kind": "method",
@@ -573,7 +573,7 @@
573
573
  {
574
574
  "kind": "variable",
575
575
  "name": "KRTable",
576
- "default": "class KRTable extends i$2 { constructor() { super(...arguments); /** * Internal flag to switch between scroll edge modes: * - 'overlay': Fixed padding with overlay elements that hide content at edges (scrollbar at viewport edge) * - 'edge': Padding scrolls with content, allowing table to reach edges when scrolling */ this._scrollStyle = 'overlay'; this._data = []; this._dataState = 'idle'; this._page = 1; this._pageSize = 50; this._totalItems = 0; this._totalPages = 0; this._searchQuery = ''; this._canScrollLeft = false; this._canScrollRight = false; this._canScrollHorizontal = false; this._columnPickerOpen = false; this._filterPanelOpened = null; this._filterPanelTab = 'filter'; this._buckets = new Map(); this._filterPanelPos = { top: 0, left: 0 }; this._sorts = []; this._resizing = null; this._resizeObserver = null; this._searchPositionLocked = false; this._model = new KRTableModel(); this.def = { columns: [] }; this._handleClickOutside = (e) => { const path = e.composedPath(); if (this._columnPickerOpen) { const picker = this.shadowRoot?.querySelector('.column-picker-wrapper'); if (picker && !path.includes(picker)) { this._columnPickerOpen = false; } } if (this._filterPanelOpened) { if (!path.some((el) => el.classList?.contains('filter-panel'))) { this._handleFilterApply(); } } }; this._handleResizeMove = (e) => { if (!this._resizing) return; const col = this._model.columns.find(c => c.id === this._resizing.columnId); if (col) { const newWidth = this._resizing.startWidth + (e.clientX - this._resizing.startX); col.width = `${Math.min(900, Math.max(50, newWidth))}px`; this.requestUpdate(); } }; this._handleResizeEnd = () => { this._resizing = null; document.removeEventListener('mousemove', this._handleResizeMove); document.removeEventListener('mouseup', this._handleResizeEnd); }; } connectedCallback() { super.connectedCallback(); this.classList.toggle('kr-table--scroll-overlay', this._scrollStyle === 'overlay'); this.classList.toggle('kr-table--scroll-edge', this._scrollStyle === 'edge'); this._fetch(); this._initRefresh(); document.addEventListener('click', this._handleClickOutside); this._resizeObserver = new ResizeObserver(() => { // Unlock and recalculate on resize since layout changes this._searchPositionLocked = false; this._updateSearchPosition(); }); this._resizeObserver.observe(this); } disconnectedCallback() { super.disconnectedCallback(); clearInterval(this._refreshTimer); document.removeEventListener('click', this._handleClickOutside); this._resizeObserver?.disconnect(); } willUpdate(changedProperties) { if (changedProperties.has('def')) { // Build internal model from user-provided def this._model = new KRTableModel(); if (this.def.title) { this._model.title = this.def.title; } if (this.def.actions) { this._model.actions = this.def.actions; } if (this.def.data) { this._model.data = this.def.data; } if (this.def.dataSource) { this._model.dataSource = this.def.dataSource; } if (typeof this.def.refreshInterval === 'number') { this._model.refreshInterval = this.def.refreshInterval; } if (typeof this.def.pageSize === 'number') { this._model.pageSize = this.def.pageSize; } if (this.def.rowClickable) { this._model.rowClickable = this.def.rowClickable; } if (this.def.rowHref) { this._model.rowHref = this.def.rowHref; } this._model.columns = this.def.columns.map(col => { const column = { ...col, filter: null }; if (!column.type) { column.type = 'string'; } if (column.type === 'actions') { column.label = col.label ?? ''; column.sticky = 'right'; column.resizable = false; return column; } if (col.filterable || col.facetable) { column.filter = new KRQuery(); column.filter.field = col.id; column.filter.type = column.type; if (col.filter) { column.filter.setOperator(col.filter.operator); column.filter.setValue(col.filter.value); } else if (col.facetable && !col.filterable) { column.filter.operator = 'in'; column.filter.value = []; } else if (column.filter.type === 'string') { column.filter.operator = 'contains'; } } return column; }); if (this.def.displayedColumns) { this._model.displayedColumns = this.def.displayedColumns; } else { this._model.displayedColumns = this._model.columns.map(c => c.id); } this._fetch(); this._initRefresh(); } } updated(changedProperties) { this._updateScrollFlags(); this._syncSlottedContent(); } /** Syncs light DOM content for cells with custom render functions */ _syncSlottedContent() { const columns = this.getDisplayedColumns().filter(col => col.render); if (!columns.length) return; // Clear old slotted content this.querySelectorAll('[slot^=\"cell-\"]').forEach(el => el.remove()); // Create new slotted content this._data.forEach((row, rowIndex) => { columns.forEach(col => { const result = col.render(row); if (!result) return; const el = document.createElement('span'); el.slot = `cell-${rowIndex}-${col.id}`; if (col.type === 'actions') { el.style.display = 'flex'; el.style.gap = '8px'; } if (typeof result === 'string') { el.innerHTML = result; } else { D(result, el); } this.appendChild(el); }); }); } // ---------------------------------------------------------------------------- // Public Interface // ---------------------------------------------------------------------------- refresh() { this._fetch(); } goToPrevPage() { if (this._page > 1) { this._page--; this._fetch(); } } goToNextPage() { if (this._page < this._totalPages) { this._page++; this._fetch(); } } goToPage(page) { if (page >= 1 && page <= this._totalPages) { this._page = page; this._fetch(); } } // ---------------------------------------------------------------------------- // Data Fetching // ---------------------------------------------------------------------------- _toSolrData() { const request = { page: this._page - 1, size: this._pageSize, sorts: this._sorts, filterFields: [], queryFields: [], facetFields: [] }; for (const col of this._model.columns) { if (!col.filter || col.filter.isEmpty() || !col.filter.isValid()) { continue; } const filterData = col.filter.toSolrData(); if (col.facetable && col.filter.operator === 'in') { filterData.tagged = true; } request.filterFields.push(filterData); } for (const col of this._model.columns) { if (!col.facetable) { continue; } request.facetFields.push({ name: col.id, type: 'FIELD', limit: 100, sort: 'count', minimumCount: 1 }); } if (this._searchQuery?.trim().length) { request.queryFields.push({ name: '_text_', operation: 'IS', value: termify(this._searchQuery, false) }); } return request; } _toDbParams() { const request = { page: this._page - 1, size: this._pageSize, sorts: this._sorts, filterFields: [], queryFields: [], facetFields: [] }; for (const col of this._model.columns) { if (!col.filter || col.filter.isEmpty() || !col.filter.isValid()) { continue; } request.filterFields.push(col.filter.toDbParams()); } if (this._searchQuery?.trim().length) { this._model.columns.filter(col => col.searchable).forEach(col => { request.queryFields.push({ name: col.id, operation: 'CONTAINS', value: this._searchQuery, and: false }); }); } return request; } /** * Fetches data from the API and updates the table. * Shows a loading spinner while fetching, then displays rows on success * or an error snackbar on failure. * Request/response format depends on dataSource.mode (solr, opensearch, db). */ _fetch() { if (this._model.data) { this._data = this._model.data; this._totalItems = this._model.data.length; this._totalPages = Math.ceil(this._model.data.length / this._pageSize); this._dataState = 'success'; return; } if (!this._model.dataSource) return; this._dataState = 'loading'; let request; if (this._model.dataSource.mode === 'db') { request = this._toDbParams(); } else { request = this._toSolrData(); } this._model.dataSource.fetch(request) .then(response => { // Parse response based on mode switch (this._model.dataSource?.mode) { case 'opensearch': { throw Error('Opensearch not supported yet'); } case 'db': { const res = response; this._data = res.data.content; this._totalItems = res.data.totalElements; this._totalPages = res.data.totalPages; this._pageSize = res.data.size; break; } default: { // solr const res = response; this._data = res.data.content; this._totalItems = res.data.totalElements; this._totalPages = res.data.totalPages; this._pageSize = res.data.size; this._parseFacetResults(res); } } this._dataState = 'success'; this._updateSearchPosition(); }) .catch(err => { this._dataState = 'error'; KRSnackbar.show({ message: err instanceof Error ? err.message : 'Failed to load data', type: 'error' }); }); } _parseFacetResults(response) { if (!response.data.facetFields) { return; } for (const col of this._model.columns) { if (!col.facetable) { continue; } const rawBuckets = response.data.facetFields[col.id]; if (!rawBuckets) { this._buckets.set(col.id, []); continue; } const buckets = []; for (const raw of rawBuckets) { // Solr returns boolean facet values as strings — coerce to actual booleans // so they match the filter values stored by toggle(). let val = raw.name; if (col.type === 'boolean' && typeof raw.name === 'string') { if (raw.name === 'true') { val = true; } else if (raw.name === 'false') { val = false; } } if (raw.name === null && raw.count > 0) { buckets.unshift({ val: null, count: raw.count }); } if (raw.name !== null) { buckets.push({ val: val, count: raw.count }); } } // Bucket sync: ensure selected values appear even with 0 results if (col.filter && col.filter.operator === 'in' && Array.isArray(col.filter.value)) { for (const selectedVal of col.filter.value) { if (!buckets.some(b => b.val === selectedVal)) { buckets.push({ val: selectedVal, count: 0 }); } } } this._buckets.set(col.id, buckets); } // Trigger re-render since Map mutation doesn't trigger Lit updates this._buckets = new Map(this._buckets); } /** * Sets up auto-refresh so the table automatically fetches fresh data * at a regular interval (useful for dashboards, monitoring views). * Configured via def.refreshInterval in milliseconds. */ _initRefresh() { clearInterval(this._refreshTimer); if (this._model.refreshInterval && this._model.refreshInterval > 0) { this._refreshTimer = window.setInterval(() => { this._fetch(); }, this._model.refreshInterval); } } _handleSearch(e) { const input = e.target; this._searchQuery = input.value; this._page = 1; this._fetch(); } _getGridTemplateColumns() { const cols = this.getDisplayedColumns(); return cols.map((col) => { // If column has explicit width, use it if (col.width) { return col.width; } // Actions columns: fit content without minimum if (col.type === 'actions') { return 'max-content'; } // No width specified - use content-based sizing with minimum return 'minmax(80px, auto)'; }).join(' '); } /** * Updates search position to be centered with equal gaps from title and tools. * On first call: resets to flex centering, measures position, then locks with fixed margin. * Subsequent calls are ignored unless _searchPositionLocked is reset (e.g., on resize). */ _updateSearchPosition() { // Skip if already locked (prevents shifts on pagination changes) if (this._searchPositionLocked) return; const search = this.shadowRoot?.querySelector('.search'); const searchField = search?.querySelector('.search-field'); if (!search || !searchField) return; // Reset to flex centering search.style.justifyContent = 'center'; searchField.style.marginLeft = ''; requestAnimationFrame(() => { const searchRect = search.getBoundingClientRect(); const fieldRect = searchField.getBoundingClientRect(); // Calculate how far from the left of search container the field currently is const currentOffset = fieldRect.left - searchRect.left; // Lock position: switch to flex-start and use fixed margin search.style.justifyContent = 'flex-start'; searchField.style.marginLeft = `${currentOffset}px`; // Mark as locked so pagination changes don't shift the search this._searchPositionLocked = true; }); } // ---------------------------------------------------------------------------- // Columns // ---------------------------------------------------------------------------- _toggleColumnPicker() { this._columnPickerOpen = !this._columnPickerOpen; } _toggleColumn(columnId) { if (this._model.displayedColumns.includes(columnId)) { this._model.displayedColumns = this._model.displayedColumns.filter(id => id !== columnId); } else { this._model.displayedColumns = [...this._model.displayedColumns, columnId]; } this.requestUpdate(); } // Clear any existing text selection on mousedown so we only detect // selections made during this click gesture, not stale selections from elsewhere _handleRowMouseDown() { if (!this._model.rowClickable) { return; } window.getSelection()?.removeAllRanges(); } _handleRowClick(row, rowIndex) { if (!this._model.rowClickable) { return; } const selection = window.getSelection(); if (selection && selection.toString().length > 0) { return; } this.dispatchEvent(new CustomEvent('row-click', { detail: { row, rowIndex }, bubbles: true, composed: true })); } // When a user toggles a column on via the column picker, it gets appended // to _displayedColumns. By mapping over _displayedColumns (not def.columns), // the new column appears at the right edge of the table instead of jumping // back to its original position in the column definition. // Actions columns are always moved to the end. getDisplayedColumns() { return this._model.displayedColumns .map(id => this._model.columns.find(col => col.id === id)) .sort((a, b) => { if (a.type === 'actions' && b.type !== 'actions') return 1; if (a.type !== 'actions' && b.type === 'actions') return -1; return 0; }); } // ---------------------------------------------------------------------------- // Scrolling // ---------------------------------------------------------------------------- /** * Scroll event handler that updates scroll flags in real-time as user scrolls. * Updates shadow indicators to show if more content exists left/right. */ _handleScroll(e) { const container = e.target; this._canScrollLeft = container.scrollLeft > 0; this._canScrollRight = container.scrollLeft < container.scrollWidth - container.clientWidth - 1; } /** * Updates scroll state flags for the table content container. * - _canScrollLeft: true if scrolled right (can scroll back left) * - _canScrollRight: true if more content exists to the right * - _canScrollHorizontal: true if content is wider than container * These flags control scroll shadow indicators and CSS classes. */ _updateScrollFlags() { const container = this.shadowRoot?.querySelector('.content'); if (container) { this._canScrollLeft = container.scrollLeft > 0; this._canScrollRight = container.scrollWidth > container.clientWidth && container.scrollLeft < container.scrollWidth - container.clientWidth - 1; this._canScrollHorizontal = container.scrollWidth > container.clientWidth; } this.classList.toggle('kr-table--scroll-left-available', this._canScrollLeft); this.classList.toggle('kr-table--scroll-right-available', this._canScrollRight); this.classList.toggle('kr-table--scroll-horizontal-available', this._canScrollHorizontal); this.classList.toggle('kr-table--sticky-left', this.getDisplayedColumns().some(c => c.sticky === 'left')); this.classList.toggle('kr-table--sticky-right', this.getDisplayedColumns().some(c => c.sticky === 'right')); } // ---------------------------------------------------------------------------- // Column Resizing // ---------------------------------------------------------------------------- _handleResizeStart(e, columnId) { e.preventDefault(); const headerCell = this.shadowRoot?.querySelector(`.header-cell[data-column-id=\"${columnId}\"]`); this._resizing = { columnId, startX: e.clientX, startWidth: headerCell?.offsetWidth || 200 }; document.addEventListener('mousemove', this._handleResizeMove); document.addEventListener('mouseup', this._handleResizeEnd); } // ---------------------------------------------------------------------------- // Sorting // ---------------------------------------------------------------------------- _handleSortClick(e, column) { if (e.shiftKey) { // Multi-sort: add or cycle existing const existingIndex = this._sorts.findIndex(s => s.sortBy === column.id); if (existingIndex === -1) { this._sorts.push({ sortBy: column.id, sortDirection: 'asc' }); } else { const existing = this._sorts[existingIndex]; if (existing.sortDirection === 'asc') { existing.sortDirection = 'desc'; } else { // on third click, remove sorting for the column this._sorts.splice(existingIndex, 1); } } this.requestUpdate(); } else { // Single sort: replace all let existing = null; if (this._sorts.length === 1) { existing = this._sorts.find(s => s.sortBy === column.id); } if (!existing) { this._sorts = [{ sortBy: column.id, sortDirection: 'asc' }]; } else if (existing.sortDirection === 'asc') { this._sorts = [{ sortBy: column.id, sortDirection: 'desc' }]; } else { this._sorts = []; } } this._page = 1; this._fetch(); } _renderSortIndicator(column) { if (!column.sortable) { return A; } const sortIndex = this._sorts.findIndex(s => s.sortBy === column.id); if (sortIndex === -1) { // Ghost arrow: visible only on hover via CSS return b ` <span class=\"header-cell__sort\" @click=${(e) => this._handleSortClick(e, column)}> <svg class=\"header-cell__sort-arrow header-cell__sort-arrow--ghost\" viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z\"/> </svg> </span> `; } let arrowStyle = {}; if (this._sorts[sortIndex].sortDirection === 'desc') { arrowStyle = { transform: 'rotate(180deg)' }; } return b ` <span class=\"header-cell__sort\" @click=${(e) => this._handleSortClick(e, column)}> <svg class=\"header-cell__sort-arrow\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=${o$1(arrowStyle)}> <path d=\"M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z\"/> </svg> ${this._sorts.length > 1 ? b ` <span class=\"header-cell__sort-priority\">${sortIndex + 1}</span> ` : A} </span> `; } // ---------------------------------------------------------------------------- // Header // ---------------------------------------------------------------------------- _handleAction(action) { if (action.href) { return; } this.dispatchEvent(new CustomEvent('action', { detail: { action: action.id }, bubbles: true, composed: true })); } // ---------------------------------------------------------------------------- // Filter Handlers // ---------------------------------------------------------------------------- _handleKqlChange(e, column) { const kql = e.target.value.trim(); if (!kql) { column.filter.clear(); this.requestUpdate(); } else { column.filter.setKql(kql); this.requestUpdate(); if (!column.filter.isValid()) { return; } } this._page = 1; this._fetch(); } _handleFilterPanelToggle(e, column) { e.stopPropagation(); if (this._filterPanelOpened === column.id) { this._filterPanelOpened = null; } else { const rect = e.currentTarget.getBoundingClientRect(); let left = rect.left; if (left + 328 > window.innerWidth) { left = window.innerWidth - 328; } this._filterPanelPos = { top: rect.bottom + 4, left }; this._filterPanelOpened = column.id; if (column.facetable) { this._filterPanelTab = 'counts'; } else { this._filterPanelTab = 'filter'; } } } _handleKqlClear(column) { column.filter.clear(); this._page = 1; this._fetch(); } _handleFilterClear() { const column = this._model.columns.find(c => c.id === this._filterPanelOpened); if (column) { column.filter.clear(); if (column.facetable && !column.filterable) { column.filter.operator = 'in'; column.filter.value = []; } } this._filterPanelOpened = null; this._page = 1; this._fetch(); } _handleFilterTextKeydown(e, column) { if (e.key === 'Enter') { e.preventDefault(); this._handleFilterApply(); } } _handleOperatorChange(e, column) { column.filter.setOperator(e.target.value); this.requestUpdate(); } _handleFilterStringChange(e, column) { column.filter.setValue(e.target.value); this.requestUpdate(); } _handleFilterNumberChange(e, column) { column.filter.setValue(Number(e.target.value)); this.requestUpdate(); } _handleFilterDateChange(e, column) { column.filter.setValue(new Date(e.target.value), 'day'); this.requestUpdate(); } _handleFilterBooleanChange(e, column) { column.filter.setValue(e.target.value === 'true'); this.requestUpdate(); } _handleFilterDateStartChange(e, column) { column.filter.setStart(new Date(e.target.value), 'day'); this.requestUpdate(); } _handleFilterDateEndChange(e, column) { column.filter.setEnd(new Date(e.target.value), 'day'); this.requestUpdate(); } _handleFilterNumberStartChange(e, column) { column.filter.setStart(Number(e.target.value)); this.requestUpdate(); } _handleFilterNumberEndChange(e, column) { column.filter.setEnd(Number(e.target.value)); this.requestUpdate(); } _handleFilterListChange(e, column) { const items = e.target.value.split(',').map((v) => v.trim()).filter((v) => v !== ''); if (column.type === 'number') { column.filter.setValue(items.map((v) => Number(v))); } else { column.filter.setValue(items); } this.requestUpdate(); } _handleFilterApply() { this._filterPanelOpened = null; this._page = 1; this._fetch(); } _handleFilterPanelTabChange(e) { this._filterPanelTab = e.detail.activeTabId; } _handleBucketToggle(e, column, bucket) { column.filter.toggle(bucket.val); this._page = 1; this._fetch(); } // ---------------------------------------------------------------------------- // Rendering // ---------------------------------------------------------------------------- _renderCellContent(column, row, rowIndex) { const value = row[column.id]; if (column.render) { // Use slot to project content from light DOM so external styles apply return b `<slot name=\"cell-${rowIndex}-${column.id}\"></slot>`; } if (value === null || value === undefined) { return ''; } switch (column.type) { case 'number': if (column.format === 'currency' && typeof value === 'number') { return value.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); } return String(value); case 'date': { let date; if (value instanceof Date) { date = value; } else if (typeof value === 'string' && /^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}/.test(value)) { // MySQL datetime format (UTC): \"2026-01-28 01:33:44:517\" // Replace last colon before ms with dot, append Z for UTC const isoString = value.replace(/(\\d{2}:\\d{2}:\\d{2}):(\\d+)$/, '$1.$2').replace(' ', 'T') + 'Z'; date = new Date(isoString); } else { date = new Date(value); } // Show date and time for datetime values in UTC return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'UTC' }); } case 'boolean': if (value === true) return 'Yes'; if (value === false) return 'No'; return ''; default: return String(value); } } /** * Returns CSS classes for a header cell based on column config. */ _getHeaderCellClasses(column, index) { return { 'header-cell': true, 'header-cell--sortable': !!column.sortable, 'header-cell--align-center': column.align === 'center', 'header-cell--align-right': column.align === 'right', 'header-cell--sticky-left': column.sticky === 'left', 'header-cell--sticky-left-last': column.sticky === 'left' && !this.getDisplayedColumns().slice(index + 1).some(c => c.sticky === 'left'), 'header-cell--sticky-right': column.sticky === 'right', 'header-cell--sticky-right-first': column.sticky === 'right' && !this.getDisplayedColumns().slice(0, index).some(c => c.sticky === 'right') }; } /** * Returns CSS classes for a table cell based on column config: * - Alignment (center, right) * - Sticky positioning (left, right) * - Border classes for the last left-sticky or first right-sticky column */ _getCellClasses(column, index) { return { 'cell': true, 'cell--actions': column.type === 'actions', 'cell--align-center': column.align === 'center', 'cell--align-right': column.align === 'right', 'cell--sticky-left': column.sticky === 'left', 'cell--sticky-left-last': column.sticky === 'left' && !this.getDisplayedColumns().slice(index + 1).some(c => c.sticky === 'left'), 'cell--sticky-right': column.sticky === 'right', 'cell--sticky-right-first': column.sticky === 'right' && !this.getDisplayedColumns().slice(0, index).some(c => c.sticky === 'right') }; } /** * Returns inline styles for a table cell: * - Width (from column config or default 150px) * - Min-width (if specified) * - Left/right offset for sticky columns (calculated from widths of preceding sticky columns) */ _getCellStyle(column, index) { const styles = {}; if (column.sticky === 'left') { let leftOffset = 0; for (let i = 0; i < index; i++) { const col = this.getDisplayedColumns()[i]; if (col.sticky === 'left') { leftOffset += parseInt(col.width || '0', 10); } } styles.left = `${leftOffset}px`; } if (column.sticky === 'right') { let rightOffset = 0; for (let i = index + 1; i < this.getDisplayedColumns().length; i++) { const col = this.getDisplayedColumns()[i]; if (col.sticky === 'right') { rightOffset += parseInt(col.width || '0', 10); } } styles.right = `${rightOffset}px`; } return styles; } /** * Renders the pagination controls: * - Previous page arrow (disabled on first page) * - Range text showing \"1-50 of 150\" format * - Next page arrow (disabled on last page) * * Hidden when there's no data or all data fits on one page. */ _renderPagination() { const start = (this._page - 1) * this._pageSize + 1; const end = Math.min(this._page * this._pageSize, this._totalItems); return b ` <div class=\"pagination\"> <span class=\"pagination-icon ${this._page === 1 ? 'pagination-icon--disabled' : ''}\" @click=${this.goToPrevPage} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z\"/></svg> </span> <span class=\"pagination-info\">${start}-${end} of ${this._totalItems}</span> <span class=\"pagination-icon ${this._page === this._totalPages ? 'pagination-icon--disabled' : ''}\" @click=${this.goToNextPage} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z\"/></svg> </span> </div> `; } /** * Renders the header toolbar containing: * - Title (left) * - Search bar with view selector dropdown (center) * - Tools (right): page navigation, refresh button, column visibility picker, actions dropdown * * Hidden when there's no title, no actions, and data fits on one page. */ _renderHeader() { if (!this._model.title && !this._model.actions?.length && this._totalPages <= 1) { return A; } return b ` <div class=\"header\"> <div class=\"title\">${this._model.title ?? ''}</div> ${this._model.dataSource?.mode === 'db' && !this._model.columns.some(col => col.searchable) ? b `<div class=\"search\"></div>` : b ` <div class=\"search\"> <!-- TODO: Saved views dropdown <div class=\"views\"> <span>Default View</span> <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z\"/></svg> </div> --> <div class=\"search-field\"> <svg class=\"search-icon\" viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z\"/></svg> <input type=\"text\" class=\"search-input\" placeholder=\"Search...\" .value=${this._searchQuery} @input=${this._handleSearch} /> </div> </div> `} <div class=\"tools\"> ${this._renderPagination()} <span class=\"refresh\" title=\"Refresh\" @click=${() => this.refresh()}> <svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-56-87.5-88T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z\"/></svg> </span> <div class=\"column-picker-wrapper\"> <span class=\"header-icon\" title=\"Columns\" @click=${this._toggleColumnPicker}> <svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M121-280v-400q0-33 23.5-56.5T201-760h559q33 0 56.5 23.5T840-680v400q0 33-23.5 56.5T760-200H201q-33 0-56.5-23.5T121-280Zm79 0h133v-400H200v400Zm213 0h133v-400H413v400Zm213 0h133v-400H626v400Z\"/></svg> </span> <div class=\"column-picker ${this._columnPickerOpen ? 'open' : ''}\"> ${[...this._model.columns].filter(col => col.type !== 'actions').sort((a, b) => (a.label ?? a.id).localeCompare(b.label ?? b.id)).map(col => b ` <div class=\"column-picker-item\" @click=${() => this._toggleColumn(col.id)}> <div class=\"column-picker-checkbox ${this._model.displayedColumns.includes(col.id) ? 'checked' : ''}\"> <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z\"/></svg> </div> <span class=\"column-picker-label\">${col.label ?? col.id}</span> </div> `)} </div> </div> ${this._model.actions?.length === 1 ? b ` <kr-button class=\"actions\" .href=${this._model.actions[0].href} .target=${this._model.actions[0].target} @click=${() => this._handleAction(this._model.actions[0])} > ${this._model.actions[0].label} </kr-button> ` : this._model.actions?.length ? b ` <kr-button class=\"actions\" .options=${this._model.actions.map(a => ({ id: a.id, label: a.label }))} @option-select=${(e) => this._handleAction({ id: e.detail.id, label: e.detail.label })} > Actions </kr-button> ` : A} </div> </div> `; } /** Renders status message (loading, error, empty) */ _renderStatus() { if (this._dataState === 'loading' && this._data.length === 0) { return b `<div class=\"status\">Loading...</div>`; } if (this._dataState === 'error' && this._data.length === 0) { return b `<div class=\"status status--error\">Error loading data</div>`; } if (this._data.length === 0) { return b `<div class=\"status\">No data available</div>`; } return A; } _renderFilterPanel() { if (!this._filterPanelOpened) { return A; } const column = this._model.columns.find(c => c.id === this._filterPanelOpened); // Build filter content (operator + value input) let valueInput = b ``; if (column.filter.operator === 'empty' || column.filter.operator === 'n_empty') { valueInput = b ` <input type=\"text\" class=\"filter-panel__input\" disabled .value=${column.filter.text} /> `; } else if (column.filter.operator === 'between' && column.type === 'date') { valueInput = b ` <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${column.filter.value?.start ?? null} @change=${(e) => this._handleFilterDateStartChange(e, column)} /> <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${column.filter.value?.end ?? null} @change=${(e) => this._handleFilterDateEndChange(e, column)} /> `; } else if (column.filter.operator === 'between' && column.type === 'number') { valueInput = b ` <input type=\"number\" class=\"filter-panel__input\" placeholder=\"Start\" .value=${column.filter.value?.start ?? ''} @input=${(e) => this._handleFilterNumberStartChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} /> <input type=\"number\" class=\"filter-panel__input\" placeholder=\"End\" .value=${column.filter.value?.end ?? ''} @input=${(e) => this._handleFilterNumberEndChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} /> `; } else if (column.filter.operator === 'in') { valueInput = b ` <textarea class=\"filter-panel__textarea\" rows=\"3\" placeholder=\"Values (comma-separated)\" .value=${column.filter.text} @input=${(e) => this._handleFilterListChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} ></textarea> `; } else if (column.type === 'boolean') { valueInput = b ` <kr-select-field placeholder=\"Value\" .value=${String(column.filter.value ?? '')} @change=${(e) => this._handleFilterBooleanChange(e, column)} > <kr-select-option value=\"true\">Yes</kr-select-option> <kr-select-option value=\"false\">No</kr-select-option> </kr-select-field> `; } else if (column.type === 'date') { valueInput = b ` <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${column.filter.value} @change=${(e) => this._handleFilterDateChange(e, column)} /> `; } else if (column.type === 'number') { valueInput = b ` <input type=\"number\" class=\"filter-panel__input\" placeholder=\"Value\" min=\"0\" .value=${column.filter.text} @input=${(e) => this._handleFilterNumberChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} /> `; } else { valueInput = b ` <input type=\"text\" class=\"filter-panel__input\" placeholder=\"Value\" .value=${column.filter.text} @input=${(e) => this._handleFilterStringChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} /> `; } const filterContent = b ` <div class=\"filter-panel__content\"> <kr-select-field .value=${column.filter.operator} @change=${(e) => this._handleOperatorChange(e, column)} > ${getOperatorsForType(column.type).map(op => b ` <kr-select-option value=${op.key}>${op.label}</kr-select-option> `)} </kr-select-field> ${valueInput} </div> `; // Build bucket list content const buckets = this._buckets.get(column.id) || []; let bucketContent; if (!buckets.length) { bucketContent = b `<div class=\"bucket-empty\">No data</div>`; } else { bucketContent = b ` <div class=\"buckets\"> ${buckets.map(bucket => { let bucketLabel = '(Empty)'; if (bucket.val !== null && bucket.val !== undefined) { if (column.type === 'boolean') { if (bucket.val === true || bucket.val === 'true') { bucketLabel = 'Yes'; } else { bucketLabel = 'No'; } } else { bucketLabel = String(bucket.val); } } let checkIcon = A; if (column.filter.has(bucket.val)) { checkIcon = b ` <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z\"/> </svg> `; } return b ` <div class=\"bucket\" @click=${(e) => this._handleBucketToggle(e, column, bucket)} > <div class=${e$1({ 'bucket__checkbox': true, 'bucket__checkbox--checked': column.filter.has(bucket.val) })}> ${checkIcon} </div> <span class=\"bucket__label\">${bucketLabel}</span> <span class=\"bucket__count\">${bucket.count}</span> </div> `; })} </div> `; } // Build panel body — tabs if both filterable+facetable, otherwise just the relevant content let panelBody; if (column.facetable && column.filterable) { panelBody = b ` <kr-tab-group size=\"small\" active-tab-id=${this._filterPanelTab} @tab-change=${(e) => this._handleFilterPanelTabChange(e)} > <kr-tab id=\"filter\" label=\"Filter\"> ${filterContent} </kr-tab> <kr-tab id=\"counts\" label=\"Counts\"> ${bucketContent} </kr-tab> </kr-tab-group> `; } else if (column.facetable) { panelBody = bucketContent; } else { panelBody = filterContent; } return b ` <div class=\"filter-panel\" style=${o$1({ top: this._filterPanelPos.top + 'px', left: this._filterPanelPos.left + 'px' })} > ${panelBody} <div class=\"filter-panel__actions\"> <kr-button variant=\"outline\" color=\"secondary\" size=\"small\" @click=${this._handleFilterClear}> Clear </kr-button> <kr-button size=\"small\" @click=${this._handleFilterApply}> Apply </kr-button> </div> </div> `; } /** * Renders filter row below column headers. * Only displays for columns with filterable: true. */ _renderFilterRow() { const columns = this.getDisplayedColumns(); if (!columns.some(col => col.filterable || col.facetable)) { return A; } return b ` <div class=\"filter-row\"> ${columns.map((col, i) => { if (!col.filterable && !col.facetable) { return b `<div class=${e$1({ 'filter-cell': true, 'filter-cell--sticky-left': col.sticky === 'left', 'filter-cell--sticky-right': col.sticky === 'right', 'filter-cell--sticky-right-first': col.sticky === 'right' && !columns.slice(0, i).some((c) => c.sticky === 'right') })} style=${o$1(this._getCellStyle(col, i))} ></div>`; } return b ` <div class=${e$1({ 'filter-cell': true, 'filter-cell--sticky-left': col.sticky === 'left', 'filter-cell--sticky-right': col.sticky === 'right', 'filter-cell--sticky-right-first': col.sticky === 'right' && !columns.slice(0, i).some((c) => c.sticky === 'right') })} style=${o$1(this._getCellStyle(col, i))} > <div class=\"filter-cell__wrapper\"> <input type=\"text\" class=${e$1({ 'filter-cell__input': true, 'filter-cell__input--invalid': !col.filter.isValid() })} .value=${col.filter.kql} @change=${(e) => this._handleKqlChange(e, col)} /> ${col.filter?.kql?.length > 0 ? b ` <button class=\"filter-cell__clear\" @click=${() => this._handleKqlClear(col)} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z\"/> </svg> </button> ` : A} <button class=${e$1({ 'filter-cell__advanced': true, 'filter-cell__advanced--opened': this._filterPanelOpened === col.id })} @click=${(e) => this._handleFilterPanelToggle(e, col)} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z\"/> </svg> </button> </div> </div> `; })} </div> `; } /** Renders the scrollable data grid with column headers and rows. */ _renderTable() { return b ` <div class=\"wrapper\"> <div class=\"overlay-left\"></div> <div class=\"overlay-right\"></div> ${this._renderStatus()} <div class=\"content\" @scroll=${this._handleScroll}> <div class=\"table\" style=\"grid-template-columns: ${this._getGridTemplateColumns()}\"> <div class=\"header-row\"> ${this.getDisplayedColumns().map((col, i) => b ` <div class=${e$1(this._getHeaderCellClasses(col, i))} style=${o$1(this._getCellStyle(col, i))} data-column-id=${col.id} > <span class=\"header-cell__label\">${col.label ?? col.id}</span> ${this._renderSortIndicator(col)} ${col.resizable !== false ? b `<div class=\"header-cell__resize\" @mousedown=${(e) => this._handleResizeStart(e, col.id)} ></div>` : A} </div> `)} </div> ${this._renderFilterRow()} ${this._data.map((row, rowIndex) => { const cells = this.getDisplayedColumns().map((col, i) => b ` <div class=${e$1(this._getCellClasses(col, i))} style=${o$1(this._getCellStyle(col, i))} data-column-id=${col.id} > ${this._renderCellContent(col, row, rowIndex)} </div> `); if (this._model.rowHref) { return b ` <a href=${this._model.rowHref(row)} class=${e$1({ 'row': true, 'row--clickable': true, 'row--link': true })} @mousedown=${() => this._handleRowMouseDown()} @click=${() => this._handleRowClick(row, rowIndex)} >${cells}</a> `; } return b ` <div class=${e$1({ 'row': true, 'row--clickable': !!this._model.rowClickable })} @mousedown=${() => this._handleRowMouseDown()} @click=${() => this._handleRowClick(row, rowIndex)} >${cells}</div> `; })} </div> </div> </div> `; } /** * Renders a data table with: * - Header bar with title, search input with view selector, and tools (pagination, refresh, column visibility, actions dropdown) * - Scrollable grid with sticky header row and optional sticky left/right columns * - Loading, error message, or empty state when no data */ render() { if (!this._model.columns.length) { return b `<slot></slot>`; } return b ` ${this._renderHeader()} ${this._renderTable()} ${this._renderFilterPanel()} `; } }"
576
+ "default": "class KRTable extends i$2 { constructor() { super(...arguments); /** * Internal flag to switch between scroll edge modes: * - 'overlay': Fixed padding with overlay elements that hide content at edges (scrollbar at viewport edge) * - 'edge': Padding scrolls with content, allowing table to reach edges when scrolling */ this._scrollStyle = 'overlay'; this._data = []; this._dataState = 'idle'; this._page = 1; this._pageSize = 50; this._totalItems = 0; this._totalPages = 0; this._searchQuery = ''; this._canScrollLeft = false; this._canScrollRight = false; this._canScrollHorizontal = false; this._columnPickerOpen = false; this._filterPanelOpened = null; this._filterPanelTab = 'filter'; this._buckets = new Map(); this._filterPanelPos = { top: 0, left: 0 }; this._sorts = []; this._resizing = null; this._resizeObserver = null; this._searchPositionLocked = false; this._model = new KRTableModel(); this.def = { columns: [] }; this._handleClickOutside = (e) => { const path = e.composedPath(); if (this._columnPickerOpen) { const picker = this.shadowRoot?.querySelector('.column-picker-wrapper'); if (picker && !path.includes(picker)) { this._columnPickerOpen = false; } } if (this._filterPanelOpened) { if (!path.some((el) => el.classList?.contains('filter-panel'))) { this._handleFilterApply(); } } }; this._handleResizeMove = (e) => { if (!this._resizing) return; const col = this._model.columns.find(c => c.id === this._resizing.columnId); if (col) { const newWidth = this._resizing.startWidth + (e.clientX - this._resizing.startX); col.width = `${Math.min(900, Math.max(50, newWidth))}px`; this.requestUpdate(); } }; this._handleResizeEnd = () => { this._resizing = null; document.removeEventListener('mousemove', this._handleResizeMove); document.removeEventListener('mouseup', this._handleResizeEnd); }; } connectedCallback() { super.connectedCallback(); this.classList.toggle('kr-table--scroll-overlay', this._scrollStyle === 'overlay'); this.classList.toggle('kr-table--scroll-edge', this._scrollStyle === 'edge'); this._fetch(); this._initRefresh(); document.addEventListener('click', this._handleClickOutside); this._resizeObserver = new ResizeObserver(() => { // Unlock and recalculate on resize since layout changes this._searchPositionLocked = false; this._updateSearchPosition(); }); this._resizeObserver.observe(this); } disconnectedCallback() { super.disconnectedCallback(); clearInterval(this._refreshTimer); document.removeEventListener('click', this._handleClickOutside); this._resizeObserver?.disconnect(); } willUpdate(changedProperties) { if (changedProperties.has('def')) { // Build internal model from user-provided def this._model = new KRTableModel(); if (this.def.title) { this._model.title = this.def.title; } if (this.def.actions) { this._model.actions = this.def.actions; } if (this.def.data) { this._model.data = this.def.data; } if (this.def.dataSource) { this._model.dataSource = this.def.dataSource; } if (typeof this.def.refreshInterval === 'number') { this._model.refreshInterval = this.def.refreshInterval; } if (typeof this.def.pageSize === 'number') { this._model.pageSize = this.def.pageSize; } if (this.def.rowClickable) { this._model.rowClickable = this.def.rowClickable; } if (this.def.rowHref) { this._model.rowHref = this.def.rowHref; } this._sorts = []; this._model.columns = this.def.columns.map(col => { const column = { ...col, filter: null }; if (!column.type) { column.type = 'string'; } if (col.sort) { this._sorts.push({ sortBy: col.id, sortDirection: col.sort }); } if (column.type === 'actions') { column.label = col.label ?? ''; column.sticky = 'right'; column.resizable = false; return column; } if (col.filterable || col.facetable) { column.filter = new KRQuery(); column.filter.field = col.id; column.filter.type = column.type; if (col.filter) { column.filter.setOperator(col.filter.operator); column.filter.setValue(col.filter.value); } else if (col.facetable && !col.filterable) { column.filter.operator = 'in'; column.filter.value = []; } else if (column.filter.type === 'string') { column.filter.operator = 'contains'; } } return column; }); if (this.def.displayedColumns) { this._model.displayedColumns = this.def.displayedColumns; } else { this._model.displayedColumns = this._model.columns.map(c => c.id); } this._fetch(); this._initRefresh(); } } updated(changedProperties) { this._updateScrollFlags(); this._syncSlottedContent(); } /** Syncs light DOM content for cells with custom render functions */ _syncSlottedContent() { const columns = this.getDisplayedColumns().filter(col => col.render); if (!columns.length) return; // Clear old slotted content this.querySelectorAll('[slot^=\"cell-\"]').forEach(el => el.remove()); // Create new slotted content this._data.forEach((row, rowIndex) => { columns.forEach(col => { const result = col.render(row); if (!result) return; const el = document.createElement('span'); el.slot = `cell-${rowIndex}-${col.id}`; if (col.type === 'actions') { el.style.display = 'flex'; el.style.gap = '8px'; } if (typeof result === 'string') { el.innerHTML = result; } else { D(result, el); } this.appendChild(el); }); }); } // ---------------------------------------------------------------------------- // Public Interface // ---------------------------------------------------------------------------- refresh() { this._fetch(); } goToPrevPage() { if (this._page > 1) { this._page--; this._fetch(); } } goToNextPage() { if (this._page < this._totalPages) { this._page++; this._fetch(); } } goToPage(page) { if (page >= 1 && page <= this._totalPages) { this._page = page; this._fetch(); } } // ---------------------------------------------------------------------------- // Data Fetching // ---------------------------------------------------------------------------- _toSolrData() { const request = { page: this._page - 1, size: this._pageSize, sorts: this._sorts, filterFields: [], queryFields: [], facetFields: [] }; for (const col of this._model.columns) { if (!col.filter || col.filter.isEmpty() || !col.filter.isValid()) { continue; } const filterData = col.filter.toSolrData(); if (col.facetable && (col.filter.operator === 'in' || col.filter.operator === 'n_in')) { filterData.tagged = true; } request.filterFields.push(filterData); } for (const col of this._model.columns) { if (!col.facetable) { continue; } request.facetFields.push({ name: col.id, type: 'FIELD', limit: 100, sort: 'count', minimumCount: 1 }); } if (this._searchQuery?.trim().length) { request.queryFields.push({ name: '_text_', operation: 'IS', value: termify(this._searchQuery, false) }); } return request; } _toDbParams() { const request = { page: this._page - 1, size: this._pageSize, sorts: this._sorts, filterFields: [], queryFields: [], facetFields: [] }; for (const col of this._model.columns) { if (!col.filter || col.filter.isEmpty() || !col.filter.isValid()) { continue; } request.filterFields.push(col.filter.toDbParams()); } if (this._searchQuery?.trim().length) { this._model.columns.filter(col => col.searchable).forEach(col => { request.queryFields.push({ name: col.id, operation: 'CONTAINS', value: this._searchQuery, and: false }); }); } return request; } /** * Fetches data from the API and updates the table. * Shows a loading spinner while fetching, then displays rows on success * or an error snackbar on failure. * Request/response format depends on dataSource.mode (solr, opensearch, db). */ _fetch() { if (this._model.data) { this._data = this._model.data; this._totalItems = this._model.data.length; this._totalPages = Math.ceil(this._model.data.length / this._pageSize); this._dataState = 'success'; return; } if (!this._model.dataSource) return; this._dataState = 'loading'; let request; if (this._model.dataSource.mode === 'db') { request = this._toDbParams(); } else { request = this._toSolrData(); } this._model.dataSource.fetch(request) .then(response => { // Parse response based on mode switch (this._model.dataSource?.mode) { case 'opensearch': { throw Error('Opensearch not supported yet'); } case 'db': { const res = response; this._data = res.data.content; this._totalItems = res.data.totalElements; this._totalPages = res.data.totalPages; this._pageSize = res.data.size; break; } default: { // solr const res = response; this._data = res.data.content; this._totalItems = res.data.totalElements; this._totalPages = res.data.totalPages; this._pageSize = res.data.size; this._parseFacetResults(res); } } this._dataState = 'success'; this._updateSearchPosition(); }) .catch(err => { this._dataState = 'error'; KRSnackbar.show({ message: err instanceof Error ? err.message : 'Failed to load data', type: 'error' }); }); } _parseFacetResults(response) { if (!response.data.facetFields) { return; } for (const col of this._model.columns) { if (!col.facetable) { continue; } const rawBuckets = response.data.facetFields[col.id]; if (!rawBuckets) { this._buckets.set(col.id, []); continue; } const buckets = []; for (const raw of rawBuckets) { // Solr returns boolean facet values as strings — coerce to actual booleans // so they match the filter values stored by toggle(). let val = raw.name; if (col.type === 'boolean' && typeof raw.name === 'string') { if (raw.name === 'true') { val = true; } else if (raw.name === 'false') { val = false; } } if (raw.name === null && raw.count > 0) { buckets.unshift({ val: null, count: raw.count }); } if (raw.name !== null) { buckets.push({ val: val, count: raw.count }); } } // Bucket sync: ensure selected values appear even with 0 results if (col.filter && (col.filter.operator === 'in' || col.filter.operator === 'n_in') && Array.isArray(col.filter.value)) { for (const selectedVal of col.filter.value) { if (!buckets.some(b => b.val === selectedVal)) { buckets.push({ val: selectedVal, count: 0 }); } } } this._buckets.set(col.id, buckets); } // Trigger re-render since Map mutation doesn't trigger Lit updates this._buckets = new Map(this._buckets); } /** * Sets up auto-refresh so the table automatically fetches fresh data * at a regular interval (useful for dashboards, monitoring views). * Configured via def.refreshInterval in milliseconds. */ _initRefresh() { clearInterval(this._refreshTimer); if (this._model.refreshInterval && this._model.refreshInterval > 0) { this._refreshTimer = window.setInterval(() => { this._fetch(); }, this._model.refreshInterval); } } _handleSearch(e) { const input = e.target; this._searchQuery = input.value; this._page = 1; this._fetch(); } _getGridTemplateColumns() { const cols = this.getDisplayedColumns(); return cols.map((col) => { // If column has explicit width, use it if (col.width) { return col.width; } // Actions columns: fit content without minimum if (col.type === 'actions') { return 'max-content'; } // No width specified - use content-based sizing with minimum return 'minmax(80px, auto)'; }).join(' '); } /** * Updates search position to be centered with equal gaps from title and tools. * On first call: resets to flex centering, measures position, then locks with fixed margin. * Subsequent calls are ignored unless _searchPositionLocked is reset (e.g., on resize). */ _updateSearchPosition() { // Skip if already locked (prevents shifts on pagination changes) if (this._searchPositionLocked) return; const search = this.shadowRoot?.querySelector('.search'); const searchField = search?.querySelector('.search-field'); if (!search || !searchField) return; // Reset to flex centering search.style.justifyContent = 'center'; searchField.style.marginLeft = ''; requestAnimationFrame(() => { const searchRect = search.getBoundingClientRect(); const fieldRect = searchField.getBoundingClientRect(); // Calculate how far from the left of search container the field currently is const currentOffset = fieldRect.left - searchRect.left; // Lock position: switch to flex-start and use fixed margin search.style.justifyContent = 'flex-start'; searchField.style.marginLeft = `${currentOffset}px`; // Mark as locked so pagination changes don't shift the search this._searchPositionLocked = true; }); } // ---------------------------------------------------------------------------- // Columns // ---------------------------------------------------------------------------- _toggleColumnPicker() { this._columnPickerOpen = !this._columnPickerOpen; } _toggleColumn(columnId) { if (this._model.displayedColumns.includes(columnId)) { this._model.displayedColumns = this._model.displayedColumns.filter(id => id !== columnId); } else { this._model.displayedColumns = [...this._model.displayedColumns, columnId]; } this.requestUpdate(); } // Clear any existing text selection on mousedown so we only detect // selections made during this click gesture, not stale selections from elsewhere _handleRowMouseDown() { if (!this._model.rowClickable) { return; } window.getSelection()?.removeAllRanges(); } _handleRowClick(row, rowIndex) { if (!this._model.rowClickable) { return; } const selection = window.getSelection(); if (selection && selection.toString().length > 0) { return; } this.dispatchEvent(new CustomEvent('row-click', { detail: { row, rowIndex }, bubbles: true, composed: true })); } // When a user toggles a column on via the column picker, it gets appended // to _displayedColumns. By mapping over _displayedColumns (not def.columns), // the new column appears at the right edge of the table instead of jumping // back to its original position in the column definition. // Actions columns are always moved to the end. getDisplayedColumns() { return this._model.displayedColumns .map(id => this._model.columns.find(col => col.id === id)) .sort((a, b) => { if (a.type === 'actions' && b.type !== 'actions') return 1; if (a.type !== 'actions' && b.type === 'actions') return -1; return 0; }); } // ---------------------------------------------------------------------------- // Scrolling // ---------------------------------------------------------------------------- /** * Scroll event handler that updates scroll flags in real-time as user scrolls. * Updates shadow indicators to show if more content exists left/right. */ _handleScroll(e) { const container = e.target; this._canScrollLeft = container.scrollLeft > 0; this._canScrollRight = container.scrollLeft < container.scrollWidth - container.clientWidth - 1; } /** * Updates scroll state flags for the table content container. * - _canScrollLeft: true if scrolled right (can scroll back left) * - _canScrollRight: true if more content exists to the right * - _canScrollHorizontal: true if content is wider than container * These flags control scroll shadow indicators and CSS classes. */ _updateScrollFlags() { const container = this.shadowRoot?.querySelector('.content'); if (container) { this._canScrollLeft = container.scrollLeft > 0; this._canScrollRight = container.scrollWidth > container.clientWidth && container.scrollLeft < container.scrollWidth - container.clientWidth - 1; this._canScrollHorizontal = container.scrollWidth > container.clientWidth; } this.classList.toggle('kr-table--scroll-left-available', this._canScrollLeft); this.classList.toggle('kr-table--scroll-right-available', this._canScrollRight); this.classList.toggle('kr-table--scroll-horizontal-available', this._canScrollHorizontal); this.classList.toggle('kr-table--sticky-left', this.getDisplayedColumns().some(c => c.sticky === 'left')); this.classList.toggle('kr-table--sticky-right', this.getDisplayedColumns().some(c => c.sticky === 'right')); } // ---------------------------------------------------------------------------- // Column Resizing // ---------------------------------------------------------------------------- _handleResizeStart(e, columnId) { e.preventDefault(); const headerCell = this.shadowRoot?.querySelector(`.header-cell[data-column-id=\"${columnId}\"]`); this._resizing = { columnId, startX: e.clientX, startWidth: headerCell?.offsetWidth || 200 }; document.addEventListener('mousemove', this._handleResizeMove); document.addEventListener('mouseup', this._handleResizeEnd); } // ---------------------------------------------------------------------------- // Sorting // ---------------------------------------------------------------------------- _handleSortClick(e, column) { if (e.shiftKey) { // Multi-sort: add or cycle existing const existingIndex = this._sorts.findIndex(s => s.sortBy === column.id); if (existingIndex === -1) { this._sorts.push({ sortBy: column.id, sortDirection: 'asc' }); } else { const existing = this._sorts[existingIndex]; if (existing.sortDirection === 'asc') { existing.sortDirection = 'desc'; } else { // on third click, remove sorting for the column this._sorts.splice(existingIndex, 1); } } this.requestUpdate(); } else { // Single sort: replace all let existing = null; if (this._sorts.length === 1) { existing = this._sorts.find(s => s.sortBy === column.id); } if (!existing) { this._sorts = [{ sortBy: column.id, sortDirection: 'asc' }]; } else if (existing.sortDirection === 'asc') { this._sorts = [{ sortBy: column.id, sortDirection: 'desc' }]; } else { this._sorts = []; } } this._page = 1; this._fetch(); } _renderSortIndicator(column) { if (!column.sortable) { return A; } const sortIndex = this._sorts.findIndex(s => s.sortBy === column.id); if (sortIndex === -1) { // Ghost arrow: visible only on hover via CSS return b ` <span class=\"header-cell__sort\" @click=${(e) => this._handleSortClick(e, column)}> <svg class=\"header-cell__sort-arrow header-cell__sort-arrow--ghost\" viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z\"/> </svg> </span> `; } let arrowStyle = {}; if (this._sorts[sortIndex].sortDirection === 'desc') { arrowStyle = { transform: 'rotate(180deg)' }; } return b ` <span class=\"header-cell__sort\" @click=${(e) => this._handleSortClick(e, column)}> <svg class=\"header-cell__sort-arrow\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=${o$1(arrowStyle)}> <path d=\"M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z\"/> </svg> ${this._sorts.length > 1 ? b ` <span class=\"header-cell__sort-priority\">${sortIndex + 1}</span> ` : A} </span> `; } // ---------------------------------------------------------------------------- // Header // ---------------------------------------------------------------------------- _handleAction(action) { if (action.href) { return; } this.dispatchEvent(new CustomEvent('action', { detail: { action: action.id }, bubbles: true, composed: true })); } // ---------------------------------------------------------------------------- // Filter Handlers // ---------------------------------------------------------------------------- _handleKqlChange(e, column) { const kql = e.target.value.trim(); if (!kql) { column.filter.clear(); this.requestUpdate(); } else { column.filter.setKql(kql); this.requestUpdate(); if (!column.filter.isValid()) { return; } } this._page = 1; this._fetch(); } _handleFilterPanelToggle(e, column) { e.stopPropagation(); if (this._filterPanelOpened === column.id) { this._filterPanelOpened = null; } else { const rect = e.currentTarget.getBoundingClientRect(); let left = rect.left; if (left + 328 > window.innerWidth) { left = window.innerWidth - 328; } this._filterPanelPos = { top: rect.bottom + 4, left }; this._filterPanelOpened = column.id; if (column.facetable) { this._filterPanelTab = 'counts'; } else { this._filterPanelTab = 'filter'; } } } _handleKqlClear(column) { column.filter.clear(); this._page = 1; this._fetch(); } _handleFilterClear() { const column = this._model.columns.find(c => c.id === this._filterPanelOpened); if (column) { column.filter.clear(); if (column.facetable && !column.filterable) { column.filter.operator = 'in'; column.filter.value = []; } } this._filterPanelOpened = null; this._page = 1; this._fetch(); } _handleFilterTextKeydown(e, column) { if (e.key === 'Enter') { e.preventDefault(); this._handleFilterApply(); } } _handleOperatorChange(e, column) { column.filter.setOperator(e.target.value); this.requestUpdate(); } _handleFilterStringChange(e, column) { column.filter.setValue(e.target.value); this.requestUpdate(); } _handleFilterNumberChange(e, column) { column.filter.setValue(Number(e.target.value)); this.requestUpdate(); } _handleFilterDateChange(e, column) { column.filter.setValue(new Date(e.target.value), 'day'); this.requestUpdate(); } _handleFilterBooleanChange(e, column) { column.filter.setValue(e.target.value === 'true'); this.requestUpdate(); } _handleFilterDateStartChange(e, column) { column.filter.setStart(new Date(e.target.value), 'day'); this.requestUpdate(); } _handleFilterDateEndChange(e, column) { column.filter.setEnd(new Date(e.target.value), 'day'); this.requestUpdate(); } _handleFilterNumberStartChange(e, column) { column.filter.setStart(Number(e.target.value)); this.requestUpdate(); } _handleFilterNumberEndChange(e, column) { column.filter.setEnd(Number(e.target.value)); this.requestUpdate(); } _handleFilterListChange(e, column) { const items = e.target.value.split(',').map((v) => v.trim()).filter((v) => v !== ''); if (column.type === 'number') { column.filter.setValue(items.map((v) => Number(v))); } else { column.filter.setValue(items); } this.requestUpdate(); } _handleFilterApply() { this._filterPanelOpened = null; this._page = 1; this._fetch(); } _handleFilterPanelTabChange(e) { this._filterPanelTab = e.detail.activeTabId; } _handleBucketToggle(e, column, bucket) { column.filter.toggle(bucket.val); this._page = 1; this._fetch(); } // ---------------------------------------------------------------------------- // Rendering // ---------------------------------------------------------------------------- _renderCellContent(column, row, rowIndex) { const value = row[column.id]; if (column.render) { // Use slot to project content from light DOM so external styles apply return b `<slot name=\"cell-${rowIndex}-${column.id}\"></slot>`; } if (value === null || value === undefined) { return ''; } switch (column.type) { case 'number': if (column.format === 'currency' && typeof value === 'number') { return value.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); } return String(value); case 'date': { let date; if (value instanceof Date) { date = value; } else if (typeof value === 'string' && /^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}/.test(value)) { // MySQL datetime format (UTC): \"2026-01-28 01:33:44:517\" // Replace last colon before ms with dot, append Z for UTC const isoString = value.replace(/(\\d{2}:\\d{2}:\\d{2}):(\\d+)$/, '$1.$2').replace(' ', 'T') + 'Z'; date = new Date(isoString); } else { date = new Date(value); } // Show date and time for datetime values in UTC return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'UTC' }); } case 'boolean': if (value === true) return 'Yes'; if (value === false) return 'No'; return ''; default: return String(value); } } /** * Returns CSS classes for a header cell based on column config. */ _getHeaderCellClasses(column, index) { return { 'header-cell': true, 'header-cell--sortable': !!column.sortable, 'header-cell--align-center': column.align === 'center', 'header-cell--align-right': column.align === 'right', 'header-cell--sticky-left': column.sticky === 'left', 'header-cell--sticky-left-last': column.sticky === 'left' && !this.getDisplayedColumns().slice(index + 1).some(c => c.sticky === 'left'), 'header-cell--sticky-right': column.sticky === 'right', 'header-cell--sticky-right-first': column.sticky === 'right' && !this.getDisplayedColumns().slice(0, index).some(c => c.sticky === 'right') }; } /** * Returns CSS classes for a table cell based on column config: * - Alignment (center, right) * - Sticky positioning (left, right) * - Border classes for the last left-sticky or first right-sticky column */ _getCellClasses(column, index) { return { 'cell': true, 'cell--actions': column.type === 'actions', 'cell--align-center': column.align === 'center', 'cell--align-right': column.align === 'right', 'cell--sticky-left': column.sticky === 'left', 'cell--sticky-left-last': column.sticky === 'left' && !this.getDisplayedColumns().slice(index + 1).some(c => c.sticky === 'left'), 'cell--sticky-right': column.sticky === 'right', 'cell--sticky-right-first': column.sticky === 'right' && !this.getDisplayedColumns().slice(0, index).some(c => c.sticky === 'right') }; } /** * Returns inline styles for a table cell: * - Width (from column config or default 150px) * - Min-width (if specified) * - Left/right offset for sticky columns (calculated from widths of preceding sticky columns) */ _getCellStyle(column, index) { const styles = {}; if (column.sticky === 'left') { let leftOffset = 0; for (let i = 0; i < index; i++) { const col = this.getDisplayedColumns()[i]; if (col.sticky === 'left') { leftOffset += parseInt(col.width || '0', 10); } } styles.left = `${leftOffset}px`; } if (column.sticky === 'right') { let rightOffset = 0; for (let i = index + 1; i < this.getDisplayedColumns().length; i++) { const col = this.getDisplayedColumns()[i]; if (col.sticky === 'right') { rightOffset += parseInt(col.width || '0', 10); } } styles.right = `${rightOffset}px`; } return styles; } /** * Renders the pagination controls: * - Previous page arrow (disabled on first page) * - Range text showing \"1-50 of 150\" format * - Next page arrow (disabled on last page) * * Hidden when there's no data or all data fits on one page. */ _renderPagination() { const start = (this._page - 1) * this._pageSize + 1; const end = Math.min(this._page * this._pageSize, this._totalItems); return b ` <div class=\"pagination\"> <span class=\"pagination-icon ${this._page === 1 ? 'pagination-icon--disabled' : ''}\" @click=${this.goToPrevPage} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z\"/></svg> </span> <span class=\"pagination-info\">${start}-${end} of ${this._totalItems}</span> <span class=\"pagination-icon ${this._page === this._totalPages ? 'pagination-icon--disabled' : ''}\" @click=${this.goToNextPage} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z\"/></svg> </span> </div> `; } /** * Renders the header toolbar containing: * - Title (left) * - Search bar with view selector dropdown (center) * - Tools (right): page navigation, refresh button, column visibility picker, actions dropdown * * Hidden when there's no title, no actions, and data fits on one page. */ _renderHeader() { if (!this._model.title && !this._model.actions?.length && this._totalPages <= 1) { return A; } return b ` <div class=\"header\"> <div class=\"title\">${this._model.title ?? ''}</div> ${this._model.dataSource?.mode === 'db' && !this._model.columns.some(col => col.searchable) ? b `<div class=\"search\"></div>` : b ` <div class=\"search\"> <!-- TODO: Saved views dropdown <div class=\"views\"> <span>Default View</span> <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z\"/></svg> </div> --> <div class=\"search-field\"> <svg class=\"search-icon\" viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z\"/></svg> <input type=\"text\" class=\"search-input\" placeholder=\"Search...\" .value=${this._searchQuery} @input=${this._handleSearch} /> </div> </div> `} <div class=\"tools\"> ${this._renderPagination()} <span class=\"refresh\" title=\"Refresh\" @click=${() => this.refresh()}> <svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-56-87.5-88T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z\"/></svg> </span> <div class=\"column-picker-wrapper\"> <span class=\"header-icon\" title=\"Columns\" @click=${this._toggleColumnPicker}> <svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M121-280v-400q0-33 23.5-56.5T201-760h559q33 0 56.5 23.5T840-680v400q0 33-23.5 56.5T760-200H201q-33 0-56.5-23.5T121-280Zm79 0h133v-400H200v400Zm213 0h133v-400H413v400Zm213 0h133v-400H626v400Z\"/></svg> </span> <div class=\"column-picker ${this._columnPickerOpen ? 'open' : ''}\"> ${[...this._model.columns].filter(col => col.type !== 'actions').sort((a, b) => (a.label ?? a.id).localeCompare(b.label ?? b.id)).map(col => b ` <div class=\"column-picker-item\" @click=${() => this._toggleColumn(col.id)}> <div class=\"column-picker-checkbox ${this._model.displayedColumns.includes(col.id) ? 'checked' : ''}\"> <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z\"/></svg> </div> <span class=\"column-picker-label\">${col.label ?? col.id}</span> </div> `)} </div> </div> ${this._model.actions?.length === 1 ? b ` <kr-button class=\"actions\" .href=${this._model.actions[0].href} .target=${this._model.actions[0].target} @click=${() => this._handleAction(this._model.actions[0])} > ${this._model.actions[0].label} </kr-button> ` : this._model.actions?.length ? b ` <kr-button class=\"actions\" .options=${this._model.actions.map(a => ({ id: a.id, label: a.label }))} @option-select=${(e) => this._handleAction({ id: e.detail.id, label: e.detail.label })} > Actions </kr-button> ` : A} </div> </div> `; } /** Renders status message (loading, error, empty) */ _renderStatus() { if (this._dataState === 'loading' && this._data.length === 0) { return b `<div class=\"status\">Loading...</div>`; } if (this._dataState === 'error' && this._data.length === 0) { return b `<div class=\"status status--error\">Error loading data</div>`; } if (this._data.length === 0) { return b `<div class=\"status\">No data available</div>`; } return A; } _renderFilterPanel() { if (!this._filterPanelOpened) { return A; } const column = this._model.columns.find(c => c.id === this._filterPanelOpened); // Build filter content (operator + value input) let valueInput = b ``; if (column.filter.operator === 'empty' || column.filter.operator === 'n_empty') { valueInput = b ` <input type=\"text\" class=\"filter-panel__input\" disabled .value=${column.filter.text} /> `; } else if (column.filter.operator === 'between' && column.type === 'date') { valueInput = b ` <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${column.filter.value?.start ?? null} @change=${(e) => this._handleFilterDateStartChange(e, column)} /> <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${column.filter.value?.end ?? null} @change=${(e) => this._handleFilterDateEndChange(e, column)} /> `; } else if (column.filter.operator === 'between' && column.type === 'number') { valueInput = b ` <input type=\"number\" class=\"filter-panel__input\" placeholder=\"Start\" .value=${column.filter.value?.start ?? ''} @input=${(e) => this._handleFilterNumberStartChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} /> <input type=\"number\" class=\"filter-panel__input\" placeholder=\"End\" .value=${column.filter.value?.end ?? ''} @input=${(e) => this._handleFilterNumberEndChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} /> `; } else if (column.filter.operator === 'in' || column.filter.operator === 'n_in') { valueInput = b ` <textarea class=\"filter-panel__textarea\" rows=\"3\" placeholder=\"Values (comma-separated)\" .value=${column.filter.text} @input=${(e) => this._handleFilterListChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} ></textarea> `; } else if (column.type === 'boolean') { valueInput = b ` <kr-select-field placeholder=\"Value\" .value=${String(column.filter.value ?? '')} @change=${(e) => this._handleFilterBooleanChange(e, column)} > <kr-select-option value=\"true\">Yes</kr-select-option> <kr-select-option value=\"false\">No</kr-select-option> </kr-select-field> `; } else if (column.type === 'date') { valueInput = b ` <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${column.filter.value} @change=${(e) => this._handleFilterDateChange(e, column)} /> `; } else if (column.type === 'number') { valueInput = b ` <input type=\"number\" class=\"filter-panel__input\" placeholder=\"Value\" min=\"0\" .value=${column.filter.text} @input=${(e) => this._handleFilterNumberChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} /> `; } else { valueInput = b ` <input type=\"text\" class=\"filter-panel__input\" placeholder=\"Value\" .value=${column.filter.text} @input=${(e) => this._handleFilterStringChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} /> `; } const filterContent = b ` <div class=\"filter-panel__content\"> <kr-select-field .value=${column.filter.operator} @change=${(e) => this._handleOperatorChange(e, column)} > ${getOperatorsForType(column.type).map(op => b ` <kr-select-option value=${op.key}>${op.label}</kr-select-option> `)} </kr-select-field> ${valueInput} </div> `; // Build bucket list content const buckets = this._buckets.get(column.id) || []; let bucketContent; if (!buckets.length) { bucketContent = b `<div class=\"bucket-empty\">No data</div>`; } else { bucketContent = b ` <div class=\"buckets\"> ${buckets.map(bucket => { let bucketLabel = '(Empty)'; if (bucket.val !== null && bucket.val !== undefined) { if (column.type === 'boolean') { if (bucket.val === true || bucket.val === 'true') { bucketLabel = 'Yes'; } else { bucketLabel = 'No'; } } else { bucketLabel = String(bucket.val); } } let checkIcon = A; if (column.filter.has(bucket.val)) { checkIcon = b ` <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z\"/> </svg> `; } return b ` <div class=\"bucket\" @click=${(e) => this._handleBucketToggle(e, column, bucket)} > <div class=${e$1({ 'bucket__checkbox': true, 'bucket__checkbox--checked': column.filter.has(bucket.val) })}> ${checkIcon} </div> <span class=\"bucket__label\">${bucketLabel}</span> <span class=\"bucket__count\">${bucket.count}</span> </div> `; })} </div> `; } // Build panel body — tabs if both filterable+facetable, otherwise just the relevant content let panelBody; if (column.facetable && column.filterable) { panelBody = b ` <kr-tab-group size=\"small\" active-tab-id=${this._filterPanelTab} @tab-change=${(e) => this._handleFilterPanelTabChange(e)} > <kr-tab id=\"filter\" label=\"Filter\"> ${filterContent} </kr-tab> <kr-tab id=\"counts\" label=\"Counts\"> ${bucketContent} </kr-tab> </kr-tab-group> `; } else if (column.facetable) { panelBody = bucketContent; } else { panelBody = filterContent; } return b ` <div class=\"filter-panel\" style=${o$1({ top: this._filterPanelPos.top + 'px', left: this._filterPanelPos.left + 'px' })} > ${panelBody} <div class=\"filter-panel__actions\"> <kr-button variant=\"outline\" color=\"secondary\" size=\"small\" @click=${this._handleFilterClear}> Clear </kr-button> <kr-button size=\"small\" @click=${this._handleFilterApply}> Apply </kr-button> </div> </div> `; } /** * Renders filter row below column headers. * Only displays for columns with filterable: true. */ _renderFilterRow() { const columns = this.getDisplayedColumns(); if (!columns.some(col => col.filterable || col.facetable)) { return A; } return b ` <div class=\"filter-row\"> ${columns.map((col, i) => { if (!col.filterable && !col.facetable) { return b `<div class=${e$1({ 'filter-cell': true, 'filter-cell--sticky-left': col.sticky === 'left', 'filter-cell--sticky-right': col.sticky === 'right', 'filter-cell--sticky-right-first': col.sticky === 'right' && !columns.slice(0, i).some((c) => c.sticky === 'right') })} style=${o$1(this._getCellStyle(col, i))} ></div>`; } return b ` <div class=${e$1({ 'filter-cell': true, 'filter-cell--sticky-left': col.sticky === 'left', 'filter-cell--sticky-right': col.sticky === 'right', 'filter-cell--sticky-right-first': col.sticky === 'right' && !columns.slice(0, i).some((c) => c.sticky === 'right') })} style=${o$1(this._getCellStyle(col, i))} > <div class=\"filter-cell__wrapper\"> <input type=\"text\" class=${e$1({ 'filter-cell__input': true, 'filter-cell__input--invalid': !col.filter.isValid() })} .value=${col.filter.kql} @change=${(e) => this._handleKqlChange(e, col)} /> ${col.filter?.kql?.length > 0 ? b ` <button class=\"filter-cell__clear\" @click=${() => this._handleKqlClear(col)} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z\"/> </svg> </button> ` : A} <button class=${e$1({ 'filter-cell__advanced': true, 'filter-cell__advanced--opened': this._filterPanelOpened === col.id })} @click=${(e) => this._handleFilterPanelToggle(e, col)} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z\"/> </svg> </button> </div> </div> `; })} </div> `; } /** Renders the scrollable data grid with column headers and rows. */ _renderTable() { return b ` <div class=\"wrapper\"> <div class=\"overlay-left\"></div> <div class=\"overlay-right\"></div> ${this._renderStatus()} <div class=\"content\" @scroll=${this._handleScroll}> <div class=\"table\" style=\"grid-template-columns: ${this._getGridTemplateColumns()}\"> <div class=\"header-row\"> ${this.getDisplayedColumns().map((col, i) => b ` <div class=${e$1(this._getHeaderCellClasses(col, i))} style=${o$1(this._getCellStyle(col, i))} data-column-id=${col.id} > <span class=\"header-cell__label\">${col.label ?? col.id}</span> ${this._renderSortIndicator(col)} ${col.resizable !== false ? b `<div class=\"header-cell__resize\" @mousedown=${(e) => this._handleResizeStart(e, col.id)} ></div>` : A} </div> `)} </div> ${this._renderFilterRow()} ${this._data.map((row, rowIndex) => { const cells = this.getDisplayedColumns().map((col, i) => b ` <div class=${e$1(this._getCellClasses(col, i))} style=${o$1(this._getCellStyle(col, i))} data-column-id=${col.id} > ${this._renderCellContent(col, row, rowIndex)} </div> `); if (this._model.rowHref) { return b ` <a href=${this._model.rowHref(row)} class=${e$1({ 'row': true, 'row--clickable': true, 'row--link': true })} @mousedown=${() => this._handleRowMouseDown()} @click=${() => this._handleRowClick(row, rowIndex)} >${cells}</a> `; } return b ` <div class=${e$1({ 'row': true, 'row--clickable': !!this._model.rowClickable })} @mousedown=${() => this._handleRowMouseDown()} @click=${() => this._handleRowClick(row, rowIndex)} >${cells}</div> `; })} </div> </div> </div> `; } /** * Renders a data table with: * - Header bar with title, search input with view selector, and tools (pagination, refresh, column visibility, actions dropdown) * - Scrollable grid with sticky header row and optional sticky left/right columns * - Loading, error message, or empty state when no data */ render() { if (!this._model.columns.length) { return b `<slot></slot>`; } return b ` ${this._renderHeader()} ${this._renderTable()} ${this._renderFilterPanel()} `; } }"
577
577
  },
578
578
  {
579
579
  "kind": "variable",
@@ -968,7 +968,7 @@
968
968
  "type": {
969
969
  "text": "object"
970
970
  },
971
- "default": "{equals:{key:\"equals\",type:\"comparison\",dataTypes:[\"string\",\"number\",\"date\",\"boolean\"],label:\"Equals\"},n_equals:{key:\"n_equals\",type:\"comparison\",dataTypes:[\"string\",\"number\",\"date\",\"boolean\"],label:\"Doesn't equal\"},contains:{key:\"contains\",type:\"comparison\",dataTypes:[\"string\"],label:\"Contains\"},n_contains:{key:\"n_contains\",type:\"comparison\",dataTypes:[\"string\"],label:\"Doesn't contain\"},starts_with:{key:\"starts_with\",type:\"comparison\",dataTypes:[\"string\"],label:\"Starts with\"},ends_with:{key:\"ends_with\",type:\"comparison\",dataTypes:[\"string\"],label:\"Ends with\"},less_than:{key:\"less_than\",type:\"comparison\",dataTypes:[\"number\",\"date\"],label:\"Less than\"},less_than_equal:{key:\"less_than_equal\",type:\"comparison\",dataTypes:[\"number\",\"date\"],label:\"Less than or equal\"},greater_than:{key:\"greater_than\",type:\"comparison\",dataTypes:[\"number\",\"date\"],label:\"Greater than\"},greater_than_equal:{key:\"greater_than_equal\",type:\"comparison\",dataTypes:[\"number\",\"date\"],label:\"Greater than or equal\"},between:{key:\"between\",type:\"range\",dataTypes:[\"number\",\"date\"],label:\"Between\"},in:{key:\"in\",type:\"list\",dataTypes:[\"string\",\"number\"],label:\"In\"},empty:{key:\"empty\",type:\"nil\",dataTypes:[\"string\",\"number\",\"date\",\"boolean\"],label:\"Empty\"},n_empty:{key:\"n_empty\",type:\"nil\",dataTypes:[\"string\",\"number\",\"date\",\"boolean\"],label:\"Not empty\"}}"
971
+ "default": "{equals:{key:\"equals\",type:\"comparison\",dataTypes:[\"string\",\"number\",\"date\",\"boolean\"],label:\"Equals\"},n_equals:{key:\"n_equals\",type:\"comparison\",dataTypes:[\"string\",\"number\",\"date\",\"boolean\"],label:\"Doesn't equal\"},contains:{key:\"contains\",type:\"comparison\",dataTypes:[\"string\"],label:\"Contains\"},n_contains:{key:\"n_contains\",type:\"comparison\",dataTypes:[\"string\"],label:\"Doesn't contain\"},starts_with:{key:\"starts_with\",type:\"comparison\",dataTypes:[\"string\"],label:\"Starts with\"},ends_with:{key:\"ends_with\",type:\"comparison\",dataTypes:[\"string\"],label:\"Ends with\"},less_than:{key:\"less_than\",type:\"comparison\",dataTypes:[\"number\",\"date\"],label:\"Less than\"},less_than_equal:{key:\"less_than_equal\",type:\"comparison\",dataTypes:[\"number\",\"date\"],label:\"Less than or equal\"},greater_than:{key:\"greater_than\",type:\"comparison\",dataTypes:[\"number\",\"date\"],label:\"Greater than\"},greater_than_equal:{key:\"greater_than_equal\",type:\"comparison\",dataTypes:[\"number\",\"date\"],label:\"Greater than or equal\"},between:{key:\"between\",type:\"range\",dataTypes:[\"number\",\"date\"],label:\"Between\"},in:{key:\"in\",type:\"list\",dataTypes:[\"string\",\"number\"],label:\"In\"},n_in:{key:\"n_in\",type:\"list\",dataTypes:[\"string\",\"number\",\"date\",\"boolean\"],label:\"Not In\"},empty:{key:\"empty\",type:\"nil\",dataTypes:[\"string\",\"number\",\"date\",\"boolean\"],label:\"Empty\"},n_empty:{key:\"n_empty\",type:\"nil\",dataTypes:[\"string\",\"number\",\"date\",\"boolean\"],label:\"Not empty\"}}"
972
972
  },
973
973
  {
974
974
  "kind": "function",
@@ -1172,7 +1172,7 @@
1172
1172
  {
1173
1173
  "kind": "variable",
1174
1174
  "name": "pt",
1175
- "default": "class extends le{constructor(){super(...arguments),this._scrollStyle=\"overlay\",this._data=[],this._dataState=\"idle\",this._page=1,this._pageSize=50,this._totalItems=0,this._totalPages=0,this._searchQuery=\"\",this._canScrollLeft=!1,this._canScrollRight=!1,this._canScrollHorizontal=!1,this._columnPickerOpen=!1,this._filterPanelOpened=null,this._filterPanelTab=\"filter\",this._buckets=new Map,this._filterPanelPos={top:0,left:0},this._sorts=[],this._resizing=null,this._resizeObserver=null,this._searchPositionLocked=!1,this._model=new ct,this.def={columns:[]},this._handleClickOutside=e=>{const t=e.composedPath();if(this._columnPickerOpen){const e=this.shadowRoot?.querySelector(\".column-picker-wrapper\");e&&!t.includes(e)&&(this._columnPickerOpen=!1)}this._filterPanelOpened&&(t.some((e=>e.classList?.contains(\"filter-panel\")))||this._handleFilterApply())},this._handleResizeMove=e=>{if(!this._resizing)return;const t=this._model.columns.find((e=>e.id===this._resizing.columnId));if(t){const i=this._resizing.startWidth+(e.clientX-this._resizing.startX);t.width=`${Math.min(900,Math.max(50,i))}px`,this.requestUpdate()}},this._handleResizeEnd=()=>{this._resizing=null,document.removeEventListener(\"mousemove\",this._handleResizeMove),document.removeEventListener(\"mouseup\",this._handleResizeEnd)}}connectedCallback(){super.connectedCallback(),this.classList.toggle(\"kr-table--scroll-overlay\",\"overlay\"===this._scrollStyle),this.classList.toggle(\"kr-table--scroll-edge\",\"edge\"===this._scrollStyle),this._fetch(),this._initRefresh(),document.addEventListener(\"click\",this._handleClickOutside),this._resizeObserver=new ResizeObserver((()=>{this._searchPositionLocked=!1,this._updateSearchPosition()})),this._resizeObserver.observe(this)}disconnectedCallback(){super.disconnectedCallback(),clearInterval(this._refreshTimer),document.removeEventListener(\"click\",this._handleClickOutside),this._resizeObserver?.disconnect()}willUpdate(e){e.has(\"def\")&&(this._model=new ct,this.def.title&&(this._model.title=this.def.title),this.def.actions&&(this._model.actions=this.def.actions),this.def.data&&(this._model.data=this.def.data),this.def.dataSource&&(this._model.dataSource=this.def.dataSource),\"number\"==typeof this.def.refreshInterval&&(this._model.refreshInterval=this.def.refreshInterval),\"number\"==typeof this.def.pageSize&&(this._model.pageSize=this.def.pageSize),this.def.rowClickable&&(this._model.rowClickable=this.def.rowClickable),this.def.rowHref&&(this._model.rowHref=this.def.rowHref),this._model.columns=this.def.columns.map((e=>{const t={...e,filter:null};return t.type||(t.type=\"string\"),\"actions\"===t.type?(t.label=e.label??\"\",t.sticky=\"right\",t.resizable=!1,t):((e.filterable||e.facetable)&&(t.filter=new dt,t.filter.field=e.id,t.filter.type=t.type,e.filter?(t.filter.setOperator(e.filter.operator),t.filter.setValue(e.filter.value)):e.facetable&&!e.filterable?(t.filter.operator=\"in\",t.filter.value=[]):\"string\"===t.filter.type&&(t.filter.operator=\"contains\")),t)})),this.def.displayedColumns?this._model.displayedColumns=this.def.displayedColumns:this._model.displayedColumns=this._model.columns.map((e=>e.id)),this._fetch(),this._initRefresh())}updated(e){this._updateScrollFlags(),this._syncSlottedContent()}_syncSlottedContent(){const e=this.getDisplayedColumns().filter((e=>e.render));e.length&&(this.querySelectorAll('[slot^=\"cell-\"]').forEach((e=>e.remove())),this._data.forEach(((t,i)=>{e.forEach((e=>{const s=e.render(t);if(!s)return;const o=document.createElement(\"span\");o.slot=`cell-${i}-${e.id}`,\"actions\"===e.type&&(o.style.display=\"flex\",o.style.gap=\"8px\"),\"string\"==typeof s?o.innerHTML=s:oe(s,o),this.appendChild(o)}))})))}refresh(){this._fetch()}goToPrevPage(){this._page>1&&(this._page--,this._fetch())}goToNextPage(){this._page<this._totalPages&&(this._page++,this._fetch())}goToPage(e){e>=1&&e<=this._totalPages&&(this._page=e,this._fetch())}_toSolrData(){const e={page:this._page-1,size:this._pageSize,sorts:this._sorts,filterFields:[],queryFields:[],facetFields:[]};for(const t of this._model.columns){if(!t.filter||t.filter.isEmpty()||!t.filter.isValid())continue;const i=t.filter.toSolrData();t.facetable&&\"in\"===t.filter.operator&&(i.tagged=!0),e.filterFields.push(i)}for(const t of this._model.columns)t.facetable&&e.facetFields.push({name:t.id,type:\"FIELD\",limit:100,sort:\"count\",minimumCount:1});return this._searchQuery?.trim().length&&e.queryFields.push({name:\"_text_\",operation:\"IS\",value:ot(this._searchQuery,!1)}),e}_toDbParams(){const e={page:this._page-1,size:this._pageSize,sorts:this._sorts,filterFields:[],queryFields:[],facetFields:[]};for(const t of this._model.columns)t.filter&&!t.filter.isEmpty()&&t.filter.isValid()&&e.filterFields.push(t.filter.toDbParams());return this._searchQuery?.trim().length&&this._model.columns.filter((e=>e.searchable)).forEach((t=>{e.queryFields.push({name:t.id,operation:\"CONTAINS\",value:this._searchQuery,and:!1})})),e}_fetch(){if(this._model.data)return this._data=this._model.data,this._totalItems=this._model.data.length,this._totalPages=Math.ceil(this._model.data.length/this._pageSize),void(this._dataState=\"success\");if(!this._model.dataSource)return;let e;this._dataState=\"loading\",e=\"db\"===this._model.dataSource.mode?this._toDbParams():this._toSolrData(),this._model.dataSource.fetch(e).then((e=>{switch(this._model.dataSource?.mode){case\"opensearch\":throw Error(\"Opensearch not supported yet\");case\"db\":{const t=e;this._data=t.data.content,this._totalItems=t.data.totalElements,this._totalPages=t.data.totalPages,this._pageSize=t.data.size;break}default:{const t=e;this._data=t.data.content,this._totalItems=t.data.totalElements,this._totalPages=t.data.totalPages,this._pageSize=t.data.size,this._parseFacetResults(t)}}this._dataState=\"success\",this._updateSearchPosition()})).catch((e=>{this._dataState=\"error\",Fe.show({message:e instanceof Error?e.message:\"Failed to load data\",type:\"error\"})}))}_parseFacetResults(e){if(e.data.facetFields){for(const t of this._model.columns){if(!t.facetable)continue;const i=e.data.facetFields[t.id];if(!i){this._buckets.set(t.id,[]);continue}const s=[];for(const e of i){let i=e.name;\"boolean\"===t.type&&\"string\"==typeof e.name&&(\"true\"===e.name?i=!0:\"false\"===e.name&&(i=!1)),null===e.name&&e.count>0&&s.unshift({val:null,count:e.count}),null!==e.name&&s.push({val:i,count:e.count})}if(t.filter&&\"in\"===t.filter.operator&&Array.isArray(t.filter.value))for(const e of t.filter.value)s.some((t=>t.val===e))||s.push({val:e,count:0});this._buckets.set(t.id,s)}this._buckets=new Map(this._buckets)}}_initRefresh(){clearInterval(this._refreshTimer),this._model.refreshInterval&&this._model.refreshInterval>0&&(this._refreshTimer=window.setInterval((()=>{this._fetch()}),this._model.refreshInterval))}_handleSearch(e){const t=e.target;this._searchQuery=t.value,this._page=1,this._fetch()}_getGridTemplateColumns(){return this.getDisplayedColumns().map((e=>e.width?e.width:\"actions\"===e.type?\"max-content\":\"minmax(80px, auto)\")).join(\" \")}_updateSearchPosition(){if(this._searchPositionLocked)return;const e=this.shadowRoot?.querySelector(\".search\"),t=e?.querySelector(\".search-field\");e&&t&&(e.style.justifyContent=\"center\",t.style.marginLeft=\"\",requestAnimationFrame((()=>{const i=e.getBoundingClientRect(),s=t.getBoundingClientRect().left-i.left;e.style.justifyContent=\"flex-start\",t.style.marginLeft=`${s}px`,this._searchPositionLocked=!0})))}_toggleColumnPicker(){this._columnPickerOpen=!this._columnPickerOpen}_toggleColumn(e){this._model.displayedColumns.includes(e)?this._model.displayedColumns=this._model.displayedColumns.filter((t=>t!==e)):this._model.displayedColumns=[...this._model.displayedColumns,e],this.requestUpdate()}_handleRowMouseDown(){this._model.rowClickable&&window.getSelection()?.removeAllRanges()}_handleRowClick(e,t){if(!this._model.rowClickable)return;const i=window.getSelection();i&&i.toString().length>0||this.dispatchEvent(new CustomEvent(\"row-click\",{detail:{row:e,rowIndex:t},bubbles:!0,composed:!0}))}getDisplayedColumns(){return this._model.displayedColumns.map((e=>this._model.columns.find((t=>t.id===e)))).sort(((e,t)=>\"actions\"===e.type&&\"actions\"!==t.type?1:\"actions\"!==e.type&&\"actions\"===t.type?-1:0))}_handleScroll(e){const t=e.target;this._canScrollLeft=t.scrollLeft>0,this._canScrollRight=t.scrollLeft<t.scrollWidth-t.clientWidth-1}_updateScrollFlags(){const e=this.shadowRoot?.querySelector(\".content\");e&&(this._canScrollLeft=e.scrollLeft>0,this._canScrollRight=e.scrollWidth>e.clientWidth&&e.scrollLeft<e.scrollWidth-e.clientWidth-1,this._canScrollHorizontal=e.scrollWidth>e.clientWidth),this.classList.toggle(\"kr-table--scroll-left-available\",this._canScrollLeft),this.classList.toggle(\"kr-table--scroll-right-available\",this._canScrollRight),this.classList.toggle(\"kr-table--scroll-horizontal-available\",this._canScrollHorizontal),this.classList.toggle(\"kr-table--sticky-left\",this.getDisplayedColumns().some((e=>\"left\"===e.sticky))),this.classList.toggle(\"kr-table--sticky-right\",this.getDisplayedColumns().some((e=>\"right\"===e.sticky)))}_handleResizeStart(e,t){e.preventDefault();const i=this.shadowRoot?.querySelector(`.header-cell[data-column-id=\"${t}\"]`);this._resizing={columnId:t,startX:e.clientX,startWidth:i?.offsetWidth||200},document.addEventListener(\"mousemove\",this._handleResizeMove),document.addEventListener(\"mouseup\",this._handleResizeEnd)}_handleSortClick(e,t){if(e.shiftKey){const e=this._sorts.findIndex((e=>e.sortBy===t.id));if(-1===e)this._sorts.push({sortBy:t.id,sortDirection:\"asc\"});else{const t=this._sorts[e];\"asc\"===t.sortDirection?t.sortDirection=\"desc\":this._sorts.splice(e,1)}this.requestUpdate()}else{let e=null;1===this._sorts.length&&(e=this._sorts.find((e=>e.sortBy===t.id))),e?\"asc\"===e.sortDirection?this._sorts=[{sortBy:t.id,sortDirection:\"desc\"}]:this._sorts=[]:this._sorts=[{sortBy:t.id,sortDirection:\"asc\"}]}this._page=1,this._fetch()}_renderSortIndicator(e){if(!e.sortable)return U;const t=this._sorts.findIndex((t=>t.sortBy===e.id));if(-1===t)return V` <span class=\"header-cell__sort\" @click=${t=>this._handleSortClick(t,e)}> <svg class=\"header-cell__sort-arrow header-cell__sort-arrow--ghost\" viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z\"/> </svg> </span> `;let i={};return\"desc\"===this._sorts[t].sortDirection&&(i={transform:\"rotate(180deg)\"}),V` <span class=\"header-cell__sort\" @click=${t=>this._handleSortClick(t,e)}> <svg class=\"header-cell__sort-arrow\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=${qe(i)}> <path d=\"M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z\"/> </svg> ${this._sorts.length>1?V` <span class=\"header-cell__sort-priority\">${t+1}</span> `:U} </span> `}_handleAction(e){e.href||this.dispatchEvent(new CustomEvent(\"action\",{detail:{action:e.id},bubbles:!0,composed:!0}))}_handleKqlChange(e,t){const i=e.target.value.trim();if(i){if(t.filter.setKql(i),this.requestUpdate(),!t.filter.isValid())return}else t.filter.clear(),this.requestUpdate();this._page=1,this._fetch()}_handleFilterPanelToggle(e,t){if(e.stopPropagation(),this._filterPanelOpened===t.id)this._filterPanelOpened=null;else{const i=e.currentTarget.getBoundingClientRect();let s=i.left;s+328>window.innerWidth&&(s=window.innerWidth-328),this._filterPanelPos={top:i.bottom+4,left:s},this._filterPanelOpened=t.id,t.facetable?this._filterPanelTab=\"counts\":this._filterPanelTab=\"filter\"}}_handleKqlClear(e){e.filter.clear(),this._page=1,this._fetch()}_handleFilterClear(){const e=this._model.columns.find((e=>e.id===this._filterPanelOpened));e&&(e.filter.clear(),e.facetable&&!e.filterable&&(e.filter.operator=\"in\",e.filter.value=[])),this._filterPanelOpened=null,this._page=1,this._fetch()}_handleFilterTextKeydown(e,t){\"Enter\"===e.key&&(e.preventDefault(),this._handleFilterApply())}_handleOperatorChange(e,t){t.filter.setOperator(e.target.value),this.requestUpdate()}_handleFilterStringChange(e,t){t.filter.setValue(e.target.value),this.requestUpdate()}_handleFilterNumberChange(e,t){t.filter.setValue(Number(e.target.value)),this.requestUpdate()}_handleFilterDateChange(e,t){t.filter.setValue(new Date(e.target.value),\"day\"),this.requestUpdate()}_handleFilterBooleanChange(e,t){t.filter.setValue(\"true\"===e.target.value),this.requestUpdate()}_handleFilterDateStartChange(e,t){t.filter.setStart(new Date(e.target.value),\"day\"),this.requestUpdate()}_handleFilterDateEndChange(e,t){t.filter.setEnd(new Date(e.target.value),\"day\"),this.requestUpdate()}_handleFilterNumberStartChange(e,t){t.filter.setStart(Number(e.target.value)),this.requestUpdate()}_handleFilterNumberEndChange(e,t){t.filter.setEnd(Number(e.target.value)),this.requestUpdate()}_handleFilterListChange(e,t){const i=e.target.value.split(\",\").map((e=>e.trim())).filter((e=>\"\"!==e));\"number\"===t.type?t.filter.setValue(i.map((e=>Number(e)))):t.filter.setValue(i),this.requestUpdate()}_handleFilterApply(){this._filterPanelOpened=null,this._page=1,this._fetch()}_handleFilterPanelTabChange(e){this._filterPanelTab=e.detail.activeTabId}_handleBucketToggle(e,t,i){t.filter.toggle(i.val),this._page=1,this._fetch()}_renderCellContent(e,t,i){const s=t[e.id];if(e.render)return V`<slot name=\"cell-${i}-${e.id}\"></slot>`;if(null==s)return\"\";switch(e.type){case\"number\":return\"currency\"===e.format&&\"number\"==typeof s?s.toLocaleString(\"en-US\",{style:\"currency\",currency:\"USD\"}):String(s);case\"date\":{let e;if(s instanceof Date)e=s;else if(\"string\"==typeof s&&/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}/.test(s)){const t=s.replace(/(\\d{2}:\\d{2}:\\d{2}):(\\d+)$/,\"$1.$2\").replace(\" \",\"T\")+\"Z\";e=new Date(t)}else e=new Date(s);return e.toLocaleString(void 0,{year:\"numeric\",month:\"short\",day:\"numeric\",hour:\"numeric\",minute:\"2-digit\",timeZone:\"UTC\"})}case\"boolean\":return!0===s?\"Yes\":!1===s?\"No\":\"\";default:return String(s)}}_getHeaderCellClasses(e,t){return{\"header-cell\":!0,\"header-cell--sortable\":!!e.sortable,\"header-cell--align-center\":\"center\"===e.align,\"header-cell--align-right\":\"right\"===e.align,\"header-cell--sticky-left\":\"left\"===e.sticky,\"header-cell--sticky-left-last\":\"left\"===e.sticky&&!this.getDisplayedColumns().slice(t+1).some((e=>\"left\"===e.sticky)),\"header-cell--sticky-right\":\"right\"===e.sticky,\"header-cell--sticky-right-first\":\"right\"===e.sticky&&!this.getDisplayedColumns().slice(0,t).some((e=>\"right\"===e.sticky))}}_getCellClasses(e,t){return{cell:!0,\"cell--actions\":\"actions\"===e.type,\"cell--align-center\":\"center\"===e.align,\"cell--align-right\":\"right\"===e.align,\"cell--sticky-left\":\"left\"===e.sticky,\"cell--sticky-left-last\":\"left\"===e.sticky&&!this.getDisplayedColumns().slice(t+1).some((e=>\"left\"===e.sticky)),\"cell--sticky-right\":\"right\"===e.sticky,\"cell--sticky-right-first\":\"right\"===e.sticky&&!this.getDisplayedColumns().slice(0,t).some((e=>\"right\"===e.sticky))}}_getCellStyle(e,t){const i={};if(\"left\"===e.sticky){let e=0;for(let i=0;i<t;i++){const t=this.getDisplayedColumns()[i];\"left\"===t.sticky&&(e+=parseInt(t.width||\"0\",10))}i.left=`${e}px`}if(\"right\"===e.sticky){let e=0;for(let i=t+1;i<this.getDisplayedColumns().length;i++){const t=this.getDisplayedColumns()[i];\"right\"===t.sticky&&(e+=parseInt(t.width||\"0\",10))}i.right=`${e}px`}return i}_renderPagination(){const e=(this._page-1)*this._pageSize+1,t=Math.min(this._page*this._pageSize,this._totalItems);return V` <div class=\"pagination\"> <span class=\"pagination-icon ${1===this._page?\"pagination-icon--disabled\":\"\"}\" @click=${this.goToPrevPage} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z\"/></svg> </span> <span class=\"pagination-info\">${e}-${t} of ${this._totalItems}</span> <span class=\"pagination-icon ${this._page===this._totalPages?\"pagination-icon--disabled\":\"\"}\" @click=${this.goToNextPage} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z\"/></svg> </span> </div> `}_renderHeader(){return!this._model.title&&!this._model.actions?.length&&this._totalPages<=1?U:V` <div class=\"header\"> <div class=\"title\">${this._model.title??\"\"}</div> ${\"db\"!==this._model.dataSource?.mode||this._model.columns.some((e=>e.searchable))?V` <div class=\"search\"> <!-- TODO: Saved views dropdown <div class=\"views\"> <span>Default View</span> <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z\"/></svg> </div> --> <div class=\"search-field\"> <svg class=\"search-icon\" viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z\"/></svg> <input type=\"text\" class=\"search-input\" placeholder=\"Search...\" .value=${this._searchQuery} @input=${this._handleSearch} /> </div> </div> `:V`<div class=\"search\"></div>`} <div class=\"tools\"> ${this._renderPagination()} <span class=\"refresh\" title=\"Refresh\" @click=${()=>this.refresh()}> <svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-56-87.5-88T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z\"/></svg> </span> <div class=\"column-picker-wrapper\"> <span class=\"header-icon\" title=\"Columns\" @click=${this._toggleColumnPicker}> <svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M121-280v-400q0-33 23.5-56.5T201-760h559q33 0 56.5 23.5T840-680v400q0 33-23.5 56.5T760-200H201q-33 0-56.5-23.5T121-280Zm79 0h133v-400H200v400Zm213 0h133v-400H413v400Zm213 0h133v-400H626v400Z\"/></svg> </span> <div class=\"column-picker ${this._columnPickerOpen?\"open\":\"\"}\"> ${[...this._model.columns].filter((e=>\"actions\"!==e.type)).sort(((e,t)=>(e.label??e.id).localeCompare(t.label??t.id))).map((e=>V` <div class=\"column-picker-item\" @click=${()=>this._toggleColumn(e.id)}> <div class=\"column-picker-checkbox ${this._model.displayedColumns.includes(e.id)?\"checked\":\"\"}\"> <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z\"/></svg> </div> <span class=\"column-picker-label\">${e.label??e.id}</span> </div> `))} </div> </div> ${1===this._model.actions?.length?V` <kr-button class=\"actions\" .href=${this._model.actions[0].href} .target=${this._model.actions[0].target} @click=${()=>this._handleAction(this._model.actions[0])} > ${this._model.actions[0].label} </kr-button> `:this._model.actions?.length?V` <kr-button class=\"actions\" .options=${this._model.actions.map((e=>({id:e.id,label:e.label})))} @option-select=${e=>this._handleAction({id:e.detail.id,label:e.detail.label})} > Actions </kr-button> `:U} </div> </div> `}_renderStatus(){return\"loading\"===this._dataState&&0===this._data.length?V`<div class=\"status\">Loading...</div>`:\"error\"===this._dataState&&0===this._data.length?V`<div class=\"status status--error\">Error loading data</div>`:0===this._data.length?V`<div class=\"status\">No data available</div>`:U}_renderFilterPanel(){if(!this._filterPanelOpened)return U;const e=this._model.columns.find((e=>e.id===this._filterPanelOpened));let t=V``;t=\"empty\"===e.filter.operator||\"n_empty\"===e.filter.operator?V` <input type=\"text\" class=\"filter-panel__input\" disabled .value=${e.filter.text} /> `:\"between\"===e.filter.operator&&\"date\"===e.type?V` <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${e.filter.value?.start??null} @change=${t=>this._handleFilterDateStartChange(t,e)} /> <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${e.filter.value?.end??null} @change=${t=>this._handleFilterDateEndChange(t,e)} /> `:\"between\"===e.filter.operator&&\"number\"===e.type?V` <input type=\"number\" class=\"filter-panel__input\" placeholder=\"Start\" .value=${e.filter.value?.start??\"\"} @input=${t=>this._handleFilterNumberStartChange(t,e)} @keydown=${t=>this._handleFilterTextKeydown(t,e)} /> <input type=\"number\" class=\"filter-panel__input\" placeholder=\"End\" .value=${e.filter.value?.end??\"\"} @input=${t=>this._handleFilterNumberEndChange(t,e)} @keydown=${t=>this._handleFilterTextKeydown(t,e)} /> `:\"in\"===e.filter.operator?V` <textarea class=\"filter-panel__textarea\" rows=\"3\" placeholder=\"Values (comma-separated)\" .value=${e.filter.text} @input=${t=>this._handleFilterListChange(t,e)} @keydown=${t=>this._handleFilterTextKeydown(t,e)} ></textarea> `:\"boolean\"===e.type?V` <kr-select-field placeholder=\"Value\" .value=${String(e.filter.value??\"\")} @change=${t=>this._handleFilterBooleanChange(t,e)} > <kr-select-option value=\"true\">Yes</kr-select-option> <kr-select-option value=\"false\">No</kr-select-option> </kr-select-field> `:\"date\"===e.type?V` <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${e.filter.value} @change=${t=>this._handleFilterDateChange(t,e)} /> `:\"number\"===e.type?V` <input type=\"number\" class=\"filter-panel__input\" placeholder=\"Value\" min=\"0\" .value=${e.filter.text} @input=${t=>this._handleFilterNumberChange(t,e)} @keydown=${t=>this._handleFilterTextKeydown(t,e)} /> `:V` <input type=\"text\" class=\"filter-panel__input\" placeholder=\"Value\" .value=${e.filter.text} @input=${t=>this._handleFilterStringChange(t,e)} @keydown=${t=>this._handleFilterTextKeydown(t,e)} /> `;const i=V` <div class=\"filter-panel__content\"> <kr-select-field .value=${e.filter.operator} @change=${t=>this._handleOperatorChange(t,e)} > ${tt(e.type).map((e=>V` <kr-select-option value=${e.key}>${e.label}</kr-select-option> `))} </kr-select-field> ${t} </div> `,s=this._buckets.get(e.id)||[];let o,r;return o=s.length?V` <div class=\"buckets\"> ${s.map((t=>{let i=\"(Empty)\";null!==t.val&&void 0!==t.val&&(i=\"boolean\"===e.type?!0===t.val||\"true\"===t.val?\"Yes\":\"No\":String(t.val));let s=U;return e.filter.has(t.val)&&(s=V` <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z\"/> </svg> `),V` <div class=\"bucket\" @click=${i=>this._handleBucketToggle(i,e,t)} > <div class=${we({bucket__checkbox:!0,\"bucket__checkbox--checked\":e.filter.has(t.val)})}> ${s} </div> <span class=\"bucket__label\">${i}</span> <span class=\"bucket__count\">${t.count}</span> </div> `}))} </div> `:V`<div class=\"bucket-empty\">No data</div>`,r=e.facetable&&e.filterable?V` <kr-tab-group size=\"small\" active-tab-id=${this._filterPanelTab} @tab-change=${e=>this._handleFilterPanelTabChange(e)} > <kr-tab id=\"filter\" label=\"Filter\"> ${i} </kr-tab> <kr-tab id=\"counts\" label=\"Counts\"> ${o} </kr-tab> </kr-tab-group> `:e.facetable?o:i,V` <div class=\"filter-panel\" style=${qe({top:this._filterPanelPos.top+\"px\",left:this._filterPanelPos.left+\"px\"})} > ${r} <div class=\"filter-panel__actions\"> <kr-button variant=\"outline\" color=\"secondary\" size=\"small\" @click=${this._handleFilterClear}> Clear </kr-button> <kr-button size=\"small\" @click=${this._handleFilterApply}> Apply </kr-button> </div> </div> `}_renderFilterRow(){const e=this.getDisplayedColumns();return e.some((e=>e.filterable||e.facetable))?V` <div class=\"filter-row\"> ${e.map(((t,i)=>t.filterable||t.facetable?V` <div class=${we({\"filter-cell\":!0,\"filter-cell--sticky-left\":\"left\"===t.sticky,\"filter-cell--sticky-right\":\"right\"===t.sticky,\"filter-cell--sticky-right-first\":\"right\"===t.sticky&&!e.slice(0,i).some((e=>\"right\"===e.sticky))})} style=${qe(this._getCellStyle(t,i))} > <div class=\"filter-cell__wrapper\"> <input type=\"text\" class=${we({\"filter-cell__input\":!0,\"filter-cell__input--invalid\":!t.filter.isValid()})} .value=${t.filter.kql} @change=${e=>this._handleKqlChange(e,t)} /> ${t.filter?.kql?.length>0?V` <button class=\"filter-cell__clear\" @click=${()=>this._handleKqlClear(t)} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z\"/> </svg> </button> `:U} <button class=${we({\"filter-cell__advanced\":!0,\"filter-cell__advanced--opened\":this._filterPanelOpened===t.id})} @click=${e=>this._handleFilterPanelToggle(e,t)} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z\"/> </svg> </button> </div> </div> `:V`<div class=${we({\"filter-cell\":!0,\"filter-cell--sticky-left\":\"left\"===t.sticky,\"filter-cell--sticky-right\":\"right\"===t.sticky,\"filter-cell--sticky-right-first\":\"right\"===t.sticky&&!e.slice(0,i).some((e=>\"right\"===e.sticky))})} style=${qe(this._getCellStyle(t,i))} ></div>`))} </div> `:U}_renderTable(){return V` <div class=\"wrapper\"> <div class=\"overlay-left\"></div> <div class=\"overlay-right\"></div> ${this._renderStatus()} <div class=\"content\" @scroll=${this._handleScroll}> <div class=\"table\" style=\"grid-template-columns: ${this._getGridTemplateColumns()}\"> <div class=\"header-row\"> ${this.getDisplayedColumns().map(((e,t)=>V` <div class=${we(this._getHeaderCellClasses(e,t))} style=${qe(this._getCellStyle(e,t))} data-column-id=${e.id} > <span class=\"header-cell__label\">${e.label??e.id}</span> ${this._renderSortIndicator(e)} ${!1!==e.resizable?V`<div class=\"header-cell__resize\" @mousedown=${t=>this._handleResizeStart(t,e.id)} ></div>`:U} </div> `))} </div> ${this._renderFilterRow()} ${this._data.map(((e,t)=>{const i=this.getDisplayedColumns().map(((i,s)=>V` <div class=${we(this._getCellClasses(i,s))} style=${qe(this._getCellStyle(i,s))} data-column-id=${i.id} > ${this._renderCellContent(i,e,t)} </div> `));return this._model.rowHref?V` <a href=${this._model.rowHref(e)} class=${we({row:!0,\"row--clickable\":!0,\"row--link\":!0})} @mousedown=${()=>this._handleRowMouseDown()} @click=${()=>this._handleRowClick(e,t)} >${i}</a> `:V` <div class=${we({row:!0,\"row--clickable\":!!this._model.rowClickable})} @mousedown=${()=>this._handleRowMouseDown()} @click=${()=>this._handleRowClick(e,t)} >${i}</div> `}))} </div> </div> </div> `}render(){return this._model.columns.length?V` ${this._renderHeader()} ${this._renderTable()} ${this._renderFilterPanel()} `:V`<slot></slot>`}}"
1175
+ "default": "class extends le{constructor(){super(...arguments),this._scrollStyle=\"overlay\",this._data=[],this._dataState=\"idle\",this._page=1,this._pageSize=50,this._totalItems=0,this._totalPages=0,this._searchQuery=\"\",this._canScrollLeft=!1,this._canScrollRight=!1,this._canScrollHorizontal=!1,this._columnPickerOpen=!1,this._filterPanelOpened=null,this._filterPanelTab=\"filter\",this._buckets=new Map,this._filterPanelPos={top:0,left:0},this._sorts=[],this._resizing=null,this._resizeObserver=null,this._searchPositionLocked=!1,this._model=new ct,this.def={columns:[]},this._handleClickOutside=e=>{const t=e.composedPath();if(this._columnPickerOpen){const e=this.shadowRoot?.querySelector(\".column-picker-wrapper\");e&&!t.includes(e)&&(this._columnPickerOpen=!1)}this._filterPanelOpened&&(t.some((e=>e.classList?.contains(\"filter-panel\")))||this._handleFilterApply())},this._handleResizeMove=e=>{if(!this._resizing)return;const t=this._model.columns.find((e=>e.id===this._resizing.columnId));if(t){const i=this._resizing.startWidth+(e.clientX-this._resizing.startX);t.width=`${Math.min(900,Math.max(50,i))}px`,this.requestUpdate()}},this._handleResizeEnd=()=>{this._resizing=null,document.removeEventListener(\"mousemove\",this._handleResizeMove),document.removeEventListener(\"mouseup\",this._handleResizeEnd)}}connectedCallback(){super.connectedCallback(),this.classList.toggle(\"kr-table--scroll-overlay\",\"overlay\"===this._scrollStyle),this.classList.toggle(\"kr-table--scroll-edge\",\"edge\"===this._scrollStyle),this._fetch(),this._initRefresh(),document.addEventListener(\"click\",this._handleClickOutside),this._resizeObserver=new ResizeObserver((()=>{this._searchPositionLocked=!1,this._updateSearchPosition()})),this._resizeObserver.observe(this)}disconnectedCallback(){super.disconnectedCallback(),clearInterval(this._refreshTimer),document.removeEventListener(\"click\",this._handleClickOutside),this._resizeObserver?.disconnect()}willUpdate(e){e.has(\"def\")&&(this._model=new ct,this.def.title&&(this._model.title=this.def.title),this.def.actions&&(this._model.actions=this.def.actions),this.def.data&&(this._model.data=this.def.data),this.def.dataSource&&(this._model.dataSource=this.def.dataSource),\"number\"==typeof this.def.refreshInterval&&(this._model.refreshInterval=this.def.refreshInterval),\"number\"==typeof this.def.pageSize&&(this._model.pageSize=this.def.pageSize),this.def.rowClickable&&(this._model.rowClickable=this.def.rowClickable),this.def.rowHref&&(this._model.rowHref=this.def.rowHref),this._sorts=[],this._model.columns=this.def.columns.map((e=>{const t={...e,filter:null};return t.type||(t.type=\"string\"),e.sort&&this._sorts.push({sortBy:e.id,sortDirection:e.sort}),\"actions\"===t.type?(t.label=e.label??\"\",t.sticky=\"right\",t.resizable=!1,t):((e.filterable||e.facetable)&&(t.filter=new dt,t.filter.field=e.id,t.filter.type=t.type,e.filter?(t.filter.setOperator(e.filter.operator),t.filter.setValue(e.filter.value)):e.facetable&&!e.filterable?(t.filter.operator=\"in\",t.filter.value=[]):\"string\"===t.filter.type&&(t.filter.operator=\"contains\")),t)})),this.def.displayedColumns?this._model.displayedColumns=this.def.displayedColumns:this._model.displayedColumns=this._model.columns.map((e=>e.id)),this._fetch(),this._initRefresh())}updated(e){this._updateScrollFlags(),this._syncSlottedContent()}_syncSlottedContent(){const e=this.getDisplayedColumns().filter((e=>e.render));e.length&&(this.querySelectorAll('[slot^=\"cell-\"]').forEach((e=>e.remove())),this._data.forEach(((t,i)=>{e.forEach((e=>{const s=e.render(t);if(!s)return;const o=document.createElement(\"span\");o.slot=`cell-${i}-${e.id}`,\"actions\"===e.type&&(o.style.display=\"flex\",o.style.gap=\"8px\"),\"string\"==typeof s?o.innerHTML=s:oe(s,o),this.appendChild(o)}))})))}refresh(){this._fetch()}goToPrevPage(){this._page>1&&(this._page--,this._fetch())}goToNextPage(){this._page<this._totalPages&&(this._page++,this._fetch())}goToPage(e){e>=1&&e<=this._totalPages&&(this._page=e,this._fetch())}_toSolrData(){const e={page:this._page-1,size:this._pageSize,sorts:this._sorts,filterFields:[],queryFields:[],facetFields:[]};for(const t of this._model.columns){if(!t.filter||t.filter.isEmpty()||!t.filter.isValid())continue;const i=t.filter.toSolrData();!t.facetable||\"in\"!==t.filter.operator&&\"n_in\"!==t.filter.operator||(i.tagged=!0),e.filterFields.push(i)}for(const t of this._model.columns)t.facetable&&e.facetFields.push({name:t.id,type:\"FIELD\",limit:100,sort:\"count\",minimumCount:1});return this._searchQuery?.trim().length&&e.queryFields.push({name:\"_text_\",operation:\"IS\",value:ot(this._searchQuery,!1)}),e}_toDbParams(){const e={page:this._page-1,size:this._pageSize,sorts:this._sorts,filterFields:[],queryFields:[],facetFields:[]};for(const t of this._model.columns)t.filter&&!t.filter.isEmpty()&&t.filter.isValid()&&e.filterFields.push(t.filter.toDbParams());return this._searchQuery?.trim().length&&this._model.columns.filter((e=>e.searchable)).forEach((t=>{e.queryFields.push({name:t.id,operation:\"CONTAINS\",value:this._searchQuery,and:!1})})),e}_fetch(){if(this._model.data)return this._data=this._model.data,this._totalItems=this._model.data.length,this._totalPages=Math.ceil(this._model.data.length/this._pageSize),void(this._dataState=\"success\");if(!this._model.dataSource)return;let e;this._dataState=\"loading\",e=\"db\"===this._model.dataSource.mode?this._toDbParams():this._toSolrData(),this._model.dataSource.fetch(e).then((e=>{switch(this._model.dataSource?.mode){case\"opensearch\":throw Error(\"Opensearch not supported yet\");case\"db\":{const t=e;this._data=t.data.content,this._totalItems=t.data.totalElements,this._totalPages=t.data.totalPages,this._pageSize=t.data.size;break}default:{const t=e;this._data=t.data.content,this._totalItems=t.data.totalElements,this._totalPages=t.data.totalPages,this._pageSize=t.data.size,this._parseFacetResults(t)}}this._dataState=\"success\",this._updateSearchPosition()})).catch((e=>{this._dataState=\"error\",Fe.show({message:e instanceof Error?e.message:\"Failed to load data\",type:\"error\"})}))}_parseFacetResults(e){if(e.data.facetFields){for(const t of this._model.columns){if(!t.facetable)continue;const i=e.data.facetFields[t.id];if(!i){this._buckets.set(t.id,[]);continue}const s=[];for(const e of i){let i=e.name;\"boolean\"===t.type&&\"string\"==typeof e.name&&(\"true\"===e.name?i=!0:\"false\"===e.name&&(i=!1)),null===e.name&&e.count>0&&s.unshift({val:null,count:e.count}),null!==e.name&&s.push({val:i,count:e.count})}if(t.filter&&(\"in\"===t.filter.operator||\"n_in\"===t.filter.operator)&&Array.isArray(t.filter.value))for(const e of t.filter.value)s.some((t=>t.val===e))||s.push({val:e,count:0});this._buckets.set(t.id,s)}this._buckets=new Map(this._buckets)}}_initRefresh(){clearInterval(this._refreshTimer),this._model.refreshInterval&&this._model.refreshInterval>0&&(this._refreshTimer=window.setInterval((()=>{this._fetch()}),this._model.refreshInterval))}_handleSearch(e){const t=e.target;this._searchQuery=t.value,this._page=1,this._fetch()}_getGridTemplateColumns(){return this.getDisplayedColumns().map((e=>e.width?e.width:\"actions\"===e.type?\"max-content\":\"minmax(80px, auto)\")).join(\" \")}_updateSearchPosition(){if(this._searchPositionLocked)return;const e=this.shadowRoot?.querySelector(\".search\"),t=e?.querySelector(\".search-field\");e&&t&&(e.style.justifyContent=\"center\",t.style.marginLeft=\"\",requestAnimationFrame((()=>{const i=e.getBoundingClientRect(),s=t.getBoundingClientRect().left-i.left;e.style.justifyContent=\"flex-start\",t.style.marginLeft=`${s}px`,this._searchPositionLocked=!0})))}_toggleColumnPicker(){this._columnPickerOpen=!this._columnPickerOpen}_toggleColumn(e){this._model.displayedColumns.includes(e)?this._model.displayedColumns=this._model.displayedColumns.filter((t=>t!==e)):this._model.displayedColumns=[...this._model.displayedColumns,e],this.requestUpdate()}_handleRowMouseDown(){this._model.rowClickable&&window.getSelection()?.removeAllRanges()}_handleRowClick(e,t){if(!this._model.rowClickable)return;const i=window.getSelection();i&&i.toString().length>0||this.dispatchEvent(new CustomEvent(\"row-click\",{detail:{row:e,rowIndex:t},bubbles:!0,composed:!0}))}getDisplayedColumns(){return this._model.displayedColumns.map((e=>this._model.columns.find((t=>t.id===e)))).sort(((e,t)=>\"actions\"===e.type&&\"actions\"!==t.type?1:\"actions\"!==e.type&&\"actions\"===t.type?-1:0))}_handleScroll(e){const t=e.target;this._canScrollLeft=t.scrollLeft>0,this._canScrollRight=t.scrollLeft<t.scrollWidth-t.clientWidth-1}_updateScrollFlags(){const e=this.shadowRoot?.querySelector(\".content\");e&&(this._canScrollLeft=e.scrollLeft>0,this._canScrollRight=e.scrollWidth>e.clientWidth&&e.scrollLeft<e.scrollWidth-e.clientWidth-1,this._canScrollHorizontal=e.scrollWidth>e.clientWidth),this.classList.toggle(\"kr-table--scroll-left-available\",this._canScrollLeft),this.classList.toggle(\"kr-table--scroll-right-available\",this._canScrollRight),this.classList.toggle(\"kr-table--scroll-horizontal-available\",this._canScrollHorizontal),this.classList.toggle(\"kr-table--sticky-left\",this.getDisplayedColumns().some((e=>\"left\"===e.sticky))),this.classList.toggle(\"kr-table--sticky-right\",this.getDisplayedColumns().some((e=>\"right\"===e.sticky)))}_handleResizeStart(e,t){e.preventDefault();const i=this.shadowRoot?.querySelector(`.header-cell[data-column-id=\"${t}\"]`);this._resizing={columnId:t,startX:e.clientX,startWidth:i?.offsetWidth||200},document.addEventListener(\"mousemove\",this._handleResizeMove),document.addEventListener(\"mouseup\",this._handleResizeEnd)}_handleSortClick(e,t){if(e.shiftKey){const e=this._sorts.findIndex((e=>e.sortBy===t.id));if(-1===e)this._sorts.push({sortBy:t.id,sortDirection:\"asc\"});else{const t=this._sorts[e];\"asc\"===t.sortDirection?t.sortDirection=\"desc\":this._sorts.splice(e,1)}this.requestUpdate()}else{let e=null;1===this._sorts.length&&(e=this._sorts.find((e=>e.sortBy===t.id))),e?\"asc\"===e.sortDirection?this._sorts=[{sortBy:t.id,sortDirection:\"desc\"}]:this._sorts=[]:this._sorts=[{sortBy:t.id,sortDirection:\"asc\"}]}this._page=1,this._fetch()}_renderSortIndicator(e){if(!e.sortable)return U;const t=this._sorts.findIndex((t=>t.sortBy===e.id));if(-1===t)return V` <span class=\"header-cell__sort\" @click=${t=>this._handleSortClick(t,e)}> <svg class=\"header-cell__sort-arrow header-cell__sort-arrow--ghost\" viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z\"/> </svg> </span> `;let i={};return\"desc\"===this._sorts[t].sortDirection&&(i={transform:\"rotate(180deg)\"}),V` <span class=\"header-cell__sort\" @click=${t=>this._handleSortClick(t,e)}> <svg class=\"header-cell__sort-arrow\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=${qe(i)}> <path d=\"M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z\"/> </svg> ${this._sorts.length>1?V` <span class=\"header-cell__sort-priority\">${t+1}</span> `:U} </span> `}_handleAction(e){e.href||this.dispatchEvent(new CustomEvent(\"action\",{detail:{action:e.id},bubbles:!0,composed:!0}))}_handleKqlChange(e,t){const i=e.target.value.trim();if(i){if(t.filter.setKql(i),this.requestUpdate(),!t.filter.isValid())return}else t.filter.clear(),this.requestUpdate();this._page=1,this._fetch()}_handleFilterPanelToggle(e,t){if(e.stopPropagation(),this._filterPanelOpened===t.id)this._filterPanelOpened=null;else{const i=e.currentTarget.getBoundingClientRect();let s=i.left;s+328>window.innerWidth&&(s=window.innerWidth-328),this._filterPanelPos={top:i.bottom+4,left:s},this._filterPanelOpened=t.id,t.facetable?this._filterPanelTab=\"counts\":this._filterPanelTab=\"filter\"}}_handleKqlClear(e){e.filter.clear(),this._page=1,this._fetch()}_handleFilterClear(){const e=this._model.columns.find((e=>e.id===this._filterPanelOpened));e&&(e.filter.clear(),e.facetable&&!e.filterable&&(e.filter.operator=\"in\",e.filter.value=[])),this._filterPanelOpened=null,this._page=1,this._fetch()}_handleFilterTextKeydown(e,t){\"Enter\"===e.key&&(e.preventDefault(),this._handleFilterApply())}_handleOperatorChange(e,t){t.filter.setOperator(e.target.value),this.requestUpdate()}_handleFilterStringChange(e,t){t.filter.setValue(e.target.value),this.requestUpdate()}_handleFilterNumberChange(e,t){t.filter.setValue(Number(e.target.value)),this.requestUpdate()}_handleFilterDateChange(e,t){t.filter.setValue(new Date(e.target.value),\"day\"),this.requestUpdate()}_handleFilterBooleanChange(e,t){t.filter.setValue(\"true\"===e.target.value),this.requestUpdate()}_handleFilterDateStartChange(e,t){t.filter.setStart(new Date(e.target.value),\"day\"),this.requestUpdate()}_handleFilterDateEndChange(e,t){t.filter.setEnd(new Date(e.target.value),\"day\"),this.requestUpdate()}_handleFilterNumberStartChange(e,t){t.filter.setStart(Number(e.target.value)),this.requestUpdate()}_handleFilterNumberEndChange(e,t){t.filter.setEnd(Number(e.target.value)),this.requestUpdate()}_handleFilterListChange(e,t){const i=e.target.value.split(\",\").map((e=>e.trim())).filter((e=>\"\"!==e));\"number\"===t.type?t.filter.setValue(i.map((e=>Number(e)))):t.filter.setValue(i),this.requestUpdate()}_handleFilterApply(){this._filterPanelOpened=null,this._page=1,this._fetch()}_handleFilterPanelTabChange(e){this._filterPanelTab=e.detail.activeTabId}_handleBucketToggle(e,t,i){t.filter.toggle(i.val),this._page=1,this._fetch()}_renderCellContent(e,t,i){const s=t[e.id];if(e.render)return V`<slot name=\"cell-${i}-${e.id}\"></slot>`;if(null==s)return\"\";switch(e.type){case\"number\":return\"currency\"===e.format&&\"number\"==typeof s?s.toLocaleString(\"en-US\",{style:\"currency\",currency:\"USD\"}):String(s);case\"date\":{let e;if(s instanceof Date)e=s;else if(\"string\"==typeof s&&/^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}/.test(s)){const t=s.replace(/(\\d{2}:\\d{2}:\\d{2}):(\\d+)$/,\"$1.$2\").replace(\" \",\"T\")+\"Z\";e=new Date(t)}else e=new Date(s);return e.toLocaleString(void 0,{year:\"numeric\",month:\"short\",day:\"numeric\",hour:\"numeric\",minute:\"2-digit\",timeZone:\"UTC\"})}case\"boolean\":return!0===s?\"Yes\":!1===s?\"No\":\"\";default:return String(s)}}_getHeaderCellClasses(e,t){return{\"header-cell\":!0,\"header-cell--sortable\":!!e.sortable,\"header-cell--align-center\":\"center\"===e.align,\"header-cell--align-right\":\"right\"===e.align,\"header-cell--sticky-left\":\"left\"===e.sticky,\"header-cell--sticky-left-last\":\"left\"===e.sticky&&!this.getDisplayedColumns().slice(t+1).some((e=>\"left\"===e.sticky)),\"header-cell--sticky-right\":\"right\"===e.sticky,\"header-cell--sticky-right-first\":\"right\"===e.sticky&&!this.getDisplayedColumns().slice(0,t).some((e=>\"right\"===e.sticky))}}_getCellClasses(e,t){return{cell:!0,\"cell--actions\":\"actions\"===e.type,\"cell--align-center\":\"center\"===e.align,\"cell--align-right\":\"right\"===e.align,\"cell--sticky-left\":\"left\"===e.sticky,\"cell--sticky-left-last\":\"left\"===e.sticky&&!this.getDisplayedColumns().slice(t+1).some((e=>\"left\"===e.sticky)),\"cell--sticky-right\":\"right\"===e.sticky,\"cell--sticky-right-first\":\"right\"===e.sticky&&!this.getDisplayedColumns().slice(0,t).some((e=>\"right\"===e.sticky))}}_getCellStyle(e,t){const i={};if(\"left\"===e.sticky){let e=0;for(let i=0;i<t;i++){const t=this.getDisplayedColumns()[i];\"left\"===t.sticky&&(e+=parseInt(t.width||\"0\",10))}i.left=`${e}px`}if(\"right\"===e.sticky){let e=0;for(let i=t+1;i<this.getDisplayedColumns().length;i++){const t=this.getDisplayedColumns()[i];\"right\"===t.sticky&&(e+=parseInt(t.width||\"0\",10))}i.right=`${e}px`}return i}_renderPagination(){const e=(this._page-1)*this._pageSize+1,t=Math.min(this._page*this._pageSize,this._totalItems);return V` <div class=\"pagination\"> <span class=\"pagination-icon ${1===this._page?\"pagination-icon--disabled\":\"\"}\" @click=${this.goToPrevPage} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z\"/></svg> </span> <span class=\"pagination-info\">${e}-${t} of ${this._totalItems}</span> <span class=\"pagination-icon ${this._page===this._totalPages?\"pagination-icon--disabled\":\"\"}\" @click=${this.goToNextPage} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z\"/></svg> </span> </div> `}_renderHeader(){return!this._model.title&&!this._model.actions?.length&&this._totalPages<=1?U:V` <div class=\"header\"> <div class=\"title\">${this._model.title??\"\"}</div> ${\"db\"!==this._model.dataSource?.mode||this._model.columns.some((e=>e.searchable))?V` <div class=\"search\"> <!-- TODO: Saved views dropdown <div class=\"views\"> <span>Default View</span> <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z\"/></svg> </div> --> <div class=\"search-field\"> <svg class=\"search-icon\" viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z\"/></svg> <input type=\"text\" class=\"search-input\" placeholder=\"Search...\" .value=${this._searchQuery} @input=${this._handleSearch} /> </div> </div> `:V`<div class=\"search\"></div>`} <div class=\"tools\"> ${this._renderPagination()} <span class=\"refresh\" title=\"Refresh\" @click=${()=>this.refresh()}> <svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-56-87.5-88T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z\"/></svg> </span> <div class=\"column-picker-wrapper\"> <span class=\"header-icon\" title=\"Columns\" @click=${this._toggleColumnPicker}> <svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M121-280v-400q0-33 23.5-56.5T201-760h559q33 0 56.5 23.5T840-680v400q0 33-23.5 56.5T760-200H201q-33 0-56.5-23.5T121-280Zm79 0h133v-400H200v400Zm213 0h133v-400H413v400Zm213 0h133v-400H626v400Z\"/></svg> </span> <div class=\"column-picker ${this._columnPickerOpen?\"open\":\"\"}\"> ${[...this._model.columns].filter((e=>\"actions\"!==e.type)).sort(((e,t)=>(e.label??e.id).localeCompare(t.label??t.id))).map((e=>V` <div class=\"column-picker-item\" @click=${()=>this._toggleColumn(e.id)}> <div class=\"column-picker-checkbox ${this._model.displayedColumns.includes(e.id)?\"checked\":\"\"}\"> <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z\"/></svg> </div> <span class=\"column-picker-label\">${e.label??e.id}</span> </div> `))} </div> </div> ${1===this._model.actions?.length?V` <kr-button class=\"actions\" .href=${this._model.actions[0].href} .target=${this._model.actions[0].target} @click=${()=>this._handleAction(this._model.actions[0])} > ${this._model.actions[0].label} </kr-button> `:this._model.actions?.length?V` <kr-button class=\"actions\" .options=${this._model.actions.map((e=>({id:e.id,label:e.label})))} @option-select=${e=>this._handleAction({id:e.detail.id,label:e.detail.label})} > Actions </kr-button> `:U} </div> </div> `}_renderStatus(){return\"loading\"===this._dataState&&0===this._data.length?V`<div class=\"status\">Loading...</div>`:\"error\"===this._dataState&&0===this._data.length?V`<div class=\"status status--error\">Error loading data</div>`:0===this._data.length?V`<div class=\"status\">No data available</div>`:U}_renderFilterPanel(){if(!this._filterPanelOpened)return U;const e=this._model.columns.find((e=>e.id===this._filterPanelOpened));let t=V``;t=\"empty\"===e.filter.operator||\"n_empty\"===e.filter.operator?V` <input type=\"text\" class=\"filter-panel__input\" disabled .value=${e.filter.text} /> `:\"between\"===e.filter.operator&&\"date\"===e.type?V` <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${e.filter.value?.start??null} @change=${t=>this._handleFilterDateStartChange(t,e)} /> <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${e.filter.value?.end??null} @change=${t=>this._handleFilterDateEndChange(t,e)} /> `:\"between\"===e.filter.operator&&\"number\"===e.type?V` <input type=\"number\" class=\"filter-panel__input\" placeholder=\"Start\" .value=${e.filter.value?.start??\"\"} @input=${t=>this._handleFilterNumberStartChange(t,e)} @keydown=${t=>this._handleFilterTextKeydown(t,e)} /> <input type=\"number\" class=\"filter-panel__input\" placeholder=\"End\" .value=${e.filter.value?.end??\"\"} @input=${t=>this._handleFilterNumberEndChange(t,e)} @keydown=${t=>this._handleFilterTextKeydown(t,e)} /> `:\"in\"===e.filter.operator||\"n_in\"===e.filter.operator?V` <textarea class=\"filter-panel__textarea\" rows=\"3\" placeholder=\"Values (comma-separated)\" .value=${e.filter.text} @input=${t=>this._handleFilterListChange(t,e)} @keydown=${t=>this._handleFilterTextKeydown(t,e)} ></textarea> `:\"boolean\"===e.type?V` <kr-select-field placeholder=\"Value\" .value=${String(e.filter.value??\"\")} @change=${t=>this._handleFilterBooleanChange(t,e)} > <kr-select-option value=\"true\">Yes</kr-select-option> <kr-select-option value=\"false\">No</kr-select-option> </kr-select-field> `:\"date\"===e.type?V` <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${e.filter.value} @change=${t=>this._handleFilterDateChange(t,e)} /> `:\"number\"===e.type?V` <input type=\"number\" class=\"filter-panel__input\" placeholder=\"Value\" min=\"0\" .value=${e.filter.text} @input=${t=>this._handleFilterNumberChange(t,e)} @keydown=${t=>this._handleFilterTextKeydown(t,e)} /> `:V` <input type=\"text\" class=\"filter-panel__input\" placeholder=\"Value\" .value=${e.filter.text} @input=${t=>this._handleFilterStringChange(t,e)} @keydown=${t=>this._handleFilterTextKeydown(t,e)} /> `;const i=V` <div class=\"filter-panel__content\"> <kr-select-field .value=${e.filter.operator} @change=${t=>this._handleOperatorChange(t,e)} > ${tt(e.type).map((e=>V` <kr-select-option value=${e.key}>${e.label}</kr-select-option> `))} </kr-select-field> ${t} </div> `,s=this._buckets.get(e.id)||[];let o,r;return o=s.length?V` <div class=\"buckets\"> ${s.map((t=>{let i=\"(Empty)\";null!==t.val&&void 0!==t.val&&(i=\"boolean\"===e.type?!0===t.val||\"true\"===t.val?\"Yes\":\"No\":String(t.val));let s=U;return e.filter.has(t.val)&&(s=V` <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z\"/> </svg> `),V` <div class=\"bucket\" @click=${i=>this._handleBucketToggle(i,e,t)} > <div class=${we({bucket__checkbox:!0,\"bucket__checkbox--checked\":e.filter.has(t.val)})}> ${s} </div> <span class=\"bucket__label\">${i}</span> <span class=\"bucket__count\">${t.count}</span> </div> `}))} </div> `:V`<div class=\"bucket-empty\">No data</div>`,r=e.facetable&&e.filterable?V` <kr-tab-group size=\"small\" active-tab-id=${this._filterPanelTab} @tab-change=${e=>this._handleFilterPanelTabChange(e)} > <kr-tab id=\"filter\" label=\"Filter\"> ${i} </kr-tab> <kr-tab id=\"counts\" label=\"Counts\"> ${o} </kr-tab> </kr-tab-group> `:e.facetable?o:i,V` <div class=\"filter-panel\" style=${qe({top:this._filterPanelPos.top+\"px\",left:this._filterPanelPos.left+\"px\"})} > ${r} <div class=\"filter-panel__actions\"> <kr-button variant=\"outline\" color=\"secondary\" size=\"small\" @click=${this._handleFilterClear}> Clear </kr-button> <kr-button size=\"small\" @click=${this._handleFilterApply}> Apply </kr-button> </div> </div> `}_renderFilterRow(){const e=this.getDisplayedColumns();return e.some((e=>e.filterable||e.facetable))?V` <div class=\"filter-row\"> ${e.map(((t,i)=>t.filterable||t.facetable?V` <div class=${we({\"filter-cell\":!0,\"filter-cell--sticky-left\":\"left\"===t.sticky,\"filter-cell--sticky-right\":\"right\"===t.sticky,\"filter-cell--sticky-right-first\":\"right\"===t.sticky&&!e.slice(0,i).some((e=>\"right\"===e.sticky))})} style=${qe(this._getCellStyle(t,i))} > <div class=\"filter-cell__wrapper\"> <input type=\"text\" class=${we({\"filter-cell__input\":!0,\"filter-cell__input--invalid\":!t.filter.isValid()})} .value=${t.filter.kql} @change=${e=>this._handleKqlChange(e,t)} /> ${t.filter?.kql?.length>0?V` <button class=\"filter-cell__clear\" @click=${()=>this._handleKqlClear(t)} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z\"/> </svg> </button> `:U} <button class=${we({\"filter-cell__advanced\":!0,\"filter-cell__advanced--opened\":this._filterPanelOpened===t.id})} @click=${e=>this._handleFilterPanelToggle(e,t)} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z\"/> </svg> </button> </div> </div> `:V`<div class=${we({\"filter-cell\":!0,\"filter-cell--sticky-left\":\"left\"===t.sticky,\"filter-cell--sticky-right\":\"right\"===t.sticky,\"filter-cell--sticky-right-first\":\"right\"===t.sticky&&!e.slice(0,i).some((e=>\"right\"===e.sticky))})} style=${qe(this._getCellStyle(t,i))} ></div>`))} </div> `:U}_renderTable(){return V` <div class=\"wrapper\"> <div class=\"overlay-left\"></div> <div class=\"overlay-right\"></div> ${this._renderStatus()} <div class=\"content\" @scroll=${this._handleScroll}> <div class=\"table\" style=\"grid-template-columns: ${this._getGridTemplateColumns()}\"> <div class=\"header-row\"> ${this.getDisplayedColumns().map(((e,t)=>V` <div class=${we(this._getHeaderCellClasses(e,t))} style=${qe(this._getCellStyle(e,t))} data-column-id=${e.id} > <span class=\"header-cell__label\">${e.label??e.id}</span> ${this._renderSortIndicator(e)} ${!1!==e.resizable?V`<div class=\"header-cell__resize\" @mousedown=${t=>this._handleResizeStart(t,e.id)} ></div>`:U} </div> `))} </div> ${this._renderFilterRow()} ${this._data.map(((e,t)=>{const i=this.getDisplayedColumns().map(((i,s)=>V` <div class=${we(this._getCellClasses(i,s))} style=${qe(this._getCellStyle(i,s))} data-column-id=${i.id} > ${this._renderCellContent(i,e,t)} </div> `));return this._model.rowHref?V` <a href=${this._model.rowHref(e)} class=${we({row:!0,\"row--clickable\":!0,\"row--link\":!0})} @mousedown=${()=>this._handleRowMouseDown()} @click=${()=>this._handleRowClick(e,t)} >${i}</a> `:V` <div class=${we({row:!0,\"row--clickable\":!!this._model.rowClickable})} @mousedown=${()=>this._handleRowMouseDown()} @click=${()=>this._handleRowClick(e,t)} >${i}</div> `}))} </div> </div> </div> `}render(){return this._model.columns.length?V` ${this._renderHeader()} ${this._renderTable()} ${this._renderFilterPanel()} `:V`<slot></slot>`}}"
1176
1176
  },
1177
1177
  {
1178
1178
  "kind": "variable",
@@ -2314,7 +2314,7 @@
2314
2314
  "type": {
2315
2315
  "text": "object"
2316
2316
  },
2317
- "default": "{ equals: { key: 'equals', type: 'comparison', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Equals' }, n_equals: { key: 'n_equals', type: 'comparison', dataTypes: ['string', 'number', 'date', 'boolean'], label: \"Doesn't equal\" }, contains: { key: 'contains', type: 'comparison', dataTypes: ['string'], label: 'Contains' }, n_contains: { key: 'n_contains', type: 'comparison', dataTypes: ['string'], label: \"Doesn't contain\" }, starts_with: { key: 'starts_with', type: 'comparison', dataTypes: ['string'], label: 'Starts with' }, ends_with: { key: 'ends_with', type: 'comparison', dataTypes: ['string'], label: 'Ends with' }, less_than: { key: 'less_than', type: 'comparison', dataTypes: ['number', 'date'], label: 'Less than' }, less_than_equal: { key: 'less_than_equal', type: 'comparison', dataTypes: ['number', 'date'], label: 'Less than or equal' }, greater_than: { key: 'greater_than', type: 'comparison', dataTypes: ['number', 'date'], label: 'Greater than' }, greater_than_equal: { key: 'greater_than_equal', type: 'comparison', dataTypes: ['number', 'date'], label: 'Greater than or equal' }, between: { key: 'between', type: 'range', dataTypes: ['number', 'date'], label: 'Between' }, in: { key: 'in', type: 'list', dataTypes: ['string', 'number'], label: 'In' }, empty: { key: 'empty', type: 'nil', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Empty' }, n_empty: { key: 'n_empty', type: 'nil', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Not empty' }, }",
2317
+ "default": "{ equals: { key: 'equals', type: 'comparison', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Equals' }, n_equals: { key: 'n_equals', type: 'comparison', dataTypes: ['string', 'number', 'date', 'boolean'], label: \"Doesn't equal\" }, contains: { key: 'contains', type: 'comparison', dataTypes: ['string'], label: 'Contains' }, n_contains: { key: 'n_contains', type: 'comparison', dataTypes: ['string'], label: \"Doesn't contain\" }, starts_with: { key: 'starts_with', type: 'comparison', dataTypes: ['string'], label: 'Starts with' }, ends_with: { key: 'ends_with', type: 'comparison', dataTypes: ['string'], label: 'Ends with' }, less_than: { key: 'less_than', type: 'comparison', dataTypes: ['number', 'date'], label: 'Less than' }, less_than_equal: { key: 'less_than_equal', type: 'comparison', dataTypes: ['number', 'date'], label: 'Less than or equal' }, greater_than: { key: 'greater_than', type: 'comparison', dataTypes: ['number', 'date'], label: 'Greater than' }, greater_than_equal: { key: 'greater_than_equal', type: 'comparison', dataTypes: ['number', 'date'], label: 'Greater than or equal' }, between: { key: 'between', type: 'range', dataTypes: ['number', 'date'], label: 'Between' }, in: { key: 'in', type: 'list', dataTypes: ['string', 'number'], label: 'In' }, n_in: { key: 'n_in', type: 'list', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Not In' }, empty: { key: 'empty', type: 'nil', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Empty' }, n_empty: { key: 'n_empty', type: 'nil', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Not empty' }, }",
2318
2318
  "description": "Data-driven operator metadata map"
2319
2319
  },
2320
2320
  {
@@ -2417,7 +2417,7 @@
2417
2417
  "name": "val"
2418
2418
  }
2419
2419
  ],
2420
- "description": "Returns true if the value array contains the given value. Only applies to 'in' operator."
2420
+ "description": "Returns true if the value array contains the given value. Only applies to 'in' and 'n_in' operators."
2421
2421
  },
2422
2422
  {
2423
2423
  "kind": "method",
@@ -2427,7 +2427,7 @@
2427
2427
  "name": "val"
2428
2428
  }
2429
2429
  ],
2430
- "description": "Adds or removes a value from the 'in' list and rebuilds text/kql."
2430
+ "description": "Adds or removes a value from the 'in' or 'n_in' list and rebuilds text/kql."
2431
2431
  },
2432
2432
  {
2433
2433
  "kind": "method",
@@ -2582,7 +2582,7 @@
2582
2582
  {
2583
2583
  "kind": "variable",
2584
2584
  "name": "KRTable",
2585
- "default": "class KRTable extends LitElement { constructor() { super(...arguments); /** * Internal flag to switch between scroll edge modes: * - 'overlay': Fixed padding with overlay elements that hide content at edges (scrollbar at viewport edge) * - 'edge': Padding scrolls with content, allowing table to reach edges when scrolling */ this._scrollStyle = 'overlay'; this._data = []; this._dataState = 'idle'; this._page = 1; this._pageSize = 50; this._totalItems = 0; this._totalPages = 0; this._searchQuery = ''; this._canScrollLeft = false; this._canScrollRight = false; this._canScrollHorizontal = false; this._columnPickerOpen = false; this._filterPanelOpened = null; this._filterPanelTab = 'filter'; this._buckets = new Map(); this._filterPanelPos = { top: 0, left: 0 }; this._sorts = []; this._resizing = null; this._resizeObserver = null; this._searchPositionLocked = false; this._model = new KRTableModel(); this.def = { columns: [] }; this._handleClickOutside = (e) => { const path = e.composedPath(); if (this._columnPickerOpen) { const picker = this.shadowRoot?.querySelector('.column-picker-wrapper'); if (picker && !path.includes(picker)) { this._columnPickerOpen = false; } } if (this._filterPanelOpened) { if (!path.some((el) => el.classList?.contains('filter-panel'))) { this._handleFilterApply(); } } }; this._handleResizeMove = (e) => { if (!this._resizing) return; const col = this._model.columns.find(c => c.id === this._resizing.columnId); if (col) { const newWidth = this._resizing.startWidth + (e.clientX - this._resizing.startX); col.width = `${Math.min(900, Math.max(50, newWidth))}px`; this.requestUpdate(); } }; this._handleResizeEnd = () => { this._resizing = null; document.removeEventListener('mousemove', this._handleResizeMove); document.removeEventListener('mouseup', this._handleResizeEnd); }; } connectedCallback() { super.connectedCallback(); this.classList.toggle('kr-table--scroll-overlay', this._scrollStyle === 'overlay'); this.classList.toggle('kr-table--scroll-edge', this._scrollStyle === 'edge'); this._fetch(); this._initRefresh(); document.addEventListener('click', this._handleClickOutside); this._resizeObserver = new ResizeObserver(() => { // Unlock and recalculate on resize since layout changes this._searchPositionLocked = false; this._updateSearchPosition(); }); this._resizeObserver.observe(this); } disconnectedCallback() { super.disconnectedCallback(); clearInterval(this._refreshTimer); document.removeEventListener('click', this._handleClickOutside); this._resizeObserver?.disconnect(); } willUpdate(changedProperties) { if (changedProperties.has('def')) { // Build internal model from user-provided def this._model = new KRTableModel(); if (this.def.title) { this._model.title = this.def.title; } if (this.def.actions) { this._model.actions = this.def.actions; } if (this.def.data) { this._model.data = this.def.data; } if (this.def.dataSource) { this._model.dataSource = this.def.dataSource; } if (typeof this.def.refreshInterval === 'number') { this._model.refreshInterval = this.def.refreshInterval; } if (typeof this.def.pageSize === 'number') { this._model.pageSize = this.def.pageSize; } if (this.def.rowClickable) { this._model.rowClickable = this.def.rowClickable; } if (this.def.rowHref) { this._model.rowHref = this.def.rowHref; } this._model.columns = this.def.columns.map(col => { const column = { ...col, filter: null }; if (!column.type) { column.type = 'string'; } if (column.type === 'actions') { column.label = col.label ?? ''; column.sticky = 'right'; column.resizable = false; return column; } if (col.filterable || col.facetable) { column.filter = new KRQuery(); column.filter.field = col.id; column.filter.type = column.type; if (col.filter) { column.filter.setOperator(col.filter.operator); column.filter.setValue(col.filter.value); } else if (col.facetable && !col.filterable) { column.filter.operator = 'in'; column.filter.value = []; } else if (column.filter.type === 'string') { column.filter.operator = 'contains'; } } return column; }); if (this.def.displayedColumns) { this._model.displayedColumns = this.def.displayedColumns; } else { this._model.displayedColumns = this._model.columns.map(c => c.id); } this._fetch(); this._initRefresh(); } } updated(changedProperties) { this._updateScrollFlags(); this._syncSlottedContent(); } /** Syncs light DOM content for cells with custom render functions */ _syncSlottedContent() { const columns = this.getDisplayedColumns().filter(col => col.render); if (!columns.length) return; // Clear old slotted content this.querySelectorAll('[slot^=\"cell-\"]').forEach(el => el.remove()); // Create new slotted content this._data.forEach((row, rowIndex) => { columns.forEach(col => { const result = col.render(row); if (!result) return; const el = document.createElement('span'); el.slot = `cell-${rowIndex}-${col.id}`; if (col.type === 'actions') { el.style.display = 'flex'; el.style.gap = '8px'; } if (typeof result === 'string') { el.innerHTML = result; } else { render(result, el); } this.appendChild(el); }); }); } // ---------------------------------------------------------------------------- // Public Interface // ---------------------------------------------------------------------------- refresh() { this._fetch(); } goToPrevPage() { if (this._page > 1) { this._page--; this._fetch(); } } goToNextPage() { if (this._page < this._totalPages) { this._page++; this._fetch(); } } goToPage(page) { if (page >= 1 && page <= this._totalPages) { this._page = page; this._fetch(); } } // ---------------------------------------------------------------------------- // Data Fetching // ---------------------------------------------------------------------------- _toSolrData() { const request = { page: this._page - 1, size: this._pageSize, sorts: this._sorts, filterFields: [], queryFields: [], facetFields: [] }; for (const col of this._model.columns) { if (!col.filter || col.filter.isEmpty() || !col.filter.isValid()) { continue; } const filterData = col.filter.toSolrData(); if (col.facetable && col.filter.operator === 'in') { filterData.tagged = true; } request.filterFields.push(filterData); } for (const col of this._model.columns) { if (!col.facetable) { continue; } request.facetFields.push({ name: col.id, type: 'FIELD', limit: 100, sort: 'count', minimumCount: 1 }); } if (this._searchQuery?.trim().length) { request.queryFields.push({ name: '_text_', operation: 'IS', value: termify(this._searchQuery, false) }); } return request; } _toDbParams() { const request = { page: this._page - 1, size: this._pageSize, sorts: this._sorts, filterFields: [], queryFields: [], facetFields: [] }; for (const col of this._model.columns) { if (!col.filter || col.filter.isEmpty() || !col.filter.isValid()) { continue; } request.filterFields.push(col.filter.toDbParams()); } if (this._searchQuery?.trim().length) { this._model.columns.filter(col => col.searchable).forEach(col => { request.queryFields.push({ name: col.id, operation: 'CONTAINS', value: this._searchQuery, and: false }); }); } return request; } /** * Fetches data from the API and updates the table. * Shows a loading spinner while fetching, then displays rows on success * or an error snackbar on failure. * Request/response format depends on dataSource.mode (solr, opensearch, db). */ _fetch() { if (this._model.data) { this._data = this._model.data; this._totalItems = this._model.data.length; this._totalPages = Math.ceil(this._model.data.length / this._pageSize); this._dataState = 'success'; return; } if (!this._model.dataSource) return; this._dataState = 'loading'; let request; if (this._model.dataSource.mode === 'db') { request = this._toDbParams(); } else { request = this._toSolrData(); } this._model.dataSource.fetch(request) .then(response => { // Parse response based on mode switch (this._model.dataSource?.mode) { case 'opensearch': { throw Error('Opensearch not supported yet'); break; } case 'db': { const res = response; this._data = res.data.content; this._totalItems = res.data.totalElements; this._totalPages = res.data.totalPages; this._pageSize = res.data.size; break; } default: { // solr const res = response; this._data = res.data.content; this._totalItems = res.data.totalElements; this._totalPages = res.data.totalPages; this._pageSize = res.data.size; this._parseFacetResults(res); } } this._dataState = 'success'; this._updateSearchPosition(); }) .catch(err => { this._dataState = 'error'; KRSnackbar.show({ message: err instanceof Error ? err.message : 'Failed to load data', type: 'error' }); }); } _parseFacetResults(response) { if (!response.data.facetFields) { return; } for (const col of this._model.columns) { if (!col.facetable) { continue; } const rawBuckets = response.data.facetFields[col.id]; if (!rawBuckets) { this._buckets.set(col.id, []); continue; } const buckets = []; for (const raw of rawBuckets) { // Solr returns boolean facet values as strings — coerce to actual booleans // so they match the filter values stored by toggle(). let val = raw.name; if (col.type === 'boolean' && typeof raw.name === 'string') { if (raw.name === 'true') { val = true; } else if (raw.name === 'false') { val = false; } } if (raw.name === null && raw.count > 0) { buckets.unshift({ val: null, count: raw.count }); } if (raw.name !== null) { buckets.push({ val: val, count: raw.count }); } } // Bucket sync: ensure selected values appear even with 0 results if (col.filter && col.filter.operator === 'in' && Array.isArray(col.filter.value)) { for (const selectedVal of col.filter.value) { if (!buckets.some(b => b.val === selectedVal)) { buckets.push({ val: selectedVal, count: 0 }); } } } this._buckets.set(col.id, buckets); } // Trigger re-render since Map mutation doesn't trigger Lit updates this._buckets = new Map(this._buckets); } /** * Sets up auto-refresh so the table automatically fetches fresh data * at a regular interval (useful for dashboards, monitoring views). * Configured via def.refreshInterval in milliseconds. */ _initRefresh() { clearInterval(this._refreshTimer); if (this._model.refreshInterval && this._model.refreshInterval > 0) { this._refreshTimer = window.setInterval(() => { this._fetch(); }, this._model.refreshInterval); } } _handleSearch(e) { const input = e.target; this._searchQuery = input.value; this._page = 1; this._fetch(); } _getGridTemplateColumns() { const cols = this.getDisplayedColumns(); return cols.map((col) => { // If column has explicit width, use it if (col.width) { return col.width; } // Actions columns: fit content without minimum if (col.type === 'actions') { return 'max-content'; } // No width specified - use content-based sizing with minimum return 'minmax(80px, auto)'; }).join(' '); } /** * Updates search position to be centered with equal gaps from title and tools. * On first call: resets to flex centering, measures position, then locks with fixed margin. * Subsequent calls are ignored unless _searchPositionLocked is reset (e.g., on resize). */ _updateSearchPosition() { // Skip if already locked (prevents shifts on pagination changes) if (this._searchPositionLocked) return; const search = this.shadowRoot?.querySelector('.search'); const searchField = search?.querySelector('.search-field'); if (!search || !searchField) return; // Reset to flex centering search.style.justifyContent = 'center'; searchField.style.marginLeft = ''; requestAnimationFrame(() => { const searchRect = search.getBoundingClientRect(); const fieldRect = searchField.getBoundingClientRect(); // Calculate how far from the left of search container the field currently is const currentOffset = fieldRect.left - searchRect.left; // Lock position: switch to flex-start and use fixed margin search.style.justifyContent = 'flex-start'; searchField.style.marginLeft = `${currentOffset}px`; // Mark as locked so pagination changes don't shift the search this._searchPositionLocked = true; }); } // ---------------------------------------------------------------------------- // Columns // ---------------------------------------------------------------------------- _toggleColumnPicker() { this._columnPickerOpen = !this._columnPickerOpen; } _toggleColumn(columnId) { if (this._model.displayedColumns.includes(columnId)) { this._model.displayedColumns = this._model.displayedColumns.filter(id => id !== columnId); } else { this._model.displayedColumns = [...this._model.displayedColumns, columnId]; } this.requestUpdate(); } // Clear any existing text selection on mousedown so we only detect // selections made during this click gesture, not stale selections from elsewhere _handleRowMouseDown() { if (!this._model.rowClickable) { return; } window.getSelection()?.removeAllRanges(); } _handleRowClick(row, rowIndex) { if (!this._model.rowClickable) { return; } const selection = window.getSelection(); if (selection && selection.toString().length > 0) { return; } this.dispatchEvent(new CustomEvent('row-click', { detail: { row, rowIndex }, bubbles: true, composed: true })); } // When a user toggles a column on via the column picker, it gets appended // to _displayedColumns. By mapping over _displayedColumns (not def.columns), // the new column appears at the right edge of the table instead of jumping // back to its original position in the column definition. // Actions columns are always moved to the end. getDisplayedColumns() { return this._model.displayedColumns .map(id => this._model.columns.find(col => col.id === id)) .sort((a, b) => { if (a.type === 'actions' && b.type !== 'actions') return 1; if (a.type !== 'actions' && b.type === 'actions') return -1; return 0; }); } // ---------------------------------------------------------------------------- // Scrolling // ---------------------------------------------------------------------------- /** * Scroll event handler that updates scroll flags in real-time as user scrolls. * Updates shadow indicators to show if more content exists left/right. */ _handleScroll(e) { const container = e.target; this._canScrollLeft = container.scrollLeft > 0; this._canScrollRight = container.scrollLeft < container.scrollWidth - container.clientWidth - 1; } /** * Updates scroll state flags for the table content container. * - _canScrollLeft: true if scrolled right (can scroll back left) * - _canScrollRight: true if more content exists to the right * - _canScrollHorizontal: true if content is wider than container * These flags control scroll shadow indicators and CSS classes. */ _updateScrollFlags() { const container = this.shadowRoot?.querySelector('.content'); if (container) { this._canScrollLeft = container.scrollLeft > 0; this._canScrollRight = container.scrollWidth > container.clientWidth && container.scrollLeft < container.scrollWidth - container.clientWidth - 1; this._canScrollHorizontal = container.scrollWidth > container.clientWidth; } this.classList.toggle('kr-table--scroll-left-available', this._canScrollLeft); this.classList.toggle('kr-table--scroll-right-available', this._canScrollRight); this.classList.toggle('kr-table--scroll-horizontal-available', this._canScrollHorizontal); this.classList.toggle('kr-table--sticky-left', this.getDisplayedColumns().some(c => c.sticky === 'left')); this.classList.toggle('kr-table--sticky-right', this.getDisplayedColumns().some(c => c.sticky === 'right')); } // ---------------------------------------------------------------------------- // Column Resizing // ---------------------------------------------------------------------------- _handleResizeStart(e, columnId) { e.preventDefault(); const headerCell = this.shadowRoot?.querySelector(`.header-cell[data-column-id=\"${columnId}\"]`); this._resizing = { columnId, startX: e.clientX, startWidth: headerCell?.offsetWidth || 200 }; document.addEventListener('mousemove', this._handleResizeMove); document.addEventListener('mouseup', this._handleResizeEnd); } // ---------------------------------------------------------------------------- // Sorting // ---------------------------------------------------------------------------- _handleSortClick(e, column) { if (e.shiftKey) { // Multi-sort: add or cycle existing const existingIndex = this._sorts.findIndex(s => s.sortBy === column.id); if (existingIndex === -1) { this._sorts.push({ sortBy: column.id, sortDirection: 'asc' }); } else { const existing = this._sorts[existingIndex]; if (existing.sortDirection === 'asc') { existing.sortDirection = 'desc'; } else { // on third click, remove sorting for the column this._sorts.splice(existingIndex, 1); } } this.requestUpdate(); } else { // Single sort: replace all let existing = null; if (this._sorts.length === 1) { existing = this._sorts.find(s => s.sortBy === column.id); } if (!existing) { this._sorts = [{ sortBy: column.id, sortDirection: 'asc' }]; } else if (existing.sortDirection === 'asc') { this._sorts = [{ sortBy: column.id, sortDirection: 'desc' }]; } else { this._sorts = []; } } this._page = 1; this._fetch(); } _renderSortIndicator(column) { if (!column.sortable) { return nothing; } const sortIndex = this._sorts.findIndex(s => s.sortBy === column.id); if (sortIndex === -1) { // Ghost arrow: visible only on hover via CSS return html ` <span class=\"header-cell__sort\" @click=${(e) => this._handleSortClick(e, column)}> <svg class=\"header-cell__sort-arrow header-cell__sort-arrow--ghost\" viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z\"/> </svg> </span> `; } let arrowStyle = {}; if (this._sorts[sortIndex].sortDirection === 'desc') { arrowStyle = { transform: 'rotate(180deg)' }; } return html ` <span class=\"header-cell__sort\" @click=${(e) => this._handleSortClick(e, column)}> <svg class=\"header-cell__sort-arrow\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=${styleMap(arrowStyle)}> <path d=\"M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z\"/> </svg> ${this._sorts.length > 1 ? html ` <span class=\"header-cell__sort-priority\">${sortIndex + 1}</span> ` : nothing} </span> `; } // ---------------------------------------------------------------------------- // Header // ---------------------------------------------------------------------------- _handleAction(action) { if (action.href) { return; } this.dispatchEvent(new CustomEvent('action', { detail: { action: action.id }, bubbles: true, composed: true })); } // ---------------------------------------------------------------------------- // Filter Handlers // ---------------------------------------------------------------------------- _handleKqlChange(e, column) { const kql = e.target.value.trim(); if (!kql) { column.filter.clear(); this.requestUpdate(); } else { column.filter.setKql(kql); this.requestUpdate(); if (!column.filter.isValid()) { return; } } this._page = 1; this._fetch(); } _handleFilterPanelToggle(e, column) { e.stopPropagation(); if (this._filterPanelOpened === column.id) { this._filterPanelOpened = null; } else { const rect = e.currentTarget.getBoundingClientRect(); let left = rect.left; if (left + 328 > window.innerWidth) { left = window.innerWidth - 328; } this._filterPanelPos = { top: rect.bottom + 4, left }; this._filterPanelOpened = column.id; if (column.facetable) { this._filterPanelTab = 'counts'; } else { this._filterPanelTab = 'filter'; } } } _handleKqlClear(column) { column.filter.clear(); this._page = 1; this._fetch(); } _handleFilterClear() { const column = this._model.columns.find(c => c.id === this._filterPanelOpened); if (column) { column.filter.clear(); if (column.facetable && !column.filterable) { column.filter.operator = 'in'; column.filter.value = []; } } this._filterPanelOpened = null; this._page = 1; this._fetch(); } _handleFilterTextKeydown(e, column) { if (e.key === 'Enter') { e.preventDefault(); this._handleFilterApply(); } } _handleOperatorChange(e, column) { column.filter.setOperator(e.target.value); this.requestUpdate(); } _handleFilterStringChange(e, column) { column.filter.setValue(e.target.value); this.requestUpdate(); } _handleFilterNumberChange(e, column) { column.filter.setValue(Number(e.target.value)); this.requestUpdate(); } _handleFilterDateChange(e, column) { column.filter.setValue(new Date(e.target.value), 'day'); this.requestUpdate(); } _handleFilterBooleanChange(e, column) { column.filter.setValue(e.target.value === 'true'); this.requestUpdate(); } _handleFilterDateStartChange(e, column) { column.filter.setStart(new Date(e.target.value), 'day'); this.requestUpdate(); } _handleFilterDateEndChange(e, column) { column.filter.setEnd(new Date(e.target.value), 'day'); this.requestUpdate(); } _handleFilterNumberStartChange(e, column) { column.filter.setStart(Number(e.target.value)); this.requestUpdate(); } _handleFilterNumberEndChange(e, column) { column.filter.setEnd(Number(e.target.value)); this.requestUpdate(); } _handleFilterListChange(e, column) { const items = e.target.value.split(',').map((v) => v.trim()).filter((v) => v !== ''); if (column.type === 'number') { column.filter.setValue(items.map((v) => Number(v))); } else { column.filter.setValue(items); } this.requestUpdate(); } _handleFilterApply() { this._filterPanelOpened = null; this._page = 1; this._fetch(); } _handleFilterPanelTabChange(e) { this._filterPanelTab = e.detail.activeTabId; } _handleBucketToggle(e, column, bucket) { column.filter.toggle(bucket.val); this._page = 1; this._fetch(); } // ---------------------------------------------------------------------------- // Rendering // ---------------------------------------------------------------------------- _renderCellContent(column, row, rowIndex) { const value = row[column.id]; if (column.render) { // Use slot to project content from light DOM so external styles apply return html `<slot name=\"cell-${rowIndex}-${column.id}\"></slot>`; } if (value === null || value === undefined) { return ''; } switch (column.type) { case 'number': if (column.format === 'currency' && typeof value === 'number') { return value.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); } return String(value); case 'date': { let date; if (value instanceof Date) { date = value; } else if (typeof value === 'string' && /^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}/.test(value)) { // MySQL datetime format (UTC): \"2026-01-28 01:33:44:517\" // Replace last colon before ms with dot, append Z for UTC const isoString = value.replace(/(\\d{2}:\\d{2}:\\d{2}):(\\d+)$/, '$1.$2').replace(' ', 'T') + 'Z'; date = new Date(isoString); } else { date = new Date(value); } // Show date and time for datetime values in UTC return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'UTC' }); } case 'boolean': if (value === true) return 'Yes'; if (value === false) return 'No'; return ''; default: return String(value); } } /** * Returns CSS classes for a header cell based on column config. */ _getHeaderCellClasses(column, index) { return { 'header-cell': true, 'header-cell--sortable': !!column.sortable, 'header-cell--align-center': column.align === 'center', 'header-cell--align-right': column.align === 'right', 'header-cell--sticky-left': column.sticky === 'left', 'header-cell--sticky-left-last': column.sticky === 'left' && !this.getDisplayedColumns().slice(index + 1).some(c => c.sticky === 'left'), 'header-cell--sticky-right': column.sticky === 'right', 'header-cell--sticky-right-first': column.sticky === 'right' && !this.getDisplayedColumns().slice(0, index).some(c => c.sticky === 'right') }; } /** * Returns CSS classes for a table cell based on column config: * - Alignment (center, right) * - Sticky positioning (left, right) * - Border classes for the last left-sticky or first right-sticky column */ _getCellClasses(column, index) { return { 'cell': true, 'cell--actions': column.type === 'actions', 'cell--align-center': column.align === 'center', 'cell--align-right': column.align === 'right', 'cell--sticky-left': column.sticky === 'left', 'cell--sticky-left-last': column.sticky === 'left' && !this.getDisplayedColumns().slice(index + 1).some(c => c.sticky === 'left'), 'cell--sticky-right': column.sticky === 'right', 'cell--sticky-right-first': column.sticky === 'right' && !this.getDisplayedColumns().slice(0, index).some(c => c.sticky === 'right') }; } /** * Returns inline styles for a table cell: * - Width (from column config or default 150px) * - Min-width (if specified) * - Left/right offset for sticky columns (calculated from widths of preceding sticky columns) */ _getCellStyle(column, index) { const styles = {}; if (column.sticky === 'left') { let leftOffset = 0; for (let i = 0; i < index; i++) { const col = this.getDisplayedColumns()[i]; if (col.sticky === 'left') { leftOffset += parseInt(col.width || '0', 10); } } styles.left = `${leftOffset}px`; } if (column.sticky === 'right') { let rightOffset = 0; for (let i = index + 1; i < this.getDisplayedColumns().length; i++) { const col = this.getDisplayedColumns()[i]; if (col.sticky === 'right') { rightOffset += parseInt(col.width || '0', 10); } } styles.right = `${rightOffset}px`; } return styles; } /** * Renders the pagination controls: * - Previous page arrow (disabled on first page) * - Range text showing \"1-50 of 150\" format * - Next page arrow (disabled on last page) * * Hidden when there's no data or all data fits on one page. */ _renderPagination() { const start = (this._page - 1) * this._pageSize + 1; const end = Math.min(this._page * this._pageSize, this._totalItems); return html ` <div class=\"pagination\"> <span class=\"pagination-icon ${this._page === 1 ? 'pagination-icon--disabled' : ''}\" @click=${this.goToPrevPage} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z\"/></svg> </span> <span class=\"pagination-info\">${start}-${end} of ${this._totalItems}</span> <span class=\"pagination-icon ${this._page === this._totalPages ? 'pagination-icon--disabled' : ''}\" @click=${this.goToNextPage} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z\"/></svg> </span> </div> `; } /** * Renders the header toolbar containing: * - Title (left) * - Search bar with view selector dropdown (center) * - Tools (right): page navigation, refresh button, column visibility picker, actions dropdown * * Hidden when there's no title, no actions, and data fits on one page. */ _renderHeader() { if (!this._model.title && !this._model.actions?.length && this._totalPages <= 1) { return nothing; } return html ` <div class=\"header\"> <div class=\"title\">${this._model.title ?? ''}</div> ${this._model.dataSource?.mode === 'db' && !this._model.columns.some(col => col.searchable) ? html `<div class=\"search\"></div>` : html ` <div class=\"search\"> <!-- TODO: Saved views dropdown <div class=\"views\"> <span>Default View</span> <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z\"/></svg> </div> --> <div class=\"search-field\"> <svg class=\"search-icon\" viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z\"/></svg> <input type=\"text\" class=\"search-input\" placeholder=\"Search...\" .value=${this._searchQuery} @input=${this._handleSearch} /> </div> </div> `} <div class=\"tools\"> ${this._renderPagination()} <span class=\"refresh\" title=\"Refresh\" @click=${() => this.refresh()}> <svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-56-87.5-88T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z\"/></svg> </span> <div class=\"column-picker-wrapper\"> <span class=\"header-icon\" title=\"Columns\" @click=${this._toggleColumnPicker}> <svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M121-280v-400q0-33 23.5-56.5T201-760h559q33 0 56.5 23.5T840-680v400q0 33-23.5 56.5T760-200H201q-33 0-56.5-23.5T121-280Zm79 0h133v-400H200v400Zm213 0h133v-400H413v400Zm213 0h133v-400H626v400Z\"/></svg> </span> <div class=\"column-picker ${this._columnPickerOpen ? 'open' : ''}\"> ${[...this._model.columns].filter(col => col.type !== 'actions').sort((a, b) => (a.label ?? a.id).localeCompare(b.label ?? b.id)).map(col => html ` <div class=\"column-picker-item\" @click=${() => this._toggleColumn(col.id)}> <div class=\"column-picker-checkbox ${this._model.displayedColumns.includes(col.id) ? 'checked' : ''}\"> <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z\"/></svg> </div> <span class=\"column-picker-label\">${col.label ?? col.id}</span> </div> `)} </div> </div> ${this._model.actions?.length === 1 ? html ` <kr-button class=\"actions\" .href=${this._model.actions[0].href} .target=${this._model.actions[0].target} @click=${() => this._handleAction(this._model.actions[0])} > ${this._model.actions[0].label} </kr-button> ` : this._model.actions?.length ? html ` <kr-button class=\"actions\" .options=${this._model.actions.map(a => ({ id: a.id, label: a.label }))} @option-select=${(e) => this._handleAction({ id: e.detail.id, label: e.detail.label })} > Actions </kr-button> ` : nothing} </div> </div> `; } /** Renders status message (loading, error, empty) */ _renderStatus() { if (this._dataState === 'loading' && this._data.length === 0) { return html `<div class=\"status\">Loading...</div>`; } if (this._dataState === 'error' && this._data.length === 0) { return html `<div class=\"status status--error\">Error loading data</div>`; } if (this._data.length === 0) { return html `<div class=\"status\">No data available</div>`; } return nothing; } _renderFilterPanel() { if (!this._filterPanelOpened) { return nothing; } const column = this._model.columns.find(c => c.id === this._filterPanelOpened); // Build filter content (operator + value input) let valueInput = html ``; if (column.filter.operator === 'empty' || column.filter.operator === 'n_empty') { valueInput = html ` <input type=\"text\" class=\"filter-panel__input\" disabled .value=${column.filter.text} /> `; } else if (column.filter.operator === 'between' && column.type === 'date') { valueInput = html ` <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${column.filter.value?.start ?? null} @change=${(e) => this._handleFilterDateStartChange(e, column)} /> <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${column.filter.value?.end ?? null} @change=${(e) => this._handleFilterDateEndChange(e, column)} /> `; } else if (column.filter.operator === 'between' && column.type === 'number') { valueInput = html ` <input type=\"number\" class=\"filter-panel__input\" placeholder=\"Start\" .value=${column.filter.value?.start ?? ''} @input=${(e) => this._handleFilterNumberStartChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} /> <input type=\"number\" class=\"filter-panel__input\" placeholder=\"End\" .value=${column.filter.value?.end ?? ''} @input=${(e) => this._handleFilterNumberEndChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} /> `; } else if (column.filter.operator === 'in') { valueInput = html ` <textarea class=\"filter-panel__textarea\" rows=\"3\" placeholder=\"Values (comma-separated)\" .value=${column.filter.text} @input=${(e) => this._handleFilterListChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} ></textarea> `; } else if (column.type === 'boolean') { valueInput = html ` <kr-select-field placeholder=\"Value\" .value=${String(column.filter.value ?? '')} @change=${(e) => this._handleFilterBooleanChange(e, column)} > <kr-select-option value=\"true\">Yes</kr-select-option> <kr-select-option value=\"false\">No</kr-select-option> </kr-select-field> `; } else if (column.type === 'date') { valueInput = html ` <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${column.filter.value} @change=${(e) => this._handleFilterDateChange(e, column)} /> `; } else if (column.type === 'number') { valueInput = html ` <input type=\"number\" class=\"filter-panel__input\" placeholder=\"Value\" min=\"0\" .value=${column.filter.text} @input=${(e) => this._handleFilterNumberChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} /> `; } else { valueInput = html ` <input type=\"text\" class=\"filter-panel__input\" placeholder=\"Value\" .value=${column.filter.text} @input=${(e) => this._handleFilterStringChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} /> `; } const filterContent = html ` <div class=\"filter-panel__content\"> <kr-select-field .value=${column.filter.operator} @change=${(e) => this._handleOperatorChange(e, column)} > ${getOperatorsForType(column.type).map(op => html ` <kr-select-option value=${op.key}>${op.label}</kr-select-option> `)} </kr-select-field> ${valueInput} </div> `; // Build bucket list content const buckets = this._buckets.get(column.id) || []; let bucketContent; if (!buckets.length) { bucketContent = html `<div class=\"bucket-empty\">No data</div>`; } else { bucketContent = html ` <div class=\"buckets\"> ${buckets.map(bucket => { let bucketLabel = '(Empty)'; if (bucket.val !== null && bucket.val !== undefined) { if (column.type === 'boolean') { if (bucket.val === true || bucket.val === 'true') { bucketLabel = 'Yes'; } else { bucketLabel = 'No'; } } else { bucketLabel = String(bucket.val); } } let checkIcon = nothing; if (column.filter.has(bucket.val)) { checkIcon = html ` <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z\"/> </svg> `; } return html ` <div class=\"bucket\" @click=${(e) => this._handleBucketToggle(e, column, bucket)} > <div class=${classMap({ 'bucket__checkbox': true, 'bucket__checkbox--checked': column.filter.has(bucket.val) })}> ${checkIcon} </div> <span class=\"bucket__label\">${bucketLabel}</span> <span class=\"bucket__count\">${bucket.count}</span> </div> `; })} </div> `; } // Build panel body — tabs if both filterable+facetable, otherwise just the relevant content let panelBody; if (column.facetable && column.filterable) { panelBody = html ` <kr-tab-group size=\"small\" active-tab-id=${this._filterPanelTab} @tab-change=${(e) => this._handleFilterPanelTabChange(e)} > <kr-tab id=\"filter\" label=\"Filter\"> ${filterContent} </kr-tab> <kr-tab id=\"counts\" label=\"Counts\"> ${bucketContent} </kr-tab> </kr-tab-group> `; } else if (column.facetable) { panelBody = bucketContent; } else { panelBody = filterContent; } return html ` <div class=\"filter-panel\" style=${styleMap({ top: this._filterPanelPos.top + 'px', left: this._filterPanelPos.left + 'px' })} > ${panelBody} <div class=\"filter-panel__actions\"> <kr-button variant=\"outline\" color=\"secondary\" size=\"small\" @click=${this._handleFilterClear}> Clear </kr-button> <kr-button size=\"small\" @click=${this._handleFilterApply}> Apply </kr-button> </div> </div> `; } /** * Renders filter row below column headers. * Only displays for columns with filterable: true. */ _renderFilterRow() { const columns = this.getDisplayedColumns(); if (!columns.some(col => col.filterable || col.facetable)) { return nothing; } return html ` <div class=\"filter-row\"> ${columns.map((col, i) => { if (!col.filterable && !col.facetable) { return html `<div class=${classMap({ 'filter-cell': true, 'filter-cell--sticky-left': col.sticky === 'left', 'filter-cell--sticky-right': col.sticky === 'right', 'filter-cell--sticky-right-first': col.sticky === 'right' && !columns.slice(0, i).some((c) => c.sticky === 'right') })} style=${styleMap(this._getCellStyle(col, i))} ></div>`; } return html ` <div class=${classMap({ 'filter-cell': true, 'filter-cell--sticky-left': col.sticky === 'left', 'filter-cell--sticky-right': col.sticky === 'right', 'filter-cell--sticky-right-first': col.sticky === 'right' && !columns.slice(0, i).some((c) => c.sticky === 'right') })} style=${styleMap(this._getCellStyle(col, i))} > <div class=\"filter-cell__wrapper\"> <input type=\"text\" class=${classMap({ 'filter-cell__input': true, 'filter-cell__input--invalid': !col.filter.isValid() })} .value=${col.filter.kql} @change=${(e) => this._handleKqlChange(e, col)} /> ${col.filter?.kql?.length > 0 ? html ` <button class=\"filter-cell__clear\" @click=${() => this._handleKqlClear(col)} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z\"/> </svg> </button> ` : nothing} <button class=${classMap({ 'filter-cell__advanced': true, 'filter-cell__advanced--opened': this._filterPanelOpened === col.id })} @click=${(e) => this._handleFilterPanelToggle(e, col)} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z\"/> </svg> </button> </div> </div> `; })} </div> `; } /** Renders the scrollable data grid with column headers and rows. */ _renderTable() { return html ` <div class=\"wrapper\"> <div class=\"overlay-left\"></div> <div class=\"overlay-right\"></div> ${this._renderStatus()} <div class=\"content\" @scroll=${this._handleScroll}> <div class=\"table\" style=\"grid-template-columns: ${this._getGridTemplateColumns()}\"> <div class=\"header-row\"> ${this.getDisplayedColumns().map((col, i) => html ` <div class=${classMap(this._getHeaderCellClasses(col, i))} style=${styleMap(this._getCellStyle(col, i))} data-column-id=${col.id} > <span class=\"header-cell__label\">${col.label ?? col.id}</span> ${this._renderSortIndicator(col)} ${col.resizable !== false ? html `<div class=\"header-cell__resize\" @mousedown=${(e) => this._handleResizeStart(e, col.id)} ></div>` : nothing} </div> `)} </div> ${this._renderFilterRow()} ${this._data.map((row, rowIndex) => { const cells = this.getDisplayedColumns().map((col, i) => html ` <div class=${classMap(this._getCellClasses(col, i))} style=${styleMap(this._getCellStyle(col, i))} data-column-id=${col.id} > ${this._renderCellContent(col, row, rowIndex)} </div> `); if (this._model.rowHref) { return html ` <a href=${this._model.rowHref(row)} class=${classMap({ 'row': true, 'row--clickable': true, 'row--link': true })} @mousedown=${() => this._handleRowMouseDown()} @click=${() => this._handleRowClick(row, rowIndex)} >${cells}</a> `; } return html ` <div class=${classMap({ 'row': true, 'row--clickable': !!this._model.rowClickable })} @mousedown=${() => this._handleRowMouseDown()} @click=${() => this._handleRowClick(row, rowIndex)} >${cells}</div> `; })} </div> </div> </div> `; } /** * Renders a data table with: * - Header bar with title, search input with view selector, and tools (pagination, refresh, column visibility, actions dropdown) * - Scrollable grid with sticky header row and optional sticky left/right columns * - Loading, error message, or empty state when no data */ render() { if (!this._model.columns.length) { return html `<slot></slot>`; } return html ` ${this._renderHeader()} ${this._renderTable()} ${this._renderFilterPanel()} `; } }"
2585
+ "default": "class KRTable extends LitElement { constructor() { super(...arguments); /** * Internal flag to switch between scroll edge modes: * - 'overlay': Fixed padding with overlay elements that hide content at edges (scrollbar at viewport edge) * - 'edge': Padding scrolls with content, allowing table to reach edges when scrolling */ this._scrollStyle = 'overlay'; this._data = []; this._dataState = 'idle'; this._page = 1; this._pageSize = 50; this._totalItems = 0; this._totalPages = 0; this._searchQuery = ''; this._canScrollLeft = false; this._canScrollRight = false; this._canScrollHorizontal = false; this._columnPickerOpen = false; this._filterPanelOpened = null; this._filterPanelTab = 'filter'; this._buckets = new Map(); this._filterPanelPos = { top: 0, left: 0 }; this._sorts = []; this._resizing = null; this._resizeObserver = null; this._searchPositionLocked = false; this._model = new KRTableModel(); this.def = { columns: [] }; this._handleClickOutside = (e) => { const path = e.composedPath(); if (this._columnPickerOpen) { const picker = this.shadowRoot?.querySelector('.column-picker-wrapper'); if (picker && !path.includes(picker)) { this._columnPickerOpen = false; } } if (this._filterPanelOpened) { if (!path.some((el) => el.classList?.contains('filter-panel'))) { this._handleFilterApply(); } } }; this._handleResizeMove = (e) => { if (!this._resizing) return; const col = this._model.columns.find(c => c.id === this._resizing.columnId); if (col) { const newWidth = this._resizing.startWidth + (e.clientX - this._resizing.startX); col.width = `${Math.min(900, Math.max(50, newWidth))}px`; this.requestUpdate(); } }; this._handleResizeEnd = () => { this._resizing = null; document.removeEventListener('mousemove', this._handleResizeMove); document.removeEventListener('mouseup', this._handleResizeEnd); }; } connectedCallback() { super.connectedCallback(); this.classList.toggle('kr-table--scroll-overlay', this._scrollStyle === 'overlay'); this.classList.toggle('kr-table--scroll-edge', this._scrollStyle === 'edge'); this._fetch(); this._initRefresh(); document.addEventListener('click', this._handleClickOutside); this._resizeObserver = new ResizeObserver(() => { // Unlock and recalculate on resize since layout changes this._searchPositionLocked = false; this._updateSearchPosition(); }); this._resizeObserver.observe(this); } disconnectedCallback() { super.disconnectedCallback(); clearInterval(this._refreshTimer); document.removeEventListener('click', this._handleClickOutside); this._resizeObserver?.disconnect(); } willUpdate(changedProperties) { if (changedProperties.has('def')) { // Build internal model from user-provided def this._model = new KRTableModel(); if (this.def.title) { this._model.title = this.def.title; } if (this.def.actions) { this._model.actions = this.def.actions; } if (this.def.data) { this._model.data = this.def.data; } if (this.def.dataSource) { this._model.dataSource = this.def.dataSource; } if (typeof this.def.refreshInterval === 'number') { this._model.refreshInterval = this.def.refreshInterval; } if (typeof this.def.pageSize === 'number') { this._model.pageSize = this.def.pageSize; } if (this.def.rowClickable) { this._model.rowClickable = this.def.rowClickable; } if (this.def.rowHref) { this._model.rowHref = this.def.rowHref; } this._sorts = []; this._model.columns = this.def.columns.map(col => { const column = { ...col, filter: null }; if (!column.type) { column.type = 'string'; } if (col.sort) { this._sorts.push({ sortBy: col.id, sortDirection: col.sort }); } if (column.type === 'actions') { column.label = col.label ?? ''; column.sticky = 'right'; column.resizable = false; return column; } if (col.filterable || col.facetable) { column.filter = new KRQuery(); column.filter.field = col.id; column.filter.type = column.type; if (col.filter) { column.filter.setOperator(col.filter.operator); column.filter.setValue(col.filter.value); } else if (col.facetable && !col.filterable) { column.filter.operator = 'in'; column.filter.value = []; } else if (column.filter.type === 'string') { column.filter.operator = 'contains'; } } return column; }); if (this.def.displayedColumns) { this._model.displayedColumns = this.def.displayedColumns; } else { this._model.displayedColumns = this._model.columns.map(c => c.id); } this._fetch(); this._initRefresh(); } } updated(changedProperties) { this._updateScrollFlags(); this._syncSlottedContent(); } /** Syncs light DOM content for cells with custom render functions */ _syncSlottedContent() { const columns = this.getDisplayedColumns().filter(col => col.render); if (!columns.length) return; // Clear old slotted content this.querySelectorAll('[slot^=\"cell-\"]').forEach(el => el.remove()); // Create new slotted content this._data.forEach((row, rowIndex) => { columns.forEach(col => { const result = col.render(row); if (!result) return; const el = document.createElement('span'); el.slot = `cell-${rowIndex}-${col.id}`; if (col.type === 'actions') { el.style.display = 'flex'; el.style.gap = '8px'; } if (typeof result === 'string') { el.innerHTML = result; } else { render(result, el); } this.appendChild(el); }); }); } // ---------------------------------------------------------------------------- // Public Interface // ---------------------------------------------------------------------------- refresh() { this._fetch(); } goToPrevPage() { if (this._page > 1) { this._page--; this._fetch(); } } goToNextPage() { if (this._page < this._totalPages) { this._page++; this._fetch(); } } goToPage(page) { if (page >= 1 && page <= this._totalPages) { this._page = page; this._fetch(); } } // ---------------------------------------------------------------------------- // Data Fetching // ---------------------------------------------------------------------------- _toSolrData() { const request = { page: this._page - 1, size: this._pageSize, sorts: this._sorts, filterFields: [], queryFields: [], facetFields: [] }; for (const col of this._model.columns) { if (!col.filter || col.filter.isEmpty() || !col.filter.isValid()) { continue; } const filterData = col.filter.toSolrData(); if (col.facetable && (col.filter.operator === 'in' || col.filter.operator === 'n_in')) { filterData.tagged = true; } request.filterFields.push(filterData); } for (const col of this._model.columns) { if (!col.facetable) { continue; } request.facetFields.push({ name: col.id, type: 'FIELD', limit: 100, sort: 'count', minimumCount: 1 }); } if (this._searchQuery?.trim().length) { request.queryFields.push({ name: '_text_', operation: 'IS', value: termify(this._searchQuery, false) }); } return request; } _toDbParams() { const request = { page: this._page - 1, size: this._pageSize, sorts: this._sorts, filterFields: [], queryFields: [], facetFields: [] }; for (const col of this._model.columns) { if (!col.filter || col.filter.isEmpty() || !col.filter.isValid()) { continue; } request.filterFields.push(col.filter.toDbParams()); } if (this._searchQuery?.trim().length) { this._model.columns.filter(col => col.searchable).forEach(col => { request.queryFields.push({ name: col.id, operation: 'CONTAINS', value: this._searchQuery, and: false }); }); } return request; } /** * Fetches data from the API and updates the table. * Shows a loading spinner while fetching, then displays rows on success * or an error snackbar on failure. * Request/response format depends on dataSource.mode (solr, opensearch, db). */ _fetch() { if (this._model.data) { this._data = this._model.data; this._totalItems = this._model.data.length; this._totalPages = Math.ceil(this._model.data.length / this._pageSize); this._dataState = 'success'; return; } if (!this._model.dataSource) return; this._dataState = 'loading'; let request; if (this._model.dataSource.mode === 'db') { request = this._toDbParams(); } else { request = this._toSolrData(); } this._model.dataSource.fetch(request) .then(response => { // Parse response based on mode switch (this._model.dataSource?.mode) { case 'opensearch': { throw Error('Opensearch not supported yet'); break; } case 'db': { const res = response; this._data = res.data.content; this._totalItems = res.data.totalElements; this._totalPages = res.data.totalPages; this._pageSize = res.data.size; break; } default: { // solr const res = response; this._data = res.data.content; this._totalItems = res.data.totalElements; this._totalPages = res.data.totalPages; this._pageSize = res.data.size; this._parseFacetResults(res); } } this._dataState = 'success'; this._updateSearchPosition(); }) .catch(err => { this._dataState = 'error'; KRSnackbar.show({ message: err instanceof Error ? err.message : 'Failed to load data', type: 'error' }); }); } _parseFacetResults(response) { if (!response.data.facetFields) { return; } for (const col of this._model.columns) { if (!col.facetable) { continue; } const rawBuckets = response.data.facetFields[col.id]; if (!rawBuckets) { this._buckets.set(col.id, []); continue; } const buckets = []; for (const raw of rawBuckets) { // Solr returns boolean facet values as strings — coerce to actual booleans // so they match the filter values stored by toggle(). let val = raw.name; if (col.type === 'boolean' && typeof raw.name === 'string') { if (raw.name === 'true') { val = true; } else if (raw.name === 'false') { val = false; } } if (raw.name === null && raw.count > 0) { buckets.unshift({ val: null, count: raw.count }); } if (raw.name !== null) { buckets.push({ val: val, count: raw.count }); } } // Bucket sync: ensure selected values appear even with 0 results if (col.filter && (col.filter.operator === 'in' || col.filter.operator === 'n_in') && Array.isArray(col.filter.value)) { for (const selectedVal of col.filter.value) { if (!buckets.some(b => b.val === selectedVal)) { buckets.push({ val: selectedVal, count: 0 }); } } } this._buckets.set(col.id, buckets); } // Trigger re-render since Map mutation doesn't trigger Lit updates this._buckets = new Map(this._buckets); } /** * Sets up auto-refresh so the table automatically fetches fresh data * at a regular interval (useful for dashboards, monitoring views). * Configured via def.refreshInterval in milliseconds. */ _initRefresh() { clearInterval(this._refreshTimer); if (this._model.refreshInterval && this._model.refreshInterval > 0) { this._refreshTimer = window.setInterval(() => { this._fetch(); }, this._model.refreshInterval); } } _handleSearch(e) { const input = e.target; this._searchQuery = input.value; this._page = 1; this._fetch(); } _getGridTemplateColumns() { const cols = this.getDisplayedColumns(); return cols.map((col) => { // If column has explicit width, use it if (col.width) { return col.width; } // Actions columns: fit content without minimum if (col.type === 'actions') { return 'max-content'; } // No width specified - use content-based sizing with minimum return 'minmax(80px, auto)'; }).join(' '); } /** * Updates search position to be centered with equal gaps from title and tools. * On first call: resets to flex centering, measures position, then locks with fixed margin. * Subsequent calls are ignored unless _searchPositionLocked is reset (e.g., on resize). */ _updateSearchPosition() { // Skip if already locked (prevents shifts on pagination changes) if (this._searchPositionLocked) return; const search = this.shadowRoot?.querySelector('.search'); const searchField = search?.querySelector('.search-field'); if (!search || !searchField) return; // Reset to flex centering search.style.justifyContent = 'center'; searchField.style.marginLeft = ''; requestAnimationFrame(() => { const searchRect = search.getBoundingClientRect(); const fieldRect = searchField.getBoundingClientRect(); // Calculate how far from the left of search container the field currently is const currentOffset = fieldRect.left - searchRect.left; // Lock position: switch to flex-start and use fixed margin search.style.justifyContent = 'flex-start'; searchField.style.marginLeft = `${currentOffset}px`; // Mark as locked so pagination changes don't shift the search this._searchPositionLocked = true; }); } // ---------------------------------------------------------------------------- // Columns // ---------------------------------------------------------------------------- _toggleColumnPicker() { this._columnPickerOpen = !this._columnPickerOpen; } _toggleColumn(columnId) { if (this._model.displayedColumns.includes(columnId)) { this._model.displayedColumns = this._model.displayedColumns.filter(id => id !== columnId); } else { this._model.displayedColumns = [...this._model.displayedColumns, columnId]; } this.requestUpdate(); } // Clear any existing text selection on mousedown so we only detect // selections made during this click gesture, not stale selections from elsewhere _handleRowMouseDown() { if (!this._model.rowClickable) { return; } window.getSelection()?.removeAllRanges(); } _handleRowClick(row, rowIndex) { if (!this._model.rowClickable) { return; } const selection = window.getSelection(); if (selection && selection.toString().length > 0) { return; } this.dispatchEvent(new CustomEvent('row-click', { detail: { row, rowIndex }, bubbles: true, composed: true })); } // When a user toggles a column on via the column picker, it gets appended // to _displayedColumns. By mapping over _displayedColumns (not def.columns), // the new column appears at the right edge of the table instead of jumping // back to its original position in the column definition. // Actions columns are always moved to the end. getDisplayedColumns() { return this._model.displayedColumns .map(id => this._model.columns.find(col => col.id === id)) .sort((a, b) => { if (a.type === 'actions' && b.type !== 'actions') return 1; if (a.type !== 'actions' && b.type === 'actions') return -1; return 0; }); } // ---------------------------------------------------------------------------- // Scrolling // ---------------------------------------------------------------------------- /** * Scroll event handler that updates scroll flags in real-time as user scrolls. * Updates shadow indicators to show if more content exists left/right. */ _handleScroll(e) { const container = e.target; this._canScrollLeft = container.scrollLeft > 0; this._canScrollRight = container.scrollLeft < container.scrollWidth - container.clientWidth - 1; } /** * Updates scroll state flags for the table content container. * - _canScrollLeft: true if scrolled right (can scroll back left) * - _canScrollRight: true if more content exists to the right * - _canScrollHorizontal: true if content is wider than container * These flags control scroll shadow indicators and CSS classes. */ _updateScrollFlags() { const container = this.shadowRoot?.querySelector('.content'); if (container) { this._canScrollLeft = container.scrollLeft > 0; this._canScrollRight = container.scrollWidth > container.clientWidth && container.scrollLeft < container.scrollWidth - container.clientWidth - 1; this._canScrollHorizontal = container.scrollWidth > container.clientWidth; } this.classList.toggle('kr-table--scroll-left-available', this._canScrollLeft); this.classList.toggle('kr-table--scroll-right-available', this._canScrollRight); this.classList.toggle('kr-table--scroll-horizontal-available', this._canScrollHorizontal); this.classList.toggle('kr-table--sticky-left', this.getDisplayedColumns().some(c => c.sticky === 'left')); this.classList.toggle('kr-table--sticky-right', this.getDisplayedColumns().some(c => c.sticky === 'right')); } // ---------------------------------------------------------------------------- // Column Resizing // ---------------------------------------------------------------------------- _handleResizeStart(e, columnId) { e.preventDefault(); const headerCell = this.shadowRoot?.querySelector(`.header-cell[data-column-id=\"${columnId}\"]`); this._resizing = { columnId, startX: e.clientX, startWidth: headerCell?.offsetWidth || 200 }; document.addEventListener('mousemove', this._handleResizeMove); document.addEventListener('mouseup', this._handleResizeEnd); } // ---------------------------------------------------------------------------- // Sorting // ---------------------------------------------------------------------------- _handleSortClick(e, column) { if (e.shiftKey) { // Multi-sort: add or cycle existing const existingIndex = this._sorts.findIndex(s => s.sortBy === column.id); if (existingIndex === -1) { this._sorts.push({ sortBy: column.id, sortDirection: 'asc' }); } else { const existing = this._sorts[existingIndex]; if (existing.sortDirection === 'asc') { existing.sortDirection = 'desc'; } else { // on third click, remove sorting for the column this._sorts.splice(existingIndex, 1); } } this.requestUpdate(); } else { // Single sort: replace all let existing = null; if (this._sorts.length === 1) { existing = this._sorts.find(s => s.sortBy === column.id); } if (!existing) { this._sorts = [{ sortBy: column.id, sortDirection: 'asc' }]; } else if (existing.sortDirection === 'asc') { this._sorts = [{ sortBy: column.id, sortDirection: 'desc' }]; } else { this._sorts = []; } } this._page = 1; this._fetch(); } _renderSortIndicator(column) { if (!column.sortable) { return nothing; } const sortIndex = this._sorts.findIndex(s => s.sortBy === column.id); if (sortIndex === -1) { // Ghost arrow: visible only on hover via CSS return html ` <span class=\"header-cell__sort\" @click=${(e) => this._handleSortClick(e, column)}> <svg class=\"header-cell__sort-arrow header-cell__sort-arrow--ghost\" viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z\"/> </svg> </span> `; } let arrowStyle = {}; if (this._sorts[sortIndex].sortDirection === 'desc') { arrowStyle = { transform: 'rotate(180deg)' }; } return html ` <span class=\"header-cell__sort\" @click=${(e) => this._handleSortClick(e, column)}> <svg class=\"header-cell__sort-arrow\" viewBox=\"0 0 24 24\" fill=\"currentColor\" style=${styleMap(arrowStyle)}> <path d=\"M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z\"/> </svg> ${this._sorts.length > 1 ? html ` <span class=\"header-cell__sort-priority\">${sortIndex + 1}</span> ` : nothing} </span> `; } // ---------------------------------------------------------------------------- // Header // ---------------------------------------------------------------------------- _handleAction(action) { if (action.href) { return; } this.dispatchEvent(new CustomEvent('action', { detail: { action: action.id }, bubbles: true, composed: true })); } // ---------------------------------------------------------------------------- // Filter Handlers // ---------------------------------------------------------------------------- _handleKqlChange(e, column) { const kql = e.target.value.trim(); if (!kql) { column.filter.clear(); this.requestUpdate(); } else { column.filter.setKql(kql); this.requestUpdate(); if (!column.filter.isValid()) { return; } } this._page = 1; this._fetch(); } _handleFilterPanelToggle(e, column) { e.stopPropagation(); if (this._filterPanelOpened === column.id) { this._filterPanelOpened = null; } else { const rect = e.currentTarget.getBoundingClientRect(); let left = rect.left; if (left + 328 > window.innerWidth) { left = window.innerWidth - 328; } this._filterPanelPos = { top: rect.bottom + 4, left }; this._filterPanelOpened = column.id; if (column.facetable) { this._filterPanelTab = 'counts'; } else { this._filterPanelTab = 'filter'; } } } _handleKqlClear(column) { column.filter.clear(); this._page = 1; this._fetch(); } _handleFilterClear() { const column = this._model.columns.find(c => c.id === this._filterPanelOpened); if (column) { column.filter.clear(); if (column.facetable && !column.filterable) { column.filter.operator = 'in'; column.filter.value = []; } } this._filterPanelOpened = null; this._page = 1; this._fetch(); } _handleFilterTextKeydown(e, column) { if (e.key === 'Enter') { e.preventDefault(); this._handleFilterApply(); } } _handleOperatorChange(e, column) { column.filter.setOperator(e.target.value); this.requestUpdate(); } _handleFilterStringChange(e, column) { column.filter.setValue(e.target.value); this.requestUpdate(); } _handleFilterNumberChange(e, column) { column.filter.setValue(Number(e.target.value)); this.requestUpdate(); } _handleFilterDateChange(e, column) { column.filter.setValue(new Date(e.target.value), 'day'); this.requestUpdate(); } _handleFilterBooleanChange(e, column) { column.filter.setValue(e.target.value === 'true'); this.requestUpdate(); } _handleFilterDateStartChange(e, column) { column.filter.setStart(new Date(e.target.value), 'day'); this.requestUpdate(); } _handleFilterDateEndChange(e, column) { column.filter.setEnd(new Date(e.target.value), 'day'); this.requestUpdate(); } _handleFilterNumberStartChange(e, column) { column.filter.setStart(Number(e.target.value)); this.requestUpdate(); } _handleFilterNumberEndChange(e, column) { column.filter.setEnd(Number(e.target.value)); this.requestUpdate(); } _handleFilterListChange(e, column) { const items = e.target.value.split(',').map((v) => v.trim()).filter((v) => v !== ''); if (column.type === 'number') { column.filter.setValue(items.map((v) => Number(v))); } else { column.filter.setValue(items); } this.requestUpdate(); } _handleFilterApply() { this._filterPanelOpened = null; this._page = 1; this._fetch(); } _handleFilterPanelTabChange(e) { this._filterPanelTab = e.detail.activeTabId; } _handleBucketToggle(e, column, bucket) { column.filter.toggle(bucket.val); this._page = 1; this._fetch(); } // ---------------------------------------------------------------------------- // Rendering // ---------------------------------------------------------------------------- _renderCellContent(column, row, rowIndex) { const value = row[column.id]; if (column.render) { // Use slot to project content from light DOM so external styles apply return html `<slot name=\"cell-${rowIndex}-${column.id}\"></slot>`; } if (value === null || value === undefined) { return ''; } switch (column.type) { case 'number': if (column.format === 'currency' && typeof value === 'number') { return value.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); } return String(value); case 'date': { let date; if (value instanceof Date) { date = value; } else if (typeof value === 'string' && /^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}/.test(value)) { // MySQL datetime format (UTC): \"2026-01-28 01:33:44:517\" // Replace last colon before ms with dot, append Z for UTC const isoString = value.replace(/(\\d{2}:\\d{2}:\\d{2}):(\\d+)$/, '$1.$2').replace(' ', 'T') + 'Z'; date = new Date(isoString); } else { date = new Date(value); } // Show date and time for datetime values in UTC return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'UTC' }); } case 'boolean': if (value === true) return 'Yes'; if (value === false) return 'No'; return ''; default: return String(value); } } /** * Returns CSS classes for a header cell based on column config. */ _getHeaderCellClasses(column, index) { return { 'header-cell': true, 'header-cell--sortable': !!column.sortable, 'header-cell--align-center': column.align === 'center', 'header-cell--align-right': column.align === 'right', 'header-cell--sticky-left': column.sticky === 'left', 'header-cell--sticky-left-last': column.sticky === 'left' && !this.getDisplayedColumns().slice(index + 1).some(c => c.sticky === 'left'), 'header-cell--sticky-right': column.sticky === 'right', 'header-cell--sticky-right-first': column.sticky === 'right' && !this.getDisplayedColumns().slice(0, index).some(c => c.sticky === 'right') }; } /** * Returns CSS classes for a table cell based on column config: * - Alignment (center, right) * - Sticky positioning (left, right) * - Border classes for the last left-sticky or first right-sticky column */ _getCellClasses(column, index) { return { 'cell': true, 'cell--actions': column.type === 'actions', 'cell--align-center': column.align === 'center', 'cell--align-right': column.align === 'right', 'cell--sticky-left': column.sticky === 'left', 'cell--sticky-left-last': column.sticky === 'left' && !this.getDisplayedColumns().slice(index + 1).some(c => c.sticky === 'left'), 'cell--sticky-right': column.sticky === 'right', 'cell--sticky-right-first': column.sticky === 'right' && !this.getDisplayedColumns().slice(0, index).some(c => c.sticky === 'right') }; } /** * Returns inline styles for a table cell: * - Width (from column config or default 150px) * - Min-width (if specified) * - Left/right offset for sticky columns (calculated from widths of preceding sticky columns) */ _getCellStyle(column, index) { const styles = {}; if (column.sticky === 'left') { let leftOffset = 0; for (let i = 0; i < index; i++) { const col = this.getDisplayedColumns()[i]; if (col.sticky === 'left') { leftOffset += parseInt(col.width || '0', 10); } } styles.left = `${leftOffset}px`; } if (column.sticky === 'right') { let rightOffset = 0; for (let i = index + 1; i < this.getDisplayedColumns().length; i++) { const col = this.getDisplayedColumns()[i]; if (col.sticky === 'right') { rightOffset += parseInt(col.width || '0', 10); } } styles.right = `${rightOffset}px`; } return styles; } /** * Renders the pagination controls: * - Previous page arrow (disabled on first page) * - Range text showing \"1-50 of 150\" format * - Next page arrow (disabled on last page) * * Hidden when there's no data or all data fits on one page. */ _renderPagination() { const start = (this._page - 1) * this._pageSize + 1; const end = Math.min(this._page * this._pageSize, this._totalItems); return html ` <div class=\"pagination\"> <span class=\"pagination-icon ${this._page === 1 ? 'pagination-icon--disabled' : ''}\" @click=${this.goToPrevPage} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z\"/></svg> </span> <span class=\"pagination-info\">${start}-${end} of ${this._totalItems}</span> <span class=\"pagination-icon ${this._page === this._totalPages ? 'pagination-icon--disabled' : ''}\" @click=${this.goToNextPage} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z\"/></svg> </span> </div> `; } /** * Renders the header toolbar containing: * - Title (left) * - Search bar with view selector dropdown (center) * - Tools (right): page navigation, refresh button, column visibility picker, actions dropdown * * Hidden when there's no title, no actions, and data fits on one page. */ _renderHeader() { if (!this._model.title && !this._model.actions?.length && this._totalPages <= 1) { return nothing; } return html ` <div class=\"header\"> <div class=\"title\">${this._model.title ?? ''}</div> ${this._model.dataSource?.mode === 'db' && !this._model.columns.some(col => col.searchable) ? html `<div class=\"search\"></div>` : html ` <div class=\"search\"> <!-- TODO: Saved views dropdown <div class=\"views\"> <span>Default View</span> <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z\"/></svg> </div> --> <div class=\"search-field\"> <svg class=\"search-icon\" viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z\"/></svg> <input type=\"text\" class=\"search-input\" placeholder=\"Search...\" .value=${this._searchQuery} @input=${this._handleSearch} /> </div> </div> `} <div class=\"tools\"> ${this._renderPagination()} <span class=\"refresh\" title=\"Refresh\" @click=${() => this.refresh()}> <svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-56-87.5-88T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z\"/></svg> </span> <div class=\"column-picker-wrapper\"> <span class=\"header-icon\" title=\"Columns\" @click=${this._toggleColumnPicker}> <svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M121-280v-400q0-33 23.5-56.5T201-760h559q33 0 56.5 23.5T840-680v400q0 33-23.5 56.5T760-200H201q-33 0-56.5-23.5T121-280Zm79 0h133v-400H200v400Zm213 0h133v-400H413v400Zm213 0h133v-400H626v400Z\"/></svg> </span> <div class=\"column-picker ${this._columnPickerOpen ? 'open' : ''}\"> ${[...this._model.columns].filter(col => col.type !== 'actions').sort((a, b) => (a.label ?? a.id).localeCompare(b.label ?? b.id)).map(col => html ` <div class=\"column-picker-item\" @click=${() => this._toggleColumn(col.id)}> <div class=\"column-picker-checkbox ${this._model.displayedColumns.includes(col.id) ? 'checked' : ''}\"> <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z\"/></svg> </div> <span class=\"column-picker-label\">${col.label ?? col.id}</span> </div> `)} </div> </div> ${this._model.actions?.length === 1 ? html ` <kr-button class=\"actions\" .href=${this._model.actions[0].href} .target=${this._model.actions[0].target} @click=${() => this._handleAction(this._model.actions[0])} > ${this._model.actions[0].label} </kr-button> ` : this._model.actions?.length ? html ` <kr-button class=\"actions\" .options=${this._model.actions.map(a => ({ id: a.id, label: a.label }))} @option-select=${(e) => this._handleAction({ id: e.detail.id, label: e.detail.label })} > Actions </kr-button> ` : nothing} </div> </div> `; } /** Renders status message (loading, error, empty) */ _renderStatus() { if (this._dataState === 'loading' && this._data.length === 0) { return html `<div class=\"status\">Loading...</div>`; } if (this._dataState === 'error' && this._data.length === 0) { return html `<div class=\"status status--error\">Error loading data</div>`; } if (this._data.length === 0) { return html `<div class=\"status\">No data available</div>`; } return nothing; } _renderFilterPanel() { if (!this._filterPanelOpened) { return nothing; } const column = this._model.columns.find(c => c.id === this._filterPanelOpened); // Build filter content (operator + value input) let valueInput = html ``; if (column.filter.operator === 'empty' || column.filter.operator === 'n_empty') { valueInput = html ` <input type=\"text\" class=\"filter-panel__input\" disabled .value=${column.filter.text} /> `; } else if (column.filter.operator === 'between' && column.type === 'date') { valueInput = html ` <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${column.filter.value?.start ?? null} @change=${(e) => this._handleFilterDateStartChange(e, column)} /> <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${column.filter.value?.end ?? null} @change=${(e) => this._handleFilterDateEndChange(e, column)} /> `; } else if (column.filter.operator === 'between' && column.type === 'number') { valueInput = html ` <input type=\"number\" class=\"filter-panel__input\" placeholder=\"Start\" .value=${column.filter.value?.start ?? ''} @input=${(e) => this._handleFilterNumberStartChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} /> <input type=\"number\" class=\"filter-panel__input\" placeholder=\"End\" .value=${column.filter.value?.end ?? ''} @input=${(e) => this._handleFilterNumberEndChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} /> `; } else if (column.filter.operator === 'in' || column.filter.operator === 'n_in') { valueInput = html ` <textarea class=\"filter-panel__textarea\" rows=\"3\" placeholder=\"Values (comma-separated)\" .value=${column.filter.text} @input=${(e) => this._handleFilterListChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} ></textarea> `; } else if (column.type === 'boolean') { valueInput = html ` <kr-select-field placeholder=\"Value\" .value=${String(column.filter.value ?? '')} @change=${(e) => this._handleFilterBooleanChange(e, column)} > <kr-select-option value=\"true\">Yes</kr-select-option> <kr-select-option value=\"false\">No</kr-select-option> </kr-select-field> `; } else if (column.type === 'date') { valueInput = html ` <input type=\"date\" class=\"filter-panel__input\" .valueAsDate=${column.filter.value} @change=${(e) => this._handleFilterDateChange(e, column)} /> `; } else if (column.type === 'number') { valueInput = html ` <input type=\"number\" class=\"filter-panel__input\" placeholder=\"Value\" min=\"0\" .value=${column.filter.text} @input=${(e) => this._handleFilterNumberChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} /> `; } else { valueInput = html ` <input type=\"text\" class=\"filter-panel__input\" placeholder=\"Value\" .value=${column.filter.text} @input=${(e) => this._handleFilterStringChange(e, column)} @keydown=${(e) => this._handleFilterTextKeydown(e, column)} /> `; } const filterContent = html ` <div class=\"filter-panel__content\"> <kr-select-field .value=${column.filter.operator} @change=${(e) => this._handleOperatorChange(e, column)} > ${getOperatorsForType(column.type).map(op => html ` <kr-select-option value=${op.key}>${op.label}</kr-select-option> `)} </kr-select-field> ${valueInput} </div> `; // Build bucket list content const buckets = this._buckets.get(column.id) || []; let bucketContent; if (!buckets.length) { bucketContent = html `<div class=\"bucket-empty\">No data</div>`; } else { bucketContent = html ` <div class=\"buckets\"> ${buckets.map(bucket => { let bucketLabel = '(Empty)'; if (bucket.val !== null && bucket.val !== undefined) { if (column.type === 'boolean') { if (bucket.val === true || bucket.val === 'true') { bucketLabel = 'Yes'; } else { bucketLabel = 'No'; } } else { bucketLabel = String(bucket.val); } } let checkIcon = nothing; if (column.filter.has(bucket.val)) { checkIcon = html ` <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z\"/> </svg> `; } return html ` <div class=\"bucket\" @click=${(e) => this._handleBucketToggle(e, column, bucket)} > <div class=${classMap({ 'bucket__checkbox': true, 'bucket__checkbox--checked': column.filter.has(bucket.val) })}> ${checkIcon} </div> <span class=\"bucket__label\">${bucketLabel}</span> <span class=\"bucket__count\">${bucket.count}</span> </div> `; })} </div> `; } // Build panel body — tabs if both filterable+facetable, otherwise just the relevant content let panelBody; if (column.facetable && column.filterable) { panelBody = html ` <kr-tab-group size=\"small\" active-tab-id=${this._filterPanelTab} @tab-change=${(e) => this._handleFilterPanelTabChange(e)} > <kr-tab id=\"filter\" label=\"Filter\"> ${filterContent} </kr-tab> <kr-tab id=\"counts\" label=\"Counts\"> ${bucketContent} </kr-tab> </kr-tab-group> `; } else if (column.facetable) { panelBody = bucketContent; } else { panelBody = filterContent; } return html ` <div class=\"filter-panel\" style=${styleMap({ top: this._filterPanelPos.top + 'px', left: this._filterPanelPos.left + 'px' })} > ${panelBody} <div class=\"filter-panel__actions\"> <kr-button variant=\"outline\" color=\"secondary\" size=\"small\" @click=${this._handleFilterClear}> Clear </kr-button> <kr-button size=\"small\" @click=${this._handleFilterApply}> Apply </kr-button> </div> </div> `; } /** * Renders filter row below column headers. * Only displays for columns with filterable: true. */ _renderFilterRow() { const columns = this.getDisplayedColumns(); if (!columns.some(col => col.filterable || col.facetable)) { return nothing; } return html ` <div class=\"filter-row\"> ${columns.map((col, i) => { if (!col.filterable && !col.facetable) { return html `<div class=${classMap({ 'filter-cell': true, 'filter-cell--sticky-left': col.sticky === 'left', 'filter-cell--sticky-right': col.sticky === 'right', 'filter-cell--sticky-right-first': col.sticky === 'right' && !columns.slice(0, i).some((c) => c.sticky === 'right') })} style=${styleMap(this._getCellStyle(col, i))} ></div>`; } return html ` <div class=${classMap({ 'filter-cell': true, 'filter-cell--sticky-left': col.sticky === 'left', 'filter-cell--sticky-right': col.sticky === 'right', 'filter-cell--sticky-right-first': col.sticky === 'right' && !columns.slice(0, i).some((c) => c.sticky === 'right') })} style=${styleMap(this._getCellStyle(col, i))} > <div class=\"filter-cell__wrapper\"> <input type=\"text\" class=${classMap({ 'filter-cell__input': true, 'filter-cell__input--invalid': !col.filter.isValid() })} .value=${col.filter.kql} @change=${(e) => this._handleKqlChange(e, col)} /> ${col.filter?.kql?.length > 0 ? html ` <button class=\"filter-cell__clear\" @click=${() => this._handleKqlClear(col)} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z\"/> </svg> </button> ` : nothing} <button class=${classMap({ 'filter-cell__advanced': true, 'filter-cell__advanced--opened': this._filterPanelOpened === col.id })} @click=${(e) => this._handleFilterPanelToggle(e, col)} > <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"> <path d=\"M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z\"/> </svg> </button> </div> </div> `; })} </div> `; } /** Renders the scrollable data grid with column headers and rows. */ _renderTable() { return html ` <div class=\"wrapper\"> <div class=\"overlay-left\"></div> <div class=\"overlay-right\"></div> ${this._renderStatus()} <div class=\"content\" @scroll=${this._handleScroll}> <div class=\"table\" style=\"grid-template-columns: ${this._getGridTemplateColumns()}\"> <div class=\"header-row\"> ${this.getDisplayedColumns().map((col, i) => html ` <div class=${classMap(this._getHeaderCellClasses(col, i))} style=${styleMap(this._getCellStyle(col, i))} data-column-id=${col.id} > <span class=\"header-cell__label\">${col.label ?? col.id}</span> ${this._renderSortIndicator(col)} ${col.resizable !== false ? html `<div class=\"header-cell__resize\" @mousedown=${(e) => this._handleResizeStart(e, col.id)} ></div>` : nothing} </div> `)} </div> ${this._renderFilterRow()} ${this._data.map((row, rowIndex) => { const cells = this.getDisplayedColumns().map((col, i) => html ` <div class=${classMap(this._getCellClasses(col, i))} style=${styleMap(this._getCellStyle(col, i))} data-column-id=${col.id} > ${this._renderCellContent(col, row, rowIndex)} </div> `); if (this._model.rowHref) { return html ` <a href=${this._model.rowHref(row)} class=${classMap({ 'row': true, 'row--clickable': true, 'row--link': true })} @mousedown=${() => this._handleRowMouseDown()} @click=${() => this._handleRowClick(row, rowIndex)} >${cells}</a> `; } return html ` <div class=${classMap({ 'row': true, 'row--clickable': !!this._model.rowClickable })} @mousedown=${() => this._handleRowMouseDown()} @click=${() => this._handleRowClick(row, rowIndex)} >${cells}</div> `; })} </div> </div> </div> `; } /** * Renders a data table with: * - Header bar with title, search input with view selector, and tools (pagination, refresh, column visibility, actions dropdown) * - Scrollable grid with sticky header row and optional sticky left/right columns * - Loading, error message, or empty state when no data */ render() { if (!this._model.columns.length) { return html `<slot></slot>`; } return html ` ${this._renderHeader()} ${this._renderTable()} ${this._renderFilterPanel()} `; } }"
2586
2586
  }
2587
2587
  ],
2588
2588
  "exports": [
@@ -3324,158 +3324,6 @@
3324
3324
  }
3325
3325
  ]
3326
3326
  },
3327
- {
3328
- "kind": "javascript-module",
3329
- "path": "src/context-menu/context-menu.ts",
3330
- "declarations": [
3331
- {
3332
- "kind": "class",
3333
- "description": "Context menu component that can be opened programmatically.\n\nUsage:\n```ts\nconst result = await ContextMenu.open({\n x: event.clientX,\n y: event.clientY,\n items: [\n { id: 'edit', label: 'Edit Item' },\n { id: 'divider', label: '', divider: true },\n { id: 'add-above', label: 'Add Item Above' },\n { id: 'add-below', label: 'Add Item Below' },\n ]\n});\n\nif (result) {\n console.log('Selected:', result.id);\n}\n```",
3334
- "name": "KRContextMenu",
3335
- "members": [
3336
- {
3337
- "kind": "field",
3338
- "name": "items",
3339
- "type": {
3340
- "text": "ContextMenuItem[]"
3341
- },
3342
- "privacy": "private",
3343
- "default": "[]"
3344
- },
3345
- {
3346
- "kind": "field",
3347
- "name": "resolvePromise",
3348
- "type": {
3349
- "text": "((value: ContextMenuItem | null) => void) | null"
3350
- },
3351
- "privacy": "private",
3352
- "default": "null"
3353
- },
3354
- {
3355
- "kind": "field",
3356
- "name": "boundHandleOutsideClick",
3357
- "privacy": "private"
3358
- },
3359
- {
3360
- "kind": "field",
3361
- "name": "boundHandleKeyDown",
3362
- "privacy": "private"
3363
- },
3364
- {
3365
- "kind": "method",
3366
- "name": "open",
3367
- "static": true,
3368
- "return": {
3369
- "type": {
3370
- "text": "Promise<ContextMenuItem | null>"
3371
- }
3372
- },
3373
- "parameters": [
3374
- {
3375
- "name": "options",
3376
- "type": {
3377
- "text": "ContextMenuOptions"
3378
- }
3379
- }
3380
- ]
3381
- },
3382
- {
3383
- "kind": "method",
3384
- "name": "show",
3385
- "return": {
3386
- "type": {
3387
- "text": "Promise<ContextMenuItem | null>"
3388
- }
3389
- },
3390
- "parameters": [
3391
- {
3392
- "name": "options",
3393
- "type": {
3394
- "text": "ContextMenuOptions"
3395
- }
3396
- }
3397
- ]
3398
- },
3399
- {
3400
- "kind": "method",
3401
- "name": "handleOutsideClick",
3402
- "privacy": "private",
3403
- "parameters": [
3404
- {
3405
- "name": "e",
3406
- "type": {
3407
- "text": "Event"
3408
- }
3409
- }
3410
- ]
3411
- },
3412
- {
3413
- "kind": "method",
3414
- "name": "handleKeyDown",
3415
- "privacy": "private",
3416
- "parameters": [
3417
- {
3418
- "name": "e",
3419
- "type": {
3420
- "text": "KeyboardEvent"
3421
- }
3422
- }
3423
- ]
3424
- },
3425
- {
3426
- "kind": "method",
3427
- "name": "handleItemClick",
3428
- "privacy": "private",
3429
- "parameters": [
3430
- {
3431
- "name": "item",
3432
- "type": {
3433
- "text": "ContextMenuItem"
3434
- }
3435
- }
3436
- ]
3437
- },
3438
- {
3439
- "kind": "method",
3440
- "name": "close",
3441
- "privacy": "private",
3442
- "parameters": [
3443
- {
3444
- "name": "result",
3445
- "type": {
3446
- "text": "ContextMenuItem | null"
3447
- }
3448
- }
3449
- ]
3450
- }
3451
- ],
3452
- "superclass": {
3453
- "name": "LitElement",
3454
- "package": "lit"
3455
- },
3456
- "tagName": "kr-context-menu",
3457
- "customElement": true
3458
- }
3459
- ],
3460
- "exports": [
3461
- {
3462
- "kind": "js",
3463
- "name": "KRContextMenu",
3464
- "declaration": {
3465
- "name": "KRContextMenu",
3466
- "module": "src/context-menu/context-menu.ts"
3467
- }
3468
- },
3469
- {
3470
- "kind": "custom-element-definition",
3471
- "name": "kr-context-menu",
3472
- "declaration": {
3473
- "name": "KRContextMenu",
3474
- "module": "src/context-menu/context-menu.ts"
3475
- }
3476
- }
3477
- ]
3478
- },
3479
3327
  {
3480
3328
  "kind": "javascript-module",
3481
3329
  "path": "src/dialog/dialog.ts",
@@ -3805,6 +3653,158 @@
3805
3653
  }
3806
3654
  ]
3807
3655
  },
3656
+ {
3657
+ "kind": "javascript-module",
3658
+ "path": "src/context-menu/context-menu.ts",
3659
+ "declarations": [
3660
+ {
3661
+ "kind": "class",
3662
+ "description": "Context menu component that can be opened programmatically.\n\nUsage:\n```ts\nconst result = await ContextMenu.open({\n x: event.clientX,\n y: event.clientY,\n items: [\n { id: 'edit', label: 'Edit Item' },\n { id: 'divider', label: '', divider: true },\n { id: 'add-above', label: 'Add Item Above' },\n { id: 'add-below', label: 'Add Item Below' },\n ]\n});\n\nif (result) {\n console.log('Selected:', result.id);\n}\n```",
3663
+ "name": "KRContextMenu",
3664
+ "members": [
3665
+ {
3666
+ "kind": "field",
3667
+ "name": "items",
3668
+ "type": {
3669
+ "text": "ContextMenuItem[]"
3670
+ },
3671
+ "privacy": "private",
3672
+ "default": "[]"
3673
+ },
3674
+ {
3675
+ "kind": "field",
3676
+ "name": "resolvePromise",
3677
+ "type": {
3678
+ "text": "((value: ContextMenuItem | null) => void) | null"
3679
+ },
3680
+ "privacy": "private",
3681
+ "default": "null"
3682
+ },
3683
+ {
3684
+ "kind": "field",
3685
+ "name": "boundHandleOutsideClick",
3686
+ "privacy": "private"
3687
+ },
3688
+ {
3689
+ "kind": "field",
3690
+ "name": "boundHandleKeyDown",
3691
+ "privacy": "private"
3692
+ },
3693
+ {
3694
+ "kind": "method",
3695
+ "name": "open",
3696
+ "static": true,
3697
+ "return": {
3698
+ "type": {
3699
+ "text": "Promise<ContextMenuItem | null>"
3700
+ }
3701
+ },
3702
+ "parameters": [
3703
+ {
3704
+ "name": "options",
3705
+ "type": {
3706
+ "text": "ContextMenuOptions"
3707
+ }
3708
+ }
3709
+ ]
3710
+ },
3711
+ {
3712
+ "kind": "method",
3713
+ "name": "show",
3714
+ "return": {
3715
+ "type": {
3716
+ "text": "Promise<ContextMenuItem | null>"
3717
+ }
3718
+ },
3719
+ "parameters": [
3720
+ {
3721
+ "name": "options",
3722
+ "type": {
3723
+ "text": "ContextMenuOptions"
3724
+ }
3725
+ }
3726
+ ]
3727
+ },
3728
+ {
3729
+ "kind": "method",
3730
+ "name": "handleOutsideClick",
3731
+ "privacy": "private",
3732
+ "parameters": [
3733
+ {
3734
+ "name": "e",
3735
+ "type": {
3736
+ "text": "Event"
3737
+ }
3738
+ }
3739
+ ]
3740
+ },
3741
+ {
3742
+ "kind": "method",
3743
+ "name": "handleKeyDown",
3744
+ "privacy": "private",
3745
+ "parameters": [
3746
+ {
3747
+ "name": "e",
3748
+ "type": {
3749
+ "text": "KeyboardEvent"
3750
+ }
3751
+ }
3752
+ ]
3753
+ },
3754
+ {
3755
+ "kind": "method",
3756
+ "name": "handleItemClick",
3757
+ "privacy": "private",
3758
+ "parameters": [
3759
+ {
3760
+ "name": "item",
3761
+ "type": {
3762
+ "text": "ContextMenuItem"
3763
+ }
3764
+ }
3765
+ ]
3766
+ },
3767
+ {
3768
+ "kind": "method",
3769
+ "name": "close",
3770
+ "privacy": "private",
3771
+ "parameters": [
3772
+ {
3773
+ "name": "result",
3774
+ "type": {
3775
+ "text": "ContextMenuItem | null"
3776
+ }
3777
+ }
3778
+ ]
3779
+ }
3780
+ ],
3781
+ "superclass": {
3782
+ "name": "LitElement",
3783
+ "package": "lit"
3784
+ },
3785
+ "tagName": "kr-context-menu",
3786
+ "customElement": true
3787
+ }
3788
+ ],
3789
+ "exports": [
3790
+ {
3791
+ "kind": "js",
3792
+ "name": "KRContextMenu",
3793
+ "declaration": {
3794
+ "name": "KRContextMenu",
3795
+ "module": "src/context-menu/context-menu.ts"
3796
+ }
3797
+ },
3798
+ {
3799
+ "kind": "custom-element-definition",
3800
+ "name": "kr-context-menu",
3801
+ "declaration": {
3802
+ "name": "KRContextMenu",
3803
+ "module": "src/context-menu/context-menu.ts"
3804
+ }
3805
+ }
3806
+ ]
3807
+ },
3808
3808
  {
3809
3809
  "kind": "javascript-module",
3810
3810
  "path": "src/file-list/file-list.ts",
@@ -4909,7 +4909,7 @@
4909
4909
  "type": {
4910
4910
  "text": "Record<KROperator, KROperatorMeta>"
4911
4911
  },
4912
- "default": "{ equals: { key: 'equals', type: 'comparison', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Equals' }, n_equals: { key: 'n_equals', type: 'comparison', dataTypes: ['string', 'number', 'date', 'boolean'], label: \"Doesn't equal\" }, contains: { key: 'contains', type: 'comparison', dataTypes: ['string'], label: 'Contains' }, n_contains: { key: 'n_contains', type: 'comparison', dataTypes: ['string'], label: \"Doesn't contain\" }, starts_with: { key: 'starts_with', type: 'comparison', dataTypes: ['string'], label: 'Starts with' }, ends_with: { key: 'ends_with', type: 'comparison', dataTypes: ['string'], label: 'Ends with' }, less_than: { key: 'less_than', type: 'comparison', dataTypes: ['number', 'date'], label: 'Less than' }, less_than_equal: { key: 'less_than_equal', type: 'comparison', dataTypes: ['number', 'date'], label: 'Less than or equal' }, greater_than: { key: 'greater_than', type: 'comparison', dataTypes: ['number', 'date'], label: 'Greater than' }, greater_than_equal: { key: 'greater_than_equal', type: 'comparison', dataTypes: ['number', 'date'], label: 'Greater than or equal' }, between: { key: 'between', type: 'range', dataTypes: ['number', 'date'], label: 'Between' }, in: { key: 'in', type: 'list', dataTypes: ['string', 'number'], label: 'In' }, empty: { key: 'empty', type: 'nil', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Empty' }, n_empty: { key: 'n_empty', type: 'nil', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Not empty' }, }",
4912
+ "default": "{ equals: { key: 'equals', type: 'comparison', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Equals' }, n_equals: { key: 'n_equals', type: 'comparison', dataTypes: ['string', 'number', 'date', 'boolean'], label: \"Doesn't equal\" }, contains: { key: 'contains', type: 'comparison', dataTypes: ['string'], label: 'Contains' }, n_contains: { key: 'n_contains', type: 'comparison', dataTypes: ['string'], label: \"Doesn't contain\" }, starts_with: { key: 'starts_with', type: 'comparison', dataTypes: ['string'], label: 'Starts with' }, ends_with: { key: 'ends_with', type: 'comparison', dataTypes: ['string'], label: 'Ends with' }, less_than: { key: 'less_than', type: 'comparison', dataTypes: ['number', 'date'], label: 'Less than' }, less_than_equal: { key: 'less_than_equal', type: 'comparison', dataTypes: ['number', 'date'], label: 'Less than or equal' }, greater_than: { key: 'greater_than', type: 'comparison', dataTypes: ['number', 'date'], label: 'Greater than' }, greater_than_equal: { key: 'greater_than_equal', type: 'comparison', dataTypes: ['number', 'date'], label: 'Greater than or equal' }, between: { key: 'between', type: 'range', dataTypes: ['number', 'date'], label: 'Between' }, in: { key: 'in', type: 'list', dataTypes: ['string', 'number'], label: 'In' }, n_in: { key: 'n_in', type: 'list', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Not In' }, empty: { key: 'empty', type: 'nil', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Empty' }, n_empty: { key: 'n_empty', type: 'nil', dataTypes: ['string', 'number', 'date', 'boolean'], label: 'Not empty' }, }",
4913
4913
  "description": "Data-driven operator metadata map"
4914
4914
  },
4915
4915
  {
@@ -5157,7 +5157,7 @@
5157
5157
  }
5158
5158
  }
5159
5159
  ],
5160
- "description": "Returns true if the value array contains the given value. Only applies to 'in' operator."
5160
+ "description": "Returns true if the value array contains the given value. Only applies to 'in' and 'n_in' operators."
5161
5161
  },
5162
5162
  {
5163
5163
  "kind": "method",
@@ -5175,7 +5175,7 @@
5175
5175
  }
5176
5176
  }
5177
5177
  ],
5178
- "description": "Adds or removes a value from the 'in' list and rebuilds text/kql."
5178
+ "description": "Adds or removes a value from the 'in' or 'n_in' list and rebuilds text/kql."
5179
5179
  },
5180
5180
  {
5181
5181
  "kind": "method",