fix: replace grid action column with link-column on primary data cell

Remove the dedicated "Edit"/"Open" action button column from all list
pages. Instead, wrap the primary data column (name, description, subject,
etc.) in a clickable <a> link via the new rowInteraction.linkColumn option.

Double-click, Enter-key, and click-to-focus row navigation preserved.
Pages with explicit actions config are unaffected. Address-book opts out
of auto-wrapping (linkColumn: false) and renders the link in its own
formatter to avoid nested <a> with avatar lightbox.

Also fixes pre-existing XSS in insertActionColumn (unescaped href/label)
and updates typography test for --text-page-title token change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 19:43:03 +01:00
parent af36583862
commit 1c5648c727
16 changed files with 87 additions and 85 deletions

View File

@@ -255,28 +255,15 @@ export function createServerGrid(options) {
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 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 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 '';
@@ -467,31 +454,29 @@ export function createServerGrid(options) {
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 actionConfig = explicitActionConfig || null;
// Link-column: wrap the target column's formatter output in an <a> 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 <a> inside <a> — if the formatter already has links, skip wrapping.
if (/<a[\s>]/i.test(innerHtml)) {
return inner;
}
return gridjsLib.html(`<a class="app-grid-link-cell" href="${escapeHtmlAttr(rowOpenUrl)}">${innerHtml}</a>`);
};
}
const selectionConfig = selection && selection.enabled ? {
id: selection.id || 'selectRow',
index: Number.isInteger(selection.index) ? selection.index : 0,
@@ -543,12 +528,13 @@ export function createServerGrid(options) {
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 = href || '#';
return `<a role="button" class="${className.trim()}" data-grid-action="${item.key}" href="${url}"${ariaAttr}${titleAttr}>${label}</a>`;
const url = escapeHtmlAttr(href || '#');
return `<a role="button" class="${className.trim()}" data-grid-action="${item.key}" href="${url}"${ariaAttr}${titleAttr}>${safeLabel}</a>`;
}
return `<button type="button" class="${className.trim()}" data-grid-action="${item.key}"${ariaAttr}${titleAttr}>${label}</button>`;
return `<button type="button" class="${className.trim()}" data-grid-action="${item.key}"${ariaAttr}${titleAttr}>${safeLabel}</button>`;
}).join('');
return gridjsLib.html(`<div class="${actionConfig.wrapperClass}" role="group">${buttons}</div>`);
}

View File

@@ -117,9 +117,7 @@ if (config) {
row.ip || '',
row.id,
]),
actions: {
enabled: false,
},
rowInteraction: { linkColumn: 3 },
search: gridSearch,
filterSchema,
urlSync: true,

View File

@@ -81,7 +81,7 @@ if (config) {
row.modified,
row.uuid,
]),
actions: { enabled: false },
rowInteraction: { linkColumn: 0 },
search: gridSearch,
filterSchema,
urlSync: true,

View File

@@ -77,7 +77,7 @@ if (config) {
{ uuid: row.user_uuid || '', label: row.user_label || '-' },
row.id,
]),
actions: { enabled: false },
rowInteraction: { linkColumn: 2 },
search: gridSearch,
filterSchema,
urlSync: true,

View File

@@ -71,9 +71,7 @@ if (config) {
row.template || '-',
row.id,
]),
actions: {
enabled: false,
},
rowInteraction: { linkColumn: 3 },
search: gridSearch,
filterSchema,
urlSync: true,

View File

@@ -48,6 +48,7 @@ if (config) {
row.created,
row.id,
]),
rowInteraction: { linkColumn: 0 },
rowDblClick: {
getUrl: (row) => {
const id = row?.cells?.[5]?.data;

View File

@@ -93,7 +93,7 @@ if (config) {
row.modified,
row.uuid,
]),
actions: { enabled: false },
rowInteraction: { linkColumn: 0 },
search: gridSearch,
filterSchema,
urlSync: true,

View File

@@ -70,7 +70,7 @@ if (config) {
row.last_error || '-',
row.id,
]),
actions: { enabled: false },
rowInteraction: { linkColumn: 0 },
search: gridSearch,
filterSchema,
urlSync: true,

View File

@@ -79,9 +79,7 @@ if (config) {
row.error_code || '',
row.id,
]),
actions: {
enabled: false,
},
rowInteraction: { linkColumn: 2 },
search: gridSearch,
filterSchema,
rowDblClick: {

View File

@@ -138,7 +138,7 @@ if (config) {
row.created,
row.uuid,
]),
actions: { enabled: false },
rowInteraction: { linkColumn: 1 },
search: gridSearch,
filterSchema,
urlSync: true,

View File

@@ -50,7 +50,7 @@ if (config) {
row.restored_at || '-',
row.id,
]),
actions: { enabled: false },
rowInteraction: { linkColumn: 2 },
search: gridSearch,
filterSchema,
urlSync: true,

View File

@@ -139,6 +139,7 @@ if (config) {
rowDataset: (row) => ({
uuid: row?.cells?.[uuidIndex]?.data,
}),
rowInteraction: { linkColumn: 2 },
rowDblClick: {
getUrl: (rowData) => {
if (!canEditRow(rowData)) {