Compare commits

..

1 Commits

Author SHA1 Message Date
Yunus M
9c4046d84a fix: revert the resizeable quick filter changes (#12058) 2026-07-09 19:00:19 +00:00
47 changed files with 380 additions and 1885 deletions

View File

@@ -31,12 +31,6 @@ export enum LOCALSTORAGE {
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
QUICK_FILTERS_WIDTH_LOGS = 'QUICK_FILTERS_WIDTH_LOGS',
QUICK_FILTERS_WIDTH_TRACES = 'QUICK_FILTERS_WIDTH_TRACES',
QUICK_FILTERS_WIDTH_METER = 'QUICK_FILTERS_WIDTH_METER',
QUICK_FILTERS_WIDTH_API_MONITORING = 'QUICK_FILTERS_WIDTH_API_MONITORING',
QUICK_FILTERS_WIDTH_EXCEPTIONS = 'QUICK_FILTERS_WIDTH_EXCEPTIONS',
QUICK_FILTERS_WIDTH_INFRA = 'QUICK_FILTERS_WIDTH_INFRA',
FUNNEL_STEPS = 'FUNNEL_STEPS',
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',

View File

@@ -163,23 +163,12 @@
}
&.filter-visible {
// Width is owned by ResizableBox (inline style); this section is the
// ResizableBox root, so it stays position: relative for the drag handle.
.api-quick-filter-left-section {
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
}
width: 260px;
}
.api-module-right-section {
flex: 1;
min-width: 0;
width: calc(100% - 260px);
}
}
}

View File

@@ -4,49 +4,21 @@ import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { LOCALSTORAGE } from 'constants/localStorage';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import DomainList from './Domains/DomainList';
import './Explorer.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Explorer(): JSX.Element {
useEffect(() => {
logEvent('API Monitoring: Landing page visited', {});
}, []);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_API_MONITORING,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className={cx('api-monitoring-page', 'filter-visible')}>
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className="api-quick-filter-left-section"
handleTestId="quick-filters-resize-handle"
>
<section className="api-quick-filter-left-section">
<QuickFilters
className="qf-api-monitoring"
source={QuickFiltersSource.API_MONITORING}
@@ -55,7 +27,7 @@ function Explorer(): JSX.Element {
showQueryName={false}
handleFilterVisibilityChange={(): void => {}}
/>
</ResizableBox>
</section>
<DomainList />
</div>
</Sentry.ErrorBoundary>

View File

@@ -1031,19 +1031,26 @@ function ExplorerOptions({
/>
</div>
</Modal>
<ExportPanelContainer
<Modal
footer={null}
onOk={onCancel(false)}
onCancel={onCancel(false)}
open={isExport}
onClose={onCancel(false)}
query={isOneChartPerQuery ? queryToExport : query}
isLoading={isLoading}
onExport={(dashboard, isNewDashboard): void => {
if (isOneChartPerQuery && queryToExport) {
onExport(dashboard, isNewDashboard, queryToExport);
} else {
onExport(dashboard, isNewDashboard);
}
}}
/>
centered
destroyOnClose
>
<ExportPanelContainer
query={isOneChartPerQuery ? queryToExport : query}
isLoading={isLoading}
onExport={(dashboard, isNewDashboard): void => {
if (isOneChartPerQuery && queryToExport) {
onExport(dashboard, isNewDashboard, queryToExport);
} else {
onExport(dashboard, isNewDashboard);
}
}}
/>
</Modal>
</div>
);
}

View File

@@ -179,8 +179,10 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Click the "New dashboard" button
const newDashboardButton = screen.getByTestId('export-panel-new-dashboard');
// Click the "New Dashboard" button
const newDashboardButton = screen.getByRole('button', {
name: /new dashboard/i,
});
await user.click(newDashboardButton);
// Wait for the API call to complete and onExport to be called
@@ -227,12 +229,13 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Wait for the dashboard select dropdown to render inside the dialog
const modal = screen.getByRole('dialog');
// Wait for dashboards to load and then click on the dashboard select dropdown
await waitFor(() => {
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
});
// Get the modal and find the dashboard select dropdown within it
const modal = screen.getByRole('dialog');
const dashboardSelect = modal.querySelector(
'[role="combobox"]',
) as HTMLElement;
@@ -248,13 +251,14 @@ describe('ExplorerOptionWrapper', () => {
const dashboardOption = screen.getByText(mockDashboard1.data.title);
await user.click(dashboardOption);
// Wait for the selection to be made and the export button to be enabled
// Wait for the selection to be made and the Export button to be enabled
await waitFor(() => {
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
const exportButton = screen.getByRole('button', { name: /export/i });
expect(exportButton).not.toBeDisabled();
});
// Click the export button
const exportButton = screen.getByTestId('export-panel-export');
// Click the Export button
const exportButton = screen.getByRole('button', { name: /export/i });
await user.click(exportButton);
// Wait for onExport to be called with the selected dashboard
@@ -322,12 +326,13 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Wait for the dashboard select dropdown to render inside the dialog
const modal = screen.getByRole('dialog');
// Wait for dashboards to load and then click on the dashboard select dropdown
await waitFor(() => {
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
});
// Get the modal and find the dashboard select dropdown within it
const modal = screen.getByRole('dialog');
const dashboardSelect = modal.querySelector(
'[role="combobox"]',
) as HTMLElement;
@@ -343,13 +348,14 @@ describe('ExplorerOptionWrapper', () => {
const dashboardOption = screen.getByText(mockDashboard.data.title);
await user.click(dashboardOption);
// Wait for the selection to be made and the export button to be enabled
// Wait for the selection to be made and the Export button to be enabled
await waitFor(() => {
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
const exportButton = screen.getByRole('button', { name: /export/i });
expect(exportButton).not.toBeDisabled();
});
// Click the export button
const exportButton = screen.getByTestId('export-panel-export');
// Click the Export button
const exportButton = screen.getByRole('button', { name: /export/i });
await user.click(exportButton);
// Wait for the handleExport function to be called and navigation to occur

View File

@@ -1,74 +0,0 @@
import { useMemo } from 'react';
// eslint-disable-next-line signoz/no-antd-components
import { Select, SelectProps } from 'antd';
import { Dashboard } from 'types/api/dashboard/getAll';
import { getSelectOptions } from './utils';
import styles from './ExportPanel.module.scss';
interface ExportDashboardSelectProps {
dashboards: Dashboard[];
value: string | null;
/** The picked dashboard, pinned as an option so its label survives a later search. */
selectedDashboard: Dashboard | null;
loading?: boolean;
disabled?: boolean;
onChange: (dashboardId: string) => void;
onSearch: (search: string) => void;
}
/**
* Dashboard picker for the "Add to dashboard" dialog. Server-side search (`filterOption`
* off, typing via `onSearch`); the selected dashboard is pinned as an option so its label
* survives a narrowing search, and `getPopupContainer` keeps the overlay from clipping the
* dropdown.
*/
function ExportDashboardSelect({
dashboards,
value,
selectedDashboard,
loading,
disabled,
onChange,
onSearch,
}: ExportDashboardSelectProps): JSX.Element {
const options = useMemo<SelectProps['options']>(() => {
const base = getSelectOptions(dashboards) ?? [];
if (
selectedDashboard &&
!base.some((option) => option.value === selectedDashboard.id)
) {
return [
{ label: selectedDashboard.data.title, value: selectedDashboard.id },
...base,
];
}
return base;
}, [dashboards, selectedDashboard]);
return (
<Select
className={styles.dashboardSelect}
placeholder="Select a dashboard"
showSearch
filterOption={false}
loading={loading}
disabled={disabled}
value={value}
onChange={onChange}
onSearch={onSearch}
data-testid="export-dashboard-select"
options={options}
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement ?? document.body
}
/>
);
}
ExportDashboardSelect.defaultProps = {
loading: false,
disabled: false,
};
export default ExportDashboardSelect;

View File

@@ -1,42 +0,0 @@
.body {
display: flex;
flex-direction: column;
gap: 20px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 12px;
color: var(--l2-foreground);
}
.dashboardSelect {
width: 100%;
}
.newDashboard {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--l2-border);
}
.hint {
font-size: 13px;
color: var(--l2-foreground);
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
width: 100%;
}

View File

@@ -1,155 +1,127 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { useMutation } from 'react-query';
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { useCreateExportDashboard } from 'hooks/dashboard/useCreateExportDashboard';
import { useExportDashboards } from 'hooks/dashboard/useExportDashboards';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import createDashboard from 'api/v1/dashboards/create';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import ExportDashboardSelect from './ExportDashboardSelect';
import styles from './ExportPanel.module.scss';
import { ExportPanelProps } from '.';
import {
DashboardSelect,
NewDashboardButton,
SelectWrapper,
Title,
Wrapper,
} from './styles';
import { filterOptions, getSelectOptions } from './utils';
export interface ExportPanelProps {
isLoading?: boolean;
onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void;
query: Query | null;
/** Controlled open state of the dialog. */
open: boolean;
/** Called when the dialog requests to close (Cancel / overlay / Esc). */
onClose: () => void;
}
/**
* "Add to dashboard" dialog: export the panel into an existing dashboard or a newly
* created one. Navigation is the caller's job via `onExport` (flag-aware V1/V2 editor).
*/
function ExportPanelContainer({
isLoading,
onExport,
open,
onClose,
}: ExportPanelProps): JSX.Element {
const { t } = useTranslation(['dashboard']);
// Track the object, not just the id, so export survives a search that narrows it out.
const [selectedDashboard, setSelectedDashboard] = useState<Dashboard | null>(
null,
);
const [searchText, setSearchText] = useState('');
const [dashboardId, setDashboardId] = useState<string | null>(null);
const {
dashboards,
data,
isLoading: isAllDashboardsLoading,
isFetching: isDashboardsFetching,
} = useExportDashboards(searchText);
refetch,
} = useGetAllDashboard();
const { create: createNewDashboard, isLoading: createDashboardLoading } =
useCreateExportDashboard({
title: t('new_dashboard_title', { ns: 'dashboard' }),
onCreated: (dashboard) => onExport(dashboard, true),
const { showErrorModal } = useErrorModal();
const { mutate: createNewDashboard, isLoading: createDashboardLoading } =
useMutation(createDashboard, {
onSuccess: (data) => {
if (data.data) {
onExport(data?.data, true);
}
refetch();
},
onError: (error) => {
showErrorModal(error as APIError);
},
});
// Reset on close so each open starts fresh (the dialog stays mounted).
useEffect(() => {
if (!open) {
setSelectedDashboard(null);
setSearchText('');
}
}, [open]);
const handleSelect = useCallback(
(dashboardId: string): void => {
setSelectedDashboard(
dashboards.find(({ id }) => id === dashboardId) ?? null,
);
},
[dashboards],
);
const options = useMemo(() => getSelectOptions(data?.data || []), [data]);
const handleExportClick = useCallback((): void => {
onExport(selectedDashboard, false);
}, [selectedDashboard, onExport]);
const currentSelectedDashboard = data?.data?.find(
({ id }) => id === dashboardId,
);
const isExportDisabled =
isAllDashboardsLoading || !selectedDashboard || isLoading;
onExport(currentSelectedDashboard || null, false);
}, [data, dashboardId, onExport]);
const handleSelect = useCallback(
(selectedDashboardId: string): void => {
setDashboardId(selectedDashboardId);
},
[setDashboardId],
);
const handleNewDashboard = useCallback(async () => {
try {
await createNewDashboard({
title: t('new_dashboard_title', {
ns: 'dashboard',
}),
uploadedGrafana: false,
version: ENTITY_VERSION_V5,
});
} catch (error) {
showErrorModal(error as APIError);
}
}, [createNewDashboard, t, showErrorModal]);
const isDashboardLoading = isAllDashboardsLoading || createDashboardLoading;
const isDisabled =
isAllDashboardsLoading || !options?.length || !dashboardId || isLoading;
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="Add to dashboard"
testId="export-panel-dialog"
footer={
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
size="md"
onClick={onClose}
testId="export-panel-cancel"
>
Cancel
</Button>
<Button
color="primary"
size="md"
loading={isLoading}
disabled={isExportDisabled}
onClick={handleExportClick}
testId="export-panel-export"
>
Add to dashboard
</Button>
</div>
}
>
<div className={styles.body}>
<div className={styles.field}>
<Typography.Text className={styles.label}>
Select a dashboard
</Typography.Text>
<ExportDashboardSelect
dashboards={dashboards}
value={selectedDashboard?.id ?? null}
selectedDashboard={selectedDashboard}
loading={isDashboardsFetching}
disabled={isAllDashboardsLoading || createDashboardLoading}
onChange={handleSelect}
onSearch={setSearchText}
/>
</div>
<Wrapper direction="vertical">
<Title>Export Panel</Title>
<div className={styles.newDashboard}>
<Typography.Text className={styles.hint}>
Or create a new dashboard with this panel
</Typography.Text>
<Button
variant="outlined"
color="secondary"
size="md"
prefix={<Plus size={14} />}
loading={createDashboardLoading}
disabled={createDashboardLoading}
onClick={createNewDashboard}
testId="export-panel-new-dashboard"
>
New dashboard
</Button>
</div>
</div>
</DialogWrapper>
<SelectWrapper direction="horizontal">
<DashboardSelect
placeholder="Select Dashboard"
options={options}
showSearch
loading={isDashboardLoading}
disabled={isDashboardLoading}
value={dashboardId}
onSelect={handleSelect}
filterOption={filterOptions}
/>
<Button
type="primary"
loading={isLoading}
disabled={isDisabled}
onClick={handleExportClick}
>
Export
</Button>
</SelectWrapper>
<Typography>
Or create dashboard with this panel -
<NewDashboardButton
disabled={createDashboardLoading}
loading={createDashboardLoading}
type="link"
onClick={handleNewDashboard}
>
New Dashboard
</NewDashboardButton>
</Typography>
</Wrapper>
);
}
ExportPanelContainer.defaultProps = {
isLoading: false,
};
export default ExportPanelContainer;

View File

@@ -0,0 +1,49 @@
import { useCallback, useState } from 'react';
import { Modal } from 'antd';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import ExportPanelContainer from './ExportPanelContainer';
function ExportPanel({
isLoading,
onExport,
query,
}: ExportPanelProps): JSX.Element {
const [isExport, setIsExport] = useState<boolean>(false);
const onModalToggle = useCallback((value: boolean) => {
setIsExport(value);
}, []);
const onCancel = (value: boolean) => (): void => {
onModalToggle(value);
};
return (
<Modal
footer={null}
onOk={onCancel(false)}
onCancel={onCancel(false)}
open={isExport}
centered
destroyOnClose
>
<ExportPanelContainer
query={query}
isLoading={isLoading}
onExport={onExport}
/>
</Modal>
);
}
export interface ExportPanelProps {
isLoading?: boolean;
onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void;
query: Query | null;
}
ExportPanel.defaultProps = { isLoading: false };
export default ExportPanel;

View File

@@ -0,0 +1,34 @@
import { FunctionComponent } from 'react';
import { Button, Select, SelectProps, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styled from 'styled-components';
export const DashboardSelect: FunctionComponent<SelectProps> = styled(
Select,
)<SelectProps>`
width: 100%;
`;
export const SelectWrapper = styled(Space)`
width: 100%;
margin-bottom: 1rem;
.ant-space-item:first-child {
width: 100%;
max-width: 20rem;
}
`;
export const Wrapper = styled(Space)`
width: 100%;
`;
export const NewDashboardButton = styled(Button)`
&&& {
padding: 0 0.125rem;
}
`;
export const Title = styled(Typography.Text)`
font-size: 1rem;
`;

View File

@@ -6,3 +6,11 @@ export const getSelectOptions = (data: Dashboard[]): SelectProps['options'] =>
label: data.title,
value: id,
}));
export const filterOptions: SelectProps['filterOption'] = (
input,
options,
): boolean =>
(options?.label?.toString() ?? '')
?.toLowerCase()
.includes(input.toLowerCase());

View File

@@ -6,7 +6,6 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import K8sBaseDetails from 'container/InfraMonitoringK8s/Base/K8sBaseDetails';
import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
@@ -17,8 +16,6 @@ import {
} from 'container/InfraMonitoringK8s/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { useAppContext } from 'providers/App/App';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -43,21 +40,8 @@ import { getHostsQuickFiltersConfig } from './utils';
import styles from './InfraMonitoringHosts.module.scss';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
@@ -155,18 +139,7 @@ function Hosts(): JSX.Element {
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.quickFiltersContainer}>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<Tooltip title="Collapse Filters">
@@ -183,7 +156,7 @@ function Hosts(): JSX.Element {
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleQuickFiltersChange}
/>
</ResizableBox>
</div>
)}
<div
className={`${styles.listContainer}${

View File

@@ -44,20 +44,26 @@
}
.quickFiltersContainer {
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
width: 280px;
min-width: 280px;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.ant-collapse-header) {
@@ -136,9 +142,7 @@
}
.listContainerFiltersVisible {
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
max-width: calc(100% - 280px);
}
.quickFiltersToggleContainer {

View File

@@ -6,7 +6,6 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import K8sBaseDetails from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
import { K8sBaseList } from 'container/InfraMonitoringK8sV2/Base/K8sBaseList';
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
@@ -17,8 +16,6 @@ import {
} from 'container/InfraMonitoringK8sV2/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { useAppContext } from 'providers/App/App';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -43,21 +40,8 @@ import { getHostsQuickFiltersConfig } from './utils';
import styles from './InfraMonitoringHosts.module.scss';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
@@ -155,18 +139,7 @@ function Hosts(): JSX.Element {
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.quickFiltersContainer}>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<Tooltip title="Collapse Filters">
@@ -183,7 +156,7 @@ function Hosts(): JSX.Element {
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleQuickFiltersChange}
/>
</ResizableBox>
</div>
)}
<div
className={`${styles.listContainer}${

View File

@@ -44,20 +44,26 @@
}
.quickFiltersContainer {
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
width: 280px;
min-width: 280px;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.ant-collapse-header) {
@@ -136,9 +142,7 @@
}
.listContainerFiltersVisible {
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
max-width: calc(100% - 280px);
}
.quickFiltersToggleContainer {

View File

@@ -44,15 +44,26 @@
}
.quickFiltersContainer {
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
width: 280px;
min-width: 280px;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.quick-filters) {
@@ -117,9 +128,7 @@
}
.listContainerFiltersVisible {
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
max-width: calc(100% - 280px);
}
.categorySelectorSection {
@@ -210,16 +219,8 @@
.quickFiltersSection {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
}
&::-webkit-scrollbar {
width: 0.1rem;
}

View File

@@ -6,7 +6,6 @@ import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import {
@@ -23,8 +22,6 @@ import {
Workflow,
} from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { FeatureKeys } from '../../constants/features';
@@ -59,23 +56,9 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
import styles from './InfraMonitoringK8s.module.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
export default function InfraMonitoringK8s(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [, setGroupBy] = useInfraMonitoringGroupBy();
@@ -229,18 +212,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.quickFiltersContainer}>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
@@ -293,7 +265,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
/>
)}
</div>
</ResizableBox>
</div>
)}
<div

View File

@@ -44,15 +44,26 @@
}
.quickFiltersContainer {
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
width: 280px;
min-width: 280px;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.quick-filters) {
@@ -117,9 +128,7 @@
}
.listContainerFiltersVisible {
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
max-width: calc(100% - 280px);
}
.categorySelectorSection {
@@ -210,16 +219,8 @@
.quickFiltersSection {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
}
&::-webkit-scrollbar {
width: 0.1rem;
}

View File

@@ -6,7 +6,6 @@ import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import {
@@ -23,8 +22,6 @@ import {
Workflow,
} from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { FeatureKeys } from '../../constants/features';
@@ -59,23 +56,9 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
import styles from './InfraMonitoringK8s.module.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
export default function InfraMonitoringK8s(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [, setGroupBy] = useInfraMonitoringGroupBy();
@@ -229,18 +212,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.quickFiltersContainer}>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
@@ -293,7 +265,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
/>
)}
</div>
</ResizableBox>
</div>
)}
<div

View File

@@ -34,7 +34,6 @@ import {
} from 'container/LogsExplorerViews/explorerUtils';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -53,6 +52,7 @@ import { Filter } from 'types/api/v5/queryRange';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
import { v4 } from 'uuid';
import LogsActionsContainer from './LogsActionsContainer';
@@ -76,7 +76,6 @@ function LogsExplorerViewsContainer({
handleChangeSelectedView: ChangeViewFunctionType;
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const [showFrequencyChart, setShowFrequencyChart] = useState(
() => getFromLocalstorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART) === 'true',
@@ -287,7 +286,7 @@ function LogsExplorerViewsContainer({
dashboardName: dashboard?.data?.title,
});
const dashboardEditView = getExportToDashboardLink({
const dashboardEditView = generateExportToDashboardLink({
query: exportDefaultQuery,
panelType: panelTypeParam,
dashboardId: dashboard.id,
@@ -296,12 +295,7 @@ function LogsExplorerViewsContainer({
safeNavigate(dashboardEditView);
},
[
safeNavigate,
exportDefaultQuery,
selectedPanelType,
getExportToDashboardLink,
],
[safeNavigate, exportDefaultQuery, selectedPanelType],
);
useEffect(() => {

View File

@@ -3,23 +3,12 @@
flex-direction: row;
.meter-explorer-quick-filters-section {
// Width is owned by ResizableBox (inline style).
flex-shrink: 0;
width: 280px;
border-right: 1px solid var(--l1-border);
&.hidden {
display: none;
}
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
}
}
.meter-explorer-content-section {
@@ -97,9 +86,7 @@
&.quick-filters-open {
.meter-explorer-content-section {
flex: 1;
width: auto;
min-width: 0;
width: calc(100% - 280px);
}
}

View File

@@ -7,7 +7,6 @@ import cx from 'classnames';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
@@ -19,8 +18,6 @@ import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Filter } from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -33,10 +30,6 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
import './Explorer.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Explorer(): JSX.Element {
const {
handleRunQuery,
@@ -62,16 +55,6 @@ function Explorer(): JSX.Element {
const [showQuickFilters, setShowQuickFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_METER,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const defaultQuery = useMemo(
() =>
updateAllQueriesOperators(
@@ -144,19 +127,10 @@ function Explorer(): JSX.Element {
'quick-filters-open': showQuickFilters,
})}
>
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
<div
className={cx('meter-explorer-quick-filters-section', {
hidden: !showQuickFilters,
})}
handleTestId="quick-filters-resize-handle"
>
<QuickFilters
className="qf-meter-explorer"
@@ -168,7 +142,7 @@ function Explorer(): JSX.Element {
setShowQuickFilters(!showQuickFilters);
}}
/>
</ResizableBox>
</div>
<div className="meter-explorer-content-section">
<div className="meter-explorer-explore-content">

View File

@@ -14,7 +14,6 @@ import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapp
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import {
@@ -36,6 +35,7 @@ import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
import { explorerViewToPanelType } from 'utils/explorerUtils';
import { v4 as uuid } from 'uuid';
@@ -63,7 +63,6 @@ function Explorer(): JSX.Element {
redirectWithQueryBuilderData,
} = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
@@ -279,7 +278,7 @@ function Explorer(): JSX.Element {
};
}
const dashboardEditView = getExportToDashboardLink({
const dashboardEditView = generateExportToDashboardLink({
query,
panelType: PANEL_TYPES.TIME_SERIES,
dashboardId: dashboard.id,
@@ -288,7 +287,7 @@ function Explorer(): JSX.Element {
safeNavigate(dashboardEditView);
},
[exportDefaultQuery, safeNavigate, yAxisUnit, getExportToDashboardLink],
[exportDefaultQuery, safeNavigate, yAxisUnit],
);
const splitedQueries = useMemo(

View File

@@ -1,93 +0,0 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import createDashboardV1 from 'api/v1/dashboards/create';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
import { useCreateExportDashboard } from '../useCreateExportDashboard';
jest.mock('hooks/useIsDashboardV2');
jest.mock('api/v1/dashboards/create');
jest.mock('api/generated/services/dashboard', () => ({
createDashboardV2: jest.fn(),
}));
jest.mock('providers/ErrorModalProvider', () => ({
useErrorModal: (): { showErrorModal: jest.Mock } => ({
showErrorModal: jest.fn(),
}),
}));
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const mockCreateV1 = createDashboardV1 as jest.Mock;
const mockCreateV2 = createDashboardV2 as jest.Mock;
function wrapper({ children }: { children: ReactNode }): JSX.Element {
const client = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
const TITLE = 'My dashboard';
beforeEach(() => {
jest.clearAllMocks();
});
describe('useCreateExportDashboard', () => {
it('creates via the V1 endpoint and returns the created dashboard when the flag is off', async () => {
mockUseIsDashboardV2.mockReturnValue(false);
const v1Dashboard = {
id: 'v1-new',
data: { title: TITLE },
} as unknown as Dashboard;
mockCreateV1.mockResolvedValue({ httpStatusCode: 200, data: v1Dashboard });
const onCreated = jest.fn();
const { result } = renderHook(
() => useCreateExportDashboard({ title: TITLE, onCreated }),
{ wrapper },
);
act(() => result.current.create());
await waitFor(() => expect(onCreated).toHaveBeenCalledWith(v1Dashboard));
expect(mockCreateV1).toHaveBeenCalledWith({
title: TITLE,
uploadedGrafana: false,
version: ENTITY_VERSION_V5,
});
expect(mockCreateV2).not.toHaveBeenCalled();
});
it('creates via the V2 Perses endpoint and normalizes the response when the flag is on', async () => {
mockUseIsDashboardV2.mockReturnValue(true);
mockCreateV2.mockResolvedValue({ data: { id: 'v2-new' } });
const onCreated = jest.fn();
const { result } = renderHook(
() => useCreateExportDashboard({ title: TITLE, onCreated }),
{ wrapper },
);
act(() => result.current.create());
await waitFor(() =>
expect(onCreated).toHaveBeenCalledWith(
expect.objectContaining({ id: 'v2-new', data: { title: TITLE } }),
),
);
expect(mockCreateV2).toHaveBeenCalledWith(
expect.objectContaining({
schemaVersion: 'v6',
spec: expect.objectContaining({ display: { name: TITLE } }),
}),
);
expect(mockCreateV1).not.toHaveBeenCalled();
});
});

View File

@@ -1,120 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
import { useExportDashboards } from '../useExportDashboards';
jest.mock('hooks/useIsDashboardV2');
jest.mock('hooks/dashboard/useGetAllDashboard');
jest.mock('api/generated/services/dashboard', () => ({
useListDashboardsForUserV2: jest.fn(),
}));
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const mockUseGetAllDashboard = useGetAllDashboard as jest.Mock;
const mockUseListV2 = useListDashboardsForUserV2 as jest.Mock;
const v1Refetch = jest.fn();
const v2Refetch = jest.fn();
const v1Dashboard = {
id: 'v1-1',
data: { title: 'V1 Dashboard' },
} as unknown as Dashboard;
const v1Other = {
id: 'v1-2',
data: { title: 'Other board' },
} as unknown as Dashboard;
beforeEach(() => {
jest.clearAllMocks();
mockUseGetAllDashboard.mockReturnValue({
data: { data: [v1Dashboard, v1Other] },
isLoading: false,
isFetching: false,
refetch: v1Refetch,
});
mockUseListV2.mockReturnValue({
data: {
data: {
dashboards: [
{
id: 'v2-1',
name: 'V2 Dashboard',
spec: { display: { name: 'V2 Dashboard' } },
},
],
},
},
isLoading: false,
isFetching: false,
refetch: v2Refetch,
});
});
describe('useExportDashboards', () => {
it('returns the V1 list and disables the V2 query when the flag is off', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useExportDashboards());
expect(result.current.dashboards).toStrictEqual([v1Dashboard, v1Other]);
expect(mockUseGetAllDashboard).toHaveBeenCalledWith({ enabled: true });
expect(mockUseListV2).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
query: { enabled: false, keepPreviousData: true },
}),
);
});
it('filters the V1 list in memory by title (case-insensitive)', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useExportDashboards('v1 dash'));
expect(result.current.dashboards).toStrictEqual([v1Dashboard]);
});
it('returns the V2 list normalized to the dashboard shape when the flag is on', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useExportDashboards());
expect(result.current.dashboards).toStrictEqual([
expect.objectContaining({ id: 'v2-1', data: { title: 'V2 Dashboard' } }),
]);
expect(mockUseGetAllDashboard).toHaveBeenCalledWith({ enabled: false });
expect(mockUseListV2).toHaveBeenCalledWith(
expect.objectContaining({ query: undefined }),
expect.objectContaining({
query: { enabled: true, keepPreviousData: true },
}),
);
});
it('passes the search term as a name-contains filter clause to the V2 query param', () => {
mockUseIsDashboardV2.mockReturnValue(true);
renderHook(() => useExportDashboards('payments'));
expect(mockUseListV2).toHaveBeenCalledWith(
expect.objectContaining({ query: "name CONTAINS 'payments'" }),
expect.anything(),
);
});
it('refetches the active source', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useExportDashboards());
result.current.refetch();
expect(v2Refetch).toHaveBeenCalledTimes(1);
expect(v1Refetch).not.toHaveBeenCalled();
});
});

View File

@@ -1,45 +0,0 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useGetExportToDashboardLink } from '../useGetExportToDashboardLink';
jest.mock('hooks/useIsDashboardV2');
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const query = { id: 'q1', queryType: 'builder' } as unknown as Query;
const params = {
dashboardId: 'dash-1',
panelType: PANEL_TYPES.TIME_SERIES,
query,
widgetId: 'w1',
};
describe('useGetExportToDashboardLink', () => {
it('builds a V1 new-widget link when the dashboard-v2 flag is off', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useGetExportToDashboardLink());
const link = result.current(params);
expect(link.startsWith('/dashboard/dash-1/new?')).toBe(true);
expect(link).toContain('graphType=');
expect(link).toContain('widgetId=w1');
expect(link).toContain('compositeQuery=');
});
it('builds a V2 panel/new link (ignoring widgetId) when the flag is on', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useGetExportToDashboardLink());
const link = result.current(params);
expect(link.startsWith('/dashboard/dash-1/panel/new?')).toBe(true);
expect(link).toContain('panelKind=signoz%2FTimeSeriesPanel');
expect(link).not.toContain('widgetId');
expect(link).toContain('compositeQuery=');
});
});

View File

@@ -1,88 +0,0 @@
import { useCallback } from 'react';
import { useMutation } from 'react-query';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import createDashboardV1 from 'api/v1/dashboards/create';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { Dashboard } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
interface UseCreateExportDashboardParams {
title: string;
/** Receives the created dashboard (normalized) for the caller to navigate/export. */
onCreated: (dashboard: Dashboard) => void;
}
interface UseCreateExportDashboardResult {
create: () => void;
isLoading: boolean;
}
/**
* Flag-aware "create a new dashboard to export into". V2 uses the Perses-spec
* `createDashboardV2`; V1 uses the legacy create. Both normalize to a `Dashboard`.
*/
export function useCreateExportDashboard({
title,
onCreated,
}: UseCreateExportDashboardParams): UseCreateExportDashboardResult {
const isDashboardV2 = useIsDashboardV2();
const { showErrorModal } = useErrorModal();
const onError = useCallback(
(error: unknown): void => showErrorModal(error as APIError),
[showErrorModal],
);
const v1 = useMutation(createDashboardV1, {
onSuccess: (data) => {
if (data.data) {
onCreated(data.data);
}
},
onError,
});
const v2 = useMutation(
() =>
createDashboardV2({
schemaVersion: 'v6',
generateName: true,
tags: null,
spec: {
display: { name: title },
layouts: [],
panels: {},
variables: [],
},
}),
{
onSuccess: (created) => {
onCreated({
id: created.data.id,
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
locked: false,
data: { title },
} as Dashboard);
},
onError,
},
);
const create = useCallback((): void => {
if (isDashboardV2) {
v2.mutate();
} else {
v1.mutate({ title, uploadedGrafana: false, version: ENTITY_VERSION_V5 });
}
}, [isDashboardV2, v1, v2, title]);
return {
create,
isLoading: isDashboardV2 ? v2.isLoading : v1.isLoading,
};
}

View File

@@ -1,88 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import { DashboardtypesListedDashboardForUserV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import useDebounce from 'hooks/useDebounce';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
const V2_LIST_LIMIT = 1000;
const SEARCH_DEBOUNCE_MS = 300;
export interface UseExportDashboardsResult {
dashboards: Dashboard[];
/** First load only — disables the picker until there are options. */
isLoading: boolean;
/** Any fetch incl. a search refetch — drives the picker spinner. */
isFetching: boolean;
refetch: () => void;
}
function toDashboard(
item: DashboardtypesListedDashboardForUserV2DTO,
): Dashboard {
return {
id: item.id,
createdAt: item.createdAt ?? '',
updatedAt: item.updatedAt ?? '',
createdBy: item.createdBy ?? '',
updatedBy: item.updatedBy ?? '',
locked: item.locked,
data: { title: item.spec.display?.name || item.name },
};
}
function filterByTitle(dashboards: Dashboard[], search: string): Dashboard[] {
const term = search.trim().toLowerCase();
if (!term) {
return dashboards;
}
return dashboards.filter((dashboard) =>
(dashboard.data.title ?? '').toLowerCase().includes(term),
);
}
// The V2 list `query` is a filter DSL (`key OP value`), not free text — wrap a typed term
// as a name-contains clause (single quotes escaped).
function toNameQuery(search: string): string | undefined {
const term = search.trim();
return term ? `name CONTAINS '${term.replace(/'/g, "\\'")}'` : undefined;
}
/**
* Flag-aware, search-filtered source for the "Add to dashboard" picker. V2 searches
* server-side (debounced); V1 filters its already-complete list in memory.
*/
export function useExportDashboards(search = ''): UseExportDashboardsResult {
const isDashboardV2 = useIsDashboardV2();
const debouncedSearch = useDebounce(search, SEARCH_DEBOUNCE_MS);
const v1 = useGetAllDashboard({ enabled: !isDashboardV2 });
const v2 = useListDashboardsForUserV2(
{ limit: V2_LIST_LIMIT, query: toNameQuery(debouncedSearch) },
{ query: { enabled: isDashboardV2, keepPreviousData: true } },
);
const dashboards = useMemo<Dashboard[]>(
() =>
isDashboardV2
? (v2.data?.data?.dashboards ?? []).map(toDashboard)
: filterByTitle(v1.data?.data ?? [], search),
[isDashboardV2, v1.data, v2.data, search],
);
const refetch = useCallback((): void => {
if (isDashboardV2) {
void v2.refetch();
} else {
void v1.refetch();
}
}, [isDashboardV2, v1, v2]);
return {
dashboards,
isLoading: isDashboardV2 ? v2.isLoading : v1.isLoading,
isFetching: isDashboardV2 ? v2.isFetching : v1.isFetching,
refetch,
};
}

View File

@@ -1,37 +0,0 @@
import { useCallback } from 'react';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { buildExportPanelLink } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
interface ExportToDashboardLinkParams {
dashboardId: string;
panelType: PANEL_TYPES;
query: Query;
widgetId: string;
}
/**
* Flag-aware "Add to Dashboard" link builder for the explorers. V2 targets the panel
* editor (`/dashboard/:id/panel/new`); V1 uses the legacy new-widget link. Keeps the V1
* `generateExportToDashboardLink` signature (V2 ignores `widgetId` — the id is made on save).
*/
export function useGetExportToDashboardLink(): (
params: ExportToDashboardLinkParams,
) => string {
const isDashboardV2 = useIsDashboardV2();
return useCallback(
({ dashboardId, panelType, query, widgetId }: ExportToDashboardLinkParams) =>
isDashboardV2
? buildExportPanelLink({ dashboardId, panelType, query })
: generateExportToDashboardLink({
query,
panelType,
dashboardId,
widgetId,
}),
[isDashboardV2],
);
}

View File

@@ -20,23 +20,12 @@
}
&.filter-visible {
// Width is owned by ResizableBox (inline style); this section is the
// ResizableBox root, so it stays position: relative for the drag handle.
.all-errors-quick-filter-section {
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
}
width: 260px;
}
.all-errors-right-section {
flex: 1;
min-width: 0;
width: calc(100% - 260px);
}
}
}

View File

@@ -18,18 +18,12 @@ import Toolbar from 'container/Toolbar/Toolbar';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import history from 'lib/history';
import { isNull } from 'lodash-es';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { routes } from './config';
import { useAllErrorsQueryState } from './QueryStateContext';
import './AllErrors.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function AllErrors(): JSX.Element {
const { pathname } = useLocation();
const { handleRunQuery } = useQueryBuilder();
@@ -61,38 +55,17 @@ function AllErrors(): JSX.Element {
setShowFilters((prev) => !prev);
};
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_EXCEPTIONS,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
return (
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className="all-errors-quick-filter-section"
handleTestId="quick-filters-resize-handle"
>
<section className={cx('all-errors-quick-filter-section')}>
<QuickFilters
className="qf-exceptions"
source={QuickFiltersSource.EXCEPTIONS}
signal={SignalType.EXCEPTIONS}
handleFilterVisibilityChange={handleFilterVisibilityChange}
/>
</ResizableBox>
</section>
)}
<section
className={cx(

View File

@@ -1,8 +1,4 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
buildExportPanelLink,
NEW_PANEL_ID,
newPanelSearch,
parseNewPanelKind,
@@ -35,56 +31,4 @@ describe('newPanelRoute', () => {
parseNewPanelKind(NEW_PANEL_ID, '?panelKind=NotARealPanel'),
).toBeNull();
});
describe('buildExportPanelLink', () => {
const query = { id: 'q1', queryType: 'builder' } as unknown as Query;
const parseLink = (
link: string,
): { path: string; params: URLSearchParams } => {
const [path, search] = link.split('?');
return { path, params: new URLSearchParams(search) };
};
it.each([
[PANEL_TYPES.TIME_SERIES, 'signoz/TimeSeriesPanel'],
[PANEL_TYPES.TABLE, 'signoz/TablePanel'],
[PANEL_TYPES.LIST, 'signoz/ListPanel'],
])('maps export panel type %s to kind %s', (panelType, expectedKind) => {
const link = buildExportPanelLink({
dashboardId: 'dash-1',
panelType,
query,
});
const { path, params } = parseLink(link);
expect(path).toBe('/dashboard/dash-1/panel/new');
expect(params.get('panelKind')).toBe(expectedKind);
expect(parseNewPanelKind(NEW_PANEL_ID, `?${params.toString()}`)).toBe(
expectedKind,
);
});
it('carries the query as a decodable compositeQuery param', () => {
const link = buildExportPanelLink({
dashboardId: 'dash-1',
panelType: PANEL_TYPES.TIME_SERIES,
query,
});
const { params } = parseLink(link);
expect(JSON.parse(params.get('compositeQuery') as string)).toStrictEqual(
query,
);
});
it('falls back to TimeSeries for a panel type with no V2 kind', () => {
const link = buildExportPanelLink({
dashboardId: 'dash-1',
panelType: PANEL_TYPES.EMPTY_WIDGET,
query,
});
expect(parseLink(link).params.get('panelKind')).toBe(
'signoz/TimeSeriesPanel',
);
});
});
});

View File

@@ -81,6 +81,7 @@ function PanelEditorContainer({
setSpec,
isSpecDirty,
panelDefinition,
defaultSignal,
query,
runQuery,
isQueryDirty,
@@ -166,11 +167,10 @@ function PanelEditorContainer({
onChangeSpec: setSpec,
});
// Seed a new List panel's columns from the query's resolved signal (not the kind's
// default logs signal) so a traces-List export gets traces columns, not logs.
// Seed a new List panel's default columns so the Columns control isn't empty.
useSeedNewListColumns({
enabled: isNew && isListPanel,
signal: listSignal,
signal: defaultSignal,
spec,
onChangeSpec: setSpec,
});

View File

@@ -1,17 +1,11 @@
import { generatePath } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
PANEL_KIND_TO_PANEL_TYPE,
PANEL_TYPE_TO_PANEL_KIND,
type PanelKind,
} from '../Panels/types/panelKind';
// New (unsaved) panels use a fixed id segment, carrying kind + target section in the
// query (`/panel/new?panelKind=&layoutIndex=…`); the real id is generated on save.
// New (unsaved) panels share a fixed id segment, carrying kind + target section
// in the query: `/panel/new?panelKind=signoz/ListPanel&layoutIndex=2`. The real
// id is generated on save.
export const NEW_PANEL_ID = 'new';
const PANEL_KIND_PARAM = 'panelKind';
const LAYOUT_INDEX_PARAM = 'layoutIndex';
@@ -43,31 +37,6 @@ export function parseNewPanelKind(
return kind && kind in PANEL_KIND_TO_PANEL_TYPE ? (kind as PanelKind) : null;
}
/**
* New-panel editor link that exports an explorer query into a V2 dashboard: maps panel
* type → kind (falls back to TimeSeries) and carries the raw `Query` as `compositeQuery`,
* encoded as the V1 link so `useGetCompositeQueryParam` reads it identically. Conversion
* happens in the editor; no `layoutIndex`, so exports land in the first section.
*/
export function buildExportPanelLink({
dashboardId,
panelType,
query,
}: {
dashboardId: string;
panelType: PANEL_TYPES;
query: Query;
}): string {
const kind = PANEL_TYPE_TO_PANEL_KIND[panelType] ?? 'signoz/TimeSeriesPanel';
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId: NEW_PANEL_ID,
});
return `${path}${newPanelSearch(kind)}&${
QueryParams.compositeQuery
}=${encodeURIComponent(JSON.stringify(query))}`;
}
/** Target section index for a new panel, or undefined when unset/invalid. */
export function parseNewPanelLayoutIndex(search: string): number | undefined {
const raw = new URLSearchParams(search).get(LAYOUT_INDEX_PARAM);

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from 'react';
import { useCallback, useMemo } from 'react';
import {
generatePath,
Redirect,
@@ -8,12 +8,14 @@ import {
import { Typography } from '@signozhq/ui/typography';
import Spinner from 'components/Spinner';
import ROUTES from 'constants/routes';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useDashboardFetch } from '../DashboardContainer/hooks/useDashboardFetch';
import { useDashboardEditGuard } from '../DashboardContainer/hooks/useDashboardEditGuard';
import { useResolvedVariables } from '../DashboardContainer/hooks/useResolvedVariables';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
import type { PanelEditorHandoffState } from '../DashboardContainer/PanelEditor/panelEditorHandoff';
import {
@@ -25,7 +27,6 @@ import { createDefaultPanel } from '../DashboardContainer/patchOps';
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/useSeedVariableSelection';
import { withVariablesSearch } from '../DashboardContainer/VariablesBar/variablesUrlState';
import { buildNewPanelSeed } from './newPanelSeed';
import styles from './PanelEditorPage.module.scss';
/**
@@ -72,26 +73,17 @@ function PanelEditorPage(): JSX.Element {
// Feed variables to the query builder autocomplete inside the editor.
useSyncVariablesForSuggestions(dashboard);
// An explorer "Add to Dashboard" export rides the query in `compositeQuery` (V1
// parity). Captured once at mount: the editor rewrites `compositeQuery` in the URL
// as the user edits, and re-reading it would churn the draft (its reset target and
// dirty baseline live in the initially-loaded panel).
const exportCompositeQuery = useGetCompositeQueryParam();
const exportCompositeQueryRef = useRef(exportCompositeQuery);
// A `panel/new?panelKind=…` route means "create": seed a default panel of that
// kind rather than looking one up (seeded from the exported query when present).
// Persisted (with a real id) only on save.
// kind rather than looking one up. Persisted (with a real id) only on save.
const newKind = parseNewPanelKind(panelId, search);
const existingPanel = dashboard?.spec.panels[panelId];
const panel = useMemo(() => {
if (newKind) {
// `kind` may be coerced from `newKind` (e.g. a ClickHouse query into List).
const { kind, pluginSpec, queries } = buildNewPanelSeed(
return createDefaultPanel(
newKind,
exportCompositeQueryRef.current,
buildPluginSpec(getPanelDefinition(newKind).sections),
buildDefaultQueries(newKind),
);
return createDefaultPanel(kind, pluginSpec, queries);
}
if (!existingPanel) {
return undefined;

View File

@@ -1,128 +0,0 @@
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { isQueryTypeSupported } from '../DashboardContainer/Panels/capabilities';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { SectionKind } from '../DashboardContainer/Panels/types/sections';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
import { toPerses } from '../DashboardContainer/queryV5/persesQueryAdapters';
import { buildNewPanelSeed } from './newPanelSeed';
jest.mock('../DashboardContainer/queryV5/persesQueryAdapters', () => ({
toPerses: jest.fn(),
}));
jest.mock('../DashboardContainer/Panels/utils/buildDefaultQueries', () => ({
buildDefaultQueries: jest.fn(),
}));
jest.mock('../DashboardContainer/Panels/utils/buildPluginSpec', () => ({
buildPluginSpec: jest.fn(),
}));
jest.mock('../DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
}));
jest.mock('../DashboardContainer/Panels/capabilities', () => ({
isQueryTypeSupported: jest.fn(),
}));
const mockToPerses = toPerses as jest.Mock;
const mockBuildDefaultQueries = buildDefaultQueries as jest.Mock;
const mockBuildPluginSpec = buildPluginSpec as jest.Mock;
const mockGetPanelDefinition = getPanelDefinition as jest.Mock;
const mockIsQueryTypeSupported = isQueryTypeSupported as jest.Mock;
const DEFAULT_QUERIES = [{ kind: 'default' }];
const CONVERTED_QUERIES = [{ kind: 'converted' }];
const BASE_SPEC = { visualization: { foo: 1 } };
const withUnit = [{ kind: SectionKind.Formatting, controls: { unit: true } }];
const withoutUnit = [
{ kind: SectionKind.Formatting, controls: { decimals: true } },
];
const q = (extra: Partial<Query> = {}): Query =>
({ queryType: 'builder', ...extra }) as unknown as Query;
describe('buildNewPanelSeed', () => {
beforeEach(() => {
jest.clearAllMocks();
mockBuildDefaultQueries.mockReturnValue(DEFAULT_QUERIES);
mockBuildPluginSpec.mockReturnValue(BASE_SPEC);
mockGetPanelDefinition.mockReturnValue({ sections: withUnit });
mockIsQueryTypeSupported.mockReturnValue(true);
});
it('uses the kind default seed when there is no exported query', () => {
const seed = buildNewPanelSeed('signoz/TimeSeriesPanel', null);
expect(seed.kind).toBe('signoz/TimeSeriesPanel');
expect(seed.queries).toBe(DEFAULT_QUERIES);
expect(seed.pluginSpec).toStrictEqual(BASE_SPEC);
expect(mockToPerses).not.toHaveBeenCalled();
});
it('converts the exported query into perses queries', () => {
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
const seed = buildNewPanelSeed('signoz/TimeSeriesPanel', q());
expect(seed.kind).toBe('signoz/TimeSeriesPanel');
expect(seed.queries).toBe(CONVERTED_QUERIES);
expect(seed.pluginSpec).toStrictEqual(BASE_SPEC);
});
it('coerces a builder-only kind to Table for a ClickHouse query', () => {
mockIsQueryTypeSupported.mockReturnValue(false);
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
const seed = buildNewPanelSeed(
'signoz/ListPanel',
q({ queryType: EQueryType.CLICKHOUSE }),
);
expect(seed.kind).toBe('signoz/TablePanel');
expect(seed.queries).toBe(CONVERTED_QUERIES);
});
it('coerces a builder-only kind to TimeSeries for a PromQL query', () => {
mockIsQueryTypeSupported.mockReturnValue(false);
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
const seed = buildNewPanelSeed(
'signoz/ListPanel',
q({ queryType: EQueryType.PROM }),
);
expect(seed.kind).toBe('signoz/TimeSeriesPanel');
expect(seed.queries).toBe(CONVERTED_QUERIES);
});
it('falls back to the default seed when conversion yields nothing runnable', () => {
mockToPerses.mockReturnValue([]);
const seed = buildNewPanelSeed('signoz/ListPanel', q());
expect(seed.queries).toBe(DEFAULT_QUERIES);
});
it('preserves the exported unit when the kind supports a unit', () => {
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
mockGetPanelDefinition.mockReturnValue({ sections: withUnit });
const seed = buildNewPanelSeed('signoz/TimeSeriesPanel', q({ unit: 'ms' }));
expect(seed.pluginSpec).toStrictEqual({
...BASE_SPEC,
formatting: { unit: 'ms' },
});
});
it('drops the unit for a kind without a unit control', () => {
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
mockGetPanelDefinition.mockReturnValue({ sections: withoutUnit });
const seed = buildNewPanelSeed('signoz/TablePanel', q({ unit: 'ms' }));
expect(seed.pluginSpec).toStrictEqual(BASE_SPEC);
});
});

View File

@@ -1,86 +0,0 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { isQueryTypeSupported } from '../DashboardContainer/Panels/capabilities';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { PANEL_KIND_TO_PANEL_TYPE } from '../DashboardContainer/Panels/types/panelKind';
import type { PanelKind } from '../DashboardContainer/Panels/types/panelKind';
import { SectionKind } from '../DashboardContainer/Panels/types/sections';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
import {
buildPluginSpec,
type SeededPluginSpec,
} from '../DashboardContainer/Panels/utils/buildPluginSpec';
import { toPerses } from '../DashboardContainer/queryV5/persesQueryAdapters';
interface NewPanelSeed {
/** Effective kind to create — may differ from the requested one (see below). */
kind: PanelKind;
queries: DashboardtypesQueryDTO[];
pluginSpec: SeededPluginSpec;
}
/** Whether a kind's Formatting section exposes a `unit` control (Table/List don't). */
function kindSupportsUnit(kind: PanelKind): boolean {
return getPanelDefinition(kind).sections.some(
(section) =>
section.kind === SectionKind.Formatting &&
'controls' in section &&
section.controls.unit === true,
);
}
/**
* Effective kind for an export: the requested kind, or — when it can't hold the query's
* language (only List, which is builder-only) — a compatible one so the query isn't
* dropped: PromQL → TimeSeries, ClickHouse → Table.
*/
function resolveSeededPanelKind(
requestedKind: PanelKind,
compositeQuery: Query | null,
): PanelKind {
if (
!compositeQuery ||
isQueryTypeSupported(requestedKind, compositeQuery.queryType)
) {
return requestedKind;
}
return compositeQuery.queryType === EQueryType.PROM
? 'signoz/TimeSeriesPanel'
: 'signoz/TablePanel';
}
/**
* Seed for a new panel from the explorer "Add to Dashboard" export: resolve the effective
* kind, convert the exported `compositeQuery` to perses queries, and carry a unit-bearing
* kind's explorer `unit`. Falls back to the kind's default seed when there's no query or
* conversion yields nothing runnable.
*/
export function buildNewPanelSeed(
requestedKind: PanelKind,
compositeQuery: Query | null,
): NewPanelSeed {
const kind = resolveSeededPanelKind(requestedKind, compositeQuery);
const pluginSpec = buildPluginSpec(getPanelDefinition(kind).sections);
if (!compositeQuery) {
return { kind, queries: buildDefaultQueries(kind), pluginSpec };
}
const converted = toPerses(compositeQuery, PANEL_KIND_TO_PANEL_TYPE[kind]);
const queries = converted.length > 0 ? converted : buildDefaultQueries(kind);
if (compositeQuery.unit && kindSupportsUnit(kind)) {
return {
kind,
queries,
pluginSpec: {
...pluginSpec,
formatting: { ...pluginSpec.formatting, unit: compositeQuery.unit },
},
};
}
return { kind, queries, pluginSpec };
}

View File

@@ -41,18 +41,15 @@
}
&.filter-visible {
// Width is owned by ResizableBox (inline style); this section is the
// ResizableBox root, so it stays position: relative for the drag handle.
.log-quick-filter-left-section {
width: 260px;
height: 100%;
overflow: visible;
min-height: 0;
position: relative;
z-index: 2;
.resizable-box__content {
display: flex;
flex-direction: column;
}
display: flex;
flex-direction: column;
.quick-filters-container {
flex: 1;
@@ -61,9 +58,7 @@
}
.log-module-right-section {
flex: 1;
width: auto;
min-width: 0;
width: calc(100% - 260px);
}
}
}

View File

@@ -26,8 +26,6 @@ import {
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { defaultTo, isEmpty, isNull } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { EventSourceProvider } from 'providers/EventSource';
import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
@@ -46,23 +44,9 @@ import { ExplorerViews } from './utils';
import './LogsExplorer.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function LogsExplorer(): JSX.Element {
const [showLiveLogs, setShowLiveLogs] = useState<boolean>(false);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
@@ -242,25 +226,14 @@ function LogsExplorer(): JSX.Element {
className={cx('logs-module-page', showFilters ? 'filter-visible' : '')}
>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className="log-quick-filter-left-section"
handleTestId="quick-filters-resize-handle"
>
<section className={cx('log-quick-filter-left-section')}>
<QuickFilters
className="qf-logs-explorer"
signal={SignalType.LOGS}
source={QuickFiltersSource.LOGS_EXPLORER}
handleFilterVisibilityChange={handleFilterVisibilityChange}
/>
</ResizableBox>
</section>
)}
<section className={cx('log-module-right-section')}>
<Toolbar

View File

@@ -76,8 +76,7 @@
--input-focus-border-color: var(--internal-ant-border-color-hover);
.filter {
// Width is owned by ResizableBox (inline style).
flex-shrink: 0;
width: 260px;
height: 100%;
min-height: 100vh;
@@ -85,14 +84,9 @@
border: 1px solid var(--l1-border);
background-color: var(--l1-background);
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
> .ant-card-body {
padding: 0;
width: 258px;
}
}
@@ -107,8 +101,6 @@
border-color: var(--l1-border);
}
.trace-explorer.filters-expanded {
flex: 1;
width: auto;
min-width: 0;
width: calc(100% - 260px);
}
}

View File

@@ -759,18 +759,19 @@ describe('TracesExplorer -', () => {
expect(createDashboardBtn).toBeInTheDocument();
fireEvent.click(createDashboardBtn);
const createDashboardModal = (await screen.findByRole(
'dialog',
)) as HTMLElement;
await expect(screen.findByText('Export Panel')).resolves.toBeInTheDocument();
const createDashboardModal = document.querySelector(
'.ant-modal-content',
) as HTMLElement;
expect(createDashboardModal).toBeInTheDocument();
// assert modal content
expect(
within(createDashboardModal).getByTestId('export-panel-new-dashboard'),
within(createDashboardModal).getByText('Select Dashboard'),
).toBeInTheDocument();
expect(
within(createDashboardModal).getByTestId('export-panel-export'),
within(createDashboardModal).getByText('New Dashboard'),
).toBeInTheDocument();
});

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
@@ -27,7 +28,6 @@ import { defaultSelectedColumns } from 'container/TracesExplorer/ListView/config
import QuerySection from 'container/TracesExplorer/QuerySection';
import TableView from 'container/TracesExplorer/TableView';
import TracesView from 'container/TracesExplorer/TracesView';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
@@ -41,12 +41,11 @@ import { isEmpty } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { TOOLBAR_VIEWS } from 'pages/TracesExplorer/constants';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { Warning } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
import {
explorerViewToPanelType,
getExplorerViewFromUrl,
@@ -63,10 +62,6 @@ import TimeSeriesView from './TimeSeriesView';
import './TracesExplorer.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function TracesExplorer(): JSX.Element {
const {
panelType,
@@ -122,16 +117,6 @@ function TracesExplorer(): JSX.Element {
const [warning, setWarning] = useState<Warning | undefined>();
const [isOpen, setOpen] = useState<boolean>(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_TRACES,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const defaultQuery = useMemo(
(): Query =>
updateAllQueriesOperators(
@@ -144,7 +129,6 @@ function TracesExplorer(): JSX.Element {
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const handleChangeSelectedView = useCallback(
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
@@ -232,7 +216,7 @@ function TracesExplorer(): JSX.Element {
dashboardName: dashboard?.data?.title,
});
const dashboardEditView = getExportToDashboardLink({
const dashboardEditView = generateExportToDashboardLink({
query,
panelType: panelTypeParam,
dashboardId: dashboard.id,
@@ -241,13 +225,7 @@ function TracesExplorer(): JSX.Element {
safeNavigate(dashboardEditView);
},
[
exportDefaultQuery,
panelType,
safeNavigate,
options,
getExportToDashboardLink,
],
[exportDefaultQuery, panelType, safeNavigate, options],
);
useShareBuilderUrl({ defaultValue: defaultQuery });
@@ -272,29 +250,16 @@ function TracesExplorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="trace-explorer-page">
{isOpen && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className="filter"
handleTestId="quick-filters-resize-handle"
>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.TRACES_EXPLORER}
signal={SignalType.TRACES}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
/>
</ResizableBox>
)}
<Card className="filter" hidden={!isOpen}>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.TRACES_EXPLORER}
signal={SignalType.TRACES}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
/>
</Card>
<div
className={cx('trace-explorer', {
'filters-expanded': isOpen,

View File

@@ -1,5 +1,6 @@
.resizable-box {
position: relative;
overflow: hidden;
&--disabled {
flex: 1;
@@ -17,17 +18,6 @@
z-index: 10;
background: var(--l2-border);
// Extend the interactive area beyond the 1px visual line so the handle
// is easy to grab and double-click, without changing its appearance.
&::before {
content: '';
position: absolute;
top: -4px;
right: -4px;
bottom: -4px;
left: -4px;
}
&:hover,
&:active {
background: var(--primary);
@@ -65,29 +55,4 @@
right: 0;
}
}
// Visible grip indicator (opt-in via the `withHandle` prop). Purely visual —
// pointer events fall through to the handle so it still owns drag + reset.
&__grip {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 26px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l1-background);
color: var(--l2-foreground);
pointer-events: none;
}
&__handle:hover &__grip,
&__handle:active &__grip {
border-color: var(--primary);
color: var(--primary);
}
}

View File

@@ -1,4 +1,3 @@
import { GripVertical } from '@signozhq/icons';
import { useCallback, useRef, useState } from 'react';
import './ResizableBox.styles.scss';
@@ -12,28 +11,15 @@ export interface ResizableBoxProps {
// resize (width). Dragging the handle away from the content grows the box;
// dragging it toward the content shrinks it.
handle?: ResizableBoxHandle;
// Canonical default size, and the target that double-click reset restores to.
defaultHeight?: number;
minHeight?: number;
maxHeight?: number;
defaultWidth?: number;
minWidth?: number;
maxWidth?: number;
// Starting size when different from the default (e.g. a persisted value).
// Falls back to defaultWidth/defaultHeight when omitted, preserving the
// behavior of callers that don't opt in.
initialWidth?: number;
initialHeight?: number;
// When true, double-clicking the handle resets the size to
// defaultWidth/defaultHeight and fires onResize with that value.
resetToDefaultOnDoubleClick?: boolean;
// When true, renders a visible grip indicator on the handle so it is
// discoverable as a draggable affordance.
withHandle?: boolean;
onResize?: (size: number) => void;
disabled?: boolean;
className?: string;
handleTestId?: string;
}
function ResizableBox({
@@ -45,22 +31,13 @@ function ResizableBox({
defaultWidth = 200,
minWidth = 50,
maxWidth = Infinity,
initialWidth,
initialHeight,
resetToDefaultOnDoubleClick = false,
withHandle = false,
onResize,
disabled = false,
className,
handleTestId,
}: ResizableBoxProps): JSX.Element {
const isHorizontal = handle === 'left' || handle === 'right';
const isStartHandle = handle === 'top' || handle === 'left';
const [size, setSize] = useState(
isHorizontal
? (initialWidth ?? defaultWidth)
: (initialHeight ?? defaultHeight),
);
const [size, setSize] = useState(isHorizontal ? defaultWidth : defaultHeight);
const containerRef = useRef<HTMLDivElement>(null);
const handleMouseDown = useCallback(
@@ -106,21 +83,6 @@ function ResizableBox({
],
);
const handleDoubleClick = useCallback((): void => {
if (!resetToDefaultOnDoubleClick) {
return;
}
const nextSize = isHorizontal ? defaultWidth : defaultHeight;
setSize(nextSize);
onResize?.(nextSize);
}, [
resetToDefaultOnDoubleClick,
isHorizontal,
defaultWidth,
defaultHeight,
onResize,
]);
const containerStyle = disabled
? undefined
: isHorizontal
@@ -137,22 +99,7 @@ function ResizableBox({
style={containerStyle}
>
<div className="resizable-box__content">{children}</div>
{!disabled && (
<div
role="separator"
aria-orientation={isHorizontal ? 'vertical' : 'horizontal'}
className={handleClass}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
data-testid={handleTestId}
>
{withHandle && (
<span className="resizable-box__grip">
<GripVertical size={12} />
</span>
)}
</div>
)}
{!disabled && <div className={handleClass} onMouseDown={handleMouseDown} />}
</div>
);
}

View File

@@ -1,137 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import ResizableBox from '../ResizableBox';
const HANDLE_TEST_ID = 'resize-handle';
describe('ResizableBox', () => {
it('starts at defaultWidth when initialWidth is omitted', () => {
render(
<ResizableBox
handle="right"
defaultWidth={260}
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
const box = screen.getByTestId(HANDLE_TEST_ID).parentElement as HTMLElement;
expect(box.style.width).toBe('260px');
});
it('starts at initialWidth when provided', () => {
render(
<ResizableBox
handle="right"
defaultWidth={260}
initialWidth={340}
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
const box = screen.getByTestId(HANDLE_TEST_ID).parentElement as HTMLElement;
expect(box.style.width).toBe('340px');
});
it('resets to defaultWidth and fires onResize on double-click when enabled', () => {
const onResize = jest.fn();
render(
<ResizableBox
handle="right"
defaultWidth={260}
initialWidth={480}
onResize={onResize}
resetToDefaultOnDoubleClick
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
const handle = screen.getByTestId(HANDLE_TEST_ID);
const box = handle.parentElement as HTMLElement;
expect(box.style.width).toBe('480px');
fireEvent.doubleClick(handle);
expect(box.style.width).toBe('260px');
expect(onResize).toHaveBeenCalledWith(260);
});
it('does nothing on double-click when reset is not enabled', () => {
const onResize = jest.fn();
render(
<ResizableBox
handle="right"
defaultWidth={260}
initialWidth={480}
onResize={onResize}
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
const handle = screen.getByTestId(HANDLE_TEST_ID);
const box = handle.parentElement as HTMLElement;
fireEvent.doubleClick(handle);
expect(box.style.width).toBe('480px');
expect(onResize).not.toHaveBeenCalled();
});
it('renders a visible grip only when withHandle is set', () => {
const { rerender, container } = render(
<ResizableBox
handle="right"
defaultWidth={260}
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
expect(container.querySelector('.resizable-box__grip')).toBeNull();
rerender(
<ResizableBox
handle="right"
defaultWidth={260}
withHandle
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
expect(container.querySelector('.resizable-box__grip')).not.toBeNull();
});
it('clamps drag to maxWidth and reports the clamped size via onResize', () => {
const onResize = jest.fn();
render(
<ResizableBox
handle="right"
defaultWidth={260}
minWidth={240}
maxWidth={500}
onResize={onResize}
handleTestId={HANDLE_TEST_ID}
>
<div>content</div>
</ResizableBox>,
);
const handle = screen.getByTestId(HANDLE_TEST_ID);
const box = handle.parentElement as HTMLElement;
fireEvent.mouseDown(handle, { clientX: 0 });
fireEvent.mouseMove(document, { clientX: 1000 });
fireEvent.mouseUp(document);
expect(box.style.width).toBe('500px');
expect(onResize).toHaveBeenLastCalledWith(500);
});
});

View File

@@ -1,89 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import usePanelWidth from '../usePanelWidth';
jest.mock('api/browser/localstorage/get');
jest.mock('api/browser/localstorage/set');
const mockedGet = getLocalStorageKey as jest.MockedFunction<
typeof getLocalStorageKey
>;
const mockedSet = setLocalStorageKey as jest.MockedFunction<
typeof setLocalStorageKey
>;
const ARGS = {
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
defaultWidth: 260,
minWidth: 240,
maxWidth: 500,
};
describe('usePanelWidth', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('returns defaultWidth when nothing is persisted', () => {
mockedGet.mockReturnValue(null);
const { result } = renderHook(() => usePanelWidth(ARGS));
expect(result.current.initialWidth).toBe(260);
});
it('returns the persisted width when present', () => {
mockedGet.mockReturnValue('340');
const { result } = renderHook(() => usePanelWidth(ARGS));
expect(result.current.initialWidth).toBe(340);
});
it('clamps an out-of-bounds persisted width on read', () => {
mockedGet.mockReturnValue('9999');
const { result } = renderHook(() => usePanelWidth(ARGS));
expect(result.current.initialWidth).toBe(500);
});
it('falls back to defaultWidth for an invalid persisted value', () => {
mockedGet.mockReturnValue('not-a-number');
const { result } = renderHook(() => usePanelWidth(ARGS));
expect(result.current.initialWidth).toBe(260);
});
it('persists a clamped width (debounced)', () => {
mockedGet.mockReturnValue(null);
const { result } = renderHook(() => usePanelWidth(ARGS));
act(() => {
result.current.persistWidth(320);
jest.advanceTimersByTime(200);
});
expect(mockedSet).toHaveBeenCalledWith(
LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
'320',
);
});
it('clamps below-min widths before persisting', () => {
mockedGet.mockReturnValue(null);
const { result } = renderHook(() => usePanelWidth(ARGS));
act(() => {
result.current.persistWidth(10);
jest.advanceTimersByTime(200);
});
expect(mockedSet).toHaveBeenCalledWith(
LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
'240',
);
});
});

View File

@@ -1,68 +0,0 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import debounce from 'lodash-es/debounce';
import { useCallback, useMemo, useRef } from 'react';
const PERSIST_DEBOUNCE_MS = 150;
interface UsePanelWidthArgs {
/** Per-page localStorage key the width is persisted under. */
storageKey: LOCALSTORAGE;
/** Canonical default width, used when nothing is persisted. */
defaultWidth: number;
minWidth: number;
maxWidth: number;
}
interface UsePanelWidthReturn {
/** Width to start from: the persisted value (clamped) or the default. */
initialWidth: number;
/** Clamp and persist a width. Debounced to avoid a write per mousemove. */
persistWidth: (width: number) => void;
}
const clamp = (value: number, min: number, max: number): number =>
Math.min(max, Math.max(min, value));
/**
* Per-page localStorage persistence for a resizable panel width. Mirrors the
* getLocalStorageKey/setLocalStorageKey idiom used for the trace span-details
* panel position. Pairs with ResizableBox: feed initialWidth into its
* initialWidth prop and persistWidth into its onResize.
*/
function usePanelWidth({
storageKey,
defaultWidth,
minWidth,
maxWidth,
}: UsePanelWidthArgs): UsePanelWidthReturn {
// Read once on mount. Kept in a ref so a re-render doesn't re-read storage.
const initialWidthRef = useRef<number | null>(null);
if (initialWidthRef.current === null) {
const stored = getLocalStorageKey(storageKey);
const parsed = stored !== null && stored !== '' ? Number(stored) : NaN;
initialWidthRef.current = Number.isFinite(parsed)
? clamp(parsed, minWidth, maxWidth)
: defaultWidth;
}
const debouncedWrite = useMemo(
() =>
debounce((width: number): void => {
setLocalStorageKey(storageKey, String(width));
}, PERSIST_DEBOUNCE_MS),
[storageKey],
);
const persistWidth = useCallback(
(width: number): void => {
debouncedWrite(clamp(width, minWidth, maxWidth));
},
[debouncedWrite, minWidth, maxWidth],
);
return { initialWidth: initialWidthRef.current, persistWidth };
}
export default usePanelWidth;