This commit is contained in:
2026-02-04 23:31:53 +01:00
commit cd59ccd99b
2401 changed files with 56808 additions and 0 deletions

View File

@@ -0,0 +1,604 @@
export function createServerGrid(options) {
const {
container,
dataUrl,
appBase = '/',
columns,
mapData,
total = (data) => data.total,
sortColumns = [],
paginationLimit = 10,
language = {},
search = null,
filters = [],
urlSync = true,
gridjs,
rowDblClick = null,
actions = null,
rowDataset = null,
selection = null
} = options || {};
const gridjsLib = gridjs || window.gridjs;
const containerEl = typeof container === 'string' ? document.querySelector(container) : container;
if (!containerEl || !gridjsLib) {
return null;
}
const params = new URLSearchParams(window.location.search);
const resolveUrl = (path) => new URL(path, appBase).toString();
const addParam = (url, key, value) => {
const nextUrl = new URL(url);
nextUrl.searchParams.set(key, value);
return nextUrl.toString();
};
const getInput = (input) => {
if (!input) return null;
return typeof input === 'string' ? document.querySelector(input) : input;
};
const getInputValue = (input) => {
if (!input) return '';
if (input.type === 'checkbox') {
return input.checked ? (input.value || '1') : '';
}
if (input.tagName === 'SELECT' && input.multiple) {
return Array.from(input.selectedOptions || []).map((option) => option.value).filter(Boolean);
}
return (input.value ?? '').toString();
};
const setInputValue = (input, value) => {
if (!input) return;
if (input.type === 'checkbox') {
input.checked = value === true || value === 'true' || value === '1';
} else if (input.tagName === 'SELECT' && input.multiple) {
const values = Array.isArray(value)
? value.map((item) => item.toString())
: value
? value.toString().split(',').map((item) => item.trim()).filter(Boolean)
: [];
Array.from(input.options || []).forEach((option) => {
option.selected = values.includes(option.value);
});
} else {
input.value = value;
}
if (input._multiSelectInstance && typeof input._multiSelectInstance.setValues === 'function') {
const values = Array.isArray(value)
? value.map((item) => item.toString())
: value
? value.toString().split(',').map((item) => item.trim()).filter(Boolean)
: [];
input._multiSelectInstance.setValues(values);
}
};
let searchValue = '';
let searchParam = 'search';
let searchInput = null;
let searchDebounce = 250;
if (search) {
searchInput = getInput(search.input);
searchParam = search.param || searchParam;
searchDebounce = search.debounce ?? searchDebounce;
const initial = params.get(searchParam) ?? search.default ?? '';
searchValue = initial || '';
if (searchInput) {
setInputValue(searchInput, initial);
}
}
const filterState = filters.map((filter) => {
const input = getInput(filter.input);
const initial = params.get(filter.param);
const value = initial !== null ? initial : (filter.default ?? '');
if (input) {
const parsed = typeof filter.parse === 'function' ? filter.parse(value) : value;
setInputValue(input, parsed);
}
return { ...filter, input, value };
});
const normalizeValue = (filter, value) => {
if (typeof filter.normalize === 'function') {
return filter.normalize(value);
}
return value;
};
const buildParams = () => {
const query = new URLSearchParams();
if (searchValue) {
query.set(searchParam, searchValue);
}
filterState.forEach((filter) => {
const raw = getInputValue(filter.input) || filter.value || '';
const normalized = normalizeValue(filter, raw);
if (Array.isArray(normalized)) {
const list = normalized.map((item) => item.toString()).filter(Boolean);
if (list.length) {
query.set(filter.param, list.join(','));
}
return;
}
if (normalized !== '' && normalized !== null && typeof normalized !== 'undefined') {
query.set(filter.param, normalized);
}
});
return query;
};
const baseUrl = () => {
const url = new URL(dataUrl, appBase);
const query = buildParams();
query.forEach((value, key) => url.searchParams.set(key, value));
return url.toString();
};
const updateUrl = () => {
if (!urlSync) return;
const url = new URL(window.location.href);
if (searchParam) {
url.searchParams.delete(searchParam);
}
filterState.forEach((filter) => url.searchParams.delete(filter.param));
const query = buildParams();
query.forEach((value, key) => url.searchParams.set(key, value));
window.history.replaceState({}, '', url.toString());
};
const actionConfig = actions && actions.enabled ? {
label: actions.label || 'Actions',
index: Number.isInteger(actions.index) ? actions.index : null,
items: Array.isArray(actions.items) ? actions.items : [],
getRowId: typeof actions.getRowId === 'function' ? actions.getRowId : null,
wrapperClass: actions.wrapperClass || 'grid-actions',
onAction: typeof actions.onAction === 'function' ? actions.onAction : null
} : null;
const selectionConfig = selection && selection.enabled ? {
id: selection.id || 'selectRow',
index: Number.isInteger(selection.index) ? selection.index : 0,
component: selection.component || null,
props: selection.props || null,
onChange: typeof selection.onChange === 'function' ? selection.onChange : null,
getSelectedIds: typeof selection.getSelectedIds === 'function' ? selection.getSelectedIds : null,
selectAllLabel: selection.selectAllLabel || 'Select all',
selectAllEnabled: selection.selectAllEnabled !== false
} : null;
const insertActionColumn = (cols) => {
if (!actionConfig) return cols;
const actionColumn = {
name: actionConfig.label,
sort: false,
formatter: (_cell, row) => {
const id = actionConfig.getRowId ? actionConfig.getRowId(row) : null;
if (!id) {
return gridjsLib.html('');
}
const buttons = actionConfig.items.map((item) => {
if (!item || !item.key) return '';
const label = typeof item.label === 'function' ? item.label(row, id) : item.label;
if (!label) return '';
const className = item.className ? ` ${item.className}` : '';
const type = item.type || 'button';
if (type === 'link') {
const href = typeof item.href === 'function' ? item.href({ row, id }) : item.href;
const url = href || '#';
return `<a role="button" class="${className.trim()}" data-grid-action="${item.key}" href="${url}">${label}</a>`;
}
return `<button type="button" class="${className.trim()}" data-grid-action="${item.key}">${label}</button>`;
}).join('');
return gridjsLib.html(`<div class="${actionConfig.wrapperClass}" role="group">${buttons}</div>`);
}
};
const next = [...cols];
const index = actionConfig.index === null ? next.length : Math.min(Math.max(actionConfig.index, 0), next.length);
next.splice(index, 0, actionColumn);
return next;
};
const insertSelectionColumn = (cols) => {
if (!selectionConfig) return cols;
const headerLabel = selectionConfig.selectAllLabel || 'Select all';
const header = selectionConfig.selectAllEnabled
? gridjsLib.html(`<input type="checkbox" data-grid-select-all aria-label="${headerLabel}">`)
: '';
const selectionColumn = {
id: selectionConfig.id,
name: header,
sort: false,
plugin: {
id: selectionConfig.id
}
};
const next = [...cols];
const index = Math.min(Math.max(selectionConfig.index ?? 0, 0), next.length);
next.splice(index, 0, selectionColumn);
return next;
};
const insertActionSort = (cols) => {
if (!actionConfig || !cols.length) return cols;
const next = [...cols];
const index = actionConfig.index === null ? next.length : Math.min(Math.max(actionConfig.index, 0), next.length);
next.splice(index, 0, null);
return next;
};
const insertSelectionSort = (cols) => {
if (!selectionConfig || !cols.length) return cols;
const next = [...cols];
const index = Math.min(Math.max(selectionConfig.index ?? 0, 0), next.length);
next.splice(index, 0, null);
return next;
};
const mapDataWithActions = (data) => {
const rows = mapData ? mapData(data) : [];
if (!actionConfig || !Array.isArray(rows)) {
return rows;
}
const index = actionConfig.index === null ? null : Math.min(Math.max(actionConfig.index, 0), Infinity);
return rows.map((row) => {
if (!Array.isArray(row)) return row;
const next = [...row];
const insertAt = index === null ? next.length : Math.min(index, next.length);
next.splice(insertAt, 0, null);
return next;
});
};
const serverConfig = {
url: baseUrl(),
then: mapDataWithActions,
total
};
let finalSortColumns = sortColumns;
if (selectionConfig) {
finalSortColumns = insertSelectionSort(finalSortColumns);
}
if (actionConfig) {
finalSortColumns = insertActionSort(finalSortColumns);
}
let currentSort = null;
const sortConfig = finalSortColumns.length ? {
enabled: true,
server: {
url: (prev, columns) => {
if (!columns.length) {
currentSort = null;
return prev;
}
const column = columns[0];
const key = finalSortColumns[column.index];
if (!key) {
currentSort = null;
return prev;
}
const direction = column.direction === 1 ? 'asc' : 'desc';
currentSort = { order: key, dir: direction };
return addParam(addParam(prev, 'order', key), 'dir', direction);
}
}
} : { enabled: false };
const paginationConfig = {
limit: paginationLimit,
server: {
url: (prev, page, limit) => {
const offset = page * limit;
return addParam(addParam(prev, 'limit', limit), 'offset', offset);
}
}
};
const plugins = [];
if (selectionConfig?.component) {
plugins.push({
id: selectionConfig.id,
component: selectionConfig.component,
props: selectionConfig.props || {}
});
}
const grid = new gridjsLib.Grid({
columns: insertActionColumn(insertSelectionColumn(columns)),
server: serverConfig,
search: false,
sort: sortConfig,
pagination: paginationConfig,
language,
plugins
});
grid.render(containerEl);
setTimeout(() => {
initSelectAll();
}, 0);
// Prevent empty-state flash before data arrives.
let hasLoadedOnce = false;
const markLoadedOnce = () => {
if (hasLoadedOnce) return;
hasLoadedOnce = true;
containerEl.classList.add('gridjs-has-loaded');
};
const setUpdating = () => containerEl.classList.add('gridjs-is-updating');
const clearUpdating = () => containerEl.classList.remove('gridjs-is-updating');
const store = grid?.config?.store;
if (store?.subscribe) {
store.subscribe((state) => {
if (!state) return;
// Grid.js status: 0=Init, 1=Loading, 2=Loaded, 3=Rendered, 4=Error
if (state.status === 1) {
setUpdating();
} else {
clearUpdating();
}
if (state.status >= 2) {
markLoadedOnce();
}
if (state.status >= 3) {
initSelectAll();
}
});
}
const applyRowDataset = () => {
if (typeof rowDataset !== 'function') return;
const rowEls = containerEl.querySelectorAll('.gridjs-tbody .gridjs-tr');
const dataRows = grid.config.store.getState().data?.rows || [];
rowEls.forEach((rowEl, index) => {
const rowData = dataRows[index] || null;
if (!rowData) return;
const attrs = rowDataset(rowData, index) || {};
Object.keys(attrs).forEach((key) => {
const value = attrs[key];
const attr = `data-${key}`;
if (value === null || typeof value === 'undefined' || value === '') {
rowEl.removeAttribute(attr);
} else {
rowEl.setAttribute(attr, String(value));
}
});
});
};
const initSelectAll = () => {
if (!selectionConfig?.component || !selectionConfig.selectAllEnabled) return;
const header = containerEl.querySelector(`th[data-column-id="${selectionConfig.id}"] input[data-grid-select-all]`);
if (!header) return;
if (header.dataset.bound === '1') {
return;
}
header.dataset.bound = '1';
const getRowCheckboxes = () => Array.from(
containerEl.querySelectorAll(`td[data-column-id="${selectionConfig.id}"] input[type="checkbox"]`)
);
const updateHeaderState = () => {
const boxes = getRowCheckboxes();
const total = boxes.length;
const checked = boxes.filter((box) => box.checked).length;
header.indeterminate = total > 0 && checked > 0 && checked < total;
header.checked = total > 0 && checked === total;
header.disabled = total === 0;
};
header.addEventListener('change', () => {
const boxes = getRowCheckboxes();
boxes.forEach((box) => {
if (box.checked !== header.checked) {
box.click();
}
});
updateHeaderState();
});
updateHeaderState();
};
if (typeof rowDataset === 'function') {
const observer = new MutationObserver(() => applyRowDataset());
observer.observe(containerEl, { childList: true, subtree: true });
applyRowDataset();
}
const getRowDataByElement = (rowEl) => {
if (!rowEl) return null;
const rows = Array.from(rowEl.parentElement?.children || []);
const rowIndex = rows.indexOf(rowEl);
if (rowIndex < 0) return null;
const dataRows = grid.config.store.getState().data?.rows || [];
const rowData = dataRows[rowIndex] || null;
if (!rowData) return null;
return { rowData, rowIndex };
};
if (rowDblClick) {
const excludeSelector = rowDblClick.exclude || '.grid-actions, button, a, input, select, textarea';
containerEl.addEventListener('dblclick', (event) => {
if (excludeSelector && event.target.closest(excludeSelector)) {
return;
}
const rowEl = event.target.closest('.gridjs-tr');
const rowInfo = getRowDataByElement(rowEl);
if (!rowInfo) {
return;
}
const { rowData, rowIndex } = rowInfo;
const getUrl = rowDblClick.getUrl;
if (typeof getUrl !== 'function') {
return;
}
const url = getUrl(rowData, rowIndex);
if (url) {
window.location.href = url;
}
});
}
if (actionConfig) {
containerEl.addEventListener('click', async (event) => {
const actionEl = event.target.closest('[data-grid-action]');
if (!actionEl) {
return;
}
const rowEl = actionEl.closest('.gridjs-tr');
const rowInfo = getRowDataByElement(rowEl);
if (!rowInfo) {
return;
}
const { rowData } = rowInfo;
const actionKey = actionEl.getAttribute('data-grid-action') || '';
const actionItem = actionConfig.items.find((item) => item && item.key === actionKey);
if (!actionItem) {
return;
}
const id = actionConfig.getRowId ? actionConfig.getRowId(rowData) : null;
if (!id) {
return;
}
const confirmText = typeof actionItem.confirm === 'function'
? actionItem.confirm({ row: rowData, id })
: actionItem.confirm;
if (confirmText && !window.confirm(confirmText)) {
return;
}
if (actionItem.type === 'link') {
const href = typeof actionItem.href === 'function'
? actionItem.href({ row: rowData, id })
: actionItem.href;
if (href) {
event.preventDefault();
window.location.href = href;
}
return;
}
if (actionConfig.onAction) {
event.preventDefault();
await actionConfig.onAction({ action: actionKey, id, row: rowData, event, grid, container: containerEl });
}
});
}
const updateGrid = () => {
updateUrl();
grid.updateConfig({
server: { ...serverConfig, url: baseUrl() }
}).forceRender();
};
const debounce = (fn, delay = 250) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
};
if (searchInput) {
searchInput.addEventListener('input', debounce((event) => {
searchValue = event.target.value.trim();
updateGrid();
}, searchDebounce));
}
filterState.forEach((filter) => {
if (!filter.input) return;
const eventName = filter.event || 'change';
filter.input.addEventListener(eventName, () => {
filter.value = getInputValue(filter.input);
updateGrid();
});
});
const resetFilters = () => {
if (search) {
const defaultValue = search.default ?? '';
searchValue = defaultValue;
if (searchInput) {
setInputValue(searchInput, defaultValue);
}
}
filterState.forEach((filter) => {
const defaultValue = filter.default ?? '';
filter.value = defaultValue;
if (filter.input) {
setInputValue(filter.input, defaultValue);
}
});
updateGrid();
};
const getSortParams = () => (currentSort ? { ...currentSort } : null);
const selectionApi = selectionConfig && selectionConfig.component ? {
getSelectedIds: () => {
if (selectionConfig.getSelectedIds) {
return selectionConfig.getSelectedIds({ grid, container: containerEl });
}
const plugin = grid?.config?.plugin?.get?.(selectionConfig.id);
const store = plugin?.props?.store;
const state = store?.state ?? store?.getState?.();
if (!state) return [];
if (Array.isArray(state.selected)) return state.selected;
if (Array.isArray(state.ids)) return state.ids;
if (state.rows && typeof state.rows === 'object') {
return Object.keys(state.rows).filter((key) => state.rows[key]);
}
if (state instanceof Set) return Array.from(state);
if (Array.isArray(state)) return state;
if (typeof state === 'object') {
return Object.keys(state).filter((key) => state[key]);
}
return [];
},
clear: () => {
const plugin = grid?.config?.plugin?.get?.(selectionConfig.id);
const store = plugin?.props?.store;
if (store?.dispatch) {
store.dispatch('reset');
} else if (store?.reset) {
store.reset();
}
}
} : null;
if (selectionConfig?.onChange && selectionConfig.component) {
grid.on('ready', () => {
const plugin = grid?.config?.plugin?.get?.(selectionConfig.id);
const store = plugin?.props?.store;
const sync = () => {
selectionConfig.onChange(selectionApi?.getSelectedIds?.() || []);
};
if (store?.on) store.on('updated', sync);
if (store?.subscribe) store.subscribe(sync);
sync();
});
}
grid.on('ready', () => {
initSelectAll();
setTimeout(() => {
if (typeof window.requestFsLightboxRefresh === 'function') {
window.requestFsLightboxRefresh();
return;
}
if (typeof window.refreshFsLightbox === 'function') {
window.refreshFsLightbox();
} else {
window.__fsLightboxRefreshPending = true;
}
}, 0);
});
return { grid, container: containerEl, updateGrid, updateUrl, baseUrl, resolveUrl, getSortParams, resetFilters, selection: selectionApi };
}