Initialer Import des AssetManagers
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
(() => {
|
||||
const collator = new Intl.Collator(document.documentElement.lang || 'de', {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
});
|
||||
|
||||
function controlValue(control) {
|
||||
if (control.tagName === 'SELECT') {
|
||||
const selected = control.options?.[control.selectedIndex];
|
||||
return [
|
||||
control.value || '',
|
||||
selected?.textContent || ''
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
if (control.type === 'checkbox' || control.type === 'radio') {
|
||||
return control.checked
|
||||
? [
|
||||
control.value || '',
|
||||
control.getAttribute('aria-label') || '',
|
||||
control.closest('label')?.textContent || ''
|
||||
].join(' ')
|
||||
: '';
|
||||
}
|
||||
|
||||
return [
|
||||
control.value || '',
|
||||
control.getAttribute('placeholder') || '',
|
||||
control.getAttribute('aria-label') || ''
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function textOutsideControls(cell) {
|
||||
const clone = cell.cloneNode(true);
|
||||
clone.querySelectorAll('input, select, textarea, option').forEach(node => node.remove());
|
||||
return (clone.textContent || '').trim();
|
||||
}
|
||||
|
||||
function textValue(cell) {
|
||||
if (!cell) return '';
|
||||
|
||||
const explicitFilterValue = cell.dataset.filterValue || '';
|
||||
|
||||
if (cell.dataset.sortValue !== undefined) {
|
||||
return `${cell.dataset.sortValue} ${explicitFilterValue}`.trim();
|
||||
}
|
||||
|
||||
const controls = [...cell.querySelectorAll('input, select, textarea')];
|
||||
if (controls.length) {
|
||||
const controlText = controls.map(controlValue).join(' ').trim();
|
||||
const additionalText = textOutsideControls(cell);
|
||||
return `${explicitFilterValue} ${controlText} ${additionalText}`.trim();
|
||||
}
|
||||
|
||||
return `${explicitFilterValue} ${cell.textContent || ''}`.trim();
|
||||
}
|
||||
|
||||
function parsedValue(value) {
|
||||
const text = value.trim();
|
||||
if (!text) return { type: 'empty', value: '' };
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2}(?:[ T].*)?$/.test(text)) {
|
||||
const timestamp = Date.parse(text.replace(' ', 'T'));
|
||||
if (!Number.isNaN(timestamp)) return { type: 'date', value: timestamp };
|
||||
}
|
||||
|
||||
if (/^\d{1,2}\.\d{1,2}\.\d{4}$/.test(text)) {
|
||||
const [day, month, year] = text.split('.').map(Number);
|
||||
return { type: 'date', value: new Date(year, month - 1, day).getTime() };
|
||||
}
|
||||
|
||||
if (/^(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(text)) {
|
||||
return { type: 'ipv4', value: text.split('.').map(Number) };
|
||||
}
|
||||
|
||||
const numeric = text.replace(/\s/g, '').replace(',', '.');
|
||||
if (/^[-+]?\d+(?:\.\d+)?$/.test(numeric)) {
|
||||
return { type: 'number', value: Number(numeric) };
|
||||
}
|
||||
|
||||
return { type: 'text', value: text };
|
||||
}
|
||||
|
||||
function compareValues(a, b) {
|
||||
const left = parsedValue(a);
|
||||
const right = parsedValue(b);
|
||||
|
||||
if (left.type === 'empty' && right.type !== 'empty') return 1;
|
||||
if (right.type === 'empty' && left.type !== 'empty') return -1;
|
||||
|
||||
if (left.type === right.type && (left.type === 'number' || left.type === 'date')) {
|
||||
return left.value - right.value;
|
||||
}
|
||||
|
||||
if (left.type === 'ipv4' && right.type === 'ipv4') {
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const difference = left.value[index] - right.value[index];
|
||||
if (difference) return difference;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
return collator.compare(String(left.value), String(right.value));
|
||||
}
|
||||
|
||||
function initTable(table) {
|
||||
const thead = table.tHead;
|
||||
const tbody = table.tBodies[0];
|
||||
if (!thead || !tbody || !thead.rows.length) return;
|
||||
if (table.dataset.tableToolsReady === '1') return;
|
||||
|
||||
const headerRow = thead.rows[0];
|
||||
|
||||
const isAssetTable = table.id === 'asset-table';
|
||||
const usesServerFilters = table.dataset.serverFilters === '1';
|
||||
let serverFilterTimer = null;
|
||||
|
||||
// Normal tables get stable synthetic keys. The asset table already has
|
||||
// real data-field keys on its movable columns; selection and action
|
||||
// columns must remain without a field key.
|
||||
if (!isAssetTable) {
|
||||
[...headerRow.cells].forEach((header, index) => {
|
||||
if (!header.dataset.field) {
|
||||
header.dataset.field = `__col_${index}`;
|
||||
header.dataset.syntheticField = '1';
|
||||
}
|
||||
});
|
||||
|
||||
[...tbody.rows].forEach(row => {
|
||||
[...row.cells].forEach((cell, index) => {
|
||||
const header = headerRow.cells[index];
|
||||
if (header && !cell.dataset.field) {
|
||||
cell.dataset.field = header.dataset.field;
|
||||
cell.dataset.syntheticField = '1';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const filterRow = document.createElement('tr');
|
||||
filterRow.className = 'column-filter-row';
|
||||
filterRow.dataset.tableFilterRow = '1';
|
||||
|
||||
const filterDefinitions = [];
|
||||
|
||||
function filterStatusTarget() {
|
||||
const wrapper = table.closest(
|
||||
'#asset-table-scroll, .management-table-scroll, .table-scroll, .table-wrap, .sticky-table'
|
||||
);
|
||||
return wrapper || table;
|
||||
}
|
||||
|
||||
function ensureFilterStatus() {
|
||||
let status = table._filterStatusElement;
|
||||
if (status?.isConnected) return status;
|
||||
|
||||
status = document.createElement('div');
|
||||
status.className = 'table-filter-status';
|
||||
status.hidden = true;
|
||||
status.setAttribute('role', 'status');
|
||||
status.setAttribute('aria-live', 'polite');
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'table-filter-status-icon';
|
||||
icon.textContent = '⌕';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const label = document.createElement('strong');
|
||||
label.className = 'table-filter-status-label';
|
||||
|
||||
const details = document.createElement('span');
|
||||
details.className = 'table-filter-status-details';
|
||||
|
||||
const clearButton = document.createElement('button');
|
||||
clearButton.type = 'button';
|
||||
clearButton.className = 'button button-secondary table-filter-clear';
|
||||
clearButton.textContent =
|
||||
document.body.dataset.clearFiltersLabel || 'Filter zurücksetzen';
|
||||
clearButton.addEventListener('click', () => {
|
||||
filterDefinitions.forEach(definition => {
|
||||
definition.input.value = '';
|
||||
});
|
||||
applyFilters();
|
||||
if (usesServerFilters) submitServerFilters(true);
|
||||
|
||||
// Keep the persisted browser state in sync with the visible table.
|
||||
// Programmatic value changes do not trigger input/change automatically.
|
||||
window.dispatchEvent(new CustomEvent('asset-table-filters-cleared', {
|
||||
detail: { table }
|
||||
}));
|
||||
|
||||
filterDefinitions[0]?.input?.focus();
|
||||
});
|
||||
|
||||
status.append(icon, label, details, clearButton);
|
||||
|
||||
const target = filterStatusTarget();
|
||||
target.parentNode?.insertBefore(status, target);
|
||||
table._filterStatusElement = status;
|
||||
return status;
|
||||
}
|
||||
|
||||
function updateFilterPresentation(active, visibleRows, totalRows) {
|
||||
filterDefinitions.forEach(definition => {
|
||||
const hasValue = Boolean(definition.input.value.trim());
|
||||
definition.input.classList.toggle('filter-input-active', hasValue);
|
||||
definition.input.closest('th')?.classList.toggle('filter-cell-active', hasValue);
|
||||
});
|
||||
|
||||
const status = ensureFilterStatus();
|
||||
const label = status.querySelector('.table-filter-status-label');
|
||||
const details = status.querySelector('.table-filter-status-details');
|
||||
const activeCount = active.length;
|
||||
|
||||
status.hidden = activeCount === 0;
|
||||
status.classList.toggle('is-active', activeCount > 0);
|
||||
|
||||
if (activeCount > 0) {
|
||||
const activeTemplate =
|
||||
document.body.dataset.filtersActiveTemplate || '{count} Filter aktiv';
|
||||
const statusTemplate =
|
||||
document.body.dataset.filterStatusTemplate ||
|
||||
'Gefiltert: {visible} von {total} Datensätzen';
|
||||
|
||||
label.textContent = activeTemplate.replace('{count}', String(activeCount));
|
||||
details.textContent = statusTemplate
|
||||
.replace('{visible}', String(visibleRows))
|
||||
.replace('{total}', String(totalRows));
|
||||
}
|
||||
}
|
||||
|
||||
function cellForDefinition(row, definition) {
|
||||
if (definition.field) {
|
||||
return row.querySelector(`td[data-field="${CSS.escape(definition.field)}"]`);
|
||||
}
|
||||
return row.cells[definition.columnIndex] || null;
|
||||
}
|
||||
|
||||
function rebuildMissingCellKeys() {
|
||||
if (isAssetTable) return;
|
||||
|
||||
[...tbody.rows].forEach(row => {
|
||||
[...row.cells].forEach((cell, index) => {
|
||||
const header = headerRow.cells[index];
|
||||
if (header && !cell.dataset.field) {
|
||||
cell.dataset.field = header.dataset.field;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function numericFilterMatches(rawValue, rawTerm) {
|
||||
const value = Number(String(rawValue).trim().replace(',', '.'));
|
||||
if (!Number.isFinite(value)) return false;
|
||||
|
||||
const term = String(rawTerm || '').trim().toLocaleLowerCase();
|
||||
if (!term) return true;
|
||||
|
||||
const normalize = input => input.replace(',', '.').trim();
|
||||
let match = term.match(/^(<=|>=|<|>|=)\s*(-?\d+(?:[.,]\d+)?)$/);
|
||||
if (match) {
|
||||
const expected = Number(normalize(match[2]));
|
||||
if (match[1] === '<') return value < expected;
|
||||
if (match[1] === '<=') return value <= expected;
|
||||
if (match[1] === '>') return value > expected;
|
||||
if (match[1] === '>=') return value >= expected;
|
||||
return value === expected;
|
||||
}
|
||||
|
||||
match = term.match(/^(-?\d+(?:[.,]\d+)?)\s*(?:\.\.|-)\s*(-?\d+(?:[.,]\d+)?)$/);
|
||||
if (!match) {
|
||||
match = term.match(/^(?:zwischen|between)\s+(-?\d+(?:[.,]\d+)?)\s+(?:und|and)\s+(-?\d+(?:[.,]\d+)?)$/);
|
||||
}
|
||||
if (match) {
|
||||
const first = Number(normalize(match[1]));
|
||||
const second = Number(normalize(match[2]));
|
||||
const minimum = Math.min(first, second);
|
||||
const maximum = Math.max(first, second);
|
||||
return value >= minimum && value <= maximum;
|
||||
}
|
||||
|
||||
const exact = Number(normalize(term));
|
||||
return Number.isFinite(exact) ? value === exact : false;
|
||||
}
|
||||
|
||||
|
||||
function submitServerFilters(immediate = false) {
|
||||
if (!usesServerFilters) return;
|
||||
if (serverFilterTimer) window.clearTimeout(serverFilterTimer);
|
||||
|
||||
const navigate = () => {
|
||||
const target = new URL(
|
||||
table.dataset.serverFilterUrl || window.location.pathname,
|
||||
window.location.origin
|
||||
);
|
||||
const current = new URL(window.location.href);
|
||||
const limit = current.searchParams.get('job_limit');
|
||||
if (limit) target.searchParams.set('job_limit', limit);
|
||||
|
||||
filterDefinitions.forEach(definition => {
|
||||
if (!definition.serverName) return;
|
||||
const value = definition.input.value.trim();
|
||||
if (value) target.searchParams.set(`job_filter_${definition.serverName}`, value);
|
||||
});
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
'assetmanager:software-jobs:last-server-filter',
|
||||
document.activeElement?.name || ''
|
||||
);
|
||||
} catch (_) {}
|
||||
window.location.assign(target.toString());
|
||||
};
|
||||
|
||||
if (immediate) navigate();
|
||||
else serverFilterTimer = window.setTimeout(navigate, 700);
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
rebuildMissingCellKeys();
|
||||
|
||||
const active = filterDefinitions
|
||||
.map(definition => ({
|
||||
definition,
|
||||
term: definition.input?.value.trim().toLocaleLowerCase() || ''
|
||||
}))
|
||||
.filter(item => item.term);
|
||||
|
||||
if (usesServerFilters) {
|
||||
const serverTotal = Number.parseInt(table.dataset.serverFilterTotal || '', 10);
|
||||
const total = Number.isFinite(serverTotal) ? serverTotal : tbody.rows.length;
|
||||
updateFilterPresentation(active, tbody.rows.length, total);
|
||||
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {
|
||||
detail: {
|
||||
table,
|
||||
visible: tbody.rows.length,
|
||||
total,
|
||||
activeFilters: active.length
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
let visibleRows = 0;
|
||||
|
||||
[...tbody.rows].forEach(row => {
|
||||
const hidden = active.some(({ definition, term }) => {
|
||||
const cell = cellForDefinition(row, definition);
|
||||
const value = textValue(cell);
|
||||
if (definition.numericFilter) {
|
||||
return !numericFilterMatches(value, term);
|
||||
}
|
||||
return !value.toLocaleLowerCase().includes(term);
|
||||
});
|
||||
|
||||
row.hidden = hidden;
|
||||
if (!hidden) visibleRows += 1;
|
||||
});
|
||||
|
||||
updateFilterPresentation(active, visibleRows, tbody.rows.length);
|
||||
|
||||
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {
|
||||
detail: {
|
||||
table,
|
||||
visible: visibleRows,
|
||||
total: tbody.rows.length,
|
||||
activeFilters: active.length
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
[...headerRow.cells].forEach((header, columnIndex) => {
|
||||
const field = header.dataset.field || '';
|
||||
const serverName = header.dataset.serverFilterName || '';
|
||||
|
||||
if (!header.dataset.noSort) {
|
||||
header.classList.add('sortable-column');
|
||||
header.title = header.dataset.sortTitle || 'Zum Sortieren anklicken';
|
||||
|
||||
header.addEventListener('click', event => {
|
||||
if (event.target.closest('input,button,a,select,textarea,label')) return;
|
||||
|
||||
const direction = header.dataset.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
|
||||
[...headerRow.cells].forEach(item => {
|
||||
delete item.dataset.sortDirection;
|
||||
item.classList.remove('sort-asc', 'sort-desc');
|
||||
});
|
||||
|
||||
header.dataset.sortDirection = direction;
|
||||
header.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
|
||||
const rows = [...tbody.rows];
|
||||
rows.sort((left, right) => {
|
||||
const definition = { field, columnIndex };
|
||||
const result = compareValues(
|
||||
textValue(cellForDefinition(left, definition)),
|
||||
textValue(cellForDefinition(right, definition))
|
||||
);
|
||||
return direction === 'asc' ? result : -result;
|
||||
});
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
rows.forEach(row => fragment.appendChild(row));
|
||||
tbody.appendChild(fragment);
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
const filterCell = document.createElement('th');
|
||||
if (field) filterCell.dataset.field = field;
|
||||
filterCell.dataset.columnIndex = String(columnIndex);
|
||||
|
||||
if (!header.dataset.noFilter && (!usesServerFilters || serverName)) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'search';
|
||||
input.placeholder = header.dataset.filterPlaceholder || 'Filtern …';
|
||||
input.setAttribute('aria-label', `Filter ${textValue(header)}`);
|
||||
input.autocomplete = 'off';
|
||||
if (usesServerFilters) {
|
||||
input.name = `job_filter_${serverName}`;
|
||||
input.value = header.dataset.serverFilterValue || '';
|
||||
input.addEventListener('input', () => {
|
||||
applyFilters();
|
||||
submitServerFilters(false);
|
||||
});
|
||||
input.addEventListener('search', () => {
|
||||
applyFilters();
|
||||
submitServerFilters(false);
|
||||
});
|
||||
input.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
submitServerFilters(true);
|
||||
});
|
||||
} else {
|
||||
input.addEventListener('input', applyFilters);
|
||||
input.addEventListener('search', applyFilters);
|
||||
}
|
||||
|
||||
filterCell.appendChild(input);
|
||||
filterDefinitions.push({
|
||||
field,
|
||||
columnIndex,
|
||||
input,
|
||||
serverName,
|
||||
numericFilter: header.dataset.numericFilter === '1'
|
||||
});
|
||||
}
|
||||
|
||||
filterRow.appendChild(filterCell);
|
||||
});
|
||||
|
||||
thead.appendChild(filterRow);
|
||||
|
||||
// Editable cells are always read live, so changed values immediately
|
||||
// participate in filtering without a stale cache.
|
||||
tbody.addEventListener('input', event => {
|
||||
if (event.target.matches('input,select,textarea')) applyFilters();
|
||||
});
|
||||
|
||||
tbody.addEventListener('change', event => {
|
||||
if (event.target.matches('input,select,textarea')) applyFilters();
|
||||
});
|
||||
|
||||
table.assetApplyFilters = applyFilters;
|
||||
table.assetRebuildFilterCache = rebuildMissingCellKeys;
|
||||
table.dataset.tableToolsReady = '1';
|
||||
ensureFilterStatus();
|
||||
|
||||
// Covers browser-restored values after reload/back navigation.
|
||||
requestAnimationFrame(() => {
|
||||
applyFilters();
|
||||
if (usesServerFilters) {
|
||||
try {
|
||||
const lastName = window.sessionStorage.getItem('assetmanager:software-jobs:last-server-filter');
|
||||
const lastInput = lastName
|
||||
? filterDefinitions.find(definition => definition.input.name === lastName)?.input
|
||||
: null;
|
||||
if (lastInput) {
|
||||
lastInput.focus();
|
||||
lastInput.setSelectionRange(lastInput.value.length, lastInput.value.length);
|
||||
}
|
||||
window.sessionStorage.removeItem('assetmanager:software-jobs:last-server-filter');
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('asset-table-structure-ready', {
|
||||
detail: { table }
|
||||
}));
|
||||
}
|
||||
|
||||
document.querySelectorAll('table.data-table').forEach(initTable);
|
||||
|
||||
window.addEventListener('asset-table-content-reloaded', event => {
|
||||
const table = event.detail?.table;
|
||||
if (table?.assetRebuildFilterCache) {
|
||||
table.assetRebuildFilterCache();
|
||||
table.assetApplyFilters?.();
|
||||
}
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user