Add React content type for field formatters#254843
Add React content type for field formatters#254843kertal wants to merge 11 commits intoelastic:mainfrom
Conversation
Introduce a third content type ('react') alongside 'html' and 'text'
that returns ReactNode instead of strings. This enables formatters to
produce native JSX, eliminating dangerouslySetInnerHTML in consumers.
Core changes:
- Add ReactContextTypeConvert, ReactContextTypeOptions types
- Add FieldFormatsContentType 'react' union member
- Create react_content_type.tsx with setup(), checkForMissingValueReact()
- Add convert()/getConverterFor() overloads for type-safe 'react' usage
- Add reactConvert property to FieldFormat base class
- HTML fallback: formatters without reactConvert delegate to HTML output,
decoding entities for tag-free strings, using dangerouslySetInnerHTML
for strings containing HTML tags
- Export new types and utilities from common/index.ts
Co-authored-by: Cursor <cursoragent@cursor.com>
Implement reactConvert for the String formatter that returns native React elements instead of HTML strings: - Missing value handling via checkForMissingValueReact - Highlight support via new getHighlightReact() utility that produces <mark> elements instead of HTML string concatenation - Falls back to textConvert for plain values Add getHighlightReact() — a React-native equivalent of getHighlightHtml() that uses null-byte markers for iterative replacement, then parses into React nodes with <mark> elements. No HTML escaping needed since React handles XSS natively. Includes comprehensive tests for both getHighlightReact and StringFormat.reactConvert covering highlights, missing values, and XSS. Co-authored-by: Cursor <cursoragent@cursor.com>
Implement reactConvert for the Boolean formatter: - Missing value handling via checkForMissingValueReact - Delegates to textConvert for the actual true/false label Co-authored-by: Cursor <cursoragent@cursor.com>
Rename url.ts → url.tsx to support JSX and implement reactConvert: - Missing value handling via checkForMissingValueReact - Returns native <a> and <img> elements instead of HTML strings - Preserves link target, rel attributes, and image dimensions - Falls back to textConvert for plain text output Co-authored-by: Cursor <cursoragent@cursor.com>
Implement reactConvert for the Color formatter: - Returns styled <span> elements with inline color/background-color - Missing value handling via checkForMissingValueReact - Falls back to plain text when no color rule matches Add tests verifying rendered DOM structure and plain text fallback. Co-authored-by: Cursor <cursoragent@cursor.com>
…kupFormat NumeralFormat (Bytes, Percent, Currency, Number): - Implement reactConvert with missing value handling - Delegate to textConvert for formatted numeric output DateNanosFormat: - Implement reactConvert with missing value handling - Delegate to textConvert for formatted date output StaticLookupFormat: - Fix textConvert to use explicit null-checks instead of || chain, preventing boolean false from being treated as "no match" - Add null guard so null values return '' instead of the string 'null' Co-authored-by: Cursor <cursoragent@cursor.com>
Update AggsTermsFieldFormat to forward 'react' type to the underlying formatter instead of mapping it to 'text'. This allows styled React output (e.g. colored spans from ColorFormat) to flow through aggregation formatting in Lens datatables. AggsMultiTermsFieldFormat continues mapping 'react' to 'text' since join() requires string values. Co-authored-by: Cursor <cursoragent@cursor.com>
Replace dangerouslySetInnerHTML with direct ReactNode rendering across the Discover ecosystem: kbn-discover-utils: - Add formatFieldValueReact() returning ReactNode via 'react' type - Add FormattedFieldValue component for React-safe rendering - Update formatHit/getFormattedFields to support React output kbn-unified-data-table: - Switch cell rendering from format(..., 'html') + innerHTML to format(..., 'react') + direct children - Remove dangerouslySetInnerHTML from source_document, cell values unified_doc_viewer: - Doc viewer table: use formattedAsReact instead of formattedAsHtml - TableFieldValue: render children instead of dangerouslySetInnerHTML - FormattedValue: accept ReactNode instead of HTML string - Content breakdown, logs overview, traces: render React children - Update all related tests to pass JSX instead of HTML strings Co-authored-by: Cursor <cursoragent@cursor.com>
Switch from format(..., 'html') + dangerouslySetInnerHTML to format(..., 'react') + direct ReactNode rendering across: - Lens datatable cell rendering - Maps tooltip property display - Table vis cell (vis_types/table) - Legacy metric expression format utility - TSVB field formatter - Data view field editor: format samples, preview, image modal - Controls plugin: restrict DataControlFieldFormatter to string types - Workflows management index form Update optimizer bundle size limits to reflect the new react_content_type module inclusion. Co-authored-by: Cursor <cursoragent@cursor.com>
|
🤖 Jobs for this PR can be triggered through checkboxes. 🚧
ℹ️ To trigger the CI, please tick the checkbox below 👇
|
There was a problem hiding this comment.
Pull request overview
Introduce a new 'react' content type for field formatters so consumers can render formatted values as ReactNode (avoiding dangerouslySetInnerHTML), while keeping fallback behavior for legacy HTML-formatters.
Changes:
- Add core
'react'content type plumbing (convert/getConverterForoverloads, react content type setup + fallback wrapper). - Update multiple UI consumers (Lens, Maps, unified doc viewer, unified data table, data view field editor) to render React nodes directly.
- Add React-native highlight utilities (
getHighlightReact) and extend formatters (String/Url/Color/etc.) withreactConvert.
Reviewed changes
Copilot reviewed 64 out of 64 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| x-pack/platform/plugins/shared/maps/public/classes/tooltips/es_tooltip_property.tsx | Switch tooltip formatting to convert(..., 'react') to avoid HTML injection. |
| x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/cell_value.tsx | Render formatted cell content as ReactNode instead of dangerouslySetInnerHTML. |
| src/platform/plugins/shared/workflows_management/public/features/run_workflow/ui/workflow_execute_index_form.tsx | Use formatHitReact and render formatted values directly. |
| src/platform/plugins/shared/vis_types/timeseries/public/application/components/lib/create_field_formatter.ts | Default to 'text' when no context type is provided. |
| src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/highlight_field/index.tsx | Update highlight field to accept/render ReactNode formatted values. |
| src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/highlight_field/highlight_field.test.tsx | Update tests to pass React nodes instead of HTML strings. |
| src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/about/field_configurations.tsx | Adjust formatter signatures to accept React formatted values. |
| src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/table_cell_value.tsx | Render table cell values as React children. |
| src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/table_cell.tsx | Feed formattedAsReact into table UI. |
| src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/field_row.ts | Add lazy formattedAsReact using formatFieldValueReact. |
| src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/content_breakdown/content_breakdown.tsx | Remove HTML injection path; render fallback content as text children. |
| src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/content_breakdown/content_breakdown.test.tsx | Update EuiCodeBlock mock to assert children rendering. |
| src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/badges/event_type.tsx | Render event type value directly (no dangerouslySetInnerHTML). |
| src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/logs_overview_highlights.tsx | Adjust formatter signatures for React formatted values. |
| src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/logs_overview.test.tsx | Update EuiCodeBlock mock to React children. |
| src/platform/plugins/shared/unified_doc_viewer/public/components/content_framework/table/table.test.tsx | Mock getFormattedFieldsReact for table tests. |
| src/platform/plugins/shared/unified_doc_viewer/public/components/content_framework/table/index.tsx | Use getFormattedFieldsReact and accept React formatted values. |
| src/platform/plugins/shared/unified_doc_viewer/public/components/content_framework/table/components/formatted_value.tsx | Render formatted values as React children. |
| src/platform/plugins/shared/unified_doc_viewer/public/components/content_framework/table/components/formatted_value.test.tsx | Update tests for ReactNode rendering. |
| src/platform/plugins/shared/field_formats/common/utils/index.ts | Export getHighlightReact from shared utils. |
| src/platform/plugins/shared/field_formats/common/utils/highlight/index.ts | Export new React highlight helper. |
| src/platform/plugins/shared/field_formats/common/utils/highlight/highlight_react.tsx | Implement React-based highlight rendering with <mark>. |
| src/platform/plugins/shared/field_formats/common/utils/highlight/highlight_react.test.tsx | Add tests for React highlight logic. |
| src/platform/plugins/shared/field_formats/common/types.ts | Add 'react' to content types + React converter types/options. |
| src/platform/plugins/shared/field_formats/common/index.ts | Export react content type symbols and React option/convert types. |
| src/platform/plugins/shared/field_formats/common/field_format.ts | Add reactConvert, react content type setup, and type-safe overloads. |
| src/platform/plugins/shared/field_formats/common/converters/url.tsx | Add reactConvert returning native <a>, <img>, <audio>. |
| src/platform/plugins/shared/field_formats/common/converters/string.ts | Add reactConvert and React highlighting for strings. |
| src/platform/plugins/shared/field_formats/common/converters/string.test.ts | Add react content type tests for StringFormat. |
| src/platform/plugins/shared/field_formats/common/converters/static_lookup.ts | Fix null/empty handling when mapping lookup entries. |
| src/platform/plugins/shared/field_formats/common/converters/numeral.ts | Add reactConvert + missing value handling. |
| src/platform/plugins/shared/field_formats/common/converters/date_nanos_shared.ts | Add reactConvert + missing value handling. |
| src/platform/plugins/shared/field_formats/common/converters/color.tsx | Add reactConvert producing styled <span>. |
| src/platform/plugins/shared/field_formats/common/converters/color.test.ts | Add react content type tests for ColorFormat. |
| src/platform/plugins/shared/field_formats/common/converters/boolean.ts | Add reactConvert + missing value handling. |
| src/platform/plugins/shared/field_formats/common/content_types/react_content_type.tsx | Add react content type setup + HTML fallback wrapper. |
| src/platform/plugins/shared/field_formats/common/content_types/index.ts | Export react content type setup and helpers. |
| src/platform/plugins/shared/data_view_field_editor/public/components/preview/types.ts | Change preview formatted value type to ReactNode. |
| src/platform/plugins/shared/data_view_field_editor/public/components/preview/preview_controller.tsx | Use 'react' converters for preview formatting. |
| src/platform/plugins/shared/data_view_field_editor/public/components/preview/image_preview_modal.tsx | Render image preview content as React nodes. |
| src/platform/plugins/shared/data_view_field_editor/public/components/preview/field_list/field_list_item.tsx | Render formatted values directly; update image detection. |
| src/platform/plugins/shared/data_view_field_editor/public/components/preview/field_list/field_list.tsx | Use 'react' formatting for preview list. |
| src/platform/plugins/shared/data_view_field_editor/public/components/field_format_editor/types.ts | Change sample output type to ReactNode. |
| src/platform/plugins/shared/data_view_field_editor/public/components/field_format_editor/samples/samples.tsx | Render sample output as ReactNode directly. |
| src/platform/plugins/shared/data_view_field_editor/public/components/field_format_editor/editors/url/url.tsx | Switch URL sample converter type to 'react'. |
| src/platform/plugins/shared/data_view_field_editor/public/components/field_format_editor/editors/default/default.tsx | Update sample converter signature to return ReactNode. |
| src/platform/plugins/shared/data/common/search/aggs/utils/get_aggs_formats.ts | Forward 'react' through aggs field format wrapper(s). |
| src/platform/plugins/shared/controls/public/controls/data_controls/types.ts | Narrow allowed formatter types for controls (string-based). |
| src/platform/plugins/shared/chart_expressions/expression_legacy_metric/public/utils/format.ts | Constrain format type to `'html' |
| src/platform/plugins/private/vis_types/table/public/components/table_vis_cell.tsx | Render formatted values as React children for table vis cells. |
| src/platform/plugins/private/vis_types/table/public/components/table_vis_cell.test.tsx | Update expected convert call to 'react'. |
| src/platform/plugins/private/vis_types/table/public/components/snapshots/table_vis_cell.test.tsx.snap | Update snapshot after removing HTML injection. |
| src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx | Use formatFieldValueReact and render react children. |
| src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx | Update inline snapshots for React children rendering. |
| src/platform/packages/shared/kbn-unified-data-table/src/components/source_document.tsx | Use formatHitReact and React rendering for source doc values. |
| src/platform/packages/shared/kbn-unified-data-table/src/components/compare_documents/hooks/use_comparison_cell_value.tsx | Use formatFieldValueReact to avoid HTML injection. |
| src/platform/packages/shared/kbn-discover-utils/src/utils/get_formatted_fields.ts | Add getFormattedFieldsReact. |
| src/platform/packages/shared/kbn-discover-utils/src/utils/format_value.ts | Add formatFieldValueReact. |
| src/platform/packages/shared/kbn-discover-utils/src/utils/format_hit.ts | Add formatHitReact + caching. |
| src/platform/packages/shared/kbn-discover-utils/src/types.ts | Add FormattedHitReact type. |
| src/platform/packages/shared/kbn-discover-utils/src/index.ts | Export FormattedFieldValue. |
| src/platform/packages/shared/kbn-discover-utils/src/components/formatted_field_value.tsx | Add FormattedFieldValue React component wrapper. |
| src/platform/packages/shared/kbn-discover-utils/index.ts | Re-export new React-formatting utilities + component. |
| packages/kbn-optimizer/limits.yml | Update optimizer limits to reflect bundle size changes. |
| static from(convertFn: FieldFormatConvertFunction): FieldFormatInstanceType { | ||
| return createCustomFieldFormat(convertFn); | ||
| return createCustomFieldFormat(convertFn as TextContextTypeConvert); | ||
| } |
There was a problem hiding this comment.
FieldFormat.from still accepts FieldFormatConvertFunction (now includes html/react) but forces the implementation into TextContextTypeConvert via a cast. This is type-unsafe and can break callers that pass an html-only converter (or any non-text signature). Consider either (a) narrowing the from parameter type to TextContextTypeConvert explicitly, or (b) updating createCustomFieldFormat (and from) to properly support the full FieldFormatConvertFunction union, including react.
| export function getFormattedFieldsReact<T extends Record<string, ReactNode>>( | ||
| doc: DataTableRecord, | ||
| fields: Array<keyof T>, | ||
| { dataView, fieldFormats }: { dataView: DataView; fieldFormats: FieldFormatsStart } | ||
| ): T { | ||
| const formatField = <K extends keyof T>(field: K) => { | ||
| const fieldStr = field as string; | ||
| return doc.flattened[fieldStr] !== undefined && doc.flattened[fieldStr] !== null | ||
| ? (formatFieldValueReact( | ||
| doc.flattened[fieldStr], | ||
| doc.raw, | ||
| fieldFormats, | ||
| dataView, | ||
| dataView.fields.getByName(fieldStr) | ||
| ) as T[K]) | ||
| : undefined; | ||
| }; | ||
|
|
||
| return fields.reduce<{ [key in keyof T]?: ReactNode }>((acc, field) => { | ||
| acc[field] = formatField(field); | ||
| return acc; | ||
| }, {}) as T; | ||
| } |
There was a problem hiding this comment.
The function can return undefined for missing fields (: undefined in formatField), but the generic constraint/return type is T extends Record<string, ReactNode> and ): T, which implies values are always present. This mismatch is currently hidden by casts and can leak undefined into places expecting a ReactNode. Suggest changing the signature to reflect optionality (e.g., T extends Record<string, ReactNode | undefined> and/or returning Partial<T>), and removing the final as T cast if possible.
| const messageCodeBlockProps = formattedValue | ||
| ? { language: 'json', children: formattedValue } | ||
| : { | ||
| language: 'txt', | ||
| dangerouslySetInnerHTML: { __html: escapeAndPreserveHighlightTags(value ?? '') }, | ||
| }; | ||
| : { language: 'txt', children: value ?? '' }; |
There was a problem hiding this comment.
This removes the previous escapeAndPreserveHighlightTags(...) path. If value can contain highlight markup (e.g., <mark> tags or other formatting inserted upstream) and formattedValue is absent, this will now render the markup as literal text instead of preserving highlight behavior. If the UI still relies on highlight preservation here, consider adding a React equivalent to the old behavior (e.g., parsing known highlight markers into <mark> React nodes), or ensuring formattedValue is always provided when highlight markup is present.
| const doesContainImage = React.isValidElement(formattedValue) && formattedValue.type === 'img'; | ||
|
|
There was a problem hiding this comment.
This image detection only works when the formatted output is a top-level <img />. With the new 'react' fallback, image-like HTML may be wrapped (e.g., <span dangerouslySetInnerHTML=...>), or an image may be nested under other elements (e.g., <a><img/></a>), causing doesContainImage to be false and breaking the image-preview path. Consider broadening detection to (1) inspect dangerouslySetInnerHTML.__html when the node is a wrapper element, and/or (2) recursively search children for an img element.
| const doesContainImage = React.isValidElement(formattedValue) && formattedValue.type === 'img'; | |
| const containsImageNode = (node: unknown): boolean => { | |
| if (!React.isValidElement(node)) { | |
| return false; | |
| } | |
| if (node.type === 'img') { | |
| return true; | |
| } | |
| const props: any = (node as any).props; | |
| if ( | |
| props && | |
| props.dangerouslySetInnerHTML && | |
| typeof props.dangerouslySetInnerHTML.__html === 'string' && | |
| props.dangerouslySetInnerHTML.__html.includes('<img') | |
| ) { | |
| return true; | |
| } | |
| if (props && props.children) { | |
| return React.Children.toArray(props.children).some(containsImageNode); | |
| } | |
| return false; | |
| }; | |
| const doesContainImage = containsImageNode(formattedValue); |
|
|
||
| interface Props { | ||
| imgHTML: string; | ||
| imgHTML: React.ReactNode; |
There was a problem hiding this comment.
The prop is now a ReactNode, but it is still named imgHTML, which implies a string of HTML. Renaming it to something like imgContent/imgNode would better reflect the new contract and avoid confusion at call sites.
| convert = (val: string, type?: FieldFormatsContentType) => | ||
| this.convertTerms(val, type) as any; | ||
| getConverterFor = (type?: FieldFormatsContentType) => (val: string) => | ||
| this.convertTerms(val, type) as any; |
There was a problem hiding this comment.
The new 'react' behavior forces convert/getConverterFor to use as any, which removes type safety for all callers of these aggs formats. Instead of casting, consider updating the relevant field-format wrapper or interface types so that 'react' is reflected in the return type (e.g., overloads similar to FieldFormat.convert), or splitting string-only vs React-capable code paths with explicit typing.
| const decodeHTMLEntities = (html: string): string => { | ||
| if (!HTML_ENTITY_RE.test(html)) { | ||
| return html; | ||
| } | ||
| return html | ||
| .replaceAll('&', '&') | ||
| .replaceAll('<', '<') | ||
| .replaceAll('>', '>') | ||
| .replaceAll('"', '"') | ||
| .replaceAll(''', "'") | ||
| .replaceAll(''', "'"); | ||
| }; |
There was a problem hiding this comment.
HTML_ENTITY_RE matches any numeric or named entity, but decodeHTMLEntities only decodes a small hard-coded subset. This can lead to inconsistent output between legacy HTML rendering and the new React fallback for tag-free strings containing other entities (e.g., /, ’, ). Consider using a more complete decoding approach for tag-free strings (e.g., a DOM-based decode in the browser, or a small well-audited entity decoder utility) so the React fallback matches existing HTML behavior more reliably.
| for (const key of Object.keys(flattened)) { | ||
| const field = dataView.fields.getByName(key); | ||
| const displayKey = field?.displayName; | ||
| const pairs = highlights[key] ? renderedPairs : otherPairs; | ||
|
|
||
| if (displayKey) { | ||
| const multiParent = field.getSubtypeMulti?.()?.multi.parent; | ||
| const isHighlighted = Boolean(highlights[key]); | ||
| const isParentHighlighted = Boolean(multiParent && highlights[multiParent]); | ||
|
|
||
| if ((isHighlighted && !isParentHighlighted) || shouldShowFieldHandler(key)) { | ||
| pairs.push([displayKey, undefined, key]); | ||
| } | ||
| } else { | ||
| pairs.push([key, undefined, key]); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Within formatHitReact, the code looks up field metadata twice (dataView.fields.getByName(key) in the first loop, then dataView.getFieldByName(key) again when formatting). If these accessors differ in cost, this adds unnecessary overhead when rendering many rows. Consider carrying the resolved field through (e.g., store it alongside the pair) so the second loop can reuse it instead of performing another lookup.
| for (const pair of renderedPairs) { | ||
| const key = pair[2]!; | ||
| pair[1] = formatFieldValueReact( | ||
| flattened[key], | ||
| hit.raw, | ||
| fieldFormats, | ||
| dataView, | ||
| dataView.getFieldByName(key) | ||
| ); | ||
| } |
There was a problem hiding this comment.
Within formatHitReact, the code looks up field metadata twice (dataView.fields.getByName(key) in the first loop, then dataView.getFieldByName(key) again when formatting). If these accessors differ in cost, this adds unnecessary overhead when rendering many rows. Consider carrying the resolved field through (e.g., store it alongside the pair) so the second loop can reuse it instead of performing another lookup.
|
/ci |
💔 Build Failed
Failed CI StepsMetrics [docs]
History
cc @kertal |
Note: it's not ready for review, experimental PR using cursor, accidentally wasn't opened as a draft, sorry 🙇
Summary
Introduce a third content type (
'react') for field formatters that returnsReactNodeinstead of HTML strings.Motivation
Field formatters historically returned HTML strings via
'html'content type, requiring consumers to usedangerouslySetInnerHTML. The new'react'content type lets formatters returnReactNodedirectly, enabling safe rendering via{children}.Commit-by-commit walkthrough
ReactContextTypeConverttype,react_content_type.tsxwithsetup(),checkForMissingValueReact(), HTML entity decoding fallback, andconvert()/getConverterFor()overloads for type-safe usagereactConvertwithgetHighlightReact()(React-native highlight utility using null-byte markers instead of HTML concatenation) + comprehensive testsreactConvertwith missing value handling.ts→.tsx, return native<a>and<img>elements<span>elements + testsreactConvertwith missing value handling; fix StaticLookup null coercion bug'react'type through to underlying formatters (enables colored cells in Lens)dangerouslySetInnerHTMLwith directReactNoderendering across data table, doc viewer table, logs overview, tracesDesign decisions
reactConvertautomatically delegate to their HTML output. Tag-free HTML strings get entity-decoded; strings with tags usedangerouslySetInnerHTMLwrapped in a<span>. This ensures backward compatibility.convert('react')returnsReactNode;convert('html'|'text')returnsstring. TypeScript enforces correct usage at call sites.AggsTermsFieldFormatforwards'react'to the underlying formatter.AggsMultiTermsFieldFormatmaps'react'→'text'sincejoin()requires strings.Changes
react_content_type.tsx,highlight_react.tsx+ tests,formatted_field_value.tsxurl.ts→url.tsxTest plan
Made with Cursor