import { warnOnce } from './app-dom.js'; import { confirmDialog } from './app-confirm-dialog.js'; import { requestFsLightboxRefresh } from './app-fslightbox-bridge.js'; import { createUiStorage } from './app-ui-storage.js'; import { gridFiltersFromSchema, withCurrentListReturn } from '../pages/app-list-utils.js'; import { initListFilterExperience } from '../pages/app-list-filter-experience.js'; import { buildChipsFromMeta, removeChipFromMetaState, clearMetaState } from '../pages/app-list-filter-state.js'; const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; const PAGE_SIZE_STORAGE_PREFIX = 'grid:page-size'; const parsePositiveInt = (value) => { const parsed = Number.parseInt(String(value ?? ''), 10); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return parsed; }; const normalizePageSizeOptions = (options) => { const source = Array.isArray(options) && options.length ? options : DEFAULT_PAGE_SIZE_OPTIONS; const normalized = []; source.forEach((option) => { const parsed = parsePositiveInt(option); if (parsed !== null && !normalized.includes(parsed)) { normalized.push(parsed); } }); return normalized.length ? normalized : [...DEFAULT_PAGE_SIZE_OPTIONS]; }; const escapeHtmlAttr = (value) => String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"'); const normalizeColumnIdPart = (value) => String(value ?? '') .trim() .toLowerCase() .replaceAll(/[^a-z0-9]+/g, '_') .replaceAll(/^_+|_+$/g, ''); const uniqueColumnId = (candidate, usedIds, fallbackPrefix = 'col') => { const base = normalizeColumnIdPart(candidate) || fallbackPrefix; let next = base; let counter = 2; while (usedIds.has(next)) { next = `${base}_${counter}`; counter += 1; } usedIds.add(next); return next; }; const DEFAULT_MIN_WIDTH = '100px'; const normalizeGridColumns = (columns, sortColumns) => { const source = Array.isArray(columns) ? columns : []; const sortKeys = Array.isArray(sortColumns) ? sortColumns : []; const usedIds = new Set(); return source.map((column, index) => { const baseColumn = (column && typeof column === 'object' && !Array.isArray(column)) ? column : { name: String(column ?? '') }; const explicitId = typeof baseColumn.id === 'string' ? baseColumn.id : ''; const sortKey = typeof sortKeys[index] === 'string' ? sortKeys[index] : ''; const label = typeof baseColumn.name === 'string' ? baseColumn.name : ''; const fallback = `col_${index + 1}`; const id = uniqueColumnId(explicitId || sortKey || label || fallback, usedIds, fallback); const skipMinWidth = baseColumn.hidden || baseColumn.plugin || baseColumn.minWidth; const minWidth = skipMinWidth ? baseColumn.minWidth : DEFAULT_MIN_WIDTH; return { ...baseColumn, id, ...(minWidth ? { minWidth } : {}) }; }); }; export function createServerGrid(options) { const { container, dataUrl, appBase = '/', columns, mapData, total = (data) => data.total, sortColumns = [], paginationLimit = 10, language = {}, search = null, filters = [], filterBindingMode = 'live', urlSync = true, urlState = null, gridjs, rowDblClick = null, actions = null, rowInteraction = null, rowDataset = null, selection = null, pageSize = null } = options || {}; const normalizedFilterBindingMode = String(filterBindingMode || 'live').trim().toLowerCase() === 'manual' ? 'manual' : 'live'; const gridjsLib = gridjs || window.gridjs; const containerEl = typeof container === 'string' ? document.querySelector(container) : container; if (!containerEl || !gridjsLib) { if (!containerEl) { warnOnce('UI_EL_MISSING', `Missing grid container: ${container}`, { module: 'grid-factory' }); } if (!gridjsLib) { warnOnce('UI_LIB_MISSING', 'Grid.js not available', { module: 'grid-factory' }); } return null; } const normalizedColumns = normalizeGridColumns(columns, sortColumns); const params = new URLSearchParams(window.location.search); const urlStateConfig = { pageParam: 'page', sortOrderParam: 'order', sortDirParam: 'dir', limitParam: 'limit', cleanFirstPage: true, cleanDefaultLimit: true, syncPage: true, syncSort: true, syncLimit: true, ...(urlState || {}) }; const pageParam = urlStateConfig.pageParam || 'page'; const sortOrderParam = urlStateConfig.sortOrderParam || 'order'; const sortDirParam = urlStateConfig.sortDirParam || 'dir'; const limitParam = urlStateConfig.limitParam || 'limit'; 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 deleteParam = (url, key) => { const nextUrl = new URL(url); nextUrl.searchParams.delete(key); return nextUrl.toString(); }; const clearSortParams = (url) => deleteParam(deleteParam(url, sortOrderParam), sortDirParam); const parsePage = (rawValue) => { const parsed = Number.parseInt(String(rawValue ?? ''), 10); if (!Number.isFinite(parsed) || parsed < 1) { return 1; } return parsed; }; const validSortKeys = new Set( (sortColumns || []) .filter((key) => typeof key === 'string') .map((key) => key.trim()) .filter(Boolean) ); const normalizeDir = (rawValue) => { const normalized = String(rawValue ?? '').trim().toLowerCase(); return normalized === 'asc' || normalized === 'desc' ? normalized : ''; }; const pageSizeConfig = pageSize && typeof pageSize === 'object' ? pageSize : {}; const pageSizeEnabled = pageSizeConfig.enabled !== false; const pageSizeOptions = normalizePageSizeOptions(pageSizeConfig.options); const defaultLimit = pageSizeEnabled ? ( pageSizeOptions.includes(parsePositiveInt(paginationLimit)) ? Number(parsePositiveInt(paginationLimit)) : pageSizeOptions[0] ) : (parsePositiveInt(paginationLimit) ?? 10); const syncLimit = pageSizeEnabled && urlStateConfig.syncLimit !== false; const cleanDefaultLimit = urlStateConfig.cleanDefaultLimit !== false; const dataUrlPath = (() => { try { return new URL(dataUrl, appBase).pathname || String(dataUrl || ''); } catch { return String(dataUrl || ''); } })(); const containerKey = typeof container === 'string' ? container : (containerEl.id ? `#${containerEl.id}` : 'grid'); const autoPageSizeStorageKey = [ PAGE_SIZE_STORAGE_PREFIX, encodeURIComponent(window.location.pathname || ''), encodeURIComponent(dataUrlPath || ''), encodeURIComponent(containerKey || '') ].join(':'); const pageSizeStorageKey = pageSizeEnabled ? (String(pageSizeConfig.storageKey || autoPageSizeStorageKey).trim() || autoPageSizeStorageKey) : ''; const pageSizeStorage = createUiStorage({ namespace: pageSizeConfig.storageNamespace || 'app-ui', version: pageSizeConfig.storageVersion || 'v1', scope: pageSizeConfig.storageScope || 'grid-page-size', }); const scopedPageSizeStorageKey = pageSizeEnabled ? pageSizeStorage.buildKey('state', pageSizeStorageKey) : ''; const parseAllowedLimit = (value) => { if (!pageSizeEnabled) { return parsePositiveInt(value); } const parsed = parsePositiveInt(value); if (parsed === null) { return null; } return pageSizeOptions.includes(parsed) ? parsed : null; }; const pageSizeLabel = String( pageSizeConfig.label || language?.pagination?.rowsPerPage || 'Rows per page' ).trim() || 'Rows per page'; const pageSizeCompactLabel = String( pageSizeConfig.compactLabel || language?.pagination?.rowsPerPageShort || 'Per page' ).trim() || 'Per page'; let currentLimit = defaultLimit; const urlLimit = syncLimit ? parseAllowedLimit(params.get(limitParam)) : null; if (urlLimit !== null) { currentLimit = urlLimit; } else if (pageSizeEnabled) { const storedLimit = parseAllowedLimit( scopedPageSizeStorageKey ? pageSizeStorage.getItem(scopedPageSizeStorageKey) : null ); if (storedLimit !== null) { currentLimit = storedLimit; } } if (scopedPageSizeStorageKey) { pageSizeStorage.setItem(scopedPageSizeStorageKey, String(currentLimit)); } let currentPage = urlStateConfig.syncPage ? parsePage(params.get(pageParam)) : 1; let currentSort = null; if (urlStateConfig.syncSort) { const initialOrder = String(params.get(sortOrderParam) ?? '').trim(); const initialDir = normalizeDir(params.get(sortDirParam)); if (initialOrder !== '' && initialDir !== '' && validSortKeys.has(initialOrder)) { currentSort = { order: initialOrder, dir: initialDir }; } } const rowInteractionConfig = rowInteraction && typeof rowInteraction === 'object' ? rowInteraction : {}; const hasRowOpenHandler = Boolean(rowDblClick && typeof rowDblClick.getUrl === 'function'); const rowInteractionEnabled = hasRowOpenHandler && rowInteractionConfig.enabled !== false; const keepDblClick = rowInteractionEnabled && rowInteractionConfig.keepDblClick !== false; const enableEnterOnRow = rowInteractionEnabled && rowInteractionConfig.enableEnterOnRow !== false; const linkColumnIndex = rowInteractionEnabled && rowInteractionConfig.linkColumn !== false ? (Number.isInteger(rowInteractionConfig.linkColumn) ? rowInteractionConfig.linkColumn : 0) : -1; const rowEnterHint = String( rowInteractionConfig.rowEnterHint || language?.actions?.rowEnterHint || 'Press Enter to open row' ).trim() || 'Press Enter to open row'; const gridRetryLabel = String(language?.errorRetry || 'Retry').trim() || 'Retry'; const resolveRowOpenUrl = (rowData, rowIndex = -1) => { if (!rowInteractionEnabled) { return ''; } const url = rowDblClick.getUrl(rowData, rowIndex); const normalized = String(url || '').trim(); if (normalized === '') { return ''; } return withCurrentListReturn(normalized); }; 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 resolveFilterRawValue = (filter) => { if (!filter) { return ''; } if (normalizedFilterBindingMode === 'manual') { return filter.value ?? ''; } if (!filter.input) { return filter.value ?? ''; } return getInputValue(filter.input); }; const buildDataParams = () => { const query = new URLSearchParams(); if (searchValue) { query.set(searchParam, searchValue); } filterState.forEach((filter) => { const raw = resolveFilterRawValue(filter); 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); } }); if (currentSort?.order && currentSort?.dir) { query.set(sortOrderParam, currentSort.order); query.set(sortDirParam, currentSort.dir); } return query; }; const buildUrlParams = () => { const query = buildDataParams(); if (!urlStateConfig.syncSort) { query.delete(sortOrderParam); query.delete(sortDirParam); } if (urlStateConfig.syncPage) { if (urlStateConfig.cleanFirstPage && currentPage <= 1) { query.delete(pageParam); } else { query.set(pageParam, String(currentPage)); } } else { query.delete(pageParam); } if (syncLimit) { if (!cleanDefaultLimit || currentLimit !== defaultLimit) { query.set(limitParam, String(currentLimit)); } else { query.delete(limitParam); } } else { query.delete(limitParam); } return query; }; const baseUrl = () => { const url = new URL(dataUrl, appBase); const query = buildDataParams(); query.forEach((value, key) => url.searchParams.set(key, value)); return url.toString(); }; const updateUrl = (options = {}) => { if (!urlSync) {return;} const historyMode = String(options?.historyMode || 'replace').trim().toLowerCase() === 'push' ? 'push' : 'replace'; const url = new URL(window.location.href); if (searchParam) { url.searchParams.delete(searchParam); } filterState.forEach((filter) => url.searchParams.delete(filter.param)); url.searchParams.delete(sortOrderParam); url.searchParams.delete(sortDirParam); url.searchParams.delete(pageParam); url.searchParams.delete(limitParam); const query = buildUrlParams(); query.forEach((value, key) => url.searchParams.set(key, value)); if (historyMode === 'push') { window.history.pushState({}, '', url.toString()); return; } window.history.replaceState({}, '', url.toString()); }; const explicitActionConfig = actions && actions.enabled ? { id: String(actions.id || 'row_actions').trim() || 'row_actions', 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 actionConfig = explicitActionConfig || null; // Link-column: wrap the target column's formatter output in an for row navigation. if (linkColumnIndex >= 0 && linkColumnIndex < normalizedColumns.length) { const targetCol = normalizedColumns[linkColumnIndex]; const originalFormatter = targetCol.formatter || null; targetCol.formatter = (cell, row) => { const rowOpenUrl = resolveRowOpenUrl(row); const inner = originalFormatter ? originalFormatter(cell, row) : cell; if (!rowOpenUrl) { return inner; } const innerHtml = (inner && typeof inner === 'object' && typeof inner.props?.content === 'string') ? inner.props.content : escapeHtmlAttr(String(inner ?? '')); // Avoid nesting inside — if the formatter already has links, skip wrapping. if (/]/i.test(innerHtml)) { return inner; } return gridjsLib.html(`${innerHtml}`); }; } 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; if (selectionConfig) { const usedColumnIds = new Set( normalizedColumns .map((column) => (column && typeof column === 'object' ? String(column.id || '').trim() : '')) .filter(Boolean) ); selectionConfig.id = uniqueColumnId(selectionConfig.id, usedColumnIds, 'select_row'); } const insertActionColumn = (cols) => { if (!actionConfig) {return cols;} const usedColumnIds = new Set( cols .map((column) => (column && typeof column === 'object' ? String(column.id || '').trim() : '')) .filter(Boolean) ); const actionColumnId = uniqueColumnId(actionConfig.id, usedColumnIds, 'row_actions'); const actionColumn = { id: actionColumnId, 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 ariaLabelRaw = typeof item.ariaLabel === 'function' ? item.ariaLabel(row, id, label) : item.ariaLabel; const titleRaw = typeof item.title === 'function' ? item.title(row, id, label) : item.title; const ariaAttr = ariaLabelRaw ? ` aria-label="${escapeHtmlAttr(ariaLabelRaw)}"` : ''; const titleAttr = titleRaw ? ` title="${escapeHtmlAttr(titleRaw)}"` : ''; const className = item.className ? ` ${item.className}` : ''; const type = item.type || 'button'; const safeLabel = escapeHtmlAttr(String(label)); if (type === 'link') { const href = typeof item.href === 'function' ? item.href({ row, id }) : item.href; const url = escapeHtmlAttr(href || '#'); return `${safeLabel}`; } 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); } const sortConfig = finalSortColumns.length ? { enabled: true, server: { url: (prev, columns) => { if (!columns.length) { // Keep URL/bootstrap sort when grid has no active sort columns yet (e.g. initial load from deep-link). if (currentSort?.order && currentSort?.dir) { return addParam(addParam(clearSortParams(prev), sortOrderParam, currentSort.order), sortDirParam, currentSort.dir); } currentSort = null; return clearSortParams(prev); } const column = columns[0]; const key = finalSortColumns[column.index]; if (!key) { if (currentSort?.order && currentSort?.dir) { return addParam(addParam(clearSortParams(prev), sortOrderParam, currentSort.order), sortDirParam, currentSort.dir); } currentSort = null; return clearSortParams(prev); } const direction = column.direction === 1 ? 'asc' : 'desc'; currentSort = { order: key, dir: direction }; if (urlSync && urlStateConfig.syncSort) { updateUrl(); } return addParam(addParam(clearSortParams(prev), sortOrderParam, key), sortDirParam, direction); } } } : { enabled: false }; const paginationConfig = { limit: currentLimit, page: Math.max(currentPage - 1, 0), // We control page resets explicitly (search/filter/reset actions) to keep deep-link pages stable. resetPageOnUpdate: false, server: { url: (prev, page, limit) => { const parsedPage = Number.parseInt(String(page ?? ''), 10); const parsedLimit = parseAllowedLimit(limit); const nextPage = Number.isFinite(parsedPage) && parsedPage >= 0 ? parsedPage : 0; const nextLimit = parsedLimit ?? currentLimit; currentLimit = nextLimit; if (scopedPageSizeStorageKey) { pageSizeStorage.setItem(scopedPageSizeStorageKey, String(currentLimit)); } currentPage = nextPage + 1; if (urlSync && (urlStateConfig.syncPage || syncLimit)) { updateUrl(); } const offset = nextPage * nextLimit; return addParam(addParam(prev, 'limit', nextLimit), '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(normalizedColumns)), 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 ensureErrorRetryControl = () => { const errorMessage = containerEl.querySelector('.gridjs-message.gridjs-error'); if (!errorMessage) { return; } if (errorMessage.querySelector('[data-grid-retry]')) { return; } const actionsWrap = document.createElement('div'); actionsWrap.className = 'app-grid-error-actions'; const retryButton = document.createElement('button'); retryButton.type = 'button'; retryButton.className = 'outline secondary app-grid-retry'; retryButton.setAttribute('data-grid-retry', '1'); retryButton.textContent = gridRetryLabel; actionsWrap.appendChild(retryButton); errorMessage.appendChild(actionsWrap); }; 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 === 4) { setTimeout(() => { ensureErrorRetryControl(); }, 0); } if (state.status >= 2) { markLoadedOnce(); } if (state.status >= 3) { initSelectAll(); } }); } const applyRowDataset = () => { const shouldApplyCustomDataset = typeof rowDataset === 'function'; if (!shouldApplyCustomDataset && !rowInteractionEnabled) {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;} if (rowInteractionEnabled) { const rowOpenUrl = resolveRowOpenUrl(rowData, index); if (rowOpenUrl !== '') { rowEl.setAttribute('data-row-open-url', rowOpenUrl); rowEl.setAttribute('tabindex', '-1'); } else { rowEl.removeAttribute('data-row-open-url'); if (rowEl.getAttribute('tabindex') === '-1') { rowEl.removeAttribute('tabindex'); } } } if (shouldApplyCustomDataset) { 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' || rowInteractionEnabled) { 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 }; }; const resolveRowOpenUrlByElement = (rowEl) => { if (!rowInteractionEnabled || !rowEl) { return ''; } const storedUrl = String(rowEl.getAttribute('data-row-open-url') || '').trim(); if (storedUrl !== '') { return storedUrl; } const rowInfo = getRowDataByElement(rowEl); if (!rowInfo) { return ''; } return resolveRowOpenUrl(rowInfo.rowData, rowInfo.rowIndex); }; const navigateToRow = (rowEl) => { const url = resolveRowOpenUrlByElement(rowEl); if (url === '') { return false; } window.location.href = url; return true; }; if (keepDblClick && 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'); navigateToRow(rowEl); }); } if (enableEnterOnRow) { const interactiveSelector = '.grid-actions, button, a, input, select, textarea, [role="button"], [contenteditable="true"]'; containerEl.addEventListener('click', (event) => { const target = event.target instanceof Element ? event.target : null; if (!target || target.closest(interactiveSelector)) { return; } const rowEl = target.closest('.gridjs-tr[data-row-open-url]'); if (!rowEl) { return; } rowEl.focus({ preventScroll: true }); }); containerEl.addEventListener('keydown', (event) => { if (event.key !== 'Enter' || event.defaultPrevented) { return; } if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { return; } const target = event.target instanceof Element ? event.target : null; if (!target || target.closest(interactiveSelector)) { return; } const rowEl = target.closest('.gridjs-tr'); if (!rowEl) { return; } if (navigateToRow(rowEl)) { event.preventDefault(); } }); } 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, rowIndex } = 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, rowIndex) : null; if (!id) { return; } const confirmText = typeof actionItem.confirm === 'function' ? actionItem.confirm({ row: rowData, id }) : actionItem.confirm; if (confirmText) { const confirmOptionsFromItem = typeof actionItem.confirmOptions === 'function' ? (actionItem.confirmOptions({ row: rowData, id, action: actionKey }) || {}) : (actionItem.confirmOptions || {}); const approved = await confirmDialog.confirm({ ...confirmOptionsFromItem, message: String(confirmText), actionKind: String(confirmOptionsFromItem.actionKind || actionKey || '') }); if (!approved) { 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 }); } }); } containerEl.addEventListener('click', (event) => { const retryButton = event.target.closest('[data-grid-retry]'); if (!retryButton) { return; } event.preventDefault(); retryButton.setAttribute('aria-busy', 'true'); updateGrid({ resetPage: false }); }); let pageSizeSelect = null; const syncPageSizeControl = () => { if (!pageSizeSelect) { return; } pageSizeSelect.value = String(currentLimit); }; const resolvePageSizeToolbar = () => { const selector = String(pageSizeConfig.toolbarSelector || '.app-list-toolbar').trim() || '.app-list-toolbar'; const allToolbars = Array.from(document.querySelectorAll(selector)); if (!allToolbars.length) { return null; } const precedingToolbars = allToolbars.filter((toolbar) => ( Boolean(toolbar.compareDocumentPosition(containerEl) & Node.DOCUMENT_POSITION_FOLLOWING) )); const pool = precedingToolbars.length ? precedingToolbars : allToolbars; const withFilterButton = pool.filter((toolbar) => toolbar.querySelector('[data-filter-drawer-open]')); const targetPool = withFilterButton.length ? withFilterButton : pool; return targetPool[targetPool.length - 1] || null; }; const mountPageSizeControl = () => { if (!pageSizeEnabled) { return; } const toolbar = resolvePageSizeToolbar(); if (!toolbar) { warnOnce( 'UI_PAGE_SIZE_HOST_MISSING', `Missing page-size toolbar near grid: ${containerKey || 'unknown'}`, { module: 'grid-factory' } ); return; } const controlKey = encodeURIComponent(`${containerKey}:${dataUrlPath}`); const existing = toolbar.querySelector(`.app-grid-page-size[data-grid-page-size-key="${controlKey}"]`); if (existing) { pageSizeSelect = existing.querySelector('select'); syncPageSizeControl(); return; } const field = document.createElement('label'); field.className = 'app-field app-grid-page-size'; field.dataset.gridPageSizeKey = controlKey; const caption = document.createElement('span'); caption.textContent = pageSizeCompactLabel; const select = document.createElement('select'); select.setAttribute('aria-label', pageSizeLabel); select.setAttribute('title', pageSizeLabel); pageSizeOptions.forEach((option) => { const optionEl = document.createElement('option'); optionEl.value = String(option); optionEl.textContent = String(option); select.appendChild(optionEl); }); select.value = String(currentLimit); select.addEventListener('change', () => { const nextLimit = parseAllowedLimit(select.value); if (nextLimit === null || nextLimit === currentLimit) { syncPageSizeControl(); return; } currentLimit = nextLimit; currentPage = 1; if (scopedPageSizeStorageKey) { pageSizeStorage.setItem(scopedPageSizeStorageKey, String(currentLimit)); } syncPageSizeControl(); updateGrid({ resetPage: true, urlHistoryMode: 'push' }); }); field.appendChild(caption); field.appendChild(select); const filterButton = toolbar.querySelector(':scope > [data-filter-drawer-open]'); if (filterButton) { filterButton.insertAdjacentElement('afterend', field); } else { toolbar.appendChild(field); } pageSizeSelect = select; syncPageSizeControl(); }; const updateGrid = (options = {}) => { const resetPage = options && options.resetPage === true; const urlHistoryMode = options?.urlHistoryMode || 'replace'; if (resetPage) { currentPage = 1; } updateUrl({ historyMode: urlHistoryMode }); const paginationState = grid?.config?.pagination && typeof grid.config.pagination === 'object' ? grid.config.pagination : paginationConfig; const nextPagination = { ...paginationState, limit: currentLimit, page: resetPage ? 0 : Math.max(currentPage - 1, 0) }; const nextConfig = { server: { ...serverConfig, url: baseUrl() }, pagination: nextPagination }; // Use Grid.js reconfiguration API so server pipeline is refreshed reliably on filter/search changes. if (typeof grid?.updateConfig === 'function') { grid.updateConfig(nextConfig).forceRender(); return; } if (grid?.config) { grid.config.server = nextConfig.server; grid.config.pagination = nextConfig.pagination; } grid.forceRender(); }; mountPageSizeControl(); 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({ resetPage: true }); }, searchDebounce)); } if (normalizedFilterBindingMode === 'live') { filterState.forEach((filter) => { if (!filter.input) {return;} const eventName = filter.event || 'change'; filter.input.addEventListener(eventName, () => { filter.value = getInputValue(filter.input); updateGrid({ resetPage: true }); }); }); } const commitFilterInputValues = () => { filterState.forEach((filter) => { if (!filter.input) {return;} filter.value = getInputValue(filter.input); }); }; const applyFilters = (options = {}) => { const resetPage = options?.resetPage !== false; const urlHistoryMode = options?.urlHistoryMode || 'replace'; commitFilterInputValues(); updateGrid({ resetPage, urlHistoryMode }); }; const resetFilters = (options = {}) => { const preserveFilterParams = Array.isArray(options?.preserveFilterParams) ? options.preserveFilterParams.map((item) => String(item ?? '').trim()).filter(Boolean) : []; const preserveFilterParamSet = new Set(preserveFilterParams); const preserveSearch = options?.preserveSearch === true; const resetPage = options?.resetPage !== false; const urlHistoryMode = options?.urlHistoryMode || 'replace'; if (search && !preserveSearch) { const defaultValue = search.default ?? ''; searchValue = defaultValue; if (searchInput) { setInputValue(searchInput, defaultValue); } } filterState.forEach((filter) => { if (preserveFilterParamSet.has(filter.param)) { if (filter.input) { filter.value = getInputValue(filter.input); } return; } const defaultValue = filter.default ?? ''; filter.value = defaultValue; if (filter.input) { setInputValue(filter.input, defaultValue); } }); updateGrid({ resetPage, urlHistoryMode }); }; const captureAppliedState = () => { const filterValues = {}; filterState.forEach((filter) => { filterValues[filter.param] = filter.value ?? filter.default ?? ''; }); return { search: searchValue, filters: filterValues, sort: currentSort ? { ...currentSort } : null, page: currentPage, limit: currentLimit }; }; const captureDraftState = () => { const filterValues = {}; filterState.forEach((filter) => { const raw = filter.input ? getInputValue(filter.input) : (filter.value ?? filter.default ?? ''); filterValues[filter.param] = raw; }); const draftSearch = searchInput ? String(getInputValue(searchInput) ?? '').trim() : searchValue; return { search: draftSearch, filters: filterValues, sort: currentSort ? { ...currentSort } : null, page: currentPage, limit: currentLimit }; }; const restoreAppliedState = (state = {}) => { if (search) { const nextSearch = typeof state?.search === 'string' ? state.search : (search.default ?? ''); searchValue = nextSearch; if (searchInput) { setInputValue(searchInput, nextSearch); } } const nextFilters = state?.filters && typeof state.filters === 'object' ? state.filters : {}; filterState.forEach((filter) => { const nextValue = Object.prototype.hasOwnProperty.call(nextFilters, filter.param) ? nextFilters[filter.param] : (filter.default ?? ''); filter.value = nextValue; if (filter.input) { setInputValue(filter.input, nextValue); } }); if (state?.sort && typeof state.sort === 'object') { const order = String(state.sort.order ?? '').trim(); const dir = normalizeDir(state.sort.dir ?? ''); if (order !== '' && dir !== '' && validSortKeys.has(order)) { currentSort = { order, dir }; } else { currentSort = null; } } else if (state?.sort === null) { currentSort = null; } if (Number.isFinite(Number(state?.page))) { const parsedPage = parsePage(state.page); currentPage = parsedPage; } const nextLimit = parseAllowedLimit(state?.limit); if (nextLimit !== null) { currentLimit = nextLimit; if (scopedPageSizeStorageKey) { pageSizeStorage.setItem(scopedPageSizeStorageKey, String(currentLimit)); } syncPageSizeControl(); } }; 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(() => { requestFsLightboxRefresh(); }, 0); }); return { grid, container: containerEl, updateGrid, updateUrl, baseUrl, resolveUrl, getSortParams, resetFilters, applyFilters, captureAppliedState, captureDraftState, restoreAppliedState, selection: selectionApi }; } const DEFAULT_LIST_FILTER_LABELS = { chipsClearAll: 'Clear all filters', chipsRemove: 'Remove filter', filtersApplied: 'Filters applied', filterRemoved: 'Filter removed', filtersCleared: 'All filters cleared' }; const resolveDomQueryElement = (target, root = document) => { if (!target) { return null; } if (typeof target === 'string') { if (root && typeof root.querySelector === 'function') { return root.querySelector(target) || document.querySelector(target); } return document.querySelector(target); } return target; }; const readDomLabelDefaults = (options = {}) => { const drawerRoot = options.drawerRoot && typeof options.drawerRoot.querySelector === 'function' ? options.drawerRoot : document; const chipsSelector = options.chipsSelector || '[data-active-filter-chips]'; const liveSelector = options.liveSelector || '[data-filter-live-region]'; const chipsEl = resolveDomQueryElement(chipsSelector, drawerRoot); const liveEl = resolveDomQueryElement(liveSelector, drawerRoot); const chipsClearAll = String(chipsEl?.dataset?.filterChipsClearAllLabel || '').trim(); const chipsRemove = String(chipsEl?.dataset?.filterChipsRemoveLabel || '').trim(); const filtersApplied = String(liveEl?.dataset?.filterLiveAppliedMessage || '').trim(); const filterRemoved = String(liveEl?.dataset?.filterLiveRemovedMessage || '').trim(); const filtersCleared = String(liveEl?.dataset?.filterLiveClearedMessage || '').trim(); return { ...(chipsClearAll !== '' ? { chipsClearAll } : {}), ...(chipsRemove !== '' ? { chipsRemove } : {}), ...(filtersApplied !== '' ? { filtersApplied } : {}), ...(filterRemoved !== '' ? { filterRemoved } : {}), ...(filtersCleared !== '' ? { filtersCleared } : {}) }; }; const normalizeListMode = (value) => { const mode = String(value || '').trim().toLowerCase(); if (mode === 'drawer' || mode === 'search-only' || mode === 'none') { return mode; } return 'drawer'; }; const resolveDrawerRoot = (gridConfig, filterOptions = {}) => { const explicitRoot = filterOptions?.drawerRoot; if (explicitRoot) { return resolveDomQueryElement(explicitRoot) || document; } const containerEl = gridConfig?.container instanceof Element ? gridConfig.container : null; if (!containerEl) { return document; } const drawerSelector = String(filterOptions?.drawer?.drawerSelector || '[data-filter-drawer]').trim() || '[data-filter-drawer]'; const openSelector = String(filterOptions?.drawer?.openSelector || '[data-filter-drawer-open]').trim() || '[data-filter-drawer-open]'; let current = containerEl; while (current && current !== document.documentElement) { const hasDrawer = Boolean(current.querySelector(drawerSelector)); const hasOpenButton = Boolean(current.querySelector(openSelector)); if (hasDrawer && hasOpenButton) { return current; } current = current.parentElement; } return containerEl.parentElement || document; }; /** * Standardized list-page bootstrap: * - builds grid filters from schema (or uses explicit filters) * - initializes server grid * - optionally wires drawer/chip filter experience */ export function initStandardListPage(options = {}) { const gridOptions = options?.grid && typeof options.grid === 'object' ? options.grid : {}; const filterOptions = options?.filters && typeof options.filters === 'object' ? options.filters : {}; const hooks = options?.hooks && typeof options.hooks === 'object' ? options.hooks : {}; const mode = normalizeListMode(filterOptions.mode); const filterSchema = Array.isArray(gridOptions.filterSchema) ? gridOptions.filterSchema : []; const extraFilters = Array.isArray(gridOptions.extraFilters) ? gridOptions.extraFilters : []; const resolvedFilters = Array.isArray(gridOptions.filters) ? gridOptions.filters : [...gridFiltersFromSchema(filterSchema), ...extraFilters]; const hasExplicitBindingMode = Object.prototype.hasOwnProperty.call(gridOptions, 'filterBindingMode'); const defaultBindingMode = mode === 'drawer' ? 'manual' : 'live'; const createOptions = { ...gridOptions, filters: resolvedFilters, filterBindingMode: hasExplicitBindingMode ? gridOptions.filterBindingMode : defaultBindingMode }; delete createOptions.filterSchema; delete createOptions.extraFilters; const gridConfig = createServerGrid(createOptions); if (typeof hooks.onGridReady === 'function') { hooks.onGridReady({ gridConfig, mode }); } let filterExperience = null; if (mode === 'drawer' && gridConfig) { const chipMeta = filterOptions.chipMeta && typeof filterOptions.chipMeta === 'object' ? filterOptions.chipMeta : {}; const drawerConfig = filterOptions.drawer && typeof filterOptions.drawer === 'object' ? filterOptions.drawer : {}; const liveConfig = filterOptions.live && typeof filterOptions.live === 'object' ? filterOptions.live : {}; const chipUiConfig = filterOptions.chips && typeof filterOptions.chips === 'object' ? filterOptions.chips : {}; const drawerRoot = resolveDrawerRoot(gridConfig, filterOptions); const domLabels = readDomLabelDefaults({ drawerRoot, chipsSelector: chipUiConfig.container || '[data-active-filter-chips]', liveSelector: liveConfig.regionSelector || '[data-filter-live-region]' }); const labels = { ...DEFAULT_LIST_FILTER_LABELS, ...domLabels, ...(filterOptions.labels && typeof filterOptions.labels === 'object' ? filterOptions.labels : {}) }; const preserveFilterParamsOnReset = Array.isArray(filterOptions.preserveFilterParamsOnReset) ? filterOptions.preserveFilterParamsOnReset : []; const watchInputs = Array.isArray(filterOptions.watchInputs) ? filterOptions.watchInputs : []; const clearMetaOptions = filterOptions.clearMetaOptions && typeof filterOptions.clearMetaOptions === 'object' ? filterOptions.clearMetaOptions : {}; const buildChips = typeof filterOptions.buildChips === 'function' ? filterOptions.buildChips : (state = {}) => buildChipsFromMeta(state, chipMeta); const removeChip = typeof filterOptions.removeChip === 'function' ? filterOptions.removeChip : (chip, state = {}) => removeChipFromMetaState(chip, state, chipMeta); const clearAllState = typeof filterOptions.clearAllState === 'function' ? filterOptions.clearAllState : (state = {}) => clearMetaState(state, chipMeta, clearMetaOptions); filterExperience = initListFilterExperience({ gridConfig, drawerRoot, drawer: drawerConfig, live: { regionSelector: '[data-filter-live-region]', appliedMessage: labels.filtersApplied, removedMessage: labels.filterRemoved, clearedMessage: labels.filtersCleared, ...liveConfig }, chips: { container: '[data-active-filter-chips]', clearAllLabel: labels.chipsClearAll, removeButtonLabel: labels.chipsRemove, ...chipUiConfig }, preserveFilterParamsOnReset, buildChips, removeChip, clearAllState, watchInputs }); } if (typeof hooks.onFilterExperienceReady === 'function') { hooks.onFilterExperienceReady({ gridConfig, filterExperience, mode }); } return { gridConfig, filterExperience }; }