1
0

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
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.
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
```js
rowInteraction: {
enabled: true,
showActionButton: true,
keepDblClick: true,
enableEnterOnRow: true,
actionColumnLabel: 'Actions',
resolveActionLabel: (url, rowData) => (url.includes('/edit/') ? 'Edit' : 'Open')
linkColumn: 0, // Index der sichtbaren Datenspalte, die als Link gerendert wird (default: 0)
keepDblClick: true, // Doppelklick-Navigation beibehalten (default: true)
enableEnterOnRow: true, // Enter-Key-Navigation auf fokussierter Row (default: true)
rowEnterHint: 'Press Enter to open row'
}
```
### 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
1. Keine globale Tab-Stop-Flut auf allen Rows.
2. Fokusstil fuer Row und Action-Button sichtbar.
3. Explizite `actions.enabled: true` pro Seite darf Auto-Action uebersteuern.
2. Fokusstil fuer Row und Link-Element sichtbar.
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 { readPageConfig } from '../../../../js/core/app-page-config.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');
if (config) {
@@ -50,7 +50,7 @@ if (config) {
{
name: labels.name || 'Name',
sort: true,
formatter: (cell) => {
formatter: (cell, row) => {
const nameValue = typeof cell === 'object' && cell !== null
? (cell.name ?? '')
: cell;
@@ -58,13 +58,18 @@ if (config) {
? (cell.avatar_uuid ?? '')
: '';
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) {
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 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 },
@@ -99,6 +104,7 @@ if (config) {
rowDataset: (row) => ({
uuid: row?.cells?.[uuidIndex]?.data,
}),
rowInteraction: { linkColumn: false },
rowDblClick: {
getUrl: (rowData) => {
const uuid = rowData?.cells?.[uuidIndex]?.data;

View File

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

View File

@@ -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);

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)) {