Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,21 @@ mosaic and tree list modes, global search, the tab menu and four dashboard block

### Fixed

- The identifier column of a list took a share of the spare width like any other (2026-10-27):
the automatic table layout hands the surplus out in proportion to what each column holds, so a
list with few columns in a wide card gave its one- to five-digit ids a few hundred pixels of empty
space (176px for the demo's category list at 1920px; recomaty-panel's Ping and Status lists,
among others). The column is now six characters wide, and widens only for an identifier that
needs more: a longer number, a UUID or an ObjectId, kept on one line, or a longer header label.
On a list wider than its card, where every column is squeezed to its smallest, it keeps its six
characters. `base_list_field` marks the cells of a column that shows the model's identifier (a
field named in `identifierFieldNames()`, `id` on a Doctrine entity, whatever its type) with the
new additive hook `adminata-list-field-id`, and `base_list` its header with
`adminata-list-field-header-id`; `components/list.css` gives both `width` and `min-width: 6ch`
(`content-box`, so the padding comes on top) and `nowrap`. Both hooks join the list group of
`tests-adminata/Contract/hooks.yaml`. `tests-adminata/Visual/list-id-column.spec.js` measures the
column in Chromium, Firefox and WebKit, `DemoSmokeTest` pins the hooks, and the product,
category and tag list baselines are regenerated. Reported in recomaty-panel#677.
- A multiple select showed what was chosen only while it had focus, in the dark theme
(2026-10-26). `.dark .adm-select option` paints every option of a dark select gray-900, and in
a list box (`<select multiple>`) that rule also covered the colour the browser gives a selected
Expand Down
24 changes: 24 additions & 0 deletions assets/css/components/list.css
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,30 @@
white-space: nowrap;
}

/*
* The identifier column (recomaty-panel#677) is six characters wide and widens only for an
* identifier that needs more: a seven-digit number, a UUID, a MongoDB ObjectId, a header label
* longer than the values. It never takes a share of the surplus. Left to the automatic layout,
* a one- to five-digit id column got its share like every other, a few hundred pixels of empty
* space beside the number on a list with few columns.
*
* Both lengths are needed. `width` makes the column a fixed one, and the automatic layout
* hands the surplus to the columns without a width first, so the rest of the list still fills
* the card; but a `width` raises only the column's largest size, and a list wider than its
* card squeezes every column to its smallest, which `min-width` holds at six characters too.
* Neither caps the column: a cell whose content is wider widens it, and `nowrap` keeps that
* content on one line (a UUID breaks at its hyphens otherwise). `content-box`, so the six
* characters are the text's and the cell's padding comes on top whatever `box-sizing` the page
* sets; `ch` is the width of a "0" in the cell's own font.
*/
.adm-table th.adminata-list-field-header-id,
.adm-table td.adminata-list-field-id {
box-sizing: content-box;
width: 6ch;
min-width: 6ch;
white-space: nowrap;
}

/*
* A row that opens its object on click says so with the cursor. The class is only on rows the
* `adminata-row-link` controller has a destination for, so a row whose default action the
Expand Down
2 changes: 1 addition & 1 deletion src/Resources/public/app.css

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion src/Resources/views/CRUD/base_list.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ file that was distributed with this source code.
`field_cell_class` — and the `list_legend` block under the table, which is what names the
glyphs. A comment nearer either one would land inside the header loop and print itself as
whitespace on every column.

The same goes for `adminata-list-field-header-id`, on the header of a column that shows the
model's identifier (one of `identifierFieldNames()`, `id` on a Doctrine entity), whose cells
`base_list_field` marks `adminata-list-field-id`: `components/list.css` sizes that column for
six characters and lets it widen only for an identifier that needs more (recomaty-panel#677).
#}

{% extends base_template %}
Expand Down Expand Up @@ -89,6 +94,7 @@ file that was distributed with this source code.
{% block table %}
<table class="adm-table adminata-list">
{% block table_header %}
{% set identifier_names = admin.modelManager.identifierFieldNames(admin.class) -%}
<thead>
<tr class="adminata-list-field-header">
{% for field_description in admin.list.getElements() %}
Expand Down Expand Up @@ -118,7 +124,7 @@ file that was distributed with this source code.
{% set sort_by = current ? admin.datagrid.values[constant('IDCT\\Adminata\\Datagrid\\DatagridInterface::SORT_ORDER')] : field_description.option(constant('IDCT\\Adminata\\Datagrid\\DatagridInterface::SORT_ORDER'), sort_parameters.filter[constant('IDCT\\Adminata\\Datagrid\\DatagridInterface::SORT_ORDER')]) %}
{% endif %}

<th class="adminata-list-field-header-{{ field_description.type }}{% if sortable %} adminata-list-field-header-order-{{ sort_by|lower }} {{ sort_active_class }}{% endif %}{% if field_description.option('header_class') %} {{ field_description.option('header_class') }}{% endif %}{% if field_description.type == constant('IDCT\\Adminata\\FieldDescription\\FieldDescriptionInterface::TYPE_ICON_ENUM') %} text-center{% endif %}"{% if field_description.option('header_style') %} style="{{ field_description.option('header_style') }}"{% endif %}{% if sortable and current %} aria-sort="{{ 'asc' == sort_by|lower ? 'descending' : 'ascending' }}"{% endif %}>
<th class="adminata-list-field-header-{{ field_description.type }}{% if sortable %} adminata-list-field-header-order-{{ sort_by|lower }} {{ sort_active_class }}{% endif %}{% if field_description.option('header_class') %} {{ field_description.option('header_class') }}{% endif %}{% if field_description.type == constant('IDCT\\Adminata\\FieldDescription\\FieldDescriptionInterface::TYPE_ICON_ENUM') %} text-center{% endif %}{% if field_description.name in identifier_names %} adminata-list-field-header-id{% endif %}"{% if field_description.option('header_style') %} style="{{ field_description.option('header_style') }}"{% endif %}{% if sortable and current %} aria-sort="{{ 'asc' == sort_by|lower ? 'descending' : 'ascending' }}"{% endif %}>
{% if sortable %}
<a class="inline-flex items-center gap-1" href="{{ admin.generateUrl(action|default('list'), sort_parameters|merge({_list_mode: admin.getListMode()})) }}">
{% endif %}
Expand Down
16 changes: 12 additions & 4 deletions src/Resources/views/CRUD/base_list_field.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ file that was distributed with this source code.
The cell envelope (PLAN/01 Q1).

`<td class="adminata-list-field adminata-list-field-{type}" objectId="…">` is **byte-identical**
to upstream's, `row_align` included: an application's cell templates extend this file, and its
XHR accordion parses the attributes out of the response. Padding and typography come from CSS on
`.adm-table`, not from anything here.
to upstream's, `row_align` included, but for the identifier column's one additive class below:
an application's cell templates extend this file, and its XHR accordion parses the attributes
out of the response. Padding and typography come from CSS on `.adm-table`, not from anything
here.

The x-editable `<span>` is rendered only for a field that is actually `editable` — the same
condition upstream used. adminata ships no editing controller in 1.0, so the span is inert
Expand All @@ -24,9 +25,16 @@ file that was distributed with this source code.
`field_cell_class` is additive (PLAN/02 §4): a cell template that extends this envelope and
needs a class on the `<td>` itself — the icon-enum cell centres its square — overrides it
rather than writing its own `<td>`, so the hooks and `objectId` stay this file's.

`adminata-list-field-id` is additive too, and the one difference from upstream's envelope: it
marks the cells of a column that shows the model's identifier (one of `identifierFieldNames()`,
`id` on a Doctrine entity), whatever the field's type, so that `components/list.css` can size
that column for six characters and let it widen only for an identifier that needs more
(recomaty-panel#677). It sits outside `field_cell_class`, so a cell template overriding that
block keeps it.
#}

<td class="adminata-list-field adminata-list-field-{{ field_description.type }}{% block field_cell_class %}{% endblock %}" objectId="{{ admin.id(object) }}"{% if field_description.option('row_align') %} style="text-align:{{ field_description.option('row_align') }}"{% endif %}>
<td class="adminata-list-field adminata-list-field-{{ field_description.type }}{% if field_description.name in admin.modelManager.identifierFieldNames(admin.class) %} adminata-list-field-id{% endif %}{% block field_cell_class %}{% endblock %}" objectId="{{ admin.id(object) }}"{% if field_description.option('row_align') %} style="text-align:{{ field_description.option('row_align') }}"{% endif %}>
{% set route_name = field_description.option('route').name|default(adminata_config.getOption('default_admin_route')) %}
{% set route_parameters = field_description.option('route').parameters|default([]) %}

Expand Down
3 changes: 3 additions & 0 deletions tests-adminata/Contract/hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ list:
- adminata-list
- adminata-list-field
- adminata-link-identifier
# Additive (recomaty-panel#677): the header and the cells of the identifier column.
- adminata-list-field-header-id
- adminata-list-field-id
- objectId
- list_batch_checkbox
- all_elements
Expand Down
36 changes: 36 additions & 0 deletions tests-adminata/Functional/DemoSmokeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,42 @@ public function testTheListRendersEveryCellType(): void
);
}

/**
* The column of the model's identifier is marked, on its header and on each of its cells, so
* that `components/list.css` can size it for six characters (recomaty-panel#677), and no other
* column is. Both lists map `id` as an `integer`.
*/
public function testTheIdentifierColumnIsMarked(): void
{
$client = self::browser();

foreach (['/admin/tests/app/product/list', '/admin/tests/app/tag/list'] as $path) {
$crawler = $client->request('GET', $path);
static::assertResponseIsSuccessful();

$rows = $crawler->filter('table.adminata-list tbody tr')->count();
static::assertGreaterThan(0, $rows, $path);

$header = $crawler->filter('table.adminata-list .adminata-list-field-header-id');
static::assertCount(1, $header, $path);
static::assertSame('th', $header->nodeName(), $path);
static::assertSame('Id', trim($header->text()), $path);

static::assertCount($rows, $crawler->filter('table.adminata-list .adminata-list-field-id'), $path);
static::assertCount(
$rows,
$crawler->filter('table.adminata-list tbody td.adminata-list-field-integer.adminata-list-field-id'),
$path
);
}

// The tag list has no batch column: the id column is the first one, in the header and in
// each of its four rows.
$crawler = $client->request('GET', '/admin/tests/app/tag/list');
static::assertCount(1, $crawler->filter('table.adminata-list thead th:first-child.adminata-list-field-header-id'));
static::assertCount(4, $crawler->filter('table.adminata-list tbody tr > td:first-child.adminata-list-field-id'));
}

/**
* The `templates.list` override adds to `list_after_table` and changes nothing else.
*/
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
160 changes: 160 additions & 0 deletions tests-adminata/Visual/list-id-column.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/*
* This file is part of the adminata package.
*
* (c) IDCT Bartosz Pachołek <bartosz@idct.tech>
*
* Forked from the Sonata Project
* (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { expect, test } from '@playwright/test';

import { open } from './support/demo.js';

/*
* The identifier column is six characters wide and widens only for an identifier that needs more
* (recomaty-panel#677).
*
* Left to the automatic table layout, the id column took its share of the surplus like every other
* column: on the category list, five columns in a 1580px card, the column of a one-digit id was
* 176px wide. `components/list.css` gives the cells `base_list_field` marks
* `adminata-list-field-id` a width and a minimum width of `6ch`, so the column is the width of
* "000000" in the cell's font plus its padding, whatever else the list holds, and a longer
* identifier widens it on one line.
*
* Layout, so each engine's wide project only, in all three: each one implements the automatic
* table layout on its own.
*/

const CATEGORIES = '/admin/tests/app/category/list';
const PRODUCTS = '/admin/tests/app/product/list';

/**
* The first id cell of the list: its content box, the width `6ch` has in its font, the width and
* the number of lines of its text, and the table against the scrolling box it sits in.
*/
async function idColumn(page) {
return page.evaluate(() => {
const cell = document.querySelector('table.adminata-list tbody td.adminata-list-field-id');
const style = getComputedStyle(cell);

const probe = document.createElement('span');
probe.style.cssText = 'position:absolute;display:inline-block;width:6ch;visibility:hidden';
for (const property of ['font-family', 'font-size', 'font-weight', 'font-style', 'font-stretch']) {
probe.style.setProperty(property, style.getPropertyValue(property));
}
document.body.append(probe);
const sixCh = probe.getBoundingClientRect().width;
probe.remove();

const range = document.createRange();
range.selectNodeContents(cell);
const lines = new Set(
Array.from(range.getClientRects())
.filter((rect) => rect.width > 0)
.map((rect) => Math.round(rect.top)),
).size;

return {
content:
cell.getBoundingClientRect().width -
parseFloat(style.paddingLeft) -
parseFloat(style.paddingRight),
sixCh,
text: range.getBoundingClientRect().width,
lines,
table: cell.closest('table').getBoundingClientRect().width,
box: cell.closest('.adm-table-scroll').clientWidth,
};
});
}

/** Puts `text` into every id cell, the way a list of longer identifiers renders them. */
async function writeIds(page, text) {
await page.evaluate((value) => {
for (const cell of document.querySelectorAll('table.adminata-list tbody td.adminata-list-field-id')) {
cell.textContent = value;
}
}, text);
}

/** Layout, so one engine's wide project each. */
function wideOnly(testInfo) {
test.skip(!testInfo.project.name.endsWith('-wide'), 'Layout: one viewport per engine.');
}

test.describe('list id column on a list narrower than its card', () => {
// The card of a 1920px window, where the category list's one-digit id column was 176px wide;
// at 1280px the share it got happened to be about six characters.
test.use({ viewport: { width: 1920, height: 1080 } });

test.beforeEach(async ({ page }, testInfo) => {
wideOnly(testInfo);
await open(page, CATEGORIES);
});

test('takes six characters and leaves the surplus to the other columns', async ({ page }) => {
const column = await idColumn(page);

// The card is wider than the list needs, and the table still fills it…
expect(Math.abs(column.table - column.box), JSON.stringify(column)).toBeLessThan(1);
// …but none of the surplus reached the id column: its content box is six characters.
expect(Math.abs(column.content - column.sixCh), JSON.stringify(column)).toBeLessThan(1);
});
});

test.describe('list id column with longer identifiers', () => {
// At 1280px, where the category list's surplus no longer covers a UUID: left to the automatic
// layout, Chromium and WebKit broke it at a hyphen.
test.beforeEach(async ({ page }, testInfo) => {
wideOnly(testInfo);
await open(page, CATEGORIES);
});

for (const [kind, id] of [
['a ten-digit number', '1234567890'],
['a UUID', '0b8f6c2e-3d41-4a7b-9e5f-1c2d3e4f5a6b'],
]) {
test(`widens on one line for ${kind}`, async ({ page }) => {
await writeIds(page, id);

const column = await idColumn(page);

expect(column.lines, JSON.stringify(column)).toBe(1);
expect(column.content, JSON.stringify(column)).toBeGreaterThan(column.sixCh);
expect(column.content + 1, JSON.stringify(column)).toBeGreaterThanOrEqual(column.text);
});
}
});

test.describe('list id column on a list wider than its card', () => {
test.beforeEach(async ({ page }, testInfo) => {
wideOnly(testInfo);
await open(page, PRODUCTS);
});

test('is marked on its header and on every cell, and nowhere else', async ({ page }) => {
const header = page.locator('table.adminata-list thead th.adminata-list-field-header-id');
await expect(header).toHaveCount(1);
await expect(header).toHaveText('Id');
await expect(page.locator('table.adminata-list .adminata-list-field-header-id')).toHaveCount(1);

const rows = await page.locator('table.adminata-list tbody tr').count();
const cells = page.locator('table.adminata-list tbody td.adminata-list-field-id');
await expect(cells).toHaveCount(rows);
// The `integer` column the admin maps `id` to, and no other.
await expect(cells.and(page.locator('td.adminata-list-field-integer'))).toHaveCount(rows);
await expect(page.locator('table.adminata-list .adminata-list-field-id')).toHaveCount(rows);
});

test('keeps six characters while every column is squeezed', async ({ page }) => {
const column = await idColumn(page);

// The columns do not fit, so each one is at its smallest; the id column's is six characters.
expect(column.table, JSON.stringify(column)).toBeGreaterThan(column.box);
expect(Math.abs(column.content - column.sixCh), JSON.stringify(column)).toBeLessThan(1);
});
});
Loading
Loading