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:
@@ -233,16 +233,20 @@
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.gridjs-container .grid-actions-row-open {
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
.app-grid-link-cell {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.gridjs-container .grid-actions .app-grid-row-open {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
.app-grid-link-cell:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
.app-grid-link-cell:focus-visible {
|
||||
outline: 2px solid var(--app-primary-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
tr.gridjs-tr[data-row-open-url] {
|
||||
@@ -276,10 +280,6 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gridjs-container table td .grid-actions .app-grid-row-open:focus-visible {
|
||||
box-shadow: 0 0 0 var(--app-outline-width) var(--app-primary-focus);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] button.gridjs-sort,
|
||||
html[data-theme="dark"] button.gridjs-sort-neutral {
|
||||
filter: invert(1);
|
||||
|
||||
@@ -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>`);
|
||||
}
|
||||
|
||||
@@ -117,9 +117,7 @@ if (config) {
|
||||
row.ip || '',
|
||||
row.id,
|
||||
]),
|
||||
actions: {
|
||||
enabled: false,
|
||||
},
|
||||
rowInteraction: { linkColumn: 3 },
|
||||
search: gridSearch,
|
||||
filterSchema,
|
||||
urlSync: true,
|
||||
|
||||
@@ -81,7 +81,7 @@ if (config) {
|
||||
row.modified,
|
||||
row.uuid,
|
||||
]),
|
||||
actions: { enabled: false },
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
search: gridSearch,
|
||||
filterSchema,
|
||||
urlSync: true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -71,9 +71,7 @@ if (config) {
|
||||
row.template || '-',
|
||||
row.id,
|
||||
]),
|
||||
actions: {
|
||||
enabled: false,
|
||||
},
|
||||
rowInteraction: { linkColumn: 3 },
|
||||
search: gridSearch,
|
||||
filterSchema,
|
||||
urlSync: true,
|
||||
|
||||
@@ -48,6 +48,7 @@ if (config) {
|
||||
row.created,
|
||||
row.id,
|
||||
]),
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
rowDblClick: {
|
||||
getUrl: (row) => {
|
||||
const id = row?.cells?.[5]?.data;
|
||||
|
||||
@@ -93,7 +93,7 @@ if (config) {
|
||||
row.modified,
|
||||
row.uuid,
|
||||
]),
|
||||
actions: { enabled: false },
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
search: gridSearch,
|
||||
filterSchema,
|
||||
urlSync: true,
|
||||
|
||||
@@ -70,7 +70,7 @@ if (config) {
|
||||
row.last_error || '-',
|
||||
row.id,
|
||||
]),
|
||||
actions: { enabled: false },
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
search: gridSearch,
|
||||
filterSchema,
|
||||
urlSync: true,
|
||||
|
||||
@@ -79,9 +79,7 @@ if (config) {
|
||||
row.error_code || '',
|
||||
row.id,
|
||||
]),
|
||||
actions: {
|
||||
enabled: false,
|
||||
},
|
||||
rowInteraction: { linkColumn: 2 },
|
||||
search: gridSearch,
|
||||
filterSchema,
|
||||
rowDblClick: {
|
||||
|
||||
@@ -138,7 +138,7 @@ if (config) {
|
||||
row.created,
|
||||
row.uuid,
|
||||
]),
|
||||
actions: { enabled: false },
|
||||
rowInteraction: { linkColumn: 1 },
|
||||
search: gridSearch,
|
||||
filterSchema,
|
||||
urlSync: true,
|
||||
|
||||
@@ -50,7 +50,7 @@ if (config) {
|
||||
row.restored_at || '-',
|
||||
row.id,
|
||||
]),
|
||||
actions: { enabled: false },
|
||||
rowInteraction: { linkColumn: 2 },
|
||||
search: gridSearch,
|
||||
filterSchema,
|
||||
urlSync: true,
|
||||
|
||||
@@ -139,6 +139,7 @@ if (config) {
|
||||
rowDataset: (row) => ({
|
||||
uuid: row?.cells?.[uuidIndex]?.data,
|
||||
}),
|
||||
rowInteraction: { linkColumn: 2 },
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => {
|
||||
if (!canEditRow(rowData)) {
|
||||
|
||||
Reference in New Issue
Block a user