Compare commits

...

7 Commits

Author SHA1 Message Date
Abhi Kumar
61fd904add chore(frontend): move shared chart and variable code out of the v1 namespace
container/DashboardContainer held nothing but shared code once the v1
dashboard was deleted — a directory named after a component that no
longer exists.

- visualization/ -> container/Visualization (v2 panels, PanelWrapper,
  API Monitoring, Billing, Meter Explorer, TimeSeriesView, alert previews)
- the variable helpers and addTagFiltersToDashboard -> container/DashboardVariables
- PanelTypeSelectionModal/menuItems -> container/Visualization/panelTypeMenuItems

Pure relocation: no file contents changed beyond import paths.

Assisted-by: Claude Opus 5
2026-08-13 14:25:24 +05:30
Abhi Kumar
b4f81ab76b chore(frontend): retire the v1 panel editor route
Nothing links to /dashboard/:dashboardId/:widgetId any more, and the
page it served loaded through the v1 dashboard GET, which the backend
answers with a deprecation error. Drop the route, its permission and
nav entries, pages/DashboardWidget, and everything under
container/NewWidget that only it reached.

The shared leaves stay put for now — timeItems, alertFomatCategories,
dataFormatCategories, Threshold and RightContainer types, ContextLinks
utils, PlotTag, QueryTypeTag, the promQL/ClickHouse query sections and
NewWidget/utils are all imported app-wide. Moving them out of the v1
namespace is a follow-up rename.

LogsPanelComponent's test went with the editor; it mounted NewWidget as
a harness to render the logs panel table.

Assisted-by: Claude Opus 5
2026-08-13 13:44:26 +05:30
Abhi Kumar
3b2959bd41 chore(frontend): export meter explorer panels to the v2 panel editor
Meter Explorer was the last caller of generateExportToDashboardLink,
which targeted the v1 editor. Move it onto useGetExportToDashboardLink
like the logs, traces and metrics explorers, and delete the v1 builder.
The ExplorerOptions export test now asserts the v2 panel URL.

Assisted-by: Claude Opus 5
2026-08-13 13:44:11 +05:30
Abhi Kumar
cc61c63aa6 chore(frontend): drop the full-view switch-to-edit-mode button
The button linked into the v1 panel editor. GridCard's full view is
never rendered inside a v2 dashboard, so the link only appeared when
useDashboardStore still held a previously-visited dashboard, and then
pointed at a route the backend already rejects. Reset Query and the
panel type selector are unaffected.

Assisted-by: Claude Opus 5
2026-08-13 13:43:57 +05:30
Abhi Kumar
529857c38b chore(frontend): drop the v1 dashboard hooks, api clients and types
Every /api/v1/dashboards handler is now a deprecation stub server-side,
so the clients for create, list, delete and lock — plus the public
dashboard create/update/revoke calls and the hooks and types around them
— were dead on both ends.

NewWidget's placement test went with useDashboardBootstrap; the old
panel editor it covers is retired in the next phase.
2026-08-13 13:20:28 +05:30
Abhi Kumar
2721ef6643 chore(frontend): drop the dead v1 grid layout and panel switch
GridCardLayout.tsx, WidgetRow and DashboardEmptyState were only ever
mounted by the v1 dashboard container; GridPanelSwitch's index and types
went with them. The shared GridCard, WidgetHeader and GridPanelSwitch
utils stay — they still back Celery, API Monitoring, Messaging Queues,
Metrics and the v2 panels.
2026-08-13 13:20:10 +05:30
Abhi Kumar
93b8fef1a5 chore(frontend): route dashboards at v2 and drop the v1 page tree
The dashboard and dashboard-list routes already rendered v2 through
one-line shims, leaving the whole v1 render tree unreachable. Point
the route components at pages/DashboardPageV2 and
pages/DashboardsListPageV2 directly and delete the orphaned tree.
2026-08-13 13:19:52 +05:30
351 changed files with 163 additions and 31140 deletions

View File

@@ -94,17 +94,12 @@ export const OnboardingV2 = Loadable(
export const DashboardsListPage = Loadable(
() =>
import(
/* webpackChunkName: "DashboardsListPage" */ 'pages/DashboardsListPage'
/* webpackChunkName: "DashboardsListPage" */ 'pages/DashboardsListPageV2'
),
);
export const DashboardPage = Loadable(
() => import(/* webpackChunkName: "DashboardPage" */ 'pages/DashboardPage'),
);
export const DashboardWidget = Loadable(
() =>
import(/* webpackChunkName: "DashboardWidgetPage" */ 'pages/DashboardWidget'),
() => import(/* webpackChunkName: "DashboardPage" */ 'pages/DashboardPageV2'),
);
export const DashboardPanelEditorPage = Loadable(

View File

@@ -13,7 +13,6 @@ import {
DashboardPage,
DashboardPanelEditorPage,
DashboardsListPage,
DashboardWidget,
EditRulesPage,
ErrorDetails,
ForgotPassword,
@@ -183,13 +182,6 @@ const routes: AppRoutes[] = [
isPrivate: false,
key: 'PUBLIC_DASHBOARD',
},
{
path: ROUTES.DASHBOARD_WIDGET,
exact: true,
component: DashboardWidget,
isPrivate: true,
key: 'DASHBOARD_WIDGET',
},
{
path: ROUTES.DASHBOARD_PANEL_EDITOR,
exact: true,

View File

@@ -1,36 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { CreatePublicDashboardProps } from 'types/api/dashboard/public/create';
/**
* @deprecated Use the generated `useCreatePublicDashboard` hook (or `createPublicDashboard` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const createPublicDashboard = async (
props: CreatePublicDashboardProps,
): Promise<SuccessResponseV2<CreatePublicDashboardProps>> => {
const { dashboardId, timeRangeEnabled = false, defaultTimeRange = DEFAULT_TIME_RANGE } = props;
try {
const response = await axios.post(
`/dashboards/${dashboardId}/public`,
{ timeRangeEnabled, defaultTimeRange },
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default createPublicDashboard;

View File

@@ -1,27 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { GetPublicDashboardMetaProps, PayloadProps,PublicDashboardMetaProps } from 'types/api/dashboard/public/getMeta';
/**
* @deprecated Use the generated `useGetPublicDashboard` hook (or `getPublicDashboard` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const getPublicDashboardMeta = async (props: GetPublicDashboardMetaProps): Promise<SuccessResponseV2<PublicDashboardMetaProps>> => {
try {
const response = await axios.get<PayloadProps>(`/dashboards/${props.id}/public`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getPublicDashboardMeta;

View File

@@ -1,29 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps,RevokePublicDashboardAccessProps } from 'types/api/dashboard/public/delete';
/**
* @deprecated Use the generated `useDeletePublicDashboard` hook (or `deletePublicDashboard` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const revokePublicDashboardAccess = async (
props: RevokePublicDashboardAccessProps,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.delete<PayloadProps>(`/dashboards/${props.id}/public`);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default revokePublicDashboardAccess;

View File

@@ -1,36 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { UpdatePublicDashboardProps } from 'types/api/dashboard/public/update';
/**
* @deprecated Use the generated `useUpdatePublicDashboard` hook (or `updatePublicDashboard` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const updatePublicDashboard = async (
props: UpdatePublicDashboardProps,
): Promise<SuccessResponseV2<UpdatePublicDashboardProps>> => {
const { dashboardId, timeRangeEnabled = false, defaultTimeRange = DEFAULT_TIME_RANGE } = props;
try {
const response = await axios.put(
`/dashboards/${dashboardId}/public`,
{ timeRangeEnabled, defaultTimeRange },
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default updatePublicDashboard;

View File

@@ -1,23 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/dashboard/create';
import { Dashboard } from 'types/api/dashboard/getAll';
const create = async (props: Props): Promise<SuccessResponseV2<Dashboard>> => {
try {
const response = await axios.post<PayloadProps>('/dashboards', {
...props,
});
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default create;

View File

@@ -1,19 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Dashboard, PayloadProps } from 'types/api/dashboard/getAll';
const getAll = async (): Promise<SuccessResponseV2<Dashboard[]>> => {
try {
const response = await axios.get<PayloadProps>('/dashboards');
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getAll;

View File

@@ -1,21 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/dashboard/delete';
const deleteDashboard = async (
props: Props,
): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.delete<PayloadProps>(`/dashboards/${props.id}`);
return {
httpStatusCode: response.status,
data: null,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default deleteDashboard;

View File

@@ -1,20 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/dashboard/get';
import { Dashboard } from 'types/api/dashboard/getAll';
const get = async (props: Props): Promise<SuccessResponseV2<Dashboard>> => {
try {
const response = await axios.get<PayloadProps>(`/dashboards/${props.id}`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default get;

View File

@@ -1,23 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/dashboard/lockUnlock';
const lock = async (props: Props): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.put<PayloadProps>(
`/dashboards/${props.id}/lock`,
{ lock: props.lock },
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default lock;

View File

@@ -1,585 +0,0 @@
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import configureStore from 'redux-mock-store';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import VariableItem from '../../../container/DashboardContainer/DashboardVariablesSelection/VariableItem';
// Mock the dashboard variables query
jest.mock('api/dashboard/variables/dashboardVariablesQuery', () => ({
__esModule: true,
default: jest.fn(() =>
Promise.resolve({
payload: {
variableValues: ['option1', 'option2', 'option3', 'option4'],
},
}),
),
}));
// Mock scrollIntoView which isn't available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Constants
const TEST_VARIABLE_NAME = 'test_variable';
const TEST_VARIABLE_ID = 'test-var-id';
// Create a mock store
const mockStore = configureStore([])({
globalTime: {
minTime: Date.now() - 3600000, // 1 hour ago
maxTime: Date.now(),
},
});
// Test data
const createMockVariable = (
overrides: Partial<IDashboardVariable> = {},
): IDashboardVariable => ({
id: TEST_VARIABLE_ID,
name: TEST_VARIABLE_NAME,
description: 'Test variable description',
type: 'QUERY',
queryValue: 'SELECT DISTINCT value FROM table',
customValue: '',
sort: 'ASC',
multiSelect: false,
showALLOption: true,
selectedValue: [],
allSelected: false,
...overrides,
});
function TestWrapper({ children }: { children: React.ReactNode }): JSX.Element {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return (
<Provider store={mockStore}>
<QueryClientProvider client={queryClient}>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 300, itemHeight: 40 }}
>
{children}
</VirtuosoMockContext.Provider>
</QueryClientProvider>
</Provider>
);
}
describe('VariableItem Integration Tests', () => {
let user: ReturnType<typeof userEvent.setup>;
let mockOnValueUpdate: jest.Mock;
beforeEach(() => {
user = userEvent.setup();
mockOnValueUpdate = jest.fn();
jest.clearAllMocks();
});
// ===== 1. INTEGRATION WITH CUSTOMSELECT =====
describe('CustomSelect Integration (VI)', () => {
it('VI-01: Single select variable integration', async () => {
const variable = createMockVariable({
multiSelect: false,
type: 'CUSTOM',
customValue: 'option1,option2,option3',
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Should render with CustomSelect
const combobox = screen.getByRole('combobox');
expect(combobox).toBeInTheDocument();
await user.click(combobox);
await waitFor(() => {
expect(screen.getByText('option1')).toBeInTheDocument();
expect(screen.getByText('option2')).toBeInTheDocument();
expect(screen.getByText('option3')).toBeInTheDocument();
});
// Select an option
const option1 = screen.getByText('option1');
await user.click(option1);
expect(mockOnValueUpdate).toHaveBeenCalledWith(
TEST_VARIABLE_NAME,
TEST_VARIABLE_ID,
'option1',
false,
);
});
});
// ===== 2. INTEGRATION WITH CUSTOMMULTISELECT =====
describe('CustomMultiSelect Integration (VI)', () => {
it('VI-02: Multi select variable integration', async () => {
const variable = createMockVariable({
multiSelect: true,
type: 'CUSTOM',
customValue: 'option1,option2,option3,option4',
showALLOption: true,
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Should render with CustomMultiSelect
const combobox = screen.getByRole('combobox');
expect(combobox).toBeInTheDocument();
await user.click(combobox);
await waitFor(() => {
// Should show ALL option
expect(screen.getByText('ALL')).toBeInTheDocument();
});
// Wait for Virtuoso to render the custom options
await waitFor(
() => {
expect(screen.getByText('option1')).toBeInTheDocument();
expect(screen.getByText('option2')).toBeInTheDocument();
expect(screen.getByText('option3')).toBeInTheDocument();
expect(screen.getByText('option4')).toBeInTheDocument();
},
{ timeout: 5000 },
);
});
});
// ===== 3. TEXTBOX VARIABLE TYPE =====
describe('Textbox Variable Integration', () => {
it('VI-03: Textbox variable handling', async () => {
const variable = createMockVariable({
type: 'TEXTBOX',
selectedValue: 'initial-value',
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Should render a regular input
const textInput = screen.getByDisplayValue('initial-value');
expect(textInput).toBeInTheDocument();
expect(textInput.tagName).toBe('INPUT');
// Clear and type new value
await user.clear(textInput);
await user.type(textInput, 'new-text-value');
// Blur the input to trigger the value update
await user.tab();
// Should call onValueUpdate after blur
await waitFor(
() => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(
TEST_VARIABLE_NAME,
TEST_VARIABLE_ID,
'new-text-value',
false,
);
},
{ timeout: 1000 },
);
});
});
// ===== 4. VALUE PERSISTENCE AND STATE MANAGEMENT =====
describe('Value Persistence and State Management', () => {
it('VI-04: All selected state handling', () => {
const variable = createMockVariable({
multiSelect: true,
type: 'CUSTOM',
customValue: 'service1,service2,service3',
selectedValue: ['service1', 'service2', 'service3'],
allSelected: true,
showALLOption: true,
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Should show "ALL" instead of individual values
expect(screen.getByText('ALL')).toBeInTheDocument();
});
it('VI-05: Dropdown behavior with temporary selections', async () => {
const variable = createMockVariable({
multiSelect: true,
type: 'CUSTOM',
customValue: 'item1,item2,item3',
selectedValue: ['item1'],
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
const combobox = screen.getByRole('combobox');
await user.click(combobox);
// Wait for dropdown to open
await waitFor(() => {
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toBeInTheDocument();
});
// Verify the component renders without crashing
expect(combobox).toBeInTheDocument();
});
});
// ===== 6. ACCESSIBILITY AND USER EXPERIENCE =====
describe('Accessibility and User Experience', () => {
it('VI-06: Variable description tooltip', async () => {
const variable = createMockVariable({
description: 'This variable controls the service selection',
type: 'CUSTOM',
customValue: 'service1,service2',
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Should show info icon
const infoIcon = document.querySelector('.info-icon');
expect(infoIcon).toBeInTheDocument();
// Hover to show tooltip
if (infoIcon) {
await user.hover(infoIcon);
}
await waitFor(() => {
expect(
screen.getByText('This variable controls the service selection'),
).toBeInTheDocument();
});
});
it('VI-07: Variable name display', () => {
const variable = createMockVariable({
name: 'service_name',
type: 'CUSTOM',
customValue: 'service1,service2',
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Should show variable name with $ prefix
expect(screen.getByText('$service_name')).toBeInTheDocument();
});
it('VI-08: Max tag count behavior', async () => {
const variable = createMockVariable({
multiSelect: true,
type: 'CUSTOM',
customValue: 'tag1,tag2,tag3,tag4,tag5,tag6,tag7',
selectedValue: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'],
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Wait for component to render
await waitFor(() => {
const combobox = screen.getByRole('combobox');
expect(combobox).toBeInTheDocument();
});
// Should show limited number of tags with "+ X more"
const tags = document.querySelectorAll('.ant-select-selection-item');
// The component should render without crashing
expect(tags.length).toBeGreaterThanOrEqual(0);
});
});
// ===== 8. SEARCH INTERACTION TESTS =====
describe('Search Interaction Tests', () => {
it('VI-14: Search persistence across dropdown open/close', async () => {
const variable = createMockVariable({
type: 'CUSTOM',
customValue: 'option1,option2,option3',
multiSelect: true,
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
const combobox = screen.getByRole('combobox');
await user.click(combobox);
await waitFor(() => {
expect(screen.getByText('ALL')).toBeInTheDocument();
});
const searchInput = document.querySelector(
'.ant-select-selection-search-input',
);
expect(searchInput).toBeInTheDocument();
if (searchInput) {
await user.type(searchInput, 'search-text');
}
// Verify search text is in input
await waitFor(() => {
expect(searchInput).toHaveValue('search-text');
});
// Press Escape to close dropdown
await user.keyboard('{Escape}');
// Dropdown should close and search text should be cleared
await waitFor(() => {
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toHaveClass('ant-select-dropdown-hidden');
expect(searchInput).toHaveValue('');
});
});
});
// ===== 9. ADVANCED KEYBOARD NAVIGATION =====
describe('Advanced Keyboard Navigation (VI)', () => {
it('VI-15: Shift + Arrow + Del chip deletion in multiselect', async () => {
const variable = createMockVariable({
type: 'CUSTOM',
customValue: 'option1,option2,option3',
multiSelect: true,
selectedValue: ['option1', 'option2', 'option3'],
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
const combobox = screen.getByRole('combobox');
await user.click(combobox);
// Navigate to chips using arrow keys
await user.keyboard('{ArrowLeft}');
// Use Shift + Arrow to navigate between chips
await user.keyboard('{Shift>}{ArrowLeft}{/Shift}');
// Use Del to delete the active chip
await user.keyboard('{Delete}');
// Note: The component may not immediately call onValueUpdate
// This test verifies the chip deletion behavior
await waitFor(() => {
// Check if a chip was removed from the selection
const selectionItems = document.querySelectorAll(
'.ant-select-selection-item',
);
expect(selectionItems.length).toBeLessThan(3);
});
});
});
// ===== 11. ADVANCED UI STATES =====
describe('Advanced UI States (VI)', () => {
it('VI-19: No data with previous value selected in variable', async () => {
const variable = createMockVariable({
type: 'CUSTOM',
customValue: '',
multiSelect: true,
selectedValue: ['previous-value'],
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Wait for component to initialize
await waitFor(() => {
const combobox = screen.getByRole('combobox');
expect(combobox).toBeInTheDocument();
});
const combobox = screen.getByRole('combobox');
await user.click(combobox);
// Should show no data message (the component may not show this exact text)
await waitFor(() => {
// Check if dropdown is empty or shows no data indication
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toBeInTheDocument();
});
// Verify the component renders without crashing
expect(combobox).toBeInTheDocument();
});
it('VI-20: Always editable accessibility in variable', async () => {
const variable = createMockVariable({
type: 'CUSTOM',
customValue: 'option1,option2',
multiSelect: true,
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
const combobox = screen.getByRole('combobox');
// Should be editable
expect(combobox).not.toBeDisabled();
await user.click(combobox);
expect(combobox).toHaveFocus();
// Should still be interactive
expect(combobox).not.toBeDisabled();
});
});
// ===== 13. DROPDOWN PERSISTENCE =====
describe('Dropdown Persistence (VI)', () => {
it('VI-24: Dropdown stays open for non-save actions in variable', async () => {
const variable = createMockVariable({
type: 'CUSTOM',
customValue: 'option1,option2,option3',
multiSelect: true,
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Wait for component to initialize
await waitFor(() => {
const combobox = screen.getByRole('combobox');
expect(combobox).toBeInTheDocument();
});
const combobox = screen.getByRole('combobox');
await user.click(combobox);
await waitFor(() => {
expect(screen.getByText('ALL')).toBeInTheDocument();
});
// Navigate with arrow keys (non-save action)
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
// Dropdown should still be open
await waitFor(() => {
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toBeInTheDocument();
});
// Verify the component renders without crashing
expect(combobox).toBeInTheDocument();
// Only ESC should close the dropdown
await user.keyboard('{Escape}');
await waitFor(() => {
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toHaveClass('ant-select-dropdown-hidden');
});
});
});
});

View File

@@ -1,4 +1,4 @@
import { uniqueOptions } from 'container/DashboardContainer/DashboardVariablesSelection/util';
import { uniqueOptions } from 'container/DashboardVariables/util';
import { OptionData } from './types';

View File

@@ -16,7 +16,6 @@ const ROUTES = {
APPLICATION: '/services',
ALL_DASHBOARD: '/dashboard',
DASHBOARD: '/dashboard/:dashboardId',
DASHBOARD_WIDGET: '/dashboard/:dashboardId/:widgetId',
DASHBOARD_PANEL_EDITOR: '/dashboard/:dashboardId/panel/:panelId',
EDIT_ALERTS: '/alerts/edit',
LIST_ALL_ALERT: '/alerts',

View File

@@ -11,7 +11,7 @@ import {
getStatusCodeBarChartWidgetData,
statusCodeWidgetInfo,
} from 'container/ApiMonitoring/utils';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import BarChart from 'container/Visualization/charts/BarChart/BarChart';
import { handleGraphClick } from 'container/GridCardLayout/GridCard/utils';
import { useGraphClickToShowButton } from 'container/GridCardLayout/useGraphClickToShowButton';
import useNavigateToExplorerPages from 'container/GridCardLayout/useNavigateToExplorerPages';

View File

@@ -1,7 +1,7 @@
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { buildBaseConfig } from 'container/Visualization/panels/utils/baseConfigBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';

View File

@@ -21,15 +21,12 @@ interface MockQueryResult {
}
// Mocks
jest.mock(
'container/DashboardContainer/visualization/charts/BarChart/BarChart',
() => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(() => <div data-testid="bar-chart-mock" />),
}),
);
jest.mock('container/Visualization/charts/BarChart/BarChart', () => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(() => <div data-testid="bar-chart-mock" />),
}));
jest.mock('components/CeleryTask/useGetGraphCustomSeries', () => ({
useGetGraphCustomSeries: (): { getCustomSeries: jest.Mock } => ({

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo, useRef } from 'react';
import { Card, Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import BarChart from 'container/Visualization/charts/BarChart/BarChart';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';

View File

@@ -1,8 +1,8 @@
import { Color } from '@signozhq/design-tokens';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { getInitialStackedBands } from 'container/Visualization/charts/utils/stackSeriesUtils';
import { buildBaseConfig } from 'container/Visualization/panels/utils/baseConfigBuilder';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';

View File

@@ -1,440 +0,0 @@
.dashboard-description-container {
box-shadow: none;
border: none;
background: unset;
box-shadow: none;
color: var(--l2-foreground);
.ant-card-body {
padding: 0px;
}
.dashboard-details {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 16px 16px 0px 16px;
align-items: flex-start;
.left-section {
display: flex;
align-items: center;
gap: 8px;
width: 45%;
.dashboard-img {
height: 16px;
width: 16px;
}
.dashboard-title {
color: var(--l1-foreground);
font-family: Inter;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 150% */
letter-spacing: -0.08px;
max-width: 80%;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.public-dashboard-icon {
margin-right: 4px;
}
}
.right-section {
display: flex;
width: 55%;
justify-content: flex-end;
flex-wrap: wrap;
align-items: center;
gap: 14px;
.icons {
display: flex;
align-items: center;
width: 32px;
height: 34px;
padding: 6px;
justify-content: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.icons:hover {
background-color: unset;
}
.configure-button {
display: flex;
align-items: center;
width: 93px;
height: 34px;
padding: 6px;
justify-content: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.add-panel-btn {
display: flex;
width: 119px;
height: 34px;
padding: 5.937px 11.875px;
justify-content: center;
align-items: center;
color: var(--primary-foreground);
background: var(--primary-background);
font-family: Inter;
font-size: 11.875px;
font-style: normal;
font-weight: 500;
line-height: 17.812px; /* 150% */
}
}
}
.dashboard-tags {
display: flex;
gap: 6px;
padding: 16px 16px 0px 16px;
flex-wrap: wrap;
.tag {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
text-align: center;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
margin-inline-end: 0px;
}
}
.dashboard-description-section {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
padding: 20px 16px 0px 16px;
/* Preserve author-entered line breaks in the description. */
white-space: pre-wrap;
overflow-wrap: anywhere;
a {
color: var(--accent-primary);
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
}
.dashboard-variables {
padding: 16px 16px 18px 16px;
}
}
.dashboard-description {
display: -webkit-box;
-webkit-line-clamp: 2; /* Show up to 2 lines */
-webkit-box-orient: vertical;
text-overflow: ellipsis;
overflow: hidden;
}
.dashboard-settings {
width: 191px;
height: 302px;
flex-shrink: 0;
.ant-popover-inner {
padding: 0px;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
) !important;
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
.menu-content {
display: flex;
flex-direction: column;
section {
display: flex;
flex-direction: column;
align-items: start;
.ant-btn {
display: flex;
width: 100%;
height: unset;
padding: 8px;
align-items: center;
gap: 12px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
border-top: none;
.ant-btn-icon {
margin-inline-end: 0px;
}
}
}
.section-1,
.section-2 {
border-bottom: 1px solid var(--l1-border);
}
.delete-dashboard .ant-btn {
color: var(--bg-cherry-400) !important;
}
}
}
.rename-dashboard {
.ant-modal-content {
width: 384px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
padding: 0px;
.ant-modal-header {
height: 52px;
padding: 16px;
background: var(--l2-background);
border-bottom: 1px solid var(--l1-border);
margin-bottom: 0px;
.ant-modal-title {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
width: 349px;
height: 20px;
}
}
.ant-modal-body {
padding: 16px;
.dashboard-content {
display: flex;
flex-direction: column;
gap: 8px;
.name-text {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
}
.dashboard-name-input {
display: flex;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
align-self: stretch;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
}
.ant-modal-footer {
padding: 16px;
margin-top: 0px;
.dashboard-rename {
display: flex;
flex-direction: row-reverse;
gap: 12px;
.cancel-btn {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 2px;
background: var(--l1-border);
.ant-btn-icon {
margin-inline-end: 0px;
}
}
.rename-btn {
display: flex;
align-items: center;
display: flex;
width: 169px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 2px;
background: var(--primary-background);
.ant-btn-icon {
margin-inline-end: 0px;
}
}
}
}
}
}
.section-naming {
.ant-modal-content {
width: 384px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
padding: 0px;
.ant-modal-header {
height: 52px;
padding: 16px;
background: var(--l2-background);
border-bottom: 1px solid var(--l1-border);
margin-bottom: 0px;
.ant-modal-title {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
width: 349px;
height: 20px;
}
}
.ant-modal-body {
padding: 16px;
.section-naming-content {
display: flex;
flex-direction: column;
gap: 8px;
.name-text {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
}
.section-name-input {
display: flex;
width: 320px;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
align-self: stretch;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
}
.ant-modal-footer {
padding: 16px;
margin-top: 0px;
.dashboard-rename {
display: flex;
flex-direction: row-reverse;
gap: 12px;
.cancel-btn {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 2px;
background: var(--l1-border);
.ant-btn-icon {
margin-inline-end: 0px;
}
}
.rename-btn {
display: flex;
align-items: center;
display: flex;
width: 140px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 2px;
background: var(--primary-background);
.ant-btn-icon {
margin-inline-end: 0px;
}
}
}
}
}
}

View File

@@ -1,43 +0,0 @@
.settings-container-root {
.ant-drawer-wrapper-body {
border-left: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
.ant-drawer-header {
height: 48px;
border-bottom: 1px solid var(--l1-border);
padding: 14px 14px 14px 11px;
.ant-drawer-header-title {
gap: 16px;
.ant-drawer-title {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding-left: 16px;
border-left: 1px solid var(--l1-border);
}
.ant-drawer-close {
height: 16px;
width: 16px;
margin-inline-end: 0px !important;
}
}
}
.ant-drawer-body {
padding: 16px;
&::-webkit-scrollbar {
width: 0.1rem;
}
}
}
}

View File

@@ -1,34 +0,0 @@
import { memo, PropsWithChildren, ReactElement } from 'react';
import { Drawer } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import './SettingsDrawer.styles.scss';
type SettingsDrawerProps = PropsWithChildren<{
drawerTitle: string;
isOpen: boolean;
onClose: () => void;
}>;
function SettingsDrawer({
children,
drawerTitle,
isOpen,
onClose,
}: SettingsDrawerProps): JSX.Element {
return (
<Drawer
title={drawerTitle}
placement="right"
width="50%"
onClose={onClose}
open={isOpen}
rootClassName="settings-container-root"
>
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
<OverlayScrollbar>{children as ReactElement}</OverlayScrollbar>
</Drawer>
);
}
export default memo(SettingsDrawer);

View File

@@ -1,235 +0,0 @@
import { ReactNode } from 'react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import {
getDashboardById,
getNonIntegrationDashboardById,
} from 'mocks-server/__mockdata__/dashboards';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
resetDashboard,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { Dashboard } from 'types/api/dashboard/getAll';
import DashboardDescription from '..';
function DashboardBootstrapWrapper({
dashboardId,
children,
}: {
dashboardId: string;
children: ReactNode;
}): JSX.Element {
useDashboardBootstrap(dashboardId);
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{children}</>;
}
interface MockSafeNavigateReturn {
safeNavigate: jest.MockedFunction<(url: string) => void>;
}
const DASHBOARD_TEST_ID = 'dashboard-title';
const DASHBOARD_TITLE_TEXT = 'thor';
const DASHBOARD_PATH = '/dashboard/4';
const mockSafeNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
}));
jest.mock(
'container/TopNav/DateTimeSelectionV2/index.tsx',
() =>
function MockDateTimeSelection(): JSX.Element {
return <div>MockDateTimeSelection</div>;
},
);
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): MockSafeNavigateReturn => ({
safeNavigate: mockSafeNavigate,
}),
}));
describe('Dashboard landing page actions header tests', () => {
beforeEach(() => {
mockSafeNavigate.mockClear();
sessionStorage.clear();
resetDashboard();
});
it('unlock dashboard should be disabled for integrations created dashboards', async () => {
const mockLocation = {
pathname: `${process.env.FRONTEND_API_ENDPOINT}${DASHBOARD_PATH}`,
search: '',
};
(useLocation as jest.Mock).mockReturnValue(mockLocation);
const { getByTestId } = render(
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
<DashboardBootstrapWrapper dashboardId="4">
<DashboardDescription
handle={{
active: false,
enter: (): Promise<void> => Promise.resolve(),
exit: (): Promise<void> => Promise.resolve(),
node: { current: null },
}}
/>
</DashboardBootstrapWrapper>
</MemoryRouter>,
);
await waitFor(() =>
expect(getByTestId(DASHBOARD_TEST_ID)).toHaveTextContent(
DASHBOARD_TITLE_TEXT,
),
);
const dashboardSettingsTrigger = getByTestId('options');
await fireEvent.click(dashboardSettingsTrigger);
const lockUnlockButton = screen.getByTestId('lock-unlock-dashboard');
await waitFor(() => expect(lockUnlockButton).toBeDisabled());
});
it('unlock dashboard should not be disabled for non integration created dashboards', async () => {
const mockLocation = {
pathname: `${process.env.FRONTEND_API_ENDPOINT}${DASHBOARD_PATH}`,
search: '',
};
(useLocation as jest.Mock).mockReturnValue(mockLocation);
server.use(
rest.get('http://localhost/api/v1/dashboards/4', (_, res, ctx) =>
res(ctx.status(200), ctx.json(getNonIntegrationDashboardById)),
),
);
const { getByTestId } = render(
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
<DashboardBootstrapWrapper dashboardId="4">
<DashboardDescription
handle={{
active: false,
enter: (): Promise<void> => Promise.resolve(),
exit: (): Promise<void> => Promise.resolve(),
node: { current: null },
}}
/>
</DashboardBootstrapWrapper>
</MemoryRouter>,
);
await waitFor(() =>
expect(getByTestId(DASHBOARD_TEST_ID)).toHaveTextContent(
DASHBOARD_TITLE_TEXT,
),
);
const dashboardSettingsTrigger = getByTestId('options');
await fireEvent.click(dashboardSettingsTrigger);
const lockUnlockButton = screen.getByTestId('lock-unlock-dashboard');
await waitFor(() => expect(lockUnlockButton).not.toBeDisabled());
});
it('should navigate to base dashboard list URL when no saved params exist', async () => {
const user = userEvent.setup();
const mockLocation = {
pathname: DASHBOARD_PATH,
search: '',
};
(useLocation as jest.Mock).mockReturnValue(mockLocation);
const { getByText } = render(
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
<DashboardBootstrapWrapper dashboardId="4">
<DashboardDescription
handle={{
active: false,
enter: (): Promise<void> => Promise.resolve(),
exit: (): Promise<void> => Promise.resolve(),
node: { current: null },
}}
/>
</DashboardBootstrapWrapper>
</MemoryRouter>,
);
await waitFor(() =>
expect(screen.getByTestId(DASHBOARD_TEST_ID)).toHaveTextContent(
DASHBOARD_TITLE_TEXT,
),
);
const dashboardButton = getByText('Dashboard /');
await user.click(dashboardButton);
expect(mockSafeNavigate).toHaveBeenCalledWith('/dashboard');
});
it('should navigate to dashboard list with saved query params when present', async () => {
const user = userEvent.setup();
const savedParams = 'columnKey=createdAt&order=ascend&page=2&search=foo';
sessionStorage.setItem('dashboardsListQueryParams', savedParams);
const mockLocation = {
pathname: DASHBOARD_PATH,
search: '',
};
(useLocation as jest.Mock).mockReturnValue(mockLocation);
useDashboardStore.setState({
dashboardData: getDashboardById.data as unknown as Dashboard,
layouts: [],
panelMap: {},
setPanelMap: jest.fn(),
setLayouts: jest.fn(),
setDashboardData: jest.fn(),
columnWidths: {},
});
const { getByText } = render(
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
<DashboardDescription
handle={{
active: false,
enter: (): Promise<void> => Promise.resolve(),
exit: (): Promise<void> => Promise.resolve(),
node: { current: null },
}}
/>
</MemoryRouter>,
);
await waitFor(() =>
expect(screen.getByTestId(DASHBOARD_TEST_ID)).toHaveTextContent(
DASHBOARD_TITLE_TEXT,
),
);
const dashboardButton = getByText('Dashboard /');
await user.click(dashboardButton);
expect(mockSafeNavigate).toHaveBeenCalledWith({
pathname: '/dashboard',
search: `?${savedParams}`,
});
});
});

View File

@@ -1,621 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { Layout } from 'react-grid-layout';
import { useTranslation } from 'react-i18next';
import { useCopyToClipboard } from 'react-use';
import {
Check,
ClipboardCopy,
Ellipsis,
FileJson,
FolderKanban,
Fullscreen,
Globe,
LockKeyhole,
PenLine,
Plus,
X,
} from '@signozhq/icons';
import { Button, Card, Modal, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboardMeta';
import { useLockDashboard } from 'hooks/dashboard/useLockDashboard';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import useComponentPermission from 'hooks/useComponentPermission';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import {
selectIsDashboardLocked,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { sortLayout } from 'providers/Dashboard/util';
import { DashboardData } from 'types/api/dashboard/getAll';
import { Props } from 'types/api/dashboard/update';
import { ROLES, USER_ROLES } from 'types/roles';
import { linkifyText } from 'utils/linkifyText';
import { ComponentTypes } from 'utils/permission';
import { v4 as uuid } from 'uuid';
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import DashboardSettings from '../DashboardSettings';
import { Base64Icons } from '../DashboardSettings/General/utils';
import DashboardVariableSelection from '../DashboardVariablesSelection';
import PanelTypeSelectionModal from '../PanelTypeSelectionModal';
import SettingsDrawer from './SettingsDrawer';
import { VariablesSettingsTab } from './types';
import {
DEFAULT_ROW_NAME,
downloadObjectAsJson,
sanitizeDashboardData,
} from './utils';
import './Description.styles.scss';
interface DashboardDescriptionProps {
handle: FullScreenHandle;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { handle } = props;
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const {
dashboardData,
panelMap,
setPanelMap,
layouts,
setLayouts,
setDashboardData,
} = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const handleDashboardLockToggle = useLockDashboard();
const variablesSettingsTabHandle = useRef<VariablesSettingsTab>(null);
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const isPublicDashboardEnabled = isCloudUser || isEnterpriseSelfHostedUser;
const selectedData = dashboardData
? {
...dashboardData.data,
uuid: dashboardData.id,
}
: ({} as DashboardData);
const { dashboardVariables } = useDashboardVariables();
const {
title = '',
description,
tags,
image = Base64Icons[0],
} = selectedData || {};
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const [sectionName, setSectionName] = useState<string>(DEFAULT_ROW_NAME);
const updateDashboardMutation = useUpdateDashboard();
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const [isDashboardSettingsOpen, setIsDashbordSettingsOpen] =
useState<boolean>(false);
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
useState<boolean>(false);
const [isPanelNameModalOpen, setIsPanelNameModalOpen] =
useState<boolean>(false);
const [isPublicDashboard, setIsPublicDashboard] = useState<boolean>(false);
let isAuthor = false;
if (dashboardData && user && user.email) {
isAuthor = dashboardData?.createdBy === user?.email;
}
let permissions: ComponentTypes[] = ['add_panel'];
if (isDashboardLocked) {
permissions = ['add_panel_locked_dashboard'];
}
const { notifications } = useNotifications();
const userRole: ROLES | null =
dashboardData?.createdBy === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: user.role;
const [addPanelPermission] = useComponentPermission(permissions, userRole);
const onEmptyWidgetHandler = useCallback(() => {
setIsPanelTypeSelectionModalOpen(true);
logEvent('Dashboard Detail: Add new panel clicked', {
dashboardId: dashboardData?.id,
dashboardName: dashboardData?.data.title,
numberOfPanels: dashboardData?.data.widgets?.length,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setIsPanelTypeSelectionModalOpen]);
const handleLockDashboardToggle = (): void => {
setIsDashbordSettingsOpen(false);
handleDashboardLockToggle(!isDashboardLocked);
};
const onNameChangeHandler = (): void => {
if (!dashboardData) {
return;
}
const updatedDashboard: Props = {
id: dashboardData.id,
data: {
...dashboardData.data,
title: updatedTitle,
},
};
updateDashboardMutation.mutate(updatedDashboard, {
onSuccess: (updatedDashboard) => {
notifications.success({
message: 'Dashboard renamed successfully',
});
setIsRenameDashboardOpen(false);
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
}
},
onError: () => {
setIsRenameDashboardOpen(true);
},
});
};
const [state, setCopy] = useCopyToClipboard();
const { t } = useTranslation(['dashboard', 'common']);
// used to set the initial value for the updatedTitle
// the context value is sometimes not available during the initial render
// due to which the updatedTitle is set to some previous value
useEffect(() => {
if (dashboardData) {
setUpdatedTitle(dashboardData.data.title);
}
}, [dashboardData]);
useEffect(() => {
if (state.error) {
notifications.error({
message: t('something_went_wrong', {
ns: 'common',
}),
});
}
if (state.value) {
notifications.success({
message: t('success', {
ns: 'common',
}),
});
}
}, [state.error, state.value, t, notifications]);
function handleAddRow(): void {
if (!dashboardData) {
return;
}
const id = uuid();
const newRowWidgetMap: { widgets: Layout[]; collapsed: boolean } = {
widgets: [],
collapsed: false,
};
const currentRowIdx = 0;
for (let j = currentRowIdx; j < layouts.length; j++) {
if (!panelMap[layouts[j].i]) {
newRowWidgetMap.widgets.push(layouts[j]);
} else {
break;
}
}
const updatedDashboard: Props = {
id: dashboardData.id,
data: {
...dashboardData.data,
layout: [
{
i: id,
w: 12,
minW: 12,
minH: 1,
maxH: 1,
x: 0,
h: 1,
y: 0,
},
...layouts.filter((e) => e.i !== PANEL_TYPES.EMPTY_WIDGET),
],
panelMap: { ...panelMap, [id]: newRowWidgetMap },
widgets: [
...(dashboardData.data.widgets || []),
{
id,
title: sectionName,
description: '',
panelTypes: PANEL_GROUP_TYPES.ROW,
},
],
},
};
updateDashboardMutation.mutate(updatedDashboard, {
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
if (updatedDashboard.data.data.layout) {
setLayouts(sortLayout(updatedDashboard.data.data.layout));
}
setDashboardData(updatedDashboard.data);
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
}
setIsPanelNameModalOpen(false);
setSectionName(DEFAULT_ROW_NAME);
},
});
}
const {
data: publicDashboardResponse,
isLoading: isLoadingPublicDashboardData,
isFetching: isFetchingPublicDashboardData,
error: errorPublicDashboardData,
isError: isErrorPublicDashboardData,
} = useGetPublicDashboardMeta(
dashboardData?.id || '',
!!dashboardData?.id && isPublicDashboardEnabled,
);
useEffect(() => {
if (!isLoadingPublicDashboardData && !isFetchingPublicDashboardData) {
if (isErrorPublicDashboardData) {
const errorDetails = errorPublicDashboardData?.getErrorDetails();
if (errorDetails?.error?.code === 'public_dashboard_not_found') {
setIsPublicDashboard(false);
}
} else {
const publicDashboardData = publicDashboardResponse?.data;
if (publicDashboardData?.publicPath) {
setIsPublicDashboard(true);
}
}
}
}, [
isLoadingPublicDashboardData,
isFetchingPublicDashboardData,
isErrorPublicDashboardData,
errorPublicDashboardData,
publicDashboardResponse?.data,
]);
const onConfigureClick = useCallback((): void => {
setIsSettingsDrawerOpen(true);
}, []);
const onSettingsDrawerClose = useCallback((): void => {
setIsSettingsDrawerOpen(false);
// good use case for a state library like Jotai
if (variablesSettingsTabHandle.current) {
variablesSettingsTabHandle.current.resetState();
}
}, []);
return (
<Card className="dashboard-description-container">
<DashboardHeader />
<section className="dashboard-details">
<div className="left-section">
<img src={image} alt="dashboard-img" className="dashboard-img" />
<Tooltip title={title.length > 30 ? title : ''}>
<Typography.Text
className="dashboard-title"
data-testid="dashboard-title"
>
{title}
</Typography.Text>
</Tooltip>
{isPublicDashboard && (
<Tooltip title="This dashboard is publicly accessible">
<Globe size={14} className="public-dashboard-icon" />
</Tooltip>
)}
{isDashboardLocked && (
<Tooltip title="This dashboard is locked">
<LockKeyhole size={14} className="lock-dashboard-icon" />
</Tooltip>
)}
</div>
<div className="right-section">
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
<Popover
open={isDashboardSettingsOpen}
arrow={false}
onOpenChange={(visible): void => setIsDashbordSettingsOpen(visible)}
rootClassName="dashboard-settings"
content={
<div className="menu-content">
<section className="section-1">
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
<Tooltip
title={
dashboardData?.createdBy === 'integration' &&
'Dashboards created by integrations cannot be unlocked'
}
>
<Button
type="text"
icon={<LockKeyhole size={14} />}
disabled={dashboardData?.createdBy === 'integration'}
onClick={handleLockDashboardToggle}
data-testid="lock-unlock-dashboard"
>
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Button>
</Tooltip>
)}
{!isDashboardLocked && editDashboard && (
<Button
type="text"
icon={<PenLine size={14} />}
onClick={(): void => {
setIsRenameDashboardOpen(true);
setIsDashbordSettingsOpen(false);
}}
>
Rename
</Button>
)}
<Button
type="text"
icon={<Fullscreen size={14} />}
onClick={handle.enter}
>
Full screen
</Button>
</section>
<section className="section-2">
{!isDashboardLocked && addPanelPermission && (
<Button
type="text"
icon={<FolderKanban size={14} />}
onClick={(): void => {
setIsPanelNameModalOpen(true);
setIsDashbordSettingsOpen(false);
}}
>
New section
</Button>
)}
<Button
type="text"
icon={<FileJson size={14} />}
onClick={(): void => {
downloadObjectAsJson(
sanitizeDashboardData(selectedData),
selectedData.title,
);
setIsDashbordSettingsOpen(false);
}}
>
Export JSON
</Button>
<Button
type="text"
icon={<ClipboardCopy size={14} />}
onClick={(): void => {
setCopy(
JSON.stringify(sanitizeDashboardData(selectedData), null, 2),
);
setIsDashbordSettingsOpen(false);
}}
>
Copy as JSON
</Button>
</section>
<section className="delete-dashboard">
<DeleteButton
createdBy={dashboardData?.createdBy || ''}
name={dashboardData?.data.title || ''}
id={String(dashboardData?.id) || ''}
isLocked={isDashboardLocked}
routeToListPage
/>
</section>
</div>
}
trigger="click"
placement="bottomRight"
>
<Button
icon={<Ellipsis size={14} />}
type="text"
className="icons"
data-testid="options"
/>
</Popover>
{!isDashboardLocked && editDashboard && (
<>
<Button
type="text"
className="configure-button"
icon={<ConfigureIcon />}
data-testid="show-drawer"
onClick={onConfigureClick}
>
Configure
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={onSettingsDrawerClose}
>
<DashboardSettings
variablesSettingsTabHandle={variablesSettingsTabHandle}
/>
</SettingsDrawer>
</>
)}
{!isDashboardLocked && addPanelPermission && (
<Button
className="add-panel-btn"
onClick={onEmptyWidgetHandler}
icon={<Plus size="md" />}
type="primary"
data-testid="add-panel-header"
>
New Panel
</Button>
)}
</div>
</section>
{(tags?.length || 0) > 0 && (
<div className="dashboard-tags">
{tags?.map((tag) => (
<Badge key={tag} className="tag" color="vanilla">
{tag}
</Badge>
))}
</div>
)}
{!isEmpty(description) && (
<section className="dashboard-description-section">
{linkifyText(description ?? '')}
</section>
)}
{!isEmpty(dashboardVariables) && (
<section className="dashboard-variables">
<DashboardVariableSelection />
</section>
)}
<PanelTypeSelectionModal />
<Modal
open={isRenameDashboardOpen}
title="Rename Dashboard"
onOk={(): void => {
// handle update dashboard here
}}
onCancel={(): void => {
setIsRenameDashboardOpen(false);
}}
rootClassName="rename-dashboard"
footer={
<div className="dashboard-rename">
<Button
type="primary"
icon={<Check size={14} />}
className="rename-btn"
onClick={onNameChangeHandler}
disabled={updateDashboardMutation.isLoading}
>
Rename Dashboard
</Button>
<Button
type="text"
icon={<X size={14} />}
className="cancel-btn"
onClick={(): void => setIsRenameDashboardOpen(false)}
>
Cancel
</Button>
</div>
}
>
<div className="dashboard-content">
<Typography.Text className="name-text">Enter a new name</Typography.Text>
<Input
data-testid="dashboard-name"
className="dashboard-name-input"
value={updatedTitle}
onChange={(e): void => setUpdatedTitle(e.target.value)}
/>
</div>
</Modal>
<Modal
open={isPanelNameModalOpen}
title="New Section"
rootClassName="section-naming"
onOk={(): void => handleAddRow()}
onCancel={(): void => {
setIsPanelNameModalOpen(false);
setSectionName(DEFAULT_ROW_NAME);
}}
footer={
<div className="dashboard-rename">
<Button
type="primary"
icon={<Check size={14} />}
className="rename-btn"
onClick={(): void => handleAddRow()}
disabled={updateDashboardMutation.isLoading}
>
Create Section
</Button>
<Button
type="text"
icon={<X size={14} />}
className="cancel-btn"
onClick={(): void => {
setIsPanelNameModalOpen(false);
setSectionName(DEFAULT_ROW_NAME);
}}
>
Cancel
</Button>
</div>
}
>
<div className="section-naming-content">
<Typography.Text className="name-text">Enter Section name</Typography.Text>
<Input
data-testid="section-name"
className="section-name-input"
value={sectionName}
onChange={(e): void => setSectionName(e.target.value)}
/>
</div>
</Modal>
</Card>
);
}
export default DashboardDescription;

View File

@@ -1,8 +0,0 @@
import { MutableRefObject } from 'react';
export interface VariablesSettingsTab {
resetState: () => void;
}
export type VariablesSettingsTabHandle =
MutableRefObject<VariablesSettingsTab | null>;

View File

@@ -1,40 +0,0 @@
import { DashboardData, IDashboardVariable } from 'types/api/dashboard/getAll';
export function sanitizeDashboardData(
selectedData: DashboardData,
): DashboardData {
if (!selectedData?.variables) {
return selectedData;
}
const updatedVariables = Object.entries(selectedData.variables).reduce(
(acc, [key, value]) => {
const { selectedValue: _selectedValue, ...rest } = value;
acc[key] = rest;
return acc;
},
{} as Record<string, IDashboardVariable>,
);
return {
...selectedData,
variables: updatedVariables,
};
}
export function downloadObjectAsJson(
exportObj: unknown,
exportName: string,
): void {
const dataStr = `data:text/json;charset=utf-8,${encodeURIComponent(
JSON.stringify(exportObj),
)}`;
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute('href', dataStr);
downloadAnchorNode.setAttribute('download', `${exportName}.json`);
document.body.appendChild(downloadAnchorNode); // required for firefox
downloadAnchorNode.click();
downloadAnchorNode.remove();
}
export const DEFAULT_ROW_NAME = 'Sample Row';

View File

@@ -1,123 +0,0 @@
.delete-variable-name {
font-weight: 700;
color: rgb(207, 19, 34);
font-style: italic;
}
.apply-to-all-variable-name {
font-weight: 700;
color: var(--bg-robin-400);
font-style: italic;
}
.dashboard-variable-settings-table {
.variable-name-drag {
display: flex;
align-items: center;
gap: 10px;
.ant-table-cell {
padding: 0px !important;
border: none !important;
color: var(--bg-robin-400);
font-family: 'Space Mono';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
}
.variable-description-actions {
display: flex;
align-items: center;
gap: 10px;
flex: 1 0 0;
.variable-description {
color: var(--bg-sienna-400);
font-family: 'Space Mono';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
.actions-btns {
position: absolute;
right: 10px;
display: none;
&:hover {
display: inline-flex;
}
.edit-variable-button {
width: 26px;
height: 22px;
border-radius: 2px;
display: flex;
padding: 4px 6px;
align-items: center;
gap: 3px;
background: var(--l1-border);
}
.delete-variable-button {
width: 26px;
height: 22px;
border-radius: 2px;
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
display: flex;
padding: 4px 6px;
align-items: center;
gap: 3px;
color: red;
}
.apply-to-all-button {
width: min-content;
height: 22px;
border-radius: 2px;
display: flex;
padding: 0px 6px;
align-items: center;
gap: 3px;
background: var(--l1-border);
}
}
}
.ant-table-cell-row-hover {
.actions-btns {
display: inline-flex;
}
}
.ant-table-thead {
.ant-table-cell {
padding: 8px 12px;
border: 1px solid var(--l1-border);
background: unset;
}
.ant-table-cell::before {
display: none;
}
}
.ant-table-tbody {
.ant-table-cell {
padding: 14px;
border: 1px solid var(--l1-border);
}
}
.ant-table-row {
.ant-table-cell:nth-child(even) {
background: color-mix(in srgb, var(--l1-border) 40%, transparent);
}
}
}

View File

@@ -1,62 +0,0 @@
.settings-tabs {
.ant-tabs-nav-list {
height: 32px;
flex-shrink: 0;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
transition: opacity 0.1s !important;
.ant-tabs-tab + .ant-tabs-tab {
margin: 0px;
}
.ant-tabs-tab:not(:last-child) {
border-right: 1px solid var(--l1-border) !important;
}
.overview-btn {
width: 114px;
display: flex;
align-items: center;
justify-content: center;
}
.variables-btn {
width: 114px;
display: flex;
align-items: center;
justify-content: center;
}
.public-dashboard-btn {
width: 150px;
display: flex;
align-items: center;
justify-content: center;
&.disabled-btn {
opacity: 0.5;
cursor: not-allowed;
}
}
.ant-tabs-ink-bar {
display: none;
}
.ant-tabs-tab-active {
.overview-btn,
.variables-btn,
.public-dashboard-btn {
color: var(--primary-background);
background: var(--l1-border);
}
}
}
.ant-tabs-nav::before {
border-bottom: none;
}
}

View File

@@ -1,31 +0,0 @@
.dynamic-variable-container {
margin: 24px 0;
.dynamic-variable-config-container {
display: grid;
grid-template-columns: 1fr 32px 16px 200px;
gap: 16px;
align-items: center;
width: 100%;
.ant-select {
.ant-select-selector {
border-radius: 4px;
border: 1px solid var(--l1-border);
}
}
.ant-input {
border-radius: 4px;
border: 1px solid var(--l1-border);
max-width: 300px;
}
.dynamic-variable-from-text {
font-family: 'Space Mono';
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
}
}

View File

@@ -1,230 +0,0 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import CustomSelect from 'components/NewSelect/CustomSelect';
import TextToolTip from 'components/TextToolTip';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import { Info } from '@signozhq/icons';
import { FieldKey } from 'types/api/dynamicVariables/getFieldKeys';
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
import './DynamicVariable.styles.scss';
enum AttributeSource {
ALL_TELEMETRY = 'All telemetry',
LOGS = 'Logs',
METRICS = 'Metrics',
TRACES = 'Traces',
}
function DynamicVariable({
setDynamicVariablesSelectedValue,
dynamicVariablesSelectedValue,
errorAttributeKeyMessage,
}: {
setDynamicVariablesSelectedValue: Dispatch<
SetStateAction<
| {
name: string;
value: string;
}
| undefined
>
>;
dynamicVariablesSelectedValue:
| {
name: string;
value: string;
}
| undefined;
errorAttributeKeyMessage?: string;
}): JSX.Element {
const sources = [
AttributeSource.ALL_TELEMETRY,
AttributeSource.LOGS,
AttributeSource.TRACES,
AttributeSource.METRICS,
];
const [attributeSource, setAttributeSource] = useState<AttributeSource>();
const [attributes, setAttributes] = useState<Record<string, FieldKey[]>>({});
const [selectedAttribute, setSelectedAttribute] = useState<string>();
const [apiSearchText, setApiSearchText] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>();
const [isRetryableError, setIsRetryableError] = useState<boolean>(true);
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
const [filteredAttributes, setFilteredAttributes] = useState<
Record<string, FieldKey[]>
>({});
useEffect(() => {
if (dynamicVariablesSelectedValue?.name) {
setSelectedAttribute(dynamicVariablesSelectedValue.name);
}
if (dynamicVariablesSelectedValue?.value) {
setAttributeSource(dynamicVariablesSelectedValue.value as AttributeSource);
}
}, [
dynamicVariablesSelectedValue?.name,
dynamicVariablesSelectedValue?.value,
]);
const { data, error, isLoading, refetch } = useGetFieldKeys({
signal:
attributeSource === AttributeSource.ALL_TELEMETRY
? undefined
: (attributeSource?.toLowerCase() as 'traces' | 'logs' | 'metrics'),
name: debouncedApiSearchText,
});
const isComplete = useMemo(() => data?.data?.complete === true, [data]);
useEffect(() => {
if (data) {
const newAttributes = data.data?.keys ?? {};
setAttributes(newAttributes);
setFilteredAttributes(newAttributes);
}
}, [data]);
// Handle error from useGetFieldKeys
useEffect(() => {
if (error) {
// Check if error is retryable (5xx) or not (4xx)
const isRetryable = checkIfRetryableError(error);
setIsRetryableError(isRetryable);
}
}, [error]);
// refetch when attributeSource changes
useEffect(() => {
if (attributeSource) {
refetch();
}
}, [attributeSource, refetch, debouncedApiSearchText]);
// Handle search based on whether we have complete data or not
const handleSearch = useCallback(
(text: string) => {
if (isComplete) {
// If complete is true, do client-side filtering
if (!text) {
setFilteredAttributes(attributes);
return;
}
const filtered: Record<string, FieldKey[]> = {};
Object.keys(attributes).forEach((key) => {
if (key.toLowerCase().includes(text.toLowerCase())) {
filtered[key] = attributes[key];
}
});
setFilteredAttributes(filtered);
} else {
// If complete is false, debounce the API call
setApiSearchText(text);
}
},
[attributes, isComplete],
);
// update setDynamicVariablesSelectedValue with debounce when attribute and source is selected
useEffect(() => {
if (selectedAttribute || attributeSource) {
setDynamicVariablesSelectedValue({
name: selectedAttribute || dynamicVariablesSelectedValue?.name || '',
value:
attributeSource ||
dynamicVariablesSelectedValue?.value ||
AttributeSource.ALL_TELEMETRY,
});
}
}, [
selectedAttribute,
attributeSource,
setDynamicVariablesSelectedValue,
dynamicVariablesSelectedValue?.name,
dynamicVariablesSelectedValue?.value,
]);
const isDarkMode = useIsDarkMode();
const errorText = (error as any)?.message || errorMessage;
return (
<div className="dynamic-variable-container">
<div className="dynamic-variable-config-container">
<CustomSelect
placeholder="Select a field"
options={Object.keys(filteredAttributes).map((key) => ({
label: key,
value: key,
}))}
loading={isLoading}
status={errorText ? 'error' : undefined}
onChange={(value): void => {
setSelectedAttribute(value);
}}
showSearch
errorMessage={errorText as any}
value={selectedAttribute || dynamicVariablesSelectedValue?.name}
onSearch={handleSearch}
onRetry={(): void => {
// reset error message
setErrorMessage(undefined);
setIsRetryableError(true);
refetch();
}}
showRetryButton={isRetryableError}
/>
<Typography className="dynamic-variable-from-text">from</Typography>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<TextToolTip
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
useFilledIcon={false}
outlinedIcon={
<Info
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginTop: 1,
}}
/>
}
/>
</span>
<Select
placeholder="Source"
defaultValue={AttributeSource.ALL_TELEMETRY}
options={sources.map((source) => ({ label: source, value: source }))}
onChange={(value): void => setAttributeSource(value as AttributeSource)}
value={attributeSource || dynamicVariablesSelectedValue?.value}
/>
</div>
{errorAttributeKeyMessage && (
<div>
<Typography.Text color="warning">
{errorAttributeKeyMessage}
</Typography.Text>
</div>
)}
</div>
);
}
DynamicVariable.defaultProps = {
errorAttributeKeyMessage: '',
};
export default DynamicVariable;

View File

@@ -1,382 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import DynamicVariable from '../DynamicVariable';
// Mock scrollIntoView since it's not available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Mock dependencies
jest.mock('hooks/dynamicVariables/useGetFieldKeys', () => ({
useGetFieldKeys: jest.fn(),
}));
jest.mock('hooks/useDebounce', () => ({
__esModule: true,
default: (value: any): any => value, // Return the same value without debouncing for testing
}));
describe('DynamicVariable Component', () => {
const mockSetDynamicVariablesSelectedValue = jest.fn();
const ATTRIBUTE_PLACEHOLDER = 'Select a field';
const LOADING_TEXT = 'Refreshing values...';
const DEFAULT_PROPS = {
setDynamicVariablesSelectedValue: mockSetDynamicVariablesSelectedValue,
dynamicVariablesSelectedValue: undefined,
errorAttributeKeyMessage: '',
};
const mockFieldKeysResponse = {
data: {
keys: {
'service.name': [],
'http.status_code': [],
duration: [],
},
complete: true,
},
httpStatusCode: 200,
};
beforeEach(() => {
jest.clearAllMocks();
// Default mock implementation
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: mockFieldKeysResponse,
error: null,
isLoading: false,
refetch: jest.fn(),
});
});
// Helper function to get the attribute select element
const getAttributeSelect = (): HTMLElement =>
screen.getAllByRole('combobox')[0];
// Helper function to get the source select element
const getSourceSelect = (): HTMLElement => screen.getAllByRole('combobox')[1];
it('renders with default state', () => {
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Check for main components
expect(screen.getByText(ATTRIBUTE_PLACEHOLDER)).toBeInTheDocument();
expect(screen.getByText('All telemetry')).toBeInTheDocument();
expect(screen.getByText('from')).toBeInTheDocument();
});
it('uses existing values from dynamicVariablesSelectedValue prop', () => {
const selectedValue = {
name: 'service.name',
value: 'Logs',
};
render(
<DynamicVariable
setDynamicVariablesSelectedValue={mockSetDynamicVariablesSelectedValue}
dynamicVariablesSelectedValue={selectedValue}
/>,
);
// Verify values are set
expect(screen.getByText('service.name')).toBeInTheDocument();
expect(screen.getByText('Logs')).toBeInTheDocument();
});
it('shows loading state when fetching data', () => {
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: null,
error: null,
isLoading: true,
refetch: jest.fn(),
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the CustomSelect dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Should show loading state
expect(screen.getByText(LOADING_TEXT)).toBeInTheDocument();
});
it('shows error message when API fails', () => {
const errorMessage = 'Failed to fetch field keys';
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: null,
error: { message: errorMessage },
isLoading: false,
refetch: jest.fn(),
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the CustomSelect dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Should show error message
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
it('updates filteredAttributes when data is loaded', async () => {
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the CustomSelect dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Wait for options to appear in the dropdown
await waitFor(() => {
// Looking for option-content elements inside the CustomSelect dropdown
const options = document.querySelectorAll('.option-content');
expect(options.length).toBeGreaterThan(0);
// Check if all expected options are present
let foundServiceName = false;
let foundHttpStatusCode = false;
let foundDuration = false;
options.forEach((option) => {
const text = option.textContent?.trim();
if (text === 'service.name') {
foundServiceName = true;
}
if (text === 'http.status_code') {
foundHttpStatusCode = true;
}
if (text === 'duration') {
foundDuration = true;
}
});
expect(foundServiceName).toBe(true);
expect(foundHttpStatusCode).toBe(true);
expect(foundDuration).toBe(true);
});
});
it('calls setDynamicVariablesSelectedValue when attribute is selected', async () => {
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the attribute dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Wait for options to appear, then click on service.name
await waitFor(() => {
// Need to find the option-item containing service.name
const serviceNameOption = screen.getByText('service.name');
expect(serviceNameOption).not.toBeNull();
expect(serviceNameOption?.textContent).toBe('service.name');
// Click on the option-item that contains service.name
const optionElement = serviceNameOption?.closest('.option-item');
if (optionElement) {
fireEvent.click(optionElement);
}
});
// Check if the setter was called with the correct value
expect(mockSetDynamicVariablesSelectedValue).toHaveBeenCalledWith({
name: 'service.name',
value: 'All telemetry',
});
});
it('calls setDynamicVariablesSelectedValue when source is selected', () => {
const mockRefetch = jest.fn();
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: mockFieldKeysResponse,
error: null,
isLoading: false,
refetch: mockRefetch,
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Get the Select component
const select = screen
.getByText('All telemetry')
.closest('div[class*="ant-select"]');
expect(select).toBeInTheDocument();
// Directly call the onChange handler by simulating the Select's onChange
// Find the props.onChange of the Select component and call it directly
fireEvent.mouseDown(select as HTMLElement);
// Use a more specific selector to find the "Logs" option
const optionsContainer = document.querySelector(
'.rc-virtual-list-holder-inner',
);
expect(optionsContainer).not.toBeNull();
// Find the option with Logs text content
const logsOption = Array.from(
optionsContainer?.querySelectorAll('.ant-select-item-option-content') || [],
)
.find((element) => element.textContent === 'Logs')
?.closest('.ant-select-item-option');
expect(logsOption).not.toBeNull();
// Click on it
if (logsOption) {
fireEvent.click(logsOption);
}
// Check if the setter was called with the correct value
expect(mockSetDynamicVariablesSelectedValue).toHaveBeenCalledWith(
expect.objectContaining({
value: 'Logs',
}),
);
});
it('filters attributes locally when complete is true', async () => {
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the attribute dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Mock the filter function behavior
const attributeKeys = Object.keys(mockFieldKeysResponse.data.keys);
// Only "http.status_code" should match the filter
const expectedFilteredKeys = attributeKeys.filter((key) =>
key.includes('http'),
);
// Verify our expected filtering logic
expect(expectedFilteredKeys).toContain('http.status_code');
expect(expectedFilteredKeys).not.toContain('service.name');
expect(expectedFilteredKeys).not.toContain('duration');
// Now verify the component's filtering ability by inputting the search text
const inputElement = screen
.getAllByRole('combobox')[0]
.querySelector('input');
if (inputElement) {
fireEvent.change(inputElement, { target: { value: 'http' } });
}
});
it('triggers API call when complete is false and search text changes', async () => {
const mockRefetch = jest.fn();
// Set up the mock to indicate that data is not complete
// and needs to be fetched from the server
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: {
data: {
keys: {
'http.status_code': [],
},
complete: false, // This indicates server-side filtering is needed
},
httpStatusCode: 200,
},
error: null,
isLoading: false,
refetch: mockRefetch,
});
// Render with Logs as the initial source
render(
<DynamicVariable
{...DEFAULT_PROPS}
dynamicVariablesSelectedValue={{
name: '',
value: 'Logs',
}}
/>,
);
// Clear any initial calls
mockRefetch.mockClear();
// Now test the search functionality
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Find the input element and simulate typing
const inputElement = document.querySelector(
'.ant-select-selection-search-input',
);
if (inputElement) {
// Simulate typing in the search input
fireEvent.change(inputElement, { target: { value: 'http' } });
// Verify that the input has the correct value
expect((inputElement as HTMLInputElement).value).toBe('http');
// Wait for the effect to run and verify refetch was called
await waitFor(
() => {
expect(mockRefetch).toHaveBeenCalled();
},
{ timeout: 3000 },
); // Increase timeout to give more time for the effect to run
}
});
it('triggers refetch when attributeSource changes', async () => {
const mockRefetch = jest.fn();
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: mockFieldKeysResponse,
error: null,
isLoading: false,
refetch: mockRefetch,
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Clear any initial calls
mockRefetch.mockClear();
// Find and click on the source select to open dropdown
const sourceSelectElement = getSourceSelect();
fireEvent.mouseDown(sourceSelectElement);
// Find and click on the "Metrics" option
const metricsOption = screen.getByText('Metrics');
fireEvent.click(metricsOption);
// Wait for the effect to run
await waitFor(() => {
// Verify that refetch was called after source selection
expect(mockRefetch).toHaveBeenCalled();
});
});
it('shows retry button when error occurs', () => {
const mockRefetch = jest.fn();
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: null,
error: { message: 'Failed to fetch field keys' },
isLoading: false,
refetch: mockRefetch,
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the attribute dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Find and click reload icon (retry button)
const reloadIcon = screen.getByTestId('retry-button');
fireEvent.click(reloadIcon);
// Should trigger refetch
expect(mockRefetch).toHaveBeenCalled();
});
});

View File

@@ -1,420 +0,0 @@
.query-container {
display: flex;
flex-flow: row wrap;
min-width: 0;
gap: 1rem;
margin-bottom: 1rem;
flex-direction: column;
}
.variable-item-container {
display: flex;
flex-direction: column;
border-radius: 3px;
border: 1px solid var(--l1-border);
.all-variables {
display: flex;
padding: 10px 16px;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--l1-border);
.all-variables-btn {
display: flex;
align-items: center;
height: 24px;
padding: 0px;
color: var(--muted-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
}
.all-variables-btn:hover {
background-color: unset !important;
}
}
.variable-item-content {
padding: 12px 16px 20px 16px;
display: flex;
flex-direction: column;
gap: 20px;
.variable-name-section {
flex-direction: column;
gap: 8px;
.typography-variables {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
.name-input {
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
padding: 6px 6px 6px 8px;
}
}
.variable-description-section {
flex-direction: column;
gap: 8px;
.typography-variables {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
.description-input {
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
padding: 6px 6px 6px 8px;
}
}
.variable-type-section {
justify-content: space-between;
margin-bottom: 0px;
align-items: center;
.variable-type-label-container {
display: flex;
align-items: center;
gap: 8px;
}
.typography-variables {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
.variable-type-btn-group {
display: grid;
grid-template-columns: max-content max-content max-content max-content;
height: 32px;
flex-shrink: 0;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
.ant-btn {
width: 114px;
height: 32px;
flex-shrink: 0;
border-radius: 2px 0px 0px 2px;
width: 100%;
}
.variable-type-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
.sidenav-beta-tag {
margin-left: 4px;
}
div {
display: flex;
align-items: center;
}
}
.variable-type-btn + .variable-type-btn {
border-left: 1px solid var(--l1-border);
}
.selected {
background: var(--l1-border);
}
}
}
.sort-values-section {
justify-content: space-between;
margin-bottom: 0;
.typography-variables {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.typography-sort {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
letter-spacing: -0.06px;
}
.sort-input {
border-radius: 2px;
border: 1px solid var(--l1-border);
.ant-select-selector {
width: 192px;
height: 32px;
padding: 6px 6px 6px 8px;
background: var(--l3-background);
}
}
}
.multiple-values-section {
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0;
.typography-variables {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
width: 339px;
}
}
.all-option-section {
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0;
.typography-variables {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
width: 339px;
}
}
.default-value-section {
display: grid;
grid-template-columns: max-content 1fr;
.typography-variables {
display: block;
}
.default-value-description {
display: block;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
}
.dynamic-variable-section {
justify-content: space-between;
margin-bottom: 0;
.typography-variables {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
width: 339px;
}
}
.variable-textbox-section {
justify-content: space-between;
margin-bottom: 0;
align-items: center;
.typography-variables {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
.default-input {
display: flex;
height: 32px;
padding: 6px 6px 6px 8px;
align-items: flex-start;
gap: 4px;
flex: 1 0 0;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
width: 342px;
}
}
.variable-custom-section {
margin-bottom: 0px;
.custom-collapse {
width: 100%;
border-radius: 3px 3px 0px 0px;
border: 1px solid var(--l1-border);
.ant-collapse-item {
border-bottom: none;
}
.ant-collapse-header {
height: 38px;
border-radius: 3px 3px 0px 0px;
background: var(--l3-background);
align-items: center;
padding: 12px;
gap: 8px;
.ant-collapse-expand-icon {
padding-inline-end: 0px;
}
.ant-collapse-header-text {
color: var(--bg-robin-400);
font-family: 'Space Mono';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
display: flex;
padding: 1px 2px;
align-items: center;
gap: 10px;
border-radius: 2px;
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
}
}
.ant-collapse-content {
border-top: none;
.ant-collapse-content-box {
padding: 0px;
}
.comma-input {
height: 109px;
border: none;
}
}
}
}
.variables-preview-section {
display: flex;
flex-direction: column;
margin-bottom: 0px;
border-radius: 0px 0px 3px 3px;
border: 1px solid var(--l1-border);
height: 108px;
margin-top: -20px;
border-collapse: collapse;
gap: 5px;
flex-flow: column;
.typography-variables {
color: var(--bg-robin-400);
font-family: 'Space Mono';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
display: inline-flex;
padding: 1px 2px;
align-items: center;
gap: 10px;
border-radius: 0px 0px 2px 0px;
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
}
.preview-values {
padding: 4.5px 11px;
display: flex;
overflow-y: auto;
flex-flow: wrap;
gap: 8px;
[data-slot='badge'] {
height: 30px;
color: var(--l1-foreground);
font-family: 'Space Mono';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
display: inline-flex;
letter-spacing: -0.07px;
align-items: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
margin-inline-end: 0px;
}
}
}
}
}
.variable-item-footer {
margin-top: 12px;
display: flex;
flex-direction: row-reverse;
.footer-btn-discard {
display: flex;
align-items: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
height: 34px;
padding: 4px 8px 4px 10px;
box-shadow: none;
}
.footer-btn-save {
display: flex;
align-items: center;
border-radius: 2px;
border: 1px solid var(--primary-background);
background: var(--primary-background);
width: 123px;
height: 34px;
padding: 4px 8px 4px 10px;
box-shadow: none;
}
}

View File

@@ -1,828 +0,0 @@
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import {
IDashboardVariable,
TSortVariableValuesType,
VariableSortTypeArr,
} from 'types/api/dashboard/getAll';
import VariableItem from './VariableItem';
// Mock modules
jest.mock('api/dashboard/variables/dashboardVariablesQuery', () => ({
__esModule: true,
default: jest.fn().mockResolvedValue({
payload: {
variableValues: ['value1', 'value2', 'value3'],
},
}),
}));
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('test-uuid'),
}));
// Mock functions
const onCancel = jest.fn();
const onSave = jest.fn();
const validateName = jest.fn(() => true);
const validateAttributeKey = jest.fn(() => true);
// Mode constant
const VARIABLE_MODE = 'ADD';
// Common text constants
const TEXT = {
INCLUDE_ALL_VALUES: 'Include an option for ALL values',
ENABLE_MULTI_VALUES: 'Enable multiple values to be checked',
VARIABLE_EXISTS: 'Variable name already exists',
VARIABLE_WHITESPACE: 'Variable name cannot contain whitespaces',
ATTRIBUTE_KEY_EXISTS: 'A variable with this attribute key already exists',
SORT_VALUES: 'Sort Values',
DEFAULT_VALUE: 'Default Value',
ALL_VARIABLES: 'All variables',
DISCARD: 'Discard',
OPTIONS: 'Options',
QUERY: 'Query',
TEXTBOX: 'Textbox',
CUSTOM: 'Custom',
DYNAMIC: 'Dynamic',
};
// Common test constants
const VARIABLE_DEFAULTS = {
sort: VariableSortTypeArr[0] as TSortVariableValuesType,
multiSelect: false,
showALLOption: false,
};
// Common variable properties
const TEST_VAR_NAMES = {
VAR1: 'variable1',
VAR2: 'variable2',
VAR3: 'variable3',
};
const TEST_VAR_IDS = {
VAR1: 'var1',
VAR2: 'var2',
VAR3: 'var3',
};
const TEST_VAR_DESCRIPTIONS = {
VAR1: 'Variable 1',
VAR2: 'Variable 2',
VAR3: 'Variable 3',
};
// Common UI elements
const SAVE_BUTTON_TEXT = 'Save Variable';
const UNIQUE_NAME_PLACEHOLDER = 'Unique name of the variable';
// Basic variable data for testing
const basicVariableData: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Variable 1',
type: 'QUERY',
queryValue: 'SELECT * FROM test',
...VARIABLE_DEFAULTS,
order: 0,
};
// Helper function to render VariableItem with common props
const renderVariableItem = (
variableData: IDashboardVariable = basicVariableData,
existingVariables: Record<string, IDashboardVariable> = {},
validateNameFn = validateName,
validateAttributeKeyFn = validateAttributeKey,
): void => {
render(
<VariableItem
variableData={variableData}
existingVariables={existingVariables}
onCancel={onCancel}
onSave={onSave}
validateName={validateNameFn}
validateAttributeKey={validateAttributeKeyFn}
mode={VARIABLE_MODE}
/>,
);
};
// Helper function to find button by text within its span
const findButtonByText = (text: string): HTMLElement | null => {
const buttons = screen.getAllByRole('button');
return buttons.find((button) => button.textContent?.includes(text)) || null;
};
describe('VariableItem Component', () => {
// Test SQL query patterns
const SQL_PATTERN_DOT = 'SELECT * FROM test WHERE env = {{.variable2}}';
const SQL_PATTERN_DOLLAR = 'SELECT * FROM test WHERE env = $variable2';
const SQL_PATTERN_BRACKET = 'SELECT * FROM test WHERE service = [[variable3]]';
const SQL_PATTERN_BRACES = 'SELECT * FROM test WHERE app = {{variable1}}';
const SQL_PATTERN_NO_VARS = 'SELECT * FROM test WHERE env = "prod"';
const SQL_PATTERN_DOT_VAR1 =
'SELECT * FROM test WHERE service = {{.variable1}}';
// Error message text constant
const CIRCULAR_DEPENDENCY_ERROR = /Cannot save: Circular dependency detected/;
// Test functions and utilities
const createVariable = (
id: string,
name: string,
description: string,
queryValue: string,
order: number,
): IDashboardVariable => ({
id,
name,
description,
type: 'QUERY',
queryValue,
...VARIABLE_DEFAULTS,
order,
});
beforeEach(() => {
jest.clearAllMocks();
});
it('renders without crashing', () => {
renderVariableItem();
expect(screen.getByText(TEXT.ALL_VARIABLES)).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Variable Type')).toBeInTheDocument();
});
describe('Variable Name Validation', () => {
it('shows error when variable name already exists', () => {
// Set validateName to return false (name exists)
const mockValidateName = jest.fn().mockReturnValue(false);
renderVariableItem({ ...basicVariableData, name: '' }, {}, mockValidateName);
// Enter a name that already exists
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'existingVariable' } });
// Error message should be displayed
expect(screen.getByText(TEXT.VARIABLE_EXISTS)).toBeInTheDocument();
// We won't check for button disabled state as it might be inconsistent in tests
});
it('allows save when current variable name is used', () => {
// Mock validate to return false for all other names but true for own name
const mockValidateName = jest
.fn()
.mockImplementation((name) => name === TEST_VAR_NAMES.VAR1);
renderVariableItem(basicVariableData, {}, mockValidateName);
// Enter the current variable name
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: TEST_VAR_NAMES.VAR1 } });
// Error should not be visible
expect(screen.queryByText(TEXT.VARIABLE_EXISTS)).not.toBeInTheDocument();
});
it('shows error when variable name contains whitespace', () => {
renderVariableItem({ ...basicVariableData, name: '' });
// Enter a name with whitespace
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'variable name' } });
// Error message should be displayed
expect(screen.getByText(TEXT.VARIABLE_WHITESPACE)).toBeInTheDocument();
// Save button should be disabled
const saveButton = screen.getByRole('button', { name: /save variable/i });
expect(saveButton).toBeDisabled();
});
it('allows variable name without whitespace', () => {
renderVariableItem({ ...basicVariableData, name: '' });
// Enter a valid name without whitespace
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'variable.name' } });
// Error should not be visible
expect(screen.queryByText(TEXT.VARIABLE_WHITESPACE)).not.toBeInTheDocument();
});
it('validates whitespace in auto-generated name for dynamic variables', () => {
// Create a dynamic variable with empty name
const dynamicVariable: IDashboardVariable = {
...basicVariableData,
name: '',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service name', // Contains whitespace
dynamicVariablesSource: 'All telemetry',
};
renderVariableItem(dynamicVariable);
// Error message should be displayed for auto-generated name
expect(screen.getByText(TEXT.VARIABLE_WHITESPACE)).toBeInTheDocument();
});
});
describe('Dynamic Variable Attribute Key Validation', () => {
it('shows error when attribute key already exists', async () => {
// Mock validateAttributeKey to return false (attribute key exists)
const mockValidateAttributeKey = jest.fn().mockReturnValue(false);
// Create a dynamic variable
const dynamicVariable: IDashboardVariable = {
...basicVariableData,
name: 'test-variable',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'All telemetry',
};
renderVariableItem(
dynamicVariable,
{},
validateName,
mockValidateAttributeKey,
);
// Switch to Dynamic type to trigger the validation
const dynamicButton = findButtonByText(TEXT.DYNAMIC);
if (dynamicButton) {
fireEvent.click(dynamicButton);
}
// Error message should be displayed
await waitFor(() => {
expect(screen.getByText(TEXT.ATTRIBUTE_KEY_EXISTS)).toBeInTheDocument();
});
// Save button should be disabled
const saveButton = screen.getByRole('button', { name: /save variable/i });
expect(saveButton).toBeDisabled();
});
it('allows saving when attribute key is unique', async () => {
// Mock validateAttributeKey to return true (attribute key is unique)
const mockValidateAttributeKey = jest.fn().mockReturnValue(true);
// Create a dynamic variable
const dynamicVariable: IDashboardVariable = {
...basicVariableData,
name: 'test-variable',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'All telemetry',
};
renderVariableItem(
dynamicVariable,
{},
validateName,
mockValidateAttributeKey,
);
// Switch to Dynamic type
const dynamicButton = findButtonByText(TEXT.DYNAMIC);
if (dynamicButton) {
fireEvent.click(dynamicButton);
}
// Error should not be visible
await waitFor(() => {
expect(
screen.queryByText(TEXT.ATTRIBUTE_KEY_EXISTS),
).not.toBeInTheDocument();
});
// Save button should not be disabled due to attribute key error
const saveButton = screen.getByRole('button', { name: /save variable/i });
expect(saveButton).not.toBeDisabled();
});
it('allows same attribute key for current variable being edited', async () => {
// Mock validateAttributeKey to return true for same variable
const mockValidateAttributeKey = jest.fn().mockImplementation(
(attributeKey, currentVariableId) =>
// Allow if it's the same variable ID
currentVariableId === TEST_VAR_IDS.VAR1,
);
// Create a dynamic variable
const dynamicVariable: IDashboardVariable = {
...basicVariableData,
id: TEST_VAR_IDS.VAR1,
name: 'test-variable',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'All telemetry',
};
renderVariableItem(
dynamicVariable,
{},
validateName,
mockValidateAttributeKey,
);
// Error should not be visible
await waitFor(() => {
expect(
screen.queryByText(TEXT.ATTRIBUTE_KEY_EXISTS),
).not.toBeInTheDocument();
});
});
it('does not validate attribute key for non-dynamic variables', async () => {
// Mock validateAttributeKey to return false (would show error for dynamic)
const mockValidateAttributeKey = jest.fn().mockReturnValue(false);
// Create a non-dynamic variable
const queryVariable: IDashboardVariable = {
...basicVariableData,
name: 'test-variable',
type: 'QUERY',
};
renderVariableItem(
queryVariable,
{},
validateName,
mockValidateAttributeKey,
);
// No error should be displayed for query variables
expect(
screen.queryByText(TEXT.ATTRIBUTE_KEY_EXISTS),
).not.toBeInTheDocument();
// validateAttributeKey should not be called for non-dynamic variables
expect(mockValidateAttributeKey).not.toHaveBeenCalled();
});
});
describe('Variable Type Switching', () => {
it('switches to CUSTOM variable type correctly', () => {
renderVariableItem();
// Find the Query button
const queryButton = findButtonByText(TEXT.QUERY);
expect(queryButton).toBeInTheDocument();
expect(queryButton).toHaveClass('selected');
// Find and click Custom button
const customButton = findButtonByText(TEXT.CUSTOM);
expect(customButton).toBeInTheDocument();
if (customButton) {
fireEvent.click(customButton);
}
// Custom button should now be selected
expect(customButton).toHaveClass('selected');
expect(queryButton).not.toHaveClass('selected');
// Custom options input should appear
expect(screen.getByText(TEXT.OPTIONS)).toBeInTheDocument();
});
it('switches to TEXTBOX variable type correctly', () => {
renderVariableItem();
// Find and click Textbox button
const textboxButton = findButtonByText(TEXT.TEXTBOX);
expect(textboxButton).toBeInTheDocument();
if (textboxButton) {
fireEvent.click(textboxButton);
}
// Textbox button should now be selected
expect(textboxButton).toHaveClass('selected');
// Default Value input should appear
expect(screen.getByText(TEXT.DEFAULT_VALUE)).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Enter a default value (if any)...'),
).toBeInTheDocument();
});
});
describe('MultiSelect and ALL Option', () => {
it('enables ALL option only when multiSelect is enabled', async () => {
renderVariableItem();
// Initially, ALL option should not be visible
expect(screen.queryByText(TEXT.INCLUDE_ALL_VALUES)).not.toBeInTheDocument();
// Enable multiple values
const multipleValuesSwitch = screen
.getByText(TEXT.ENABLE_MULTI_VALUES)
.closest('.multiple-values-section')
?.querySelector('button');
expect(multipleValuesSwitch).toBeInTheDocument();
if (multipleValuesSwitch) {
fireEvent.click(multipleValuesSwitch);
}
// Now ALL option should be visible
await waitFor(() => {
expect(screen.getByText(TEXT.INCLUDE_ALL_VALUES)).toBeInTheDocument();
});
// Disable multiple values
if (multipleValuesSwitch) {
fireEvent.click(multipleValuesSwitch);
}
// ALL option should be hidden again
await waitFor(() => {
expect(screen.queryByText(TEXT.INCLUDE_ALL_VALUES)).not.toBeInTheDocument();
});
});
it('disables ALL option when multiSelect is disabled', async () => {
// Create variable with multiSelect and showALLOption both enabled
const variable: IDashboardVariable = {
...basicVariableData,
multiSelect: true,
showALLOption: true,
};
renderVariableItem(variable);
// ALL option should be visible initially
expect(screen.getByText(TEXT.INCLUDE_ALL_VALUES)).toBeInTheDocument();
// Disable multiple values
const multipleValuesSwitch = screen
.getByText(TEXT.ENABLE_MULTI_VALUES)
.closest('.multiple-values-section')
?.querySelector('button');
if (multipleValuesSwitch) {
fireEvent.click(multipleValuesSwitch);
}
// ALL option should be hidden
await waitFor(() => {
expect(screen.queryByText(TEXT.INCLUDE_ALL_VALUES)).not.toBeInTheDocument();
});
// Check that when saving, showALLOption is set to false
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
fireEvent.click(saveButton);
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
multiSelect: false,
showALLOption: false,
}),
expect.anything(), // widgetIds prop
);
});
});
describe('Cancel and Navigation', () => {
it('calls onCancel when clicking All Variables button', () => {
renderVariableItem();
// Click All variables button
const allVariablesButton = screen.getByText(TEXT.ALL_VARIABLES);
fireEvent.click(allVariablesButton);
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('calls onCancel when clicking Discard button', () => {
renderVariableItem();
// Click Discard button
const discardButton = screen.getByText(TEXT.DISCARD);
fireEvent.click(discardButton);
expect(onCancel).toHaveBeenCalledTimes(1);
});
});
describe('Cyclic Dependency Detection', () => {
// Common function to render the component with variables and click save
const renderAndSave = async (
variableData: IDashboardVariable,
existingVariables: Record<string, IDashboardVariable>,
): Promise<void> => {
renderVariableItem(variableData, existingVariables);
// Fill in the variable name if it's not already populated
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
if (nameInput.getAttribute('value') === '') {
fireEvent.change(nameInput, { target: { value: variableData.name || '' } });
}
// Click save button to trigger the dependency check
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
fireEvent.click(saveButton);
};
// Common expectations for finding circular dependency error
const expectCircularDependencyError = async (): Promise<void> => {
await waitFor(() => {
expect(screen.getByText(CIRCULAR_DEPENDENCY_ERROR)).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
};
// Test for cyclic dependency detection
it('detects circular dependency and shows error message', async () => {
// Create variables with circular dependency
const variable1 = createVariable(
TEST_VAR_IDS.VAR1,
TEST_VAR_NAMES.VAR1,
TEST_VAR_DESCRIPTIONS.VAR1,
SQL_PATTERN_DOT,
0,
);
const variable2 = createVariable(
TEST_VAR_IDS.VAR2,
TEST_VAR_NAMES.VAR2,
TEST_VAR_DESCRIPTIONS.VAR2,
SQL_PATTERN_DOT_VAR1,
1,
);
const existingVariables = {
[TEST_VAR_IDS.VAR2]: variable2,
};
await renderAndSave(variable1, existingVariables);
await expectCircularDependencyError();
});
// Test for saving with no circular dependency
it('allows saving when no circular dependency exists', async () => {
// Create variables without circular dependency
const variable1 = createVariable(
TEST_VAR_IDS.VAR1,
TEST_VAR_NAMES.VAR1,
TEST_VAR_DESCRIPTIONS.VAR1,
SQL_PATTERN_NO_VARS,
0,
);
const variable2 = createVariable(
TEST_VAR_IDS.VAR2,
TEST_VAR_NAMES.VAR2,
TEST_VAR_DESCRIPTIONS.VAR2,
SQL_PATTERN_DOT_VAR1,
1,
);
const existingVariables = {
[TEST_VAR_IDS.VAR2]: variable2,
};
await renderAndSave(variable1, existingVariables);
// Verify the onSave function was called
await waitFor(() => {
expect(onSave).toHaveBeenCalled();
});
});
// Test with multiple variable formats in query
it('detects circular dependency with different variable formats', async () => {
// Create variables with circular dependency using different formats
const variable1 = createVariable(
TEST_VAR_IDS.VAR1,
TEST_VAR_NAMES.VAR1,
TEST_VAR_DESCRIPTIONS.VAR1,
SQL_PATTERN_DOLLAR,
0,
);
const variable2 = createVariable(
TEST_VAR_IDS.VAR2,
TEST_VAR_NAMES.VAR2,
TEST_VAR_DESCRIPTIONS.VAR2,
SQL_PATTERN_BRACKET,
1,
);
const variable3 = createVariable(
TEST_VAR_IDS.VAR3,
TEST_VAR_NAMES.VAR3,
TEST_VAR_DESCRIPTIONS.VAR3,
SQL_PATTERN_BRACES,
2,
);
const existingVariables = {
[TEST_VAR_IDS.VAR2]: variable2,
[TEST_VAR_IDS.VAR3]: variable3,
};
await renderAndSave(variable1, existingVariables);
await expectCircularDependencyError();
});
});
describe('Textbox Variable Default Value Handling', () => {
it('saves textbox variable with defaultValue and selectedValue set to textboxValue', async () => {
const user = userEvent.setup();
const textboxVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Textbox Variable',
type: 'TEXTBOX',
textboxValue: 'my-default-value',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(textboxVariable);
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with defaultValue and selectedValue equal to textboxValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: 'my-default-value',
defaultValue: 'my-default-value',
selectedValue: 'my-default-value',
}),
expect.anything(),
);
});
it('saves textbox variable with empty values when textboxValue is empty', async () => {
const user = userEvent.setup();
const textboxVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Textbox Variable',
type: 'TEXTBOX',
textboxValue: '',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(textboxVariable);
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with empty defaultValue and selectedValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: '',
defaultValue: '',
selectedValue: '',
}),
expect.anything(),
);
});
it('updates textbox defaultValue and selectedValue when user changes textboxValue input', async () => {
const user = userEvent.setup();
const textboxVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Textbox Variable',
type: 'TEXTBOX',
textboxValue: 'initial-value',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(textboxVariable);
// Change the textbox value
const textboxInput = screen.getByPlaceholderText(
'Enter a default value (if any)...',
);
await user.clear(textboxInput);
await user.type(textboxInput, 'updated-value');
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with the updated defaultValue and selectedValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: 'updated-value',
defaultValue: 'updated-value',
selectedValue: 'updated-value',
}),
expect.anything(),
);
});
it('non-textbox variables use variableDefaultValue instead of textboxValue', async () => {
const user = userEvent.setup();
const queryVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Query Variable',
type: 'QUERY',
queryValue: 'SELECT * FROM test',
textboxValue: 'should-not-be-used',
defaultValue: 'query-default-value',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(queryVariable);
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with defaultValue not being textboxValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'QUERY',
defaultValue: 'query-default-value',
}),
expect.anything(),
);
// Verify that defaultValue is NOT the textboxValue
const savedVariable = onSave.mock.calls[0][1];
expect(savedVariable.defaultValue).not.toBe('should-not-be-used');
});
it('switching to textbox type sets defaultValue and selectedValue correctly on save', async () => {
const user = userEvent.setup();
// Start with a QUERY variable
const queryVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Variable',
type: 'QUERY',
queryValue: 'SELECT * FROM test',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(queryVariable);
// Switch to TEXTBOX type
const textboxButton = findButtonByText(TEXT.TEXTBOX);
expect(textboxButton).toBeInTheDocument();
if (textboxButton) {
await user.click(textboxButton);
}
// Enter a default value in the textbox input
const textboxInput = screen.getByPlaceholderText(
'Enter a default value (if any)...',
);
await user.type(textboxInput, 'new-textbox-default');
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with type TEXTBOX and correct defaultValue and selectedValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: 'new-textbox-default',
defaultValue: 'new-textbox-default',
selectedValue: 'new-textbox-default',
}),
expect.anything(),
);
});
});
});

View File

@@ -1,851 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { orange } from '@ant-design/colors';
import { Color } from '@signozhq/design-tokens';
import { Button, Collapse, Input, Select } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import cx from 'classnames';
import Editor from 'components/Editor';
import { CustomSelect } from 'components/NewSelect';
import TextToolTip from 'components/TextToolTip';
import { PANEL_GROUP_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useWidgetsByDynamicVariableId } from 'hooks/dashboard/useWidgetsByDynamicVariableId';
import { getWidgetsHavingDynamicVariableAttribute } from 'hooks/dashboard/utils';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import { isEmpty, map } from 'lodash-es';
import {
ArrowLeft,
Check,
ClipboardType,
DatabaseZap,
Info,
LayoutList,
Pyramid,
X,
} from '@signozhq/icons';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { AppState } from 'store/reducers';
import {
IDashboardVariable,
TSortVariableValuesType,
TVariableQueryType,
VariableSortTypeArr,
Widgets,
} from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as generateUUID } from 'uuid';
import {
buildDependencies,
buildDependencyGraph,
} from '../../../DashboardVariablesSelection/util';
import { variablePropsToPayloadVariables } from '../../../utils';
import { TVariableMode } from '../types';
import DynamicVariable from './DynamicVariable/DynamicVariable';
import { LabelContainer, VariableItemRow } from './styles';
import { WidgetSelector } from './WidgetSelector';
import './VariableItem.styles.scss';
const { Option } = Select;
interface VariableItemProps {
variableData: IDashboardVariable;
existingVariables: Record<string, IDashboardVariable>;
onCancel: () => void;
onSave: (
mode: TVariableMode,
variableData: IDashboardVariable,
widgetIds?: string[],
) => void;
validateName: (arg0: string) => boolean;
validateAttributeKey: (
attributeKey: string,
currentVariableId?: string,
) => boolean;
mode: TVariableMode;
}
function VariableItem({
variableData,
existingVariables,
onCancel,
onSave,
validateName,
validateAttributeKey,
mode,
}: VariableItemProps): JSX.Element {
const [variableName, setVariableName] = useState<string>(
variableData.name || '',
);
const [hasUserManuallyChangedName, setHasUserManuallyChangedName] =
useState<boolean>(false);
const [variableDescription, setVariableDescription] = useState<string>(
variableData.description || '',
);
const [queryType, setQueryType] = useState<TVariableQueryType>(
variableData.type || 'DYNAMIC',
);
const [variableQueryValue, setVariableQueryValue] = useState<string>(
variableData.queryValue || '',
);
const [variableCustomValue, setVariableCustomValue] = useState<string>(
variableData.customValue || '',
);
const [variableTextboxValue, setVariableTextboxValue] = useState<string>(
variableData.textboxValue || '',
);
const [variableSortType, setVariableSortType] =
useState<TSortVariableValuesType>(
variableData.sort || VariableSortTypeArr[0],
);
const [variableMultiSelect, setVariableMultiSelect] = useState<boolean>(
variableData.multiSelect || false,
);
const [variableShowALLOption, setVariableShowALLOption] = useState<boolean>(
variableData.showALLOption || false,
);
const [previewValues, setPreviewValues] = useState<string[]>([]);
const [variableDefaultValue, setVariableDefaultValue] = useState<string>(
(variableData.defaultValue as string) || '',
);
const isDarkMode = useIsDarkMode();
const [dynamicVariablesSelectedValue, setDynamicVariablesSelectedValue] =
useState<{ name: string; value: string }>();
// Error messages
const [errorName, setErrorName] = useState<boolean>(false);
const [errorNameMessage, setErrorNameMessage] = useState<string>('');
const [errorAttributeKey, setErrorAttributeKey] = useState<boolean>(false);
const [errorAttributeKeyMessage, setErrorAttributeKeyMessage] =
useState<string>('');
const [errorPreview, setErrorPreview] = useState<string | null>(null);
useEffect(() => {
if (
variableData.dynamicVariablesAttribute &&
variableData.dynamicVariablesSource
) {
setDynamicVariablesSelectedValue({
name: variableData.dynamicVariablesAttribute,
value: variableData.dynamicVariablesSource,
});
}
}, [
variableData.dynamicVariablesAttribute,
variableData.dynamicVariablesSource,
]);
// Validate attribute key uniqueness for dynamic variables
useEffect(() => {
if (queryType === 'DYNAMIC' && dynamicVariablesSelectedValue?.name) {
if (
!validateAttributeKey(dynamicVariablesSelectedValue.name, variableData.id)
) {
setErrorAttributeKey(true);
setErrorAttributeKeyMessage(
'A variable with this attribute key already exists',
);
} else {
setErrorAttributeKey(false);
setErrorAttributeKeyMessage('');
}
} else {
setErrorAttributeKey(false);
setErrorAttributeKeyMessage('');
}
}, [
queryType,
dynamicVariablesSelectedValue?.name,
validateAttributeKey,
variableData.id,
]);
// Auto-set variable name to selected attribute name in creation mode when user hasn't manually changed it
useEffect(() => {
if (
mode === 'ADD' && // Only in creation mode
queryType === 'DYNAMIC' && // Only for dynamic variables
dynamicVariablesSelectedValue?.name && // Attribute is selected
!hasUserManuallyChangedName // User hasn't manually changed the name
) {
const newName = dynamicVariablesSelectedValue.name;
setVariableName(newName);
// Trigger validation for the auto-set name
if (/\s/.test(newName)) {
setErrorName(true);
setErrorNameMessage('Variable name cannot contain whitespaces');
} else if (!validateName(newName)) {
setErrorName(true);
setErrorNameMessage('Variable name already exists');
} else {
setErrorName(false);
setErrorNameMessage('');
}
}
}, [
mode,
queryType,
dynamicVariablesSelectedValue?.name,
hasUserManuallyChangedName,
validateName,
]);
const REQUIRED_NAME_MESSAGE = 'Variable name is required';
// Initialize error state for empty name
useEffect(() => {
if (!variableName.trim()) {
setErrorName(true);
}
}, [variableName]);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { data: fieldValues } = useGetFieldValues({
signal:
dynamicVariablesSelectedValue?.value === 'All telemetry'
? undefined
: (dynamicVariablesSelectedValue?.value?.toLowerCase() as
| 'traces'
| 'logs'
| 'metrics'),
name: dynamicVariablesSelectedValue?.name || '',
enabled:
!!dynamicVariablesSelectedValue?.name &&
!!dynamicVariablesSelectedValue?.value,
startUnixMilli: minTime,
endUnixMilli: maxTime,
});
const [selectedWidgets, setSelectedWidgets] = useState<string[]>([]);
const { dashboardData } = useDashboardStore();
const widgetsByDynamicVariableId = useWidgetsByDynamicVariableId();
useEffect(() => {
if (variableData?.id && variableData.id in widgetsByDynamicVariableId) {
setSelectedWidgets(widgetsByDynamicVariableId[variableData.id] || []);
} else if (dynamicVariablesSelectedValue?.name) {
const widgets = getWidgetsHavingDynamicVariableAttribute(
dynamicVariablesSelectedValue?.name,
(dashboardData?.data?.widgets?.filter(
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
) || []) as Widgets[],
variableData.name,
);
setSelectedWidgets(widgets || []);
}
}, [
dynamicVariablesSelectedValue?.name,
dashboardData,
variableData.id,
variableData.name,
widgetsByDynamicVariableId,
]);
useEffect(() => {
if (queryType === 'CUSTOM') {
setPreviewValues(
sortValues(
commaValuesParser(variableCustomValue),
variableSortType,
) as never,
);
}
if (queryType === 'QUERY') {
setPreviewValues((prev) => sortValues(prev, variableSortType) as never);
}
}, [
queryType,
variableCustomValue,
variableData.customValue,
variableData.type,
variableSortType,
]);
useEffect(() => {
if (
queryType === 'DYNAMIC' &&
fieldValues &&
dynamicVariablesSelectedValue?.name &&
dynamicVariablesSelectedValue?.value
) {
setPreviewValues(
sortValues(
fieldValues.data?.normalizedValues || [],
variableSortType,
) as never,
);
}
}, [
fieldValues,
variableSortType,
queryType,
dynamicVariablesSelectedValue?.name,
dynamicVariablesSelectedValue?.value,
]);
const variableValue = useMemo(() => {
if (queryType === 'TEXTBOX') {
return variableTextboxValue;
}
if (variableMultiSelect) {
let value = variableData.selectedValue;
if (isEmpty(value)) {
if (variableData.showALLOption) {
if (variableDefaultValue) {
value = variableDefaultValue;
} else {
value = previewValues;
}
} else if (variableDefaultValue) {
value = variableDefaultValue;
} else {
value = previewValues?.[0];
}
}
return value;
}
if (isEmpty(variableData.selectedValue)) {
if (variableDefaultValue) {
return variableDefaultValue;
}
return previewValues?.[0]?.toString();
}
return variableData.selectedValue || variableDefaultValue;
}, [
variableMultiSelect,
variableData.selectedValue,
variableData.showALLOption,
variableDefaultValue,
variableTextboxValue,
queryType,
previewValues,
]);
const handleSave = (): void => {
// Check for cyclic dependencies
const newVariable = {
name: variableName,
description: variableDescription,
type: queryType,
queryValue: variableQueryValue,
customValue: variableCustomValue,
textboxValue: variableTextboxValue,
multiSelect: variableMultiSelect,
showALLOption: queryType === 'DYNAMIC' ? true : variableShowALLOption,
sort: variableSortType,
// the reason we need to do this is because defaultValues are treated differently in case of textbox type
// They are the exact same and not like the other types where defaultValue is a separate field
defaultValue:
queryType === 'TEXTBOX' ? variableTextboxValue : variableDefaultValue,
modificationUUID: generateUUID(),
id: variableData.id || generateUUID(),
order: variableData.order,
...(queryType === 'DYNAMIC' && {
dynamicVariablesAttribute: dynamicVariablesSelectedValue?.name,
dynamicVariablesSource: dynamicVariablesSelectedValue?.value,
}),
selectedValue: variableValue,
allSelected: variableData.allSelected,
};
const allVariables = [...Object.values(existingVariables), newVariable];
const dependencies = buildDependencies(allVariables);
const { hasCycle, cycleNodes } = buildDependencyGraph(dependencies);
if (hasCycle) {
setErrorPreview(
`Cannot save: Circular dependency detected between variables: ${cycleNodes?.join(
' → ',
)}`,
);
return;
}
onSave(mode, newVariable, selectedWidgets);
};
// Fetches the preview values for the SQL variable query
const handleQueryResult = (response: any): void => {
if (response?.payload?.variableValues) {
setPreviewValues(
sortValues(
response.payload?.variableValues || [],
variableSortType,
) as never,
);
} else {
setPreviewValues([]);
}
};
const { isFetching: previewLoading, refetch: runQuery } = useQuery(
[REACT_QUERY_KEY.DASHBOARD_BY_ID, variableData.name, variableName],
{
enabled: false,
queryFn: () =>
dashboardVariablesQuery({
query: variableQueryValue || '',
variables: variablePropsToPayloadVariables(existingVariables),
}),
refetchOnWindowFocus: false,
onSuccess: (response) => {
setErrorPreview(null);
handleQueryResult(response);
},
onError: (error: {
details: {
error: string;
};
}) => {
const { details } = error;
if (details.error) {
let message = details.error;
if ((details.error ?? '').toString().includes('Syntax error:')) {
message =
'Please make sure query is valid and dependent variables are selected';
}
setErrorPreview(message);
}
},
},
);
const handleTestRunQuery = useCallback(() => {
runQuery();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className="variable-item-container">
<div className="all-variables">
<Button
type="text"
className="all-variables-btn"
icon={<ArrowLeft size={14} />}
onClick={onCancel}
>
All variables
</Button>
</div>
<div className="variable-item-content">
<VariableItemRow className="variable-name-section">
<LabelContainer>
<Typography className="typography-variables">Name</Typography>
</LabelContainer>
<div>
<Input
placeholder="Unique name of the variable"
value={variableName}
className="name-input"
onChange={({ target: { value } }): void => {
setVariableName(value);
setHasUserManuallyChangedName(true); // Mark that user has manually changed the name
// Check for empty name
if (!value.trim()) {
setErrorName(true);
setErrorNameMessage(REQUIRED_NAME_MESSAGE);
}
// Check for whitespace in name
else if (/\s/.test(value)) {
setErrorName(true);
setErrorNameMessage('Variable name cannot contain whitespaces');
}
// Check for duplicate name
else if (!validateName(value) && value !== variableData.name) {
setErrorName(true);
setErrorNameMessage('Variable name already exists');
}
// No errors
else {
setErrorName(false);
setErrorNameMessage('');
}
}}
/>
<div>
<Typography.Text color="warning">{errorNameMessage}</Typography.Text>
</div>
</div>
</VariableItemRow>
<VariableItemRow className="variable-description-section">
<LabelContainer>
<Typography className="typography-variables">Description</Typography>
</LabelContainer>
<Input.TextArea
value={variableDescription}
placeholder="Enter a description for the variable"
className="description-input"
rows={3}
onChange={(e): void => setVariableDescription(e.target.value)}
/>
</VariableItemRow>
<VariableItemRow className="variable-type-section">
<LabelContainer className="variable-type-label-container">
<Typography className="typography-variables">Variable Type</Typography>
<TextToolTip
text="Learn more about supported variable types"
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
urlText="here"
useFilledIcon={false}
outlinedIcon={
<Info
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
}
/>
</LabelContainer>
<div className="variable-type-btn-group">
<Button
type="text"
icon={<Pyramid size={14} />}
className={cx(
'variable-type-btn',
queryType === 'DYNAMIC' ? 'selected' : '',
)}
onClick={(): void => {
setQueryType('DYNAMIC');
setPreviewValues([]);
// Reset manual change flag if no name is entered
if (!variableName.trim()) {
setHasUserManuallyChangedName(false);
}
}}
>
Dynamic
<Badge color="robin" className="sidenav-beta-tag">
Beta
</Badge>
</Button>
<Button
type="text"
icon={<ClipboardType size={14} />}
className={cx(
'variable-type-btn',
queryType === 'TEXTBOX' ? 'selected' : '',
)}
onClick={(): void => {
setQueryType('TEXTBOX');
setPreviewValues([]);
// Reset manual change flag if no name is entered
if (!variableName.trim()) {
setHasUserManuallyChangedName(false);
}
}}
>
Textbox
</Button>
<Button
type="text"
icon={<LayoutList size={14} />}
className={cx(
'variable-type-btn',
queryType === 'CUSTOM' ? 'selected' : '',
)}
onClick={(): void => {
setQueryType('CUSTOM');
setPreviewValues([]);
// Reset manual change flag if no name is entered
if (!variableName.trim()) {
setHasUserManuallyChangedName(false);
}
}}
>
Custom
</Button>
<Button
type="text"
icon={<DatabaseZap size={14} />}
className={cx(
'variable-type-btn',
queryType === 'QUERY' ? 'selected' : '',
)}
onClick={(): void => {
setQueryType('QUERY');
setPreviewValues([]);
// Reset manual change flag if no name is entered
if (!variableName.trim()) {
setHasUserManuallyChangedName(false);
}
}}
>
Query
<Badge color="amber" className="sidenav-beta-tag">
Not Recommended
</Badge>
<div onClick={(e): void => e.stopPropagation()}>
<TextToolTip
text="Learn why we don't recommend"
url="https://signoz.io/docs/userguide/manage-variables/#why-avoid-clickhouse-query-variables"
urlText="here"
useFilledIcon={false}
outlinedIcon={
<Info
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
}
/>
</div>
</Button>
</div>
</VariableItemRow>
{queryType === 'DYNAMIC' && (
<div className="variable-dynamic-section">
<DynamicVariable
setDynamicVariablesSelectedValue={setDynamicVariablesSelectedValue}
dynamicVariablesSelectedValue={dynamicVariablesSelectedValue}
errorAttributeKeyMessage={errorAttributeKeyMessage}
/>
</div>
)}
{queryType === 'QUERY' && (
<div className="query-container">
<LabelContainer>
<Typography>Query</Typography>
</LabelContainer>
<div style={{ flex: 1, position: 'relative' }}>
<Editor
language="sql"
value={variableQueryValue}
onChange={(e): void => setVariableQueryValue(e)}
height="240px"
options={{
fontSize: 13,
wordWrap: 'on',
lineNumbers: 'off',
glyphMargin: false,
folding: false,
lineDecorationsWidth: 0,
lineNumbersMinChars: 0,
minimap: {
enabled: false,
},
}}
/>
<Button
type="primary"
size="small"
onClick={handleTestRunQuery}
style={{
position: 'absolute',
bottom: 0,
}}
loading={previewLoading}
>
Test Run Query
</Button>
</div>
</div>
)}
{queryType === 'CUSTOM' && (
<VariableItemRow className="variable-custom-section">
<Collapse
collapsible="header"
rootClassName="custom-collapse"
defaultActiveKey={['1']}
items={[
{
key: '1',
label: 'Options',
children: (
<Input.TextArea
value={variableCustomValue}
placeholder="Enter options separated by commas."
rootClassName="comma-input"
onChange={(e): void => {
setVariableCustomValue(e.target.value);
setPreviewValues(
sortValues(
commaValuesParser(e.target.value),
variableSortType,
) as never,
);
}}
/>
),
},
]}
/>
</VariableItemRow>
)}
{queryType === 'TEXTBOX' && (
<VariableItemRow className="variable-textbox-section">
<LabelContainer>
<Typography className="typography-variables">Default Value</Typography>
</LabelContainer>
<Input
value={variableTextboxValue}
className="default-input"
onChange={(e): void => {
setVariableTextboxValue(e.target.value);
}}
placeholder="Enter a default value (if any)..."
style={{ width: 400 }}
/>
</VariableItemRow>
)}
{(queryType === 'QUERY' ||
queryType === 'CUSTOM' ||
queryType === 'DYNAMIC') && (
<>
<VariableItemRow className="variables-preview-section">
<LabelContainer style={{ width: '100%' }}>
<Typography className="typography-variables">
Preview of Values
</Typography>
</LabelContainer>
<div className="preview-values">
{errorPreview ? (
<Typography style={{ color: orange[5] }}>{errorPreview}</Typography>
) : (
map(previewValues, (value, idx) => (
<Badge key={`${value}${idx}`} color="vanilla">
{value.toString()}
</Badge>
))
)}
</div>
</VariableItemRow>
<VariableItemRow className="sort-values-section">
<LabelContainer>
<Typography className="typography-variables">Sort Values</Typography>
</LabelContainer>
<Select
defaultActiveFirstOption
defaultValue={VariableSortTypeArr[0]}
value={variableSortType}
onChange={(value: TSortVariableValuesType): void =>
setVariableSortType(value)
}
className="sort-input"
>
<Option value={VariableSortTypeArr[0]}>Disabled</Option>
<Option value={VariableSortTypeArr[1]}>Ascending</Option>
<Option value={VariableSortTypeArr[2]}>Descending</Option>
</Select>
</VariableItemRow>
<VariableItemRow className="multiple-values-section">
<LabelContainer>
<Typography className="typography-variables">
Enable multiple values to be checked
</Typography>
</LabelContainer>
<Switch
value={variableMultiSelect}
onChange={(e): void => {
setVariableMultiSelect(e);
if (!e) {
setVariableShowALLOption(false);
}
}}
/>
</VariableItemRow>
{variableMultiSelect && queryType !== 'DYNAMIC' && (
<VariableItemRow className="all-option-section">
<LabelContainer>
<Typography className="typography-variables">
Include an option for ALL values
</Typography>
</LabelContainer>
<Switch
value={variableShowALLOption}
onChange={(e): void => setVariableShowALLOption(e)}
/>
</VariableItemRow>
)}
<VariableItemRow className="default-value-section">
<LabelContainer>
<Typography className="typography-variables">Default Value</Typography>
<Typography className="default-value-description">
{queryType === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value'}
</Typography>
</LabelContainer>
<CustomSelect
placeholder="Select a default value"
value={variableDefaultValue}
onChange={(value): void => setVariableDefaultValue(value)}
options={previewValues.map((value) => ({
label: value,
value,
}))}
/>
</VariableItemRow>
</>
)}
{queryType === 'DYNAMIC' && (
<VariableItemRow className="dynamic-variable-section">
<LabelContainer>
<Typography className="typography-variables">
Select Panels to apply this variable
</Typography>
</LabelContainer>
<WidgetSelector
selectedWidgets={selectedWidgets}
setSelectedWidgets={setSelectedWidgets}
/>
</VariableItemRow>
)}
</div>
</div>
<div className="variable-item-footer">
<VariableItemRow>
<Button
type="default"
onClick={onCancel}
icon={<X size={14} />}
className="footer-btn-discard"
>
Discard
</Button>
<Button
type="primary"
onClick={handleSave}
disabled={errorName || errorAttributeKey}
icon={<Check size={14} />}
className="footer-btn-save"
>
Save Variable
</Button>
</VariableItemRow>
</div>
</>
);
}
export default VariableItem;

View File

@@ -1,64 +0,0 @@
import React from 'react';
import { CustomMultiSelect } from 'components/NewSelect';
import { PANEL_GROUP_TYPES } from 'constants/queryBuilder';
import { generateGridTitle } from 'container/GridPanelSwitch/utils';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { WidgetRow, Widgets } from 'types/api/dashboard/getAll';
export function WidgetSelector({
selectedWidgets,
setSelectedWidgets,
}: {
selectedWidgets: string[];
setSelectedWidgets: (widgets: string[]) => void;
}): JSX.Element {
const { dashboardData } = useDashboardStore();
// Get layout IDs for cross-referencing
const layoutIds = new Set(
(dashboardData?.data?.layout || []).map((item) => item.i),
);
// Filter and deduplicate widgets by ID, keeping only those with layout entries
// and excluding row widgets since they are not panels that can have variables
const widgets = Object.values(
(dashboardData?.data?.widgets || []).reduce(
(acc: Record<string, WidgetRow | Widgets>, widget: WidgetRow | Widgets) => {
if (
widget.id &&
layoutIds.has(widget.id) &&
widget.panelTypes !== PANEL_GROUP_TYPES.ROW
) {
acc[widget.id] = widget;
}
return acc;
},
{},
),
);
// Filter selectedWidgets to only include widgets that are present in the current layout
const validSelectedWidgets = selectedWidgets.filter((widgetId) =>
layoutIds.has(widgetId),
);
// Update selectedWidgets if any invalid widgets were removed
React.useEffect(() => {
if (validSelectedWidgets.length !== selectedWidgets.length) {
setSelectedWidgets(validSelectedWidgets);
}
}, [validSelectedWidgets, selectedWidgets.length, setSelectedWidgets]);
return (
<CustomMultiSelect
placeholder="Select Panels"
options={widgets.map((widget: WidgetRow | Widgets) => ({
label: generateGridTitle(widget.title),
value: widget.id,
}))}
value={validSelectedWidgets}
onChange={(value): void => setSelectedWidgets(value as string[])}
showLabels
/>
);
}

View File

@@ -1,11 +0,0 @@
import { Row } from 'antd';
import styled from 'styled-components';
export const VariableItemRow = styled(Row)`
gap: 1rem;
margin-bottom: 1rem;
`;
export const LabelContainer = styled.div`
width: 200px;
`;

View File

@@ -1,537 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { GripVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
import type { DragEndEvent, UniqueIdentifier } from '@dnd-kit/core';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { arrayMove, SortableContext, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button, Modal, Row, RowProps, Space, Table, Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { VariablesSettingsTabHandle } from 'container/DashboardContainer/DashboardDescription/types';
import { convertVariablesToDbFormat } from 'container/DashboardContainer/DashboardVariablesSelection/util';
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { toast } from '@signozhq/ui/sonner';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard';
import { TVariableMode } from './types';
import VariableItem from './VariableItem/VariableItem';
import '../DashboardSettings.styles.scss';
function TableRow({ children, ...props }: RowProps): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
// @ts-expect-error
id: props['data-row-key'],
});
const style: React.CSSProperties = {
...props.style,
transform: CSS.Transform.toString(transform && { ...transform, scaleY: 1 }),
transition,
...(isDragging ? { position: 'relative', zIndex: 9999 } : {}),
};
return (
<tr {...props} ref={setNodeRef} style={style} {...attributes}>
{React.Children.map(children, (child) => {
const childElement = child as React.ReactElement;
if (childElement.key === 'name') {
return React.cloneElement(childElement, {
key: 'name-with-drag',
children: (
<div className="variable-name-drag">
<GripVertical
ref={setActivatorNodeRef as unknown as React.Ref<SVGSVGElement>}
style={{ touchAction: 'none', cursor: 'move' }}
size="md"
{...listeners}
/>
{child}
</div>
),
});
}
return childElement;
})}
</tr>
);
}
function VariablesSettings({
variablesSettingsTabHandle,
}: {
variablesSettingsTabHandle: VariablesSettingsTabHandle;
}): JSX.Element {
const variableToDelete = useRef<IDashboardVariable | null>(null);
const [deleteVariableModal, setDeleteVariableModal] = useState(false);
const variableToApplyToAll = useRef<IDashboardVariable | null>(null);
const [applyToAllModal, setApplyToAllModal] = useState(false);
const { t } = useTranslation(['dashboard']);
const { dashboardData, setDashboardData } = useDashboardStore();
const { dashboardVariables } = useDashboardVariables();
const [variablesTableData, setVariablesTableData] = useState<any>([]);
const [variblesOrderArr, setVariablesOrderArr] = useState<number[]>([]);
const [existingVariableNamesMap, setExistingVariableNamesMap] = useState<
Record<string, string>
>({});
const [variableViewMode, setVariableViewMode] = useState<null | TVariableMode>(
null,
);
const [variableEditData, setVariableEditData] =
useState<null | IDashboardVariable>(null);
const onDoneVariableViewMode = (): void => {
setVariableViewMode(null);
setVariableEditData(null);
};
const onVariableViewModeEnter = (
viewType: TVariableMode,
varData: IDashboardVariable,
): void => {
setVariableEditData(varData);
setVariableViewMode(viewType);
};
useEffect(() => {
if (!variablesSettingsTabHandle) {
return;
}
variablesSettingsTabHandle.current = {
resetState: onDoneVariableViewMode,
};
}, [variablesSettingsTabHandle]);
const updateMutation = useUpdateDashboard();
const addDynamicVariableToPanels = useAddDynamicVariableToPanels();
useEffect(() => {
const tableRowData = [];
const variableOrderArr = [];
const variableNamesMap = {};
for (const [key, value] of Object.entries(dashboardVariables)) {
const { order, id, name } = value;
tableRowData.push({
key,
name: key,
...dashboardVariables[key],
id,
});
if (name) {
// @ts-expect-error
variableNamesMap[name] = name;
}
if (order) {
variableOrderArr.push(order);
}
}
tableRowData.sort((a, b) => a.order - b.order);
variableOrderArr.sort((a, b) => a - b);
setVariablesTableData(tableRowData);
setVariablesOrderArr(variableOrderArr);
setExistingVariableNamesMap(variableNamesMap);
}, [dashboardVariables]);
const updateVariables = (
updatedVariablesData: IDashboardVariables,
currentRequestedId?: string,
widgetIds?: string[],
applyToAll?: boolean,
): void => {
if (!dashboardData) {
return;
}
const newDashboard =
(currentRequestedId &&
updatedVariablesData[currentRequestedId || '']?.type === 'DYNAMIC' &&
addDynamicVariableToPanels(
dashboardData,
updatedVariablesData[currentRequestedId || ''],
widgetIds,
applyToAll,
)) ||
dashboardData;
updateMutation.mutateAsync(
{
id: dashboardData.id,
data: {
...newDashboard.data,
variables: updatedVariablesData,
},
},
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
toast.success(t('variable_updated_successfully'));
}
},
},
);
};
const getVariableOrder = (): number => {
if (variblesOrderArr && variblesOrderArr.length > 0) {
return variblesOrderArr[variblesOrderArr.length - 1] + 1;
}
return 0;
};
const onVariableSaveHandler = (
mode: TVariableMode,
variableData: IDashboardVariable,
widgetIds?: string[],
applyToAll?: boolean,
): void => {
const updatedVariableData = {
...variableData,
order: variableData?.order >= 0 ? variableData.order : getVariableOrder(),
};
const newVariablesArr = variablesTableData.map(
(variable: IDashboardVariable) => {
if (variable.id === updatedVariableData.id) {
return updatedVariableData;
}
return variable;
},
);
if (mode === 'ADD') {
newVariablesArr.push(updatedVariableData);
}
const variables = convertVariablesToDbFormat(newVariablesArr);
setVariablesTableData(newVariablesArr);
updateVariables(variables, variableData?.id, widgetIds, applyToAll);
onDoneVariableViewMode();
};
const onVariableDeleteHandler = (variable: IDashboardVariable): void => {
variableToDelete.current = variable;
setDeleteVariableModal(true);
};
const handleDeleteConfirm = (): void => {
if (!dashboardData || !variableToDelete.current) {
setDeleteVariableModal(false);
return;
}
const newVariablesArr = variablesTableData.filter(
(variable: IDashboardVariable) =>
variable.id !== variableToDelete?.current?.id,
);
const updatedVariables = convertVariablesToDbFormat(newVariablesArr);
const cleanedDashboard =
removeVariableReferencesFromDashboard(
dashboardData,
variableToDelete.current.name || '',
) || dashboardData;
updateMutation.mutateAsync(
{
id: dashboardData.id,
data: {
...cleanedDashboard.data,
variables: updatedVariables,
},
},
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
toast.success(t('variable_updated_successfully'));
}
},
},
);
variableToDelete.current = null;
setDeleteVariableModal(false);
};
const handleDeleteCancel = (): void => {
variableToDelete.current = null;
setDeleteVariableModal(false);
};
const onApplyToAllHandler = (variable: IDashboardVariable): void => {
variableToApplyToAll.current = variable;
setApplyToAllModal(true);
};
const handleApplyToAllConfirm = (): void => {
if (variableToApplyToAll.current) {
onVariableSaveHandler(
variableViewMode || 'EDIT',
variableToApplyToAll.current,
[],
true,
);
}
variableToApplyToAll.current = null;
setApplyToAllModal(false);
};
const handleApplyToAllCancel = (): void => {
variableToApplyToAll.current = null;
setApplyToAllModal(false);
};
const validateVariableName = (name: string): boolean =>
!existingVariableNamesMap[name];
const validateAttributeKey = (
attributeKey: string,
currentVariableId?: string,
): boolean => {
// Check if any other dynamic variable already uses this attribute key
const isDuplicateAttributeKey = Object.values(dashboardVariables).some(
(variable: IDashboardVariable) =>
variable.type === 'DYNAMIC' &&
variable.dynamicVariablesAttribute === attributeKey &&
variable.id !== currentVariableId, // Exclude current variable being edited
);
return !isDuplicateAttributeKey;
};
const columns = [
{
title: 'Variable',
dataIndex: 'name',
width: '50%',
key: 'name',
},
{
title: 'Description',
width: '50%',
key: 'description',
render: (variable: IDashboardVariable): JSX.Element => (
<div className="variable-description-actions">
<Typography.Text className="variable-description">
{variable.description}
</Typography.Text>
<Space className="actions-btns">
{variable.type === 'DYNAMIC' && (
<Button
type="text"
onClick={(): void => onApplyToAllHandler(variable)}
className="apply-to-all-button"
loading={updateMutation.isLoading}
>
<Typography.Text>Apply to all</Typography.Text>
</Button>
)}
<Button
type="text"
onClick={(): void => onVariableViewModeEnter('EDIT', variable)}
className="edit-variable-button"
>
<PenLine size={14} />
</Button>
<Button
type="text"
onClick={(): void => {
if (variable) {
onVariableDeleteHandler(variable);
}
}}
className="delete-variable-button"
>
<Trash2 size={14} />
</Button>
</Space>
</div>
),
},
];
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
// https://docs.dndkit.com/api-documentation/sensors/pointer#activation-constraints
distance: 1,
},
}),
);
const onDragEnd = ({ active, over }: DragEndEvent): void => {
if (active.id !== over?.id) {
const activeIndex = variablesTableData.findIndex(
(i: { key: UniqueIdentifier }) => i.key === active.id,
);
const overIndex = variablesTableData.findIndex(
(i: { key: UniqueIdentifier | undefined }) => i.key === over?.id,
);
const updatedVariables: IDashboardVariable[] = arrayMove(
variablesTableData,
activeIndex,
overIndex,
);
const reArrangedVariables = {};
for (let index = 0; index < updatedVariables.length; index += 1) {
const variableName = updatedVariables[index].name;
if (variableName) {
// @ts-expect-error
reArrangedVariables[variableName] = {
...updatedVariables[index],
order: index,
};
}
}
updateVariables(reArrangedVariables);
setVariablesTableData(updatedVariables);
}
};
return (
<>
{variableViewMode ? (
<VariableItem
variableData={{ ...variableEditData } as IDashboardVariable}
existingVariables={dashboardVariables}
onSave={onVariableSaveHandler}
onCancel={onDoneVariableViewMode}
validateName={validateVariableName}
validateAttributeKey={validateAttributeKey}
mode={variableViewMode}
/>
) : (
<>
<Row
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
padding: '0.5rem 0',
position: 'absolute',
top: '-56px',
right: '0px',
zIndex: '1',
}}
>
<Button
data-testid="add-new-variable"
type="primary"
onClick={(): void =>
onVariableViewModeEnter('ADD', {} as IDashboardVariable)
}
>
<Flex align="center" justify="center" gap={4}>
<Plus size="md" /> Add Variable
</Flex>
</Button>
</Row>
<DndContext
sensors={sensors}
modifiers={[restrictToVerticalAxis]}
onDragEnd={onDragEnd}
>
<SortableContext
// rowKey array
items={variablesTableData.map((variable: { key: any }) => variable.key)}
>
<Table
components={{
body: {
row: TableRow,
},
}}
rowKey="key"
columns={columns}
pagination={false}
dataSource={variablesTableData}
className="dashboard-variable-settings-table"
/>
</SortableContext>
</DndContext>
</>
)}
<Modal
title="Delete variable"
centered
open={deleteVariableModal}
onOk={handleDeleteConfirm}
onCancel={handleDeleteCancel}
okButtonProps={{ loading: updateMutation.isLoading }}
>
<Typography.Text>
Are you sure you want to delete variable{' '}
<span className="delete-variable-name">
{variableToDelete?.current?.name}
</span>
?
</Typography.Text>
</Modal>
<Modal
title="Apply variable to all panels"
centered
open={applyToAllModal}
onOk={handleApplyToAllConfirm}
onCancel={handleApplyToAllCancel}
okText="Apply to all"
cancelText="Cancel"
>
<Typography.Text>
Are you sure you want to apply variable{' '}
<span className="apply-to-all-variable-name">
{variableToApplyToAll?.current?.name}
</span>{' '}
to all panels? This action may affect panels where this variable is not
applicable.
</Typography.Text>
</Modal>
</>
);
}
export default VariablesSettings;

View File

@@ -1,7 +0,0 @@
export type TVariableMode = 'VIEW' | 'EDIT' | 'ADD';
export const VariableModes = {
VIEW: 'VIEW',
EDIT: 'EDIT',
ADD: 'ADD',
};

View File

@@ -1,49 +0,0 @@
.badgesContainer {
display: flex;
align-items: center;
flex-flow: wrap;
gap: 6px;
}
.badgeContainer {
color: var(--bg-sienna-400);
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.52px;
height: 24px;
flex-shrink: 0;
border-radius: 50px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
padding: 2px 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.inputContainer {
> div {
margin: 0;
}
padding-left: 0px !important;
padding-right: 0px !important;
}
.tagsInput {
display: flex;
border: none;
padding: 0px;
width: 183px;
height: 24px;
padding: 3px 1px;
flex-shrink: 0;
}
.editInput {
.ant-form-item {
margin-bottom: 0px;
}
}

View File

@@ -1,115 +0,0 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { Col, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import Input from 'components/Input';
import styles from './AddBadges.module.scss';
function AddTags({ tags, setTags }: AddTagsProps): JSX.Element {
const [inputValue, setInputValue] = useState<string>('');
const [editInputIndex, setEditInputIndex] = useState(-1);
const [editInputValue, setEditInputValue] = useState('');
const handleInputConfirm = (): void => {
if (inputValue) {
setTags([...tags, inputValue]);
}
setInputValue('');
};
const handleEditInputConfirm = (): void => {
const newTags = [...tags];
newTags[editInputIndex] = editInputValue;
setTags(newTags);
setEditInputIndex(-1);
setInputValue('');
};
const handleClose = (removedTag: string): void => {
const newTags = tags.filter((tag) => tag !== removedTag);
setTags(newTags);
};
const onChangeHandler = (
value: string,
func: Dispatch<SetStateAction<string>>,
): void => {
func(value);
};
return (
<div className={styles.badgesContainer}>
{tags.map((tag, index) => {
if (editInputIndex === index) {
return (
<Col key={tag} lg={4} className={styles.editInput}>
<Input
size="small"
value={editInputValue}
onChangeHandler={(event): void =>
onChangeHandler(event.target.value, setEditInputValue)
}
onBlurHandler={handleEditInputConfirm}
onPressEnterHandler={handleEditInputConfirm}
/>
</Col>
);
}
const isLongTag = tag.length > 20;
const tagElem = (
<Badge
key={tag}
color="vanilla"
className={styles.badgeContainer}
closable
onClose={(e): void => {
e.preventDefault();
handleClose(tag);
}}
>
<span
onDoubleClick={(e): void => {
setEditInputIndex(index);
setEditInputValue(tag);
e.preventDefault();
}}
>
{isLongTag ? `${tag.slice(0, 20)}...` : tag}
</span>
</Badge>
);
return isLongTag ? (
<Tooltip title={tag} key={tag}>
{tagElem}
</Tooltip>
) : (
tagElem
);
})}
<Col className={styles.inputContainer}>
<Input
type="text"
value={inputValue}
rootClassName={styles.tagsInput}
placeholder="Start typing your tag name"
onChangeHandler={(event): void =>
onChangeHandler(event.target.value, setInputValue)
}
onBlurHandler={handleInputConfirm}
onPressEnterHandler={handleInputConfirm}
/>
</Col>
</div>
);
}
interface AddTagsProps {
tags: string[];
setTags: Dispatch<SetStateAction<string[]>>;
}
export default AddTags;

View File

@@ -1,227 +0,0 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 16px;
}
.overviewSettings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
}
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.crossPanelSyncSectionTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.crossPanelSyncSectionHeader {
display: flex;
align-items: center;
gap: 6px;
align-self: flex-start;
}
.crossPanelSyncInfoIcon {
cursor: help;
color: var(--l3-foreground);
}
.crossPanelSyncTooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 300px;
}
.crossPanelSyncTooltipTitle {
font-size: 14px;
}
.crossPanelSyncTooltipDescription {
font-size: 12px;
line-height: 1.5;
}
.crossPanelSyncTooltipDocLink {
display: flex;
align-items: center;
gap: 4px;
color: var(--primary-background);
font-size: 12px;
margin-top: 4px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px solid var(--l1-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
.nameIconInput {
display: flex;
}
.dashboardImageInput {
:global(.ant-select-selector) {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l3-background) !important;
:global(.ant-select-selection-item) {
display: flex;
align-items: center;
}
}
&:global(.ant-select-dropdown) {
padding: 0px !important;
}
:global(.ant-select-item) {
padding: 0px;
align-items: center;
justify-content: center;
:global(.ant-select-item-option-content) {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.dashboardName {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
margin-bottom: 0.5rem;
}
.descriptionTextArea {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.overviewSettingsFooter {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
}
.unsaved {
display: flex;
align-items: center;
gap: 8px;
}
.unsavedDot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsavedChanges {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.footerActionBtns {
display: flex;
gap: 8px;
}
.discardBtn {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.saveBtn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@@ -1,299 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Col, Input, Select, Space, Tooltip } from 'antd';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Typography } from '@signozhq/ui/typography';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, ExternalLink, SolidInfoCircle, X } from '@signozhq/icons';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import styles from './GeneralSettings.module.scss';
import { Button } from './styles';
import { Base64Icons } from './utils';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { getAbsoluteUrl } from 'utils/basePath';
const { Option } = Select;
function GeneralDashboardSettings(): JSX.Element {
const { dashboardData, setDashboardData } = useDashboardStore();
const updateDashboardMutation = useUpdateDashboard();
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(
dashboardData?.id,
);
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
useSyncTooltipFilterMode(dashboardData?.id);
const selectedData = dashboardData?.data;
const {
title = '',
tags = [],
description = '',
image = Base64Icons[0],
} = selectedData || {};
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const [updatedTags, setUpdatedTags] = useState<string[]>(tags || []);
const [updatedDescription, setUpdatedDescription] = useState(
description || '',
);
const [updatedImage, setUpdatedImage] = useState<string>(image);
const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] =
useState<number>(0);
const { t } = useTranslation('common');
const onSaveHandler = (): void => {
if (!dashboardData) {
return;
}
updateDashboardMutation.mutate(
{
id: dashboardData.id,
data: {
...dashboardData.data,
description: updatedDescription,
tags: updatedTags,
title: updatedTitle,
image: updatedImage,
},
},
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
}
},
onError: () => {},
},
);
};
useEffect(() => {
let numberOfUnsavedChanges = 0;
const initialValues = [title, description, tags, image];
const updatedValues = [
updatedTitle,
updatedDescription,
updatedTags,
updatedImage,
];
initialValues.forEach((val, index) => {
if (!isEqual(val, updatedValues[index])) {
numberOfUnsavedChanges += 1;
}
});
setNumberOfUnsavedChanges(numberOfUnsavedChanges);
}, [
description,
image,
tags,
title,
updatedDescription,
updatedImage,
updatedTags,
updatedTitle,
]);
const discardHandler = (): void => {
setUpdatedTitle(title);
setUpdatedImage(image);
setUpdatedTags(tags);
setUpdatedDescription(description);
};
return (
<div className={styles.overviewContent}>
<Col className={styles.overviewSettings}>
<Space
direction="vertical"
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: '21px',
}}
>
<div>
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
defaultActiveFirstOption
data-testid="dashboard-image"
suffixIcon={null}
rootClassName={styles.dashboardImageInput}
value={updatedImage}
onChange={(value: string): void => setUpdatedImage(value)}
>
{Base64Icons.map((icon) => (
<Option value={icon} key={icon}>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</Option>
))}
</Select>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
value={updatedTitle}
onChange={(e): void => setUpdatedTitle(e.target.value)}
/>
</section>
</div>
<div>
<Typography className={styles.dashboardName}>Description</Typography>
<Input.TextArea
data-testid="dashboard-desc"
rows={6}
value={updatedDescription}
className={styles.descriptionTextArea}
onChange={(e): void => setUpdatedDescription(e.target.value)}
/>
</div>
<div>
<Typography className={styles.dashboardName}>Tags</Typography>
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
</div>
</Space>
</Col>
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
<div className={styles.crossPanelSyncSectionHeader}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<Tooltip
title={
<div className={styles.crossPanelSyncTooltipContent}>
<strong className={styles.crossPanelSyncTooltipTitle}>
Cross-Panel Sync
</strong>
<span className={styles.crossPanelSyncTooltipDescription}>
Sync crosshair and tooltip across all the dashboard panels
</span>
<a
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
target="_blank"
rel="noopener noreferrer"
className={styles.crossPanelSyncTooltipDocLink}
>
Learn more
<ExternalLink size={12} />
</a>
</div>
}
placement="top"
mouseEnterDelay={0.5}
>
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
</Tooltip>
</div>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Sync Mode
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<ToggleGroupSimple
type="single"
value={cursorSyncMode}
onChange={(value: string): void => {
setCursorSyncMode(value as DashboardCursorSync);
}}
items={[
{ value: DashboardCursorSync.None, label: 'No Sync' },
{ value: DashboardCursorSync.Crosshair, label: 'Crosshair' },
{ value: DashboardCursorSync.Tooltip, label: 'Tooltip' },
]}
/>
</div>
{cursorSyncMode === DashboardCursorSync.Tooltip && (
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Synced Tooltip Series
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Show only series that intersect on group-by, or every series with the
matching ones highlighted
</Typography.Text>
</div>
<ToggleGroupSimple
type="single"
value={syncTooltipFilterMode}
onChange={(value: string): void => {
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
path: getAbsoluteUrl(window.location.pathname),
mode: value,
});
setSyncTooltipFilterMode(value as SyncTooltipFilterMode);
}}
items={[
{ value: SyncTooltipFilterMode.All, label: 'All' },
{ value: SyncTooltipFilterMode.Filtered, label: 'Filtered' },
]}
/>
</div>
)}
</Col>
{numberOfUnsavedChanges > 0 && (
<div className={styles.overviewSettingsFooter}>
<div className={styles.unsaved}>
<div className={styles.unsavedDot} />
<Typography.Text className={styles.unsavedChanges}>
{numberOfUnsavedChanges} unsaved change
{numberOfUnsavedChanges > 1 && 's'}
</Typography.Text>
</div>
<div className={styles.footerActionBtns}>
<Button
disabled={updateDashboardMutation.isLoading}
icon={<X size={14} />}
onClick={discardHandler}
type="text"
className={styles.discardBtn}
>
Discard
</Button>
<Button
style={{
margin: '16px 0',
}}
disabled={updateDashboardMutation.isLoading}
loading={updateDashboardMutation.isLoading}
icon={<Check size={14} />}
data-testid="save-dashboard-config"
onClick={onSaveHandler}
type="primary"
className={styles.saveBtn}
>
{t('save')}
</Button>
</div>
</div>
)}
</div>
);
}
export default GeneralDashboardSettings;

View File

@@ -1,20 +0,0 @@
import { Button as ButtonComponent, Drawer } from 'antd';
import styled from 'styled-components';
export const Container = styled.div`
margin-top: 0.5rem;
`;
export const Button = styled(ButtonComponent)`
&&& {
display: flex;
align-items: center;
}
`;
export const DrawerContainer = styled(Drawer)`
.ant-drawer-header {
padding: 0;
border: none;
}
`;

File diff suppressed because one or more lines are too long

View File

@@ -1,109 +0,0 @@
.public-dashboard-setting-container {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
.public-dashboard-setting-content {
display: flex;
flex-direction: column;
gap: 8px;
.public-dashboard-setting-content-title {
margin-bottom: 16px;
}
.timerange-enabled-checkbox {
margin-bottom: 8px;
}
.default-time-range-select {
margin-bottom: 8px;
.default-time-range-select-label {
margin-bottom: 4px;
.default-time-range-select-label-text {
font-size: 12px;
font-weight: 500;
}
}
.ant-select-selector {
display: flex;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
.ant-select-selection-item {
display: flex;
align-items: center;
.list-item-image {
height: 16px;
width: 16px;
}
}
}
}
.public-dashboard-url {
.url-label-container {
margin-bottom: 4px;
.url-label {
font-size: 12px;
font-weight: 500;
}
}
.url-container {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
border-radius: 4px;
padding: 0px 4px;
.url-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.default-time-range-select-dropdown {
width: 200px;
}
.public-dashboard-setting-callout {
margin-top: 12px;
background: color-mix(in srgb, var(--primary-background) 10%, transparent);
padding: 12px 8px;
border-radius: 3px;
.public-dashboard-setting-callout-text {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 400;
color: var(--text-robin-300);
}
}
}
.public-dashboard-setting-actions {
margin-top: 32px;
display: flex;
gap: 8px;
justify-content: flex-end;
}
}

View File

@@ -1,434 +0,0 @@
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import { fireEvent, within } from '@testing-library/react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { StatusCodes } from 'http-status-codes';
import {
publishedPublicDashboardMeta,
unpublishedPublicDashboardMeta,
} from 'mocks-server/__mockdata__/publicDashboard';
import { rest, server } from 'mocks-server/server';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { USER_ROLES } from 'types/roles';
import PublicDashboardSetting from '../index';
// Mock dependencies
jest.mock('providers/Dashboard/store/useDashboardStore');
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: jest.fn(),
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
const mockUseDashboard = jest.mocked(useDashboardStore);
const mockUseCopyToClipboard = jest.mocked(useCopyToClipboard);
const mockToast = jest.mocked(toast);
// Test constants
const MOCK_DASHBOARD_ID = 'test-dashboard-id';
const MOCK_PUBLIC_PATH = '/public/dashboard/test-dashboard-id';
const DASHBOARD_VARIABLES_WARNING =
"Dashboard variables won't work in public dashboards";
// Use wildcard pattern to match both relative and absolute URLs in MSW
const publicDashboardURL = `*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`;
const mockDashboardData = {
id: MOCK_DASHBOARD_ID,
data: {
title: 'Test Dashboard',
widgets: [],
layout: [],
panelMap: {},
variables: {},
},
};
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
});
const mockSetCopyPublicDashboardURL = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Mock window.open
window.open = jest.fn();
// Mock useDashboardStore
mockUseDashboard.mockReturnValue({
dashboardData: mockDashboardData,
} as unknown as ReturnType<typeof useDashboardStore>);
// Mock useCopyToClipboard
mockUseCopyToClipboard.mockReturnValue([
undefined,
mockSetCopyPublicDashboardURL,
] as unknown as ReturnType<typeof useCopyToClipboard>);
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
describe('PublicDashboardSetting', () => {
describe('Unpublished Dashboard', () => {
it('Unpublished dashboard should be handled correctly', async () => {
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(
ctx.status(StatusCodes.NOT_FOUND),
ctx.json(unpublishedPublicDashboardMeta),
),
),
);
render(<PublicDashboardSetting />);
await waitFor(() => {
expect(
screen.getByText(
/This dashboard is private. Publish it to make it accessible to anyone with the link./i,
),
).toBeInTheDocument();
});
await expect(
screen.findByRole('checkbox', { name: /enable time range/i }),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/default time range/i),
).resolves.toBeInTheDocument();
expect(screen.getByText(/Last 30 minutes/i)).toBeInTheDocument();
await waitFor(() => {
expect(
screen.getByText(new RegExp(DASHBOARD_VARIABLES_WARNING, 'i')),
).toBeInTheDocument();
});
await expect(
screen.findByRole('button', { name: /publish dashboard/i }),
).resolves.toBeInTheDocument();
});
});
describe('Published Dashboard', () => {
it('Published dashboard should be handled correctly', async () => {
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
);
render(<PublicDashboardSetting />);
await waitFor(() => {
expect(
screen.getByText(
/This dashboard is publicly accessible. Anyone with the link can view it./i,
),
).toBeInTheDocument();
});
await expect(
screen.findByRole('checkbox', { name: /enable time range/i }),
).resolves.toBeChecked();
await waitFor(() => {
expect(screen.getByText(/default time range/i)).toBeInTheDocument();
});
expect(screen.getByText(/Last 30 minutes/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/Public Dashboard URL/i)).toBeInTheDocument();
});
await expect(
screen.findByRole('button', { name: /update published dashboard/i }),
).resolves.toBeInTheDocument();
await expect(
screen.findByRole('button', { name: /unpublish dashboard/i }),
).resolves.toBeInTheDocument();
});
});
describe('Time Range Settings', () => {
beforeEach(() => {
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
);
});
it('should toggle time range enabled when checkbox is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<PublicDashboardSetting />);
// Wait for checkbox to be rendered and verify initial state
const checkbox = await screen.findByRole('checkbox', {
name: /enable time range/i,
});
expect(checkbox).toBeChecked();
await user.click(checkbox);
await waitFor(() => {
expect(checkbox).not.toBeChecked();
});
});
it('should update default time range when select value changes', async () => {
render(<PublicDashboardSetting />);
const selectContainer = await screen.findByTestId(
'default-time-range-select-dropdown',
);
const combobox = within(selectContainer).getByRole('combobox');
fireEvent.mouseDown(combobox);
await screen.findByRole('listbox');
const option = await screen.findByText(/Last 1 hour/i, {
selector: '.ant-select-item-option-content',
});
fireEvent.click(option);
await waitFor(() => {
expect(
within(selectContainer).getByText(/Last 1 hour/i),
).toBeInTheDocument();
});
});
});
describe('Create Public Dashboard', () => {
it('should call create API when publish button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let createApiCalled = false;
server.use(
rest.get(publicDashboardURL, (_req, res, ctx) =>
res(
ctx.status(StatusCodes.OK),
ctx.json({
data: {
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
publicPath: '',
},
}),
),
),
rest.post(publicDashboardURL, async (req, res, ctx) => {
const body = await req.json();
createApiCalled = true;
expect(body).toStrictEqual({
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
});
return res(
ctx.status(StatusCodes.CREATED),
ctx.json({
data: {
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
publicPath: MOCK_PUBLIC_PATH,
},
}),
);
}),
);
render(<PublicDashboardSetting />);
// Find and click publish button
const publishButton = await screen.findByRole('button', {
name: /publish dashboard/i,
});
await user.click(publishButton);
await waitFor(() => {
expect(createApiCalled).toBe(true);
expect(mockToast.success).toHaveBeenCalledWith(
'Public dashboard created successfully',
);
});
});
});
describe('Update Public Dashboard', () => {
it('should call update API when update button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let updateApiCalled = false;
let capturedRequestBody: {
timeRangeEnabled: boolean;
defaultTimeRange: string;
} | null = null;
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
rest.put(publicDashboardURL, async (req, res, ctx) => {
const body = await req.json();
updateApiCalled = true;
capturedRequestBody = body;
return res(ctx.status(StatusCodes.NO_CONTENT), ctx.json({}));
}),
);
render(<PublicDashboardSetting />);
// Wait for API response and component update
const updateButton = await screen.findByRole(
'button',
{ name: /update published dashboard/i },
{ timeout: 5000 },
);
await user.click(updateButton);
await waitFor(() => {
expect(updateApiCalled).toBe(true);
expect(capturedRequestBody).toStrictEqual({
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
});
expect(mockToast.success).toHaveBeenCalledWith(
'Public dashboard updated successfully',
);
});
});
});
describe('Revoke Public Dashboard Access', () => {
it('should call revoke API when unpublish button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let revokeApiCalled = false;
let capturedDashboardId: string | null = null;
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
rest.delete(publicDashboardURL, (req, res, ctx) => {
revokeApiCalled = true;
// Extract dashboard ID from URL: /api/v1/dashboards/{id}/public
const urlMatch = req.url.pathname.match(
/\/api\/v1\/dashboards\/([^/]+)\/public/,
);
capturedDashboardId = urlMatch ? urlMatch[1] : null;
return res(ctx.status(StatusCodes.NO_CONTENT), ctx.json({}));
}),
);
render(<PublicDashboardSetting />);
// Wait for API response and component update
const unpublishButton = await screen.findByRole(
'button',
{ name: /unpublish dashboard/i },
{ timeout: 5000 },
);
await user.click(unpublishButton);
await waitFor(() => {
expect(revokeApiCalled).toBe(true);
expect(capturedDashboardId).toBe(MOCK_DASHBOARD_ID);
expect(mockToast.success).toHaveBeenCalledWith(
'Dashboard unpublished successfully',
);
});
});
});
describe('Non-admin user permissions', () => {
it('should disable "Publish dashboard" button for non-admin users', async () => {
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(
ctx.status(StatusCodes.NOT_FOUND),
ctx.json(unpublishedPublicDashboardMeta),
),
),
);
render(<PublicDashboardSetting />, {}, { role: USER_ROLES.VIEWER });
const publishButton = await screen.findByRole('button', {
name: /publish dashboard/i,
});
expect(publishButton).toBeInTheDocument();
expect(publishButton).toBeDisabled();
expect(publishButton).toHaveAttribute('disabled');
});
describe('Published dashboard buttons for non-admin users', () => {
beforeEach(() => {
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
);
});
it('should disable "Unpublish dashboard" button for non-admin users', async () => {
render(<PublicDashboardSetting />, {}, { role: USER_ROLES.VIEWER });
const unpublishButton = await screen.findByRole('button', {
name: /unpublish dashboard/i,
});
expect(unpublishButton).toBeInTheDocument();
expect(unpublishButton).toBeDisabled();
expect(unpublishButton).toHaveAttribute('disabled');
});
it('should disable "Update published dashboard" button for non-admin users', async () => {
render(<PublicDashboardSetting />, {}, { role: USER_ROLES.VIEWER });
const updateButton = await screen.findByRole('button', {
name: /update published dashboard/i,
});
expect(updateButton).toBeInTheDocument();
expect(updateButton).toBeDisabled();
expect(updateButton).toHaveAttribute('disabled');
});
});
});
});

View File

@@ -1,378 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation } from 'react-query';
import { useCopyToClipboard } from 'react-use';
import { Checkbox } from '@signozhq/ui/checkbox';
import { toast } from '@signozhq/ui/sonner';
import { Button, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import createPublicDashboardAPI from 'api/dashboard/public/createPublicDashboard';
import revokePublicDashboardAccessAPI from 'api/dashboard/public/revokePublicDashboardAccess';
import updatePublicDashboardAPI from 'api/dashboard/public/updatePublicDashboard';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboardMeta';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import {
Copy,
ExternalLink,
Globe,
Info,
LoaderCircle,
Trash,
} from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { PublicDashboardMetaProps } from 'types/api/dashboard/public/getMeta';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import './PublicDashboard.styles.scss';
export const TIME_RANGE_PRESETS_OPTIONS = [
{
label: 'Last 5 minutes',
value: '5m',
},
{
label: 'Last 15 minutes',
value: '15m',
},
{
label: 'Last 30 minutes',
value: '30m',
},
{
label: 'Last 1 hour',
value: '1h',
},
{
label: 'Last 6 hours',
value: '6h',
},
{
label: 'Last 1 day',
value: '24h',
},
];
const showErrorNotification = (error: APIError): void => {
toast.error(error.getErrorCode(), {
description: error.getErrorMessage(),
});
};
function PublicDashboardSetting(): JSX.Element {
const [publicDashboardData, setPublicDashboardData] = useState<
PublicDashboardMetaProps | undefined
>(undefined);
const [timeRangeEnabled, setTimeRangeEnabled] = useState(true);
const [defaultTimeRange, setDefaultTimeRange] = useState(DEFAULT_TIME_RANGE);
const [, setCopyPublicDashboardURL] = useCopyToClipboard();
const { dashboardData } = useDashboardStore();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const isPublicDashboardEnabled = isCloudUser || isEnterpriseSelfHostedUser;
const { user } = useAppContext();
const isAdmin = user?.role === USER_ROLES.ADMIN;
const handleDefaultTimeRange = useCallback((value: string): void => {
setDefaultTimeRange(value);
}, []);
const handleTimeRangeEnabled = useCallback((): void => {
setTimeRangeEnabled((prev) => !prev);
}, []);
const {
data: publicDashboardResponse,
isLoading: isLoadingPublicDashboard,
isFetching: isFetchingPublicDashboard,
refetch: refetchPublicDashboard,
error: errorPublicDashboard,
} = useGetPublicDashboardMeta(
dashboardData?.id || '',
!!dashboardData?.id && isPublicDashboardEnabled,
);
const isPublicDashboard = !!publicDashboardData?.publicPath;
useEffect(() => {
if (publicDashboardResponse?.data) {
setPublicDashboardData(publicDashboardResponse?.data);
}
if (errorPublicDashboard) {
console.error('Error getting public dashboard', errorPublicDashboard);
setPublicDashboardData(undefined);
setTimeRangeEnabled(true);
setDefaultTimeRange(DEFAULT_TIME_RANGE);
}
}, [publicDashboardResponse, errorPublicDashboard]);
useEffect(() => {
if (publicDashboardResponse?.data) {
setTimeRangeEnabled(
publicDashboardResponse?.data?.timeRangeEnabled || false,
);
setDefaultTimeRange(
publicDashboardResponse?.data?.defaultTimeRange || DEFAULT_TIME_RANGE,
);
}
}, [publicDashboardResponse]);
const {
mutate: createPublicDashboard,
isLoading: isLoadingCreatePublicDashboard,
data: createPublicDashboardResponse,
} = useMutation(createPublicDashboardAPI, {
onSuccess: () => {
toast.success('Public dashboard created successfully');
},
onError: (error: APIError) => {
showErrorNotification(error);
},
});
const {
mutate: updatePublicDashboard,
isLoading: isLoadingUpdatePublicDashboard,
data: updatePublicDashboardResponse,
} = useMutation(updatePublicDashboardAPI, {
onSuccess: () => {
toast.success('Public dashboard updated successfully');
},
onError: (error: APIError) => {
showErrorNotification(error);
},
});
const {
mutate: revokePublicDashboardAccess,
isLoading: isLoadingRevokePublicDashboardAccess,
data: revokePublicDashboardAccessResponse,
} = useMutation(revokePublicDashboardAccessAPI, {
onSuccess: () => {
toast.success('Dashboard unpublished successfully');
},
onError: (error: APIError) => {
showErrorNotification(error);
},
});
const handleCreatePublicDashboard = (): void => {
if (!dashboardData) {
return;
}
createPublicDashboard({
dashboardId: dashboardData.id,
timeRangeEnabled,
defaultTimeRange,
});
};
const handleUpdatePublicDashboard = (): void => {
if (!dashboardData) {
return;
}
updatePublicDashboard({
dashboardId: dashboardData.id,
timeRangeEnabled,
defaultTimeRange,
});
};
const handleRevokePublicDashboardAccess = (): void => {
if (!dashboardData) {
return;
}
revokePublicDashboardAccess({
id: dashboardData.id,
});
};
useEffect(() => {
if (
(createPublicDashboardResponse &&
createPublicDashboardResponse.httpStatusCode === 201) ||
(updatePublicDashboardResponse &&
updatePublicDashboardResponse.httpStatusCode === 204) ||
(revokePublicDashboardAccessResponse &&
revokePublicDashboardAccessResponse.httpStatusCode === 204)
) {
refetchPublicDashboard();
}
}, [
createPublicDashboardResponse,
updatePublicDashboardResponse,
revokePublicDashboardAccessResponse,
refetchPublicDashboard,
]);
const handleCopyPublicDashboardURL = (): void => {
if (!publicDashboardResponse?.data?.publicPath) {
return;
}
try {
setCopyPublicDashboardURL(
getAbsoluteUrl(publicDashboardResponse?.data?.publicPath ?? ''),
);
toast.success('Copied Public Dashboard URL successfully');
} catch (error) {
console.error('Error copying public dashboard URL', error);
}
};
const publicDashboardURL = useMemo(
() => getAbsoluteUrl(publicDashboardResponse?.data?.publicPath ?? ''),
[publicDashboardResponse],
);
const isLoading =
isLoadingCreatePublicDashboard ||
isLoadingUpdatePublicDashboard ||
isLoadingRevokePublicDashboardAccess ||
isLoadingPublicDashboard;
return (
<div className="public-dashboard-setting-container">
<div className="public-dashboard-setting-content">
<Typography.Title
level={5}
className="public-dashboard-setting-content-title"
>
{isPublicDashboard
? 'This dashboard is publicly accessible. Anyone with the link can view it.'
: 'This dashboard is private. Publish it to make it accessible to anyone with the link.'}
</Typography.Title>
<div className="timerange-enabled-checkbox">
<Checkbox
id="enable-time-range"
value={timeRangeEnabled}
onChange={handleTimeRangeEnabled}
>
Enable time range
</Checkbox>
</div>
<div className="default-time-range-select">
<div className="default-time-range-select-label">
<Typography.Text className="default-time-range-select-label-text">
Default time range
</Typography.Text>
</div>
<Select
placeholder="Select default time range"
options={TIME_RANGE_PRESETS_OPTIONS}
value={defaultTimeRange}
onChange={handleDefaultTimeRange}
data-testid="default-time-range-select-dropdown"
className="default-time-range-select-dropdown"
/>
</div>
{isPublicDashboard && (
<div className="public-dashboard-url">
<div className="url-label-container">
<Typography.Text className="url-label">
Public Dashboard URL
</Typography.Text>
</div>
<div className="url-container">
<Typography.Text className="url-text">
{publicDashboardURL}
</Typography.Text>
<Button
type="link"
className="url-copy-btn periscope-btn ghost"
icon={<Copy size={12} />}
onClick={handleCopyPublicDashboardURL}
/>
<Button
type="link"
className="periscope-btn ghost"
icon={<ExternalLink size={12} />}
onClick={(): void => {
if (publicDashboardURL) {
openInNewTab(publicDashboardURL);
}
}}
/>
</div>
</div>
)}
<div className="public-dashboard-setting-callout">
<Typography.Text className="public-dashboard-setting-callout-text">
<Info size={12} className="public-dashboard-setting-callout-icon" />{' '}
Dashboard variables won&apos;t work in public dashboards
</Typography.Text>
</div>
<div className="public-dashboard-setting-actions">
{!isPublicDashboard ? (
<Button
type="primary"
className="create-public-dashboard-btn periscope-btn primary"
disabled={isLoading || !isAdmin}
onClick={handleCreatePublicDashboard}
loading={
isLoadingCreatePublicDashboard ||
isFetchingPublicDashboard ||
isLoadingPublicDashboard
}
icon={
isLoadingCreatePublicDashboard ||
isFetchingPublicDashboard ||
isLoadingPublicDashboard ? (
<LoaderCircle className="animate-spin" size={14} />
) : (
<Globe size={14} />
)
}
>
Publish dashboard
</Button>
) : (
<>
<Button
type="default"
className="periscope-btn secondary"
disabled={isLoading || !isAdmin}
onClick={handleRevokePublicDashboardAccess}
loading={isLoadingRevokePublicDashboardAccess}
icon={<Trash size={14} />}
>
Unpublish dashboard
</Button>
<Button
type="primary"
className="create-public-dashboard-btn periscope-btn primary"
disabled={isLoading || !isAdmin}
onClick={handleUpdatePublicDashboard}
loading={isLoadingUpdatePublicDashboard}
icon={<Globe size={14} />}
>
Update published dashboard
</Button>
</>
)}
</div>
</div>
</div>
);
}
export default PublicDashboardSetting;

View File

@@ -1,79 +0,0 @@
import { Button, Tabs, Tooltip } from 'antd';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { Braces, Globe, Table } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import { VariablesSettingsTabHandle } from '../DashboardDescription/types';
import DashboardVariableSettings from './DashboardVariableSettings';
import GeneralDashboardSettings from './General';
import PublicDashboardSetting from './PublicDashboard';
import './DashboardSettingsContent.styles.scss';
function DashboardSettings({
variablesSettingsTabHandle,
}: {
variablesSettingsTabHandle: VariablesSettingsTabHandle;
}): JSX.Element {
const { user } = useAppContext();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
const publicDashboardItem = {
label: (
<Tooltip
title={
user?.role !== USER_ROLES.ADMIN
? 'Only admins can publish / manage public dashboards'
: ''
}
placement="right"
>
<Button
type="text"
icon={<Globe size={14} />}
className={`public-dashboard-btn ${
user?.role !== USER_ROLES.ADMIN ? 'disabled-btn' : ''
}`}
>
Publish
</Button>
</Tooltip>
),
key: 'public-dashboard',
children: <PublicDashboardSetting />,
disabled: user?.role !== USER_ROLES.ADMIN,
};
const items = [
{
label: (
<Button type="text" icon={<Table size={14} />} className="overview-btn">
Overview
</Button>
),
key: 'general',
children: <GeneralDashboardSettings />,
},
{
label: (
<Button type="text" icon={<Braces size={14} />} className="variables-btn">
Variables
</Button>
),
key: 'variables',
children: (
<DashboardVariableSettings
variablesSettingsTabHandle={variablesSettingsTabHandle}
/>
),
},
...(enablePublicDashboard ? [publicDashboardItem] : []),
];
return <Tabs items={items} animated className="settings-tabs" />;
}
export default DashboardSettings;

View File

@@ -1,69 +0,0 @@
import { memo, useEffect, useMemo } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import { VariableItemProps } from './VariableItem';
import { customVariableSelectStrategy } from './variableSelectStrategy/customVariableSelectStrategy';
type CustomVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'onValueUpdate'
>;
function CustomVariableInput({
variableData,
onValueUpdate,
}: CustomVariableInputProps): JSX.Element {
const optionsData: (string | number | boolean)[] = useMemo(() => {
return sortValues(
commaValuesParser(variableData.customValue || ''),
variableData.sort,
) as (string | number | boolean)[];
}, [variableData.customValue, variableData.sort]);
const {
value,
defaultValue,
enableSelectAll,
onChange,
onDropdownVisibleChange,
handleClear,
applyDefaultIfNeeded,
} = useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy: customVariableSelectStrategy,
});
// Apply default on mount — options are available synchronously for custom variables
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(applyDefaultIfNeeded, []);
const selectOptions = useMemo(
() =>
optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
})),
[optionsData],
);
return (
<SelectVariableInput
variableId={variableData.id}
options={selectOptions}
value={value}
onChange={onChange}
onDropdownVisibleChange={onDropdownVisibleChange}
onClear={handleClear}
enableSelectAll={enableSelectAll}
defaultValue={defaultValue}
isMultiSelect={variableData.multiSelect}
/>
);
}
export default memo(CustomVariableInput);

View File

@@ -1,113 +0,0 @@
.variable-item {
display: flex;
align-items: center;
.variable-name {
display: flex;
min-width: 56px;
height: 32px;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--bg-robin-300);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
.info-icon {
margin-left: 4px;
color: var(--l2-foreground);
}
}
.variable-value {
display: flex;
min-width: 120px;
height: 32px;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
border-left: none;
background: var(--l2-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
&:hover,
&:focus-within {
outline: 1px solid var(--bg-robin-400);
}
}
.variable-select {
.ant-select-selector {
overflow-y: hidden !important;
}
.ant-select-item {
display: flex;
align-items: center;
gap: 8px;
}
.rc-virtual-list-holder {
[data-testid='option-ALL'] {
border-bottom: 1px solid var(--l1-border);
padding-bottom: 12px;
margin-bottom: 8px;
}
}
.all-label {
display: flex;
gap: 16px;
}
.dropdown-checkbox-label {
display: grid;
grid-template-columns: 24px 1fr;
}
.dropdown-value {
display: grid;
grid-template-columns: 1fr max-content;
.option-text {
padding: 0 8px;
}
.toggle-tag-label {
padding-left: 8px;
right: 40px;
font-weight: 500;
}
}
}
}
.dropdown-styles {
min-width: 400px;
max-width: 500px;
}
.cycle-error-alert {
margin-bottom: 12px;
padding: 4px 12px;
font-size: 12px;
}
.dashboard-variables-selection-container {
display: flex;
flex-wrap: wrap;
gap: 12px;
}

View File

@@ -1,181 +0,0 @@
import { memo, useCallback, useEffect, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Row } from 'antd';
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
import { updateLocalStorageDashboardVariable } from 'hooks/dashboard/useDashboardFromLocalStorage';
import {
useDashboardVariables,
useDashboardVariablesSelector,
} from 'hooks/dashboard/useDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import {
enqueueDescendantsOfVariable,
enqueueFetchOfAllVariables,
initializeVariableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { useShallow } from 'zustand/react/shallow';
import VariableItem from './VariableItem';
import './DashboardVariableSelection.styles.scss';
function DashboardVariableSelection(): JSX.Element | null {
const { dashboardId, setDashboardData } = useDashboardStore(
useShallow((s) => ({
dashboardId: s.dashboardData?.id ?? '',
setDashboardData: s.setDashboardData,
})),
);
const { updateUrlVariable } = useVariablesFromUrl();
const { dashboardVariables } = useDashboardVariables();
const sortedVariablesArray = useDashboardVariablesSelector(
(state) => state.sortedVariablesArray,
);
const dynamicVariableOrder = useDashboardVariablesSelector(
(state) => state.dynamicVariableOrder,
);
const dependencyData = useDashboardVariablesSelector(
(state) => state.dependencyData,
);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// Memoize the order key to avoid unnecessary triggers
const variableOrderKey = useMemo(() => {
const queryVariableOrderKey = dependencyData?.order?.join(',') ?? '';
const dynamicVariableOrderKey = dynamicVariableOrder?.join(',') ?? '';
return `${queryVariableOrderKey}|${dynamicVariableOrderKey}`;
}, [dependencyData?.order, dynamicVariableOrder]);
// Initialize fetch store then start a new fetch cycle.
// Runs on dependency order changes, and time range changes.
useEffect(() => {
const allVariableNames = sortedVariablesArray
.map((v) => v.name)
.filter((name): name is string => !!name);
initializeVariableFetchStore(allVariableNames);
enqueueFetchOfAllVariables();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variableOrderKey, minTime, maxTime]);
// Performance optimization: For dynamic variables with allSelected=true, we don't store
// individual values in localStorage since we can always derive them from available options.
// This makes localStorage much lighter and more efficient.
const onValueUpdate = useCallback(
(
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
): void => {
// For dynamic variables, only store in localStorage when NOT allSelected
// This makes localStorage much lighter by avoiding storing all individual values
const variable = dashboardVariables[id] || dashboardVariables[name];
const isDynamic = variable.type === 'DYNAMIC';
updateLocalStorageDashboardVariable(
dashboardId,
name,
value,
allSelected,
isDynamic,
);
if (allSelected) {
updateUrlVariable(name || id, ALL_SELECTED_VALUE);
} else {
updateUrlVariable(name || id, value);
}
// Synchronously update the external store with the new variable value so that
// child variables see the updated parent value when they refetch, rather than
// waiting for setDashboardData → useEffect → updateDashboardVariablesStore.
const updatedVariables = { ...dashboardVariables };
if (updatedVariables[id]) {
updatedVariables[id] = {
...updatedVariables[id],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
if (updatedVariables[name]) {
updatedVariables[name] = {
...updatedVariables[name],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
updateDashboardVariablesStore({ dashboardId, variables: updatedVariables });
setDashboardData((prev) => {
if (prev) {
const oldVariables = { ...prev?.data.variables };
// this is added to handle case where we have two different
// schemas for variable response
if (oldVariables?.[id]) {
oldVariables[id] = {
...oldVariables[id],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
if (oldVariables?.[name]) {
oldVariables[name] = {
...oldVariables[name],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
return {
...prev,
data: {
...prev?.data,
variables: {
...oldVariables,
},
},
};
}
return prev;
});
// Cascade: enqueue query-type descendants for refetching.
// Safe to call synchronously now that the store already has the updated value.
enqueueDescendantsOfVariable(name);
},
[dashboardId, dashboardVariables, updateUrlVariable, setDashboardData],
);
return (
<Row className="dashboard-variables-selection-container">
{sortedVariablesArray.map((variable) => {
const key = `${variable.name}${variable.id}${variable.order}`;
return (
<VariableItem
key={key}
existingVariables={dashboardVariables}
variableData={variable}
onValueUpdate={onValueUpdate}
/>
);
})}
</Row>
);
}
export default memo(DashboardVariableSelection);

View File

@@ -1,327 +0,0 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
import useDebounce from 'hooks/useDebounce';
import { AppState } from 'store/reducers';
import { SuccessResponseV2 } from 'types/api';
import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
import { GlobalReducer } from 'types/reducer/globalTime';
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from '../../../constants/queryCacheTime';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import {
buildExistingDynamicVariableQuery,
extractErrorMessage,
getOptionsForDynamicVariable,
mergeUniqueStrings,
settleVariableFetch,
} from './util';
import { VariableItemProps } from './VariableItem';
import { dynamicVariableSelectStrategy } from './variableSelectStrategy/dynamicVariableSelectStrategy';
import './DashboardVariableSelection.styles.scss';
type DynamicVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'onValueUpdate' | 'existingVariables'
>;
function DynamicVariableInput({
variableData,
onValueUpdate,
existingVariables,
}: DynamicVariableInputProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const [isRetryableError, setIsRetryableError] = useState<boolean>(true);
const [isComplete, setIsComplete] = useState<boolean>(false);
const [filteredOptionsData, setFilteredOptionsData] = useState<
(string | number | boolean)[]
>([]);
const [relatedValues, setRelatedValues] = useState<string[]>([]);
const [originalRelatedValues, setOriginalRelatedValues] = useState<string[]>(
[],
);
// Track dropdown open state for auto-checking new values
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
const [apiSearchText, setApiSearchText] = useState<string>('');
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
const allAvailableOptionStrings = useMemo(
() => mergeUniqueStrings(optionsData, relatedValues),
[optionsData, relatedValues],
);
const {
value,
tempSelection,
setTempSelection,
handleClear,
enableSelectAll,
defaultValue,
applyDefaultIfNeeded,
onChange,
onDropdownVisibleChange,
} = useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy: dynamicVariableSelectStrategy,
allAvailableOptionStrings,
});
// Create a dependency key from all dynamic variables
const dynamicVariablesKey = useMemo(() => {
if (!existingVariables) {
return 'no_variables';
}
const dynamicVars = Object.values(existingVariables)
.filter((v) => v.type === 'DYNAMIC')
.map(
(v) => `${v.name || 'unnamed'}:${JSON.stringify(v.selectedValue || null)}`,
)
.join('|');
return dynamicVars || 'no_dynamic_variables';
}, [existingVariables]);
const { maxTime, minTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const {
variableFetchCycleId,
isVariableSettled,
isVariableFetching,
hasVariableFetchedOnce,
isVariableWaitingForDependencies,
variableDependencyWaitMessage,
} = useVariableFetchState(variableData.name || '');
const existingQuery = useMemo(
() =>
buildExistingDynamicVariableQuery(
existingVariables,
variableData.id,
!!variableData.dynamicVariablesAttribute,
),
[existingVariables, variableData.id, variableData.dynamicVariablesAttribute],
);
// Wrap the hook's onDropdownVisibleChange to also track isDropdownOpen and handle cleanup
const handleSelectDropdownVisibilityChange = useCallback(
(visible: boolean): void => {
setIsDropdownOpen(visible);
onDropdownVisibleChange(visible);
if (!visible) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
setApiSearchText('');
}
},
[onDropdownVisibleChange, optionsData, originalRelatedValues],
);
const handleQuerySuccess = useCallback(
(data: SuccessResponseV2<FieldValueResponse>): void => {
const newNormalizedValues = data.data?.normalizedValues || [];
const newRelatedValues = data.data?.relatedValues || [];
if (!debouncedApiSearchText) {
setOptionsData(newNormalizedValues);
setIsComplete(data.data?.complete || false);
}
setFilteredOptionsData(newNormalizedValues);
setRelatedValues(newRelatedValues);
setOriginalRelatedValues(newRelatedValues);
// Sync temp selection with latest API values when ALL is active and dropdown is open
if (variableData.allSelected && isDropdownOpen) {
const latestValues = mergeUniqueStrings(
newNormalizedValues,
newRelatedValues,
);
const currentStrings = Array.isArray(tempSelection)
? tempSelection.map((v) => v.toString())
: tempSelection
? [tempSelection.toString()]
: [];
const areSame =
currentStrings.length === latestValues.length &&
latestValues.every((v) => currentStrings.includes(v));
if (!areSame) {
setTempSelection(latestValues);
}
}
// Apply default if no value is selected (e.g., new variable, first load)
if (!debouncedApiSearchText) {
applyDefaultIfNeeded(
mergeUniqueStrings(newNormalizedValues, newRelatedValues),
);
}
settleVariableFetch(variableData.name, 'complete');
},
[
debouncedApiSearchText,
variableData.allSelected,
variableData.name,
isDropdownOpen,
tempSelection,
setTempSelection,
applyDefaultIfNeeded,
],
);
const handleQueryError = useCallback(
(error: { message?: string } | null): void => {
if (error) {
setErrorMessage(extractErrorMessage(error));
setIsRetryableError(checkIfRetryableError(error));
}
settleVariableFetch(variableData.name, 'failure');
},
[variableData.name],
);
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || `variable_${variableData.id}`,
dynamicVariablesKey,
minTime,
maxTime,
debouncedApiSearchText,
variableData.dynamicVariablesSource,
variableData.dynamicVariablesAttribute,
variableFetchCycleId,
],
{
/*
* enabled if
* - we have dynamic variable source and attribute defined (ALWAYS)
* - AND
* - we're either still fetching variable options
* - OR
* - if variable is in idle state and we have already fetched options for it
**/
enabled:
!!variableData.dynamicVariablesSource &&
!!variableData.dynamicVariablesAttribute &&
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
queryFn: ({ signal }) =>
getFieldValues(
variableData.dynamicVariablesSource?.toLowerCase() === 'all telemetry'
? undefined
: (variableData.dynamicVariablesSource?.toLowerCase() as
| 'traces'
| 'logs'
| 'metrics'),
variableData.dynamicVariablesAttribute,
debouncedApiSearchText,
minTime,
maxTime,
existingQuery,
signal,
),
onSuccess: handleQuerySuccess,
onError: handleQueryError,
},
);
const handleRetry = useCallback((): void => {
setErrorMessage(null);
setIsRetryableError(true);
refetch();
}, [refetch]);
const handleSearch = useCallback(
(text: string) => {
if (isComplete) {
if (!text) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
return;
}
const lowerText = text.toLowerCase();
setFilteredOptionsData(
optionsData.filter((option) =>
option.toString().toLowerCase().includes(lowerText),
),
);
setRelatedValues(
originalRelatedValues.filter((val) =>
val.toLowerCase().includes(lowerText),
),
);
} else {
setApiSearchText(text);
}
},
[isComplete, optionsData, originalRelatedValues],
);
const selectOptions = useMemo(
() =>
getOptionsForDynamicVariable(filteredOptionsData || [], relatedValues || []),
[filteredOptionsData, relatedValues],
);
return (
<SelectVariableInput
variableId={variableData.id}
options={selectOptions}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleSelectDropdownVisibilityChange}
onClear={handleClear}
enableSelectAll={enableSelectAll}
defaultValue={defaultValue}
isMultiSelect={variableData.multiSelect}
// dynamic variable specific + API related props
loading={isLoading}
errorMessage={errorMessage}
onRetry={handleRetry}
isDynamicVariable
showRetryButton={isRetryableError}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
onSearch={handleSearch}
waiting={isVariableWaitingForDependencies}
waitingMessage={variableDependencyWaitMessage}
/>
);
}
export default memo(DynamicVariableInput);

View File

@@ -1,267 +0,0 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import { isArray, isEmpty } from 'lodash-es';
import { AppState } from 'store/reducers';
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from '../../../constants/queryCacheTime';
import { variablePropsToPayloadVariables } from '../utils';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import { areArraysEqual, settleVariableFetch } from './util';
import { VariableItemProps } from './VariableItem';
import { queryVariableSelectStrategy } from './variableSelectStrategy/queryVariableSelectStrategy';
type QueryVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'existingVariables' | 'onValueUpdate'
>;
function QueryVariableInput({
variableData,
existingVariables,
onValueUpdate,
}: QueryVariableInputProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const { maxTime, minTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const {
variableFetchCycleId,
isVariableSettled,
isVariableFetching,
hasVariableFetchedOnce,
isVariableWaitingForDependencies,
variableDependencyWaitMessage,
} = useVariableFetchState(variableData.name || '');
const {
tempSelection,
setTempSelection,
value,
defaultValue,
enableSelectAll,
onChange,
onDropdownVisibleChange,
handleClear,
getDefaultValue,
} = useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy: queryVariableSelectStrategy,
});
const getOptions = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
(variablesRes: VariableResponseProps | null): void => {
try {
setErrorMessage(null);
// This is just a check given the previously undefined typed name prop. Not significant
// This will be changed when we change the schema
// TODO: @AshwinBhatkal Perses
if (!variableData.name) {
return;
}
// if the response is not an array, premature return
if (
!variablesRes?.variableValues ||
!Array.isArray(variablesRes?.variableValues)
) {
return;
}
const sortedNewOptions = sortValues(
variablesRes.variableValues,
variableData.sort,
);
const sortedOldOptions = sortValues(optionsData, variableData.sort);
// if options are the same as before, no need to update state or check for selected value validity
// ! selectedValue needs to be set in the first pass though, as options are initially empty array and we need to apply default if needed
// Expecatation is that when oldOptions are not empty, then there is always some selectedValue
if (areArraysEqual(sortedNewOptions, sortedOldOptions)) {
return;
}
setOptionsData(sortedNewOptions);
let isSelectedValueMissingInNewOptions = false;
// Check if currently selected value(s) are present in the new options list
if (isArray(variableData.selectedValue)) {
isSelectedValueMissingInNewOptions = variableData.selectedValue.some(
(val) => !sortedNewOptions.includes(val),
);
} else if (
variableData.selectedValue &&
!sortedNewOptions.includes(variableData.selectedValue)
) {
isSelectedValueMissingInNewOptions = true;
}
// If multi-select with ALL option enabled, and ALL is currently selected, we want to maintain that state and select all new options
// This block does not depend on selected value because of ALL and also because we would only come here if options are different from the previous
if (
variableData.multiSelect &&
variableData.showALLOption &&
variableData.allSelected &&
isSelectedValueMissingInNewOptions
) {
onValueUpdate(variableData.name, variableData.id, sortedNewOptions, true);
// Update tempSelection to maintain ALL state when dropdown is open
if (tempSelection !== undefined) {
setTempSelection(sortedNewOptions.map((option) => option.toString()));
}
return;
}
const value = variableData.selectedValue;
let allSelected = false;
if (variableData.multiSelect) {
const { selectedValue } = variableData;
allSelected =
sortedNewOptions.length > 0 &&
Array.isArray(selectedValue) &&
sortedNewOptions.every((option) => selectedValue.includes(option));
}
if (
variableData.name &&
variableData.id &&
!isEmpty(variableData.selectedValue)
) {
onValueUpdate(variableData.name, variableData.id, value, allSelected);
} else {
const defaultValue = getDefaultValue(sortedNewOptions);
if (defaultValue !== undefined) {
onValueUpdate(
variableData.name,
variableData.id,
defaultValue,
allSelected,
);
}
}
} catch (e) {
console.error(e);
}
},
[
variableData,
optionsData,
onValueUpdate,
tempSelection,
setTempSelection,
getDefaultValue,
],
);
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || '',
`${minTime}`,
`${maxTime}`,
variableFetchCycleId,
],
{
/*
* enabled if
* - we're either still fetching variable options
* - OR
* - if variable is in idle state and we have already fetched options for it
**/
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
queryFn: ({ signal }) =>
dashboardVariablesQuery(
{
query: variableData.queryValue || '',
variables: variablePropsToPayloadVariables(existingVariables),
},
signal,
),
refetchOnWindowFocus: false,
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onSuccess: (response) => {
getOptions(response.payload);
settleVariableFetch(variableData.name, 'complete');
},
onError: (error: {
details: {
error: string;
};
}) => {
const { details } = error;
if (details.error) {
let message = details.error;
if ((details.error ?? '').toString().includes('Syntax error:')) {
message =
'Please make sure query is valid and dependent variables are selected';
}
setErrorMessage(message);
}
settleVariableFetch(variableData.name, 'failure');
},
},
);
const handleRetry = useCallback((): void => {
setErrorMessage(null);
refetch();
}, [refetch]);
const selectOptions = useMemo(
() =>
optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
})),
[optionsData],
);
return (
<SelectVariableInput
variableId={variableData.id}
options={selectOptions}
value={value}
onChange={onChange}
onDropdownVisibleChange={onDropdownVisibleChange}
onClear={handleClear}
enableSelectAll={enableSelectAll}
defaultValue={defaultValue}
isMultiSelect={variableData.multiSelect}
// query variable specific, API related props
loading={isLoading}
errorMessage={errorMessage}
onRetry={handleRetry}
waiting={isVariableWaitingForDependencies}
waitingMessage={variableDependencyWaitMessage}
/>
);
}
export default memo(QueryVariableInput);

View File

@@ -1,150 +0,0 @@
import { memo, useMemo } from 'react';
import { orange } from '@ant-design/colors';
import { Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { SolidAlertTriangle } from '@signozhq/icons';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import { OptionData } from 'components/NewSelect/types';
import { popupContainer } from 'utils/selectPopupContainer';
import { ALL_SELECT_VALUE } from '../utils';
import { SelectItemStyle } from './styles';
const errorIconStyle = { margin: '0 0.5rem' };
interface SelectVariableInputProps {
variableId: string;
options: OptionData[];
value: string | string[] | undefined;
enableSelectAll: boolean;
isMultiSelect: boolean;
onChange: (value: string | string[]) => void;
onClear: () => void;
defaultValue?: string | string[];
onDropdownVisibleChange?: (visible: boolean) => void;
loading?: boolean;
errorMessage?: string | null;
onRetry?: () => void;
isDynamicVariable?: boolean;
showRetryButton?: boolean;
showIncompleteDataMessage?: boolean;
onSearch?: (searchTerm: string) => void;
waiting?: boolean;
waitingMessage?: string;
}
const MAX_TAG_DISPLAY_VALUES = 10;
export const renderMaxTagPlaceholder = (
omittedValues: { label?: React.ReactNode; value?: string | number }[],
): JSX.Element => {
const valuesToShow = omittedValues.slice(0, MAX_TAG_DISPLAY_VALUES);
const hasMore = omittedValues.length > MAX_TAG_DISPLAY_VALUES;
const tooltipText =
valuesToShow.map(({ value: v }) => v ?? '').join(', ') +
(hasMore ? ` + ${omittedValues.length - MAX_TAG_DISPLAY_VALUES} more` : '');
return (
<Tooltip title={tooltipText}>
<span>+ {omittedValues.length} </span>
</Tooltip>
);
};
function SelectVariableInput({
variableId,
options,
value,
onChange,
onDropdownVisibleChange,
onClear,
loading,
errorMessage,
onRetry,
enableSelectAll,
isMultiSelect,
defaultValue,
isDynamicVariable,
showRetryButton,
showIncompleteDataMessage,
onSearch,
waitingMessage,
}: SelectVariableInputProps): JSX.Element {
const commonProps = useMemo(
() => ({
// main props
key: variableId,
value,
defaultValue,
// setup props
placeholder: 'Select value',
className: 'variable-select',
popupClassName: 'dropdown-styles',
getPopupContainer: popupContainer,
showSearch: true,
bordered: false,
// changing props
'data-testid': 'variable-select',
onChange,
loading,
waitingMessage,
style: SelectItemStyle,
options,
errorMessage,
onRetry,
// dynamic variable only props
isDynamicVariable,
showRetryButton,
showIncompleteDataMessage,
onSearch,
}),
[
variableId,
defaultValue,
onChange,
loading,
waitingMessage,
options,
value,
errorMessage,
onRetry,
isDynamicVariable,
showRetryButton,
showIncompleteDataMessage,
onSearch,
],
);
return (
<>
{isMultiSelect ? (
<CustomMultiSelect
{...commonProps}
placement="bottomLeft"
maxTagCount={2}
onDropdownVisibleChange={onDropdownVisibleChange}
maxTagPlaceholder={renderMaxTagPlaceholder}
onClear={onClear}
enableAllSelection={enableSelectAll}
maxTagTextLength={30}
allowClear={value !== ALL_SELECT_VALUE}
/>
) : (
<CustomSelect {...commonProps} />
)}
{errorMessage && (
<span style={errorIconStyle}>
<Popover placement="top" content={<Typography>{errorMessage}</Typography>}>
<SolidAlertTriangle style={{ color: orange[5] }} size="md" />
</Popover>
</span>
)}
</>
);
}
export default memo(SelectVariableInput);

View File

@@ -1,85 +0,0 @@
import { memo, useCallback, useRef, useState } from 'react';
import { Input, InputRef } from 'antd';
import { VariableItemProps } from './VariableItem';
type TextboxVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'onValueUpdate'
>;
function TextboxVariableInput({
variableData,
onValueUpdate,
}: TextboxVariableInputProps): JSX.Element {
const handleChange = useCallback(
(inputValue: string | string[]): void => {
if (inputValue === variableData.selectedValue) {
return;
}
if (variableData.name) {
onValueUpdate(variableData.name, variableData.id, inputValue, false);
}
},
[
onValueUpdate,
variableData.id,
variableData.name,
variableData.selectedValue,
],
);
const textboxInputRef = useRef<InputRef>(null);
const [textboxInputValue, setTextboxInputValue] = useState<string>(
(variableData.selectedValue?.toString() ||
variableData.defaultValue?.toString()) ??
'',
);
const handleInputOnChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setTextboxInputValue(event.target.value);
},
[setTextboxInputValue],
);
const handleInputOnBlur = useCallback(
(event: React.FocusEvent<HTMLInputElement>): void => {
const value = event.target.value.trim();
// If empty, reset to default value
if (!value && variableData.defaultValue) {
setTextboxInputValue(variableData.defaultValue.toString());
handleChange(variableData.defaultValue.toString());
} else {
handleChange(value);
}
},
[handleChange, variableData.defaultValue],
);
const handleInputOnKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
textboxInputRef.current?.blur();
}
},
[],
);
return (
<Input
key={variableData.id}
ref={textboxInputRef}
placeholder="Enter value"
data-testid={`variable-textbox-${variableData.id}`}
bordered={false}
value={textboxInputValue}
title={textboxInputValue}
onChange={handleInputOnChange}
onBlur={handleInputOnBlur}
onKeyDown={handleInputOnKeyDown}
/>
);
}
export default memo(TextboxVariableInput);

View File

@@ -1,154 +0,0 @@
import React, { useEffect } from 'react';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import '@testing-library/jest-dom/extend-expect';
import VariableItem from './VariableItem';
const mockVariableData: IDashboardVariable = {
id: 'test_variable',
description: 'Test Variable',
type: 'TEXTBOX',
textboxValue: 'defaultValue',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
name: 'testVariable',
};
// New mock data for a custom variable
const mockCustomVariableData: IDashboardVariable = {
...mockVariableData,
name: 'customVariable',
type: 'CUSTOM',
customValue: 'option1,option2,option3',
};
const mockOnValueUpdate = jest.fn();
describe('VariableItem', () => {
let useEffectSpy: jest.SpyInstance;
beforeEach(() => {
useEffectSpy = jest.spyOn(React, 'useEffect');
});
afterEach(() => {
jest.clearAllMocks();
useEffectSpy.mockRestore();
});
it('renders component with default props', () => {
render(
<MockQueryClientProvider>
<VariableItem
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</MockQueryClientProvider>,
);
expect(screen.getByText('$testVariable')).toBeInTheDocument();
});
it('renders Input when the variable type is TEXTBOX', () => {
render(
<MockQueryClientProvider>
<VariableItem
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</MockQueryClientProvider>,
);
expect(
screen.getByTestId('variable-textbox-test_variable'),
).toBeInTheDocument();
});
it('calls onValueUpdate when Input value changes and blurs', async () => {
render(
<MockQueryClientProvider>
<VariableItem
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</MockQueryClientProvider>,
);
const inputElement = screen.getByTestId('variable-textbox-test_variable');
// Change the value
act(() => {
fireEvent.change(inputElement, { target: { value: 'newValue' } });
});
// Blur the input to trigger the update
act(() => {
fireEvent.blur(inputElement);
});
await waitFor(() => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'testVariable',
'test_variable',
'newValue',
false,
);
});
});
it('renders a Select element when variable type is CUSTOM', () => {
render(
<MockQueryClientProvider>
<VariableItem
variableData={mockCustomVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</MockQueryClientProvider>,
);
expect(screen.getByText('$customVariable')).toBeInTheDocument();
expect(screen.getByTestId('variable-select')).toBeInTheDocument();
});
it('renders a Select element with all selected', async () => {
const customVariableData = {
...mockCustomVariableData,
allSelected: true,
showALLOption: true,
multiSelect: true,
};
render(
<MockQueryClientProvider>
<VariableItem
variableData={customVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</MockQueryClientProvider>,
);
expect(screen.getByText('ALL')).toBeInTheDocument();
});
it('calls useEffect when the component mounts', () => {
render(
<MockQueryClientProvider>
<VariableItem
variableData={mockCustomVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</MockQueryClientProvider>,
);
expect(useEffect).toHaveBeenCalled();
});
});

View File

@@ -1,76 +0,0 @@
import { memo } from 'react';
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { SolidInfoCircle } from '@signozhq/icons';
import CustomVariableInput from './CustomVariableInput';
import DynamicVariableInput from './DynamicVariableInput';
import QueryVariableInput from './QueryVariableInput';
import TextboxVariableInput from './TextboxVariableInput';
import './DashboardVariableSelection.styles.scss';
export interface VariableItemProps {
variableData: IDashboardVariable;
existingVariables: Record<string, IDashboardVariable>;
onValueUpdate: (
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
}
function VariableItem({
variableData,
onValueUpdate,
existingVariables,
}: VariableItemProps): JSX.Element {
const { name, description, type: variableType } = variableData;
return (
<div className="variable-item">
<Typography.Text className="variable-name" truncate={1}>
${name}
{description && (
<Tooltip title={description}>
<SolidInfoCircle className="info-icon" size="md" />
</Tooltip>
)}
</Typography.Text>
<div className="variable-value">
{variableType === 'TEXTBOX' && (
<TextboxVariableInput
variableData={variableData}
onValueUpdate={onValueUpdate}
/>
)}
{variableType === 'CUSTOM' && (
<CustomVariableInput
variableData={variableData}
onValueUpdate={onValueUpdate}
/>
)}
{variableType === 'QUERY' && (
<QueryVariableInput
variableData={variableData}
onValueUpdate={onValueUpdate}
existingVariables={existingVariables}
/>
)}
{variableType === 'DYNAMIC' && (
<DynamicVariableInput
variableData={variableData}
onValueUpdate={onValueUpdate}
existingVariables={existingVariables}
/>
)}
</div>
</div>
);
}
export default memo(VariableItem);

View File

@@ -1,388 +0,0 @@
import { act, render } from '@testing-library/react';
import * as dashboardVariablesStoreModule from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import {
dashboardVariablesStore,
setDashboardVariablesStore,
updateDashboardVariablesStore,
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import {
IDashboardVariables,
IDashboardVariablesStoreState,
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import {
enqueueDescendantsOfVariable,
enqueueFetchOfAllVariables,
initializeVariableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DashboardVariableSelection from '../DashboardVariableSelection';
// Mutable container to capture the onValueUpdate callback from VariableItem
const mockVariableItemCallbacks: {
onValueUpdate?: (
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
} = {};
// Mock providers/Dashboard/Dashboard
const mockSetDashboardData = jest.fn();
const mockUpdateLocalStorageDashboardVariables = jest.fn();
interface MockDashboardStoreState {
dashboardData?: { id: string };
setDashboardData: typeof mockSetDashboardData;
updateLocalStorageDashboardVariables: typeof mockUpdateLocalStorageDashboardVariables;
}
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (
selector?: (s: Record<string, unknown>) => MockDashboardStoreState,
): MockDashboardStoreState => {
const state = {
dashboardData: { id: 'dash-1' },
setDashboardData: mockSetDashboardData,
updateLocalStorageDashboardVariables:
mockUpdateLocalStorageDashboardVariables,
};
return selector ? selector(state) : state;
},
}));
// Mock hooks/dashboard/useVariablesFromUrl
const mockUpdateUrlVariable = jest.fn();
const mockGetUrlVariables = jest.fn().mockReturnValue({});
jest.mock('hooks/dashboard/useVariablesFromUrl', () => ({
__esModule: true,
default: (): Record<string, unknown> => ({
updateUrlVariable: mockUpdateUrlVariable,
getUrlVariables: mockGetUrlVariables,
}),
}));
// Mock variableFetchStore functions
jest.mock('providers/Dashboard/store/variableFetchStore', () => ({
initializeVariableFetchStore: jest.fn(),
enqueueFetchOfAllVariables: jest.fn(),
enqueueDescendantsOfVariable: jest.fn(),
}));
// Mock initializeDefaultVariables
jest.mock('providers/Dashboard/initializeDefaultVariables', () => ({
initializeDefaultVariables: jest.fn(),
}));
// Mock react-redux useSelector for globalTime
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }),
}));
// VariableItem mock captures the onValueUpdate prop for use in onValueUpdate tests
jest.mock('../VariableItem', () => ({
__esModule: true,
default: (props: any): JSX.Element => {
mockVariableItemCallbacks.onValueUpdate = props.onValueUpdate;
return <div data-testid="variable-item" />;
},
}));
function createVariable(
overrides: Partial<IDashboardVariable> = {},
): IDashboardVariable {
return {
id: 'test-id',
name: 'test-var',
description: '',
type: 'QUERY',
sort: 'DISABLED',
showALLOption: false,
multiSelect: false,
order: 0,
...overrides,
};
}
function resetStore(): void {
dashboardVariablesStore.set(() => ({
dashboardId: '',
variables: {},
sortedVariablesArray: [],
dependencyData: null,
variableTypes: {},
dynamicVariableOrder: [],
}));
}
describe('DashboardVariableSelection', () => {
beforeEach(() => {
resetStore();
jest.clearAllMocks();
});
it('should call initializeVariableFetchStore and enqueueFetchOfAllVariables on mount', () => {
const variables: IDashboardVariables = {
env: createVariable({ name: 'env', type: 'QUERY', order: 0 }),
};
setDashboardVariablesStore({ dashboardId: 'dash-1', variables });
render(<DashboardVariableSelection />);
expect(initializeVariableFetchStore).toHaveBeenCalledWith(['env']);
expect(enqueueFetchOfAllVariables).toHaveBeenCalled();
});
it('should re-trigger fetch cycle when dynamicVariableOrder changes', () => {
const variables: IDashboardVariables = {
env: createVariable({ name: 'env', type: 'QUERY', order: 0 }),
};
setDashboardVariablesStore({ dashboardId: 'dash-1', variables });
render(<DashboardVariableSelection />);
// Clear mocks after initial render
(initializeVariableFetchStore as jest.Mock).mockClear();
(enqueueFetchOfAllVariables as jest.Mock).mockClear();
// Add a DYNAMIC variable which changes dynamicVariableOrder
act(() => {
updateDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({ name: 'env', type: 'QUERY', order: 0 }),
dyn1: createVariable({ name: 'dyn1', type: 'DYNAMIC', order: 1 }),
},
});
});
expect(initializeVariableFetchStore).toHaveBeenCalledWith(
expect.arrayContaining(['env', 'dyn1']),
);
expect(enqueueFetchOfAllVariables).toHaveBeenCalled();
});
it('should re-trigger fetch cycle when a dynamic variable is removed', () => {
const variables: IDashboardVariables = {
env: createVariable({ name: 'env', type: 'QUERY', order: 0 }),
dyn1: createVariable({ name: 'dyn1', type: 'DYNAMIC', order: 1 }),
dyn2: createVariable({ name: 'dyn2', type: 'DYNAMIC', order: 2 }),
};
setDashboardVariablesStore({ dashboardId: 'dash-1', variables });
render(<DashboardVariableSelection />);
(initializeVariableFetchStore as jest.Mock).mockClear();
(enqueueFetchOfAllVariables as jest.Mock).mockClear();
// Remove dyn2, changing dynamicVariableOrder from ['dyn1','dyn2'] to ['dyn1']
act(() => {
updateDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({ name: 'env', type: 'QUERY', order: 0 }),
dyn1: createVariable({ name: 'dyn1', type: 'DYNAMIC', order: 1 }),
},
});
});
expect(initializeVariableFetchStore).toHaveBeenCalledWith(['env', 'dyn1']);
expect(enqueueFetchOfAllVariables).toHaveBeenCalled();
});
it('should NOT re-trigger fetch cycle when dynamicVariableOrder stays the same', () => {
const variables: IDashboardVariables = {
env: createVariable({ name: 'env', type: 'QUERY', order: 0 }),
dyn1: createVariable({ name: 'dyn1', type: 'DYNAMIC', order: 1 }),
};
setDashboardVariablesStore({ dashboardId: 'dash-1', variables });
render(<DashboardVariableSelection />);
(initializeVariableFetchStore as jest.Mock).mockClear();
(enqueueFetchOfAllVariables as jest.Mock).mockClear();
// Update a non-dynamic variable's selectedValue — dynamicVariableOrder unchanged
act(() => {
const snapshot = dashboardVariablesStore.getSnapshot();
dashboardVariablesStore.set(
(): IDashboardVariablesStoreState => ({
...snapshot,
variables: {
...snapshot.variables,
env: {
...snapshot.variables.env,
selectedValue: 'production',
},
},
}),
);
});
expect(initializeVariableFetchStore).not.toHaveBeenCalled();
expect(enqueueFetchOfAllVariables).not.toHaveBeenCalled();
});
describe('onValueUpdate', () => {
let updateStoreSpy: jest.SpyInstance;
beforeEach(() => {
resetStore();
jest.clearAllMocks();
// Real implementation pass-through — we just want to observe calls
updateStoreSpy = jest.spyOn(
dashboardVariablesStoreModule,
'updateDashboardVariablesStore',
);
});
afterEach(() => {
updateStoreSpy.mockRestore();
});
it('updates dashboardVariablesStore synchronously before enqueueDescendantsOfVariable', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({ name: 'env', id: 'env-id', order: 0 }),
},
});
render(<DashboardVariableSelection />);
const callOrder: string[] = [];
updateStoreSpy.mockImplementation(() => {
callOrder.push('updateDashboardVariablesStore');
});
(enqueueDescendantsOfVariable as jest.Mock).mockImplementation(() => {
callOrder.push('enqueueDescendantsOfVariable');
});
act(() => {
mockVariableItemCallbacks.onValueUpdate?.(
'env',
'env-id',
'production',
false,
);
});
expect(callOrder).toStrictEqual([
'updateDashboardVariablesStore',
'enqueueDescendantsOfVariable',
]);
});
it('passes updated variable value to dashboardVariablesStore', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({
name: 'env',
id: 'env-id',
order: 0,
selectedValue: 'staging',
}),
},
});
render(<DashboardVariableSelection />);
// Clear spy calls that happened during setup/render
updateStoreSpy.mockClear();
act(() => {
mockVariableItemCallbacks.onValueUpdate?.(
'env',
'env-id',
'production',
false,
);
});
expect(updateStoreSpy).toHaveBeenCalledWith(
expect.objectContaining({
dashboardId: 'dash-1',
variables: expect.objectContaining({
env: expect.objectContaining({
selectedValue: 'production',
allSelected: false,
}),
}),
}),
);
});
it('calls enqueueDescendantsOfVariable synchronously without a timer', () => {
jest.useFakeTimers();
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({ name: 'env', id: 'env-id', order: 0 }),
},
});
render(<DashboardVariableSelection />);
act(() => {
mockVariableItemCallbacks.onValueUpdate?.(
'env',
'env-id',
'production',
false,
);
});
// Must be called immediately — no timer advancement needed
expect(enqueueDescendantsOfVariable).toHaveBeenCalledWith('env');
jest.useRealTimers();
});
it('propagates allSelected and haveCustomValuesSelected to the store', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({
name: 'env',
id: 'env-id',
order: 0,
multiSelect: true,
showALLOption: true,
}),
},
});
render(<DashboardVariableSelection />);
updateStoreSpy.mockClear();
act(() => {
mockVariableItemCallbacks.onValueUpdate?.(
'env',
'env-id',
['production', 'staging'],
true,
false,
);
});
expect(updateStoreSpy).toHaveBeenCalledWith(
expect.objectContaining({
variables: expect.objectContaining({
env: expect.objectContaining({
selectedValue: ['production', 'staging'],
allSelected: true,
haveCustomValuesSelected: false,
}),
}),
}),
);
});
});
});

View File

@@ -1,266 +0,0 @@
import * as ReactQuery from 'react-query';
// eslint-disable-next-line no-restricted-imports
import * as ReactRedux from 'react-redux';
import { fireEvent, render, screen } from '@testing-library/react';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DynamicVariableInput from '../DynamicVariableInput';
// Mock useVariableFetchState to return "fetching" state so useQuery is enabled
jest.mock('hooks/dashboard/useVariableFetchState', () => ({
useVariableFetchState: (): Record<string, unknown> => ({
variableFetchCycleId: 0,
variableFetchState: 'loading',
isVariableSettled: false,
isVariableFetching: true,
hasVariableFetchedOnce: false,
isVariableWaitingForDependencies: false,
variableDependencyWaitMessage: '',
}),
}));
// Don't mock the components - use real ones
// Mock for useQuery
const mockQueryResult = {
data: undefined,
error: null,
isError: false,
isIdle: false,
isLoading: false,
isPreviousData: false,
isSuccess: true,
status: 'success',
isFetched: true,
isFetchingNextPage: false,
isFetchingPreviousPage: false,
isPlaceholderData: false,
isPaused: false,
isRefetchError: false,
isRefetching: false,
isStale: false,
isLoadingError: false,
isFetching: false,
isFetchedAfterMount: true,
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 0,
refetch: jest.fn(),
remove: jest.fn(),
fetchNextPage: jest.fn(),
fetchPreviousPage: jest.fn(),
hasNextPage: false,
hasPreviousPage: false,
} as any;
// Sample data for testing
const mockApiResponse = {
data: {
normalizedValues: ['frontend', 'backend', 'database'],
complete: true,
},
httpStatusCode: 200,
};
// Mock scrollIntoView since it's not available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
describe('DynamicVariableInput Component', () => {
const mockOnValueUpdate = jest.fn();
const mockDynamicVariableData: IDashboardVariable = {
id: 'var1',
name: 'service',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
selectedValue: 'frontend',
multiSelect: false,
showALLOption: false,
allSelected: false,
description: '',
sort: 'DISABLED',
};
const mockMultiSelectDynamicVariableData: IDashboardVariable = {
...mockDynamicVariableData,
id: 'var2',
name: 'services',
multiSelect: true,
selectedValue: ['frontend', 'backend'],
showALLOption: true,
};
const mockExistingVariables: Record<string, IDashboardVariable> = {
var1: mockDynamicVariableData,
var2: mockMultiSelectDynamicVariableData,
};
beforeEach(() => {
jest.clearAllMocks();
mockOnValueUpdate.mockClear();
// Mock useSelector
const useSelectorSpy = jest.spyOn(ReactRedux, 'useSelector');
useSelectorSpy.mockReturnValue({
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-02T00:00:00Z',
});
// Mock useQuery with success state
const useQuerySpy = jest.spyOn(ReactQuery, 'useQuery');
useQuerySpy.mockReturnValue({
...mockQueryResult,
data: mockApiResponse,
isLoading: false,
error: null,
});
});
it('renders with single select variable correctly', () => {
render(
<DynamicVariableInput
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify the selected value is displayed
const selectedItem = screen.getByRole('combobox');
expect(selectedItem).toBeInTheDocument();
// CustomSelect doesn't use the 'mode' attribute for single select
expect(selectedItem).not.toHaveAttribute('mode');
});
it('renders with multi select variable correctly', () => {
// First set up allSelected to true to properly test the ALL display
const multiSelectWithAllSelected = {
...mockMultiSelectDynamicVariableData,
allSelected: true,
};
render(
<DynamicVariableInput
variableData={multiSelectWithAllSelected}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// In ALL selected mode, there should be an "ALL" text element
expect(screen.getByText('ALL')).toBeInTheDocument();
});
it('shows loading state correctly', () => {
// Mock loading state
jest.spyOn(ReactQuery, 'useQuery').mockReturnValue({
...mockQueryResult,
data: null,
isLoading: true,
isFetching: true,
isSuccess: false,
status: 'loading',
});
render(
<DynamicVariableInput
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Open dropdown to see loading text
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// The loading text should appear in the dropdown
expect(screen.getByText('Refreshing values...')).toBeInTheDocument();
});
it('handles error state correctly', () => {
const errorMessage = 'Failed to fetch data';
// Mock error state
jest.spyOn(ReactQuery, 'useQuery').mockReturnValue({
...mockQueryResult,
data: null,
isLoading: false,
isSuccess: false,
isError: true,
status: 'error',
error: { message: errorMessage },
});
render(
<DynamicVariableInput
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// For error states, we should check that error handling is in place
// Without opening the dropdown as the error message might be handled differently
expect(ReactQuery.useQuery).toHaveBeenCalled();
// We don't need to check refetch as it might be called during component initialization
});
it('makes API call to fetch variable values', () => {
render(
<DynamicVariableInput
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify the useQuery hook was called with expected parameters
expect(ReactQuery.useQuery).toHaveBeenCalledWith(
[
'DASHBOARD_BY_ID',
mockDynamicVariableData.name,
'service:"frontend"|services:["frontend","backend"]', // The actual dynamicVariablesKey
'2023-01-01T00:00:00Z', // minTime from useSelector mock
'2023-01-02T00:00:00Z', // maxTime from useSelector mock
'',
'Traces',
'service.name',
0, // variableFetchCycleId
],
expect.objectContaining({
enabled: true, // isVariableFetching is true from mock
queryFn: expect.any(Function),
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
});
it('has the correct selected value', () => {
// Use a different variable configuration to test different behavior
const customVariable = {
...mockDynamicVariableData,
id: 'custom1',
name: 'customService',
selectedValue: 'backend',
};
render(
<DynamicVariableInput
variableData={customVariable}
existingVariables={{ ...mockExistingVariables, custom1: customVariable }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Find the selection item in the component using data-testid
const selectElement = screen.getByTestId('variable-select');
expect(selectElement).toBeInTheDocument();
// Check that the selected value is displayed in the select element
expect(selectElement).toHaveTextContent('backend');
});
});

View File

@@ -1,274 +0,0 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, render, waitFor } from '@testing-library/react';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { variableFetchStore } from 'providers/Dashboard/store/variableFetchStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import QueryVariableInput from '../QueryVariableInput';
jest.mock('api/dashboard/variables/dashboardVariablesQuery');
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }),
}));
function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false, refetchOnWindowFocus: false },
},
});
}
function Wrapper({
children,
queryClient,
}: {
children: React.ReactNode;
queryClient: QueryClient;
}): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
function createVariable(
overrides: Partial<IDashboardVariable> = {},
): IDashboardVariable {
return {
id: 'env-id',
name: 'env',
description: '',
type: 'QUERY',
sort: 'DISABLED',
showALLOption: false,
multiSelect: false,
order: 0,
queryValue: 'SELECT env FROM table',
...overrides,
};
}
/** Put the named variable into 'loading' state so useQuery fires on mount */
function setVariableLoading(name: string): void {
variableFetchStore.update((draft) => {
draft.states[name] = 'loading';
draft.cycleIds[name] = (draft.cycleIds[name] || 0) + 1;
});
}
function resetFetchStore(): void {
variableFetchStore.set(() => ({
states: {},
lastUpdated: {},
cycleIds: {},
}));
}
describe('QueryVariableInput - getOptions logic', () => {
const mockOnValueUpdate = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
resetFetchStore();
});
afterEach(() => {
resetFetchStore();
});
it('applies default value (first option) when selectedValue is empty on first load', async () => {
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
statusCode: 200,
payload: { variableValues: ['production', 'staging', 'dev'] },
});
const variable = createVariable({ selectedValue: undefined });
setVariableLoading('env');
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
await waitFor(() => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'env',
'env-id',
'production', // first option by default
false,
);
});
});
it('keeps existing selectedValue when it is present in new options', async () => {
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
statusCode: 200,
payload: { variableValues: ['production', 'staging'] },
});
const variable = createVariable({ selectedValue: 'staging' });
setVariableLoading('env');
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
await waitFor(() => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'env',
'env-id',
'staging',
false,
);
});
});
it('selects all new options when allSelected=true and value is missing from new options', async () => {
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
statusCode: 200,
payload: { variableValues: ['production', 'staging'] },
});
const variable = createVariable({
selectedValue: ['old-env'],
allSelected: true,
multiSelect: true,
showALLOption: true,
});
setVariableLoading('env');
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
await waitFor(() => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'env',
'env-id',
['production', 'staging'],
true,
);
});
});
it('does not call onValueUpdate a second time when options have not changed', async () => {
const mockQueryFn = jest.fn().mockResolvedValue({
statusCode: 200,
payload: { variableValues: ['production', 'staging'] },
});
(dashboardVariablesQuery as jest.Mock).mockImplementation(mockQueryFn);
const variable = createVariable({ selectedValue: 'production' });
setVariableLoading('env');
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
// Wait for first fetch and onValueUpdate call
await waitFor(() => {
expect(mockOnValueUpdate).toHaveBeenCalledTimes(1);
});
mockOnValueUpdate.mockClear();
// Trigger a second fetch cycle with the same API response
act(() => {
variableFetchStore.update((draft) => {
draft.states['env'] = 'revalidating';
draft.cycleIds['env'] = (draft.cycleIds['env'] || 0) + 1;
});
});
// Wait for second query to fire
await waitFor(() => {
expect(mockQueryFn).toHaveBeenCalledTimes(2);
});
// Options are unchanged, so onValueUpdate must not fire again
expect(mockOnValueUpdate).not.toHaveBeenCalled();
});
it('does not call onValueUpdate when API returns a non-array response', async () => {
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
statusCode: 200,
payload: { variableValues: null },
});
const variable = createVariable({ selectedValue: 'production' });
setVariableLoading('env');
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
await waitFor(() => {
expect(dashboardVariablesQuery).toHaveBeenCalled();
});
expect(mockOnValueUpdate).not.toHaveBeenCalled();
});
it('does not fire the query when variableData.name is empty', () => {
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
statusCode: 200,
payload: { variableValues: ['production'] },
});
// Variable with no name — useVariableFetchState will be called with ''
// and the query key will have an empty name, leaving it disabled
const variable = createVariable({ name: '' });
// Note: we do NOT put it in 'loading' state since name is empty
// (no variableFetchStore entry for '' means isVariableFetching=false)
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
expect(dashboardVariablesQuery).not.toHaveBeenCalled();
expect(mockOnValueUpdate).not.toHaveBeenCalled();
});
});

View File

@@ -1,129 +0,0 @@
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { render, screen, waitFor } from 'tests/test-utils';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import '@testing-library/jest-dom/extend-expect';
import VariableItem from '../VariableItem';
const mockOnValueUpdate = jest.fn();
const TEST_VARIABLE_ID = 'test_variable';
const VARIABLE_SELECT_TESTID = 'variable-select';
const TEST_VARIABLE_NAME = 'testVariable';
const TEST_VARIABLE_DESCRIPTION = 'Test Variable';
const renderVariableItem = (
variableData: IDashboardVariable,
): ReturnType<typeof render> =>
render(
<MockQueryClientProvider>
<VariableItem
variableData={variableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</MockQueryClientProvider>,
);
describe('VariableItem Default Value Selection Behavior', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Single Select Variables', () => {
it('should keep previous selection value', async () => {
const variable: IDashboardVariable = {
id: TEST_VARIABLE_ID,
name: TEST_VARIABLE_NAME,
description: TEST_VARIABLE_DESCRIPTION,
type: 'CUSTOM',
customValue: 'option1,option2,option3',
selectedValue: 'option1',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
};
renderVariableItem(variable);
await waitFor(() => {
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
});
await expect(screen.findByText('option1')).resolves.toBeInTheDocument();
});
it('should auto-select first option when no previous and no default', async () => {
const variable: IDashboardVariable = {
id: TEST_VARIABLE_ID,
name: TEST_VARIABLE_NAME,
description: TEST_VARIABLE_DESCRIPTION,
type: 'CUSTOM',
customValue: 'option1,option2,option3',
selectedValue: undefined,
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
};
renderVariableItem(variable);
await waitFor(() => {
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
});
// With the new variable select strategy, the first option is auto-selected
await expect(screen.findByText('option1')).resolves.toBeInTheDocument();
});
});
describe('Multi Select Variables with ALL enabled', () => {
it('should show ALL when all options are selected', async () => {
const variable: IDashboardVariable = {
id: TEST_VARIABLE_ID,
name: TEST_VARIABLE_NAME,
description: TEST_VARIABLE_DESCRIPTION,
type: 'CUSTOM',
customValue: 'option1,option2,option3',
selectedValue: ['option1', 'option2', 'option3'],
allSelected: true,
sort: 'DISABLED',
multiSelect: true,
showALLOption: true,
};
renderVariableItem(variable);
await waitFor(() => {
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
});
await expect(screen.findByText('ALL')).resolves.toBeInTheDocument();
});
});
describe('Multi Select Variables with ALL disabled', () => {
it('should show placeholder when no selection', async () => {
const variable: IDashboardVariable = {
id: TEST_VARIABLE_ID,
name: TEST_VARIABLE_NAME,
description: TEST_VARIABLE_DESCRIPTION,
type: 'CUSTOM',
customValue: 'option1,option2,option3',
selectedValue: undefined,
sort: 'DISABLED',
multiSelect: true,
showALLOption: false,
};
renderVariableItem(variable);
await waitFor(() => {
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
});
await expect(screen.findByText('Select value')).resolves.toBeInTheDocument();
});
});
});

View File

@@ -1,3 +0,0 @@
import DashboardVariableSelection from './DashboardVariableSelection';
export default DashboardVariableSelection;

View File

@@ -1,46 +0,0 @@
import { grey } from '@ant-design/colors';
import { Typography } from '@signozhq/ui/typography';
import styled from 'styled-components';
export const VariableContainer = styled.div`
max-width: 100%;
border: 1px solid ${grey[1]}66;
border-radius: 2px;
padding: 0;
padding-left: 0.5rem;
margin-right: 8px;
display: flex;
align-items: center;
margin-bottom: 0.3rem;
gap: 4px;
padding: 4px;
`;
export const VariableName = styled(Typography)`
font-size: 0.8rem;
color: ${grey[0]};
min-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
`;
export const VariableValue = styled(Typography)`
font-size: 0.8rem;
color: ${grey[0]};
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
max-width: 300px;
`;
export const SelectItemStyle = {
minWidth: 120,
fontSize: '0.8rem',
width: '100%',
};

View File

@@ -1,256 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { isEmpty } from 'lodash-es';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { ALL_SELECT_VALUE } from '../utils';
import { areArraysEqual, getSelectValue } from './util';
import { VariableSelectStrategy } from './variableSelectStrategy/variableSelectStrategyTypes';
import {
areArraysEqualIgnoreOrder,
uniqueValues,
} from './variableSelectStrategy/variableSelectStrategyUtils';
interface UseDashboardVariableSelectHelperParams {
variableData: IDashboardVariable;
optionsData: (string | number | boolean)[];
onValueUpdate: (
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
strategy: VariableSelectStrategy;
/** Override for all available option strings (default: optionsData.map(String)) */
allAvailableOptionStrings?: string[];
}
interface UseDashboardVariableSelectHelperReturn {
// State
tempSelection: string | string[] | undefined;
setTempSelection: React.Dispatch<
React.SetStateAction<string | string[] | undefined>
>;
value: string | string[] | undefined;
defaultValue: string | string[] | undefined;
// Derived values
enableSelectAll: boolean;
// Handlers
onChange: (value: string | string[]) => void;
onDropdownVisibleChange: (visible: boolean) => void;
handleClear: () => void;
// Default value helpers
applyDefaultIfNeeded: (
overrideOptions?: (string | number | boolean)[],
) => void;
getDefaultValue: (
overrideOptions?: (string | number | boolean)[],
) => string | string[] | undefined;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export function useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy,
allAvailableOptionStrings,
}: UseDashboardVariableSelectHelperParams): UseDashboardVariableSelectHelperReturn {
const { selectedValue } = variableData;
const [tempSelection, setTempSelection] = useState<
string | string[] | undefined
>(undefined);
const selectedValueStringified = useMemo(
() => getSelectValue(selectedValue, variableData),
[selectedValue, variableData],
);
const enableSelectAll = variableData.multiSelect && variableData.showALLOption;
const effectiveAllAvailableOptionStrings = useMemo(
() => allAvailableOptionStrings ?? optionsData.map((v) => v.toString()),
[allAvailableOptionStrings, optionsData],
);
const selectValue =
variableData.allSelected && enableSelectAll
? ALL_SELECT_VALUE
: selectedValueStringified;
const getDefaultValue = useCallback(
(overrideOptions?: (string | number | boolean)[]) => {
const options = overrideOptions || optionsData;
if (variableData.multiSelect) {
if (variableData.showALLOption) {
return variableData.defaultValue || options.map((o) => o.toString());
}
return variableData.defaultValue || options?.[0]?.toString();
}
return variableData.defaultValue || options[0]?.toString();
},
[
variableData.multiSelect,
variableData.showALLOption,
variableData.defaultValue,
optionsData,
],
);
const defaultValue = useMemo(() => getDefaultValue(), [getDefaultValue]);
const handleChange = useCallback(
(inputValue: string | string[]): void => {
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
if (
value === variableData.selectedValue ||
(Array.isArray(value) &&
Array.isArray(variableData.selectedValue) &&
areArraysEqual(value, variableData.selectedValue))
) {
return;
}
strategy.handleChange({
value,
variableData,
optionsData,
allAvailableOptionStrings: effectiveAllAvailableOptionStrings,
onValueUpdate,
});
},
[
variableData,
optionsData,
effectiveAllAvailableOptionStrings,
onValueUpdate,
strategy,
],
);
const handleTempChange = useCallback(
(inputValue: string | string[]): void => {
// Store the selection in temporary state while dropdown is open
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
setTempSelection(uniqueValues(value));
},
[variableData.multiSelect],
);
// Single select onChange: apply default if value is empty
const handleSingleSelectChange = useCallback(
(inputValue: string | string[]): void => {
if (isEmpty(inputValue)) {
if (defaultValue !== undefined) {
handleChange(defaultValue as string | string[]);
}
return;
}
handleChange(inputValue);
},
[handleChange, defaultValue],
);
// Handle dropdown visibility changes
const onDropdownVisibleChange = useCallback(
(visible: boolean): void => {
// Initialize temp selection when opening dropdown
if (visible) {
if (variableData.allSelected && enableSelectAll) {
// When ALL is selected, show all available options as individually checked
setTempSelection([...effectiveAllAvailableOptionStrings]);
} else {
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
}
}
// Apply changes when closing dropdown
else if (!visible && tempSelection !== undefined) {
// If ALL was selected before AND all options remain selected, skip updating
const wasAllSelected = enableSelectAll && variableData.allSelected;
const isAllSelectedAfter =
enableSelectAll &&
Array.isArray(tempSelection) &&
tempSelection.length === effectiveAllAvailableOptionStrings.length &&
effectiveAllAvailableOptionStrings.every((v) => tempSelection.includes(v));
if (wasAllSelected && isAllSelectedAfter) {
setTempSelection(undefined);
return;
}
// Apply default if closing with empty selection
let valueToApply = tempSelection;
if (isEmpty(tempSelection) && defaultValue !== undefined) {
valueToApply = defaultValue as string | string[];
}
// Order-agnostic change detection
const currentValue = variableData.selectedValue;
const hasChanged =
valueToApply !== currentValue &&
!(
Array.isArray(valueToApply) &&
Array.isArray(currentValue) &&
areArraysEqualIgnoreOrder(valueToApply, currentValue)
);
if (hasChanged) {
handleChange(valueToApply);
}
setTempSelection(undefined);
}
},
[
variableData,
enableSelectAll,
effectiveAllAvailableOptionStrings,
tempSelection,
handleChange,
defaultValue,
],
);
// Explicit function for callers to apply default on mount / data load
// Pass overrideOptions when freshly-loaded options aren't in state yet (async callers)
const applyDefaultIfNeeded = useCallback(
(overrideOptions?: (string | number | boolean)[]): void => {
if (isEmpty(selectValue)) {
const defaultValueFromOptions = getDefaultValue(overrideOptions);
if (defaultValueFromOptions !== undefined) {
handleChange(defaultValueFromOptions as string | string[]);
}
}
},
[selectValue, handleChange, getDefaultValue],
);
const handleClear = useCallback((): void => {
handleChange([]);
}, [handleChange]);
const value = variableData.multiSelect
? tempSelection || selectValue
: selectValue;
const onChange = useMemo(() => {
return variableData.multiSelect ? handleTempChange : handleSingleSelectChange;
}, [variableData.multiSelect, handleTempChange, handleSingleSelectChange]);
return {
tempSelection,
setTempSelection,
enableSelectAll,
onDropdownVisibleChange,
handleClear,
value,
defaultValue,
onChange,
applyDefaultIfNeeded,
getDefaultValue,
};
}

View File

@@ -1,5 +0,0 @@
import { defaultVariableSelectStrategy } from './defaultVariableSelectStrategy';
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
export const customVariableSelectStrategy: VariableSelectStrategy =
defaultVariableSelectStrategy;

View File

@@ -1,20 +0,0 @@
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
export const defaultVariableSelectStrategy: VariableSelectStrategy = {
handleChange({ value, variableData, optionsData, onValueUpdate }) {
if (!variableData.name) {
return;
}
const isAllSelected =
Array.isArray(value) &&
value.length > 0 &&
optionsData.every((option) => value.includes(option.toString()));
if (isAllSelected && variableData.showALLOption) {
onValueUpdate(variableData.name, variableData.id, optionsData, true);
} else {
onValueUpdate(variableData.name, variableData.id, value, false);
}
},
};

View File

@@ -1,37 +0,0 @@
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
export const dynamicVariableSelectStrategy: VariableSelectStrategy = {
handleChange({
value,
variableData,
allAvailableOptionStrings,
onValueUpdate,
}) {
if (!variableData.name) {
return;
}
if (
value === ALL_SELECT_VALUE ||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
) {
onValueUpdate(variableData.name, variableData.id, null, true);
} else {
// For ALL selection in dynamic variables, pass null to avoid storing values
// The parent component will handle this appropriately
const haveCustomValuesSelected =
Array.isArray(value) &&
!value.every((v) => allAvailableOptionStrings.includes(v.toString()));
onValueUpdate(
variableData.name,
variableData.id,
value,
allAvailableOptionStrings.every((v) => value.includes(v.toString())),
haveCustomValuesSelected,
);
}
},
};

View File

@@ -1,5 +0,0 @@
import { defaultVariableSelectStrategy } from './defaultVariableSelectStrategy';
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
export const queryVariableSelectStrategy: VariableSelectStrategy =
defaultVariableSelectStrategy;

View File

@@ -1,11 +0,0 @@
import { VariableItemProps } from '../VariableItem';
export interface VariableSelectStrategy {
handleChange(params: {
value: string | string[];
variableData: VariableItemProps['variableData'];
onValueUpdate: VariableItemProps['onValueUpdate'];
optionsData: (string | number | boolean)[];
allAvailableOptionStrings: string[];
}): void;
}

View File

@@ -1,32 +0,0 @@
import { isEqual } from 'lodash-es';
export const areArraysEqualIgnoreOrder = (
a: (string | number | boolean)[],
b: (string | number | boolean)[],
): boolean => {
if (a.length !== b.length) {
return false;
}
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return isEqual(sortedA, sortedB);
};
export const uniqueValues = (values: string[] | string): string[] | string => {
if (Array.isArray(values)) {
const uniqueValues: string[] = [];
const seenValues = new Set<string>();
values.forEach((value) => {
if (seenValues.has(value)) {
return;
}
seenValues.add(value);
uniqueValues.push(value);
});
return uniqueValues;
}
return values;
};

View File

@@ -1,20 +0,0 @@
import { FullScreenHandle } from 'react-full-screen';
import GridGraphLayout from 'container/GridCardLayout';
import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils';
import { GridComponentSliderContainer } from './styles';
interface GridGraphsProps {
handle: FullScreenHandle;
}
function GridGraphs(props: GridGraphsProps): JSX.Element {
const { handle } = props;
return (
<GridComponentSliderContainer>
<GridGraphLayout handle={handle} enableDrillDown={isDrilldownEnabled()} />
</GridComponentSliderContainer>
);
}
export default GridGraphs;

View File

@@ -1,28 +0,0 @@
import { Card as CardComponent, Row } from 'antd';
import styled from 'styled-components';
export const CardContainer = styled(Row)`
&&& {
margin-top: 1rem;
}
`;
export const Card = styled(CardComponent)`
&&& {
cursor: pointer;
}
.ant-card-body {
display: flex;
width: 100%;
justify-content: center;
align-items: center;
> span {
margin-right: 0.5rem;
}
}
`;
export const GridComponentSliderContainer = styled.div`
margin-top: 1rem;
`;

View File

@@ -1,118 +0,0 @@
.panel-type-selection-modal {
.ant-modal-content {
width: 515px;
max-height: 646px;
overflow-y: auto;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
padding: 0px;
.ant-modal-header {
height: 52px;
padding: 16px;
background: var(--l2-background);
border-bottom: 1px solid var(--l1-border);
margin-bottom: 0px;
.ant-modal-title {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
}
.ant-modal-body {
.panel-selection {
display: flex;
flex-flow: wrap;
gap: 16px;
.selected {
background: var(--l1-border);
}
.ant-card {
display: flex;
height: 80px;
width: 232px;
padding: 19px 0px;
justify-content: center;
align-items: center;
border-radius: 4px;
cursor: pointer;
border: 1px solid var(--l1-border);
.ant-card-body {
padding: 0px;
border-radius: 0px;
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
.ant-typography {
margin-top: 0px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.ant-card-body::before {
content: none;
}
.ant-card-body::after {
content: none;
}
}
.panel-type-text {
text-align: center;
margin-top: 1rem;
}
}
}
.ant-modal-footer {
border-radius: 0px 0px 4px 4px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
padding: 12px 15px;
margin-top: 0px;
.ant-btn {
width: 100%;
display: flex;
align-items: center;
flex-direction: row-reverse;
justify-content: center;
color: var(--l1-foreground);
/* button/ small */
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 200% */
border-radius: 2px;
background: var(--primary-background);
padding: 4px 8px;
gap: 4px;
}
}
&::-webkit-scrollbar {
width: 0.1rem;
}
}
}

View File

@@ -1,72 +0,0 @@
import { memo } from 'react';
import { Card, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES, PANEL_TYPES_INITIAL_QUERY } from 'constants/queryBuilder';
import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import { v4 as uuid } from 'uuid';
import { PanelTypesWithData } from './menuItems';
import './PanelTypeSelectionModal.styles.scss';
function PanelTypeSelectionModal(): JSX.Element {
const { isPanelTypeSelectionModalOpen, setIsPanelTypeSelectionModalOpen } =
usePanelTypeSelectionModalStore();
const onClickHandler = (name: PANEL_TYPES) => (): void => {
const id = uuid();
setIsPanelTypeSelectionModalOpen(false);
logEvent('Dashboard Detail: New panel type selected', {
panelType: name,
widgetId: id,
});
const queryParams = {
graphType: name,
widgetId: id,
[QueryParams.compositeQuery]: JSON.stringify(
PANEL_TYPES_INITIAL_QUERY[name],
),
};
history.push(
`${history.location.pathname}/new?${createQueryParams(queryParams)}`,
);
};
const handleCardClick = (panelType: PANEL_TYPES): void => {
onClickHandler(panelType)();
};
return (
<Modal
open={isPanelTypeSelectionModalOpen}
onCancel={(): void => {
setIsPanelTypeSelectionModalOpen(false);
}}
rootClassName="panel-type-selection-modal"
footer={null}
title="New Panel"
>
<div className="panel-selection">
{PanelTypesWithData.map(({ name, icon, display }) => (
<Card
onClick={(): void => handleCardClick(name)}
id={name}
key={name}
data-testid={`panel-type-${name}`}
>
{icon}
<Typography className="panel-type-text">{display}</Typography>
</Card>
))}
</div>
</Modal>
);
}
export default memo(PanelTypeSelectionModal);

View File

@@ -1,525 +0,0 @@
import React from 'react';
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import * as ReactRedux from 'react-redux';
import {
act,
fireEvent,
render,
RenderResult,
screen,
waitFor,
} from '@testing-library/react';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DynamicVariableInput from '../DashboardVariablesSelection/DynamicVariableInput';
// Mock useVariableFetchState to return "fetching" state so useQuery is enabled
jest.mock('hooks/dashboard/useVariableFetchState', () => ({
useVariableFetchState: (): Record<string, unknown> => ({
variableFetchCycleId: 0,
variableFetchState: 'loading',
isVariableSettled: false,
isVariableFetching: true,
hasVariableFetchedOnce: false,
isVariableWaitingForDependencies: false,
variableDependencyWaitMessage: '',
}),
}));
// Mock the getFieldValues API
jest.mock('api/dynamicVariables/getFieldValues', () => ({
getFieldValues: jest.fn(),
}));
// Mock useQuery from react-query
jest.mock('react-query', () => {
const originalModule = jest.requireActual('react-query');
return {
...originalModule,
useQuery: jest.fn(),
};
});
describe('Dynamic Variable Default Behavior', () => {
const mockOnValueUpdate = jest.fn();
const mockApiResponse = {
httpStatusCode: 200,
data: {
values: {
stringValues: ['frontend', 'backend', 'database', 'cache'],
},
normalizedValues: ['frontend', 'backend', 'database', 'cache'],
complete: true,
},
};
let queryClient: QueryClient;
const renderWithQueryClient = (component: React.ReactElement): RenderResult =>
render(
<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>,
);
beforeEach(() => {
// Mock scrollIntoView for JSDOM environment
window.HTMLElement.prototype.scrollIntoView = jest.fn();
jest.clearAllMocks();
// Create a new QueryClient for each test
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Mock getFieldValues API to return our test data
(getFieldValues as jest.Mock).mockResolvedValue(mockApiResponse);
// Mock useQuery implementation to avoid infinite re-renders
// and ensure onSuccess is called once
(useQuery as jest.Mock).mockImplementation((key, options) => {
const { onSuccess, enabled, queryFn } = options || {};
const variableName = key[1];
const dynamicVarsKey = key[2];
React.useEffect(() => {
if (enabled !== false) {
if (onSuccess) {
// For 'services' tests:
// 1. "Default to ALL" expectations imply empty options -> [] behavior. This happens when selectedValue is undefined (dynamicVarsKey has 'null').
// 2. "ALL Option Special Value" needs full options to render the "ALL" item in dropdown. This happens when selectedValue is defined.
if (
variableName === 'services' &&
typeof dynamicVarsKey === 'string' &&
dynamicVarsKey.includes('null')
) {
onSuccess({
...mockApiResponse,
data: { ...mockApiResponse.data, normalizedValues: [] },
});
} else {
onSuccess(mockApiResponse);
}
}
if (queryFn) {
queryFn({ signal: undefined });
}
}
}, [enabled, variableName, dynamicVarsKey]); // Only depend on enabled/keys
return {
isLoading: false,
isError: false,
data: mockApiResponse,
refetch: jest.fn(),
};
});
jest.spyOn(ReactRedux, 'useSelector').mockReturnValue({
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-02T00:00:00Z',
});
});
describe('Single Select Default Values', () => {
it('should use default value when no previous selection exists', async () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'service',
type: 'DYNAMIC',
multiSelect: false,
defaultValue: 'backend',
selectedValue: undefined,
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
showALLOption: false,
allSelected: false,
};
await act(async () => {
renderWithQueryClient(
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
});
// Should call onValueUpdate with default value
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'service',
'var1',
'backend',
true,
false,
);
});
it('should preserve previous selection over default value', async () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'service',
type: 'DYNAMIC',
multiSelect: false,
defaultValue: 'backend',
selectedValue: 'frontend',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
showALLOption: false,
allSelected: false,
};
await act(async () => {
renderWithQueryClient(
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
});
// Should NOT call onValueUpdate since previous selection exists
expect(mockOnValueUpdate).not.toHaveBeenCalledWith();
// Check if the previous selection 'frontend' is displayed in the UI
// For single select, the value should be visible in the select component
const selectElement = screen.getByRole('combobox');
expect(selectElement).toBeInTheDocument();
// Open dropdown to check if 'frontend' is selected
await act(async () => {
fireEvent.mouseDown(selectElement);
});
// Check if 'frontend' option is present and selected in dropdown
const frontendOption = screen.getByRole('option', { name: 'frontend' });
expect(frontendOption).toHaveClass('selected');
});
it('should use first value when no default and no previous selection', async () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'service',
type: 'DYNAMIC',
multiSelect: false,
defaultValue: undefined,
selectedValue: undefined,
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
showALLOption: false,
allSelected: false,
};
await act(async () => {
renderWithQueryClient(
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
});
// Check if the dropdown is present
const selectElement = screen.getByRole('combobox');
expect(selectElement).toBeInTheDocument();
// Wait for API call to complete and data to be loaded
await waitFor(() => {
expect(getFieldValues).toHaveBeenCalledWith(
'traces',
'service.name',
'',
'2023-01-01T00:00:00Z',
'2023-01-02T00:00:00Z',
'',
undefined, // signal
);
});
// Open dropdown to check available options
await act(async () => {
fireEvent.mouseDown(selectElement);
});
// Wait for dropdown to populate with API data
await waitFor(() => {
expect(
screen.getByRole('option', { name: 'frontend' }),
).toBeInTheDocument();
});
expect(screen.getByRole('option', { name: 'backend' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'database' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'cache' })).toBeInTheDocument();
// Check if the first value 'frontend' is selected (active)
const frontendOption = screen.getByRole('option', { name: 'frontend' });
expect(frontendOption).toHaveClass('active');
});
});
describe('Multi Select Default Values - ALL Enabled', () => {
it('should use default value when no previous selection exists', async () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'services',
type: 'DYNAMIC',
multiSelect: true,
showALLOption: true,
defaultValue: ['backend', 'database'] as unknown as string,
selectedValue: undefined,
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
allSelected: false,
};
await act(async () => {
renderWithQueryClient(
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
});
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'services',
'var1',
['backend', 'database'],
true,
true,
);
});
it('should preserve previous selection over default', async () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'services',
type: 'DYNAMIC',
multiSelect: true,
showALLOption: true,
defaultValue: ['backend'] as unknown as string,
selectedValue: ['frontend', 'cache'],
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
allSelected: false,
};
await act(async () => {
renderWithQueryClient(
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
});
// Should NOT call onValueUpdate since previous selection exists
expect(mockOnValueUpdate).not.toHaveBeenCalledWith();
// The component shows individual selected values ['frontend', 'cache']
// when specific values are selected (not ALL)
expect(screen.getByText('frontend')).toBeInTheDocument();
expect(screen.getByText('cache')).toBeInTheDocument();
// Verify that the multi-select wrapper is present
const multiselectWrapper = screen
.getByTestId('variable-select')
.closest('.custom-multiselect-wrapper');
expect(multiselectWrapper).toBeInTheDocument();
// Verify that 'backend' (default value) is NOT displayed since previous selection takes priority
expect(screen.queryByText('backend')).not.toBeInTheDocument();
});
it('sahould default to ALL when no default and no previous selection', async () => {
const variableData: IDashboardVariable = {
id: 'var21',
name: 'services',
type: 'DYNAMIC',
multiSelect: true,
showALLOption: true,
defaultValue: undefined,
selectedValue: undefined,
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
allSelected: false,
};
await act(async () => {
renderWithQueryClient(
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
});
// Open dropdown to check if ALL option is selected
const selectElement = screen.getByRole('combobox');
await act(async () => {
fireEvent.mouseDown(selectElement);
});
// Check if ALL option is present in dropdown
const allOption = screen.getByText('ALL');
expect(allOption).toBeInTheDocument();
// Verify that the ALL option is available for selection
const allOptionContainer = allOption.closest(
'.option-item, .ant-select-item-option',
);
expect(allOptionContainer).toBeInTheDocument();
// Check if the checkbox exists (it should be unchecked initially)
const checkbox = allOptionContainer?.querySelector(
'[role="checkbox"]',
) as HTMLElement;
expect(checkbox).toBeInTheDocument();
// Should call onValueUpdate with all values (ALL selection)
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'services',
'var21',
[],
true, // allSelected = true
false,
);
});
});
describe('Multi Select Default Values - ALL Disabled', () => {
it('should use default value over first value when provided', async () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'services',
type: 'DYNAMIC',
multiSelect: true,
showALLOption: false,
defaultValue: ['database', 'cache'] as unknown as string,
selectedValue: undefined,
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
allSelected: false,
};
await act(async () => {
renderWithQueryClient(
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
});
// Should call onValueUpdate with default values
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'services',
'var1',
['database', 'cache'],
true,
true,
);
});
});
describe('ALL Option Special Value', () => {
it('should display ALL correctly when all values are selected', async () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'services',
type: 'DYNAMIC',
multiSelect: true,
showALLOption: true,
allSelected: true,
selectedValue: ['frontend', 'backend', 'database', 'cache'],
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
};
await act(async () => {
renderWithQueryClient(
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
});
// Check if ALL is displayed in the UI (in the main selection area)
const allTextElement = screen.getByText('ALL');
expect(allTextElement).toBeInTheDocument();
// Verify that the ALL selection wrapper is present and has correct class
const allSelectedWrapper = allTextElement.closest(
'.custom-multiselect-wrapper',
);
expect(allSelectedWrapper).toHaveClass('all-selected');
// Open dropdown to check if ALL option is selected/active
const selectElement = screen.getByRole('combobox');
await act(async () => {
fireEvent.mouseDown(selectElement);
});
// Wait for API data to be loaded and dropdown to populate
await waitFor(() => {
expect(getFieldValues).toHaveBeenCalledWith(
'traces',
'service.name',
'',
'2023-01-01T00:00:00Z',
'2023-01-02T00:00:00Z',
'',
undefined, // signal
);
});
// Check if ALL option is present in dropdown and selected
// Use getAllByText to get all ALL elements and find the one in dropdown
const allElements = screen.getAllByText('ALL');
expect(allElements.length).toBeGreaterThan(1); // Should have ALL in UI and dropdown
// Find the ALL option in the dropdown (should have the 'all-option' class)
const dropdownAllOption = screen.getByRole('option', { name: 'ALL' });
expect(dropdownAllOption).toBeInTheDocument();
expect(dropdownAllOption).toHaveClass('all-option');
expect(dropdownAllOption).toHaveClass('selected');
// Check if the checkbox for ALL option is checked
const checkbox = dropdownAllOption.querySelector(
'[role="checkbox"]',
) as HTMLElement;
expect(checkbox).toBeInTheDocument();
expect(checkbox).toHaveAttribute('data-state', 'checked');
});
});
});

View File

@@ -1,508 +0,0 @@
import { VirtuosoMockContext } from 'react-virtuoso';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { renderHook } from '@testing-library/react';
import {
Dashboard,
IDashboardVariable,
Widgets,
} from 'types/api/dashboard/getAll';
import { useAddDynamicVariableToPanels } from '../../../hooks/dashboard/useAddDynamicVariableToPanels';
import { WidgetSelector } from '../DashboardSettings/DashboardVariableSettings/VariableItem/WidgetSelector';
// Mock scrollIntoView since it's not available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Constants to avoid duplication
const CPU_USAGE_TEXT = 'CPU Usage';
const MEMORY_USAGE_TEXT = 'Memory Usage';
const ROW_WIDGET_TEXT = 'Row Widget';
// Helper function to create variable config
const createVariableConfig = (
name: string,
attribute: string,
): IDashboardVariable => ({
id: `var_${name}`,
name,
type: 'DYNAMIC',
description: '',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
dynamicVariablesAttribute: attribute,
});
const mockDashboard = {
data: {
widgets: [
{
id: 'widget1',
title: CPU_USAGE_TEXT,
panelTypes: 'GRAPH',
},
{
id: 'widget2',
title: MEMORY_USAGE_TEXT,
panelTypes: 'TABLE',
},
{
id: 'widget3',
title: ROW_WIDGET_TEXT,
panelTypes: 'row', // Should be filtered out (lowercase 'row' to match PANEL_GROUP_TYPES.ROW)
},
],
layout: [{ i: 'widget1' }, { i: 'widget2' }, { i: 'widget3' }],
},
};
// Mock dependencies
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
dashboardData: mockDashboard,
}),
}));
jest.mock('constants/queryBuilder', () => ({
PANEL_GROUP_TYPES: {
ROW: 'row', // Match the actual constant value
},
PANEL_TYPES: {
TIME_SERIES: 'graph',
VALUE: 'value',
TABLE: 'table',
LIST: 'list',
TRACE: 'trace',
BAR: 'bar',
PIE: 'pie',
HISTOGRAM: 'histogram',
EMPTY_WIDGET: 'EMPTY_WIDGET',
},
initialQueriesMap: {
metrics: {
builder: {
queryData: [{}],
},
},
logs: {
builder: {
queryData: [{}],
},
},
traces: {
builder: {
queryData: [{}],
},
},
},
}));
jest.mock('container/GridPanelSwitch/utils', () => ({
generateGridTitle: (title: string): string => title || 'Untitled Panel',
}));
describe('Panel Management Tests', () => {
describe('WidgetSelector Component', () => {
const mockSetSelectedWidgets = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should display panel titles using generateGridTitle', async () => {
await act(async () => {
render(
<VirtuosoMockContext.Provider
value={{ viewportHeight: 300, itemHeight: 100 }}
>
<WidgetSelector
selectedWidgets={[]}
setSelectedWidgets={mockSetSelectedWidgets}
/>
,
</VirtuosoMockContext.Provider>,
);
});
// Open the dropdown to see options
const selectElement = screen.getByRole('combobox');
await act(async () => {
fireEvent.mouseDown(selectElement);
});
// Should show panel titles (excluding ROW widgets) in dropdown
expect(screen.getByText(CPU_USAGE_TEXT)).toBeInTheDocument();
expect(screen.getByText(MEMORY_USAGE_TEXT)).toBeInTheDocument();
expect(screen.queryByText(ROW_WIDGET_TEXT)).not.toBeInTheDocument();
});
it('should filter out row widgets and widgets without layout', () => {
const modifiedDashboard = {
...mockDashboard,
data: {
...mockDashboard.data,
widgets: [
...mockDashboard.data.widgets,
{
id: 'widget4',
title: 'Orphaned Widget',
panelTypes: 'GRAPH',
}, // No layout entry
],
},
};
// Temporarily mock the dashboard
jest.doMock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
dashboardData: modifiedDashboard,
}),
}));
render(
<WidgetSelector
selectedWidgets={[]}
setSelectedWidgets={mockSetSelectedWidgets}
/>,
);
// Should not show orphaned widget
expect(screen.queryByText('Orphaned Widget')).not.toBeInTheDocument();
});
it('should show selected widgets correctly', async () => {
await act(async () => {
render(
<WidgetSelector
selectedWidgets={['widget1', 'widget2']}
setSelectedWidgets={mockSetSelectedWidgets}
/>,
);
});
// Component should show ALL text when all widgets are selected
const allElements = screen.getAllByText('ALL');
expect(allElements.length).toBeGreaterThan(0);
// Check if the dropdown shows selected state correctly
const selectElement = screen.getByRole('combobox');
await act(async () => {
fireEvent.mouseDown(selectElement);
});
// Verify the ALL option in the dropdown is selected/checked
// Find the ALL option by its role instead of text to avoid ambiguity
const allOption = screen.getByRole('option', { name: /ALL/i });
expect(allOption).toBeInTheDocument();
expect(allOption).toHaveClass('selected');
const allCheckbox = allOption.querySelector(
'[role="checkbox"]',
) as HTMLElement;
expect(allCheckbox).toBeInTheDocument();
expect(allCheckbox).toHaveAttribute('data-state', 'checked');
});
});
describe('useAddDynamicVariableToPanels Hook', () => {
it('should add tag filters to specific selected panels', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const dashboard: Dashboard = {
data: {
widgets: [
{
id: 'widget1',
query: {
builder: {
queryData: [
{
filter: { expression: '' },
filters: { items: [] },
},
],
},
},
},
],
},
} as any;
const variableConfig = createVariableConfig('service', 'service.name');
const updatedDashboard = addDynamicVariableToPanels(
dashboard,
variableConfig,
['widget1'],
false,
);
// Verify tag filter was added to the specific widget using new filter expression format
const widget = updatedDashboard?.data?.widgets?.[0] as any;
const queryData = widget?.query?.builder?.queryData?.[0];
// Check that filter expression contains the variable reference
expect(queryData.filter.expression).toContain('service.name in $service');
// Check that filters array also contains the filter item
const filters = queryData.filters.items;
expect(filters).toContainEqual(
expect.objectContaining({
id: expect.any(String), // ID is generated dynamically
key: {
dataType: 'string',
id: 'service.name--string--',
key: 'service.name',
type: '',
},
op: 'IN',
value: '$service',
}),
);
});
it('should apply to all panels when applyToAll is true', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const dashboard: Dashboard = {
data: {
widgets: [
{
id: 'widget1',
query: {
builder: {
queryData: [
{
filter: { expression: '' },
filters: { items: [] },
},
],
},
},
},
{
id: 'widget2',
query: {
builder: {
queryData: [
{
filter: { expression: '' },
filters: { items: [] },
},
],
},
},
},
],
},
} as any;
const variableConfig = createVariableConfig('service', 'service.name');
const updatedDashboard = addDynamicVariableToPanels(
dashboard,
variableConfig,
['widget1'],
true, // Apply to all
);
// Both widgets should have the filter expression
const widget1QueryData = (updatedDashboard?.data?.widgets?.[0] as Widgets)
?.query?.builder?.queryData?.[0];
const widget2QueryData = (updatedDashboard?.data?.widgets?.[1] as Widgets)
?.query?.builder?.queryData?.[0];
// Check filter expressions
expect(widget1QueryData?.filter?.expression).toContain(
'service.name in $service',
);
expect(widget2QueryData?.filter?.expression).toContain(
'service.name in $service',
);
// Check filters arrays
const widget1Filters = widget1QueryData?.filters?.items;
const widget2Filters = widget2QueryData?.filters?.items;
expect(widget1Filters).toContainEqual(
expect.objectContaining({
id: expect.any(String), // ID is generated dynamically
key: {
dataType: 'string',
id: 'service.name--string--',
key: 'service.name',
type: '',
},
op: 'IN',
value: '$service',
}),
);
expect(widget2Filters).toContainEqual(
expect.objectContaining({
id: expect.any(String),
key: {
dataType: 'string',
id: 'service.name--string--',
key: 'service.name',
type: '',
},
op: 'IN',
value: '$service',
}),
);
});
it('should validate tag filter format with variable name', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const dashboard: Dashboard = {
data: {
widgets: [
{
id: 'widget1',
query: {
builder: {
queryData: [
{
filters: { items: [] },
filter: { expression: '' },
},
],
},
},
},
],
},
} as any;
const variableConfig = {
name: 'custom_service_var',
dynamicVariablesAttribute: 'service.name',
};
const updatedDashboard = addDynamicVariableToPanels(
dashboard,
variableConfig as any,
['widget1'],
false,
);
const filters = (updatedDashboard?.data?.widgets?.[0] as Widgets)?.query
?.builder?.queryData?.[0]?.filters?.items;
const filterExpression = (updatedDashboard?.data?.widgets?.[0] as Widgets)
?.query?.builder?.queryData?.[0]?.filter?.expression;
expect(filterExpression).toContain('service.name in $custom_service_var');
expect(filters).toContainEqual(
expect.objectContaining({
value: '$custom_service_var', // Should use exact variable name
}),
);
});
it('should handle empty widget selection gracefully', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const dashboard: Dashboard = {
data: { widgets: [] },
} as any;
const variableConfig = {
name: 'service',
dynamicVariablesAttribute: 'service.name',
};
const updatedDashboard = addDynamicVariableToPanels(
dashboard,
variableConfig as any,
[],
false,
);
// Should return dashboard unchanged
expect(updatedDashboard).toStrictEqual(dashboard);
});
it('should handle undefined dashboard gracefully', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const variableConfig = {
name: 'service',
dynamicVariablesAttribute: 'service.name',
};
const updatedDashboard = addDynamicVariableToPanels(
undefined,
variableConfig as any,
['widget1'],
false,
);
// Should return undefined
expect(updatedDashboard).toBeUndefined();
});
it('should preserve existing filters when adding new variable filter', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const dashboard: Dashboard = {
data: {
widgets: [
{
id: 'widget1',
query: {
builder: {
queryData: [
{
filter: {
expression: "service.name IN ['adservice']",
},
},
],
},
},
},
],
},
} as any;
const variableConfig = {
name: 'host.name',
dynamicVariablesAttribute: 'host.name',
description: '',
type: 'DYNAMIC',
queryValue: '',
customValue: '',
textboxValue: '',
multiSelect: false,
showALLOption: false,
sort: 'DISABLED',
defaultValue: '3',
modificationUUID: 'bd3a85ab-1393-4783-971e-c252adfd4920',
id: '6b6f526a-6404-46fc-8d87-dc53e7ba8e1f',
order: 0,
dynamicVariablesSource: 'Traces',
};
const updatedDashboard = addDynamicVariableToPanels(
dashboard,
variableConfig as any,
['widget1'],
false,
);
const filterExpression = (updatedDashboard?.data?.widgets?.[0] as Widgets)
?.query?.builder?.queryData?.[0]?.filter?.expression;
expect(filterExpression).toContain(
"service.name IN ['adservice'] host.name in $host.name",
);
});
});
});

View File

@@ -1,242 +0,0 @@
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import configureStore from 'redux-mock-store';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import VariableItem from '../DashboardSettings/DashboardVariableSettings/VariableItem/VariableItem';
// Mock dependencies
jest.mock('api/dashboard/variables/dashboardVariablesQuery');
jest.mock('hooks/dynamicVariables/useGetFieldValues', () => ({
useGetFieldValues: (): any => ({
data: {
payload: {
normalizedValues: ['frontend', 'backend', 'database'],
},
},
isLoading: false,
error: null,
}),
}));
jest.mock('components/Editor', () => {
function MockEditor({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}): JSX.Element {
return (
<textarea
data-testid="sql-editor"
value={value}
onChange={(e): void => onChange(e.target.value)}
/>
);
}
MockEditor.displayName = 'MockEditor';
return MockEditor;
});
const mockStore = configureStore([])(() => ({
globalTime: {
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-02T00:00:00Z',
},
}));
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TestWrapper({ children }: { children: React.ReactNode }): JSX.Element {
return (
<Provider store={mockStore}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</Provider>
);
}
TestWrapper.displayName = 'TestWrapper';
describe('VariableItem Component - Creation Flow', () => {
const mockOnSave = jest.fn();
const mockOnCancel = jest.fn();
const mockValidateName = jest.fn();
const mockValidateAttributeKey = jest.fn();
// Constants to avoid string duplication
const VARIABLE_NAME_PLACEHOLDER = 'Unique name of the variable';
const VARIABLE_DESCRIPTION_PLACEHOLDER =
'Enter a description for the variable';
const SAVE_BUTTON_TEXT = 'Save Variable';
const DISCARD_BUTTON_TEXT = 'Discard';
const defaultProps = {
variableData: {} as IDashboardVariable,
existingVariables: {},
onCancel: mockOnCancel,
onSave: mockOnSave,
validateName: mockValidateName,
mode: 'ADD' as const,
validateAttributeKey: mockValidateAttributeKey,
};
beforeEach(() => {
jest.clearAllMocks();
mockValidateName.mockReturnValue(true);
});
describe('Dynamic Variable Creation', () => {
it('should switch between variable types correctly', () => {
render(
<TestWrapper>
<VariableItem {...defaultProps} />
</TestWrapper>,
);
// Test switching to different variable types
const textboxButton = screen.getByText('Textbox');
fireEvent.click(textboxButton);
// Check if the button has the selected class or is in selected state
expect(textboxButton.closest('button')).toHaveClass('selected');
const customButton = screen.getByText('Custom');
fireEvent.click(customButton);
expect(customButton.closest('button')).toHaveClass('selected');
const queryButton = screen.getByText('Query');
fireEvent.click(queryButton);
expect(queryButton.closest('button')).toHaveClass('selected');
// Verify SQL editor appears for QUERY type
expect(screen.getByTestId('sql-editor')).toBeInTheDocument();
});
it('should validate variable name and show errors', () => {
mockValidateName.mockReturnValue(false);
render(
<TestWrapper>
<VariableItem {...defaultProps} />
</TestWrapper>,
);
const nameInput = screen.getByPlaceholderText(VARIABLE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'duplicate_name' } });
// Should show error message
expect(screen.getByText('Variable name already exists')).toBeInTheDocument();
// Save button should be disabled
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
expect(saveButton.closest('button')).toBeDisabled();
});
it('should detect and prevent cyclic dependencies', async () => {
const existingVariables = {
var1: {
id: 'var1',
name: 'var1',
queryValue: 'SELECT * WHERE field = $var2',
type: 'QUERY',
description: '',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
} as IDashboardVariable,
var2: {
id: 'var2',
name: 'var2',
queryValue: 'SELECT * WHERE field = $var1',
type: 'QUERY',
description: '',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
} as IDashboardVariable,
};
render(
<TestWrapper>
<VariableItem {...defaultProps} existingVariables={existingVariables} />
</TestWrapper>,
);
// Fill in name and create a variable that would create cycle
const nameInput = screen.getByPlaceholderText(VARIABLE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'var3' } });
// Switch to QUERY type
const queryButton = screen.getByText('Query');
fireEvent.click(queryButton);
// Add query that creates dependency
const sqlEditor = screen.getByTestId('sql-editor');
fireEvent.change(sqlEditor, {
target: { value: 'SELECT * WHERE field = $var1' },
});
// Try to save
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
fireEvent.click(saveButton);
// Should show cycle detection error
await waitFor(() => {
expect(
screen.getByText(/Circular dependency detected/),
).toBeInTheDocument();
});
});
it('should handle cancel button functionality', () => {
render(
<TestWrapper>
<VariableItem {...defaultProps} />
</TestWrapper>,
);
// Fill in some data
const nameInput = screen.getByPlaceholderText(VARIABLE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'test_variable' } });
// Click cancel
const cancelButton = screen.getByText(DISCARD_BUTTON_TEXT);
fireEvent.click(cancelButton);
// Should call onCancel
expect(mockOnCancel).toHaveBeenCalledWith(expect.any(Object));
});
it('should persist form fields when switching between variable types', () => {
render(
<TestWrapper>
<VariableItem {...defaultProps} />
</TestWrapper>,
);
// Fill in name and description
const nameInput = screen.getByPlaceholderText(VARIABLE_NAME_PLACEHOLDER);
const descriptionInput = screen.getByPlaceholderText(
VARIABLE_DESCRIPTION_PLACEHOLDER,
);
fireEvent.change(nameInput, { target: { value: 'persistent_var' } });
fireEvent.change(descriptionInput, {
target: { value: 'Persistent description' },
});
// Switch to TEXTBOX type
const textboxButton = screen.getByText('Textbox');
fireEvent.click(textboxButton);
// Switch back to DYNAMIC
const dynamicButton = screen.getByText('Dynamic');
fireEvent.click(dynamicButton);
// Name and description should be preserved
expect(nameInput).toHaveValue('persistent_var');
expect(descriptionInput).toHaveValue('Persistent description');
});
});
});

View File

@@ -1,63 +0,0 @@
.dashboard-breadcrumbs {
width: 100%;
height: 48px;
display: flex;
gap: 6px;
align-items: center;
max-width: 80%;
.dashboard-btn {
display: flex;
align-items: center;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding: 0px;
height: 20px;
}
.dashboard-btn:hover {
background-color: unset;
}
.id-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 0px 2px;
border-radius: 2px;
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
height: 20px;
max-width: calc(100% - 120px);
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ant-btn-icon {
margin-inline-end: 4px;
}
}
.id-btn:hover {
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-300);
}
.dashboard-icon-image {
height: 14px;
width: 14px;
}
}

View File

@@ -1,64 +0,0 @@
import { useCallback, useRef } from 'react';
import { Button } from 'antd';
import getSessionStorageApi from 'api/browser/sessionstorage/get';
import ROUTES from 'constants/routes';
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { LayoutGrid } from '@signozhq/icons';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { DashboardData } from 'types/api/dashboard/getAll';
import { Base64Icons } from '../../DashboardSettings/General/utils';
import './DashboardBreadcrumbs.styles.scss';
function DashboardBreadcrumbs(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { dashboardData } = useDashboardStore();
const updatedAtRef = useRef(dashboardData?.updatedAt);
const selectedData = dashboardData
? {
...dashboardData.data,
uuid: dashboardData.id,
}
: ({} as DashboardData);
const { title = '', image = Base64Icons[0] } = selectedData || {};
const goToListPage = useCallback(() => {
const dashboardsListQueryParamsString = getSessionStorageApi(
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
);
const hasDashboardBeenUpdated =
dashboardData?.updatedAt !== updatedAtRef.current;
if (!hasDashboardBeenUpdated && dashboardsListQueryParamsString) {
safeNavigate({
pathname: ROUTES.ALL_DASHBOARD,
search: `?${dashboardsListQueryParamsString}`,
});
} else {
safeNavigate(ROUTES.ALL_DASHBOARD);
}
}, [safeNavigate, dashboardData?.updatedAt]);
return (
<div className="dashboard-breadcrumbs">
<Button
type="text"
icon={<LayoutGrid size={14} />}
className="dashboard-btn"
onClick={goToListPage}
>
Dashboard /
</Button>
<Button type="text" className="id-btn dashboard-name-btn">
<img src={image} alt="dashboard-icon" className="dashboard-icon-image" />
{title}
</Button>
</div>
);
}
export default DashboardBreadcrumbs;

View File

@@ -1,9 +0,0 @@
.dashboard-header {
border-bottom: 1px solid var(--l1-border);
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 0 8px;
box-sizing: border-box;
}

View File

@@ -1,17 +0,0 @@
import { memo } from 'react';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
import './DashboardHeader.styles.scss';
function DashboardHeader(): JSX.Element {
return (
<div className="dashboard-header">
<DashboardBreadcrumbs />
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
</div>
);
}
export default memo(DashboardHeader);

View File

@@ -1,16 +0,0 @@
import { useFullScreenHandle } from 'react-full-screen';
import Description from './DashboardDescription';
import GridGraphs from './GridGraphs';
function DashboardContainer(): JSX.Element {
const handle = useFullScreenHandle();
return (
<div>
<Description handle={handle} />
<GridGraphs handle={handle} />
</div>
);
}
export default DashboardContainer;

View File

@@ -1,18 +0,0 @@
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { PayloadVariables } from 'types/api/dashboard/variables/query';
export function variablePropsToPayloadVariables(
variables: Record<string, IDashboardVariable>,
): PayloadVariables {
const payloadVariables: PayloadVariables = {};
Object.entries(variables).forEach(([, value]) => {
if (value?.name) {
payloadVariables[value.name] = value?.selectedValue;
}
});
return payloadVariables;
}
export const ALL_SELECT_VALUE = '__ALL__';

View File

@@ -1,117 +0,0 @@
import { AlignedData } from 'uplot';
import { getInitialStackedBands, stack } from '../stackUtils';
describe('stackUtils', () => {
describe('stack', () => {
const neverOmit = (): boolean => false;
it('preserves time axis as first row', () => {
const data: AlignedData = [
[100, 200, 300],
[1, 2, 3],
[4, 5, 6],
];
const { data: result } = stack(data, neverOmit);
expect(result[0]).toStrictEqual([100, 200, 300]);
});
it('stacks value series cumulatively (last = raw, first = total)', () => {
// Time, then 3 value series. Stack order: last series stays raw, then we add upward.
const data: AlignedData = [
[0, 1, 2],
[1, 2, 3], // series 1
[4, 5, 6], // series 2
[7, 8, 9], // series 3
];
const { data: result } = stack(data, neverOmit);
// result[1] = s1+s2+s3, result[2] = s2+s3, result[3] = s3
expect(result[1]).toStrictEqual([12, 15, 18]); // 1+4+7, 2+5+8, 3+6+9
expect(result[2]).toStrictEqual([11, 13, 15]); // 4+7, 5+8, 6+9
expect(result[3]).toStrictEqual([7, 8, 9]);
});
it('treats null values as 0 when stacking', () => {
const data: AlignedData = [
[0, 1],
[1, null],
[null, 10],
];
const { data: result } = stack(data, neverOmit);
expect(result[1]).toStrictEqual([1, 10]); // total
expect(result[2]).toStrictEqual([0, 10]); // last series with null→0
});
it('copies omitted series as-is without accumulating', () => {
// Omit series 2 (index 2); series 1 and 3 are stacked.
const data: AlignedData = [
[0, 1],
[10, 20], // series 1
[100, 200], // series 2 - omitted
[1, 2], // series 3
];
const omitSeries2 = (i: number): boolean => i === 2;
const { data: result } = stack(data, omitSeries2);
// series 3 raw: [1, 2]; series 2 omitted: [100, 200] as-is; series 1 stacked with s3: [11, 22]
expect(result[1]).toStrictEqual([11, 22]); // 10+1, 20+2
expect(result[2]).toStrictEqual([100, 200]); // copied, not stacked
expect(result[3]).toStrictEqual([1, 2]);
});
it('returns bands between consecutive visible series when none omitted', () => {
const data: AlignedData = [
[0, 1],
[1, 2],
[3, 4],
[5, 6],
];
const { bands } = stack(data, neverOmit);
expect(bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
});
it('returns bands only between visible series when some are omitted', () => {
// 4 value series; omit index 2. Visible: 1, 3, 4. Bands: [1,3], [3,4]
const data: AlignedData = [[0], [1], [2], [3], [4]];
const omitSeries2 = (i: number): boolean => i === 2;
const { bands } = stack(data, omitSeries2);
expect(bands).toStrictEqual([{ series: [1, 3] }, { series: [3, 4] }]);
});
it('returns empty bands when only one value series', () => {
const data: AlignedData = [
[0, 1],
[1, 2],
];
const { bands } = stack(data, neverOmit);
expect(bands).toStrictEqual([]);
});
});
describe('getInitialStackedBands', () => {
it('returns one band between each consecutive pair for seriesCount 3', () => {
expect(getInitialStackedBands(3)).toStrictEqual([
{ series: [1, 2] },
{ series: [2, 3] },
]);
});
it('returns empty array for seriesCount 0 or 1', () => {
expect(getInitialStackedBands(0)).toStrictEqual([]);
expect(getInitialStackedBands(1)).toStrictEqual([]);
});
it('returns single band for seriesCount 2', () => {
expect(getInitialStackedBands(2)).toStrictEqual([{ series: [1, 2] }]);
});
it('returns bands [1,2], [2,3], ..., [n-1, n] for seriesCount n', () => {
const bands = getInitialStackedBands(5);
expect(bands).toStrictEqual([
{ series: [1, 2] },
{ series: [2, 3] },
{ series: [3, 4] },
{ series: [4, 5] },
]);
});
});
});

View File

@@ -1,116 +0,0 @@
import uPlot, { AlignedData } from 'uplot';
/**
* Stack data cumulatively (top-down: first series = top, last = bottom).
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
*/
export function stack(
data: AlignedData,
omit: (seriesIndex: number) => boolean,
): { data: AlignedData; bands: uPlot.Band[] } {
const timeAxis = data[0];
const pointCount = timeAxis.length;
const valueSeriesCount = data.length - 1; // exclude time axis
const stackedSeries = buildStackedSeries({
data,
valueSeriesCount,
pointCount,
omit,
});
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
return {
data: [timeAxis, ...stackedSeries] as AlignedData,
bands,
};
}
interface BuildStackedSeriesParams {
data: AlignedData;
valueSeriesCount: number;
pointCount: number;
omit: (seriesIndex: number) => boolean;
}
/**
* Accumulate from last series upward: last series = raw values, first = total.
* Omitted series are copied as-is (no accumulation).
*/
function buildStackedSeries({
data,
valueSeriesCount,
pointCount,
omit,
}: BuildStackedSeriesParams): (number | null)[][] {
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
const cumulativeSums = Array(pointCount).fill(0) as number[];
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
const rawValues = data[seriesIndex] as (number | null)[];
if (omit(seriesIndex)) {
stackedSeries[seriesIndex - 1] = rawValues;
} else {
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
const numericValue = rawValue == null ? 0 : Number(rawValue);
return (cumulativeSums[pointIndex] += numericValue);
});
}
}
return stackedSeries;
}
/**
* Bands define fill between consecutive visible series for stacked appearance.
* uPlot format: [upperSeriesIdx, lowerSeriesIdx].
*/
function buildFillBands(
seriesLength: number,
omit: (seriesIndex: number) => boolean,
): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesLength; seriesIndex++) {
if (omit(seriesIndex)) {
continue;
}
const nextVisibleSeriesIndex = findNextVisibleSeriesIndex(
seriesLength,
seriesIndex,
omit,
);
if (nextVisibleSeriesIndex !== -1) {
bands.push({ series: [seriesIndex, nextVisibleSeriesIndex] });
}
}
return bands;
}
function findNextVisibleSeriesIndex(
seriesLength: number,
afterIndex: number,
omit: (seriesIndex: number) => boolean,
): number {
for (let i = afterIndex + 1; i < seriesLength; i++) {
if (!omit(i)) {
return i;
}
}
return -1;
}
/**
* Returns band indices for initial stacked state (no series omitted).
* Top-down: first series at top, band fills between consecutive series.
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
*/
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
bands.push({ series: [seriesIndex, seriesIndex + 1] });
}
return bands;
}

View File

@@ -4,10 +4,7 @@ import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
// Import dependency building functions
import {
buildDependencies,
buildDependencyGraph,
} from '../DashboardVariablesSelection/util';
import { buildDependencies, buildDependencyGraph } from '../util';
// Mock scrollIntoView since it's not available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();

View File

@@ -13,8 +13,7 @@ import {
} from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
import { v4 } from 'uuid';
import { buildExportPanelLink } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
import ExplorerOptionWrapper from '../ExplorerOptionWrapper';
import { getExplorerToolBarVisibility } from '../utils';
@@ -291,7 +290,7 @@ describe('ExplorerOptionWrapper', () => {
});
});
it('should test actual handleExport function with generateExportToDashboardLink and verify useUpdateDashboard is NOT called', async () => {
it('should test actual handleExport function with buildExportPanelLink and verify useUpdateDashboard is NOT called', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// Mock the safeNavigate function
@@ -303,7 +302,6 @@ describe('ExplorerOptionWrapper', () => {
>;
const panelTypeParam = PANEL_TYPES.TIME_SERIES;
const widgetId = v4();
const query = mockQuery;
// Create a real handleExport function similar to LogsExplorerViews
@@ -313,12 +311,11 @@ describe('ExplorerOptionWrapper', () => {
return;
}
// Call the actual generateExportToDashboardLink function (not mocked)
const dashboardEditView = generateExportToDashboardLink({
// Call the actual buildExportPanelLink function (not mocked)
const dashboardEditView = buildExportPanelLink({
query,
panelType: panelTypeParam,
dashboardId: dashboard.id,
widgetId,
});
// Simulate navigation
@@ -380,8 +377,8 @@ describe('ExplorerOptionWrapper', () => {
await waitFor(() => {
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/dashboard/${TEST_DASHBOARD_ID}/new?graphType=${panelTypeParam}&widgetId=${widgetId}&compositeQuery=${encodeURIComponent(
JSON.stringify(query),
`/dashboard/${TEST_DASHBOARD_ID}/panel/new?panelKind=signoz%2FTimeSeriesPanel&compositeQuery=${encodeURIComponent(
encodeURIComponent(JSON.stringify(query)),
)}`,
);
});

View File

@@ -1,8 +1,8 @@
import { useMemo } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import BarChart from 'container/Visualization/charts/BarChart/BarChart';
import TimeSeries from 'container/Visualization/charts/TimeSeries/TimeSeries';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';

View File

@@ -3,8 +3,8 @@ import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Threshold } from 'container/CreateAlertV2/context/types';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { PanelMode } from 'container/Visualization/panels/types';
import { buildBaseConfig } from 'container/Visualization/panels/utils/baseConfigBuilder';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import {
BooleanFormats,

View File

@@ -1,176 +0,0 @@
.dashboard-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 500px;
.dashboard-content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
.heading {
display: flex;
flex-direction: column;
gap: 4px;
.icons {
color: white;
}
.welcome {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px; /* 171.429% */
letter-spacing: -0.07px;
}
.welcome-info {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 24px;
letter-spacing: -0.07px;
}
}
.actions {
display: flex;
flex-direction: column;
.actions-1,
.actions-2 {
display: flex;
width: 560px;
padding: 12px;
justify-content: space-between;
align-items: flex-start;
border-radius: 4px 4px 0px 0px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
.configure-button {
display: flex;
width: 113px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
flex-shrink: 0;
border-radius: 2px;
border: 1px solid var(--secondary-border);
background: var(--secondary-background);
color: var(--secondary-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.actions-configure {
display: flex;
flex-direction: column;
gap: 6px;
.actions-configure-text {
display: flex;
gap: 8px;
align-items: center;
.configure {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.configure-info {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
letter-spacing: -0.06px;
padding-left: 22px;
}
}
.add-panel-btn {
display: flex;
width: 113px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
flex-shrink: 0;
border-radius: 2px;
border: 1px solid var(---border);
background: var(--primary-background);
box-shadow: none;
color: var(--primary-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.actions-add-panel {
display: flex;
flex-direction: column;
gap: 6px;
.actions-panel-text {
display: flex;
gap: 8px;
align-items: center;
.icons {
color: white;
}
.panel {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.panel-info {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
letter-spacing: -0.06px;
padding-left: 22px;
}
}
}
.actions-2 {
border-top: none;
}
}
}
}

View File

@@ -1,158 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
import SettingsDrawer from 'container/DashboardContainer/DashboardDescription/SettingsDrawer';
import { VariablesSettingsTab } from 'container/DashboardContainer/DashboardDescription/types';
import DashboardSettings from 'container/DashboardContainer/DashboardSettings';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import {
selectIsDashboardLocked,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { ROLES, USER_ROLES } from 'types/roles';
import { ComponentTypes } from 'utils/permission';
import dashboardEmojiUrl from '@/assets/Icons/dashboard_emoji.svg';
import landscapeUrl from '@/assets/Icons/landscape.svg';
import toolsUrl from '@/assets/Icons/tools.svg';
import './DashboardEmptyState.styles.scss';
export default function DashboardEmptyState(): JSX.Element {
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const { dashboardData } = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const variablesSettingsTabHandle = useRef<VariablesSettingsTab>(null);
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const { user } = useAppContext();
let permissions: ComponentTypes[] = ['add_panel'];
if (isDashboardLocked) {
permissions = ['add_panel_locked_dashboard'];
}
const userRole: ROLES | null =
dashboardData?.createdBy === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: user.role;
const [addPanelPermission] = useComponentPermission(permissions, userRole);
const onEmptyWidgetHandler = useCallback(() => {
setIsPanelTypeSelectionModalOpen(true);
logEvent('Dashboard Detail: Add new panel clicked', {
dashboardId: dashboardData?.id,
dashboardName: dashboardData?.data.title,
numberOfPanels: dashboardData?.data.widgets?.length,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setIsPanelTypeSelectionModalOpen]);
const onConfigureClick = useCallback((): void => {
setIsSettingsDrawerOpen(true);
}, []);
const onSettingsDrawerClose = useCallback((): void => {
setIsSettingsDrawerOpen(false);
if (variablesSettingsTabHandle.current) {
variablesSettingsTabHandle.current.resetState();
}
}, []);
return (
<section className="dashboard-empty-state">
<div className="dashboard-content">
<section className="heading">
<img
src={dashboardEmojiUrl}
alt="header-image"
style={{ height: '32px', width: '32px' }}
/>
<Typography.Text className="welcome">
Welcome to your new dashboard
</Typography.Text>
<Typography.Text className="welcome-info">
Follow the steps to populate it with data and share with your teammates
</Typography.Text>
</section>
<section className="actions">
<div className="actions-1">
<div className="actions-configure">
<div className="actions-configure-text">
<img
src={toolsUrl}
alt="header-image"
style={{ height: '14px', width: '14px' }}
/>
<Typography.Text className="configure">
Configure your new dashboard
</Typography.Text>
</div>
<Typography.Text className="configure-info">
Give it a name, add description, tags and variables
</Typography.Text>
</div>
{/* This Empty State needs to be consolidated. The SettingsDrawer should be global to the
whole dashboard page instead of confined to this Empty State */}
<Button
type="text"
className="configure-button"
icon={<ConfigureIcon />}
data-testid="show-drawer"
onClick={onConfigureClick}
>
Configure
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={onSettingsDrawerClose}
>
<DashboardSettings
variablesSettingsTabHandle={variablesSettingsTabHandle}
/>
</SettingsDrawer>
</div>
<div className="actions-2">
<div className="actions-add-panel">
<div className="actions-panel-text">
<img
src={landscapeUrl}
alt="header-image"
style={{ height: '14px', width: '14px' }}
/>
<Typography.Text className="panel">Add panels</Typography.Text>
</div>
<Typography.Text className="panel-info">
Add panels to visualize your data
</Typography.Text>
</div>
{!isDashboardLocked && addPanelPermission && (
<Button
className="add-panel-btn"
onClick={onEmptyWidgetHandler}
icon={<Plus size="md" />}
type="primary"
data-testid="add-panel"
>
New Panel
</Button>
)}
</div>
</section>
</div>
</section>
);
}

View File

@@ -3,7 +3,7 @@ import { Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelTypesWithData } from 'container/DashboardContainer/PanelTypeSelectionModal/menuItems';
import { PanelTypesWithData } from 'container/Visualization/panelTypeMenuItems';
import { handleQueryChange } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';

View File

@@ -1,6 +1,6 @@
import { MutableRefObject } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { PanelMode } from 'container/Visualization/panels/types';
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { render, screen, waitFor } from 'tests/test-utils';

Some files were not shown because too many files have changed in this diff Show More