[One Discover] Add 'Read More', 'Read Less' functionality to highlight_fields (#215326)

Added Read More/ Read Less functionality to fields in Document view in
Discover.
Also width of a field has been slightly increased.

<img width="766" alt="Screenshot 2025-03-21 at 11 10 02"
src="https://github.com/user-attachments/assets/771f0b0e-4613-4b5f-9785-558f22f44236"
/>
<img width="784" alt="Screenshot 2025-03-21 at 11 15 28"
src="https://github.com/user-attachments/assets/3b5a8b18-fbce-4cf6-9ede-9dfb70b33c2f"
/>

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Robert Stelmach 2025-04-02 16:01:12 +02:00 committed by GitHub
parent c1939bb647
commit d92ecd4a17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 221 additions and 13 deletions

View file

@ -68,4 +68,25 @@ Hook managing an abort controller instance that aborts when it goes out of scope
// Will be aborted when the component unmounts
await fetch(url, { signal })
```
### [useTruncateText](.src/use_truncate_text/use_truncate_text.ts)
Hook for managing text truncation with expand/collapse functionality. Provides controlled truncation of long text content with customizable length.
```tsx
const { displayText, isExpanded, toggleExpanded, shouldTruncate } = useTruncateText(
longContent,
150, // Max length before truncation (default: 500)
100 // Optional: Max characters to show when truncated
);
<EuiText>
{displayText}
{shouldTruncate && (
<EuiLink onClick={toggleExpanded}>
{isExpanded ? 'Show less' : 'Show more'}
</EuiLink>
)}
</EuiText>
```

View file

@ -13,5 +13,6 @@ export { useDebounceFn } from './src/use_debounce_fn';
export { useThrottleFn } from './src/use_throttle_fn';
export { useAbortController } from './src/use_abort_controller';
export { useAbortableAsync } from './src/use_abortable_async';
export { useTruncateText } from './src/use_truncate_text';
export type { UseAbortableAsync, AbortableAsyncState } from './src/use_abortable_async';
export type { UseBooleanHandlers, UseBooleanResult } from './src/use_boolean';

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export * from './use_truncate_text';

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { renderHook, act } from '@testing-library/react';
import { useTruncateText } from './use_truncate_text';
describe('useTruncateText', () => {
const shortText = 'Short text';
const longText = 'A'.repeat(600);
test('should not truncate text shorter than maxLength', () => {
const { result } = renderHook(() => useTruncateText(shortText));
expect(result.current.displayText).toBe(shortText);
expect(result.current.shouldTruncate).toBe(false);
expect(result.current.isExpanded).toBe(false);
});
test('should truncate text longer than maxLength', () => {
const { result } = renderHook(() => useTruncateText(longText));
expect(result.current.displayText).toBe(`${'A'.repeat(500)}...`);
expect(result.current.shouldTruncate).toBe(true);
expect(result.current.isExpanded).toBe(false);
});
test('should respect custom maxLength', () => {
const customMaxLength = 200;
const { result } = renderHook(() => useTruncateText(longText, customMaxLength));
expect(result.current.displayText).toBe(`${'A'.repeat(200)}...`);
expect(result.current.shouldTruncate).toBe(true);
});
test('should respect custom maxCharLength', () => {
const customMaxLength = 300;
const customMaxCharLength = 100;
const { result } = renderHook(() =>
useTruncateText(longText, customMaxLength, customMaxCharLength)
);
expect(result.current.displayText).toBe(`${'A'.repeat(100)}...`);
expect(result.current.shouldTruncate).toBe(true);
});
test('should show full text when expanded', () => {
const { result } = renderHook(() => useTruncateText(longText));
expect(result.current.displayText).toBe(`${'A'.repeat(500)}...`);
act(() => {
result.current.toggleExpanded();
});
expect(result.current.isExpanded).toBe(true);
expect(result.current.displayText).toBe(longText);
});
test('should toggle expanded state', () => {
const { result } = renderHook(() => useTruncateText(longText));
expect(result.current.isExpanded).toBe(false);
act(() => {
result.current.toggleExpanded();
});
expect(result.current.isExpanded).toBe(true);
act(() => {
result.current.toggleExpanded();
});
expect(result.current.isExpanded).toBe(false);
});
test('should handle null or undefined text', () => {
const { result } = renderHook(() => useTruncateText(null as unknown as string));
expect(result.current.displayText).toBe(null);
expect(result.current.shouldTruncate).toBe(false);
});
});

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useState, useMemo } from 'react';
export const useTruncateText = (
text: string,
maxLength: number = 500,
maxCharLength: number = maxLength
) => {
const [isExpanded, setIsExpanded] = useState(false);
const shouldTruncate = text?.length > maxLength;
const displayText = useMemo(() => {
if (!shouldTruncate || isExpanded) return text;
return `${text?.slice(0, maxCharLength)}...`;
}, [text, shouldTruncate, isExpanded, maxCharLength]);
const toggleExpanded = () => setIsExpanded(!isExpanded);
return { displayText, isExpanded, toggleExpanded, shouldTruncate };
};

View file

@ -76,6 +76,7 @@ export function LogsOverviewHighlights({
label={serviceLabel}
fieldMetadata={fieldsMetadata[fieldConstants.SERVICE_NAME_FIELD]}
{...getHighlightProps(fieldConstants.SERVICE_NAME_FIELD)}
truncate
/>
)}
{shouldRenderHighlight(fieldConstants.HOST_NAME_FIELD) && (
@ -84,6 +85,7 @@ export function LogsOverviewHighlights({
label={hostNameLabel}
fieldMetadata={fieldsMetadata[fieldConstants.HOST_NAME_FIELD]}
{...getHighlightProps(fieldConstants.HOST_NAME_FIELD)}
truncate
/>
)}
{shouldRenderHighlight(fieldConstants.TRACE_ID_FIELD) && (
@ -92,6 +94,7 @@ export function LogsOverviewHighlights({
label={traceLabel}
fieldMetadata={fieldsMetadata[fieldConstants.TRACE_ID_FIELD]}
{...getHighlightProps(fieldConstants.TRACE_ID_FIELD)}
truncate
/>
)}
{shouldRenderHighlight(fieldConstants.ORCHESTRATOR_CLUSTER_NAME_FIELD) && (
@ -100,6 +103,7 @@ export function LogsOverviewHighlights({
label={orchestratorClusterNameLabel}
fieldMetadata={fieldsMetadata[fieldConstants.ORCHESTRATOR_CLUSTER_NAME_FIELD]}
{...getHighlightProps(fieldConstants.ORCHESTRATOR_CLUSTER_NAME_FIELD)}
truncate
/>
)}
{shouldRenderHighlight(fieldConstants.ORCHESTRATOR_RESOURCE_ID_FIELD) && (
@ -108,6 +112,7 @@ export function LogsOverviewHighlights({
label={orchestratorResourceIdLabel}
fieldMetadata={fieldsMetadata[fieldConstants.ORCHESTRATOR_RESOURCE_ID_FIELD]}
{...getHighlightProps(fieldConstants.ORCHESTRATOR_RESOURCE_ID_FIELD)}
truncate
/>
)}
</HighlightSection>
@ -137,6 +142,7 @@ export function LogsOverviewHighlights({
label={cloudRegionLabel}
fieldMetadata={fieldsMetadata[fieldConstants.CLOUD_REGION_FIELD]}
{...getHighlightProps(fieldConstants.CLOUD_REGION_FIELD)}
truncate
/>
)}
{shouldRenderHighlight(fieldConstants.CLOUD_AVAILABILITY_ZONE_FIELD) && (
@ -145,6 +151,7 @@ export function LogsOverviewHighlights({
label={cloudAvailabilityZoneLabel}
fieldMetadata={fieldsMetadata[fieldConstants.CLOUD_AVAILABILITY_ZONE_FIELD]}
{...getHighlightProps(fieldConstants.CLOUD_AVAILABILITY_ZONE_FIELD)}
truncate
/>
)}
{shouldRenderHighlight(fieldConstants.CLOUD_PROJECT_ID_FIELD) && (
@ -153,6 +160,7 @@ export function LogsOverviewHighlights({
label={cloudProjectIdLabel}
fieldMetadata={fieldsMetadata[fieldConstants.CLOUD_PROJECT_ID_FIELD]}
{...getHighlightProps(fieldConstants.CLOUD_PROJECT_ID_FIELD)}
truncate
/>
)}
{shouldRenderHighlight(fieldConstants.CLOUD_INSTANCE_ID_FIELD) && (
@ -161,6 +169,7 @@ export function LogsOverviewHighlights({
label={cloudInstanceIdLabel}
fieldMetadata={fieldsMetadata[fieldConstants.CLOUD_INSTANCE_ID_FIELD]}
{...getHighlightProps(fieldConstants.CLOUD_INSTANCE_ID_FIELD)}
truncate
/>
)}
</HighlightSection>
@ -175,6 +184,7 @@ export function LogsOverviewHighlights({
label={logPathFileLabel}
fieldMetadata={fieldsMetadata[fieldConstants.LOG_FILE_PATH_FIELD]}
{...getHighlightProps(fieldConstants.LOG_FILE_PATH_FIELD)}
truncate
/>
)}
{shouldRenderHighlight(fieldConstants.DATASTREAM_DATASET_FIELD) && (
@ -183,6 +193,7 @@ export function LogsOverviewHighlights({
label={datasetLabel}
fieldMetadata={fieldsMetadata[fieldConstants.DATASTREAM_DATASET_FIELD]}
{...getHighlightProps(fieldConstants.DATASTREAM_DATASET_FIELD)}
truncate
/>
)}
{shouldRenderHighlight(fieldConstants.DATASTREAM_NAMESPACE_FIELD) && (
@ -192,6 +203,7 @@ export function LogsOverviewHighlights({
fieldMetadata={fieldsMetadata[fieldConstants.DATASTREAM_NAMESPACE_FIELD]}
useBadge
{...getHighlightProps(fieldConstants.DATASTREAM_NAMESPACE_FIELD)}
truncate
/>
)}
{renderStreamsField && renderStreamsField({ doc })}
@ -201,6 +213,7 @@ export function LogsOverviewHighlights({
label={shipperLabel}
fieldMetadata={fieldsMetadata[fieldConstants.AGENT_NAME_FIELD]}
{...getHighlightProps(fieldConstants.AGENT_NAME_FIELD)}
truncate
/>
)}
</HighlightSection>

View file

@ -24,6 +24,7 @@ export interface HighlightFieldProps {
useBadge?: boolean;
value?: unknown;
children?: (props: { content: React.ReactNode }) => React.ReactNode | React.ReactNode;
truncate?: boolean;
}
export function HighlightField({
@ -35,6 +36,7 @@ export function HighlightField({
useBadge = false,
value,
children,
truncate,
...props
}: HighlightFieldProps) {
const hasFieldDescription = !!fieldMetadata?.short;
@ -47,7 +49,7 @@ export function HighlightField({
</EuiTitle>
{hasFieldDescription ? <HighlightFieldDescription fieldMetadata={fieldMetadata} /> : null}
</EuiFlexGroup>
<HoverActionPopover title={value} value={value} field={field}>
<HoverActionPopover title={value} value={value} field={field} truncate={truncate}>
<EuiFlexGroup
responsive={false}
alignItems="center"

View file

@ -16,7 +16,10 @@ import {
EuiToolTip,
PopoverAnchorPosition,
type EuiPopoverProps,
EuiLink,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useTruncateText } from '@kbn/react-hooks';
import { useUIFieldActions } from '../../../hooks/use_field_actions';
interface HoverPopoverActionProps {
@ -27,8 +30,19 @@ interface HoverPopoverActionProps {
title?: unknown;
anchorPosition?: PopoverAnchorPosition;
display?: EuiPopoverProps['display'];
truncate?: boolean;
}
const MAX_CHAR_LENGTH = 500;
const readMore = i18n.translate('unifiedDocViewer.observability.traces.details.readMore', {
defaultMessage: 'Read more',
});
const readLess = i18n.translate('unifiedDocViewer.observability.traces.details.readLess', {
defaultMessage: 'Read less',
});
export const HoverActionPopover = ({
children,
title,
@ -37,6 +51,7 @@ export const HoverActionPopover = ({
formattedValue,
anchorPosition = 'upCenter',
display = 'inline-block',
truncate,
}: HoverPopoverActionProps) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const leaveTimer = useRef<NodeJS.Timeout | null>(null);
@ -53,24 +68,50 @@ export const HoverActionPopover = ({
setIsPopoverOpen(true);
};
const getTitleText = () => {
if (Array.isArray(title)) {
return title.join(' ');
} else if (typeof title === 'string') {
return title;
}
return title as string;
};
const onMouseLeave = () => {
leaveTimer.current = setTimeout(() => setIsPopoverOpen(false), 100);
};
const titleText = getTitleText();
const { displayText, isExpanded, toggleExpanded, shouldTruncate } = useTruncateText(
titleText,
MAX_CHAR_LENGTH
);
const displayTitle = truncate ? displayText : titleText;
return (
<span onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<EuiPopover
button={children}
isOpen={isPopoverOpen}
anchorPosition={anchorPosition}
anchorPosition={shouldTruncate ? 'leftCenter' : anchorPosition}
closePopover={closePopoverPlaceholder}
panelPaddingSize="s"
panelStyle={{ minWidth: '24px' }}
display={display}
>
{(title as string) && (
<EuiPopoverTitle className="eui-textBreakWord" css={{ maxWidth: '200px' }}>
{title as string}
<EuiPopoverTitle
className="eui-textBreakWord"
css={{
maxWidth: isExpanded ? '350px' : '300px',
display: 'flex',
flexDirection: 'column',
}}
>
{displayTitle}
{shouldTruncate && truncate && (
<EuiLink onClick={toggleExpanded}>{isExpanded ? readLess : readMore}</EuiLink>
)}
</EuiPopoverTitle>
)}
<EuiFlexGroup wrap gutterSize="none" alignItems="center" justifyContent="spaceBetween">

View file

@ -40,7 +40,8 @@
"@kbn/apm-types",
"@kbn/event-stacktrace",
"@kbn/elastic-agent-utils",
"@kbn/data-view-utils"
"@kbn/data-view-utils",
"@kbn/react-hooks"
],
"exclude": ["target/**/*"]
}

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import React, { useState } from 'react';
import React from 'react';
import { EuiCode, EuiFlexGroup } from '@elastic/eui';
import { useTruncateText } from '@kbn/react-hooks';
import { readLess, readMore } from '../../../../common/translations';
interface TruncatedTextWithToggleProps {
@ -22,11 +23,11 @@ export const ExpandableTruncatedText = ({
truncatedTextLength = 35,
codeLanguage,
}: TruncatedTextWithToggleProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const shouldTruncate = text.length > maxCharLength;
const displayText =
shouldTruncate && !isExpanded ? `${text.slice(0, truncatedTextLength)}...` : text;
const { displayText, isExpanded, toggleExpanded, shouldTruncate } = useTruncateText(
text,
maxCharLength,
truncatedTextLength
);
return (
<EuiFlexGroup direction="column" gutterSize="none" style={{ width: '100%' }}>
@ -37,7 +38,7 @@ export const ExpandableTruncatedText = ({
<EuiCode>
<button
data-test-subj="truncatedTextToggle"
onClick={() => setIsExpanded(!isExpanded)}
onClick={toggleExpanded}
color="primary"
css={{ fontWeight: 'bold', textDecoration: 'underline' }}
>

View file

@ -56,7 +56,8 @@
"@kbn/task-manager-plugin",
"@kbn/field-utils",
"@kbn/logging",
"@kbn/ui-theme"
"@kbn/ui-theme",
"@kbn/react-hooks"
],
"exclude": [
"target/**/*"