listen ansichten verbessert

This commit is contained in:
2026-03-05 11:17:42 +01:00
parent 4b31fc7664
commit c5f657c8c8
133 changed files with 2806 additions and 636 deletions

View File

@@ -3,6 +3,55 @@ import { gridFiltersFromSchema } 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.
}
};
export function createServerGrid(options) {
const {
container,
@@ -22,8 +71,10 @@ export function createServerGrid(options) {
gridjs,
rowDblClick = null,
actions = null,
rowInteraction = null,
rowDataset = null,
selection = null
selection = null,
pageSize = null
} = options || {};
const normalizedFilterBindingMode = String(filterBindingMode || 'live').trim().toLowerCase() === 'manual'
@@ -47,14 +98,18 @@ export function createServerGrid(options) {
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);
@@ -84,6 +139,68 @@ export function createServerGrid(options) {
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';
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) {
@@ -93,6 +210,36 @@ export function createServerGrid(options) {
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 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);
return String(url || '').trim();
};
const getInput = (input) => {
if (!input) {return null;}
@@ -223,6 +370,15 @@ export function createServerGrid(options) {
} 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;
};
@@ -244,6 +400,7 @@ export function createServerGrid(options) {
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') {
@@ -253,7 +410,7 @@ export function createServerGrid(options) {
window.history.replaceState({}, '', url.toString());
};
const actionConfig = actions && actions.enabled ? {
const explicitActionConfig = actions && actions.enabled ? {
label: actions.label || 'Actions',
index: Number.isInteger(actions.index) ? actions.index : null,
items: Array.isArray(actions.items) ? actions.items : [],
@@ -261,6 +418,26 @@ export function createServerGrid(options) {
wrapperClass: actions.wrapperClass || 'grid-actions',
onAction: typeof actions.onAction === 'function' ? actions.onAction : null
} : null;
const rowOpenActionConfig = !explicitActionConfig && showRowActionButton ? {
label: rowActionColumnLabel,
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);
},
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,
@@ -402,18 +579,22 @@ export function createServerGrid(options) {
} : { enabled: false };
const paginationConfig = {
limit: paginationLimit,
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 = Number.parseInt(String(limit ?? ''), 10);
const parsedLimit = parseAllowedLimit(limit);
const nextPage = Number.isFinite(parsedPage) && parsedPage >= 0 ? parsedPage : 0;
const nextLimit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : paginationLimit;
const nextLimit = parsedLimit ?? currentLimit;
currentLimit = nextLimit;
if (pageSizeEnabled) {
safeStorageSet(pageSizeStorageKey, String(currentLimit));
}
currentPage = nextPage + 1;
if (urlSync && urlStateConfig.syncPage) {
if (urlSync && (urlStateConfig.syncPage || syncLimit)) {
updateUrl();
}
const offset = nextPage * nextLimit;
@@ -476,22 +657,37 @@ export function createServerGrid(options) {
}
const applyRowDataset = () => {
if (typeof rowDataset !== 'function') {return;}
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;}
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);
if (rowInteractionEnabled) {
const rowOpenUrl = resolveRowOpenUrl(rowData, index);
if (rowOpenUrl !== '') {
rowEl.setAttribute('data-row-open-url', rowOpenUrl);
rowEl.setAttribute('tabindex', '-1');
} else {
rowEl.setAttribute(attr, String(value));
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));
}
});
}
});
};
@@ -530,7 +726,7 @@ export function createServerGrid(options) {
updateHeaderState();
};
if (typeof rowDataset === 'function') {
if (typeof rowDataset === 'function' || rowInteractionEnabled) {
const observer = new MutationObserver(() => applyRowDataset());
observer.observe(containerEl, { childList: true, subtree: true });
applyRowDataset();
@@ -547,25 +743,71 @@ export function createServerGrid(options) {
return { rowData, rowIndex };
};
if (rowDblClick) {
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');
const rowInfo = getRowDataByElement(rowEl);
if (!rowInfo) {
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 { rowData, rowIndex } = rowInfo;
const getUrl = rowDblClick.getUrl;
if (typeof getUrl !== 'function') {
const rowEl = target.closest('.gridjs-tr[data-row-open-url]');
if (!rowEl) {
return;
}
const url = getUrl(rowData, rowIndex);
if (url) {
window.location.href = url;
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();
}
});
}
@@ -581,13 +823,13 @@ export function createServerGrid(options) {
if (!rowInfo) {
return;
}
const { rowData } = rowInfo;
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) : null;
const id = actionConfig.getRowId ? actionConfig.getRowId(rowData, rowIndex) : null;
if (!id) {
return;
}
@@ -614,6 +856,91 @@ export function createServerGrid(options) {
});
}
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 = pageSizeLabel;
const select = document.createElement('select');
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';
@@ -621,12 +948,18 @@ export function createServerGrid(options) {
currentPage = 1;
}
updateUrl({ historyMode: urlHistoryMode });
const nextConfig = {
server: { ...serverConfig, url: baseUrl() }
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
};
if (resetPage && grid?.config?.pagination && typeof grid.config.pagination === 'object') {
nextConfig.pagination = { ...grid.config.pagination, page: 0 };
}
// Use Grid.js reconfiguration API so server pipeline is refreshed reliably on filter/search changes.
if (typeof grid?.updateConfig === 'function') {
@@ -636,13 +969,13 @@ export function createServerGrid(options) {
if (grid?.config) {
grid.config.server = nextConfig.server;
if (nextConfig.pagination) {
grid.config.pagination = nextConfig.pagination;
}
grid.config.pagination = nextConfig.pagination;
}
grid.forceRender();
};
mountPageSizeControl();
const debounce = (fn, delay = 250) => {
let timer;
return (...args) => {
@@ -724,7 +1057,8 @@ export function createServerGrid(options) {
search: searchValue,
filters: filterValues,
sort: currentSort ? { ...currentSort } : null,
page: currentPage
page: currentPage,
limit: currentLimit
};
};
@@ -741,7 +1075,8 @@ export function createServerGrid(options) {
search: draftSearch,
filters: filterValues,
sort: currentSort ? { ...currentSort } : null,
page: currentPage
page: currentPage,
limit: currentLimit
};
};
@@ -783,6 +1118,12 @@ export function createServerGrid(options) {
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);