Table: Display alt text in image cell when image fails to load (#126904)

* chore(gdev): add panel to Table kitchen sink for image cell

* test(table): add tests for ImageCell sub-component, TDD, bug test fails

* fix(table): display alt text in ImageCell when image does not load

---------

Co-authored-by: Paul Marbach <paul.marbach@grafana.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jesse David Peterson 2026-06-26 13:34:33 -03:00 committed by GitHub
parent 1f8863d817
commit 26ce105050
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 244 additions and 1 deletions

View file

@ -1057,6 +1057,166 @@
}
}
},
"panel-13": {
"kind": "Panel",
"spec": {
"id": 13,
"title": "Image cell table",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "grafana-testdata-datasource",
"version": "v0",
"spec": {
"csvContent": "img_loaded,img_loaded_title,img_broken_alt,img_broken_noalt\nhttps://grafana.com/media/menus/products/grafana-menu-icon.svg,https://grafana.com/media/menus/products/grafana-menu-icon.svg,https://example.com/does-not-exist.png,https://example.com/does-not-exist.png",
"scenarioId": "csv_content"
}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "table",
"version": "12.2.0",
"spec": {
"options": {
"cellHeight": "sm",
"showHeader": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "img_loaded"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"type": "image"
}
},
{
"id": "displayName",
"value": "Image (loaded)"
},
{
"id": "custom.width",
"value": 140
}
]
},
{
"matcher": {
"id": "byName",
"options": "img_loaded_title"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"title": "Grafana logo",
"type": "image"
}
},
{
"id": "displayName",
"value": "Image (loaded, with title)"
},
{
"id": "custom.width",
"value": 180
}
]
},
{
"matcher": {
"id": "byName",
"options": "img_broken_alt"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"alt": "Image failed to load",
"type": "image"
}
},
{
"id": "displayName",
"value": "Image (broken, with alt)"
},
{
"id": "custom.width",
"value": 200
}
]
},
{
"matcher": {
"id": "byName",
"options": "img_broken_noalt"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"type": "image"
}
},
{
"id": "displayName",
"value": "Image (broken, no alt)"
},
{
"id": "custom.width",
"value": 200
}
]
}
]
}
}
}
}
},
"panel-2": {
"kind": "Panel",
"spec": {
@ -2629,6 +2789,19 @@
"name": "panel-12"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 31,
"width": 12,
"height": 7,
"element": {
"kind": "ElementReference",
"name": "panel-13"
}
}
}
]
}

View file

@ -0,0 +1,70 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { type Field, FieldType } from '@grafana/data';
import { TableCellDisplayMode } from '../../types';
import { ImageCell } from './ImageCell';
const makeField = (url: string): Field => ({
name: 'image',
type: FieldType.string,
values: [url],
config: {},
display: () => ({ text: url, numeric: 0, color: undefined }),
});
const baseCellOptions = {
type: TableCellDisplayMode.Image as const,
};
describe('ImageCell', () => {
it('renders an img element with the correct src when the URL is valid', () => {
const url = 'https://example.com/image.png';
render(<ImageCell cellOptions={baseCellOptions} field={makeField(url)} value={url} rowIdx={0} />);
const img = screen.getByRole('img');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', url);
});
it('renders the configured alt text on the img element', () => {
const url = 'https://example.com/image.png';
const cellOptions = { ...baseCellOptions, alt: 'My alt text' };
render(<ImageCell cellOptions={cellOptions} field={makeField(url)} value={url} rowIdx={0} />);
expect(screen.getByRole('img')).toHaveAttribute('alt', 'My alt text');
});
it('renders configured alt text instead of the raw URL when the image fails to load', () => {
const url = 'https://example.com/broken.png';
const cellOptions = { ...baseCellOptions, alt: 'Broken image alt' };
render(<ImageCell cellOptions={cellOptions} field={makeField(url)} value={url} rowIdx={0} />);
fireEvent.error(screen.getByRole('img'));
expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(screen.getByText('Broken image alt')).toBeInTheDocument();
expect(screen.queryByText(url)).not.toBeInTheDocument();
});
it('falls back to the raw URL when the image fails to load and no alt text is configured', () => {
const url = 'https://example.com/broken.png';
render(<ImageCell cellOptions={baseCellOptions} field={makeField(url)} value={url} rowIdx={0} />);
fireEvent.error(screen.getByRole('img'));
expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(screen.getByText(url)).toBeInTheDocument();
});
it('renders nothing when the field display text is empty', () => {
const field: Field = {
name: 'image',
type: FieldType.string,
values: [''],
config: {},
display: () => ({ text: '', numeric: 0, color: undefined }),
};
const { container } = render(<ImageCell cellOptions={baseCellOptions} field={field} value="" rowIdx={0} />);
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -17,7 +17,7 @@ export const ImageCell = ({ cellOptions, field, value, rowIdx }: ImageCellProps)
return (
<MaybeWrapWithLink field={field} rowIdx={rowIdx}>
{error ? text : <img alt={alt} src={text} title={title} onError={() => setError(true)} />}
{error ? (alt ?? text) : <img alt={alt} src={text} title={title} onError={() => setError(true)} />}
</MaybeWrapWithLink>
);
};