diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 58a4a5c4..580e2990 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -546,4 +546,378 @@ dialog { } } +} + +/* Advanced Search Builder */ +.toggle-advanced-btn { + display: flex; + align-items: center; + gap: 0.375rem; + background: transparent; + border: 1px solid var(--color-border); + color: var(--color-text-secondary); + + &:hover { + border-color: var(--color-brand); + color: var(--color-text); + } + + &.active { + background-color: var(--color-brand); + border-color: var(--color-brand); + color: var(--color-text-invert); + } + + svg { + flex-shrink: 0; + } +} + +.search-builder { + position: fixed; + top: var(--header-height); + left: 0; + right: 0; + background-color: var(--color-background); + border-bottom: 1px solid var(--color-border); + padding: 1rem; + z-index: 9; + max-height: 50vh; + overflow-y: auto; + transition: transform 0.2s ease, opacity 0.2s ease; + + &.collapsed { + display: none; + } +} + +.search-builder-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.search-builder-title { + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-secondary); +} + +.clear-all-btn { + background: transparent; + border: none; + color: var(--color-text-tertiary); + font-size: 0.75rem; + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + + &:hover { + color: var(--color-text); + background-color: var(--color-surface); + } +} + +.filter-rows { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.filter-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.filter-row select, +.filter-row input { + font-size: 0.8125rem; + line-height: 1.1; + padding: 0.5rem 0.625rem; + border-radius: 0.25rem; + border: 1px solid var(--color-border); + height: 2rem; + background-color: var(--color-background); + color: var(--color-text); + + &:focus { + border-color: var(--color-brand); + outline: none; + } +} + +.filter-row .connector-select { + width: 5rem; +} + +.filter-row .field-select { + width: 9rem; +} + +.filter-row .operator-select { + width: 7rem; +} + +.filter-row .value-input, +.filter-row .value-select { + flex: 1; + min-width: 8rem; + max-width: 16rem; +} + +.filter-row .remove-filter-btn { + background: transparent; + border: none; + color: var(--color-text-tertiary); + cursor: pointer; + padding: 0.25rem; + border-radius: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + flex-shrink: 0; + + &:hover { + color: var(--color-text); + background-color: var(--color-surface); + } +} + +.add-filter-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + background: transparent; + border: 1px dashed var(--color-border); + color: var(--color-text-secondary); + font-size: 0.8125rem; + cursor: pointer; + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + margin-bottom: 0.75rem; + + &:hover { + border-color: var(--color-brand); + color: var(--color-text); + } +} + +.active-filters-summary { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + font-size: 0.75rem; + color: var(--color-text-tertiary); + + &:empty { + display: none; + } +} + +.filter-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + background-color: var(--color-surface); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-family: var(--font-mono); + font-size: 0.6875rem; +} + +.filter-chip-connector { + color: var(--color-brand); + font-weight: 500; + margin: 0 0.125rem; +} + +/* Adjust table margin when search builder is visible */ +body.search-builder-open table { + margin-top: calc(var(--header-height) + var(--search-builder-height, 150px)); +} + +/* Adjust sticky header position when search builder is visible */ +body.search-builder-open table thead th { + top: calc(var(--header-height) + var(--search-builder-height, 150px)); +} + +@media (max-width: 45rem) { + .toggle-advanced-btn span { + display: none; + } + + .filter-row { + flex-direction: column; + align-items: stretch; + } + + .filter-row select, + .filter-row input { + width: 100%; + } + + .filter-row .connector-select, + .filter-row .field-select, + .filter-row .operator-select, + .filter-row .value-input, + .filter-row .value-select { + width: 100%; + max-width: none; + } + + .filter-row .remove-filter-btn { + align-self: flex-end; + } + + .filter-group { + padding: 0.5rem; + } +} + +/* Filter Groups */ +.filter-groups { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.filter-group { + border: 1px solid var(--color-border); + border-radius: 0.375rem; + padding: 0.75rem; + background-color: var(--color-surface); + position: relative; +} + +.filter-group-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border); +} + +.filter-group-label { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-tertiary); + display: flex; + align-items: center; + gap: 0.375rem; +} + +.filter-group-label select { + font-size: 0.6875rem; + padding: 0.125rem 0.25rem; + border: 1px solid var(--color-border); + border-radius: 0.25rem; + background-color: var(--color-background); + color: var(--color-text-secondary); + text-transform: uppercase; + cursor: pointer; +} + +.remove-group-btn { + background: transparent; + border: none; + color: var(--color-text-tertiary); + cursor: pointer; + padding: 0.25rem; + border-radius: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: var(--color-text); + background-color: var(--color-background); + } +} + +.filter-group-conditions { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.add-condition-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + background: transparent; + border: none; + color: var(--color-text-tertiary); + font-size: 0.75rem; + cursor: pointer; + padding: 0.375rem 0.5rem; + margin-top: 0.375rem; + border-radius: 0.25rem; + + &:hover { + color: var(--color-text); + background-color: var(--color-background); + } +} + +.group-connector { + display: flex; + align-items: center; + justify-content: flex-start; + padding: 0.25rem 0; +} + +.group-connector select { + font-size: 0.75rem; + font-weight: 500; + padding: 0.25rem 0.5rem; + border: 1px dashed var(--color-border); + border-radius: 0.25rem; + background-color: var(--color-background); + color: var(--color-brand); + text-transform: uppercase; + cursor: pointer; + + &:hover { + border-color: var(--color-brand); + } +} + +.add-group-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + background: transparent; + border: 1px dashed var(--color-border); + color: var(--color-text-secondary); + font-size: 0.8125rem; + cursor: pointer; + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + margin-top: 0.5rem; + + &:hover { + border-color: var(--color-brand); + color: var(--color-text); + } +} + +/* Filter chip styles for group summary */ +.filter-group-chip { + display: inline; +} + +.filter-chip-connector-inline { + color: var(--color-text-tertiary); + font-size: 0.625rem; + margin: 0 0.25rem; } \ No newline at end of file diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 81afb434..12fc0b1b 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -146,6 +146,30 @@ document.querySelectorAll("th.sortable").forEach((header) => { /////////////////// // Handle Search /////////////////// + +import { + FIELD_CONFIGS, + evaluateFilterExpression, + serializeFilterExpression, + deserializeFilterExpression, + generateExpressionSummary, + type FilterCondition, + type FilterGroup, + type FilterExpression, +} from './search'; + +let filterExpression: FilterExpression = { groups: [], groupConnectors: [] }; +let idCounter = 0; + +// DOM elements for advanced search +const toggleAdvanced = document.getElementById('toggle-advanced')!; +const searchBuilder = document.getElementById('search-builder')!; +const filterGroupsContainer = document.getElementById('filter-groups')!; +const addGroupBtn = document.getElementById('add-group')!; +const clearAllBtn = document.getElementById('clear-all-filters')!; +const activeFiltersSummary = document.getElementById('active-filters-summary')!; + +// Simple search function (unchanged) function filterTable(value: string) { const lowerCaseValues = value.toLowerCase().split(",").filter(str => str.trim() !== ""); const rows = document.querySelectorAll( @@ -157,13 +181,387 @@ function filterTable(value: string) { cell.textContent!.toLowerCase() ); const isVisible = lowerCaseValues.length === 0 || - lowerCaseValues.some((lowerCaseValue) => cellTexts.some((text) => text.includes(lowerCaseValue))); + lowerCaseValues.some((lowerCaseValue) => cellTexts.some((text) => text.includes(lowerCaseValue))); row.style.display = isVisible ? "" : "none"; }); updateQueryParams({ search: value || null }); } +// ID generator +function generateId(): string { + return `id-${++idCounter}`; +} + +// Create a condition row within a group +function createConditionRow(groupId: string, condition?: Partial): HTMLElement { + const id = condition?.id || generateId(); + + const row = document.createElement('div'); + row.className = 'filter-row'; + row.dataset.conditionId = id; + + // Field selector + const fieldSelect = document.createElement('select'); + fieldSelect.className = 'field-select'; + fieldSelect.innerHTML = FIELD_CONFIGS.map(f => + `` + ).join(''); + fieldSelect.value = condition?.field || FIELD_CONFIGS[0].name; + fieldSelect.addEventListener('change', () => { + updateOperatorsForField(row, fieldSelect.value); + updateValueInputForField(row, fieldSelect.value); + updateExpressionAndApply(); + }); + + // Operator selector + const operatorSelect = document.createElement('select'); + operatorSelect.className = 'operator-select'; + const fieldConfig = FIELD_CONFIGS.find(f => f.name === fieldSelect.value)!; + operatorSelect.innerHTML = fieldConfig.operators.map(op => + `` + ).join(''); + operatorSelect.value = condition?.operator || fieldConfig.operators[0].value; + operatorSelect.addEventListener('change', () => updateExpressionAndApply()); + + // Value input (text, select for boolean, or date input) + let valueElement: HTMLInputElement | HTMLSelectElement; + if (fieldConfig.type === 'boolean') { + valueElement = document.createElement('select'); + valueElement.className = 'value-select'; + valueElement.innerHTML = ` + + + `; + } else if (fieldConfig.type === 'date') { + valueElement = document.createElement('input'); + valueElement.type = 'month'; + valueElement.className = 'value-input date-input'; + } else { + valueElement = document.createElement('input'); + valueElement.type = 'text'; + valueElement.className = 'value-input'; + valueElement.placeholder = 'Enter value...'; + } + valueElement.addEventListener('input', () => updateExpressionAndApply()); + valueElement.addEventListener('change', () => updateExpressionAndApply()); + if (condition?.value) { + valueElement.value = condition.value; + } + + // Remove button + const removeBtn = document.createElement('button'); + removeBtn.className = 'remove-filter-btn'; + removeBtn.innerHTML = ` + + + + + `; + removeBtn.addEventListener('click', () => removeCondition(groupId, id)); + + row.appendChild(fieldSelect); + row.appendChild(operatorSelect); + row.appendChild(valueElement); + row.appendChild(removeBtn); + + return row; +} + +// Create a filter group element +function createFilterGroupElement(group?: Partial, groupIndex?: number): HTMLElement { + const id = group?.id || generateId(); + const isFirstGroup = groupIndex === 0 || filterExpression.groups.length === 0; + + const groupElement = document.createElement('div'); + groupElement.className = 'filter-group'; + groupElement.dataset.groupId = id; + + // Group header + const header = document.createElement('div'); + header.className = 'filter-group-header'; + + const label = document.createElement('div'); + label.className = 'filter-group-label'; + label.innerHTML = `Match conditions`; + + const internalConnectorSelect = label.querySelector('.internal-connector') as HTMLSelectElement; + internalConnectorSelect.value = group?.internalConnector || 'OR'; + internalConnectorSelect.addEventListener('change', () => updateExpressionAndApply()); + + const removeGroupBtn = document.createElement('button'); + removeGroupBtn.className = 'remove-group-btn'; + removeGroupBtn.innerHTML = ` + + + + + `; + removeGroupBtn.addEventListener('click', () => removeGroup(id)); + + header.appendChild(label); + header.appendChild(removeGroupBtn); + + // Conditions container + const conditionsContainer = document.createElement('div'); + conditionsContainer.className = 'filter-group-conditions'; + + // Add existing conditions if restoring + if (group?.conditions) { + group.conditions.forEach(condition => { + conditionsContainer.appendChild(createConditionRow(id, condition)); + }); + } + + // Add condition button + const addConditionBtn = document.createElement('button'); + addConditionBtn.className = 'add-condition-btn'; + addConditionBtn.innerHTML = ` + + + + + Add Condition + `; + addConditionBtn.addEventListener('click', () => { + conditionsContainer.appendChild(createConditionRow(id)); + updateExpressionAndApply(); + updateSearchBuilderHeight(); + }); + + groupElement.appendChild(header); + groupElement.appendChild(conditionsContainer); + groupElement.appendChild(addConditionBtn); + + return groupElement; +} + +// Create a group connector element (AND/OR between groups) +function createGroupConnector(index: number): HTMLElement { + const connector = document.createElement('div'); + connector.className = 'group-connector'; + connector.dataset.connectorIndex = String(index); + connector.innerHTML = ` + + `; + const select = connector.querySelector('select')!; + select.value = filterExpression.groupConnectors[index] || 'AND'; + select.addEventListener('change', () => updateExpressionAndApply()); + return connector; +} + +function updateOperatorsForField(row: HTMLElement, fieldName: string) { + const operatorSelect = row.querySelector('.operator-select') as HTMLSelectElement; + const fieldConfig = FIELD_CONFIGS.find(f => f.name === fieldName)!; + operatorSelect.innerHTML = fieldConfig.operators.map(op => + `` + ).join(''); +} + +function updateValueInputForField(row: HTMLElement, fieldName: string) { + const fieldConfig = FIELD_CONFIGS.find(f => f.name === fieldName)!; + const oldValue = row.querySelector('.value-input, .value-select') as HTMLInputElement | HTMLSelectElement; + + let newValue: HTMLInputElement | HTMLSelectElement; + if (fieldConfig.type === 'boolean') { + newValue = document.createElement('select'); + newValue.className = 'value-select'; + newValue.innerHTML = ` + + + `; + } else if (fieldConfig.type === 'date') { + newValue = document.createElement('input'); + newValue.type = 'month'; + newValue.className = 'value-input date-input'; + } else { + newValue = document.createElement('input'); + newValue.type = 'text'; + newValue.className = 'value-input'; + newValue.placeholder = 'Enter value...'; + } + newValue.addEventListener('input', () => updateExpressionAndApply()); + newValue.addEventListener('change', () => updateExpressionAndApply()); + + oldValue.replaceWith(newValue); +} + +function addGroup(group?: Partial) { + const groupIndex = filterExpression.groups.length; + + // Add group connector if not first group + if (groupIndex > 0) { + filterGroupsContainer.appendChild(createGroupConnector(groupIndex - 1)); + } + + const groupElement = createFilterGroupElement(group, groupIndex); + filterGroupsContainer.appendChild(groupElement); + + // Add initial condition if new group + if (!group?.conditions?.length) { + const conditionsContainer = groupElement.querySelector('.filter-group-conditions')!; + const groupId = groupElement.dataset.groupId!; + conditionsContainer.appendChild(createConditionRow(groupId)); + } + + updateExpressionAndApply(); + updateSearchBuilderHeight(); +} + +function removeGroup(groupId: string) { + const groupElement = filterGroupsContainer.querySelector(`[data-group-id="${groupId}"]`); + if (groupElement) { + // Remove preceding connector if exists + const prevSibling = groupElement.previousElementSibling; + if (prevSibling?.classList.contains('group-connector')) { + prevSibling.remove(); + } + // Or remove following connector if this was first group + const nextSibling = groupElement.nextElementSibling; + if (!prevSibling && nextSibling?.classList.contains('group-connector')) { + nextSibling.remove(); + } + + groupElement.remove(); + updateExpressionAndApply(); + updateSearchBuilderHeight(); + } +} + +function removeCondition(groupId: string, conditionId: string) { + const groupElement = filterGroupsContainer.querySelector(`[data-group-id="${groupId}"]`); + if (groupElement) { + const conditionRow = groupElement.querySelector(`[data-condition-id="${conditionId}"]`); + if (conditionRow) { + conditionRow.remove(); + updateExpressionAndApply(); + updateSearchBuilderHeight(); + } + } +} + +function clearAllFilters() { + filterGroupsContainer.innerHTML = ''; + filterExpression = { groups: [], groupConnectors: [] }; + applyAdvancedFilters(); + updateActiveFiltersSummary(); + updateQueryParams({ filters: null }); + updateSearchBuilderHeight(); +} + +function getExpressionFromDOM(): FilterExpression { + const groups: FilterGroup[] = []; + const groupConnectors: ('AND' | 'OR')[] = []; + + const groupElements = filterGroupsContainer.querySelectorAll('.filter-group'); + const connectorElements = filterGroupsContainer.querySelectorAll('.group-connector'); + + groupElements.forEach((groupElement) => { + const groupId = (groupElement as HTMLElement).dataset.groupId || generateId(); + const internalConnector = (groupElement.querySelector('.internal-connector') as HTMLSelectElement)?.value as 'AND' | 'OR' || 'OR'; + + const conditions: FilterCondition[] = []; + const conditionRows = groupElement.querySelectorAll('.filter-row'); + + conditionRows.forEach((row) => { + const id = (row as HTMLElement).dataset.conditionId || generateId(); + const field = (row.querySelector('.field-select') as HTMLSelectElement)?.value; + const operator = (row.querySelector('.operator-select') as HTMLSelectElement)?.value; + const value = (row.querySelector('.value-input, .value-select') as HTMLInputElement | HTMLSelectElement)?.value; + + conditions.push({ id, field, operator, value, connector: 'AND' }); + }); + + groups.push({ id: groupId, conditions, internalConnector }); + }); + + connectorElements.forEach((connectorElement) => { + const select = connectorElement.querySelector('select') as HTMLSelectElement; + groupConnectors.push((select?.value as 'AND' | 'OR') || 'AND'); + }); + + return { groups, groupConnectors }; +} + +function updateExpressionAndApply() { + filterExpression = getExpressionFromDOM(); + applyAdvancedFilters(); + updateActiveFiltersSummary(); + serializeExpressionToURL(); +} + +function applyAdvancedFilters() { + const rows = document.querySelectorAll( + "table tbody tr" + ) as NodeListOf; + + // If no groups or all groups have empty conditions, show all + const hasActiveFilters = filterExpression.groups.some(g => + g.conditions.some(c => c.value.trim() !== '') + ); + + if (!hasActiveFilters) { + rows.forEach(row => row.style.display = ''); + return; + } + + rows.forEach((row) => { + const getCellValue = (columnIndex: number) => + row.cells[columnIndex]?.textContent?.trim() || ''; + + const isVisible = evaluateFilterExpression(getCellValue, filterExpression); + row.style.display = isVisible ? '' : 'none'; + }); +} + +function updateActiveFiltersSummary() { + activeFiltersSummary.innerHTML = generateExpressionSummary(filterExpression); +} + +function serializeExpressionToURL() { + const serialized = serializeFilterExpression(filterExpression); + updateQueryParams({ filters: serialized || null }); +} + +function updateSearchBuilderHeight() { + if (!searchBuilder.classList.contains('collapsed')) { + requestAnimationFrame(() => { + const height = searchBuilder.offsetHeight; + document.documentElement.style.setProperty('--search-builder-height', `${height}px`); + }); + } +} + +// Toggle advanced search panel +toggleAdvanced.addEventListener('click', () => { + const isCollapsed = searchBuilder.classList.contains('collapsed'); + searchBuilder.classList.toggle('collapsed'); + toggleAdvanced.classList.toggle('active'); + document.body.classList.toggle('search-builder-open', isCollapsed); + + // Update table margin dynamically + if (isCollapsed) { + // If panel is being opened and there are no groups, add one as placeholder + if (filterExpression.groups.length === 0) { + addGroup(); + } + updateSearchBuilderHeight(); + } +}); + +// Add group button +addGroupBtn.addEventListener('click', () => addGroup()); + +// Clear all button +clearAllBtn.addEventListener('click', clearAllFilters); + +// Simple search search.addEventListener("input", () => { filterTable(search.value); }); @@ -234,6 +632,35 @@ function initializeFromURL() { const direction = (params.get("order") as "asc" | "desc") || "asc"; sortTable(columnIndex, direction); })(); + + // Restore advanced filters from URL + (() => { + const filtersParam = params.get("filters"); + if (!filtersParam) return; + + const restoredExpression = deserializeFilterExpression(filtersParam, generateId); + if (restoredExpression.groups.length > 0) { + // Open the search builder panel + searchBuilder.classList.remove('collapsed'); + toggleAdvanced.classList.add('active'); + document.body.classList.add('search-builder-open'); + + // Create filter groups + restoredExpression.groups.forEach((group, index) => { + // Add group connector if not first group + if (index > 0) { + filterGroupsContainer.appendChild(createGroupConnector(index - 1)); + filterExpression.groupConnectors.push(restoredExpression.groupConnectors[index - 1] || 'AND'); + } + + const groupElement = createFilterGroupElement(group, index); + filterGroupsContainer.appendChild(groupElement); + }); + + filterExpression = restoredExpression; + updateSearchBuilderHeight(); + } + })(); } document.addEventListener("DOMContentLoaded", initializeFromURL); diff --git a/packages/web/src/render.tsx b/packages/web/src/render.tsx index 4a67ee5e..07a73551 100644 --- a/packages/web/src/render.tsx +++ b/packages/web/src/render.tsx @@ -207,12 +207,35 @@ export const Rendered = renderToString(
- + ⌘K
+ + @@ -437,8 +460,8 @@ export const Rendered = renderToString( {model.structured_output === undefined ? "-" : model.structured_output - ? "Yes" - : "No"} + ? "Yes" + : "No"} diff --git a/packages/web/src/search.test.ts b/packages/web/src/search.test.ts new file mode 100644 index 00000000..85ecba61 --- /dev/null +++ b/packages/web/src/search.test.ts @@ -0,0 +1,658 @@ +/** + * Tests for the Advanced Search Module + */ +import { describe, expect, it, beforeEach } from 'bun:test'; +import { + evaluateOperator, + evaluateCondition, + evaluateFilters, + evaluateFilterGroup, + evaluateFilterExpression, + getActiveFilters, + matchesSimpleSearch, + serializeFilters, + deserializeFilters, + serializeFilterExpression, + deserializeFilterExpression, + generateFilterSummary, + generateExpressionSummary, + getFieldConfig, + FIELD_CONFIGS, + type FilterCondition, + type FilterGroup, + type FilterExpression, +} from './search'; + +describe('evaluateOperator', () => { + describe('equals operator', () => { + it('should return true for exact match', () => { + expect(evaluateOperator('anthropic', 'equals', 'anthropic')).toBe(true); + }); + + it('should return true for case-insensitive match', () => { + expect(evaluateOperator('Anthropic', 'equals', 'anthropic')).toBe(true); + }); + + it('should return false for partial match', () => { + expect(evaluateOperator('anthropic-claude', 'equals', 'anthropic')).toBe(false); + }); + + it('should handle whitespace', () => { + expect(evaluateOperator(' anthropic ', 'equals', 'anthropic')).toBe(true); + }); + }); + + describe('contains operator', () => { + it('should return true for substring match', () => { + expect(evaluateOperator('anthropic-claude-3', 'contains', 'claude')).toBe(true); + }); + + it('should return true for exact match', () => { + expect(evaluateOperator('claude', 'contains', 'claude')).toBe(true); + }); + + it('should return false for no match', () => { + expect(evaluateOperator('openai-gpt', 'contains', 'claude')).toBe(false); + }); + }); + + describe('startsWith operator', () => { + it('should return true when value starts with search term', () => { + expect(evaluateOperator('claude-3-opus', 'startsWith', 'claude')).toBe(true); + }); + + it('should return false when value does not start with search term', () => { + expect(evaluateOperator('gpt-4-claude', 'startsWith', 'claude')).toBe(false); + }); + }); + + describe('is operator (boolean)', () => { + it('should return true for matching boolean text', () => { + expect(evaluateOperator('Yes', 'is', 'yes')).toBe(true); + }); + + it('should return false for non-matching boolean text', () => { + expect(evaluateOperator('No', 'is', 'yes')).toBe(false); + }); + }); + + describe('unknown operator', () => { + it('should return true for unknown operator', () => { + expect(evaluateOperator('anything', 'unknownOp', 'value')).toBe(true); + }); + }); + + describe('after operator (dates)', () => { + it('should return true when cell date is after search date', () => { + expect(evaluateOperator('Feb 2025', 'after', 'Jan 2025')).toBe(true); + }); + + it('should return false when cell date is before search date', () => { + expect(evaluateOperator('Dec 2024', 'after', 'Jan 2025')).toBe(false); + }); + + it('should handle ISO date format', () => { + expect(evaluateOperator('2025-02', 'after', '2025-01')).toBe(true); + }); + + it('should return false for equal dates', () => { + expect(evaluateOperator('Jan 2025', 'after', 'Jan 2025')).toBe(false); + }); + + it('should return false for invalid dates', () => { + expect(evaluateOperator('-', 'after', 'Jan 2025')).toBe(false); + }); + }); + + describe('before operator (dates)', () => { + it('should return true when cell date is before search date', () => { + expect(evaluateOperator('Dec 2024', 'before', 'Jan 2025')).toBe(true); + }); + + it('should return false when cell date is after search date', () => { + expect(evaluateOperator('Feb 2025', 'before', 'Jan 2025')).toBe(false); + }); + + it('should return false for equal dates', () => { + expect(evaluateOperator('Jan 2025', 'before', 'Jan 2025')).toBe(false); + }); + }); +}); + +describe('evaluateCondition', () => { + const mockGetCellValue = (values: string[]) => (columnIndex: number) => values[columnIndex] || ''; + + it('should evaluate a text condition correctly', () => { + const getCellValue = mockGetCellValue(['Anthropic', 'Claude 3', 'anthropic', 'claude-3-opus']); + const condition: FilterCondition = { + id: '1', + field: 'provider', + operator: 'equals', + value: 'anthropic', + connector: 'AND', + }; + expect(evaluateCondition(getCellValue, condition)).toBe(true); + }); + + it('should return true for unknown field', () => { + const getCellValue = mockGetCellValue(['Anthropic']); + const condition: FilterCondition = { + id: '1', + field: 'unknownField', + operator: 'equals', + value: 'test', + connector: 'AND', + }; + expect(evaluateCondition(getCellValue, condition)).toBe(true); + }); +}); + +describe('evaluateFilters', () => { + const mockGetCellValue = (values: string[]) => (columnIndex: number) => values[columnIndex] || ''; + + it('should return true for empty filter list', () => { + const getCellValue = mockGetCellValue(['Anthropic']); + expect(evaluateFilters(getCellValue, [])).toBe(true); + }); + + it('should evaluate single filter correctly', () => { + const getCellValue = mockGetCellValue(['Anthropic', 'Claude 3']); + const filters: FilterCondition[] = [ + { id: '1', field: 'provider', operator: 'contains', value: 'anthro', connector: 'AND' }, + ]; + expect(evaluateFilters(getCellValue, filters)).toBe(true); + }); + + it('should evaluate AND logic correctly (both true)', () => { + const getCellValue = mockGetCellValue(['Anthropic', 'Claude 3']); + const filters: FilterCondition[] = [ + { id: '1', field: 'provider', operator: 'contains', value: 'anthro', connector: 'AND' }, + { id: '2', field: 'model', operator: 'contains', value: 'claude', connector: 'AND' }, + ]; + expect(evaluateFilters(getCellValue, filters)).toBe(true); + }); + + it('should evaluate AND logic correctly (one false)', () => { + const getCellValue = mockGetCellValue(['Anthropic', 'Claude 3']); + const filters: FilterCondition[] = [ + { id: '1', field: 'provider', operator: 'contains', value: 'anthro', connector: 'AND' }, + { id: '2', field: 'model', operator: 'contains', value: 'gpt', connector: 'AND' }, + ]; + expect(evaluateFilters(getCellValue, filters)).toBe(false); + }); + + it('should evaluate OR logic correctly (one true)', () => { + const getCellValue = mockGetCellValue(['Anthropic', 'Claude 3']); + const filters: FilterCondition[] = [ + { id: '1', field: 'provider', operator: 'contains', value: 'openai', connector: 'AND' }, + { id: '2', field: 'provider', operator: 'contains', value: 'anthro', connector: 'OR' }, + ]; + expect(evaluateFilters(getCellValue, filters)).toBe(true); + }); + + it('should evaluate complex AND/OR combinations', () => { + // Anthropic Claude model + const getCellValue = mockGetCellValue(['Anthropic', 'Claude 3']); + + // (provider=openai AND model=gpt) OR provider=anthropic + // This should be: false AND false OR true = true + const filters: FilterCondition[] = [ + { id: '1', field: 'provider', operator: 'equals', value: 'openai', connector: 'AND' }, + { id: '2', field: 'model', operator: 'contains', value: 'gpt', connector: 'AND' }, + { id: '3', field: 'provider', operator: 'contains', value: 'anthropic', connector: 'OR' }, + ]; + expect(evaluateFilters(getCellValue, filters)).toBe(true); + }); +}); + +describe('getActiveFilters', () => { + it('should filter out empty values', () => { + const filters: FilterCondition[] = [ + { id: '1', field: 'provider', operator: 'equals', value: '', connector: 'AND' }, + { id: '2', field: 'model', operator: 'equals', value: 'claude', connector: 'AND' }, + { id: '3', field: 'modelId', operator: 'equals', value: ' ', connector: 'AND' }, + ]; + const active = getActiveFilters(filters); + expect(active).toHaveLength(1); + expect(active[0].value).toBe('claude'); + }); + + it('should return all filters if all have values', () => { + const filters: FilterCondition[] = [ + { id: '1', field: 'provider', operator: 'equals', value: 'anthropic', connector: 'AND' }, + { id: '2', field: 'model', operator: 'equals', value: 'claude', connector: 'AND' }, + ]; + expect(getActiveFilters(filters)).toHaveLength(2); + }); +}); + +describe('matchesSimpleSearch', () => { + const cells = ['Anthropic', 'Claude 3 Opus', 'anthropic', 'claude-3-opus']; + + it('should return true for empty search', () => { + expect(matchesSimpleSearch(cells, '')).toBe(true); + }); + + it('should return true when any cell matches', () => { + expect(matchesSimpleSearch(cells, 'opus')).toBe(true); + }); + + it('should support comma-separated search terms', () => { + expect(matchesSimpleSearch(cells, 'gpt,claude')).toBe(true); + }); + + it('should return false when no cell matches', () => { + expect(matchesSimpleSearch(cells, 'openai')).toBe(false); + }); +}); + +describe('serializeFilters / deserializeFilters', () => { + let idCounter = 0; + const generateId = () => `test-${++idCounter}`; + + beforeEach(() => { + idCounter = 0; + }); + + it('should serialize filters to URL format', () => { + const filters: FilterCondition[] = [ + { id: '1', field: 'provider', operator: 'contains', value: 'anthropic', connector: 'AND' }, + ]; + const serialized = serializeFilters(filters); + expect(serialized).toBe('AND:provider:contains:anthropic'); + }); + + it('should handle multiple filters', () => { + const filters: FilterCondition[] = [ + { id: '1', field: 'provider', operator: 'contains', value: 'anthro', connector: 'AND' }, + { id: '2', field: 'model', operator: 'equals', value: 'claude', connector: 'OR' }, + ]; + const serialized = serializeFilters(filters); + expect(serialized).toBe('AND:provider:contains:anthro|OR:model:equals:claude'); + }); + + it('should deserialize filters from URL format', () => { + const serialized = 'AND:provider:contains:anthropic|OR:model:equals:claude'; + const filters = deserializeFilters(serialized, generateId); + + expect(filters).toHaveLength(2); + expect(filters[0].field).toBe('provider'); + expect(filters[0].operator).toBe('contains'); + expect(filters[0].value).toBe('anthropic'); + expect(filters[0].connector).toBe('AND'); + expect(filters[1].connector).toBe('OR'); + }); + + it('should handle URL-encoded values', () => { + const filters: FilterCondition[] = [ + { id: '1', field: 'model', operator: 'contains', value: 'claude 3', connector: 'AND' }, + ]; + const serialized = serializeFilters(filters); + const deserialized = deserializeFilters(serialized, generateId); + + expect(deserialized[0].value).toBe('claude 3'); + }); + + it('should return empty array for empty input', () => { + expect(deserializeFilters('', generateId)).toEqual([]); + }); +}); + +describe('generateFilterSummary', () => { + it('should return empty string for no filters', () => { + expect(generateFilterSummary([])).toBe(''); + }); + + it('should return empty string for filters with empty values', () => { + const filters: FilterCondition[] = [ + { id: '1', field: 'provider', operator: 'equals', value: '', connector: 'AND' }, + ]; + expect(generateFilterSummary(filters)).toBe(''); + }); + + it('should generate HTML summary for single filter', () => { + const filters: FilterCondition[] = [ + { id: '1', field: 'provider', operator: 'contains', value: 'anthropic', connector: 'AND' }, + ]; + const summary = generateFilterSummary(filters); + expect(summary).toContain('Provider'); + expect(summary).toContain('contains'); + expect(summary).toContain('anthropic'); + expect(summary).toContain('filter-chip'); + }); + + it('should include connector for multiple filters', () => { + const filters: FilterCondition[] = [ + { id: '1', field: 'provider', operator: 'contains', value: 'anthro', connector: 'AND' }, + { id: '2', field: 'model', operator: 'equals', value: 'claude', connector: 'OR' }, + ]; + const summary = generateFilterSummary(filters); + expect(summary).toContain('filter-chip-connector'); + expect(summary).toContain('OR'); + }); +}); + +describe('getFieldConfig', () => { + it('should return field config for valid field name', () => { + const config = getFieldConfig('provider'); + expect(config).toBeDefined(); + expect(config?.label).toBe('Provider'); + expect(config?.columnIndex).toBe(0); + }); + + it('should return undefined for unknown field', () => { + expect(getFieldConfig('unknownField')).toBeUndefined(); + }); +}); + +describe('FIELD_CONFIGS', () => { + it('should have all expected fields', () => { + const fieldNames = FIELD_CONFIGS.map(f => f.name); + expect(fieldNames).toContain('provider'); + expect(fieldNames).toContain('model'); + expect(fieldNames).toContain('providerId'); + expect(fieldNames).toContain('modelId'); + expect(fieldNames).toContain('toolCall'); + expect(fieldNames).toContain('reasoning'); + }); + + it('should have correct types for boolean fields', () => { + const booleanFields = FIELD_CONFIGS.filter(f => f.type === 'boolean'); + expect(booleanFields.length).toBeGreaterThan(0); + booleanFields.forEach(field => { + expect(field.operators).toHaveLength(1); + expect(field.operators[0].value).toBe('is'); + }); + }); +}); + +// Tests for Grouped Filter Clauses +describe('evaluateFilterGroup', () => { + const mockGetCellValue = (values: string[]) => (columnIndex: number) => values[columnIndex] || ''; + + it('should return true for empty group', () => { + const getCellValue = mockGetCellValue(['Anthropic']); + const group: FilterGroup = { + id: '1', + conditions: [], + internalConnector: 'OR', + }; + expect(evaluateFilterGroup(getCellValue, group)).toBe(true); + }); + + it('should evaluate OR group correctly (any match)', () => { + const getCellValue = mockGetCellValue(['Anthropic', 'Claude 3']); + const group: FilterGroup = { + id: '1', + conditions: [ + { id: 'c1', field: 'provider', operator: 'contains', value: 'openai', connector: 'AND' }, + { id: 'c2', field: 'provider', operator: 'contains', value: 'anthropic', connector: 'AND' }, + ], + internalConnector: 'OR', + }; + expect(evaluateFilterGroup(getCellValue, group)).toBe(true); + }); + + it('should evaluate OR group correctly (no match)', () => { + const getCellValue = mockGetCellValue(['Anthropic', 'Claude 3']); + const group: FilterGroup = { + id: '1', + conditions: [ + { id: 'c1', field: 'provider', operator: 'contains', value: 'openai', connector: 'AND' }, + { id: 'c2', field: 'provider', operator: 'contains', value: 'google', connector: 'AND' }, + ], + internalConnector: 'OR', + }; + expect(evaluateFilterGroup(getCellValue, group)).toBe(false); + }); + + it('should evaluate AND group correctly (all match)', () => { + const getCellValue = mockGetCellValue(['Anthropic', 'Claude 3']); + const group: FilterGroup = { + id: '1', + conditions: [ + { id: 'c1', field: 'provider', operator: 'contains', value: 'anthro', connector: 'AND' }, + { id: 'c2', field: 'model', operator: 'contains', value: 'claude', connector: 'AND' }, + ], + internalConnector: 'AND', + }; + expect(evaluateFilterGroup(getCellValue, group)).toBe(true); + }); + + it('should evaluate AND group correctly (partial match fails)', () => { + const getCellValue = mockGetCellValue(['Anthropic', 'Claude 3']); + const group: FilterGroup = { + id: '1', + conditions: [ + { id: 'c1', field: 'provider', operator: 'contains', value: 'anthro', connector: 'AND' }, + { id: 'c2', field: 'model', operator: 'contains', value: 'gpt', connector: 'AND' }, + ], + internalConnector: 'AND', + }; + expect(evaluateFilterGroup(getCellValue, group)).toBe(false); + }); +}); + +describe('evaluateFilterExpression', () => { + const mockGetCellValue = (values: string[]) => (columnIndex: number) => values[columnIndex] || ''; + + it('should return true for empty expression', () => { + const getCellValue = mockGetCellValue(['Anthropic']); + const expression: FilterExpression = { groups: [], groupConnectors: [] }; + expect(evaluateFilterExpression(getCellValue, expression)).toBe(true); + }); + + it('should evaluate single group expression', () => { + const getCellValue = mockGetCellValue(['Anthropic', 'Claude 3']); + const expression: FilterExpression = { + groups: [{ + id: '1', + conditions: [ + { id: 'c1', field: 'provider', operator: 'contains', value: 'anthro', connector: 'AND' }, + ], + internalConnector: 'OR', + }], + groupConnectors: [], + }; + expect(evaluateFilterExpression(getCellValue, expression)).toBe(true); + }); + + it('should evaluate two groups with AND connector', () => { + // (Provider=Anthropic OR Provider=OpenAI) AND (Model contains Claude) + const getCellValue = mockGetCellValue(['Anthropic', 'Claude 3', '', '', '', 'Yes']); + const expression: FilterExpression = { + groups: [ + { + id: '1', + conditions: [ + { id: 'c1', field: 'provider', operator: 'contains', value: 'anthro', connector: 'AND' }, + { id: 'c2', field: 'provider', operator: 'contains', value: 'openai', connector: 'AND' }, + ], + internalConnector: 'OR', // Match ANY + }, + { + id: '2', + conditions: [ + { id: 'c3', field: 'model', operator: 'contains', value: 'claude', connector: 'AND' }, + ], + internalConnector: 'AND', + }, + ], + groupConnectors: ['AND'], + }; + // (anthropic OR openai) AND (claude) = true AND true = true + expect(evaluateFilterExpression(getCellValue, expression)).toBe(true); + }); + + it('should evaluate two groups with AND connector (fails)', () => { + // (Provider=OpenAI OR Provider=Google) AND (Model contains Claude) + const getCellValue = mockGetCellValue(['Anthropic', 'Claude 3']); + const expression: FilterExpression = { + groups: [ + { + id: '1', + conditions: [ + { id: 'c1', field: 'provider', operator: 'contains', value: 'openai', connector: 'AND' }, + { id: 'c2', field: 'provider', operator: 'contains', value: 'google', connector: 'AND' }, + ], + internalConnector: 'OR', + }, + { + id: '2', + conditions: [ + { id: 'c3', field: 'model', operator: 'contains', value: 'claude', connector: 'AND' }, + ], + internalConnector: 'AND', + }, + ], + groupConnectors: ['AND'], + }; + // (openai OR google) AND (claude) = false AND true = false + expect(evaluateFilterExpression(getCellValue, expression)).toBe(false); + }); + + it('should evaluate two groups with OR connector', () => { + // (Provider=OpenAI) OR (Provider=Anthropic) + const getCellValue = mockGetCellValue(['Anthropic', 'Claude 3']); + const expression: FilterExpression = { + groups: [ + { + id: '1', + conditions: [ + { id: 'c1', field: 'provider', operator: 'contains', value: 'openai', connector: 'AND' }, + ], + internalConnector: 'OR', + }, + { + id: '2', + conditions: [ + { id: 'c3', field: 'provider', operator: 'contains', value: 'anthro', connector: 'AND' }, + ], + internalConnector: 'OR', + }, + ], + groupConnectors: ['OR'], + }; + // (openai) OR (anthropic) = false OR true = true + expect(evaluateFilterExpression(getCellValue, expression)).toBe(true); + }); +}); + +describe('serializeFilterExpression / deserializeFilterExpression', () => { + let idCounter = 0; + const generateId = () => `test-${++idCounter}`; + + beforeEach(() => { + idCounter = 0; + }); + + it('should serialize single group expression', () => { + const expression: FilterExpression = { + groups: [{ + id: '1', + conditions: [ + { id: 'c1', field: 'provider', operator: 'contains', value: 'anthropic', connector: 'AND' }, + ], + internalConnector: 'OR', + }], + groupConnectors: [], + }; + const serialized = serializeFilterExpression(expression); + expect(serialized).toBe('OR(provider:contains:anthropic)'); + }); + + it('should serialize multiple groups with connectors', () => { + const expression: FilterExpression = { + groups: [ + { + id: '1', + conditions: [ + { id: 'c1', field: 'provider', operator: 'contains', value: 'anthropic', connector: 'AND' }, + { id: 'c2', field: 'provider', operator: 'contains', value: 'openai', connector: 'AND' }, + ], + internalConnector: 'OR', + }, + { + id: '2', + conditions: [ + { id: 'c3', field: 'reasoning', operator: 'is', value: 'Yes', connector: 'AND' }, + ], + internalConnector: 'AND', + }, + ], + groupConnectors: ['AND'], + }; + const serialized = serializeFilterExpression(expression); + expect(serialized).toBe('OR(provider:contains:anthropic,provider:contains:openai)~AND~AND(reasoning:is:Yes)'); + }); + + it('should deserialize expression from URL format', () => { + const serialized = 'OR(provider:contains:anthropic,provider:contains:openai)~AND~AND(reasoning:is:Yes)'; + const expression = deserializeFilterExpression(serialized, generateId); + + expect(expression.groups).toHaveLength(2); + expect(expression.groupConnectors).toHaveLength(1); + expect(expression.groupConnectors[0]).toBe('AND'); + + expect(expression.groups[0].internalConnector).toBe('OR'); + expect(expression.groups[0].conditions).toHaveLength(2); + expect(expression.groups[0].conditions[0].value).toBe('anthropic'); + + expect(expression.groups[1].internalConnector).toBe('AND'); + expect(expression.groups[1].conditions[0].field).toBe('reasoning'); + }); + + it('should handle empty expression', () => { + expect(serializeFilterExpression({ groups: [], groupConnectors: [] })).toBe(''); + const deserialized = deserializeFilterExpression('', generateId); + expect(deserialized.groups).toHaveLength(0); + }); +}); + +describe('generateExpressionSummary', () => { + it('should return empty string for empty expression', () => { + expect(generateExpressionSummary({ groups: [], groupConnectors: [] })).toBe(''); + }); + + it('should generate summary for single group', () => { + const expression: FilterExpression = { + groups: [{ + id: '1', + conditions: [ + { id: 'c1', field: 'provider', operator: 'contains', value: 'anthropic', connector: 'AND' }, + ], + internalConnector: 'OR', + }], + groupConnectors: [], + }; + const summary = generateExpressionSummary(expression); + expect(summary).toContain('Provider'); + expect(summary).toContain('anthropic'); + expect(summary).toContain('filter-group-chip'); + }); + + it('should include group connector for multiple groups', () => { + const expression: FilterExpression = { + groups: [ + { + id: '1', + conditions: [ + { id: 'c1', field: 'provider', operator: 'contains', value: 'anthro', connector: 'AND' }, + ], + internalConnector: 'OR', + }, + { + id: '2', + conditions: [ + { id: 'c3', field: 'reasoning', operator: 'is', value: 'Yes', connector: 'AND' }, + ], + internalConnector: 'AND', + }, + ], + groupConnectors: ['AND'], + }; + const summary = generateExpressionSummary(expression); + expect(summary).toContain('filter-chip-connector'); + expect(summary).toContain('AND'); + }); +}); diff --git a/packages/web/src/search.ts b/packages/web/src/search.ts new file mode 100644 index 00000000..8724d17b --- /dev/null +++ b/packages/web/src/search.ts @@ -0,0 +1,530 @@ +/** + * Advanced Search Module + * + * Contains the pure logic for filtering models with AND/OR conditions. + * This module is separated from DOM manipulation for testability. + */ + +// Field configuration for advanced filters +export interface FieldConfig { + name: string; + label: string; + columnIndex: number; + type: 'text' | 'boolean' | 'modalities' | 'date'; + operators: { value: string; label: string }[]; +} + +export interface FilterCondition { + id: string; + field: string; + operator: string; + value: string; + connector: 'AND' | 'OR'; +} + +// New types for grouped filter clauses +export interface FilterGroup { + id: string; + conditions: FilterCondition[]; // Conditions within the group + internalConnector: 'AND' | 'OR'; // How conditions connect within this group +} + +export interface FilterExpression { + groups: FilterGroup[]; + groupConnectors: ('AND' | 'OR')[]; // How groups connect to each other (length = groups.length - 1) +} + +// Operator definitions for text fields +const TEXT_OPERATORS = [ + { value: 'equals', label: 'equals' }, + { value: 'contains', label: 'contains' }, + { value: 'startsWith', label: 'starts with' }, +]; + +// Operator definitions for boolean fields +const BOOLEAN_OPERATORS = [{ value: 'is', label: 'is' }]; + +// Operator definitions for date fields +const DATE_OPERATORS = [ + { value: 'after', label: 'after' }, + { value: 'before', label: 'before' }, + { value: 'equals', label: 'equals' }, +]; + +// Field configurations - maps field names to table column indices and operators +export const FIELD_CONFIGS: FieldConfig[] = [ + { + name: 'provider', + label: 'Provider', + columnIndex: 0, + type: 'text', + operators: TEXT_OPERATORS, + }, + { + name: 'model', + label: 'Model', + columnIndex: 1, + type: 'text', + operators: TEXT_OPERATORS, + }, + { + name: 'providerId', + label: 'Provider ID', + columnIndex: 2, + type: 'text', + operators: TEXT_OPERATORS, + }, + { + name: 'modelId', + label: 'Model ID', + columnIndex: 3, + type: 'text', + operators: TEXT_OPERATORS, + }, + { + name: 'toolCall', + label: 'Tool Call', + columnIndex: 4, + type: 'boolean', + operators: BOOLEAN_OPERATORS, + }, + { + name: 'reasoning', + label: 'Reasoning', + columnIndex: 5, + type: 'boolean', + operators: BOOLEAN_OPERATORS, + }, + { + name: 'structuredOutput', + label: 'Structured Output', + columnIndex: 19, + type: 'boolean', + operators: BOOLEAN_OPERATORS, + }, + { + name: 'temperature', + label: 'Temperature', + columnIndex: 20, + type: 'boolean', + operators: BOOLEAN_OPERATORS, + }, + { + name: 'weights', + label: 'Weights', + columnIndex: 21, + type: 'text', + operators: [ + { value: 'equals', label: 'equals' }, + { value: 'contains', label: 'contains' }, + ], + }, + { + name: 'releaseDate', + label: 'Release Date', + columnIndex: 23, + type: 'date', + operators: DATE_OPERATORS, + }, +]; + +/** + * Parse a date string into a Date object + * Handles formats: "Jan 2025", "2025-01", "2025-01-15", "January 2025" + */ +function parseDate(dateStr: string): Date | null { + const trimmed = dateStr.trim(); + if (!trimmed || trimmed === '-') return null; + + // Try ISO format first (2025-01-15 or 2025-01) + const isoMatch = trimmed.match(/^(\d{4})-(\d{2})(?:-(\d{2}))?$/); + if (isoMatch) { + const year = parseInt(isoMatch[1]); + const month = parseInt(isoMatch[2]) - 1; + const day = isoMatch[3] ? parseInt(isoMatch[3]) : 1; + return new Date(year, month, day); + } + + // Try "Month Year" format (Jan 2025, January 2025) + const monthYearMatch = trimmed.match(/^([A-Za-z]+)\s+(\d{4})$/); + if (monthYearMatch) { + const monthNames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; + const monthStr = monthYearMatch[1].toLowerCase().slice(0, 3); + const monthIndex = monthNames.indexOf(monthStr); + if (monthIndex !== -1) { + const year = parseInt(monthYearMatch[2]); + return new Date(year, monthIndex, 1); + } + } + + // Try native Date parsing as fallback + const parsed = new Date(trimmed); + return isNaN(parsed.getTime()) ? null : parsed; +} + +/** + * Evaluate a single condition against a cell value + */ +export function evaluateOperator( + cellValue: string, + operator: string, + searchValue: string +): boolean { + const normalizedCell = cellValue.trim().toLowerCase(); + const normalizedSearch = searchValue.toLowerCase(); + + switch (operator) { + case 'equals': + return normalizedCell === normalizedSearch; + case 'contains': + return normalizedCell.includes(normalizedSearch); + case 'startsWith': + return normalizedCell.startsWith(normalizedSearch); + case 'is': + return normalizedCell === normalizedSearch; + case 'after': { + const cellDate = parseDate(cellValue); + const searchDate = parseDate(searchValue); + if (!cellDate || !searchDate) return false; + return cellDate > searchDate; + } + case 'before': { + const cellDate = parseDate(cellValue); + const searchDate = parseDate(searchValue); + if (!cellDate || !searchDate) return false; + return cellDate < searchDate; + } + default: + return true; + } +} + +/** + * Evaluate a single filter condition against a row's cell values + */ +export function evaluateCondition( + getCellValue: (columnIndex: number) => string, + condition: FilterCondition, + fieldConfigs: FieldConfig[] = FIELD_CONFIGS +): boolean { + const fieldConfig = fieldConfigs.find(f => f.name === condition.field); + if (!fieldConfig) return true; + + const cellValue = getCellValue(fieldConfig.columnIndex); + return evaluateOperator(cellValue, condition.operator, condition.value); +} + +/** + * Evaluate all filters with AND/OR logic + * Returns true if the row matches all filter conditions + */ +export function evaluateFilters( + getCellValue: (columnIndex: number) => string, + filterList: FilterCondition[], + fieldConfigs: FieldConfig[] = FIELD_CONFIGS +): boolean { + if (filterList.length === 0) return true; + + // Evaluate first condition + let result = evaluateCondition(getCellValue, filterList[0], fieldConfigs); + + // Apply subsequent conditions with their connectors + for (let i = 1; i < filterList.length; i++) { + const filter = filterList[i]; + const conditionResult = evaluateCondition(getCellValue, filter, fieldConfigs); + + if (filter.connector === 'OR') { + result = result || conditionResult; + } else { + result = result && conditionResult; + } + } + + return result; +} + +/** + * Evaluate a single filter group + * All conditions within a group are connected by the group's internalConnector + */ +export function evaluateFilterGroup( + getCellValue: (columnIndex: number) => string, + group: FilterGroup, + fieldConfigs: FieldConfig[] = FIELD_CONFIGS +): boolean { + const activeConditions = group.conditions.filter(c => c.value.trim() !== ''); + if (activeConditions.length === 0) return true; + + if (group.internalConnector === 'AND') { + // All conditions must match + return activeConditions.every(condition => + evaluateCondition(getCellValue, condition, fieldConfigs) + ); + } else { + // At least one condition must match + return activeConditions.some(condition => + evaluateCondition(getCellValue, condition, fieldConfigs) + ); + } +} + +/** + * Evaluate a complete filter expression with grouped clauses + * Groups are connected by groupConnectors array + */ +export function evaluateFilterExpression( + getCellValue: (columnIndex: number) => string, + expression: FilterExpression, + fieldConfigs: FieldConfig[] = FIELD_CONFIGS +): boolean { + if (expression.groups.length === 0) return true; + + // Filter out empty groups (groups with no active conditions) + const activeGroups = expression.groups.filter(g => + g.conditions.some(c => c.value.trim() !== '') + ); + + if (activeGroups.length === 0) return true; + + // Evaluate first group + let result = evaluateFilterGroup(getCellValue, activeGroups[0], fieldConfigs); + + // Apply subsequent groups with their connectors + for (let i = 1; i < activeGroups.length; i++) { + const groupResult = evaluateFilterGroup(getCellValue, activeGroups[i], fieldConfigs); + const connector = expression.groupConnectors[i - 1] || 'AND'; + + if (connector === 'OR') { + result = result || groupResult; + } else { + result = result && groupResult; + } + } + + return result; +} + +/** + * Get active filters (filters with non-empty values) + */ +export function getActiveFilters(filters: FilterCondition[]): FilterCondition[] { + return filters.filter(f => f.value.trim() !== ''); +} + +/** + * Simple search: split by comma and match any cell + */ +export function matchesSimpleSearch( + cellTexts: string[], + searchValue: string +): boolean { + const lowerCaseValues = searchValue + .toLowerCase() + .split(',') + .filter(str => str.trim() !== ''); + + if (lowerCaseValues.length === 0) return true; + + const lowerCaseCells = cellTexts.map(text => text.toLowerCase()); + return lowerCaseValues.some(searchTerm => + lowerCaseCells.some(cell => cell.includes(searchTerm.trim())) + ); +} + +/** + * Serialize filters to URL-safe string format + */ +export function serializeFilters(filters: FilterCondition[]): string { + const activeFilters = getActiveFilters(filters); + if (activeFilters.length === 0) return ''; + + return activeFilters + .map(f => `${f.connector}:${f.field}:${f.operator}:${encodeURIComponent(f.value)}`) + .join('|'); +} + +/** + * Deserialize filters from URL string format + */ +export function deserializeFilters( + param: string, + generateId: () => string +): FilterCondition[] { + if (!param) return []; + + return param.split('|').map(part => { + const [connector, field, operator, value] = part.split(':'); + return { + id: generateId(), + connector: connector as 'AND' | 'OR', + field, + operator, + value: decodeURIComponent(value || ''), + }; + }); +} + +/** + * Generate a filter summary HTML string for display + */ +export function generateFilterSummary( + filters: FilterCondition[], + fieldConfigs: FieldConfig[] = FIELD_CONFIGS +): string { + const activeFilters = getActiveFilters(filters); + if (activeFilters.length === 0) return ''; + + return activeFilters + .map((filter, index) => { + const fieldConfig = fieldConfigs.find(f => f.name === filter.field); + const connector = index === 0 ? '' : `${filter.connector}`; + return `${connector}${fieldConfig?.label || filter.field} ${filter.operator} "${filter.value}"`; + }) + .join(' '); +} + +/** + * Get field configuration by name + */ +export function getFieldConfig(fieldName: string): FieldConfig | undefined { + return FIELD_CONFIGS.find(f => f.name === fieldName); +} + +/** + * Serialize a filter expression to URL-safe string format + * Format: GROUP(connector:field:op:value,...)~connector~GROUP(...) + */ +export function serializeFilterExpression(expression: FilterExpression): string { + if (expression.groups.length === 0) return ''; + + const groupStrings = expression.groups.map(group => { + const conditionStrings = group.conditions + .filter(c => c.value.trim() !== '') + .map(c => `${c.field}:${c.operator}:${encodeURIComponent(c.value)}`) + .join(','); + return `${group.internalConnector}(${conditionStrings})`; + }).filter(g => g !== 'AND()' && g !== 'OR()'); // Filter out empty groups + + if (groupStrings.length === 0) return ''; + + // Join groups with their connectors + let result = groupStrings[0]; + for (let i = 1; i < groupStrings.length; i++) { + const connector = expression.groupConnectors[i - 1] || 'AND'; + result += `~${connector}~${groupStrings[i]}`; + } + return result; +} + +/** + * Deserialize a filter expression from URL string format + */ +export function deserializeFilterExpression( + param: string, + generateId: () => string +): FilterExpression { + if (!param) return { groups: [], groupConnectors: [] }; + + const groups: FilterGroup[] = []; + const groupConnectors: ('AND' | 'OR')[] = []; + + // Split by group connectors (~AND~ or ~OR~) + const parts = param.split(/~(AND|OR)~/); + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + + // Even indices are groups, odd indices are connectors + if (i % 2 === 0) { + // Parse group: "AND(field:op:value,field:op:value)" or "OR(...)" + const match = part.match(/^(AND|OR)\((.*)?\)$/); + if (match) { + const internalConnector = match[1] as 'AND' | 'OR'; + const conditionsStr = match[2] || ''; + + const conditions: FilterCondition[] = conditionsStr + .split(',') + .filter(s => s.trim() !== '') + .map(s => { + const [field, operator, value] = s.split(':'); + return { + id: generateId(), + field, + operator, + value: decodeURIComponent(value || ''), + connector: 'AND' as const, // Not used within groups + }; + }); + + groups.push({ + id: generateId(), + conditions, + internalConnector, + }); + } + } else { + // This is a connector between groups + groupConnectors.push(part as 'AND' | 'OR'); + } + } + + return { groups, groupConnectors }; +} + +/** + * Generate a summary HTML string for a filter expression + */ +export function generateExpressionSummary( + expression: FilterExpression, + fieldConfigs: FieldConfig[] = FIELD_CONFIGS +): string { + const activeGroups = expression.groups.filter(g => + g.conditions.some(c => c.value.trim() !== '') + ); + + if (activeGroups.length === 0) return ''; + + return activeGroups + .map((group, groupIndex) => { + const activeConditions = group.conditions.filter(c => c.value.trim() !== ''); + + const conditionHtml = activeConditions + .map((condition, condIndex) => { + const fieldConfig = fieldConfigs.find(f => f.name === condition.field); + const connector = condIndex === 0 ? '' : + `${group.internalConnector}`; + return `${connector}${fieldConfig?.label || condition.field} ${condition.operator} "${condition.value}"`; + }) + .join(' '); + + const groupConnector = groupIndex === 0 ? '' : + `${expression.groupConnectors[groupIndex - 1] || 'AND'}`; + + return `${groupConnector}(${conditionHtml})`; + }) + .join(' '); +} + +/** + * Create an empty filter group + */ +export function createEmptyGroup(generateId: () => string): FilterGroup { + return { + id: generateId(), + conditions: [], + internalConnector: 'OR', + }; +} + +/** + * Create an empty filter condition + */ +export function createEmptyCondition(generateId: () => string): FilterCondition { + return { + id: generateId(), + field: FIELD_CONFIGS[0]?.name || 'provider', + operator: 'contains', + value: '', + connector: 'AND', + }; +}
{model.temperature ? "Yes" : "No"} {model.open_weights ? "Open" : "Closed"}