1517 lines
51 KiB
JavaScript
1517 lines
51 KiB
JavaScript
import { warnOnce } from './app-dom.js';
|
|
import { confirmDialog } from './app-confirm-dialog.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 safeStorageGet = (key) => {
|
|
const storageKey = String(key || '').trim();
|
|
if (storageKey === '') {
|
|
return null;
|
|
}
|
|
try {
|
|
return window.localStorage.getItem(storageKey);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const safeStorageSet = (key, value) => {
|
|
const storageKey = String(key || '').trim();
|
|
if (storageKey === '') {
|
|
return;
|
|
}
|
|
try {
|
|
window.localStorage.setItem(storageKey, value);
|
|
} catch {
|
|
// localStorage can be unavailable; fail safe.
|
|
}
|
|
};
|
|
|
|
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 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);
|
|
return { ...baseColumn, id };
|
|
});
|
|
};
|
|
|
|
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 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(safeStorageGet(pageSizeStorageKey));
|
|
if (storedLimit !== null) {
|
|
currentLimit = storedLimit;
|
|
}
|
|
}
|
|
if (pageSizeEnabled) {
|
|
safeStorageSet(pageSizeStorageKey, 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 showRowActionButton = rowInteractionEnabled && rowInteractionConfig.showActionButton !== false;
|
|
const rowActionColumnLabel = String(
|
|
rowInteractionConfig.actionColumnLabel
|
|
|| language?.actions?.column
|
|
|| 'Actions'
|
|
).trim() || 'Actions';
|
|
const rowActionOpenLabel = String(language?.actions?.open || 'Open').trim() || 'Open';
|
|
const rowActionEditLabel = String(language?.actions?.edit || 'Edit').trim() || 'Edit';
|
|
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 defaultResolveRowActionLabel = (url) => (
|
|
/(^|\/)edit(\/|$|\?)/i.test(String(url || '').trim())
|
|
? rowActionEditLabel
|
|
: rowActionOpenLabel
|
|
);
|
|
const resolveActionLabel = typeof rowInteractionConfig.resolveActionLabel === 'function'
|
|
? rowInteractionConfig.resolveActionLabel
|
|
: defaultResolveRowActionLabel;
|
|
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 rowOpenActionConfig = !explicitActionConfig && showRowActionButton ? {
|
|
id: 'row_actions',
|
|
label: gridjsLib.html(
|
|
`<span class="app-grid-actions-header" data-tooltip="${escapeHtmlAttr(rowEnterHint)}" data-tooltip-pos="top" aria-label="${escapeHtmlAttr(rowEnterHint)}">${escapeHtmlAttr(rowActionColumnLabel)}</span>`
|
|
),
|
|
index: null,
|
|
items: [{
|
|
key: 'row-open',
|
|
type: 'link',
|
|
className: 'outline secondary app-grid-row-open',
|
|
label: (rowData, url) => {
|
|
const dynamicLabel = resolveActionLabel(url, rowData);
|
|
const normalizedLabel = String(dynamicLabel || '').trim();
|
|
return normalizedLabel || defaultResolveRowActionLabel(url);
|
|
},
|
|
ariaLabel: (_rowData, _url, label) => `${String(label || '').trim()}. ${rowEnterHint}`,
|
|
title: (_rowData, _url) => rowEnterHint,
|
|
href: ({ id }) => id
|
|
}],
|
|
// For row-open actions the URL itself is the stable row identifier.
|
|
getRowId: (rowData) => resolveRowOpenUrl(rowData),
|
|
wrapperClass: 'grid-actions grid-actions-row-open',
|
|
onAction: null
|
|
} : null;
|
|
const actionConfig = explicitActionConfig || rowOpenActionConfig;
|
|
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';
|
|
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}"${ariaAttr}${titleAttr}>${label}</a>`;
|
|
}
|
|
return `<button type="button" class="${className.trim()}" data-grid-action="${item.key}"${ariaAttr}${titleAttr}>${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);
|
|
}
|
|
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 (pageSizeEnabled) {
|
|
safeStorageSet(pageSizeStorageKey, 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;
|
|
safeStorageSet(pageSizeStorageKey, 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;
|
|
safeStorageSet(pageSizeStorageKey, 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(() => {
|
|
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,
|
|
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';
|
|
};
|
|
|
|
/**
|
|
* 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 = filterOptions.drawerRoot || document;
|
|
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
|
|
};
|
|
}
|