fixes and refactor
This commit is contained in:
50
static/js/__tests__/search_snippet.ui.test.js
Normal file
50
static/js/__tests__/search_snippet.ui.test.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
// Load sanitizer and highlight utils used by the UI
|
||||
require('../sanitizer.js');
|
||||
require('../highlight.js');
|
||||
|
||||
describe('Search highlight integration (server snippet rendering)', () => {
|
||||
const { formatSnippet, highlight, buildTokens } = window.highlightUtils;
|
||||
|
||||
test('formatSnippet preserves server <strong> and sanitizes dangerous HTML', () => {
|
||||
const tokens = buildTokens('alpha');
|
||||
const serverSnippet = 'Hello <strong>Alpha</strong> <img src=x onerror=alert(1)> <a href="javascript:evil()">link</a>';
|
||||
const html = formatSnippet(serverSnippet, tokens);
|
||||
// Server-provided strong is preserved
|
||||
expect(html).toContain('<strong>Alpha</strong>');
|
||||
// Dangerous attributes removed
|
||||
expect(html).not.toContain('onerror=');
|
||||
// javascript: protocol removed
|
||||
expect(html.toLowerCase()).not.toContain('href="javascript:');
|
||||
// Image tag should remain but sanitized (no onerror)
|
||||
expect(html).toContain('<img');
|
||||
});
|
||||
|
||||
test('setSafeHTML inserts sanitized content into DOM safely', () => {
|
||||
const container = document.createElement('div');
|
||||
const rawHtml = '<div onclick="evil()"><script>alert(1)</script>Text <b>bold</b></div>';
|
||||
// Using global helper installed by sanitizer.js
|
||||
window.setSafeHTML(container, rawHtml);
|
||||
// Script tags removed
|
||||
expect(container.innerHTML).not.toContain('<script>');
|
||||
// Event handlers stripped
|
||||
expect(container.innerHTML).not.toContain('onclick=');
|
||||
// Harmless markup preserved
|
||||
expect(container.innerHTML).toContain('<b>bold</b>');
|
||||
});
|
||||
|
||||
test('highlight then sanitize flow escapes original tags and wraps tokens', () => {
|
||||
const tokens = buildTokens('john smith');
|
||||
const out = highlight('Hello <b>John</b> Smith & Sons', tokens);
|
||||
// Original b-tags escaped
|
||||
expect(out).toContain('<b>');
|
||||
// Tokens wrapped with strong
|
||||
expect(out).toMatch(/<strong>John<\/strong>/);
|
||||
expect(out).toMatch(/<strong>Smith<\/strong>/);
|
||||
// Ampersand escaped
|
||||
expect(out).toContain('& Sons');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ let isEditing = false;
|
||||
let editingCustomerId = null;
|
||||
let selectedCustomerIds = new Set();
|
||||
let customerCompactMode = false;
|
||||
let customerFocusIndex = -1;
|
||||
let _customerNavInitialized = false;
|
||||
|
||||
// Local debounce fallback to avoid dependency on main.js
|
||||
function _localDebounce(func, wait) {
|
||||
@@ -49,6 +51,7 @@ function displayCustomers(customers) {
|
||||
}
|
||||
|
||||
customers.forEach(customer => {
|
||||
const rowIndex = tbody.children.length;
|
||||
const phones = Array.isArray(customer.phone_numbers) ? customer.phone_numbers : [];
|
||||
const primaryPhone = phones.length > 0 ? (phones[0].phone || '') : '';
|
||||
const phoneCount = phones.length;
|
||||
@@ -58,6 +61,8 @@ function displayCustomers(customers) {
|
||||
|
||||
// Store customer ID as data attribute to avoid escaping issues in onclick
|
||||
row.dataset.customerId = customer.id;
|
||||
row.dataset.rowIndex = String(rowIndex);
|
||||
row.setAttribute('tabindex', '-1');
|
||||
|
||||
// Build clean, simple row structure with clickable rows (no inline onclick to avoid backslash issues)
|
||||
const pad = customerCompactMode ? 'px-3 py-2' : 'px-6 py-4';
|
||||
@@ -122,11 +127,16 @@ function displayCustomers(customers) {
|
||||
e.stopPropagation();
|
||||
editCustomer(customer.id);
|
||||
});
|
||||
|
||||
// Focus management for keyboard navigation
|
||||
row.addEventListener('mouseenter', () => setCustomerFocus(rowIndex));
|
||||
row.addEventListener('click', () => setCustomerFocus(rowIndex));
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
// No select-all
|
||||
refreshCustomerKeyboardRows();
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
@@ -803,12 +813,13 @@ function enhanceCustomerTableRows() {
|
||||
function initializeCustomerListEnhancer() {
|
||||
const tbody = document.getElementById('customersTableBody');
|
||||
if (!tbody || window._customerListObserver) return;
|
||||
const debouncedEnhance = (typeof window.debounce === 'function' ? window.debounce : _localDebounce)(() => enhanceCustomerTableRows(), 10);
|
||||
const debouncedEnhance = (typeof window.debounce === 'function' ? window.debounce : _localDebounce)(() => { enhanceCustomerTableRows(); refreshCustomerKeyboardRows(); }, 10);
|
||||
const observer = new MutationObserver(() => debouncedEnhance());
|
||||
observer.observe(tbody, { childList: true, subtree: false });
|
||||
window._customerListObserver = observer;
|
||||
// Initial pass
|
||||
enhanceCustomerTableRows();
|
||||
initializeCustomerListKeyboardNav();
|
||||
}
|
||||
|
||||
// Selection helpers
|
||||
@@ -878,4 +889,79 @@ function onSelectAllChange(checked) {
|
||||
// Expose helpers
|
||||
window.initializeCustomerListState = initializeCustomerListState;
|
||||
window.toggleCompactMode = toggleCompactMode;
|
||||
window.onSelectAllChange = onSelectAllChange;
|
||||
window.onSelectAllChange = onSelectAllChange;
|
||||
|
||||
// Keyboard navigation for customer list
|
||||
function initializeCustomerListKeyboardNav() {
|
||||
if (_customerNavInitialized) return;
|
||||
_customerNavInitialized = true;
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const active = document.activeElement || e.target;
|
||||
const tag = active && active.tagName ? active.tagName.toUpperCase() : '';
|
||||
const isTyping = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (active && active.isContentEditable);
|
||||
if (isTyping) return;
|
||||
const tbody = document.getElementById('customersTableBody');
|
||||
if (!tbody || tbody.children.length === 0) return;
|
||||
switch (e.key) {
|
||||
case 'ArrowDown': e.preventDefault(); moveCustomerFocus(1); break;
|
||||
case 'ArrowUp': e.preventDefault(); moveCustomerFocus(-1); break;
|
||||
case 'PageDown': e.preventDefault(); moveCustomerFocus(10); break;
|
||||
case 'PageUp': e.preventDefault(); moveCustomerFocus(-10); break;
|
||||
case 'Home': e.preventDefault(); setCustomerFocus(0); break;
|
||||
case 'End': e.preventDefault(); setCustomerFocus(tbody.children.length - 1); break;
|
||||
case 'Enter': e.preventDefault(); openFocusedCustomer(); break;
|
||||
}
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
function refreshCustomerKeyboardRows() {
|
||||
const tbody = document.getElementById('customersTableBody');
|
||||
if (!tbody) return;
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
rows.forEach((row, idx) => {
|
||||
row.dataset.rowIndex = String(idx);
|
||||
if (!row.hasAttribute('tabindex')) row.setAttribute('tabindex', '-1');
|
||||
if (!row._navBound) {
|
||||
row.addEventListener('mouseenter', () => setCustomerFocus(idx));
|
||||
row.addEventListener('click', () => setCustomerFocus(idx));
|
||||
row._navBound = true;
|
||||
}
|
||||
});
|
||||
if (customerFocusIndex < 0 && rows.length > 0) setCustomerFocus(0);
|
||||
}
|
||||
|
||||
function setCustomerFocus(index) {
|
||||
const tbody = document.getElementById('customersTableBody');
|
||||
if (!tbody) return;
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
if (rows.length === 0) { customerFocusIndex = -1; return; }
|
||||
const clamped = Math.max(0, Math.min(index, rows.length - 1));
|
||||
if (clamped === customerFocusIndex) return;
|
||||
if (customerFocusIndex >= 0 && rows[customerFocusIndex]) {
|
||||
rows[customerFocusIndex].classList.remove('ring-2', 'ring-blue-400', 'dark:ring-blue-500', 'bg-blue-50', 'dark:bg-blue-900/30');
|
||||
}
|
||||
customerFocusIndex = clamped;
|
||||
const row = rows[customerFocusIndex];
|
||||
if (!row) return;
|
||||
row.classList.add('ring-2', 'ring-blue-400', 'dark:ring-blue-500', 'bg-blue-50', 'dark:bg-blue-900/30');
|
||||
try { row.scrollIntoView({ block: 'nearest' }); } catch (_) {}
|
||||
}
|
||||
|
||||
function moveCustomerFocus(delta) {
|
||||
const next = (customerFocusIndex < 0 ? 0 : customerFocusIndex) + delta;
|
||||
setCustomerFocus(next);
|
||||
}
|
||||
|
||||
function openFocusedCustomer() {
|
||||
const tbody = document.getElementById('customersTableBody');
|
||||
if (!tbody || customerFocusIndex < 0) return;
|
||||
const row = tbody.querySelector(`tr[data-row-index="${customerFocusIndex}"]`) || Array.from(tbody.querySelectorAll('tr'))[customerFocusIndex];
|
||||
const id = row && row.dataset ? row.dataset.customerId : null;
|
||||
if (id) viewCustomer(id);
|
||||
}
|
||||
|
||||
// Expose for external usage/debugging
|
||||
window.initializeCustomerListKeyboardNav = initializeCustomerListKeyboardNav;
|
||||
window.refreshCustomerKeyboardRows = refreshCustomerKeyboardRows;
|
||||
window.setCustomerFocus = setCustomerFocus;
|
||||
window.openFocusedCustomer = openFocusedCustomer;
|
||||
@@ -2,13 +2,23 @@
|
||||
function buildTokens(rawQuery) {
|
||||
const q = (rawQuery || '').trim();
|
||||
if (!q) return [];
|
||||
// Normalize punctuation to spaces, trim non-alphanumerics at ends, dedupe
|
||||
// Normalize punctuation to spaces, trim non-alphanumerics at ends
|
||||
const tokens = q
|
||||
.replace(/[,_;:]+/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map(t => t.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, ''))
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(tokens));
|
||||
// Case-insensitive dedupe while preserving original order and casing (parity with server)
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const tok of tokens) {
|
||||
const lowered = tok.toLowerCase();
|
||||
if (!seen.has(lowered)) {
|
||||
seen.add(lowered);
|
||||
result.push(tok);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
|
||||
Reference in New Issue
Block a user