From 1c5648c72768b8c131291c50fbfb9c355b9795c3 Mon Sep 17 00:00:00 2001 From: fs Date: Tue, 24 Mar 2026 19:43:03 +0100 Subject: [PATCH] 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 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 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) --- .../references/row-interaction-standard.md | 32 +++++--- .../web/js/pages/address-book-index.js | 14 +++- .../TypographyTokenContractTest.php | 2 +- web/css/vendor-overrides/gridjs.css | 24 +++--- web/js/core/app-grid-factory.js | 74 ++++++++----------- web/js/pages/admin-api-audit-index.js | 4 +- web/js/pages/admin-departments-index.js | 2 +- web/js/pages/admin-import-audit-index.js | 2 +- web/js/pages/admin-mail-log-index.js | 4 +- web/js/pages/admin-permissions-index.js | 1 + web/js/pages/admin-roles-index.js | 2 +- web/js/pages/admin-scheduled-jobs-index.js | 2 +- web/js/pages/admin-system-audit-index.js | 4 +- web/js/pages/admin-tenants-index.js | 2 +- .../pages/admin-user-lifecycle-audit-index.js | 2 +- web/js/pages/admin-users-index.js | 1 + 16 files changed, 87 insertions(+), 85 deletions(-) diff --git a/.agents/skills/starterkit-grid-standards/references/row-interaction-standard.md b/.agents/skills/starterkit-grid-standards/references/row-interaction-standard.md index 42285de..059bdfe 100644 --- a/.agents/skills/starterkit-grid-standards/references/row-interaction-standard.md +++ b/.agents/skills/starterkit-grid-standards/references/row-interaction-standard.md @@ -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 ``-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 ``-Elementen umwickeln (z.B. Avatar mit Lightbox). diff --git a/modules/addressbook/web/js/pages/address-book-index.js b/modules/addressbook/web/js/pages/address-book-index.js index 076a61a..e2ed6ee 100644 --- a/modules/addressbook/web/js/pages/address-book-index.js +++ b/modules/addressbook/web/js/pages/address-book-index.js @@ -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 + ? `${name}` + : `${name}`; if (!avatarUuid) { const initials = escapeHtml(initialsForName(nameValue)); - return gridjs.html(`${initials}${name}`); + return gridjs.html(`${initials}${nameHtml}`); } 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(`${name}`); + return gridjs.html(`${nameHtml}`); }, }, { 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; diff --git a/tests/Architecture/TypographyTokenContractTest.php b/tests/Architecture/TypographyTokenContractTest.php index ab1d9f7..ab74edf 100644 --- a/tests/Architecture/TypographyTokenContractTest.php +++ b/tests/Architecture/TypographyTokenContractTest.php @@ -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) { diff --git a/web/css/vendor-overrides/gridjs.css b/web/css/vendor-overrides/gridjs.css index e1abb86..d85ccf5 100644 --- a/web/css/vendor-overrides/gridjs.css +++ b/web/css/vendor-overrides/gridjs.css @@ -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); diff --git a/web/js/core/app-grid-factory.js b/web/js/core/app-grid-factory.js index 37960c4..bf4908d 100644 --- a/web/js/core/app-grid-factory.js +++ b/web/js/core/app-grid-factory.js @@ -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( - `${escapeHtmlAttr(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); - }, - 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 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 inside — if the formatter already has links, skip wrapping. + if (/]/i.test(innerHtml)) { + return inner; + } + return gridjsLib.html(`${innerHtml}`); + }; + } + 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 `${label}`; + const url = escapeHtmlAttr(href || '#'); + return `${safeLabel}`; } - return ``; + return ``; }).join(''); return gridjsLib.html(`
${buttons}
`); } diff --git a/web/js/pages/admin-api-audit-index.js b/web/js/pages/admin-api-audit-index.js index 0ef1f33..7e7b9bd 100644 --- a/web/js/pages/admin-api-audit-index.js +++ b/web/js/pages/admin-api-audit-index.js @@ -117,9 +117,7 @@ if (config) { row.ip || '', row.id, ]), - actions: { - enabled: false, - }, + rowInteraction: { linkColumn: 3 }, search: gridSearch, filterSchema, urlSync: true, diff --git a/web/js/pages/admin-departments-index.js b/web/js/pages/admin-departments-index.js index 849c9b2..01ed174 100644 --- a/web/js/pages/admin-departments-index.js +++ b/web/js/pages/admin-departments-index.js @@ -81,7 +81,7 @@ if (config) { row.modified, row.uuid, ]), - actions: { enabled: false }, + rowInteraction: { linkColumn: 0 }, search: gridSearch, filterSchema, urlSync: true, diff --git a/web/js/pages/admin-import-audit-index.js b/web/js/pages/admin-import-audit-index.js index 74492a6..eb50737 100644 --- a/web/js/pages/admin-import-audit-index.js +++ b/web/js/pages/admin-import-audit-index.js @@ -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, diff --git a/web/js/pages/admin-mail-log-index.js b/web/js/pages/admin-mail-log-index.js index 39953b6..68f0f69 100644 --- a/web/js/pages/admin-mail-log-index.js +++ b/web/js/pages/admin-mail-log-index.js @@ -71,9 +71,7 @@ if (config) { row.template || '-', row.id, ]), - actions: { - enabled: false, - }, + rowInteraction: { linkColumn: 3 }, search: gridSearch, filterSchema, urlSync: true, diff --git a/web/js/pages/admin-permissions-index.js b/web/js/pages/admin-permissions-index.js index 095431c..fe4eab1 100644 --- a/web/js/pages/admin-permissions-index.js +++ b/web/js/pages/admin-permissions-index.js @@ -48,6 +48,7 @@ if (config) { row.created, row.id, ]), + rowInteraction: { linkColumn: 0 }, rowDblClick: { getUrl: (row) => { const id = row?.cells?.[5]?.data; diff --git a/web/js/pages/admin-roles-index.js b/web/js/pages/admin-roles-index.js index 7ee0b84..d9a96fe 100644 --- a/web/js/pages/admin-roles-index.js +++ b/web/js/pages/admin-roles-index.js @@ -93,7 +93,7 @@ if (config) { row.modified, row.uuid, ]), - actions: { enabled: false }, + rowInteraction: { linkColumn: 0 }, search: gridSearch, filterSchema, urlSync: true, diff --git a/web/js/pages/admin-scheduled-jobs-index.js b/web/js/pages/admin-scheduled-jobs-index.js index d27525d..a65c42a 100644 --- a/web/js/pages/admin-scheduled-jobs-index.js +++ b/web/js/pages/admin-scheduled-jobs-index.js @@ -70,7 +70,7 @@ if (config) { row.last_error || '-', row.id, ]), - actions: { enabled: false }, + rowInteraction: { linkColumn: 0 }, search: gridSearch, filterSchema, urlSync: true, diff --git a/web/js/pages/admin-system-audit-index.js b/web/js/pages/admin-system-audit-index.js index bc88d85..07007ef 100644 --- a/web/js/pages/admin-system-audit-index.js +++ b/web/js/pages/admin-system-audit-index.js @@ -79,9 +79,7 @@ if (config) { row.error_code || '', row.id, ]), - actions: { - enabled: false, - }, + rowInteraction: { linkColumn: 2 }, search: gridSearch, filterSchema, rowDblClick: { diff --git a/web/js/pages/admin-tenants-index.js b/web/js/pages/admin-tenants-index.js index 06ff328..89e0429 100644 --- a/web/js/pages/admin-tenants-index.js +++ b/web/js/pages/admin-tenants-index.js @@ -138,7 +138,7 @@ if (config) { row.created, row.uuid, ]), - actions: { enabled: false }, + rowInteraction: { linkColumn: 1 }, search: gridSearch, filterSchema, urlSync: true, diff --git a/web/js/pages/admin-user-lifecycle-audit-index.js b/web/js/pages/admin-user-lifecycle-audit-index.js index 594d4fc..3f5eebe 100644 --- a/web/js/pages/admin-user-lifecycle-audit-index.js +++ b/web/js/pages/admin-user-lifecycle-audit-index.js @@ -50,7 +50,7 @@ if (config) { row.restored_at || '-', row.id, ]), - actions: { enabled: false }, + rowInteraction: { linkColumn: 2 }, search: gridSearch, filterSchema, urlSync: true, diff --git a/web/js/pages/admin-users-index.js b/web/js/pages/admin-users-index.js index fcd491f..0c81816 100644 --- a/web/js/pages/admin-users-index.js +++ b/web/js/pages/admin-users-index.js @@ -139,6 +139,7 @@ if (config) { rowDataset: (row) => ({ uuid: row?.cells?.[uuidIndex]?.data, }), + rowInteraction: { linkColumn: 2 }, rowDblClick: { getUrl: (rowData) => { if (!canEditRow(rowData)) {