mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
c1939bb647
commit
d92ecd4a17
11 changed files with 221 additions and 13 deletions
|
@ -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>
|
||||
```
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
|
@ -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' }}
|
||||
>
|
||||
|
|
|
@ -56,7 +56,8 @@
|
|||
"@kbn/task-manager-plugin",
|
||||
"@kbn/field-utils",
|
||||
"@kbn/logging",
|
||||
"@kbn/ui-theme"
|
||||
"@kbn/ui-theme",
|
||||
"@kbn/react-hooks"
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue