Update to v1.0.0-alpha6

- Add configurable filter views with AND/OR logic in settings
- Implement column sorting and filtering via click menu popup
- Add filter-as-you-type with 300ms debounce
- Add tooltips to view buttons showing filter configuration
- Fix server timeout issues (increased to 30s)
- Maintain sort/filter state across table refreshes
This commit is contained in:
2026-01-02 05:39:11 +00:00
parent ad4a4fdf4b
commit ae306e946b
3 changed files with 701 additions and 6 deletions
+390 -6
View File
@@ -242,6 +242,13 @@
<p class="text-gray-600 mt-1">Scroll horizontally and vertically • First two columns are frozen</p>
</div>
<!-- View Buttons Section -->
<div class="bg-white rounded-lg shadow-2xl px-4 py-2 mt-2">
<div id="viewButtonsContainer" class="flex items-center space-x-2 flex-wrap gap-y-2">
<!-- View buttons will be dynamically generated here -->
</div>
</div>
<!-- Search Section -->
<div class="bg-white rounded-lg shadow-2xl px-4 py-2 mt-2">
<div class="flex justify-end">
@@ -326,7 +333,7 @@
<!-- Footer -->
<div class="mt-2 flex justify-between items-center text-white text-xs px-4">
<span id="footerEmail"></span>
<span>v1.0.0-alpha5</span>
<span>v1.0.0-alpha6</span>
</div>
</div>
@@ -371,6 +378,11 @@
let appSettings = null;
let appSettingsRecordId = null;
let appSettingsUnsubscribe = null;
let appViews = null;
let appViewsRecordId = null;
let currentViewFilter = null; // Current active view filter
let currentSort = { column: 'Job_Number', direction: 'desc' }; // Current sort state
let columnFilters = {}; // Column-specific filters { columnName: searchTerm }
let tableReloadTimer = null;
let isLoadingTableData = false;
let allRecords = []; // Store all records for filtering
@@ -415,6 +427,44 @@
}, 200);
}
// Load app views from PocketBase
async function loadAppViews() {
try {
const records = await pb.collection('app_preferences_settings').getFullList({
filter: `app_name = "Estimator Job List"`
});
if (records.length > 0) {
const viewsData = records[0].app_views_json;
appViewsRecordId = records[0].id;
if (viewsData) {
appViews = typeof viewsData === 'string' ? JSON.parse(viewsData) : viewsData;
console.log('Loaded app views:', appViews);
}
}
} catch (error) {
console.error('Error loading app views:', error);
}
}
// Save app views to PocketBase
async function saveAppViews() {
try {
if (!appViewsRecordId) {
console.error('No views record ID available');
return;
}
const viewsJson = JSON.stringify(appViews);
await pb.collection('app_preferences_settings').update(appViewsRecordId, {
app_views_json: viewsJson
});
console.log('Views saved successfully');
} catch (error) {
console.error('Error saving views:', error);
}
}
// Load app settings from PocketBase (global settings)
async function loadAppSettings() {
try {
@@ -507,8 +557,133 @@
const colSettings = getColumnSettings(col.name);
const alignment = colSettings?.alignment || 'left';
const wrapClass = colSettings?.headerWrap ? 'whitespace-normal break-words' : 'whitespace-nowrap';
return `<th class="px-4 py-3 text-${alignment} font-semibold ${wrapClass}">${prettyLabel(col.name)}</th>`;
const sortIcon = currentSort.column === col.name
? (currentSort.direction === 'asc' ? ' ▲' : ' ▼')
: '';
const hasFilter = columnFilters[col.name] ? ' 🔍' : '';
return `<th class="px-4 py-3 text-${alignment} font-semibold ${wrapClass} cursor-pointer hover:bg-gray-100" data-column="${col.name}">${prettyLabel(col.name)}${sortIcon}${hasFilter}</th>`;
}).join('');
// Attach click handlers for column menu
document.querySelectorAll('th[data-column]').forEach(th => {
th.addEventListener('click', (e) => {
showColumnMenu(e.currentTarget, e.currentTarget.dataset.column);
});
});
}
// Show column menu popup
function showColumnMenu(headerElement, columnName) {
// Remove any existing menu
const existingMenu = document.getElementById('columnMenu');
if (existingMenu) existingMenu.remove();
const menu = document.createElement('div');
menu.id = 'columnMenu';
menu.className = 'absolute bg-white border border-gray-300 rounded-lg shadow-lg p-3 z-[2000]';
menu.style.minWidth = '250px';
const rect = headerElement.getBoundingClientRect();
menu.style.left = `${rect.left}px`;
menu.style.top = `${rect.bottom + 5}px`;
const currentFilter = columnFilters[columnName] || '';
const isSorted = currentSort.column === columnName;
menu.innerHTML = `
<div class="text-sm font-semibold mb-2 pb-2 border-b border-gray-200">${prettyLabel(columnName)}</div>
<div class="mb-3">
<button class="w-full text-left px-3 py-2 hover:bg-blue-100 rounded text-sm" data-action="sort-asc">
${isSorted && currentSort.direction === 'asc' ? '✓ ' : ''}Sort A → Z
</button>
<button class="w-full text-left px-3 py-2 hover:bg-blue-100 rounded text-sm" data-action="sort-desc">
${isSorted && currentSort.direction === 'desc' ? '✓ ' : ''}Sort Z → A
</button>
${isSorted ? '<button class="w-full text-left px-3 py-2 hover:bg-red-100 rounded text-sm text-red-600" data-action="clear-sort">Clear Sort</button>' : ''}
</div>
<div class="border-t border-gray-200 pt-3">
<label class="block text-xs text-gray-600 mb-1">Filter:</label>
<input type="text"
class="w-full px-2 py-1 border border-gray-300 rounded text-sm focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
placeholder="Search in ${prettyLabel(columnName)}..."
value="${currentFilter}"
id="columnFilterInput"
/>
${currentFilter ? '<div class="mt-2"><button class="w-full px-3 py-1 bg-red-500 text-white rounded text-xs hover:bg-red-600" data-action="clear-filter">Clear Filter</button></div>' : ''}
</div>
`;
document.body.appendChild(menu);
// Focus the filter input
const filterInput = menu.querySelector('#columnFilterInput');
if (filterInput) filterInput.focus();
// Debounce filter application to avoid re-rendering while typing
let filterTimeout;
filterInput.addEventListener('input', (e) => {
clearTimeout(filterTimeout);
filterTimeout = setTimeout(() => {
const value = e.target.value.trim();
if (value) {
columnFilters[columnName] = value;
} else {
delete columnFilters[columnName];
}
// Only re-render table rows, not the header (which would destroy the menu)
renderTableRows();
}, 300); // Wait 300ms after user stops typing
});
// Handle menu actions
menu.addEventListener('click', (e) => {
const action = e.target.dataset.action;
if (!action) return;
switch (action) {
case 'sort-asc':
currentSort.column = columnName;
currentSort.direction = 'asc';
renderTableHeader();
renderTableRows();
menu.remove();
break;
case 'sort-desc':
currentSort.column = columnName;
currentSort.direction = 'desc';
renderTableHeader();
renderTableRows();
menu.remove();
break;
case 'clear-sort':
currentSort.column = 'Job_Number';
currentSort.direction = 'desc';
renderTableHeader();
renderTableRows();
menu.remove();
break;
case 'clear-filter':
delete columnFilters[columnName];
renderTableHeader();
renderTableRows();
menu.remove();
break;
}
});
// Close menu when clicking outside
const closeOnOutsideClick = (e) => {
if (!menu.contains(e.target) && e.target !== headerElement) {
menu.remove();
// Update header to show filter icon after menu closes
renderTableHeader();
document.removeEventListener('click', closeOnOutsideClick);
}
};
setTimeout(() => document.addEventListener('click', closeOnOutsideClick), 0);
}
// Apply global settings to table (styles)
@@ -702,6 +877,9 @@
await loadAppSettings();
console.log('Settings loaded');
await loadAppViews();
console.log('Views loaded');
await startSettingsWatch();
console.log('Settings watch started');
@@ -736,6 +914,7 @@
}
await loadAppSettings();
await loadAppViews();
await startSettingsWatch();
showTable();
await loadTableData();
@@ -775,6 +954,164 @@
// Setup search listener after table is shown
setupSearchListener();
// Setup view button listeners
setupViewButtons();
}
// Set up view button event handlers
function setupViewButtons() {
if (!appViews || appViews.length === 0) {
console.log('No views loaded');
return;
}
const container = document.getElementById('viewButtonsContainer');
container.innerHTML = '';
// Generate buttons dynamically from appViews
appViews.forEach((view, idx) => {
const button = document.createElement('button');
button.id = `view_${view.id}`;
button.className = idx === 0
? 'px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm font-medium transition-colors'
: 'px-3 py-1.5 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 text-sm font-medium transition-colors';
button.textContent = view.label;
// Generate tooltip showing filter configuration
if (view.filters && view.filters.length > 0) {
const filterDescriptions = view.filters.map(filter => {
const fieldName = filter.field.replace(/_/g, ' ');
let desc = fieldName;
switch (filter.operator) {
case 'equals':
desc += ` = ${filter.value}`;
break;
case 'notEquals':
desc += `${filter.value}`;
break;
case 'contains':
desc += ` contains "${filter.value}"`;
break;
case 'empty':
desc += ` is empty`;
break;
case 'notEmpty':
desc += ` is not empty`;
break;
}
return desc;
});
const logic = view.filterLogic || 'AND';
const tooltip = filterDescriptions.join(`\n${logic} `);
button.title = tooltip;
} else {
button.title = 'Show all records (no filters)';
}
button.addEventListener('click', () => {
// Update active button styling
container.querySelectorAll('button').forEach(btn => {
btn.className = 'px-3 py-1.5 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 text-sm font-medium transition-colors';
});
button.className = 'px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm font-medium transition-colors';
// Create filter function from view definition
if (!view.filters || view.filters.length === 0) {
currentViewFilter = null;
} else {
currentViewFilter = (record) => {
const filterLogic = view.filterLogic || 'AND';
const evaluateFilter = (filter) => {
const fieldValue = record[filter.field];
switch (filter.operator) {
case 'equals':
// Handle boolean comparison
if (filter.value === 'true' || filter.value === true) {
return fieldValue === true;
} else if (filter.value === 'false' || filter.value === false) {
return fieldValue === false;
}
return String(fieldValue) === String(filter.value);
case 'notEquals':
if (filter.value === 'true' || filter.value === true) {
return fieldValue !== true;
} else if (filter.value === 'false' || filter.value === false) {
return fieldValue !== false;
}
return String(fieldValue) !== String(filter.value);
case 'contains':
return fieldValue && String(fieldValue).toLowerCase().includes(String(filter.value).toLowerCase());
case 'empty':
return !fieldValue || String(fieldValue).trim() === '';
case 'notEmpty':
return fieldValue && String(fieldValue).trim() !== '';
default:
return true;
}
};
// Apply AND or OR logic
if (filterLogic === 'OR') {
return view.filters.some(filter => evaluateFilter(filter));
} else {
return view.filters.every(filter => evaluateFilter(filter));
}
};
}
renderTableRows();
});
container.appendChild(button);
});
// Set first view as active filter on initial load
if (appViews.length > 0 && appViews[0].filters && appViews[0].filters.length > 0) {
const firstView = appViews[0];
const filterLogic = firstView.filterLogic || 'AND';
currentViewFilter = (record) => {
const evaluateFilter = (filter) => {
const fieldValue = record[filter.field];
switch (filter.operator) {
case 'equals':
if (filter.value === 'true' || filter.value === true) {
return fieldValue === true;
} else if (filter.value === 'false' || filter.value === false) {
return fieldValue === false;
}
return String(fieldValue) === String(filter.value);
case 'notEquals':
if (filter.value === 'true' || filter.value === true) {
return fieldValue !== true;
} else if (filter.value === 'false' || filter.value === false) {
return fieldValue !== false;
}
return String(fieldValue) !== String(filter.value);
case 'contains':
return fieldValue && String(fieldValue).toLowerCase().includes(String(filter.value).toLowerCase());
case 'empty':
return !fieldValue || String(fieldValue).trim() === '';
case 'notEmpty':
return fieldValue && String(fieldValue).trim() !== '';
default:
return true;
}
};
// Apply AND or OR logic
if (filterLogic === 'OR') {
return firstView.filters.some(filter => evaluateFilter(filter));
} else {
return firstView.filters.every(filter => evaluateFilter(filter));
}
};
}
}
// Settings button handler
@@ -1078,8 +1415,34 @@
tableBody.innerHTML = '';
const visibleColumns = getVisibleColumns();
// Filter records based on search term
const filteredRecords = filterRecords(allRecords, currentSearchTerm);
// Filter records based on search term and column filters
let filteredRecords = filterRecords(allRecords, currentSearchTerm);
// Apply sorting
if (currentSort.column) {
filteredRecords = filteredRecords.slice().sort((a, b) => {
let valA = a[currentSort.column];
let valB = b[currentSort.column];
// Handle null/undefined values
if (valA === null || valA === undefined) valA = '';
if (valB === null || valB === undefined) valB = '';
// Handle numeric values
if (!isNaN(valA) && !isNaN(valB)) {
valA = parseFloat(valA) || 0;
valB = parseFloat(valB) || 0;
} else {
// Convert to string for comparison
valA = String(valA).toLowerCase();
valB = String(valB).toLowerCase();
}
if (valA < valB) return currentSort.direction === 'asc' ? -1 : 1;
if (valA > valB) return currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
}
// Update search result count
const searchResultCount = document.getElementById('searchResultCount');
@@ -1178,8 +1541,29 @@
// Filter records based on search term
function filterRecords(records, searchTerm) {
let filtered = records;
// Apply view filter first
if (currentViewFilter) {
filtered = filtered.filter(record => {
return currentViewFilter(record);
});
}
// Apply column-specific filters
if (Object.keys(columnFilters).length > 0) {
filtered = filtered.filter(record => {
return Object.entries(columnFilters).every(([column, filterTerm]) => {
const value = record[column];
if (!value && value !== false && value !== 0) return false;
return String(value).toLowerCase().includes(filterTerm.toLowerCase());
});
});
}
// Then apply global search term filter
if (!searchTerm || !searchTerm.trim()) {
return records;
return filtered;
}
const term = searchTerm.toLowerCase().trim();
@@ -1197,7 +1581,7 @@
'note_thread_text'
];
return records.filter(record => {
return filtered.filter(record => {
return searchFields.some(field => {
const value = record[field];
if (!value) return false;