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 `${label}`; } return ``; }).join(''); return gridjsLib.html(`
${buttons}
`); } }; 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(``) : ''; 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 }; }