Compare commits

...

3 Commits

Author SHA1 Message Date
Vinícius Lourenço
670cb7aca5 feat(components): added shared components for alerts 2026-05-11 14:58:00 -03:00
Vinícius Lourenço
0daf7a12da chore(signozhq/ui): bump to v0.0.19 2026-05-11 14:47:09 -03:00
Vinícius Lourenço
cc7d7017ae feat(tanstack-table): add showPageSize flag and callbacks to pagination 2026-05-11 14:45:20 -03:00
23 changed files with 899 additions and 144 deletions

View File

@@ -50,7 +50,7 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.1.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.18",
"@signozhq/ui": "0.0.19",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",

View File

@@ -0,0 +1,36 @@
.labelColumn {
display: flex;
gap: 4px;
align-items: center;
overflow-x: auto;
max-width: 100%;
}
.labelBadge {
cursor: default;
font-size: 12px;
--badge-display: inline;
max-width: 180px;
min-width: 100px;
text-overflow: ellipsis;
}
.labelPopover {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
max-height: 300px;
overflow-y: auto;
}
.labelBadgePopover {
font-size: 12px;
}
.labelValue {
text-overflow: ellipsis;
overflow: hidden;
}

View File

@@ -0,0 +1,89 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LabelColumn from './LabelColumn';
function renderWithProviders(
ui: React.ReactElement,
): ReturnType<typeof render> {
return render(<TooltipProvider>{ui}</TooltipProvider>);
}
describe('LabelColumn', () => {
it('should render all labels when 5 or fewer', () => {
const labels = ['env', 'service', 'region'];
renderWithProviders(<LabelColumn labels={labels} />);
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-region')).toBeInTheDocument();
});
it('should truncate labels and show +N badge when more than 5 labels', () => {
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
renderWithProviders(<LabelColumn labels={labels} />);
// First 3 visible
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-region')).toBeInTheDocument();
// +3 badge for remaining
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+3');
});
it('should render label with value when value prop provided', () => {
const labels = ['env'];
const value = { env: 'production' };
renderWithProviders(<LabelColumn labels={labels} value={value} />);
expect(screen.getByTestId('label-tag-env')).toHaveTextContent(
'env: production',
);
});
it('should render labels without value when value is not provided for that label', () => {
const labels = ['env', 'service'];
const value = { env: 'production' };
renderWithProviders(<LabelColumn labels={labels} value={value} />);
expect(screen.getByTestId('label-tag-env')).toHaveTextContent(
'env: production',
);
expect(screen.getByTestId('label-tag-service')).toHaveTextContent('service');
});
it('should show popover with all labels when clicking +N badge', async () => {
const user = userEvent.setup();
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
renderWithProviders(<LabelColumn labels={labels} />);
await user.click(screen.getByTestId('label-overflow-badge'));
// All labels should appear in popover
expect(screen.getByTestId('label-popover')).toBeInTheDocument();
expect(screen.getByTestId('label-popover-item-env')).toBeInTheDocument();
expect(screen.getByTestId('label-popover-item-version')).toBeInTheDocument();
});
it('should render empty when no labels provided', () => {
renderWithProviders(<LabelColumn labels={[]} />);
const column = screen.getByTestId('label-column');
expect(column.children).toHaveLength(0);
});
it('should use primary color by default', () => {
const labels = ['env'];
renderWithProviders(<LabelColumn labels={labels} />);
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,123 @@
import {
Badge,
Popover,
PopoverContent,
PopoverTrigger,
TooltipSimple,
} from '@signozhq/ui';
import styles from './LabelColumn.module.scss';
export interface LabelColumnProps {
labels: string[];
color?:
| 'primary'
| 'secondary'
| 'success'
| 'error'
| 'warning'
| 'robin'
| 'forest'
| 'amber'
| 'sienna'
| 'cherry'
| 'sakura'
| 'aqua'
| 'vanilla';
value?: { [key: string]: string };
}
function getLabelRenderingValue(label: string, value?: string): JSX.Element {
const title = value ? `${label}: ${value}` : label;
const content = value ? `${label}: ${value}` : label;
return (
<span title={title} className={styles.labelValue}>
{content}
</span>
);
}
function getLabelAndValueContent(label: string, value?: string): string {
return value ? `${label}: ${value}` : label;
}
function LabelTag({
label,
value,
color,
}: {
label: string;
color?: LabelColumnProps['color'];
value?: LabelColumnProps['value'];
}): JSX.Element {
const tooltipTitle = value?.[label] ? `${label}: ${value[label]}` : label;
return (
<TooltipSimple title={tooltipTitle}>
<Badge
color={color}
className={styles.labelBadge}
variant="outline"
data-testid={`label-tag-${label}`}
>
{getLabelRenderingValue(label, value?.[label])}
</Badge>
</TooltipSimple>
);
}
const MAX_LABELS_TO_DISPLAY = 5;
function LabelColumn({
labels,
value,
color = 'primary',
}: LabelColumnProps): JSX.Element {
const visibleLabels =
labels.length > MAX_LABELS_TO_DISPLAY ? labels.slice(0, 3) : labels;
const remainingLabels =
labels.length > MAX_LABELS_TO_DISPLAY ? labels.slice(3) : [];
return (
<div className={styles.labelColumn} data-testid="label-column">
{visibleLabels.map((label) => (
<LabelTag key={label} label={label} color={color} value={value} />
))}
{remainingLabels.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<Badge
color={color}
className={styles.labelBadge}
variant="outline"
data-testid="label-overflow-badge"
>
+{remainingLabels.length}
</Badge>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
className={styles.labelPopover}
data-testid="label-popover"
>
{labels.map((label) => (
<Badge
key={label}
color={color}
className={styles.labelBadgePopover}
variant="outline"
data-testid={`label-popover-item-${label}`}
>
{getLabelAndValueContent(label, value?.[label])}
</Badge>
))}
</PopoverContent>
</Popover>
)}
</div>
);
}
export default LabelColumn;

View File

@@ -0,0 +1,2 @@
export { default } from './LabelColumn';
export type { LabelColumnProps } from './LabelColumn';

View File

@@ -0,0 +1,4 @@
.lastUpdated {
font-size: 12px;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,105 @@
import { render, screen, act } from '@testing-library/react';
import LastUpdatedText from './LastUpdatedText';
describe('LastUpdatedText', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should return null when updatedAt is null', () => {
const { container } = render(<LastUpdatedText updatedAt={null} />);
expect(container.firstChild).toBeNull();
});
it('should render formatted time distance', () => {
const now = Date.now();
const fiveMinutesAgo = now - 5 * 60 * 1000;
jest.setSystemTime(now);
render(<LastUpdatedText updatedAt={fiveMinutesAgo} />);
expect(screen.getByTestId('last-updated-text')).toHaveTextContent(
/Updated.*5 minutes ago/,
);
});
it('should have title with ISO formatted date', () => {
const now = Date.now();
const fiveMinutesAgo = now - 5 * 60 * 1000;
jest.setSystemTime(now);
render(<LastUpdatedText updatedAt={fiveMinutesAgo} />);
expect(screen.getByTestId('last-updated-text').title).toMatch(
/^\d{4}-\d{2}-\d{2}/,
);
});
it('should update text periodically', () => {
const now = Date.now();
jest.setSystemTime(now);
render(<LastUpdatedText updatedAt={now} />);
expect(screen.getByTestId('last-updated-text')).toHaveTextContent(
/Updated.*less than a minute ago/,
);
act(() => {
jest.advanceTimersByTime(61000);
});
expect(screen.getByTestId('last-updated-text')).toHaveTextContent(
/Updated.*1 minute ago/,
);
});
it('should cleanup interval on unmount', () => {
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
const now = Date.now();
jest.setSystemTime(now);
const { unmount } = render(<LastUpdatedText updatedAt={now} />);
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
clearIntervalSpy.mockRestore();
});
it('should render with recent timestamp', () => {
const now = Date.now();
const tenSecondsAgo = now - 10 * 1000;
jest.setSystemTime(now);
render(<LastUpdatedText updatedAt={tenSecondsAgo} />);
expect(screen.getByTestId('last-updated-text')).toHaveTextContent(
/Updated.*less than a minute ago/,
);
});
it('should render with hour-old timestamp', () => {
const now = Date.now();
const oneHourAgo = now - 60 * 60 * 1000;
jest.setSystemTime(now);
render(<LastUpdatedText updatedAt={oneHourAgo} />);
expect(screen.getByTestId('last-updated-text')).toHaveTextContent(
/Updated.*1 hour ago/,
);
});
});

View File

@@ -0,0 +1,63 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { formatDistanceToNow, formatISO } from 'date-fns';
import styles from './LastUpdatedText.module.scss';
interface LastUpdatedTextProps {
updatedAt: number | null;
}
const LastUpdatedText = memo(function LastUpdatedText({
updatedAt,
}: LastUpdatedTextProps): JSX.Element | null {
const [text, setText] = useState('');
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const lastUpdatedAtDate = useMemo(() => {
if (!updatedAt) {
return '-';
}
try {
return formatISO(updatedAt);
} catch (e) {
console.error(e);
return 'Failed to parse date.';
}
}, [updatedAt]);
useEffect(() => {
if (!updatedAt) {
setText('');
return;
}
const updateText = (): void => {
setText(formatDistanceToNow(updatedAt, { addSuffix: true }));
};
updateText();
intervalRef.current = setInterval(updateText, 1000);
return (): void => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [updatedAt]);
if (!text) {
return null;
}
return (
<span
className={styles.lastUpdated}
title={lastUpdatedAtDate}
data-testid="last-updated-text"
>
Updated {text}
</span>
);
});
export default LastUpdatedText;

View File

@@ -0,0 +1 @@
export { default } from './LastUpdatedText';

View File

@@ -0,0 +1,50 @@
.statCard {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: var(--spacing-1);
padding: var(--spacing-4) var(--spacing-7);
background: var(--l2-background);
border-radius: 4px;
border: 1px solid var(--l1-border);
min-width: 80px;
height: 58px;
box-sizing: border-box;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
font-family: inherit;
text-align: left;
margin: 0;
-webkit-appearance: none;
appearance: none;
}
.statCardClickable {
cursor: pointer;
&:hover {
border-color: var(--l2-foreground);
}
}
.statCardActive {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 10%, var(--l2-background));
}
.statLabel {
font-size: 11px;
font-weight: 500;
color: var(--l2-foreground);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.statValue {
font-size: 18px;
font-weight: 600;
color: var(--l1-foreground);
line-height: 1.2;
}

View File

@@ -0,0 +1,101 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import StatCard from './StatCard';
describe('StatCard', () => {
it('should render label and value', () => {
render(<StatCard label="Firing" value={5} />);
expect(screen.getByTestId('stat-card-label')).toHaveTextContent('Firing');
expect(screen.getByTestId('stat-card-value')).toHaveTextContent('5');
});
it('should apply custom color to value', () => {
render(<StatCard label="Firing" value={5} color="red" />);
expect(screen.getByTestId('stat-card-value')).toHaveStyle({ color: 'red' });
});
it('should not have button role when onClick is not provided', () => {
render(<StatCard label="Firing" value={5} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('should have button role when onClick is provided', () => {
const onClick = jest.fn();
render(<StatCard label="Firing" value={5} onClick={onClick} />);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('should call onClick with exclusive: false on regular click', async () => {
const user = userEvent.setup();
const onClick = jest.fn();
render(<StatCard label="Firing" value={5} onClick={onClick} />);
await user.click(screen.getByTestId('stat-card'));
expect(onClick).toHaveBeenCalledWith({ exclusive: false });
});
it('should call onClick with exclusive: true on alt+click', async () => {
const user = userEvent.setup();
const onClick = jest.fn();
render(<StatCard label="Firing" value={5} onClick={onClick} />);
await user.keyboard('{Alt>}');
await user.click(screen.getByTestId('stat-card'));
await user.keyboard('{/Alt}');
expect(onClick).toHaveBeenCalledWith({ exclusive: true });
});
it('should call onClick on Enter key press', async () => {
const user = userEvent.setup();
const onClick = jest.fn();
render(<StatCard label="Firing" value={5} onClick={onClick} />);
const card = screen.getByTestId('stat-card');
card.focus();
await user.keyboard('{Enter}');
expect(onClick).toHaveBeenCalledWith({ exclusive: false });
});
it('should call onClick on Space key press', async () => {
const user = userEvent.setup();
const onClick = jest.fn();
render(<StatCard label="Firing" value={5} onClick={onClick} />);
const card = screen.getByTestId('stat-card');
card.focus();
await user.keyboard(' ');
expect(onClick).toHaveBeenCalledWith({ exclusive: false });
});
it('should be focusable when onClick is provided', () => {
render(<StatCard label="Firing" value={5} onClick={jest.fn()} />);
expect(screen.getByTestId('stat-card')).toHaveAttribute('tabindex', '0');
});
it('should not be focusable when onClick is not provided', () => {
render(<StatCard label="Firing" value={5} />);
expect(screen.getByTestId('stat-card')).not.toHaveAttribute('tabindex');
});
it('should not have color style when color prop is not provided', () => {
render(<StatCard label="Firing" value={5} />);
expect(screen.getByTestId('stat-card-value')).not.toHaveAttribute('style');
});
});

View File

@@ -0,0 +1,66 @@
import styles from './StatCard.module.scss';
export interface StatCardClickEvent {
exclusive: boolean;
}
interface StatCardProps {
label: string;
value: number;
color?: string;
onClick?: (event: StatCardClickEvent) => void;
isActive?: boolean;
}
function StatCard({
label,
value,
color,
onClick,
isActive,
}: StatCardProps): JSX.Element {
const cardClassName = [
styles.statCard,
onClick && styles.statCardClickable,
isActive && styles.statCardActive,
]
.filter(Boolean)
.join(' ');
const handleClick = (e: React.MouseEvent): void => {
if (onClick) {
onClick({ exclusive: e.altKey });
}
};
const handleKeyDown = (e: React.KeyboardEvent): void => {
if (onClick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onClick({ exclusive: e.altKey });
}
};
return (
<div
className={cardClassName}
onClick={onClick ? handleClick : undefined}
onKeyDown={onClick ? handleKeyDown : undefined}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
data-testid="stat-card"
>
<span className={styles.statLabel} data-testid="stat-card-label">
{label}
</span>
<span
className={styles.statValue}
style={color ? { color } : undefined}
data-testid="stat-card-value"
>
{value}
</span>
</div>
);
}
export default StatCard;

View File

@@ -0,0 +1,2 @@
export { default } from './StatCard';
export type { StatCardClickEvent } from './StatCard';

View File

@@ -0,0 +1,87 @@
import {
STATE_ORDER,
SEVERITY_ORDER,
STATE_LABELS,
STATE_COLORS,
SEVERITY_COLORS,
} from './constants';
describe('Alerts constants', () => {
describe('STATE_ORDER', () => {
it('should have correct order of states', () => {
expect(STATE_ORDER).toStrictEqual([
'firing',
'pending',
'inactive',
'disabled',
]);
});
it('should have firing as highest priority', () => {
expect(STATE_ORDER[0]).toBe('firing');
});
});
describe('SEVERITY_ORDER', () => {
it('should have correct order of severities', () => {
expect(SEVERITY_ORDER).toStrictEqual([
'critical',
'error',
'warning',
'info',
]);
});
it('should have critical as highest priority', () => {
expect(SEVERITY_ORDER[0]).toBe('critical');
});
});
describe('STATE_LABELS', () => {
it('should map firing to Firing', () => {
expect(STATE_LABELS.firing).toBe('Firing');
});
it('should map pending to Pending', () => {
expect(STATE_LABELS.pending).toBe('Pending');
});
it('should map inactive to OK', () => {
expect(STATE_LABELS.inactive).toBe('OK');
});
it('should map disabled to Disabled', () => {
expect(STATE_LABELS.disabled).toBe('Disabled');
});
});
describe('STATE_COLORS', () => {
it('should have colors for all states', () => {
expect(STATE_COLORS).toHaveProperty('firing');
expect(STATE_COLORS).toHaveProperty('pending');
expect(STATE_COLORS).toHaveProperty('inactive');
expect(STATE_COLORS).toHaveProperty('disabled');
});
it('should use CSS variables for colors', () => {
Object.values(STATE_COLORS).forEach((color) => {
expect(color).toMatch(/^var\(--/);
});
});
});
describe('SEVERITY_COLORS', () => {
it('should have colors for all severities', () => {
expect(SEVERITY_COLORS).toHaveProperty('critical');
expect(SEVERITY_COLORS).toHaveProperty('error');
expect(SEVERITY_COLORS).toHaveProperty('warning');
expect(SEVERITY_COLORS).toHaveProperty('info');
});
it('should use CSS variables for colors', () => {
Object.values(SEVERITY_COLORS).forEach((color) => {
expect(color).toMatch(/^var\(--/);
});
});
});
});

View File

@@ -0,0 +1,23 @@
export const STATE_ORDER = ['firing', 'pending', 'inactive', 'disabled'];
export const SEVERITY_ORDER = ['critical', 'error', 'warning', 'info'];
export const STATE_LABELS: Record<string, string> = {
firing: 'Firing',
pending: 'Pending',
inactive: 'OK',
disabled: 'Disabled',
};
export const STATE_COLORS: Record<string, string> = {
firing: 'var(--bg-cherry-500)',
pending: 'var(--bg-amber-500)',
inactive: 'var(--bg-forest-500)',
disabled: 'var(--l2-foreground)',
};
export const SEVERITY_COLORS: Record<string, string> = {
critical: 'var(--bg-cherry-500)',
error: 'var(--bg-cherry-400)',
warning: 'var(--bg-amber-500)',
info: 'var(--bg-robin-500)',
};

View File

@@ -0,0 +1,12 @@
export { default as StatCard } from './StatCard';
export type { StatCardClickEvent } from './StatCard';
export { default as LastUpdatedText } from './LastUpdatedText';
export { default as LabelColumn } from './LabelColumn';
export type { LabelColumnProps } from './LabelColumn';
export {
STATE_ORDER,
SEVERITY_ORDER,
STATE_LABELS,
STATE_COLORS,
SEVERITY_COLORS,
} from './constants';

View File

@@ -39,6 +39,7 @@ import {
} from './TanStackTableStateContext';
import {
FlatItem,
SortState,
TableRowContext,
TanStackTableHandle,
TanStackTableProps,
@@ -100,6 +101,7 @@ function TanStackTableInner<TData>(
onRowClick,
onRowClickNewTab,
onRowDeactivate,
onSort,
activeRowIndex,
renderExpandedRow,
getRowCanExpand,
@@ -127,10 +129,10 @@ function TanStackTableInner<TData>(
const {
page,
limit,
setPage,
setLimit,
setPage: internalSetPage,
setLimit: internalSetLimit,
orderBy,
setOrderBy,
setOrderBy: internalSetOrderBy,
expanded,
setExpanded,
} = useTableParams(enableQueryParams, {
@@ -138,6 +140,30 @@ function TanStackTableInner<TData>(
limit: pagination?.defaultLimit,
});
const setPage = useCallback(
(p: number) => {
internalSetPage(p);
pagination?.onPageChange?.(p);
},
[internalSetPage, pagination],
);
const setLimit = useCallback(
(l: number) => {
internalSetLimit(l);
pagination?.onLimitChange?.(l);
},
[internalSetLimit, pagination],
);
const setOrderBy = useCallback(
(sort: SortState | null) => {
internalSetOrderBy(sort);
onSort?.(sort);
},
[internalSetOrderBy, onSort],
);
const isGrouped = (groupBy?.length ?? 0) > 0;
const {
@@ -607,14 +633,16 @@ function TanStackTableInner<TData>(
setPage(p);
}}
/>
<div className={viewStyles.paginationPageSize}>
<ComboboxSimple
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => setLimit(+value)}
items={paginationPageSizeItems}
/>
</div>
{(pagination.showPageSize ?? true) && (
<div className={viewStyles.paginationPageSize}>
<ComboboxSimple
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => setLimit(+value)}
items={paginationPageSizeItems}
/>
</div>
)}
{suffixPaginationContent}
</div>
)}

View File

@@ -117,6 +117,10 @@ export type PaginationProps = {
defaultLimit?: number;
showTotalCount?: boolean;
totalCountLabel?: string;
/** @default true */
showPageSize?: boolean;
onPageChange?: (page: number) => void;
onLimitChange?: (limit: number) => void;
};
export type TanstackTableQueryParamsConfig = {
@@ -160,6 +164,8 @@ export type TanStackTableProps<TData> = {
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: string) => void;
onRowDeactivate?: () => void;
/** Called when sort state changes */
onSort?: (sort: SortState | null) => void;
activeRowIndex?: number;
renderExpandedRow?: (
row: TData,

View File

@@ -1,5 +1,5 @@
import { Button } from '@signozhq/ui/button';
import { Tooltip, TooltipProvider } from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui';
import { Copy } from '@signozhq/icons';
import './CopyIconButton.styles.scss';
@@ -19,22 +19,20 @@ function CopyIconButton({
: 'Copy to clipboard';
return (
<TooltipProvider>
<Tooltip title={tooltipTitle}>
<span>
<Button
color="secondary"
variant="ghost"
size="icon"
aria-label={ariaLabel}
disabled={disabled}
className="mcp-copy-btn"
prefix={<Copy size={14} />}
onClick={onCopy}
/>
</span>
</Tooltip>
</TooltipProvider>
<TooltipSimple title={tooltipTitle}>
<span>
<Button
color="secondary"
variant="ghost"
size="icon"
aria-label={ariaLabel}
disabled={disabled}
className="mcp-copy-btn"
prefix={<Copy size={14} />}
onClick={onCopy}
/>
</span>
</TooltipSimple>
);
}

View File

@@ -6,12 +6,7 @@ import {
TabsRoot,
TabsTrigger,
} from '@signozhq/ui/tabs';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui';
import {
Bookmark,
CalendarClock,
@@ -497,27 +492,22 @@ function SpanDetailsPanel({
actions.push({
key: 'dock-toggle',
component: (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void =>
onVariantChange(
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
)
}
>
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent className="dock-toggle-tooltip">
{isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipSimple
title={isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void =>
onVariantChange(
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
)
}
>
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
</Button>
</TooltipSimple>
),
});
}

View File

@@ -1,10 +1,5 @@
import { Button } from '@signozhq/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { Link } from 'lucide-react';
import { Span } from 'types/api/trace/getTraceV2';
@@ -21,24 +16,17 @@ export default function SpanLineActionButtons({
return (
<div className="span-line-action-buttons">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onSpanCopy}
className="copy-span-btn"
>
<Link size={14} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-line-action-tooltip">
Copy Span Link
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipSimple title="Copy Span Link">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onSpanCopy}
className="copy-span-btn"
>
<Link size={14} />
</Button>
</TooltipSimple>
</div>
);
}

View File

@@ -11,12 +11,7 @@ import {
} from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui';
import {
createColumnHelper,
flexRender,
@@ -109,26 +104,24 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
const eventTimeMs = event.timeUnixNano / 1e6;
return (
<TooltipProvider>
<Tooltip
open
onOpenChange={(open): void => {
if (!open) {
setShowPopover(false);
}
}}
>
<TooltipTrigger asChild>{dot}</TooltipTrigger>
<TooltipContent className="span-hover-card-popover">
<EventTooltipContent
eventName={event.name}
timeOffsetMs={eventTimeMs - spanTimestamp}
isError={isError}
attributeMap={event.attributeMap || {}}
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipSimple
open
onOpenChange={(open: boolean): void => {
if (!open) {
setShowPopover(false);
}
}}
title={
<EventTooltipContent
eventName={event.name}
timeOffsetMs={eventTimeMs - spanTimestamp}
isError={isError}
attributeMap={event.attributeMap || {}}
/>
}
>
{dot}
</TooltipSimple>
);
});
@@ -323,40 +316,28 @@ const SpanOverview = memo(function SpanOverview({
{/* Action buttons — shown on hover via CSS, right-aligned */}
<span className="span-row-actions">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={onSpanCopy}
>
<Link size={12} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-action-tooltip">
Copy Span Link
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={handleFunnelClick}
>
<ListPlus size={12} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-action-tooltip">
Add to Trace Funnel
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipSimple title="Copy Span Link">
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={onSpanCopy}
>
<Link size={12} />
</Button>
</TooltipSimple>
<TooltipSimple title="Add to Trace Funnel">
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={handleFunnelClick}
>
<ListPlus size={12} />
</Button>
</TooltipSimple>
</span>
</div>
</SpanHoverCard>

View File

@@ -5614,10 +5614,10 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/ui@0.0.18":
version "0.0.18"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.18.tgz#a96f843aea87d2a435ed0efc68d0a94eaae98baa"
integrity sha512-1p3ALh76kafiz5yX7ReNKVcHDt2od7CcZD/Vx9i2adTwTeynkLJcEfVoXoJD3oh1kKTleooOiOjRyxlA7VzmSA==
"@signozhq/ui@0.0.19":
version "0.0.19"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.19.tgz#125cbfb9c6bc39ace7f9a99b2b3fdd291a6bf76e"
integrity sha512-2q6aRxN/PR4PlR2xJZAREEuvLPiDFggfFKzCW2Z5vHVVbrgnvZHWD1jPUuwszfEg0ceH3UvkwqceO7wN4uRJAA==
dependencies:
"@chenglou/pretext" "^0.0.5"
"@radix-ui/react-checkbox" "^1.2.3"