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

@@ -6,26 +6,40 @@ Row-Navigation discoverable und keyboard-freundlich machen.
## Standardverhalten ## Standardverhalten
1. Sichtbare Action-Spalte rechts (`Open`/`Edit`) fuer Listen mit `rowDblClick`. 1. Konfigurierbares Link-Column: Die primaere Datenspalte (z.B. Name, Beschreibung) wird als klickbarer `<a>`-Link gerendert.
2. Enter auf fokussierter Row loest dieselbe Navigation aus. 2. Enter auf fokussierter Row loest dieselbe Navigation aus.
3. Doppelklick bleibt als Fallback fuer Power-User. 3. Doppelklick bleibt als Fallback fuer Power-User.
4. Nur Rows mit Ziel-URL erhalten `data-row-open-url`. 4. Nur Rows mit Ziel-URL erhalten `data-row-open-url` und den Link im Link-Column.
5. Rows ohne URL (z.B. fehlende Berechtigung) zeigen den Inhalt als Plain-Text ohne Link.
## Contract ## Contract
```js ```js
rowInteraction: { rowInteraction: {
enabled: true, enabled: true,
showActionButton: true, linkColumn: 0, // Index der sichtbaren Datenspalte, die als Link gerendert wird (default: 0)
keepDblClick: true, keepDblClick: true, // Doppelklick-Navigation beibehalten (default: true)
enableEnterOnRow: true, enableEnterOnRow: true, // Enter-Key-Navigation auf fokussierter Row (default: true)
actionColumnLabel: 'Actions', rowEnterHint: 'Press Enter to open row'
resolveActionLabel: (url, rowData) => (url.includes('/edit/') ? 'Edit' : 'Open')
} }
``` ```
### linkColumn-Zuordnung
| Seite | linkColumn | Zielspalte |
|---|---|---|
| Users | 2 | First name (nach Avatar + hidden UUID) |
| Tenants | 1 | Description (nach Avatar) |
| Roles, Departments, Permissions | 0 | Description |
| Address book | 1 | Name (nach hidden UUID) |
| Mail log | 3 | Subject |
| Scheduled jobs | 0 | Job label |
| System audit, Import audit, User lifecycle audit | 2 | Event/Profile/Action |
| API audit | 3 | Path |
## Guardrails ## Guardrails
1. Keine globale Tab-Stop-Flut auf allen Rows. 1. Keine globale Tab-Stop-Flut auf allen Rows.
2. Fokusstil fuer Row und Action-Button sichtbar. 2. Fokusstil fuer Row und Link-Element sichtbar.
3. Explizite `actions.enabled: true` pro Seite darf Auto-Action uebersteuern. 3. Explizite `actions.enabled: true` pro Seite darf Link-Column uebersteuern (eigene Action-Spalte).
4. Link-Column darf keine Spalte mit verschachtelten `<a>`-Elementen umwickeln (z.B. Avatar mit Lightbox).

View File

@@ -3,7 +3,7 @@ import { initMultiSelectCascade } from '../../../../js/components/app-multiselec
import { initStandardListPage } from '../../../../js/core/app-grid-factory.js'; import { initStandardListPage } from '../../../../js/core/app-grid-factory.js';
import { readPageConfig } from '../../../../js/core/app-page-config.js'; import { readPageConfig } from '../../../../js/core/app-page-config.js';
import { warnOnce } from '../../../../js/core/app-dom.js'; import { warnOnce } from '../../../../js/core/app-dom.js';
import { getAppBase, escapeHtml, buildBadgeList, gridFilterMultiCsv, gridFilterText } from '../../../../js/pages/app-list-utils.js'; import { getAppBase, escapeHtml, buildBadgeList, gridFilterMultiCsv, gridFilterText, withCurrentListReturn } from '../../../../js/pages/app-list-utils.js';
const config = readPageConfig('address-book-index'); const config = readPageConfig('address-book-index');
if (config) { if (config) {
@@ -50,7 +50,7 @@ if (config) {
{ {
name: labels.name || 'Name', name: labels.name || 'Name',
sort: true, sort: true,
formatter: (cell) => { formatter: (cell, row) => {
const nameValue = typeof cell === 'object' && cell !== null const nameValue = typeof cell === 'object' && cell !== null
? (cell.name ?? '') ? (cell.name ?? '')
: cell; : cell;
@@ -58,13 +58,18 @@ if (config) {
? (cell.avatar_uuid ?? '') ? (cell.avatar_uuid ?? '')
: ''; : '';
const name = escapeHtml(nameValue || '-'); const name = escapeHtml(nameValue || '-');
const uuid = row?.cells?.[uuidIndex]?.data;
const editUrl = uuid ? withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString()) : '';
const nameHtml = editUrl
? `<a class="app-grid-link-cell" href="${escapeHtml(editUrl)}">${name}</a>`
: `<span>${name}</span>`;
if (!avatarUuid) { if (!avatarUuid) {
const initials = escapeHtml(initialsForName(nameValue)); const initials = escapeHtml(initialsForName(nameValue));
return gridjs.html(`<span class="grid-name-cell"><span class="grid-avatar grid-avatar-placeholder">${initials}</span><span>${name}</span></span>`); return gridjs.html(`<span class="grid-name-cell"><span class="grid-avatar grid-avatar-placeholder">${initials}</span>${nameHtml}</span>`);
} }
const src = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=64`, appBase).toString(); const src = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=64`, appBase).toString();
const full = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=256`, appBase).toString(); const full = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=256`, appBase).toString();
return gridjs.html(`<span class="grid-name-cell"><a data-fslightbox="address-book-avatars" href="${full}"><img class="grid-avatar" src="${src}" alt="" loading="lazy"></a><span>${name}</span></span>`); return gridjs.html(`<span class="grid-name-cell"><a data-fslightbox="address-book-avatars" href="${full}"><img class="grid-avatar" src="${src}" alt="" loading="lazy"></a>${nameHtml}</span>`);
}, },
}, },
{ name: labels.email || 'Email', sort: true }, { name: labels.email || 'Email', sort: true },
@@ -99,6 +104,7 @@ if (config) {
rowDataset: (row) => ({ rowDataset: (row) => ({
uuid: row?.cells?.[uuidIndex]?.data, uuid: row?.cells?.[uuidIndex]?.data,
}), }),
rowInteraction: { linkColumn: false },
rowDblClick: { rowDblClick: {
getUrl: (rowData) => { getUrl: (rowData) => {
const uuid = rowData?.cells?.[uuidIndex]?.data; const uuid = rowData?.cells?.[uuidIndex]?.data;

View File

@@ -175,7 +175,7 @@ class TypographyTokenContractTest extends TestCase
'--text-label' => '--text-sm', '--text-label' => '--text-sm',
'--text-caption' => '--text-xs', '--text-caption' => '--text-xs',
'--text-title' => '--text-xl', '--text-title' => '--text-xl',
'--text-page-title' => '--text-2xl', '--text-page-title' => '--text-3xl',
]; ];
foreach ($aliases as $alias => $primitive) { foreach ($aliases as $alias => $primitive) {

View File

@@ -233,16 +233,20 @@
gap: 0.35rem; gap: 0.35rem;
} }
.gridjs-container .grid-actions-row-open { .app-grid-link-cell {
justify-content: flex-end; color: inherit;
width: 100%; text-decoration: none;
} }
.gridjs-container .grid-actions .app-grid-row-open { .app-grid-link-cell:hover {
padding: 4px 10px; text-decoration: underline;
font-size: 12px; color: var(--app-primary);
line-height: 1.2; }
white-space: nowrap;
.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] { tr.gridjs-tr[data-row-open-url] {
@@ -276,10 +280,6 @@
outline: none; 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,
html[data-theme="dark"] button.gridjs-sort-neutral { html[data-theme="dark"] button.gridjs-sort-neutral {
filter: invert(1); filter: invert(1);

View File

@@ -255,28 +255,15 @@ export function createServerGrid(options) {
const rowInteractionEnabled = hasRowOpenHandler && rowInteractionConfig.enabled !== false; const rowInteractionEnabled = hasRowOpenHandler && rowInteractionConfig.enabled !== false;
const keepDblClick = rowInteractionEnabled && rowInteractionConfig.keepDblClick !== false; const keepDblClick = rowInteractionEnabled && rowInteractionConfig.keepDblClick !== false;
const enableEnterOnRow = rowInteractionEnabled && rowInteractionConfig.enableEnterOnRow !== false; const enableEnterOnRow = rowInteractionEnabled && rowInteractionConfig.enableEnterOnRow !== false;
const showRowActionButton = rowInteractionEnabled && rowInteractionConfig.showActionButton !== false; const linkColumnIndex = rowInteractionEnabled && rowInteractionConfig.linkColumn !== false
const rowActionColumnLabel = String( ? (Number.isInteger(rowInteractionConfig.linkColumn) ? rowInteractionConfig.linkColumn : 0)
rowInteractionConfig.actionColumnLabel : -1;
|| 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( const rowEnterHint = String(
rowInteractionConfig.rowEnterHint rowInteractionConfig.rowEnterHint
|| language?.actions?.rowEnterHint || language?.actions?.rowEnterHint
|| 'Press Enter to open row' || 'Press Enter to open row'
).trim() || 'Press Enter to open row'; ).trim() || 'Press Enter to open row';
const gridRetryLabel = String(language?.errorRetry || 'Retry').trim() || 'Retry'; 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) => { const resolveRowOpenUrl = (rowData, rowIndex = -1) => {
if (!rowInteractionEnabled) { if (!rowInteractionEnabled) {
return ''; return '';
@@ -467,31 +454,29 @@ export function createServerGrid(options) {
wrapperClass: actions.wrapperClass || 'grid-actions', wrapperClass: actions.wrapperClass || 'grid-actions',
onAction: typeof actions.onAction === 'function' ? actions.onAction : null onAction: typeof actions.onAction === 'function' ? actions.onAction : null
} : null; } : null;
const rowOpenActionConfig = !explicitActionConfig && showRowActionButton ? { const actionConfig = explicitActionConfig || null;
id: 'row_actions',
label: gridjsLib.html( // Link-column: wrap the target column's formatter output in an <a> for row navigation.
`<span class="app-grid-actions-header" data-tooltip="${escapeHtmlAttr(rowEnterHint)}" data-tooltip-pos="top" aria-label="${escapeHtmlAttr(rowEnterHint)}">${escapeHtmlAttr(rowActionColumnLabel)}</span>` if (linkColumnIndex >= 0 && linkColumnIndex < normalizedColumns.length) {
), const targetCol = normalizedColumns[linkColumnIndex];
index: null, const originalFormatter = targetCol.formatter || null;
items: [{ targetCol.formatter = (cell, row) => {
key: 'row-open', const rowOpenUrl = resolveRowOpenUrl(row);
type: 'link', const inner = originalFormatter ? originalFormatter(cell, row) : cell;
className: 'outline secondary app-grid-row-open', if (!rowOpenUrl) {
label: (rowData, url) => { return inner;
const dynamicLabel = resolveActionLabel(url, rowData); }
const normalizedLabel = String(dynamicLabel || '').trim(); const innerHtml = (inner && typeof inner === 'object' && typeof inner.props?.content === 'string')
return normalizedLabel || defaultResolveRowActionLabel(url); ? inner.props.content
}, : escapeHtmlAttr(String(inner ?? ''));
ariaLabel: (_rowData, _url, label) => `${String(label || '').trim()}. ${rowEnterHint}`, // Avoid nesting <a> inside <a> — if the formatter already has links, skip wrapping.
title: (_rowData, _url) => rowEnterHint, if (/<a[\s>]/i.test(innerHtml)) {
href: ({ id }) => id return inner;
}], }
// For row-open actions the URL itself is the stable row identifier. return gridjsLib.html(`<a class="app-grid-link-cell" href="${escapeHtmlAttr(rowOpenUrl)}">${innerHtml}</a>`);
getRowId: (rowData) => resolveRowOpenUrl(rowData), };
wrapperClass: 'grid-actions grid-actions-row-open', }
onAction: null
} : null;
const actionConfig = explicitActionConfig || rowOpenActionConfig;
const selectionConfig = selection && selection.enabled ? { const selectionConfig = selection && selection.enabled ? {
id: selection.id || 'selectRow', id: selection.id || 'selectRow',
index: Number.isInteger(selection.index) ? selection.index : 0, index: Number.isInteger(selection.index) ? selection.index : 0,
@@ -543,12 +528,13 @@ export function createServerGrid(options) {
const titleAttr = titleRaw ? ` title="${escapeHtmlAttr(titleRaw)}"` : ''; const titleAttr = titleRaw ? ` title="${escapeHtmlAttr(titleRaw)}"` : '';
const className = item.className ? ` ${item.className}` : ''; const className = item.className ? ` ${item.className}` : '';
const type = item.type || 'button'; const type = item.type || 'button';
const safeLabel = escapeHtmlAttr(String(label));
if (type === 'link') { if (type === 'link') {
const href = typeof item.href === 'function' ? item.href({ row, id }) : item.href; const href = typeof item.href === 'function' ? item.href({ row, id }) : item.href;
const url = href || '#'; const url = escapeHtmlAttr(href || '#');
return `<a role="button" class="${className.trim()}" data-grid-action="${item.key}" href="${url}"${ariaAttr}${titleAttr}>${label}</a>`; 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(''); }).join('');
return gridjsLib.html(`<div class="${actionConfig.wrapperClass}" role="group">${buttons}</div>`); return gridjsLib.html(`<div class="${actionConfig.wrapperClass}" role="group">${buttons}</div>`);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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