coming together
This commit is contained in:
133
static/js/__tests__/alerts.ui.test.js
Normal file
133
static/js/__tests__/alerts.ui.test.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// Load sanitizer and alerts modules (IIFE attaches to window)
|
||||
require(path.join(__dirname, '..', 'sanitizer.js'));
|
||||
require(path.join(__dirname, '..', 'alerts.js'));
|
||||
|
||||
// Polyfill requestAnimationFrame for jsdom
|
||||
beforeAll(() => {
|
||||
if (!global.requestAnimationFrame) {
|
||||
global.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('alerts.show UI behavior', () => {
|
||||
it('creates a container and renders a notification', () => {
|
||||
const wrapper = window.alerts.show('Hello world', 'info', { duration: 0 });
|
||||
const container = document.getElementById('notification-container');
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.contains(wrapper)).toBe(true);
|
||||
expect(wrapper.className).toMatch(/alert-notification/);
|
||||
});
|
||||
|
||||
it('applies styling based on type and aliases', () => {
|
||||
const s = window.alerts.show('ok', 'success', { duration: 0 });
|
||||
const e = window.alerts.show('bad', 'error', { duration: 0 }); // alias to danger
|
||||
const w = window.alerts.show('warn', 'warning', { duration: 0 });
|
||||
const i = window.alerts.show('info', 'info', { duration: 0 });
|
||||
|
||||
expect(s.className).toContain('bg-green-50');
|
||||
expect(e.className).toContain('bg-red-50');
|
||||
expect(w.className).toContain('bg-yellow-50');
|
||||
expect(i.className).toContain('bg-blue-50');
|
||||
});
|
||||
|
||||
it('renders title when provided', () => {
|
||||
const wrapper = window.alerts.show('Body', 'info', { title: 'My Title', duration: 0 });
|
||||
const titleEl = wrapper.querySelector('p.text-sm.font-bold');
|
||||
expect(titleEl).toBeTruthy();
|
||||
expect(titleEl.textContent).toBe('My Title');
|
||||
});
|
||||
|
||||
it('sanitizes HTML when html option is true', () => {
|
||||
const wrapper = window.alerts.show('<img src=x onerror=alert(1)><script>evil()</script><p>hi</p>', 'info', { html: true, duration: 0 });
|
||||
const textEl = wrapper.querySelector('.text-sm.mt-1.font-semibold');
|
||||
expect(textEl).toBeTruthy();
|
||||
const html = textEl.innerHTML;
|
||||
expect(html).toContain('<img');
|
||||
expect(html).toContain('<p>hi</p>');
|
||||
expect(html).not.toMatch(/<script/i);
|
||||
expect(html).not.toMatch(/onerror/i);
|
||||
});
|
||||
|
||||
it('supports Node message content without sanitization', () => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = 'node message';
|
||||
const wrapper = window.alerts.show(span, 'info', { duration: 0 });
|
||||
const textEl = wrapper.querySelector('.text-sm.mt-1.font-semibold');
|
||||
expect(textEl.textContent).toContain('node message');
|
||||
});
|
||||
|
||||
it('is dismissible by default and calls onClose', () => {
|
||||
const onClose = jest.fn();
|
||||
const wrapper = window.alerts.show('dismiss me', 'info', { onClose, duration: 0 });
|
||||
const btn = wrapper.querySelector('button[aria-label="Close"]');
|
||||
expect(btn).toBeTruthy();
|
||||
btn.click();
|
||||
expect(document.body.contains(wrapper)).toBe(false);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('can be non-dismissible', () => {
|
||||
const wrapper = window.alerts.show('stay', 'info', { dismissible: false, duration: 0 });
|
||||
const btn = wrapper.querySelector('button[aria-label="Close"]');
|
||||
expect(btn).toBeNull();
|
||||
});
|
||||
|
||||
it('auto-closes after duration', () => {
|
||||
jest.useFakeTimers();
|
||||
const wrapper = window.alerts.show('timeout', 'info', { duration: 50 });
|
||||
expect(document.body.contains(wrapper)).toBe(true);
|
||||
jest.advanceTimersByTime(400);
|
||||
expect(document.body.contains(wrapper)).toBe(false);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders actions and handles clicks (autoClose true by default)', () => {
|
||||
const onClick = jest.fn();
|
||||
const wrapper = window.alerts.show('with action', 'info', {
|
||||
duration: 0,
|
||||
actions: [
|
||||
{ label: 'Retry', onClick }
|
||||
]
|
||||
});
|
||||
const buttons = Array.from(wrapper.querySelectorAll('button'));
|
||||
const retryBtn = buttons.find((b) => b.textContent === 'Retry');
|
||||
expect(retryBtn).toBeTruthy();
|
||||
retryBtn.click();
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(document.body.contains(wrapper)).toBe(false);
|
||||
});
|
||||
|
||||
it('respects action.autoClose = false', () => {
|
||||
const onClick = jest.fn();
|
||||
const wrapper = window.alerts.show('stay open', 'info', {
|
||||
duration: 0,
|
||||
actions: [
|
||||
{ label: 'Stay', onClick, autoClose: false }
|
||||
]
|
||||
});
|
||||
const buttons = Array.from(wrapper.querySelectorAll('button'));
|
||||
const stayBtn = buttons.find((b) => b.textContent === 'Stay');
|
||||
expect(stayBtn).toBeTruthy();
|
||||
stayBtn.click();
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(document.body.contains(wrapper)).toBe(true);
|
||||
});
|
||||
|
||||
it('supports custom containerId and element id', () => {
|
||||
const wrapper = window.alerts.show('custom', 'info', { duration: 0, containerId: 'alt-container', id: 'toast-1' });
|
||||
const container = document.getElementById('alt-container');
|
||||
expect(container).toBeTruthy();
|
||||
expect(wrapper.id).toBe('toast-1');
|
||||
expect(container.contains(wrapper)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
79
static/js/__tests__/highlight.test.js
Normal file
79
static/js/__tests__/highlight.test.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
// Load dependencies used by highlight utils
|
||||
require('../sanitizer.js');
|
||||
require('../highlight.js');
|
||||
|
||||
describe('highlightUtils', () => {
|
||||
const { buildTokens, highlight, escape: esc, formatSnippet } = window.highlightUtils;
|
||||
|
||||
test('buildTokens normalizes punctuation, trims non-alphanumerics, and dedupes', () => {
|
||||
const tokens = buildTokens(' John, Smith; "Smith" (J.) ');
|
||||
// Expect order preserved except deduping
|
||||
expect(tokens).toEqual(['John', 'Smith', 'J']);
|
||||
const empty = buildTokens(' , ; : ');
|
||||
expect(empty).toEqual([]);
|
||||
});
|
||||
|
||||
test('escape encodes special characters safely', () => {
|
||||
const out = esc('<div> & "quotes" and \'apostrophes\'');
|
||||
expect(out).toContain('<div>');
|
||||
expect(out).toContain('&');
|
||||
expect(out).toContain('"');
|
||||
expect(out).toContain(''');
|
||||
expect(esc('Tom & Jerry')).toBe('Tom & Jerry');
|
||||
});
|
||||
|
||||
test('highlight wraps tokens in <strong> and does not break HTML by escaping first', () => {
|
||||
const tokens = buildTokens('john smith');
|
||||
const result = highlight('Hello <b>John</b> Smith & Sons', tokens);
|
||||
// Should escape original tags and then apply strong
|
||||
expect(result).toContain('<b>');
|
||||
expect(result).toMatch(/<strong>John<\/strong>/i);
|
||||
expect(result).toMatch(/<strong>Smith<\/strong>/i);
|
||||
// Ampersand must be escaped
|
||||
expect(result).toContain('& Sons');
|
||||
});
|
||||
|
||||
test('highlight handles overlapping tokens by sequential replacement', () => {
|
||||
const tokens = buildTokens('ann anna');
|
||||
const out = highlight('Anna and Ann went', tokens);
|
||||
// Both tokens should appear highlighted; order of replacement should not remove prior highlights
|
||||
const strongCount = (out.match(/<strong>/g) || []).length;
|
||||
expect(strongCount).toBeGreaterThanOrEqual(2);
|
||||
expect(out).toMatch(/<strong>Anna<\/strong> and <strong>Ann<\/strong> went/i);
|
||||
});
|
||||
|
||||
test('formatSnippet uses server-provided strong tags if present', () => {
|
||||
const tokens = buildTokens('alpha');
|
||||
const serverSnippet = 'Value: <strong>Alpha</strong> beta';
|
||||
const html = formatSnippet(serverSnippet, tokens);
|
||||
// Should preserve strong from server
|
||||
expect(html).toContain('<strong>Alpha</strong>');
|
||||
// Should be sanitized and not double-escaped
|
||||
expect(html).toContain('Value: ');
|
||||
});
|
||||
|
||||
test('formatSnippet applies client-side bold when server snippet is plain text', () => {
|
||||
const tokens = buildTokens('delta');
|
||||
const plain = 'Gamma delta epsilon';
|
||||
const html = formatSnippet(plain, tokens);
|
||||
expect(html).toMatch(/Gamma <strong>delta<\/strong> epsilon/i);
|
||||
});
|
||||
|
||||
test('highlight is case-insensitive and preserves original text casing', () => {
|
||||
const tokens = buildTokens('joHN smiTH');
|
||||
const out = highlight('John Smith', tokens);
|
||||
// Must wrap both tokens and preserve the original casing from the source text
|
||||
expect(out).toBe('<strong>John</strong> <strong>Smith</strong>');
|
||||
});
|
||||
|
||||
test('formatSnippet highlights with mixed-case query tokens but keeps snippet casing', () => {
|
||||
const tokens = buildTokens('doE');
|
||||
const html = formatSnippet('Hello Doe', tokens);
|
||||
// Exact casing from snippet should be preserved inside <strong>
|
||||
expect(html).toContain('Hello <strong>Doe</strong>');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
123
static/js/__tests__/upload.ui.test.js
Normal file
123
static/js/__tests__/upload.ui.test.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// Install a fetch mock BEFORE loading the fetch-wrapper so it captures our mock
|
||||
const fetchMock = jest.fn();
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// Load sanitizer and alerts (IIFE attaches to window)
|
||||
require(path.join(__dirname, '..', 'sanitizer.js'));
|
||||
require(path.join(__dirname, '..', 'alerts.js'));
|
||||
// Load fetch wrapper (captures current global fetch as originalFetch)
|
||||
require(path.join(__dirname, '..', 'fetch-wrapper.js'));
|
||||
|
||||
// Polyfill requestAnimationFrame for jsdom animations in alerts
|
||||
beforeAll(() => {
|
||||
if (!global.requestAnimationFrame) {
|
||||
global.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
// Minimal UI helper that simulates the upload UI error handling
|
||||
async function uploadFileUI() {
|
||||
// Attempt an upload; on failure, surface the envelope via alerts.error with a sanitized HTML message
|
||||
const resp = await window.http.wrappedFetch('/api/documents/upload/FILE-123', {
|
||||
method: 'POST',
|
||||
body: new FormData(),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await window.http.toError(resp, 'Upload failed');
|
||||
const msg = window.http.formatAlert(err, 'Upload failed');
|
||||
// html: true to allow basic markup from server but sanitized; duration 0 so it stays for assertions
|
||||
return window.alerts.error(msg, { html: true, duration: 0, id: 'upload-error' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function makeErrorResponse({ status = 400, envelope, headerCid = null }) {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
headers: {
|
||||
get: (name) => {
|
||||
if (name && name.toLowerCase() === 'x-correlation-id') return headerCid || null;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
clone() {
|
||||
return this;
|
||||
},
|
||||
async json() {
|
||||
return envelope;
|
||||
},
|
||||
async text() {
|
||||
try { return JSON.stringify(envelope); } catch (_) { return ''; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Upload UI error handling', () => {
|
||||
it('displays server error via alerts.error, sanitizes HTML, and includes correlation ID', async () => {
|
||||
const cid = 'cid-abc123';
|
||||
const envelope = {
|
||||
success: false,
|
||||
error: { status: 400, code: 'http_error', message: 'Invalid <b>file</b> <script>evil()</script><img src=x onerror="alert(1)">' },
|
||||
correlation_id: cid,
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(makeErrorResponse({ status: 400, envelope, headerCid: cid }));
|
||||
|
||||
const wrapper = await uploadFileUI();
|
||||
expect(wrapper).toBeTruthy();
|
||||
expect(wrapper.id).toBe('upload-error');
|
||||
|
||||
const content = wrapper.querySelector('.text-sm.mt-1.font-semibold');
|
||||
expect(content).toBeTruthy();
|
||||
const html = content.innerHTML;
|
||||
// Preserves safe markup
|
||||
expect(html).toContain('<b>file</b>');
|
||||
// Scripts and event handlers removed
|
||||
expect(html).not.toMatch(/<script/i);
|
||||
expect(html).not.toMatch(/onerror=/i);
|
||||
// Correlation reference present
|
||||
expect(html).toMatch(/Ref: cid-abc123/);
|
||||
});
|
||||
|
||||
it('uses correlation ID from header when both header and envelope provide values', async () => {
|
||||
const headerCid = 'cid-header';
|
||||
const bodyCid = 'cid-body';
|
||||
const envelope = {
|
||||
success: false,
|
||||
error: { status: 400, code: 'http_error', message: 'Invalid type' },
|
||||
correlation_id: bodyCid,
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(makeErrorResponse({ status: 400, envelope, headerCid }));
|
||||
|
||||
const wrapper = await uploadFileUI();
|
||||
const html = wrapper.querySelector('.text-sm.mt-1.font-semibold').innerHTML;
|
||||
expect(html).toMatch(/Ref: cid-header/);
|
||||
expect(html).not.toMatch(/cid-body/);
|
||||
});
|
||||
|
||||
it('falls back to basic text if alerts module is missing but our alerts is present; ensures container exists', async () => {
|
||||
const cid = 'cid-xyz';
|
||||
const envelope = {
|
||||
success: false,
|
||||
error: { status: 400, code: 'http_error', message: 'Bad <em>upload</em>' },
|
||||
correlation_id: cid,
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(makeErrorResponse({ status: 400, envelope, headerCid: cid }));
|
||||
|
||||
const wrapper = await uploadFileUI();
|
||||
const container = document.getElementById('notification-container');
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.contains(wrapper)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -240,8 +240,9 @@ function showCustomerDetailsModal(customer) {
|
||||
<button onclick="closeCustomerDetailsModal()" class="px-4 py-2 bg-neutral-300 dark:bg-neutral-600 text-neutral-800 dark:text-white hover:bg-neutral-400 dark:hover:bg-neutral-500 rounded-lg transition-colors text-sm font-medium border border-neutral-400 dark:border-neutral-500">
|
||||
Close
|
||||
</button>
|
||||
<button onclick="closeCustomerDetailsModal(); editCustomer('${escapeHtml(customer.id)}');" style="background-color: #dc2626; color: white; padding: 8px 16px; border-radius: 6px; border: none; font-weight: 500; font-size: 14px;">
|
||||
<i class="fa-solid fa-pencil" style="margin-right: 4px;"></i>Edit
|
||||
<button onclick="closeCustomerDetailsModal(); editCustomer('${escapeHtml(customer.id)}');" class="inline-flex items-center px-4 py-2 bg-danger-600 text-white hover:bg-danger-700 rounded-lg transition-colors text-sm font-medium shadow-sm">
|
||||
<i class="fa-solid fa-pencil mr-2"></i>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,18 @@ let currentPage = 0;
|
||||
let currentSearch = '';
|
||||
let isEditing = false;
|
||||
let editingCustomerId = null;
|
||||
let selectedCustomerIds = new Set();
|
||||
let customerCompactMode = false;
|
||||
|
||||
// Local debounce fallback to avoid dependency on main.js
|
||||
function _localDebounce(func, wait) {
|
||||
let timeout;
|
||||
return function() {
|
||||
const context = this, args = arguments;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(context, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Enhanced table display function
|
||||
function displayCustomers(customers) {
|
||||
@@ -18,51 +30,74 @@ function displayCustomers(customers) {
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
// Selection removed
|
||||
|
||||
// Build highlight function based on currentSearch tokens
|
||||
const tokens = (window.highlightUtils && typeof window.highlightUtils.buildTokens === 'function')
|
||||
? window.highlightUtils.buildTokens((currentSearch || '').trim())
|
||||
: [];
|
||||
function highlightText(text) {
|
||||
if (!text) return '';
|
||||
if (!window.highlightUtils || typeof window.highlightUtils.highlight !== 'function' || tokens.length === 0) {
|
||||
return escapeHtml(String(text));
|
||||
}
|
||||
// Use safe highlighter that computes ranges, then transform <strong> to styled <mark>
|
||||
const strongHtml = window.highlightUtils.highlight(String(text), tokens);
|
||||
return strongHtml
|
||||
.replace(/<strong>/g, '<mark class="bg-yellow-200 text-neutral-900 rounded px-0.5">')
|
||||
.replace(/<\/strong>/g, '</mark>');
|
||||
}
|
||||
|
||||
customers.forEach(customer => {
|
||||
const phones = Array.isArray(customer.phone_numbers) ? customer.phone_numbers : [];
|
||||
const primaryPhone = phones.length > 0 ? (phones[0].phone || '') : '';
|
||||
const phoneCount = phones.length;
|
||||
const phoneHtml = `${highlightText(primaryPhone)}${phoneCount > 1 ? ` <span class="text-xs text-neutral-500">(+${phoneCount - 1} more)</span>` : ''}`;
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'group border-b border-neutral-100 dark:border-neutral-700/50 hover:bg-gradient-to-r hover:from-blue-50/30 hover:to-indigo-50/30 dark:hover:from-blue-900/10 dark:hover:to-indigo-900/10 transition-all duration-200';
|
||||
row.className = 'group odd:bg-neutral-50 dark:odd:bg-neutral-800/50 border-b border-neutral-100 dark:border-neutral-700/50 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors';
|
||||
|
||||
// Store customer ID as data attribute to avoid escaping issues in onclick
|
||||
row.dataset.customerId = customer.id;
|
||||
|
||||
// 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';
|
||||
row.innerHTML = `
|
||||
<td class="px-4 py-4 cursor-pointer customer-cell">
|
||||
<div class="text-sm font-mono font-semibold text-neutral-900 dark:text-neutral-100 bg-gradient-to-br from-neutral-50 to-neutral-100 dark:from-neutral-800 dark:to-neutral-700 px-3 py-2 rounded-lg shadow-sm border border-neutral-200/50 dark:border-neutral-600/50 group-hover:shadow-md transition-shadow">
|
||||
${escapeHtml(customer.id || '')}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4 cursor-pointer customer-cell">
|
||||
<td class="${pad} cursor-pointer customer-cell">
|
||||
<div class="text-sm font-mono font-semibold text-neutral-900 dark:text-neutral-100 bg-gradient-to-br from-neutral-50 to-neutral-100 dark:from-neutral-800 dark:to-neutral-700 px-3 py-2 rounded-lg shadow-sm border border-neutral-200/50 dark:border-neutral-600/50 group-hover:shadow-md transition-shadow">
|
||||
${highlightText(customer.id || '')}
|
||||
</div>
|
||||
</td>
|
||||
<td class="${pad} cursor-pointer customer-cell">
|
||||
<div class="text-sm font-semibold text-neutral-900 dark:text-neutral-100 group-hover:text-blue-700 dark:group-hover:text-blue-300 transition-colors">
|
||||
${escapeHtml(formatFullName(customer))}
|
||||
${highlightText(formatFullName(customer))}
|
||||
</div>
|
||||
${customer.title ? `<div class="text-xs text-neutral-500 dark:text-neutral-400 mt-1 font-medium">${escapeHtml(customer.title)}</div>` : ''}
|
||||
${customerCompactMode ? '' : (customer.title ? `<div class="text-xs text-neutral-500 dark:text-neutral-400 mt-1 font-medium">${highlightText(customer.title)}</div>` : '')}
|
||||
</td>
|
||||
<td class="px-4 py-4 cursor-pointer customer-cell">
|
||||
${customer.group ? `<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-bold bg-gradient-to-r from-blue-50 to-blue-100 dark:from-blue-900/40 dark:to-blue-800/40 text-blue-800 dark:text-blue-200 border border-blue-200/70 dark:border-blue-700/70 shadow-sm">${escapeHtml(customer.group)}</span>` : '<span class="text-neutral-400 text-sm font-medium">-</span>'}
|
||||
</td>
|
||||
<td class="px-4 py-4 cursor-pointer customer-cell">
|
||||
<td class="${pad} cursor-pointer customer-cell">
|
||||
${customer.group ? `<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-bold bg-gradient-to-r from-blue-50 to-blue-100 dark:from-blue-900/40 dark:to-blue-800/40 text-blue-800 dark:text-blue-200 border border-blue-200/70 dark:border-blue-700/70 shadow-sm">${highlightText(customer.group)}</span>` : '<span class="text-neutral-400 text-sm font-medium">-</span>'}
|
||||
</td>
|
||||
<td class="${pad} cursor-pointer customer-cell">
|
||||
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
${escapeHtml(formatCityState(customer))}
|
||||
${highlightText(formatCityState(customer))}
|
||||
</div>
|
||||
${customer.a1 ? `<div class="text-xs text-neutral-500 dark:text-neutral-400 mt-1 truncate max-w-xs font-medium">${escapeHtml(customer.a1)}</div>` : ''}
|
||||
${customerCompactMode ? '' : (customer.a1 ? `<div class="text-xs text-neutral-500 dark:text-neutral-400 mt-1 truncate max-w-xs font-medium">${highlightText(customer.a1)}</div>` : '')}
|
||||
</td>
|
||||
<td class="px-4 py-4 cursor-pointer customer-cell">
|
||||
<td class="${pad} cursor-pointer customer-cell">
|
||||
<div class="text-sm font-mono font-medium text-neutral-900 dark:text-neutral-100">
|
||||
${formatPrimaryPhone(customer.phone_numbers || [])}
|
||||
${phoneHtml}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4 cursor-pointer customer-cell">
|
||||
${customer.email ? `<a href="mailto:${encodeURIComponent(customer.email)}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 text-sm font-medium transition-colors underline decoration-blue-300/50 hover:decoration-blue-500" onclick="event.stopPropagation()">${escapeHtml(customer.email)}</a>` : '<span class="text-neutral-400 text-sm font-medium">-</span>'}
|
||||
</td>
|
||||
<td class="px-4 py-4 text-right">
|
||||
<td class="${pad} cursor-pointer customer-cell">
|
||||
${customer.email ? `<a href="mailto:${encodeURIComponent(customer.email)}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 text-sm font-medium transition-colors underline decoration-blue-300/50 hover:decoration-blue-500" onclick="event.stopPropagation()">${highlightText(customer.email)}</a>` : '<span class="text-neutral-400 text-sm font-medium">-</span>'}
|
||||
</td>
|
||||
<td class="${pad} text-right">
|
||||
<div class="flex items-center justify-end space-x-2 opacity-70 group-hover:opacity-100 transition-opacity">
|
||||
<button class="view-customer-btn inline-flex items-center px-3 py-2 bg-gradient-to-r from-slate-100 to-slate-200 dark:from-slate-700 dark:to-slate-600 text-slate-700 dark:text-slate-200 hover:from-slate-200 hover:to-slate-300 dark:hover:from-slate-600 dark:hover:to-slate-500 rounded-lg text-sm font-semibold transition-all duration-200 shadow-sm hover:shadow-md border border-slate-300/50 dark:border-slate-500/50">
|
||||
<i class="fa-solid fa-eye mr-2"></i>
|
||||
View
|
||||
</button>
|
||||
<button class="edit-customer-btn" style="display: inline-flex; align-items: center; background-color: #dc2626; color: white; padding: 8px 12px; border-radius: 6px; border: none; font-weight: 600; font-size: 14px;">
|
||||
<i class="fa-solid fa-pencil" style="margin-right: 8px;"></i>
|
||||
<button class="edit-customer-btn inline-flex items-center px-3 py-2 bg-primary-600 text-white hover:bg-primary-700 rounded-lg text-sm font-semibold transition-all duration-200 shadow-sm hover:shadow-md">
|
||||
<i class="fa-solid fa-pencil mr-2"></i>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
@@ -90,6 +125,8 @@ function displayCustomers(customers) {
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
// No select-all
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
@@ -713,4 +750,132 @@ window.populateEditForm = populateEditForm;
|
||||
window.populatePhoneNumbers = populatePhoneNumbers;
|
||||
window.clearCustomerForm = clearCustomerForm;
|
||||
window.addPhoneNumber = addPhoneNumber;
|
||||
window.removePhoneNumber = removePhoneNumber;
|
||||
window.removePhoneNumber = removePhoneNumber;
|
||||
window.updateRowSelectionClass = updateRowSelectionClass;
|
||||
window.syncSelectAllCheckbox = syncSelectAllCheckbox;
|
||||
window.enhanceCustomerTableRows = enhanceCustomerTableRows;
|
||||
window.initializeCustomerListEnhancer = initializeCustomerListEnhancer;
|
||||
|
||||
// Enhance existing rows (useful for phone search results or server-rendered content)
|
||||
function enhanceCustomerTableRows() {
|
||||
const tbody = document.getElementById('customersTableBody');
|
||||
if (!tbody) return;
|
||||
// Load persisted selection
|
||||
let savedSet = new Set();
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('customers.selectedIds') || '[]');
|
||||
savedSet = new Set(Array.isArray(saved) ? saved : []);
|
||||
} catch (_) {}
|
||||
Array.from(tbody.querySelectorAll('tr')).forEach(row => {
|
||||
const id = row.dataset && row.dataset.customerId ? row.dataset.customerId : null;
|
||||
if (!id) return;
|
||||
let firstCell = row.children[0];
|
||||
const hasCheckbox = firstCell && firstCell.querySelector && firstCell.querySelector('.customer-row-select');
|
||||
if (!hasCheckbox) {
|
||||
const cell = document.createElement('td');
|
||||
cell.className = `px-4 ${customerCompactMode ? 'py-2' : 'py-4'} text-center align-middle`;
|
||||
cell.innerHTML = `<input type="checkbox" class="customer-row-select h-4 w-4" data-id="${escapeHtml(id)}">`;
|
||||
row.insertBefore(cell, row.firstChild);
|
||||
firstCell = cell;
|
||||
}
|
||||
const checkbox = row.querySelector('.customer-row-select');
|
||||
if (checkbox) {
|
||||
checkbox.checked = savedSet.has(id);
|
||||
updateRowSelectionClass(row, checkbox.checked);
|
||||
if (!checkbox._enhanced) {
|
||||
checkbox.addEventListener('change', () => {
|
||||
if (checkbox.checked) {
|
||||
selectedCustomerIds.add(id);
|
||||
} else {
|
||||
selectedCustomerIds.delete(id);
|
||||
}
|
||||
saveSelectedIds();
|
||||
updateRowSelectionClass(row, checkbox.checked);
|
||||
syncSelectAllCheckbox();
|
||||
});
|
||||
checkbox._enhanced = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
syncSelectAllCheckbox();
|
||||
}
|
||||
|
||||
function initializeCustomerListEnhancer() {
|
||||
const tbody = document.getElementById('customersTableBody');
|
||||
if (!tbody || window._customerListObserver) return;
|
||||
const debouncedEnhance = (typeof window.debounce === 'function' ? window.debounce : _localDebounce)(() => enhanceCustomerTableRows(), 10);
|
||||
const observer = new MutationObserver(() => debouncedEnhance());
|
||||
observer.observe(tbody, { childList: true, subtree: false });
|
||||
window._customerListObserver = observer;
|
||||
// Initial pass
|
||||
enhanceCustomerTableRows();
|
||||
}
|
||||
|
||||
// Selection helpers
|
||||
function saveSelectedIds() {
|
||||
try { localStorage.setItem('customers.selectedIds', JSON.stringify(Array.from(selectedCustomerIds))); } catch (_) {}
|
||||
}
|
||||
|
||||
function updateRowSelectionClass(row, selected) {
|
||||
row.classList.toggle('bg-blue-50', selected);
|
||||
row.classList.toggle('dark:bg-blue-900/30', selected);
|
||||
}
|
||||
|
||||
function syncSelectAllCheckbox() {
|
||||
const headerCb = document.getElementById('selectAllCustomers');
|
||||
if (!headerCb) return;
|
||||
const checkboxes = Array.from(document.querySelectorAll('#customersTableBody .customer-row-select'));
|
||||
if (checkboxes.length === 0) {
|
||||
headerCb.checked = false;
|
||||
headerCb.indeterminate = false;
|
||||
return;
|
||||
}
|
||||
const checkedCount = checkboxes.filter(cb => cb.checked).length;
|
||||
headerCb.checked = checkedCount === checkboxes.length;
|
||||
headerCb.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
|
||||
}
|
||||
|
||||
// Compact mode helpers
|
||||
function initializeCustomerListState() {
|
||||
try {
|
||||
customerCompactMode = localStorage.getItem('customers.compactMode') === '1';
|
||||
} catch (_) { customerCompactMode = false; }
|
||||
updateCompactModeButton();
|
||||
}
|
||||
|
||||
function toggleCompactMode() {
|
||||
customerCompactMode = !customerCompactMode;
|
||||
try { localStorage.setItem('customers.compactMode', customerCompactMode ? '1' : '0'); } catch (_) {}
|
||||
updateCompactModeButton();
|
||||
// Re-render current page with current search
|
||||
loadCustomers(currentPage, currentSearch);
|
||||
}
|
||||
|
||||
function updateCompactModeButton() {
|
||||
const btn = document.getElementById('toggleCompactMode');
|
||||
if (btn) {
|
||||
btn.textContent = `Compact: ${customerCompactMode ? 'On' : 'Off'}`;
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectAllChange(checked) {
|
||||
const checkboxes = Array.from(document.querySelectorAll('#customersTableBody .customer-row-select'));
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checked;
|
||||
const row = cb.closest('tr');
|
||||
const id = cb.dataset.id;
|
||||
if (checked) {
|
||||
selectedCustomerIds.add(id);
|
||||
} else {
|
||||
selectedCustomerIds.delete(id);
|
||||
}
|
||||
updateRowSelectionClass(row, checked);
|
||||
});
|
||||
saveSelectedIds();
|
||||
syncSelectAllCheckbox();
|
||||
}
|
||||
|
||||
// Expose helpers
|
||||
window.initializeCustomerListState = initializeCustomerListState;
|
||||
window.toggleCompactMode = toggleCompactMode;
|
||||
window.onSelectAllChange = onSelectAllChange;
|
||||
314
static/js/flexible.js
Normal file
314
static/js/flexible.js
Normal file
@@ -0,0 +1,314 @@
|
||||
(function() {
|
||||
const apiBase = '/api/flexible';
|
||||
let state = {
|
||||
fileType: '',
|
||||
targetTable: '',
|
||||
q: '',
|
||||
skip: 0,
|
||||
limit: 50,
|
||||
total: 0,
|
||||
hasKeys: [],
|
||||
};
|
||||
|
||||
function q(id) { return document.getElementById(id); }
|
||||
|
||||
function formatPreviewHtml(obj, term) {
|
||||
// Returns sanitized HTML with clickable keys
|
||||
try {
|
||||
const payload = obj && obj.unmapped && typeof obj.unmapped === 'object' ? obj.unmapped : obj;
|
||||
const keys = Object.keys(payload || {}).slice(0, 5);
|
||||
const segments = keys.map((k) => {
|
||||
const safeKey = window.htmlSanitizer.escape(String(k));
|
||||
const valueStr = String(payload[k]).slice(0, 60);
|
||||
const valueHtml = term && term.trim().length > 0 ? highlight(valueStr, term) : window.htmlSanitizer.escape(valueStr);
|
||||
return `<span class="kv-pair"><button type="button" class="key-link text-primary-700 dark:text-primary-400 hover:underline" data-key="${safeKey}">${safeKey}</button>: ${valueHtml}</span>`;
|
||||
});
|
||||
return segments.join(', ');
|
||||
} catch (_) { return ''; }
|
||||
}
|
||||
|
||||
function escapeRegExp(str) {
|
||||
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function highlight(text, term) {
|
||||
if (!term) return window.htmlSanitizer.escape(text);
|
||||
const pattern = new RegExp(escapeRegExp(term), 'ig');
|
||||
const escaped = window.htmlSanitizer.escape(text);
|
||||
// Replace on the escaped string to avoid breaking HTML
|
||||
return escaped.replace(pattern, (m) => `<mark>${window.htmlSanitizer.escape(m)}</mark>`);
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
try {
|
||||
const res = await window.http.wrappedFetch(`${apiBase}/options`);
|
||||
if (!res.ok) throw await window.http.toError(res, 'Failed to load options');
|
||||
const data = await res.json();
|
||||
const fileSel = q('filterFileType');
|
||||
const tableSel = q('filterTargetTable');
|
||||
// Clear existing except first
|
||||
fileSel.length = 1; tableSel.length = 1;
|
||||
(data.file_types || []).forEach(v => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = v; opt.textContent = v; fileSel.appendChild(opt);
|
||||
});
|
||||
(data.target_tables || []).forEach(v => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = v; opt.textContent = v; tableSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
alert(window.http.formatAlert(e, 'Error loading options'));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRows() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (state.fileType) params.set('file_type', state.fileType);
|
||||
if (state.targetTable) params.set('target_table', state.targetTable);
|
||||
if (state.q) params.set('q', state.q);
|
||||
if (Array.isArray(state.hasKeys)) {
|
||||
state.hasKeys.forEach((k) => {
|
||||
if (k && String(k).trim().length > 0) params.append('has_keys', String(k).trim());
|
||||
});
|
||||
}
|
||||
params.set('skip', String(state.skip));
|
||||
params.set('limit', String(state.limit));
|
||||
const res = await window.http.wrappedFetch(`${apiBase}/imports?${params.toString()}`);
|
||||
if (!res.ok) throw await window.http.toError(res, 'Failed to load flexible imports');
|
||||
const data = await res.json();
|
||||
state.total = data.total || 0;
|
||||
renderRows(data.items || []);
|
||||
renderMeta();
|
||||
renderKeyChips();
|
||||
} catch (e) {
|
||||
alert(window.http.formatAlert(e, 'Error loading flexible imports'));
|
||||
}
|
||||
}
|
||||
|
||||
function renderRows(items) {
|
||||
const tbody = q('flexibleRows');
|
||||
tbody.innerHTML = '';
|
||||
items.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'hover:bg-neutral-50 dark:hover:bg-neutral-700/40 cursor-pointer';
|
||||
tr.innerHTML = `
|
||||
<td class="px-3 py-2 whitespace-nowrap">${item.id}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">${window.htmlSanitizer.escape(item.file_type || '')}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">${window.htmlSanitizer.escape(item.target_table || '')}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-xs text-neutral-500">${window.htmlSanitizer.escape((item.primary_key_field || '') + (item.primary_key_value ? '=' + item.primary_key_value : ''))}</td>
|
||||
<td class="px-3 py-2 text-xs previewCell"></td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<button class="inline-flex items-center gap-1 text-primary-600 hover:text-primary-700 px-2 py-1 text-xs rounded-md hover:bg-primary-50 dark:hover:bg-primary-900/30" data-action="export" data-id="${item.id}">
|
||||
<i class="fa-solid fa-download"></i>
|
||||
<span>CSV</span>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
// Set sanitized highlighted preview
|
||||
const previewCell = tr.querySelector('.previewCell');
|
||||
const previewHtml = formatPreviewHtml(item.extra_data || {}, state.q);
|
||||
window.setSafeHTML(previewCell, previewHtml);
|
||||
// Bind click on keys to add filters
|
||||
previewCell.querySelectorAll('.key-link').forEach((btn) => {
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
const key = btn.getAttribute('data-key') || '';
|
||||
addKeyFilter(key);
|
||||
});
|
||||
});
|
||||
// Row click opens modal
|
||||
tr.addEventListener('click', (ev) => {
|
||||
// Ignore clicks on the export button inside the row
|
||||
const target = ev.target.closest('button[data-action="export"]');
|
||||
if (target) return;
|
||||
openDetailModal(item);
|
||||
});
|
||||
// Export button handler
|
||||
tr.querySelector('button[data-action="export"]').addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
exportSingleRow(item.id);
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderMeta() {
|
||||
const start = state.total === 0 ? 0 : state.skip + 1;
|
||||
const end = Math.min(state.skip + state.limit, state.total);
|
||||
q('rowsMeta').textContent = `Showing ${start}-${end} of ${state.total}`;
|
||||
q('prevPageBtn').disabled = state.skip === 0;
|
||||
q('nextPageBtn').disabled = state.skip + state.limit >= state.total;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
state.fileType = q('filterFileType').value || '';
|
||||
state.targetTable = q('filterTargetTable').value || '';
|
||||
state.q = (q('quickSearch').value || '').trim();
|
||||
state.skip = 0;
|
||||
loadRows();
|
||||
}
|
||||
|
||||
function addKeyFilter(key) {
|
||||
const k = String(key || '').trim();
|
||||
if (!k) return;
|
||||
if (!Array.isArray(state.hasKeys)) state.hasKeys = [];
|
||||
if (!state.hasKeys.includes(k)) {
|
||||
state.hasKeys.push(k);
|
||||
state.skip = 0;
|
||||
loadRows();
|
||||
}
|
||||
}
|
||||
|
||||
function removeKeyFilter(key) {
|
||||
const k = String(key || '').trim();
|
||||
if (!k) return;
|
||||
state.hasKeys = (state.hasKeys || []).filter((x) => x !== k);
|
||||
state.skip = 0;
|
||||
loadRows();
|
||||
}
|
||||
|
||||
function clearKeyFilters() {
|
||||
if ((state.hasKeys || []).length === 0) return;
|
||||
state.hasKeys = [];
|
||||
state.skip = 0;
|
||||
loadRows();
|
||||
}
|
||||
|
||||
function renderKeyChips() {
|
||||
const container = q('keyChipsContainer');
|
||||
const chipsWrap = q('keyChips');
|
||||
const clearBtn = q('clearKeyChips');
|
||||
if (!container || !chipsWrap) return;
|
||||
chipsWrap.innerHTML = '';
|
||||
const keys = state.hasKeys || [];
|
||||
if (keys.length === 0) {
|
||||
container.classList.add('hidden');
|
||||
} else {
|
||||
container.classList.remove('hidden');
|
||||
keys.forEach((k) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-primary-50 text-primary-700 border border-primary-200 hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-200 dark:border-primary-800';
|
||||
btn.setAttribute('data-chip-key', k);
|
||||
btn.innerHTML = `<span class="font-mono">${window.htmlSanitizer.escape(k)}</span> <i class="fa-solid fa-xmark"></i>`;
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
removeKeyFilter(k);
|
||||
});
|
||||
chipsWrap.appendChild(btn);
|
||||
});
|
||||
}
|
||||
if (clearBtn) {
|
||||
clearBtn.onclick = (ev) => { ev.preventDefault(); clearKeyFilters(); };
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (state.fileType) params.set('file_type', state.fileType);
|
||||
if (state.targetTable) params.set('target_table', state.targetTable);
|
||||
if (Array.isArray(state.hasKeys)) {
|
||||
state.hasKeys.forEach((k) => {
|
||||
if (k && String(k).trim().length > 0) params.append('has_keys', String(k).trim());
|
||||
});
|
||||
}
|
||||
const url = `${apiBase}/export?${params.toString()}`;
|
||||
const res = await window.http.wrappedFetch(url);
|
||||
if (!res.ok) throw await window.http.toError(res, 'Export failed');
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
a.href = objectUrl;
|
||||
a.download = 'flexible_unmapped.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
} catch (e) {
|
||||
alert(window.http.formatAlert(e, 'Error exporting CSV'));
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSingleRow(rowId) {
|
||||
try {
|
||||
const res = await window.http.wrappedFetch(`${apiBase}/export/${rowId}`);
|
||||
if (!res.ok) throw await window.http.toError(res, 'Export failed');
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
a.href = objectUrl;
|
||||
a.download = `flexible_row_${rowId}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
} catch (e) {
|
||||
alert(window.http.formatAlert(e, 'Error exporting row CSV'));
|
||||
}
|
||||
}
|
||||
|
||||
function openDetailModal(item) {
|
||||
// Populate fields
|
||||
q('detailRowId').textContent = `#${item.id}`;
|
||||
q('detailFileType').textContent = item.file_type || '';
|
||||
q('detailTargetTable').textContent = item.target_table || '';
|
||||
q('detailPkField').textContent = item.primary_key_field || '';
|
||||
q('detailPkValue').textContent = item.primary_key_value || '';
|
||||
try {
|
||||
const pretty = JSON.stringify(item.extra_data || {}, null, 2);
|
||||
q('detailJson').textContent = pretty;
|
||||
} catch (_) {
|
||||
q('detailJson').textContent = '';
|
||||
}
|
||||
const exportBtn = q('detailExportBtn');
|
||||
exportBtn.onclick = () => exportSingleRow(item.id);
|
||||
openModal('flexibleDetailModal');
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
q('applyFiltersBtn').addEventListener('click', applyFilters);
|
||||
q('exportCsvBtn').addEventListener('click', exportCsv);
|
||||
const clearBtn = q('clearKeyChips');
|
||||
if (clearBtn) clearBtn.addEventListener('click', (ev) => { ev.preventDefault(); clearKeyFilters(); });
|
||||
// Quick search with debounce
|
||||
const searchInput = q('quickSearch');
|
||||
let searchTimer = null;
|
||||
searchInput.addEventListener('input', () => {
|
||||
const value = searchInput.value || '';
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
state.q = value.trim();
|
||||
state.skip = 0;
|
||||
loadRows();
|
||||
}, 300);
|
||||
});
|
||||
searchInput.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
clearTimeout(searchTimer);
|
||||
state.q = (searchInput.value || '').trim();
|
||||
state.skip = 0;
|
||||
loadRows();
|
||||
}
|
||||
});
|
||||
q('prevPageBtn').addEventListener('click', () => {
|
||||
state.skip = Math.max(0, state.skip - state.limit);
|
||||
loadRows();
|
||||
});
|
||||
q('nextPageBtn').addEventListener('click', () => {
|
||||
if (state.skip + state.limit < state.total) {
|
||||
state.skip += state.limit;
|
||||
loadRows();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
bindEvents();
|
||||
loadOptions().then(loadRows);
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
95
static/js/highlight.js
Normal file
95
static/js/highlight.js
Normal file
@@ -0,0 +1,95 @@
|
||||
(function(){
|
||||
function buildTokens(rawQuery) {
|
||||
const q = (rawQuery || '').trim();
|
||||
if (!q) return [];
|
||||
// Normalize punctuation to spaces, trim non-alphanumerics at ends, dedupe
|
||||
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));
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
try {
|
||||
return (window.htmlSanitizer && window.htmlSanitizer.escape)
|
||||
? window.htmlSanitizer.escape(text)
|
||||
: String(text == null ? '' : text);
|
||||
} catch (_) {
|
||||
return String(text == null ? '' : text);
|
||||
}
|
||||
}
|
||||
|
||||
function highlight(text, tokens) {
|
||||
const value = text == null ? '' : String(text);
|
||||
if (!value || !Array.isArray(tokens) || tokens.length === 0) return escapeHtml(value);
|
||||
try {
|
||||
const source = String(value);
|
||||
const haystack = source.toLowerCase();
|
||||
const uniqueTokens = Array.from(new Set((tokens || []).map(t => String(t).toLowerCase()).filter(Boolean)));
|
||||
const ranges = [];
|
||||
uniqueTokens.forEach(t => {
|
||||
let from = 0;
|
||||
while (from <= haystack.length - t.length && t.length > 0) {
|
||||
const idx = haystack.indexOf(t, from);
|
||||
if (idx === -1) break;
|
||||
ranges.push([idx, idx + t.length]);
|
||||
from = idx + 1; // allow overlapping matches shift by 1
|
||||
}
|
||||
});
|
||||
if (ranges.length === 0) return escapeHtml(source);
|
||||
// Merge overlapping/adjacent ranges
|
||||
ranges.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
const merged = [];
|
||||
let [curStart, curEnd] = ranges[0];
|
||||
for (let i = 1; i < ranges.length; i++) {
|
||||
const [s, e] = ranges[i];
|
||||
if (s <= curEnd) {
|
||||
// overlap or adjacency
|
||||
curEnd = Math.max(curEnd, e);
|
||||
} else {
|
||||
merged.push([curStart, curEnd]);
|
||||
[curStart, curEnd] = [s, e];
|
||||
}
|
||||
}
|
||||
merged.push([curStart, curEnd]);
|
||||
// Build output with escaping of text segments
|
||||
let out = '';
|
||||
let pos = 0;
|
||||
merged.forEach(([s, e]) => {
|
||||
if (pos < s) out += escapeHtml(source.slice(pos, s));
|
||||
out += '<strong>' + escapeHtml(source.slice(s, e)) + '</strong>';
|
||||
pos = e;
|
||||
});
|
||||
if (pos < source.length) out += escapeHtml(source.slice(pos));
|
||||
return out;
|
||||
} catch (_) {
|
||||
return escapeHtml(String(text));
|
||||
}
|
||||
}
|
||||
|
||||
function formatSnippet(snippet, tokens) {
|
||||
if (!snippet) return '';
|
||||
let html = String(snippet);
|
||||
try {
|
||||
const hasStrong = /<\s*strong\b/i.test(html);
|
||||
if (!hasStrong) {
|
||||
html = highlight(html, Array.isArray(tokens) ? tokens : []);
|
||||
}
|
||||
if (window.htmlSanitizer && typeof window.htmlSanitizer.sanitize === 'function') {
|
||||
html = window.htmlSanitizer.sanitize(html);
|
||||
}
|
||||
} catch (_) {}
|
||||
return html;
|
||||
}
|
||||
|
||||
window.highlightUtils = {
|
||||
buildTokens,
|
||||
highlight,
|
||||
escape: escapeHtml,
|
||||
formatSnippet
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ function handleKeyboardShortcuts(event) {
|
||||
event.preventDefault();
|
||||
navigateTo('/documents');
|
||||
break;
|
||||
case 'Alt+I':
|
||||
event.preventDefault();
|
||||
navigateTo('/import');
|
||||
break;
|
||||
case 'Alt+A':
|
||||
event.preventDefault();
|
||||
navigateTo('/admin');
|
||||
|
||||
@@ -277,7 +277,7 @@ function setAuthToken(token) {
|
||||
// Page helpers
|
||||
function isLoginPage() {
|
||||
const path = window.location.pathname;
|
||||
return path === '/login' || path === '/';
|
||||
return path === '/login';
|
||||
}
|
||||
|
||||
// Verify the current access token by hitting /api/auth/me
|
||||
@@ -329,6 +329,14 @@ async function checkUserPermissions() {
|
||||
const adminDivider = document.getElementById('admin-menu-divider');
|
||||
if (adminItem) adminItem.classList.remove('hidden');
|
||||
if (adminDivider) adminDivider.classList.remove('hidden');
|
||||
const importDesktop = document.getElementById('nav-import-desktop');
|
||||
const importMobile = document.getElementById('nav-import-mobile');
|
||||
if (importDesktop) importDesktop.classList.remove('hidden');
|
||||
if (importMobile) importMobile.classList.remove('hidden');
|
||||
const flexibleDesktop = document.getElementById('nav-flexible-desktop');
|
||||
const flexibleMobile = document.getElementById('nav-flexible-mobile');
|
||||
if (flexibleDesktop) flexibleDesktop.classList.remove('hidden');
|
||||
if (flexibleMobile) flexibleMobile.classList.remove('hidden');
|
||||
}
|
||||
const userDropdownName = document.querySelector('#userDropdown button span');
|
||||
if (user.full_name && userDropdownName) {
|
||||
@@ -555,6 +563,8 @@ function initializeDataTable(tableId, options = {}) {
|
||||
const headers = table.querySelectorAll('th[data-sort]');
|
||||
headers.forEach(header => {
|
||||
header.classList.add('sortable-header');
|
||||
header.classList.add('cursor-pointer');
|
||||
header.classList.add('select-none');
|
||||
header.addEventListener('click', () => sortTable(table, header));
|
||||
});
|
||||
|
||||
@@ -577,10 +587,16 @@ function sortTable(table, header) {
|
||||
// Remove sort classes from all headers
|
||||
table.querySelectorAll('th').forEach(th => {
|
||||
th.classList.remove('sort-asc', 'sort-desc');
|
||||
const indicator = th.querySelector('.sort-indicator');
|
||||
if (indicator) indicator.remove();
|
||||
});
|
||||
|
||||
// Add sort class to current header
|
||||
header.classList.add(isAscending ? 'sort-asc' : 'sort-desc');
|
||||
const indicator = document.createElement('span');
|
||||
indicator.className = 'sort-indicator ml-1 text-neutral-400';
|
||||
indicator.textContent = isAscending ? '▲' : '▼';
|
||||
header.appendChild(indicator);
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const aValue = a.children[columnIndex].textContent.trim();
|
||||
@@ -669,7 +685,7 @@ function initializeSearch(searchInput, resultsContainer, searchFunction) {
|
||||
try {
|
||||
showLoading(resultsContainer, 'Searching...');
|
||||
const results = await searchFunction(query);
|
||||
displaySearchResults(resultsContainer, results);
|
||||
displaySearchResults(resultsContainer, results, query);
|
||||
} catch (error) {
|
||||
resultsContainer.innerHTML = '<p class="text-danger">Search failed</p>';
|
||||
}
|
||||
@@ -677,18 +693,21 @@ function initializeSearch(searchInput, resultsContainer, searchFunction) {
|
||||
});
|
||||
}
|
||||
|
||||
function displaySearchResults(container, results) {
|
||||
function displaySearchResults(container, results, query = '') {
|
||||
if (!results || results.length === 0) {
|
||||
container.innerHTML = '<p class="text-neutral-500">No results found</p>';
|
||||
return;
|
||||
}
|
||||
const tokens = (window.highlightUtils && typeof window.highlightUtils.buildTokens === 'function')
|
||||
? window.highlightUtils.buildTokens(query)
|
||||
: [];
|
||||
|
||||
const resultsHtmlRaw = results.map(result => `
|
||||
<div class="search-result p-2 border-bottom">
|
||||
<div class="flex justify-between">
|
||||
<div>
|
||||
<strong>${result.title}</strong>
|
||||
<small class="text-neutral-500 block">${result.description}</small>
|
||||
<strong>${window.highlightUtils ? window.highlightUtils.highlight(result.title || '', tokens) : (result.title || '')}</strong>
|
||||
<small class="text-neutral-500 block">${window.highlightUtils ? window.highlightUtils.highlight(result.description || '', tokens) : (result.description || '')}</small>
|
||||
</div>
|
||||
<span class="inline-block px-2 py-0.5 text-xs rounded bg-neutral-200 text-neutral-700">${result.type}</span>
|
||||
</div>
|
||||
|
||||
@@ -64,14 +64,24 @@
|
||||
return window.DOMPurify.sanitize(dirty);
|
||||
}
|
||||
// Trigger async load so the next call benefits
|
||||
ensureDOMPurifyLoaded().catch(() => {});
|
||||
try {
|
||||
const loader = (window && window.htmlSanitizer && typeof window.htmlSanitizer.ensureDOMPurifyLoaded === 'function')
|
||||
? window.htmlSanitizer.ensureDOMPurifyLoaded
|
||||
: ensureDOMPurifyLoaded;
|
||||
loader().catch(() => {});
|
||||
} catch (_) {}
|
||||
return fallbackSanitize(dirty);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = String(text == null ? '' : text);
|
||||
return span.innerHTML;
|
||||
// Encode &, <, >, ", and '
|
||||
const str = String(text == null ? '' : text);
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function setSafeHTML(element, html) {
|
||||
|
||||
46
static/js/upload-helper.js
Normal file
46
static/js/upload-helper.js
Normal file
@@ -0,0 +1,46 @@
|
||||
(function() {
|
||||
/**
|
||||
* uploadWithAlerts
|
||||
* Small helper to perform a fetch for uploads and, on failure, show a UI alert with
|
||||
* a correlation reference from the server's error envelope. It uses window.http.toError
|
||||
* and window.http.formatAlert to produce a user-facing message.
|
||||
*
|
||||
* Usage:
|
||||
* const formData = new FormData();
|
||||
* formData.append('file', input.files[0]);
|
||||
* const respJson = await uploadWithAlerts(`/api/documents/upload/${fileNo}`, formData);
|
||||
* // respJson is the parsed JSON on success, otherwise an Error is thrown after alerting
|
||||
*
|
||||
* The alert includes "Ref: <correlation-id>" when available.
|
||||
*/
|
||||
async function uploadWithAlerts(url, formData, { method = 'POST', extraOptions = {}, alertTitle = 'Upload failed' } = {}) {
|
||||
const options = {
|
||||
method,
|
||||
body: formData,
|
||||
...extraOptions,
|
||||
};
|
||||
const response = await window.http.wrappedFetch(url, options);
|
||||
if (!response.ok) {
|
||||
const err = await window.http.toError(response, alertTitle);
|
||||
const msg = window.http.formatAlert(err, alertTitle);
|
||||
if (window.alerts && typeof window.alerts.error === 'function') {
|
||||
window.alerts.error(msg, { html: true, duration: 0 });
|
||||
} else if (window.showNotification) {
|
||||
window.showNotification(msg, 'error', 8000);
|
||||
} else {
|
||||
alert(String(msg));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose globally for pages to use
|
||||
window.uploadWithAlerts = uploadWithAlerts;
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user