Compare commits

..

10 Commits

Author SHA1 Message Date
Gaurav Tewari
634166860b fix: add dropdown to list 2026-05-22 01:40:15 +05:30
Gaurav Tewari
af8f2fa95a chore: self review comments 2026-05-22 01:32:54 +05:30
Gaurav Tewari
f81fd78ff6 fix: lint 2026-05-21 22:09:24 +05:30
Gaurav Tewari
b589a7b2e9 fix: failing test 2026-05-21 20:24:03 +05:30
Gaurav Tewari
716dbc7847 chore: migration from antd 2026-05-21 18:05:21 +05:30
Gaurav Tewari
3a92c7577f fix: another bug 2026-05-21 16:00:12 +05:30
Gaurav Tewari
ba043a5741 fix: side nav issue 2026-05-21 14:59:23 +05:30
Gaurav Tewari
6d2b99eb8d fix: self review changes 2026-05-21 14:11:13 +05:30
Gaurav Tewari
3765ca3d42 chore: migrate dropdown 2026-05-20 17:39:33 +05:30
primus-bot[bot]
74c875ec79 chore(release): bump to v0.125.0 (#11369)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-05-20 07:18:40 +00:00
99 changed files with 995 additions and 4661 deletions

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.124.0
image: signoz/signoz:v0.125.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.124.0
image: signoz/signoz:v0.125.0
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.124.0}
image: signoz/signoz:${VERSION:-v0.125.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.124.0}
image: signoz/signoz:${VERSION:-v0.125.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

101
dropdown-test.md Normal file
View File

@@ -0,0 +1,101 @@
ere's the full guide. Dev server is at http://localhost:3301. The components are listed by what's easiest to find — top items
don't need any setup, bottom items need data (a dashboard with widgets, an alert, a funnel).
1. Sidebar — Help & Settings menus
File: src/container/SideNav/SideNav.tsx
Where to look: the left sidebar, scroll to the very bottom. Two icons sit there: a ? icon (Help & Support) and a gear icon
(Settings).
Click each → a menu pops up to the right.
Verify: items render with icons, divider lines between groups, click outside closes. Extra test: while the menu is open, cmd-click
(mac) / ctrl-click (linux) the "Shortcuts" item — should open shortcuts in a new tab, not navigate the current one.
URL: any page works, e.g. http://localhost:3301/
2. Alerts list — row "…" action menu
File: src/components/DropDown/DropDown.tsx (the shared wrapper, consumed here)
Where to look: the "Action" column at the right of each alert row — a three-dot (…) icon.
Click it → menu with Enable/Disable, Edit, etc.
URL: http://localhost:3301/alerts
3. Alerts list — column-filter button
File: src/components/ResizeTable/DynamicColumnTable.tsx
Where to look: the top-right of the alerts table — a sliders/filter icon ("additional filters" button).
Click → list of column names with a Switch next to each → toggle to hide/show columns.
URL: http://localhost:3301/alerts
4. Alert detail page — action menu in header
File: src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx
Where to look: click any alert from the list to open detail page. Top-right of the header: an ellipsis (…) icon next to the
enable/disable toggle.
Click → Rename / Duplicate / Delete (Delete is red — that's the new danger: true styling).
URL: alerts list → click any alert.
5. Dashboards list — "New dashboard" split menu
File: src/container/ListOfDashboard/DashboardsList.tsx
Where to look: top-right of the dashboards page — a blue "New dashboard" button (or in the empty state, a "New Dashboard" button in
the center).
Click → menu with Create dashboard / Import JSON / View templates.
URL: http://localhost:3301/dashboard
6. Widget kebab menu (on a dashboard panel)
File: src/container/GridCardLayout/WidgetHeader/index.tsx
Where to look: open any dashboard with at least one panel. Hover over a panel — top-right of the panel header shows a
vertical-ellipsis icon (⋮).
Click (note: was hover-trigger before, now click — this is the intentional behavior change) → View / Edit / Clone / Create Alert /
Download / Delete.
URL: http://localhost:3301/dashboard/<dashboardId> — open any dashboard from list.
7. Widget builder — Columns add panel
File: src/container/NewWidget/LeftContainer/ExplorerColumnsRenderer.tsx
Where to look: from a dashboard, click "+ Add panel" (or edit an existing panel). In the panel builder, in the left "Columns"
section, there's a plus (+) button next to the column chips.
Click → a panel pops up above the button with a Search input + scrollable list of attribute keys to add as columns.
URL: http://localhost:3301/dashboard/<dashboardId>/new (a dashboard must already exist)
8. Widget builder — Threshold color picker
File: src/container/NewWidget/RightContainer/Threshold/ColorSelector.tsx
Where to look: same widget builder, scroll the right-side config panel to "Thresholds" → click "+ Add threshold" → on the new
threshold row, click the colored swatch button.
Click → Red / Orange / Green / Blue / Custom Color (Custom Color opens a nested color picker on hover).
9. Funnel step — Latency pointer picker
File: src/pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelStep.tsx
Where to look: Traces Funnels page → open or create a funnel → expand a step's config. At the bottom of the step there's "Latency
pointer" with a dropdown trigger showing the current pointer + a down-chevron.
Click → list of pointer options with radio dots (this is the new selection UI — was previously a background highlight).
URL: http://localhost:3301/traces-funnels
10. Download button (Excel / CSV)
File: src/container/Download/Download.tsx
Where to look (easiest): any APM service detail page → scroll to the Top Operations table → top-right has a "Download" link with a
cloud icon.
Click → Excel / CSV options.
URL: http://localhost:3301/services/<service-name> — pick any service.
Also rendered (same component) on: Logs Explorer toolbar (/logs/logs-explorer) and any explorer page rendering a QueryTable.
11. Explorer card — saved view delete
File: src/components/ExplorerCard/ExplorerCard.tsx
Where to look: currently not visible in the UI — the parent component sets showSaveView = false (see ExplorerCard.tsx:165). The
migration is correct but you won't see it unless that flag is flipped. Skip this one.
---
Quick verification priority
If you only have time for a few, hit these — they cover all three migration patterns (Simple, compositional-controlled,
compositional-with-cmd-click):
1. Sidebar Help menu + cmd-click on "Shortcuts" (covers SideNav compositional + onOpenChange + native MouseEvent handling)
2. Widget kebab ⋮ on a dashboard panel (covers hover→click behavior change)
3. Column "+" panel in widget builder (covers controlled-open + custom content compositional API)
4. Any alert row … on /alerts (covers the shared DropDown wrapper)

View File

@@ -15,6 +15,8 @@
const BANNED_COMPONENTS = {
Typography: 'Use @signozhq/ui Typography instead of antd Typography.',
Dropdown:
'Use @signozhq/ui DropdownMenuSimple (or the composable DropdownMenu primitives) from @signozhq/ui/dropdown-menu instead of antd Dropdown.',
};
export default {

View File

@@ -1,7 +0,0 @@
.dropdown-button {
color: var(--l1-foreground);
}
.dropdown-icon {
font-size: 1.2rem;
}

View File

@@ -1,51 +0,0 @@
import { useState } from 'react';
import { Ellipsis } from '@signozhq/icons';
import { Button, Dropdown, MenuProps } from 'antd';
import './DropDown.styles.scss';
function DropDown({
element,
onDropDownItemClick,
}: {
element: JSX.Element[];
onDropDownItemClick?: MenuProps['onClick'];
}): JSX.Element {
const items: MenuProps['items'] = element.map(
(e: JSX.Element, index: number) => ({
label: e,
key: index,
}),
);
const [isDdOpen, setDdOpen] = useState<boolean>(false);
return (
<Dropdown
menu={{
items,
onMouseEnter: (): void => setDdOpen(true),
onMouseLeave: (): void => setDdOpen(false),
onClick: (item): void => onDropDownItemClick?.(item),
}}
open={isDdOpen}
>
<Button
type="link"
className={`dropdown-button`}
onClick={(e): void => {
e.preventDefault();
setDdOpen(true);
}}
>
<Ellipsis className="dropdown-icon" size={16} />
</Button>
</Dropdown>
);
}
DropDown.defaultProps = {
onDropDownItemClick: (): void => {},
};
export default DropDown;

View File

@@ -1,15 +1,7 @@
import { useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import {
Button,
Col,
Dropdown,
MenuProps,
Popover,
Row,
Select,
Space,
} from 'antd';
import { Button, Col, Popover, Row, Select, Space } from 'antd';
import { DropdownMenuSimple, type MenuProps } from '@signozhq/ui/dropdown-menu';
import { Typography } from '@signozhq/ui/typography';
import axios from 'axios';
import TextToolTip from 'components/TextToolTip';
@@ -241,9 +233,9 @@ function ExplorerCard({
</Popover>
<Share2 onClick={onCopyUrlHandler} size="md" />
{viewKey && (
<Dropdown trigger={['click']} menu={moreOptionMenu}>
<Ellipsis size="md" />
</Dropdown>
<DropdownMenuSimple menu={moreOptionMenu}>
<Button type="text" size="small" icon={<Ellipsis size="md" />} />
</DropdownMenuSimple>
)}
</Space>
</OffSetCol>

View File

@@ -6,7 +6,7 @@ import {
useMemo,
useState,
} from 'react';
import { Dropdown } from 'antd';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import cx from 'classnames';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -195,7 +195,7 @@ export const QueryV2 = forwardRef(function QueryV2(
)}
{isMultiQueryAllowed && (
<Dropdown
<DropdownMenuSimple
className="query-actions-dropdown"
menu={{
items: [
@@ -217,10 +217,10 @@ export const QueryV2 = forwardRef(function QueryV2(
: []),
],
}}
placement="bottomRight"
align="end"
>
<Ellipsis size={16} />
</Dropdown>
</DropdownMenuSimple>
)}
</div>
</div>

View File

@@ -4,13 +4,13 @@ import type {
TableColumnsType as ColumnsType,
TableColumnType as ColumnType,
} from 'antd';
import { Button, Dropdown, Flex, MenuProps, Switch } from 'antd';
import { Button, Flex, Switch } from 'antd';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import logEvent from 'api/common/logEvent';
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { SlidersHorizontal } from '@signozhq/icons';
import { popupContainer } from 'utils/selectPopupContainer';
import ResizeTable from './ResizeTable';
import { DynamicColumnTableProps } from './types';
@@ -85,8 +85,9 @@ function DynamicColumnTable({
);
};
const items: MenuProps['items'] =
const items: MenuItem[] =
dynamicColumns?.map((column, index) => ({
key: String(index),
label: (
<div className="dynamicColumnsTable-items">
<div>{column.title?.toString()}</div>
@@ -96,8 +97,6 @@ function DynamicColumnTable({
/>
</div>
),
key: index,
type: 'checkbox',
})) || [];
// Get current page from URL or default to 1
@@ -126,18 +125,14 @@ function DynamicColumnTable({
<Flex justify="flex-end" align="center" gap={8}>
{facingIssueBtn && <LaunchChatSupport {...facingIssueBtn} />}
{dynamicColumns && (
<Dropdown
getPopupContainer={popupContainer}
menu={{ items }}
trigger={['click']}
>
<DropdownMenuSimple menu={{ items }}>
<Button
className="dynamicColumnTable-button filter-btn"
size="middle"
icon={<SlidersHorizontal size={14} />}
data-testid="additional-filters-button"
/>
</Dropdown>
</DropdownMenuSimple>
)}
</Flex>

View File

@@ -1,6 +1,7 @@
import { Dispatch, SetStateAction, useCallback, useMemo } from 'react';
import { ChevronDown, Globe } from '@signozhq/icons';
import { Button, Dropdown } from 'antd';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import TimeItems, {
timePreferance,
@@ -27,20 +28,17 @@ function TimePreference({
const menu = useMemo(
() => ({
items: menuItems,
onClick: timeMenuItemOnChangeHandler,
items: menuItems.map((item) => ({
...item,
onClick: timeMenuItemOnChangeHandler,
})),
}),
[timeMenuItemOnChangeHandler],
);
return (
<Dropdown
menu={menu}
rootClassName="time-selection-menu"
className="time-selection-target"
trigger={['click']}
>
<Button>
<DropdownMenuSimple menu={menu} className="time-selection-menu">
<Button className="time-selection-target">
<div className="button-selected-text">
<Globe size={14} />
<Typography.Text className="selected-value">
@@ -49,7 +47,7 @@ function TimePreference({
</div>
<ChevronDown size="md" />
</Button>
</Dropdown>
</DropdownMenuSimple>
);
}

View File

@@ -11,8 +11,13 @@ import {
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@signozhq/ui/dropdown-menu';
import { toast } from '@signozhq/ui/sonner';
import { Dropdown, Skeleton } from 'antd';
import { Skeleton } from 'antd';
import {
RenderErrorResponseDTO,
ZeustypesHostDTO,
@@ -200,10 +205,19 @@ export default function CustomDomainSettings(): JSX.Element {
!workspaceName ? 'workspace-name-hidden' : ''
}`}
>
<Dropdown
trigger={['click']}
disabled={isFetchingHosts}
dropdownRender={(): JSX.Element => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="link"
color="none"
disabled={isFetchingHosts}
>
<Link2 size={12} />
<span>{stripProtocol(activeHost?.url ?? '')}</span>
<ChevronDown size={12} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<div className="workspace-url-dropdown">
<span className="workspace-url-dropdown-header">
All Workspace URLs
@@ -236,14 +250,8 @@ export default function CustomDomainSettings(): JSX.Element {
);
})}
</div>
)}
>
<Button variant="link" color="none">
<Link2 size={12} />
<span>{stripProtocol(activeHost?.url ?? '')}</span>
<ChevronDown size={12} />
</Button>
</Dropdown>
</DropdownMenuContent>
</DropdownMenu>
<span className="custom-domain-card-meta-timezone">
<Clock size={11} />
{timezone.offset}

View File

@@ -1,4 +1,5 @@
import { GetHosts200 } from 'api/generated/services/sigNoz.schemas';
import userEvent from '@testing-library/user-event';
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
@@ -142,12 +143,13 @@ describe('CustomDomainSettings', () => {
});
it('shows all workspace URLs as links in the dropdown', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CustomDomainSettings />);
await screen.findByText(/custom-host\.test\.cloud/i);
// Open the URL dropdown
fireEvent.click(
await user.click(
screen.getByRole('button', { name: /custom-host\.test\.cloud/i }),
);

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,11 +0,0 @@
.container {
display: flex;
flex-direction: column;
height: 100%;
}
.body {
flex: 1;
padding: 12px 24px;
overflow: auto;
}

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,395 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
import { useCopyToClipboard } from 'react-use';
import {
Check,
ClipboardCopy,
Ellipsis,
FileJson,
Fullscreen,
Globe,
LockKeyhole,
PenLine,
Plus,
X,
} from '@signozhq/icons';
import { Button, Card, Input, Modal, Popover, Tag, Tooltip } from 'antd';
import { toast } from '@signozhq/ui/sonner';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import {
lockDashboardV2,
patchDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import useComponentPermission from 'hooks/useComponentPermission';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { Base64Icons } from '../../DashboardContainer/DashboardSettings/General/utils';
import DashboardSettingsV2 from '../DashboardSettings';
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import SettingsDrawer from './SettingsDrawer';
import '../../DashboardContainer/DashboardDescription/Description.styles.scss';
import type { V2Dashboard } from '../utils';
interface DashboardDescriptionV2Props {
dashboard: V2Dashboard | undefined;
handle: FullScreenHandle;
onRefetch: () => void;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function DashboardDescriptionV2(props: DashboardDescriptionV2Props): JSX.Element {
const { dashboard, handle, onRefetch } = props;
const id = dashboard?.id ?? '';
const isDashboardLocked = !!dashboard?.locked;
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const title = dashboard?.spec?.display?.name ?? '';
const description = dashboard?.spec?.display?.description ?? '';
const image = dashboard?.image || Base64Icons[0];
const tags = useMemo(
() =>
(dashboard?.tags ?? []).map((t) =>
t.key === t.value ? t.key : `${t.key}:${t.value}`,
),
[dashboard?.tags],
);
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const [isDashboardSettingsOpen, setIsDashbordSettingsOpen] =
useState<boolean>(false);
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
useState<boolean>(false);
const isAuthor =
!!user?.email && !!dashboard?.createdBy && dashboard.createdBy === user.email;
const addPanelPermission = !isDashboardLocked;
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
const isPublicDashboard = false;
const { showErrorModal } = useErrorModal();
const { t } = useTranslation(['dashboard', 'common']);
const [isRenameLoading, setIsRenameLoading] = useState<boolean>(false);
useEffect(() => {
if (dashboard) {setUpdatedTitle(title);}
}, [dashboard, title]);
const handleLockDashboardToggle = async (): Promise<void> => {
if (!id) {return;}
setIsDashbordSettingsOpen(false);
try {
if (isDashboardLocked) {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
} else {
await lockDashboardV2({ id });
toast.success('Dashboard locked');
}
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
}
};
const onNameChangeHandler = async (): Promise<void> => {
const trimmed = updatedTitle.trim();
if (!id || !trimmed || trimmed === title) {
setIsRenameDashboardOpen(false);
return;
}
try {
setIsRenameLoading(true);
const patch: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/display/name',
value: trimmed,
},
];
await patchDashboardV2({ id }, patch);
toast.success('Dashboard renamed successfully');
setIsRenameDashboardOpen(false);
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
setIsRenameDashboardOpen(true);
} finally {
setIsRenameLoading(false);
}
};
const onEmptyWidgetHandler = (): void => {
logEvent('Dashboard Detail V2: Add new panel clicked', {
dashboardId: id,
});
toast.info('V2 panel editor coming next');
};
const [state, setCopy] = useCopyToClipboard();
useEffect(() => {
if (state.error) {
toast.error(t('something_went_wrong', { ns: 'common' }));
}
if (state.value) {
toast.success(t('success', { ns: 'common' }));
}
}, [state.error, state.value, t]);
const dashboardDataJSON = (): string =>
JSON.stringify(dashboard ?? {}, null, 2);
const exportJSON = (): void => {
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${title || 'dashboard'}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const onConfigureClick = (): void => {
setIsSettingsDrawerOpen(true);
};
const onSettingsDrawerClose = (): void => {
setIsSettingsDrawerOpen(false);
};
return (
<Card className="dashboard-description-container">
<DashboardHeader title={title} image={image} />
<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={
dashboard?.createdBy === 'integration' &&
'Dashboards created by integrations cannot be unlocked'
}
>
<Button
type="text"
icon={<LockKeyhole size={14} />}
disabled={dashboard?.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">
<Button
type="text"
icon={<FileJson size={14} />}
onClick={(): void => {
exportJSON();
setIsDashbordSettingsOpen(false);
}}
>
Export JSON
</Button>
<Button
type="text"
icon={<ClipboardCopy size={14} />}
onClick={(): void => {
setCopy(dashboardDataJSON());
setIsDashbordSettingsOpen(false);
}}
>
Copy as JSON
</Button>
</section>
<section className="delete-dashboard">
<DeleteButton
createdBy={dashboard?.createdBy || ''}
name={title}
id={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}
>
<DashboardSettingsV2
dashboard={dashboard}
onRefetch={onRefetch}
/>
</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 && (
<div className="dashboard-tags">
{tags.map((tag) => (
<Tag key={tag} className="tag">
{tag}
</Tag>
))}
</div>
)}
{!isEmpty(description) && (
<section className="dashboard-description-section">{description}</section>
)}
<Modal
open={isRenameDashboardOpen}
title="Rename Dashboard"
onOk={onNameChangeHandler}
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={isRenameLoading}
>
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>
</Card>
);
}
export default DashboardDescriptionV2;

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,363 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Radio to @signozhq/ui/radio-group
import { Col, Input, Radio, Select, Space, Tooltip } from 'antd';
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 {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, ExternalLink, SolidInfoCircle, X } from '@signozhq/icons';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesJSONPatchOperationDTO,
TagtypesPostableTagDTO,
} from 'api/generated/services/sigNoz.schemas';
import { toast } from '@signozhq/ui/sonner';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
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';
import type { V2Dashboard } from '../../utils';
const { Option } = Select;
interface Props {
dashboard: V2Dashboard | undefined;
onRefetch: () => void;
}
// Convert V2 tags ({key, value}[]) into "key:value" strings for the V1
// AddTags component (which expects string[]), and back on save.
//
// V2 tags require both `key` and `value` to be non-empty server-side
// (returns `tag_invalid_value` otherwise). To preserve the V1 single-word
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
// collapsed back to just `x` for display.
function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
}
function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
return tagStrings
.map((s) => {
const trimmed = s.trim();
const idx = trimmed.indexOf(':');
if (idx === -1) {
return { key: trimmed, value: trimmed };
}
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
return { key, value: value || key };
})
.filter((t) => t.key.length > 0);
}
function GeneralDashboardSettingsV2({
dashboard,
onRefetch,
}: Props): JSX.Element {
const id = dashboard?.id ?? '';
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(id);
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
useSyncTooltipFilterMode(id);
const title = dashboard?.spec?.display?.name ?? '';
const description = dashboard?.spec?.display?.description ?? '';
const image = dashboard?.image || Base64Icons[0];
const tagsAsStrings = useMemo(
() => tagsToStrings(dashboard?.tags ?? []),
[dashboard?.tags],
);
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const [updatedTags, setUpdatedTags] = useState<string[]>(tagsAsStrings);
const [updatedDescription, setUpdatedDescription] =
useState<string>(description);
const [updatedImage, setUpdatedImage] = useState<string>(image);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] =
useState<number>(0);
const { t } = useTranslation('common');
const { showErrorModal } = useErrorModal();
// Sync state when dashboard refetches after a save
useEffect(() => {
setUpdatedTitle(title);
setUpdatedDescription(description);
setUpdatedImage(image);
setUpdatedTags(tagsAsStrings);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard?.updatedAt]);
const buildPatch = (): DashboardtypesJSONPatchOperationDTO[] => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
const replace = (
path: string,
value: unknown,
): DashboardtypesJSONPatchOperationDTO => ({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path,
value,
});
if (updatedTitle !== title) {
ops.push(replace('/spec/display/name', updatedTitle));
}
if (updatedDescription !== description) {
ops.push(replace('/spec/display/description', updatedDescription));
}
if (updatedImage !== image) {
ops.push(replace('/image', updatedImage));
}
if (!isEqual(updatedTags, tagsAsStrings)) {
ops.push(replace('/tags', stringsToTags(updatedTags)));
}
return ops;
};
const onSaveHandler = async (): Promise<void> => {
if (!id) {
return;
}
const ops = buildPatch();
if (ops.length === 0) {
return;
}
try {
setIsSaving(true);
await patchDashboardV2({ id }, ops);
toast.success('Dashboard updated');
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
};
useEffect(() => {
let n = 0;
const initialValues = [title, description, tagsAsStrings, image];
const updatedValues = [
updatedTitle,
updatedDescription,
updatedTags,
updatedImage,
];
initialValues.forEach((val, index) => {
if (!isEqual(val, updatedValues[index])) {
n += 1;
}
});
setNumberOfUnsavedChanges(n);
}, [
description,
image,
tagsAsStrings,
title,
updatedDescription,
updatedImage,
updatedTags,
updatedTitle,
]);
const discardHandler = (): void => {
setUpdatedTitle(title);
setUpdatedImage(image);
setUpdatedTags(tagsAsStrings);
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>
<Radio.Group
value={cursorSyncMode}
onChange={(e): void => {
setCursorSyncMode(e.target.value as DashboardCursorSync);
}}
>
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
<Radio.Button value={DashboardCursorSync.Crosshair}>
Crosshair
</Radio.Button>
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
</Radio.Group>
</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>
<Radio.Group
value={syncTooltipFilterMode}
onChange={(e): void => {
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
path: getAbsoluteUrl(window.location.pathname),
mode: e.target.value,
});
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
}}
>
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
Filtered
</Radio.Button>
</Radio.Group>
</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={isSaving}
icon={<X size={14} />}
onClick={discardHandler}
type="text"
className={styles.discardBtn}
>
Discard
</Button>
<Button
style={{ margin: '16px 0' }}
disabled={isSaving}
loading={isSaving}
icon={<Check size={14} />}
data-testid="save-dashboard-config"
onClick={onSaveHandler}
type="primary"
className={styles.saveBtn}
>
{t('save')}
</Button>
</div>
</div>
)}
</div>
);
}
export default GeneralDashboardSettingsV2;

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,67 +0,0 @@
import { Button, Empty, Tabs } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Braces, Globe, Table } from '@signozhq/icons';
import '../../DashboardContainer/DashboardSettings/DashboardSettingsContent.styles.scss';
import GeneralDashboardSettingsV2 from './General';
import type { V2Dashboard } from '../utils';
interface Props {
dashboard: V2Dashboard | undefined;
onRefetch: () => void;
}
function Placeholder({ message }: { message: string }): JSX.Element {
return (
<div style={{ padding: 24 }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={<Typography.Text>{message}</Typography.Text>}
/>
</div>
);
}
function DashboardSettingsV2({ dashboard, onRefetch }: Props): JSX.Element {
const items = [
{
label: (
<Button type="text" icon={<Table size={14} />}>
General
</Button>
),
key: 'general',
children: (
<GeneralDashboardSettingsV2
dashboard={dashboard}
onRefetch={onRefetch}
/>
),
},
{
label: (
<Button type="text" icon={<Braces size={14} />}>
Variables
</Button>
),
key: 'variables',
children: <Placeholder message="V2 dashboard variables coming next." />,
},
{
label: (
<Button type="text" icon={<Globe size={14} />}>
Publish
</Button>
),
key: 'public-dashboard',
children: (
<Placeholder message="V2 public dashboard publishing coming next." />
),
},
];
return <Tabs items={items} />;
}
export default DashboardSettingsV2;

View File

@@ -1,17 +0,0 @@
.addButton {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 8px 12px;
background: transparent;
border: 1px dashed var(--bg-slate-400, #1d212d);
border-radius: 4px;
color: var(--bg-vanilla-400, #8993ae);
cursor: pointer;
&:hover {
border-color: var(--bg-robin-500);
color: var(--bg-vanilla-100, #fff);
}
}

View File

@@ -1,75 +0,0 @@
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardSectionV2 } from '../utils';
import { useAddSection } from './hooks/useAddSection';
import { useFirstSectionMigration } from './hooks/useFirstSectionMigration';
import FirstSectionMigrationModal from './FirstSectionMigrationModal';
import styles from './AddSectionControl.module.scss';
const DEFAULT_SECTION_TITLE = 'New section';
interface Props {
sections: DashboardSectionV2[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
dashboardId: string | undefined;
isSectioned: boolean;
onRefetch: () => void;
}
function AddSectionControl({
sections,
layouts,
dashboardId,
isSectioned,
onRefetch,
}: Props): JSX.Element {
const [isMigrationOpen, setIsMigrationOpen] = useState(false);
const { addSection } = useAddSection({ layouts, dashboardId, onRefetch });
const { migrate, isSaving } = useFirstSectionMigration({
sections,
dashboardId,
onRefetch,
});
// Free-flowing dashboard with existing panels → must migrate before sections
// can coexist (every panel must belong to a section once any exists).
const needsMigration =
!isSectioned && sections.some((s) => s.items.length > 0);
const handleClick = (): void => {
if (needsMigration) {
setIsMigrationOpen(true);
return;
}
void addSection(DEFAULT_SECTION_TITLE);
};
const handleConfirmMigration = async (): Promise<void> => {
await migrate(DEFAULT_SECTION_TITLE);
setIsMigrationOpen(false);
};
return (
<>
<button
type="button"
className={styles.addButton}
onClick={handleClick}
data-testid="add-section"
>
<Plus size={14} />
Add section
</button>
<FirstSectionMigrationModal
open={isMigrationOpen}
isSaving={isSaving}
onClose={(): void => setIsMigrationOpen(false)}
onConfirm={handleConfirmMigration}
/>
</>
);
}
export default AddSectionControl;

View File

@@ -1,41 +0,0 @@
import { Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
interface Props {
open: boolean;
isSaving: boolean;
onClose: () => void;
onConfirm: () => void;
}
/**
* Shown when the user adds the first section to a free-flowing dashboard that
* already has panels. Confirms grouping the existing panels into a section
* before proceeding.
*/
function FirstSectionMigrationModal({
open,
isSaving,
onClose,
onConfirm,
}: Props): JSX.Element {
return (
<Modal
open={open}
title="Group panels into sections?"
onCancel={onClose}
onOk={onConfirm}
okText="Continue"
okButtonProps={{ disabled: isSaving, 'data-testid': 'confirm-migration' }}
destroyOnClose
>
<Typography.Text>
This dashboard&apos;s panels are currently free-flowing. Adding a section
will move the existing panels into their own section, and a new empty
section will be added below. You can rename sections afterwards.
</Typography.Text>
</Modal>
);
}
export default FirstSectionMigrationModal;

View File

@@ -1,4 +0,0 @@
.emptyState {
padding: 48px;
text-align: center;
}

View File

@@ -1,16 +0,0 @@
.trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
border-radius: 2px;
color: var(--bg-vanilla-400, #8993ae);
cursor: pointer;
&:hover {
color: var(--bg-vanilla-100, #fff);
background: var(--bg-slate-400, #1d212d);
}
}

View File

@@ -1,95 +0,0 @@
import { useMemo } from 'react';
import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DashboardSectionV2 } from '../utils';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import styles from './PanelActionsMenu.module.scss';
interface Props {
panelId: string;
currentLayoutIndex: number;
sections: DashboardSectionV2[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function PanelActionsMenu({
panelId,
currentLayoutIndex,
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const result: MenuItem[] = [];
if (onMovePanel) {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
);
if (targets.length === 0) {
result.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
disabled: true,
});
} else {
result.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
onMovePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
});
}
}
if (onDeletePanel) {
if (result.length > 0) {
result.push({ type: 'divider' });
}
result.push({
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete panel',
onClick: (): void =>
onDeletePanel({ panelId, layoutIndex: currentLayoutIndex }),
});
}
return result;
}, [sections, currentLayoutIndex, panelId, onMovePanel, onDeletePanel]);
return (
<DropdownMenuSimple menu={{ items }}>
<button
type="button"
className={styles.trigger}
aria-label="Panel actions"
data-testid={`panel-actions-${panelId}`}
// Stop pointer/mouse down from reaching the RGL drag handle this
// button lives inside, so opening the menu never starts a panel drag.
onPointerDown={(e): void => e.stopPropagation()}
onMouseDown={(e): void => e.stopPropagation()}
onClick={(e): void => e.stopPropagation()}
>
<EllipsisVertical size={14} />
</button>
</DropdownMenuSimple>
);
}
export default PanelActionsMenu;

View File

@@ -1,22 +0,0 @@
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.typeButton {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 4px;
color: var(--bg-vanilla-100, #fff);
cursor: pointer;
text-align: left;
&:hover {
border-color: var(--bg-robin-500);
}
}

View File

@@ -1,82 +0,0 @@
import { Modal } from 'antd';
import {
BarChart,
ChartLine,
ChartPie,
Hash,
List,
Table,
} from '@signozhq/icons';
import styles from './PanelTypeSelectionModalV2.module.scss';
interface PanelType {
pluginKind: string;
label: string;
icon: JSX.Element;
}
const PANEL_TYPES: PanelType[] = [
{
pluginKind: 'signoz/TimeSeriesPanel',
label: 'Time Series',
icon: <ChartLine size={16} />,
},
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
{
pluginKind: 'signoz/BarChartPanel',
label: 'Bar Chart',
icon: <BarChart size={16} />,
},
{
pluginKind: 'signoz/PieChartPanel',
label: 'Pie Chart',
icon: <ChartPie size={16} />,
},
{
pluginKind: 'signoz/HistogramPanel',
label: 'Histogram',
icon: <BarChart size={16} />,
},
{ pluginKind: 'signoz/ListPanel', label: 'List', icon: <List size={16} /> },
];
interface Props {
open: boolean;
onClose: () => void;
onSelect: (pluginKind: string) => void;
}
function PanelTypeSelectionModalV2({
open,
onClose,
onSelect,
}: Props): JSX.Element {
return (
<Modal
open={open}
title="Select a panel type"
onCancel={onClose}
footer={null}
destroyOnClose
>
<div className={styles.grid}>
{PANEL_TYPES.map((type) => (
<button
key={type.pluginKind}
type="button"
className={styles.typeButton}
data-testid={`panel-type-${type.pluginKind}`}
onClick={(): void => onSelect(type.pluginKind)}
>
{type.icon}
{type.label}
</button>
))}
</div>
</Modal>
);
}
export default PanelTypeSelectionModalV2;

View File

@@ -1,52 +0,0 @@
.panel {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 4px;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--bg-slate-400, #1d212d);
cursor: grab;
}
.headerLeft {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.headerTitle {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
margin-inline-end: 0;
}
.body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
color: var(--bg-vanilla-400, #8993ae);
font-size: 12px;
text-align: center;
}
.bodyKind {
margin-bottom: 6px;
}

View File

@@ -1,93 +0,0 @@
import { useMemo } from 'react';
import { Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { EllipsisVertical } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardSectionV2 } from '../utils';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import PanelActionsMenu from './PanelActionsMenu';
import styles from './PanelV2.module.scss';
interface Props {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/**
* Placeholder: true once this panel's section enters the viewport. The panel
* query-loading implementation (later PR) will consume this to lazily fetch
* data. Currently unused on purpose.
*/
isVisible?: boolean;
/** Section actions — present only in editable sectioned mode. */
currentLayoutIndex?: number;
sections?: DashboardSectionV2[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function PanelV2({
panel,
panelId,
isVisible,
currentLayoutIndex,
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
const queryCount = panel?.spec?.queries?.length ?? 0;
const headerTitle = useMemo(() => {
if (!description) {
return name;
}
return (
<Tooltip title={description}>
<span>{name}</span>
</Tooltip>
);
}, [name, description]);
return (
<div
className={styles.panel}
data-panel-visible={isVisible ? 'true' : 'false'}
>
<div className={`${styles.header} panel-drag-handle`}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>
{headerTitle}
</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
</div>
{currentLayoutIndex !== undefined && (onMovePanel || onDeletePanel) ? (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={currentLayoutIndex}
sections={sections ?? []}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
) : (
<EllipsisVertical size={14} />
)}
</div>
<div className={styles.body}>
<div>
<div className={styles.bodyKind}>{kind} panel</div>
<div>
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering
coming next
</div>
</div>
</div>
</div>
);
}
export default PanelV2;

View File

@@ -1,64 +0,0 @@
import { useEffect, useState } from 'react';
import { Modal } from 'antd';
import { Input } from '@signozhq/ui/input';
interface Props {
open: boolean;
initialValue: string;
isSaving: boolean;
onClose: () => void;
onSubmit: (title: string) => void;
}
function RenameSectionModal({
open,
initialValue,
isSaving,
onClose,
onSubmit,
}: Props): JSX.Element {
const [value, setValue] = useState<string>(initialValue);
// Reseed the field each time the modal opens.
useEffect(() => {
if (open) {
setValue(initialValue);
}
}, [open, initialValue]);
const submit = (): void => {
const trimmed = value.trim();
if (trimmed) {
onSubmit(trimmed);
}
};
return (
<Modal
open={open}
title="Rename section"
onCancel={onClose}
onOk={submit}
okText="Rename"
okButtonProps={{ disabled: isSaving || !value.trim() }}
destroyOnClose
>
<Input
testId="rename-section-input"
autoFocus
value={value}
maxLength={120}
placeholder="Section name"
onChange={(e): void => setValue(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
e.preventDefault();
submit();
}
}}
/>
</Modal>
);
}
export default RenameSectionModal;

View File

@@ -1,9 +0,0 @@
.section {
margin-bottom: 12px;
border: 1px solid var(--bg-slate-500);
border-radius: 4px;
}
.dragging {
opacity: 0.8;
}

View File

@@ -1,160 +0,0 @@
import { useRef, useState } from 'react';
import { Modal } from 'antd';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import type { DashboardSectionV2 } from '../utils';
import { useRenameSection } from './hooks/useRenameSection';
import { useToggleSectionCollapse } from './hooks/useToggleSectionCollapse';
import { useDeleteSection } from './hooks/useDeleteSection';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import type { AddPanelArgs } from './hooks/useAddPanelToSection';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import PanelTypeSelectionModalV2 from './PanelTypeSelectionModalV2';
import RenameSectionModal from './RenameSectionModal';
import SectionGrid from './SectionGrid';
import SectionHeader, { type SectionDragHandle } from './SectionHeader';
import styles from './Section.module.scss';
interface Props {
section: DashboardSectionV2;
dashboardId: string | undefined;
isEditable: boolean;
onRefetch: () => void;
/** All sections + move handler, for the per-panel "Move to section" action. */
sections?: DashboardSectionV2[];
onMovePanel?: (args: MovePanelArgs) => void;
/** Adds a panel to this section; present only in editable sectioned mode. */
onAddPanel?: (args: AddPanelArgs) => void;
/** Deletes a panel from this section. */
onDeletePanel?: (args: DeletePanelArgs) => void;
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow sections. */
dragHandle?: SectionDragHandle;
}
function Section({
section,
dashboardId,
isEditable,
onRefetch,
sections,
onMovePanel,
onAddPanel,
onDeletePanel,
dragHandle,
}: Props): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
// true once the section scrolls into (or near) the viewport.
const isVisible = useIntersectionObserver(containerRef, {
rootMargin: '200px',
});
const { open, toggle } = useToggleSectionCollapse({
layoutIndex: section.layoutIndex,
initialOpen: section.open,
dashboardId,
});
const [isRenaming, setIsRenaming] = useState(false);
const { rename, isSaving } = useRenameSection({
layoutIndex: section.layoutIndex,
dashboardId,
onRefetch,
});
const handleRenameSubmit = async (title: string): Promise<void> => {
const ok = await rename(title);
if (ok) {
setIsRenaming(false);
}
};
const [isAddingPanel, setIsAddingPanel] = useState(false);
const handleSelectPanelType = (pluginKind: string): void => {
onAddPanel?.({ layoutIndex: section.layoutIndex, pluginKind });
setIsAddingPanel(false);
};
const { deleteSection } = useDeleteSection({
section,
dashboardId,
onRefetch,
});
const confirmDeleteSection = (): void => {
Modal.confirm({
title: `Delete section "${section.title ?? ''}"?`,
content: 'Panels in this section will be removed.',
okText: 'Delete',
okButtonProps: { danger: true },
centered: true,
onOk: () => deleteSection(),
});
};
const grid = (
<SectionGrid
items={section.items}
layoutIndex={section.layoutIndex}
dashboardId={dashboardId}
isEditable={isEditable}
onRefetch={onRefetch}
isVisible={isVisible}
sections={sections}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
);
if (!section.title) {
// Untitled section — just the grid (no header chrome), but still observed
// for the viewport signal.
return (
<div
ref={containerRef}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>
{grid}
</div>
);
}
return (
<div
ref={containerRef}
className={styles.section}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>
<SectionHeader
sectionId={section.id}
title={section.title}
open={open}
onToggle={toggle}
repeatVariable={section.repeatVariable}
dragHandle={dragHandle}
onRename={isEditable ? (): void => setIsRenaming(true) : undefined}
onAddPanel={
isEditable && onAddPanel ? (): void => setIsAddingPanel(true) : undefined
}
onDeleteSection={isEditable ? confirmDeleteSection : undefined}
/>
{open ? grid : null}
<RenameSectionModal
open={isRenaming}
initialValue={section.title}
isSaving={isSaving}
onClose={(): void => setIsRenaming(false)}
onSubmit={handleRenameSubmit}
/>
<PanelTypeSelectionModalV2
open={isAddingPanel}
onClose={(): void => setIsAddingPanel(false)}
onSelect={handleSelectPanelType}
/>
</div>
);
}
export default Section;

View File

@@ -1,16 +0,0 @@
.trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
border-radius: 2px;
color: var(--bg-vanilla-400, #8993ae);
cursor: pointer;
&:hover {
color: var(--bg-vanilla-100, #fff);
background: var(--bg-slate-400, #1d212d);
}
}

View File

@@ -1,68 +0,0 @@
import { useMemo } from 'react';
import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import styles from './SectionActionsMenu.module.scss';
interface Props {
sectionId: string;
onAddPanel?: () => void;
onRename?: () => void;
onDeleteSection?: () => void;
}
function SectionActionsMenu({
sectionId,
onAddPanel,
onRename,
onDeleteSection,
}: Props): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const result: MenuItem[] = [];
if (onAddPanel) {
result.push({
key: 'add-panel',
icon: <Plus size={14} />,
label: 'Add panel',
onClick: onAddPanel,
});
}
if (onRename) {
result.push({
key: 'rename',
icon: <PenLine size={14} />,
label: 'Rename section',
onClick: onRename,
});
}
if (onDeleteSection) {
result.push(
{ type: 'divider' },
{
key: 'delete-section',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete section',
onClick: onDeleteSection,
},
);
}
return result;
}, [onAddPanel, onRename, onDeleteSection]);
return (
<DropdownMenuSimple menu={{ items }}>
<button
type="button"
className={styles.trigger}
aria-label="Section actions"
data-testid={`dashboard-section-actions-${sectionId}`}
>
<EllipsisVertical size={14} />
</button>
</DropdownMenuSimple>
);
}
export default SectionActionsMenu;

View File

@@ -1,7 +0,0 @@
.preview {
border: 1px solid var(--bg-robin-500);
border-radius: 4px;
background: var(--bg-ink-400, #0b0c0e);
box-shadow: 0 8px 24px rgb(0 0 0 / 40%);
cursor: grabbing;
}

View File

@@ -1,32 +0,0 @@
import type { DashboardSectionV2 } from '../utils';
import SectionHeader from './SectionHeader';
import styles from './SectionDragPreview.module.scss';
interface Props {
section: DashboardSectionV2;
}
/**
* Lightweight preview rendered inside the DragOverlay while a section is being
* dragged. Deliberately header-only (no react-grid-layout) so the overlay is
* cheap and never triggers RGL width re-measurement.
*/
function SectionDragPreview({ section }: Props): JSX.Element {
const panelCount = section.items.length;
const title = `${section.title ?? ''} · ${panelCount} ${
panelCount === 1 ? 'panel' : 'panels'
}`;
return (
<div className={styles.preview}>
<SectionHeader
sectionId={`${section.id}-preview`}
title={title}
open={false}
onToggle={(): void => undefined}
/>
</div>
);
}
export default SectionDragPreview;

View File

@@ -1,12 +0,0 @@
.grid {
// Override react-grid-layout's default red drag/resize placeholder with the
// SigNoz brand blue.
:global(.react-grid-item.react-grid-placeholder) {
background: var(--bg-robin-500);
opacity: 0.2;
border-radius: 4px;
transition-duration: 100ms;
z-index: 2;
user-select: none;
}
}

View File

@@ -1,129 +0,0 @@
import { useCallback, useMemo } from 'react';
import GridLayout, {
type ItemCallback,
WidthProvider,
type Layout,
} from 'react-grid-layout';
import type { DashboardSectionV2 } from '../utils';
import { usePersistLayout } from './hooks/usePersistLayout';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import PanelV2 from './PanelV2';
import styles from './SectionGrid.module.scss';
const ResponsiveGridLayout = WidthProvider(GridLayout);
interface Props {
items: DashboardSectionV2['items'];
layoutIndex: number;
dashboardId: string | undefined;
isEditable: boolean;
onRefetch: () => void;
/** Forwarded to panels — true when the parent section is in the viewport. */
isVisible?: boolean;
/** All sections + move handler — present only in editable sectioned mode (panel "Move to section"). */
sections?: DashboardSectionV2[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function SectionGrid({
items,
layoutIndex,
dashboardId,
isEditable,
onRefetch,
isVisible,
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
const rglLayout = useMemo<Layout[]>(
() =>
items.map((item) => ({
i: item.id,
x: item.x,
y: item.y,
w: item.width,
h: item.height,
})),
[items],
);
const { handleLayoutChange } = usePersistLayout({
layoutIndex,
items,
dashboardId,
onRefetch,
});
// On drop, if the pointer is released over a different section, move the
// panel there instead of repositioning it within this section. RGL clamps
// the dragged item visually to this grid, but the pointer is free, so we
// hit-test the release point against section containers.
const handleDragStop = useCallback<ItemCallback>(
// eslint-disable-next-line max-params -- signature fixed by react-grid-layout's ItemCallback
(layout, oldItem, _newItem, _placeholder, event) => {
// Deterministically hit-test the release point against section
// containers (rect-based, so the dragged item on top doesn't interfere).
const targetEl = Array.from(
document.querySelectorAll<HTMLElement>('[data-section-layout-index]'),
).find((el) => {
const r = el.getBoundingClientRect();
return (
event.clientX >= r.left &&
event.clientX <= r.right &&
event.clientY >= r.top &&
event.clientY <= r.bottom
);
});
const attr = targetEl?.getAttribute('data-section-layout-index');
const targetIndex = attr != null ? Number(attr) : null;
if (onMovePanel && targetIndex != null && targetIndex !== layoutIndex) {
onMovePanel({
panelId: oldItem.i,
fromLayoutIndex: layoutIndex,
toLayoutIndex: targetIndex,
});
return;
}
handleLayoutChange(layout);
},
[onMovePanel, layoutIndex, handleLayoutChange],
);
return (
<ResponsiveGridLayout
className={styles.grid}
cols={12}
rowHeight={45}
autoSize
useCSSTransforms
layout={rglLayout}
draggableHandle=".panel-drag-handle"
isDraggable={isEditable}
isResizable={isEditable}
onDragStop={handleDragStop}
onResizeStop={handleLayoutChange}
margin={[8, 8]}
>
{items.map((item) => (
<div key={item.id}>
<PanelV2
panel={item.panel}
panelId={item.id}
isVisible={isVisible}
currentLayoutIndex={layoutIndex}
sections={isEditable ? sections : undefined}
onMovePanel={isEditable ? onMovePanel : undefined}
onDeletePanel={isEditable ? onDeletePanel : undefined}
/>
</div>
))}
</ResponsiveGridLayout>
);
}
export default SectionGrid;

View File

@@ -1,52 +0,0 @@
.header {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
&.headerOpen {
border-bottom: 1px solid var(--bg-slate-500);
}
}
.dragHandle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: none;
color: var(--bg-vanilla-400, #8993ae);
cursor: grab;
&:active {
cursor: grabbing;
}
}
.toggle {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
padding: 0;
background: transparent;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
min-width: 0;
}
.title {
margin-left: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.repeatBadge {
margin-left: 8px;
opacity: 0.6;
}

View File

@@ -1,80 +0,0 @@
import type { DraggableAttributes } from '@dnd-kit/core';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import { ChevronDown, ChevronRight, GripVertical } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import SectionActionsMenu from './SectionActionsMenu';
import styles from './SectionHeader.module.scss';
export interface SectionDragHandle {
attributes: DraggableAttributes;
listeners: SyntheticListenerMap | undefined;
setActivatorNodeRef: (element: HTMLElement | null) => void;
}
interface Props {
sectionId: string;
title: string;
open: boolean;
onToggle: () => void;
repeatVariable?: string;
dragHandle?: SectionDragHandle;
onRename?: () => void;
onAddPanel?: () => void;
onDeleteSection?: () => void;
}
function SectionHeader({
sectionId,
title,
open,
onToggle,
repeatVariable,
dragHandle,
onRename,
onAddPanel,
onDeleteSection,
}: Props): JSX.Element {
const hasActions = !!(onAddPanel || onRename || onDeleteSection);
return (
<div className={`${styles.header} ${open ? styles.headerOpen : ''}`}>
{dragHandle ? (
<button
type="button"
className={styles.dragHandle}
ref={dragHandle.setActivatorNodeRef}
aria-label="Drag to reorder section"
data-testid={`dashboard-section-drag-${sectionId}`}
{...dragHandle.attributes}
{...dragHandle.listeners}
>
<GripVertical size={14} />
</button>
) : null}
<button
type="button"
className={styles.toggle}
onClick={onToggle}
data-testid={`dashboard-section-toggle-${sectionId}`}
>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<Typography.Text className={styles.title}>{title}</Typography.Text>
{repeatVariable ? (
<Typography.Text className={styles.repeatBadge}>
(repeats per ${repeatVariable})
</Typography.Text>
) : null}
</button>
{hasActions ? (
<SectionActionsMenu
sectionId={sectionId}
onAddPanel={onAddPanel}
onRename={onRename}
onDeleteSection={onDeleteSection}
/>
) : null}
</div>
);
}
export default SectionHeader;

View File

@@ -1,124 +0,0 @@
import { useMemo } from 'react';
import { closestCenter, DndContext, DragOverlay } from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardSectionV2 } from '../utils';
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
import { useMovePanelToSection } from './hooks/useMovePanelToSection';
import { useAddPanelToSection } from './hooks/useAddPanelToSection';
import { useDeletePanel } from './hooks/useDeletePanel';
import Section from './Section';
import SectionDragPreview from './SectionDragPreview';
import SortableSection from './SortableSection';
interface Props {
sections: DashboardSectionV2[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
dashboardId: string | undefined;
isEditable: boolean;
onRefetch: () => void;
}
function SectionList({
sections,
layouts,
dashboardId,
isEditable,
onRefetch,
}: Props): JSX.Element {
const {
sensors,
orderedSections,
activeSection,
onDragStart,
onDragEnd,
onDragCancel,
} = useSectionDragReorder({ sections, layouts, dashboardId, onRefetch });
const onMovePanel = useMovePanelToSection({
sections,
dashboardId,
onRefetch,
});
const onAddPanel = useAddPanelToSection({ sections, dashboardId, onRefetch });
const onDeletePanel = useDeletePanel({ sections, dashboardId, onRefetch });
// Only titled sections participate in reordering; untitled (free-flow)
// blocks render in place without a drag handle.
const sortableIds = useMemo(
() => orderedSections.filter((s) => s.title).map((s) => s.id),
[orderedSections],
);
if (!isEditable) {
return (
<>
{sections.map((section) => (
<Section
key={section.id}
section={section}
dashboardId={dashboardId}
isEditable={isEditable}
onRefetch={onRefetch}
/>
))}
</>
);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{orderedSections.map((section) =>
section.title ? (
<SortableSection
key={section.id}
section={section}
dashboardId={dashboardId}
isEditable={isEditable}
onRefetch={onRefetch}
sections={sections}
onMovePanel={onMovePanel}
onAddPanel={onAddPanel}
onDeletePanel={onDeletePanel}
/>
) : (
<Section
key={section.id}
section={section}
dashboardId={dashboardId}
isEditable={isEditable}
onRefetch={onRefetch}
sections={sections}
onMovePanel={onMovePanel}
onAddPanel={onAddPanel}
onDeletePanel={onDeletePanel}
/>
),
)}
</SortableContext>
{/* dropAnimation disabled: optimistic reorder already places the section,
so animating the overlay back would cause a visible snap/shake. */}
<DragOverlay dropAnimation={null}>
{activeSection ? <SectionDragPreview section={activeSection} /> : null}
</DragOverlay>
</DndContext>
);
}
export default SectionList;

View File

@@ -1,68 +0,0 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { DashboardSectionV2 } from '../utils';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import type { AddPanelArgs } from './hooks/useAddPanelToSection';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import Section from './Section';
interface Props {
section: DashboardSectionV2;
dashboardId: string | undefined;
isEditable: boolean;
onRefetch: () => void;
sections: DashboardSectionV2[];
onMovePanel: (args: MovePanelArgs) => void;
onAddPanel: (args: AddPanelArgs) => void;
onDeletePanel: (args: DeletePanelArgs) => void;
}
function SortableSection({
section,
dashboardId,
isEditable,
onRefetch,
sections,
onMovePanel,
onAddPanel,
onDeletePanel,
}: Props): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: section.id });
// dnd-kit drives the drag transform per-frame, so this must be an inline
// style — there is no static-stylesheet equivalent for a live transform.
// While dragging, the original is hidden (the DragOverlay renders the moving
// preview); keeping it in place preserves the gap and lets siblings animate.
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0 : undefined,
};
return (
<div ref={setNodeRef} style={style}>
<Section
section={section}
dashboardId={dashboardId}
isEditable={isEditable}
onRefetch={onRefetch}
sections={sections}
onMovePanel={onMovePanel}
onAddPanel={onAddPanel}
onDeletePanel={onDeletePanel}
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
/>
</div>
);
}
export default SortableSection;

View File

@@ -1,77 +0,0 @@
import { useCallback } from 'react';
import { v4 as uuid } from 'uuid';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import {
addPanelToSectionOps,
createDefaultPanel,
panelRef,
} from '../../patchOps';
import type { DashboardSectionV2 } from '../../utils';
interface Params {
sections: DashboardSectionV2[];
dashboardId: string | undefined;
onRefetch: () => void;
}
export interface AddPanelArgs {
layoutIndex: number;
pluginKind: string;
}
/**
* Creates a new panel and places its item ref at the bottom of the target
* section, as one atomic patch. Structure-only: the panel is a valid minimal
* placeholder (its query is filled in once the panel editor lands).
*/
export function useAddPanelToSection({
sections,
dashboardId,
onRefetch,
}: Params): (args: AddPanelArgs) => Promise<void> {
const { showErrorModal } = useErrorModal();
return useCallback(
async ({ layoutIndex, pluginKind }: AddPanelArgs): Promise<void> => {
if (!dashboardId) {
return;
}
const target = sections.find((s) => s.layoutIndex === layoutIndex);
if (!target) {
return;
}
const panelId = uuid();
const nextY = target.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
try {
await patchDashboardV2(
{ id: dashboardId },
addPanelToSectionOps({
panelId,
panel: createDefaultPanel(pluginKind),
layoutIndex,
item: {
x: 0,
y: nextY,
width: 6,
height: 6,
content: { $ref: panelRef(panelId) },
},
}),
);
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, onRefetch, showErrorModal],
);
}

View File

@@ -1,58 +0,0 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { addSectionOp, newGridLayout, reorderLayoutsOp } from '../../patchOps';
interface Params {
layouts: DashboardtypesLayoutDTO[] | undefined | null;
dashboardId: string | undefined;
onRefetch: () => void;
}
interface Result {
addSection: (title: string) => Promise<void>;
isSaving: boolean;
}
/**
* Appends an empty titled section. When the dashboard has no layouts yet, the
* layouts array is created via a `replace` (an `add` to a missing/empty array
* pointer is unreliable); otherwise a new Grid is appended.
*/
export function useAddSection({
layouts,
dashboardId,
onRefetch,
}: Params): Result {
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const addSection = useCallback(
async (title: string): Promise<void> => {
const trimmed = title.trim();
if (!dashboardId || !trimmed) {
return;
}
const op =
!layouts || layouts.length === 0
? reorderLayoutsOp([newGridLayout(trimmed)])
: addSectionOp(trimmed);
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [op]);
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[layouts, dashboardId, onRefetch, showErrorModal],
);
return { addSection, isSaving };
}

View File

@@ -1,55 +0,0 @@
import { useCallback } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { removePanelOp, replaceSectionItemsOp } from '../../patchOps';
import type { DashboardSectionV2 } from '../../utils';
interface Params {
sections: DashboardSectionV2[];
dashboardId: string | undefined;
onRefetch: () => void;
}
export interface DeletePanelArgs {
panelId: string;
layoutIndex: number;
}
/**
* Removes a panel: drops its item ref from the section's items and deletes the
* panel from `spec.panels`, as one atomic patch.
*/
export function useDeletePanel({
sections,
dashboardId,
onRefetch,
}: Params): (args: DeletePanelArgs) => Promise<void> {
const { showErrorModal } = useErrorModal();
return useCallback(
async ({ panelId, layoutIndex }: DeletePanelArgs): Promise<void> => {
if (!dashboardId) {
return;
}
const section = sections.find((s) => s.layoutIndex === layoutIndex);
if (!section) {
return;
}
const nextItems = section.items.filter((i) => i.id !== panelId);
try {
await patchDashboardV2({ id: dashboardId }, [
replaceSectionItemsOp(layoutIndex, nextItems),
removePanelOp(panelId),
]);
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, onRefetch, showErrorModal],
);
}

View File

@@ -1,54 +0,0 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { removePanelOp, removeSectionOp } from '../../patchOps';
import type { DashboardSectionV2 } from '../../utils';
interface Params {
section: DashboardSectionV2;
dashboardId: string | undefined;
onRefetch: () => void;
}
interface Result {
deleteSection: () => Promise<void>;
isSaving: boolean;
}
/**
* Deletes a section: removes its Grid layout and deletes every panel it
* contained from `spec.panels` (orphan cleanup), as one atomic patch.
*/
export function useDeleteSection({
section,
dashboardId,
onRefetch,
}: Params): Result {
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const deleteSection = useCallback(async (): Promise<void> => {
if (!dashboardId) {
return;
}
const ops: DashboardtypesJSONPatchOperationDTO[] = section.items.map((i) =>
removePanelOp(i.id),
);
ops.push(removeSectionOp(section.layoutIndex));
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, ops);
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [section, dashboardId, onRefetch, showErrorModal]);
return { deleteSection, isSaving };
}

View File

@@ -1,67 +0,0 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { addSectionOp, titleUntitledSectionOp } from '../../patchOps';
import type { DashboardSectionV2 } from '../../utils';
interface Params {
sections: DashboardSectionV2[];
dashboardId: string | undefined;
onRefetch: () => void;
}
interface Result {
migrate: (newSectionTitle: string) => Promise<void>;
isSaving: boolean;
}
/**
* Converts a free-flowing dashboard into a sectioned one: every existing
* untitled layout that holds panels is titled in place ("Section 1", "Section
* 2", …), then the brand-new section the user asked for is appended — all in one
* atomic patch. Used once the user confirms the migration prompt.
*/
export function useFirstSectionMigration({
sections,
dashboardId,
onRefetch,
}: Params): Result {
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const migrate = useCallback(
async (newSectionTitle: string): Promise<void> => {
const trimmed = newSectionTitle.trim();
if (!dashboardId || !trimmed) {
return;
}
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
let counter = 1;
sections.forEach((s) => {
if (!s.title && s.items.length > 0) {
ops.push(titleUntitledSectionOp(s.layoutIndex, `Section ${counter}`));
counter += 1;
}
});
ops.push(addSectionOp(trimmed));
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, ops);
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[sections, dashboardId, onRefetch, showErrorModal],
);
return { migrate, isSaving };
}

View File

@@ -1,80 +0,0 @@
import { useCallback } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { movePanelBetweenSectionsOps } from '../../patchOps';
import type { DashboardSectionV2 } from '../../utils';
export interface MovePanelArgs {
panelId: string;
fromLayoutIndex: number;
toLayoutIndex: number;
}
interface Params {
sections: DashboardSectionV2[];
dashboardId: string | undefined;
onRefetch: () => void;
}
/**
* Relocates a panel's item ref from one section to another. The panel itself
* stays in `spec.panels`; only the grid item moves, dropped into a free row at
* the bottom of the target section. Persisted as one atomic patch.
*/
export function useMovePanelToSection({
sections,
dashboardId,
onRefetch,
}: Params): (args: MovePanelArgs) => Promise<void> {
const { showErrorModal } = useErrorModal();
return useCallback(
async ({
panelId,
fromLayoutIndex,
toLayoutIndex,
}: MovePanelArgs): Promise<void> => {
if (!dashboardId || fromLayoutIndex === toLayoutIndex) {
return;
}
const source = sections.find((s) => s.layoutIndex === fromLayoutIndex);
const target = sections.find((s) => s.layoutIndex === toLayoutIndex);
if (!source || !target) {
return;
}
const moved = source.items.find((i) => i.id === panelId);
if (!moved) {
return;
}
const sourceItems = source.items.filter((i) => i.id !== panelId);
// Place at a fresh row at the bottom of the target section.
const nextY = target.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
try {
await patchDashboardV2(
{ id: dashboardId },
movePanelBetweenSectionsOps({
sourceIndex: fromLayoutIndex,
sourceItems,
targetIndex: toLayoutIndex,
targetItems,
}),
);
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, onRefetch, showErrorModal],
);
}

View File

@@ -1,104 +0,0 @@
import { useCallback, useState } from 'react';
import type { Layout } from 'react-grid-layout';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { replaceSectionItemsOp } from '../../patchOps';
import type { GridItemV2 } from '../../utils';
interface Params {
layoutIndex: number;
items: GridItemV2[];
dashboardId: string | undefined;
onRefetch: () => void;
}
interface Result {
handleLayoutChange: (rglLayout: Layout[]) => void;
isSaving: boolean;
}
/** Maps an RGL layout back onto the section's grid items, preserving panel refs. */
function mergeRglLayout(
rglLayout: Layout[],
items: GridItemV2[],
): GridItemV2[] {
const byId = new Map(items.map((item) => [item.id, item]));
return rglLayout
.map((entry) => {
const existing = byId.get(entry.i);
if (!existing) {
return null;
}
return {
...existing,
x: entry.x,
y: entry.y,
width: entry.w,
height: entry.h,
};
})
.filter((item): item is GridItemV2 => item !== null);
}
function hasGeometryChanged(next: GridItemV2[], prev: GridItemV2[]): boolean {
if (next.length !== prev.length) {
return true;
}
const prevById = new Map(prev.map((item) => [item.id, item]));
return next.some((item) => {
const before = prevById.get(item.id);
if (!before) {
return true;
}
return (
before.x !== item.x ||
before.y !== item.y ||
before.width !== item.width ||
before.height !== item.height
);
});
}
/**
* Persists panel geometry within a single section. Call the returned handler
* from RGL's `onDragStop`/`onResizeStop` (stop events only — not continuous
* `onLayoutChange`) to limit network churn.
*/
export function usePersistLayout({
layoutIndex,
items,
dashboardId,
onRefetch,
}: Params): Result {
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const handleLayoutChange = useCallback(
async (rglLayout: Layout[]): Promise<void> => {
if (!dashboardId) {
return;
}
const nextItems = mergeRglLayout(rglLayout, items);
if (!hasGeometryChanged(nextItems, items)) {
return;
}
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [
replaceSectionItemsOp(layoutIndex, nextItems),
]);
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[dashboardId, items, layoutIndex, onRefetch, showErrorModal],
);
return { handleLayoutChange, isSaving };
}

View File

@@ -1,53 +0,0 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { renameSectionOp } from '../../patchOps';
interface Params {
layoutIndex: number;
dashboardId: string | undefined;
onRefetch: () => void;
}
interface Result {
rename: (title: string) => Promise<boolean>;
isSaving: boolean;
}
/** Renames a section's title via `replace /spec/layouts/<i>/spec/display/title`. */
export function useRenameSection({
layoutIndex,
dashboardId,
onRefetch,
}: Params): Result {
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const rename = useCallback(
async (title: string): Promise<boolean> => {
const trimmed = title.trim();
if (!dashboardId || !trimmed) {
return false;
}
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [
renameSectionOp(layoutIndex, trimmed),
]);
onRefetch();
return true;
} catch (error) {
showErrorModal(error as APIError);
return false;
} finally {
setIsSaving(false);
}
},
[dashboardId, layoutIndex, onRefetch, showErrorModal],
);
return { rename, isSaving };
}

View File

@@ -1,129 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
type DragEndEvent,
type DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { reorderLayoutsOp } from '../../patchOps';
import type { DashboardSectionV2 } from '../../utils';
interface Params {
sections: DashboardSectionV2[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
dashboardId: string | undefined;
onRefetch: () => void;
}
interface Result {
sensors: ReturnType<typeof useSensors>;
/** Display order — optimistically reordered on drop so the UI doesn't wait on refetch. */
orderedSections: DashboardSectionV2[];
/** The section currently being dragged (for the DragOverlay preview), or null. */
activeSection: DashboardSectionV2 | null;
onDragStart: (event: DragStartEvent) => void;
onDragEnd: (event: DragEndEvent) => void;
onDragCancel: () => void;
}
/**
* Owns section-reorder drag state. Reorders happen optimistically in local
* state (keyed by stable section id) and persist via a single
* `replace /spec/layouts` patch; the optimistic order is cleared once fresh
* server data arrives. Each section maps 1:1 to a Grid layout via `layoutIndex`,
* so the new layouts array is rebuilt by mapping the reordered sections back.
*/
export function useSectionDragReorder({
sections,
layouts,
dashboardId,
onRefetch,
}: Params): Result {
const [activeId, setActiveId] = useState<string | null>(null);
const [localOrderIds, setLocalOrderIds] = useState<string[] | null>(null);
const { showErrorModal } = useErrorModal();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// Server data is the source of truth — drop optimistic order whenever it changes.
useEffect(() => {
setLocalOrderIds(null);
}, [sections]);
const orderedSections = useMemo<DashboardSectionV2[]>(() => {
if (!localOrderIds) {
return sections;
}
const byId = new Map(sections.map((s) => [s.id, s]));
const ordered = localOrderIds
.map((id) => byId.get(id))
.filter((s): s is DashboardSectionV2 => s !== undefined);
return ordered.length === sections.length ? ordered : sections;
}, [sections, localOrderIds]);
const onDragStart = useCallback((event: DragStartEvent): void => {
setActiveId(String(event.active.id));
}, []);
const onDragCancel = useCallback((): void => {
setActiveId(null);
}, []);
const onDragEnd = useCallback(
async (event: DragEndEvent): Promise<void> => {
setActiveId(null);
const { active, over } = event;
if (!over || active.id === over.id || !dashboardId || !layouts) {
return;
}
const oldIndex = orderedSections.findIndex((s) => s.id === active.id);
const newIndex = orderedSections.findIndex((s) => s.id === over.id);
if (oldIndex < 0 || newIndex < 0) {
return;
}
const newOrdered = arrayMove(orderedSections, oldIndex, newIndex);
setLocalOrderIds(newOrdered.map((s) => s.id));
const newLayouts = newOrdered
.map((s) => layouts[s.layoutIndex])
.filter((l): l is DashboardtypesLayoutDTO => l !== undefined);
try {
await patchDashboardV2({ id: dashboardId }, [reorderLayoutsOp(newLayouts)]);
onRefetch();
} catch (error) {
setLocalOrderIds(null); // revert optimistic order on failure
showErrorModal(error as APIError);
}
},
[orderedSections, layouts, dashboardId, onRefetch, showErrorModal],
);
const activeSection = useMemo(
() => orderedSections.find((s) => s.id === activeId) ?? null,
[orderedSections, activeId],
);
return {
sensors,
orderedSections,
activeSection,
onDragStart,
onDragEnd,
onDragCancel,
};
}

View File

@@ -1,55 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { setSectionCollapseOp } from '../../patchOps';
interface Params {
layoutIndex: number;
initialOpen: boolean;
dashboardId: string | undefined;
}
interface Result {
open: boolean;
toggle: () => void;
}
/**
* Owns a section's expand/collapse state. The toggle is optimistic (snappy UI)
* and persists to `spec.layouts[i].spec.display.collapse.open` in the
* background — no refetch, since collapse is a lightweight UI preference and a
* full dashboard refetch would flicker. Reverts on patch failure.
*/
export function useToggleSectionCollapse({
layoutIndex,
initialOpen,
dashboardId,
}: Params): Result {
const [open, setOpen] = useState<boolean>(initialOpen);
const { showErrorModal } = useErrorModal();
// Reconcile with server state when it changes (e.g. after a refetch).
useEffect(() => {
setOpen(initialOpen);
}, [initialOpen]);
const toggle = useCallback((): void => {
setOpen((prev) => {
const next = !prev;
if (dashboardId) {
patchDashboardV2({ id: dashboardId }, [
setSectionCollapseOp(layoutIndex, next),
]).catch((error) => {
setOpen(prev); // revert on failure
showErrorModal(error as APIError);
});
}
return next;
});
}, [dashboardId, layoutIndex, showErrorModal]);
return { open, toggle };
}

View File

@@ -1,104 +0,0 @@
import { useMemo } from 'react';
import { Empty } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { layoutsToSections } from '../utils';
import AddSectionControl from './AddSectionControl';
import Section from './Section';
import SectionList from './SectionList';
import styles from './GridCardLayoutV2.module.scss';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
interface Props {
layouts: DashboardtypesLayoutDTO[] | undefined | null;
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined;
dashboardId: string | undefined;
isEditable: boolean;
onRefetch: () => void;
}
function GridCardLayoutV2({
layouts,
panels,
dashboardId,
isEditable,
onRefetch,
}: Props): JSX.Element {
const sections = useMemo(
() => layoutsToSections(layouts, panels),
[layouts, panels],
);
const isEmpty =
sections.length === 0 || sections.every((s) => s.items.length === 0);
// Sectioned mode = at least one titled layout. Sections then become a
// draggable, reorderable list; otherwise the dashboard is a single
// free-flowing grid with no section chrome or reordering.
const isSectioned = useMemo(() => sections.some((s) => !!s.title), [sections]);
const renderContent = (): JSX.Element => {
if (isEmpty) {
return (
<div className={styles.emptyState}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<Typography.Text>No panels in this dashboard yet</Typography.Text>
}
/>
</div>
);
}
if (isSectioned) {
return (
<SectionList
sections={sections}
layouts={layouts}
dashboardId={dashboardId}
isEditable={isEditable}
onRefetch={onRefetch}
/>
);
}
return (
<>
{sections.map((section) => (
<Section
key={section.id}
section={section}
dashboardId={dashboardId}
isEditable={isEditable}
onRefetch={onRefetch}
/>
))}
</>
);
};
return (
<>
{renderContent()}
{isEditable ? (
<AddSectionControl
sections={sections}
layouts={layouts}
dashboardId={dashboardId}
isSectioned={isSectioned}
onRefetch={onRefetch}
/>
) : null}
</>
);
}
export default GridCardLayoutV2;

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,58 +0,0 @@
import { useCallback } 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 { Base64Icons } from '../../../DashboardContainer/DashboardSettings/General/utils';
import './DashboardBreadcrumbs.styles.scss';
interface Props {
title: string;
image?: string;
}
function DashboardBreadcrumbs({ title, image }: Props): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const goToListPage = useCallback(() => {
const dashboardsListQueryParamsString = getSessionStorageApi(
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
);
if (dashboardsListQueryParamsString) {
safeNavigate({
pathname: ROUTES.ALL_DASHBOARD,
search: `?${dashboardsListQueryParamsString}`,
});
} else {
safeNavigate(ROUTES.ALL_DASHBOARD);
}
}, [safeNavigate]);
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 || Base64Icons[0]}
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,22 +0,0 @@
import { memo } from 'react';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
import './DashboardHeader.styles.scss';
interface Props {
title: string;
image?: string;
}
function DashboardHeader({ title, image }: Props): JSX.Element {
return (
<div className="dashboard-header">
<DashboardBreadcrumbs title={title} image={image} />
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
</div>
);
}
export default memo(DashboardHeader);

View File

@@ -1,46 +0,0 @@
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import DashboardDescriptionV2 from './DashboardDescriptionV2';
import GridCardLayoutV2 from './GridCardLayoutV2';
import type { V2Dashboard } from './utils';
import styles from './DashboardContainerV2.module.scss';
interface Props {
dashboard: V2Dashboard | undefined;
onRefetch: () => void;
}
function DashboardContainerV2({ dashboard, onRefetch }: Props): JSX.Element {
const fullScreenHandle = useFullScreenHandle();
const spec = dashboard?.spec;
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const isEditable = !dashboard?.locked && editDashboard;
return (
<FullScreen handle={fullScreenHandle}>
<div className={styles.container}>
<DashboardDescriptionV2
dashboard={dashboard}
handle={fullScreenHandle}
onRefetch={onRefetch}
/>
<div className={styles.body}>
<GridCardLayoutV2
layouts={spec?.layouts}
panels={spec?.panels ?? undefined}
dashboardId={dashboard?.id}
isEditable={isEditable}
onRefetch={onRefetch}
/>
</div>
</div>
</FullScreen>
);
}
export default DashboardContainerV2;

View File

@@ -1,189 +0,0 @@
import type {
DashboardGridItemDTO,
DashboardtypesJSONPatchOperationDTO,
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesJSONPatchOperationDTOOp } from 'api/generated/services/sigNoz.schemas';
import type { GridItemV2 } from './utils';
/**
* Pure RFC-6902 JSON-Patch builders for the V2 dashboard spec. These are
* intentionally side-effect-free (no React, no network) so they can be unit
* tested and reused by the layout hooks. JSON pointers target the postable
* shape: `/spec/layouts/...`, `/spec/panels/...` (matches the existing V2
* patches in DashboardSettings/General and DashboardDescriptionV2).
*/
const { add, replace, remove } = DashboardtypesJSONPatchOperationDTOOp;
const PANEL_REF_PREFIX = '#/spec/panels/';
export function panelRef(panelId: string): string {
return `${PANEL_REF_PREFIX}${panelId}`;
}
/**
* Builds a minimal, backend-valid panel for a given plugin kind. The spec
* requires exactly one query whose plugin kind is allowed for the panel;
* `signoz/BuilderQuery` is allowed for every panel kind and its contents are not
* validated, so an empty builder query is the safe default. The real query is
* filled in once the panel editor lands.
*/
export function createDefaultPanel(pluginKind: string): DashboardtypesPanelDTO {
// The DTO types plugin/query kinds as large generated enum unions; the kind
// here is chosen dynamically by the user, so we build the structurally-valid
// shape and assert the type.
return {
kind: 'Panel',
spec: {
display: { name: 'New panel' },
plugin: { kind: pluginKind, spec: {} },
queries: [
{
kind: 'TimeSeriesQuery',
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { name: 'A' } } },
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
/** Converts a UI grid item back into the spec's grid-item DTO shape. */
export function gridItemToDTO(item: GridItemV2): DashboardGridItemDTO {
return {
x: item.x,
y: item.y,
width: item.width,
height: item.height,
content: { $ref: panelRef(item.id) },
};
}
/** Replace the entire items array of one section (used on panel move/resize). */
export function replaceSectionItemsOp(
layoutIndex: number,
items: GridItemV2[],
): DashboardtypesJSONPatchOperationDTO {
return {
op: replace,
path: `/spec/layouts/${layoutIndex}/spec/items`,
value: items.map(gridItemToDTO),
};
}
/** Replace the whole layouts array (used on section reorder — avoids move-index ambiguity). */
export function reorderLayoutsOp(
layouts: DashboardtypesLayoutDTO[],
): DashboardtypesJSONPatchOperationDTO {
return { op: replace, path: '/spec/layouts', value: layouts };
}
/** An empty titled Grid layout (one section). */
export function newGridLayout(title: string): DashboardtypesLayoutDTO {
return {
kind: 'Grid' as DashboardtypesLayoutDTO['kind'],
spec: { display: { title, collapse: { open: true } }, items: [] },
};
}
/** Append a new, empty titled Grid section. */
export function addSectionOp(
title: string,
): DashboardtypesJSONPatchOperationDTO {
return { op: add, path: '/spec/layouts/-', value: newGridLayout(title) };
}
interface AddPanelToSectionArgs {
panelId: string;
panel: DashboardtypesPanelDTO;
layoutIndex: number;
item: DashboardGridItemDTO;
}
/** Add a panel to `spec.panels` and an item ref into a section, as one atomic patch. */
export function addPanelToSectionOps({
panelId,
panel,
layoutIndex,
item,
}: AddPanelToSectionArgs): DashboardtypesJSONPatchOperationDTO[] {
return [
{ op: add, path: `/spec/panels/${panelId}`, value: panel },
{ op: add, path: `/spec/layouts/${layoutIndex}/spec/items/-`, value: item },
];
}
interface MovePanelArgs {
sourceIndex: number;
sourceItems: GridItemV2[];
targetIndex: number;
targetItems: GridItemV2[];
}
/** Move a panel's item ref from one section to another (panel stays in spec.panels). */
export function movePanelBetweenSectionsOps({
sourceIndex,
sourceItems,
targetIndex,
targetItems,
}: MovePanelArgs): DashboardtypesJSONPatchOperationDTO[] {
return [
replaceSectionItemsOp(sourceIndex, sourceItems),
replaceSectionItemsOp(targetIndex, targetItems),
];
}
/** Rename an existing section's title. */
export function renameSectionOp(
layoutIndex: number,
title: string,
): DashboardtypesJSONPatchOperationDTO {
return {
op: replace,
path: `/spec/layouts/${layoutIndex}/spec/display/title`,
value: title,
};
}
/** Persist a section's collapse state. `add` safely replaces the collapse object if present. */
export function setSectionCollapseOp(
layoutIndex: number,
open: boolean,
): DashboardtypesJSONPatchOperationDTO {
return {
op: add,
path: `/spec/layouts/${layoutIndex}/spec/display/collapse`,
value: { open },
};
}
/**
* First-section migration: give an existing untitled (free-flowing) layout a
* title, turning it into a section in place while preserving its panels.
*/
export function titleUntitledSectionOp(
layoutIndex: number,
title: string,
): DashboardtypesJSONPatchOperationDTO {
return {
op: add,
path: `/spec/layouts/${layoutIndex}/spec/display`,
value: { title, collapse: { open: true } },
};
}
/** Remove a section. Panel cleanup (orphaned refs) is handled by the caller. */
export function removeSectionOp(
layoutIndex: number,
): DashboardtypesJSONPatchOperationDTO {
return { op: remove, path: `/spec/layouts/${layoutIndex}` };
}
/** Remove a panel definition from `spec.panels`. */
export function removePanelOp(
panelId: string,
): DashboardtypesJSONPatchOperationDTO {
return { op: remove, path: `/spec/panels/${panelId}` };
}

View File

@@ -1,157 +0,0 @@
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
export type V2Dashboard = DashboardtypesGettableDashboardV2DTO;
export interface GridItemV2 {
id: string;
x: number;
y: number;
width: number;
height: number;
panel: DashboardtypesPanelDTO | undefined;
}
const PANEL_REF_PREFIX = '#/spec/panels/';
export function extractPanelIdFromRef(ref: string | undefined): string | null {
if (!ref) {
return null;
}
if (!ref.startsWith(PANEL_REF_PREFIX)) {
return null;
}
return ref.slice(PANEL_REF_PREFIX.length);
}
export function flattenGridLayout(
layouts: DashboardtypesLayoutDTO[] | undefined | null,
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
): GridItemV2[] {
if (!layouts?.length) {
return [];
}
const items: GridItemV2[] = [];
layouts.forEach((layoutEnvelope) => {
if (layoutEnvelope?.kind !== 'Grid') {
return;
}
const gridItems = layoutEnvelope.spec?.items ?? [];
gridItems.forEach((item) => {
const id = extractPanelIdFromRef(item.content?.$ref);
if (!id) {
return;
}
items.push({
id,
x: item.x ?? 0,
y: item.y ?? 0,
width: item.width ?? 6,
height: item.height ?? 6,
panel: panels?.[id],
});
});
});
return items;
}
/**
* A section corresponds to one entry in `spec.layouts`. If the Grid has a
* `display.title`, it renders with a collapsible header; otherwise it is a
* "default" untitled section (visually just the grid).
*/
export interface DashboardSectionV2 {
/**
* Stable identity used for React keys and dnd-kit sortable item ids. Derived
* from the section's content (its first panel ref) so it survives reordering
* — unlike the positional `layoutIndex`. See `getSectionStableId`.
*/
id: string;
/** Position of this section's Grid in `spec.layouts`. All JSON-Patch ops target by this. */
layoutIndex: number;
title: string | undefined;
open: boolean;
items: GridItemV2[];
repeatVariable: string | undefined;
}
/**
* Derives a stable id for a section from its content. Reordering sections changes
* their `layoutIndex` but not their content, so keying off the first panel ref
* keeps React component instances (and any local state) bound to the right
* section across a reorder. Empty sections fall back to a positional id — they
* are rarely reordered, and a future backend `id` on the layout spec is the
* proper long-term fix.
*/
export function getSectionStableId(
items: GridItemV2[],
layoutIndex: number,
): string {
if (items.length > 0) {
return `sec-${items[0].id}`;
}
return `sec-empty-${layoutIndex}`;
}
export function layoutsToSections(
layouts: DashboardtypesLayoutDTO[] | undefined | null,
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
): DashboardSectionV2[] {
if (!layouts?.length) {
return [];
}
return layouts
.map((layoutEnvelope, idx) => {
if (layoutEnvelope?.kind !== 'Grid') {
return null;
}
const spec = layoutEnvelope.spec;
const items: GridItemV2[] = (spec?.items ?? [])
.map((item) => {
const id = extractPanelIdFromRef(item.content?.$ref);
if (!id) {
return null;
}
return {
id,
x: item.x ?? 0,
y: item.y ?? 0,
width: item.width ?? 6,
height: item.height ?? 6,
panel: panels?.[id],
};
})
.filter((it): it is GridItemV2 => it !== null);
const title = spec?.display?.title;
// `open` defaults to true when no collapse field is set (the section
// is expanded by default).
const open = spec?.display?.collapse?.open !== false;
return {
id: getSectionStableId(items, idx),
layoutIndex: idx,
title,
open,
items,
repeatVariable: spec?.repeatVariable,
};
})
.filter((s): s is DashboardSectionV2 => s !== null);
}
export function getPanelKindLabel(
panel: DashboardtypesPanelDTO | undefined,
): string {
const kind = panel?.spec?.plugin?.kind;
if (!kind) {
return 'unknown';
}
return kind.replace(/^signoz\//, '');
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { CloudDownload } from '@signozhq/icons';
import { Button, Dropdown, MenuProps, Flex } from 'antd';
import { DropdownMenuSimple, type MenuProps } from '@signozhq/ui/dropdown-menu';
import { Button, Flex } from 'antd';
import { unparse } from 'papaparse';
import { DownloadProps } from './Download.types';
@@ -67,7 +68,7 @@ function Download({ data, isLoading, fileName }: DownloadProps): JSX.Element {
};
return (
<Dropdown menu={menu} trigger={['click']}>
<DropdownMenuSimple menu={menu}>
<Button
className="download-button"
loading={isLoading || isDownloading}
@@ -79,7 +80,7 @@ function Download({ data, isLoading, fileName }: DownloadProps): JSX.Element {
Download
</Flex>
</Button>
</Dropdown>
</DropdownMenuSimple>
);
}

View File

@@ -1,8 +1,4 @@
import {
Col,
Dropdown as DropDownComponent,
Input as InputComponent,
} from 'antd';
import { Col, Input as InputComponent } from 'antd';
import { Typography as TypographyComponent } from '@signozhq/ui/typography';
import styled from 'styled-components';
@@ -34,16 +30,6 @@ export const ButtonContainer = styled.div`
}
`;
export const Dropdown = styled(DropDownComponent)`
&&& {
display: flex;
justify-content: center;
align-items: center;
max-width: 150px;
min-width: 150px;
}
`;
export const TextContainer = styled.div`
&&& {
min-width: 100px;

View File

@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { AppProvider } from 'providers/App/App';
@@ -176,6 +177,7 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
describe('WidgetGraphComponent', () => {
it('should show correct menu items when hovering over more options while loading', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { getByTestId, findByRole, getByText, container } = render(
<MockQueryClientProvider>
<ErrorModalProvider>
@@ -208,7 +210,7 @@ describe('WidgetGraphComponent', () => {
expect(skeleton).toBeInTheDocument();
const moreOptionsButton = getByTestId('widget-header-options');
fireEvent.mouseEnter(moreOptionsButton);
await user.click(moreOptionsButton);
const menu = await findByRole('menu');
expect(menu).toBeInTheDocument();

View File

@@ -54,6 +54,17 @@
visibility: visible;
}
// currently the width of the dropdown menu is set to 100% of the parent container,
// which is not desired. This is a workaround to unset that width and allow the dropdown menu to size based on its content.
// This is necessary because the dropdown menu can contain items with varying widths, and setting it to 100% can cause layout issues and make the menu look unbalanced.
// we should idealy fix this in the dropdown menu component itself, but for now this is a quick fix to ensure the dropdown menu looks correct in the widget header.
[data-radix-popper-content-wrapper]
[data-slot='dropdown-menu-content'].widget-header-dropdown
[data-slot='dropdown-menu-item'] {
width: unset !important;
}
.widget-api-actions {
padding-right: 0.25rem;
}

View File

@@ -467,6 +467,7 @@ describe('WidgetHeader', () => {
describe('Create Alerts Menu Item', () => {
it('renders Create Alerts menu item with external link icon when included in headerMenuList', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<WidgetHeader
title={TEST_WIDGET_TITLE}
@@ -483,7 +484,7 @@ describe('WidgetHeader', () => {
const moreOptionsIcon = await screen.findByTestId(WIDGET_HEADER_OPTIONS_ID);
expect(moreOptionsIcon).toBeInTheDocument();
await userEvent.hover(moreOptionsIcon);
await user.click(moreOptionsIcon);
await screen.findByText(CREATE_ALERTS_TEXT);
@@ -494,6 +495,7 @@ describe('WidgetHeader', () => {
});
it('Create Alerts menu item is enabled and clickable', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockCreateAlertsHandler = jest.fn();
const useCreateAlerts = jest.requireMock(
'hooks/queryBuilder/useCreateAlerts',
@@ -517,12 +519,12 @@ describe('WidgetHeader', () => {
expect(useCreateAlerts).toHaveBeenCalledWith(mockWidget, 'dashboardView');
const moreOptionsIcon = await screen.findByTestId(WIDGET_HEADER_OPTIONS_ID);
await userEvent.hover(moreOptionsIcon);
await user.click(moreOptionsIcon);
const createAlertsMenuItem = await screen.findByText(CREATE_ALERTS_TEXT);
// Verify the menu item is clickable by actually clicking it
await userEvent.click(createAlertsMenuItem);
await user.click(createAlertsMenuItem);
expect(mockCreateAlertsHandler).toHaveBeenCalledTimes(1);
});
});

View File

@@ -15,7 +15,8 @@ import {
X,
} from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { Button, Dropdown, Input, MenuProps, Tooltip } from 'antd';
import { Button, Input, Tooltip } from 'antd';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import { Typography } from '@signozhq/ui/typography';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import ErrorPopover from 'components/ErrorPopover/ErrorPopover';
@@ -128,7 +129,7 @@ function WidgetHeader({
],
);
const onMenuItemSelectHandler: MenuProps['onClick'] = useCallback(
const onMenuItemSelectHandler = useCallback(
({ key }: { key: string }): void => {
if (isTWidgetOptions(key)) {
const functionToCall = keyMethodMapping[key];
@@ -188,18 +189,8 @@ function WidgetHeader({
{
key: MenuItemKeys.CreateAlerts,
icon: <Bell size="md" />,
label: (
<span
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
}}
>
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.CreateAlerts]}
<SquareArrowOutUpRight size={10} />
</span>
),
label: MENUITEM_KEYS_VS_LABELS[MenuItemKeys.CreateAlerts],
rightIcon: <SquareArrowOutUpRight size="lg" />,
isVisible: headerMenuList?.includes(MenuItemKeys.CreateAlerts) || false,
disabled: false,
},
@@ -221,8 +212,10 @@ function WidgetHeader({
const menu = useMemo(
() => ({
items: updatedMenuList,
onClick: onMenuItemSelectHandler,
items: updatedMenuList.map((item) => ({
...item,
onClick: onMenuItemSelectHandler,
})),
}),
[updatedMenuList, onMenuItemSelectHandler],
);
@@ -321,7 +314,12 @@ function WidgetHeader({
/>
)}
{menu && Array.isArray(menu.items) && menu.items.length > 0 && (
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
<DropdownMenuSimple
menu={menu}
side="bottom"
align="end"
className="widget-header-dropdown"
>
<Button
data-testid="widget-header-options"
className={`widget-header-more-options ${
@@ -329,7 +327,7 @@ function WidgetHeader({
}`}
icon={<EllipsisVertical size="md" />}
/>
</Dropdown>
</DropdownMenuSimple>
)}
</div>
</>

View File

@@ -6,6 +6,7 @@ export interface MenuItem {
key: MenuItemKeys;
icon: ReactNode;
label: ReactNode;
rightIcon?: ReactNode;
isVisible: boolean;
disabled: boolean;
danger?: boolean;

View File

@@ -1,9 +1,9 @@
import type { MenuItemType } from 'antd/es/menu/hooks/useItems';
import type { MenuItem as DropdownMenuItem } from '@signozhq/ui/dropdown-menu';
import { MenuItemKeys } from './contants';
import { MenuItem } from './types';
export const generateMenuList = (actions: MenuItem[]): MenuItemType[] =>
export const generateMenuList = (actions: MenuItem[]): DropdownMenuItem[] =>
actions
.filter((action: MenuItem) => action.isVisible)
.map(({ key, icon: Icon, label, disabled, ...rest }) => ({

View File

@@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next';
import { UseQueryResult } from 'react-query';
import { Button, Flex, Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Plus } from '@signozhq/icons';
import { Ellipsis, Plus } from '@signozhq/icons';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { ColumnsType } from 'antd/es/table/interface';
import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
@@ -15,7 +16,6 @@ import type {
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import { AxiosError } from 'axios';
import DropDown from 'components/DropDown/DropDown';
import {
DynamicColumnsKey,
TableDataSource,
@@ -323,55 +323,67 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
dataIndex: 'id',
key: 'action',
width: 10,
render: (id: RuletypesRuleDTO['id'], record): JSX.Element => (
<div data-testid="alert-actions">
<DropDown
onDropDownItemClick={(item): void =>
alertActionLogEvent(item.key, record)
render: (id: RuletypesRuleDTO['id'], record): JSX.Element => {
const actionItems = [
<ToggleAlertState
key="1"
disabled={record.disabled ?? false}
setData={setData}
id={id ?? ''}
/>,
<ColumnButton
key="2"
onClick={(e: React.MouseEvent): void =>
onEditHandler(record, { newTab: isModifierKeyPressed(e) })
}
element={[
<ToggleAlertState
key="1"
disabled={record.disabled ?? false}
setData={setData}
id={id ?? ''}
/>,
<ColumnButton
key="2"
onClick={(e: React.MouseEvent): void =>
onEditHandler(record, { newTab: isModifierKeyPressed(e) })
}
type="link"
loading={editLoader}
>
Edit
</ColumnButton>,
<ColumnButton
key="3-new-tab"
onClick={(): void => onEditHandler(record, { newTab: true })}
type="link"
loading={editLoader}
>
Edit in New Tab
</ColumnButton>,
<ColumnButton
key="3-clone"
onClick={onCloneHandler(record)}
type="link"
loading={cloneLoader}
>
Clone
</ColumnButton>,
<DeleteAlert
key="4"
notifications={notificationsApi}
setData={setData}
id={id ?? ''}
/>,
];
return (
<div data-testid="alert-actions">
<DropdownMenuSimple
menu={{
items: actionItems.map((element, index) => ({
key: String(index),
label: element,
onClick: ({ key }): void => alertActionLogEvent(key, record),
})),
}}
>
<Button
type="link"
loading={editLoader}
>
Edit
</ColumnButton>,
<ColumnButton
key="3"
onClick={(): void => onEditHandler(record, { newTab: true })}
type="link"
loading={editLoader}
>
Edit in New Tab
</ColumnButton>,
<ColumnButton
key="3"
onClick={onCloneHandler(record)}
type="link"
loading={cloneLoader}
>
Clone
</ColumnButton>,
<DeleteAlert
key="4"
notifications={notificationsApi}
setData={setData}
id={id ?? ''}
/>,
]}
/>
</div>
),
style={{ color: 'var(--l1-foreground)' }}
icon={<Ellipsis size={16} />}
/>
</DropdownMenuSimple>
</div>
);
},
});
}

View File

@@ -12,12 +12,11 @@ import { useTranslation } from 'react-i18next';
import { generatePath } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { Color } from '@signozhq/design-tokens';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import {
Button,
Dropdown,
Flex,
Input,
MenuProps,
Modal,
Popover,
Skeleton,
@@ -553,7 +552,7 @@ function DashboardsList(): JSX.Element {
];
const getCreateDashboardItems = useMemo(() => {
const menuItems: MenuProps['items'] = [
const menuItems: MenuItem[] = [
{
label: (
<div
@@ -711,11 +710,11 @@ function DashboardsList(): JSX.Element {
{createNewDashboard && (
<section className="actions">
<Dropdown
overlayClassName="new-dashboard-menu"
<DropdownMenuSimple
className="new-dashboard-menu"
menu={{ items: getCreateDashboardItems }}
placement="bottomRight"
trigger={['click']}
side="bottom"
align="end"
>
<Button
type="text"
@@ -727,7 +726,7 @@ function DashboardsList(): JSX.Element {
>
New Dashboard
</Button>
</Dropdown>
</DropdownMenuSimple>
<Button
type="text"
className="learn-more"
@@ -756,11 +755,11 @@ function DashboardsList(): JSX.Element {
onChange={handleSearch}
/>
{createNewDashboard && (
<Dropdown
overlayClassName="new-dashboard-menu"
<DropdownMenuSimple
className="new-dashboard-menu"
menu={{ items: getCreateDashboardItems }}
placement="bottomRight"
trigger={['click']}
side="bottom"
align="end"
>
<Button
type="primary"
@@ -773,7 +772,7 @@ function DashboardsList(): JSX.Element {
>
New dashboard
</Button>
</Dropdown>
</DropdownMenuSimple>
)}
</div>

View File

@@ -2,7 +2,13 @@ import { useCallback } from 'react';
import { useCopyToClipboard } from 'react-use';
import { orange } from '@ant-design/colors';
import { Settings } from '@signozhq/icons';
import { Dropdown, MenuProps } from 'antd';
import {
type BaseMenuItem,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@signozhq/ui/dropdown-menu';
import {
negateOperator,
OPERATORS,
@@ -135,41 +141,38 @@ function BodyTitleRenderer({
viewName,
]);
const onClickHandler: MenuProps['onClick'] = (props): void => {
const onClickHandler = (key: string): void => {
const mapper = {
[DROPDOWN_KEY.FILTER_IN]: filterHandler(true),
[DROPDOWN_KEY.FILTER_OUT]: filterHandler(false),
[DROPDOWN_KEY.GROUP_BY]: groupByHandler,
};
const handler = mapper[props.key];
const handler = mapper[key];
if (handler) {
handler();
}
};
const menu: MenuProps = {
items: [
{
key: DROPDOWN_KEY.FILTER_IN,
label: `Filter for ${value}`,
},
{
key: DROPDOWN_KEY.FILTER_OUT,
label: `Filter out ${value}`,
},
...(isGroupBySupported
? [
{
key: DROPDOWN_KEY.GROUP_BY,
label: `Group by ${nodeKey}`,
},
]
: []),
],
onClick: onClickHandler,
};
const menuItems: BaseMenuItem[] = [
{
key: DROPDOWN_KEY.FILTER_IN,
label: `Filter for ${value}`,
},
{
key: DROPDOWN_KEY.FILTER_OUT,
label: `Filter out ${value}`,
},
...(isGroupBySupported
? [
{
key: DROPDOWN_KEY.GROUP_BY,
label: `Group by ${nodeKey}`,
},
]
: []),
];
const handleNodeClick = useCallback(
(e: React.MouseEvent): void => {
@@ -218,15 +221,23 @@ function BodyTitleRenderer({
}}
onMouseDown={(e): void => e.preventDefault()}
>
<Dropdown
menu={menu}
trigger={['click']}
dropdownRender={(originNode): React.ReactNode => (
<div data-log-detail-ignore="true">{originNode}</div>
)}
>
<Settings style={{ marginRight: 8 }} className="hover-reveal" />
</Dropdown>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Settings style={{ marginRight: 8 }} className="hover-reveal" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<div data-log-detail-ignore="true">
{menuItems.map((item) => (
<DropdownMenuItem
key={item.key}
onSelect={(): void => onClickHandler(item.key as string)}
>
{item.label}
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
</span>
)}
{title.toString()}{' '}

View File

@@ -2,9 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { Check, ChevronDown, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { Input } from '@signozhq/ui/input';
import type { MenuProps } from 'antd';
import { Dropdown } from 'antd';
import { useListUsers } from 'api/generated/services/users';
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
@@ -96,7 +95,7 @@ function MembersSettings(): JSX.Element {
).length;
const totalCount = allMembers.length;
const filterMenuItems: MenuProps['items'] = [
const filterMenuItems: MenuItem[] = [
{
key: FilterMode.All,
label: (
@@ -172,10 +171,9 @@ function MembersSettings(): JSX.Element {
</div>
<div className="members-settings__controls">
<Dropdown
<DropdownMenuSimple
menu={{ items: filterMenuItems }}
trigger={['click']}
overlayClassName="members-filter-dropdown"
className="members-filter-dropdown"
>
<Button
variant="solid"
@@ -185,7 +183,7 @@ function MembersSettings(): JSX.Element {
<span>{filterLabel}</span>
<ChevronDown size={12} className="members-filter-trigger__chevron" />
</Button>
</Dropdown>
</DropdownMenuSimple>
<div className="members-settings__search">
<Input

View File

@@ -1,4 +1,5 @@
import type { TypesUserDTO } from 'api/generated/services/sigNoz.schemas';
import userEvent from '@testing-library/user-event';
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen } from 'tests/test-utils';
@@ -76,14 +77,15 @@ describe('MembersSettings (integration)', () => {
});
it('filters to pending invites via the filter dropdown', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MembersSettings />);
await screen.findByText('Alice Smith');
fireEvent.click(screen.getByRole('button', { name: /all members/i }));
await user.click(screen.getByRole('button', { name: /all members/i }));
const pendingOption = await screen.findByText(/pending invites/i);
fireEvent.click(pendingOption);
await user.click(pendingOption);
await screen.findByText('charlie@signoz.io');
expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument();

View File

@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { generatePath } from 'react-router-dom';
import { Color } from '@signozhq/design-tokens';
import { Dropdown, Skeleton } from 'antd';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import {
useGetMetricAlerts,
@@ -126,12 +127,11 @@ function DashboardsAndAlertsPopover({
return (
<div className="dashboards-and-alerts-popover-container">
{dashboardsPopoverContent && (
<Dropdown
<DropdownMenuSimple
menu={{
items: dashboardsPopoverContent,
}}
placement="bottomLeft"
trigger={['click']}
align="start"
>
<div
className="dashboards-and-alerts-popover dashboards-popover"
@@ -142,15 +142,14 @@ function DashboardsAndAlertsPopover({
{pluralize(dashboards.length, 'dashboard')}
</Typography.Text>
</div>
</Dropdown>
</DropdownMenuSimple>
)}
{alertsPopoverContent && (
<Dropdown
<DropdownMenuSimple
menu={{
items: alertsPopoverContent,
}}
placement="bottomLeft"
trigger={['click']}
align="start"
>
<div
className="dashboards-and-alerts-popover alerts-popover"
@@ -161,7 +160,7 @@ function DashboardsAndAlertsPopover({
{pluralize(alerts.length, 'alert rule')}
</Typography.Text>
</div>
</Dropdown>
</DropdownMenuSimple>
)}
</div>
);

View File

@@ -7,7 +7,12 @@ import {
DropResult,
} from 'react-beautiful-dnd';
import { Color } from '@signozhq/design-tokens';
import { Button, Divider, Dropdown, Input, MenuProps, Tooltip } from 'antd';
import { Button, Divider, Input, Tooltip } from 'antd';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@signozhq/ui/dropdown-menu';
import { Typography } from '@signozhq/ui/typography';
import { FieldDataType } from 'api/v5/v5';
import { SOMETHING_WENT_WRONG } from 'constants/api';
@@ -159,34 +164,12 @@ function ExplorerColumnsRenderer({
debouncedSetQuerySearchText(e.target.value);
};
const items: MenuProps['items'] = [
{
key: 'search',
label: (
<Input
type="text"
placeholder="Search"
className="explorer-columns-search"
value={searchText}
onChange={handleSearchChange}
prefix={<Search size={16} style={{ padding: '6px' }} />}
/>
),
},
{
key: 'columns',
label: (
<ExplorerAttributeColumns
isLoading={isLoading}
data={data}
searchText={searchText}
isAttributeKeySelected={isAttributeKeySelected}
handleCheckboxChange={handleCheckboxChange}
dataSource={initialDataSource}
/>
),
},
];
const handleOpenChange = (nextOpen: boolean): void => {
setOpen(nextOpen);
if (nextOpen) {
setSearchText('');
}
};
const removeSelectedLogField = (name: string): void => {
if (
@@ -238,13 +221,6 @@ function ExplorerColumnsRenderer({
}
};
const toggleDropdown = (): void => {
setOpen(!open);
if (!open) {
setSearchText('');
}
};
const isDarkMode = useIsDarkMode();
return (
@@ -327,25 +303,38 @@ function ExplorerColumnsRenderer({
</Droppable>
</DragDropContext>
<div>
<Dropdown
menu={{ items }}
arrow
placement="top"
open={open}
overlayClassName="explorer-columns-dropdown"
>
<Button
className="action-btn"
data-testid="add-columns-button"
icon={
<CirclePlus
size={16}
color={isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100}
/>
}
onClick={toggleDropdown}
/>
</Dropdown>
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button
className="action-btn"
data-testid="add-columns-button"
icon={
<CirclePlus
size={16}
color={isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100}
/>
}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="explorer-columns-dropdown">
<Input
type="text"
placeholder="Search"
className="explorer-columns-search"
value={searchText}
onChange={handleSearchChange}
prefix={<Search size={16} style={{ padding: '6px' }} />}
/>
<ExplorerAttributeColumns
isLoading={isLoading}
data={data}
searchText={searchText}
isAttributeKeySelected={isAttributeKeySelected}
handleCheckboxChange={handleCheckboxChange}
dataSource={initialDataSource}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}

View File

@@ -146,6 +146,7 @@ describe('ExplorerColumnsRenderer', () => {
});
it('opens and closes the dropdown', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Wrapper>
<ExplorerColumnsRenderer
@@ -158,12 +159,12 @@ describe('ExplorerColumnsRenderer', () => {
);
const addButton = screen.getByTestId('add-columns-button');
await userEvent.click(addButton);
await user.click(addButton);
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
expect(screen.getByText('attribute1')).toBeInTheDocument();
await userEvent.click(addButton);
await user.click(addButton);
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});

View File

@@ -13,7 +13,7 @@ import { Plus, Trash2 } from '@signozhq/icons';
import { ContextLinkProps, Widgets } from 'types/api/dashboard/getAll';
import { getBaseUrl } from 'utils/basePath';
import VariablesDropdown from './VariablesDropdown';
import VariablesPopover from './VariablesPopover';
import './UpdateContextLinks.styles.scss';
@@ -71,7 +71,7 @@ function UpdateContextLinks({
customVariables: fieldVariables,
});
// Transform variables into the format expected by VariablesDropdown
// Transform variables into the format expected by VariablesPopover
const transformedVariables = useMemo(
() => transformContextVariables(variables),
[variables],
@@ -224,7 +224,9 @@ function UpdateContextLinks({
},
]}
>
<VariablesDropdown
{/* TODO: replace with AutoComplete with options for variables and
previously used URLs for better UX */}
<VariablesPopover
onVariableSelect={handleVariableSelect}
variables={transformedVariables}
>
@@ -252,7 +254,7 @@ function UpdateContextLinks({
/>
</div>
)}
</VariablesDropdown>
</VariablesPopover>
</Form.Item>
{/* Remove the separate variables section */}
@@ -282,7 +284,7 @@ function UpdateContextLinks({
/>
</Col>
<Col span={16}>
<VariablesDropdown
<VariablesPopover
onVariableSelect={(variableName, cursorPosition): void =>
handleParamVariableSelect(index, variableName, cursorPosition)
}
@@ -311,7 +313,7 @@ function UpdateContextLinks({
}
/>
)}
</VariablesDropdown>
</VariablesPopover>
</Col>
<Col span={2}>
<Button

View File

@@ -1,26 +0,0 @@
.variables-dropdown-container {
.url-input-trigger {
width: 100%;
.url-input-field {
width: 100%;
}
}
// Override Ant Design dropdown styles
.ant-dropdown-menu {
max-height: 300px;
overflow-y: auto;
padding: 8px 0;
}
}
.variable-row {
display: flex;
justify-content: space-between;
.variable-source {
color: #666;
font-size: 12px;
}
}

View File

@@ -1,93 +0,0 @@
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { Dropdown } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import './VariablesDropdown.styles.scss';
interface VariablesDropdownProps {
onVariableSelect: (variableName: string, cursorPosition?: number) => void;
variables: VariableItem[];
children: (props: {
onVariableSelect: (variableName: string, cursorPosition?: number) => void;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
cursorPosition: number | null;
setCursorPosition: (position: number | null) => void;
}) => ReactNode;
}
interface VariableItem {
name: string;
source: string;
}
function VariablesDropdown({
onVariableSelect,
variables,
children,
}: VariablesDropdownProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
// Click outside handler
useEffect(() => {
function handleClickOutside(event: MouseEvent): void {
if (
wrapperRef.current &&
!wrapperRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return (): void => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const dropdownItems = useMemo(
() =>
variables.map((v) => ({
key: v.name,
label: (
<div className="variable-row">
<Typography.Text className="variable-name">{`{{${v.name}}}`}</Typography.Text>
<Typography.Text className="variable-source">{v.source}</Typography.Text>
</div>
),
})),
[variables],
);
return (
<div className="variables-dropdown-container" ref={wrapperRef}>
<Dropdown
menu={{
items: dropdownItems,
onClick: ({ key }): void => {
const variableName = key as string;
onVariableSelect(`{{${variableName}}}`, cursorPosition || undefined);
setIsOpen(false);
},
}}
open={isOpen}
placement="bottomLeft"
trigger={['click']}
getPopupContainer={(): HTMLElement => wrapperRef.current || document.body}
>
{children({
onVariableSelect,
isOpen,
setIsOpen,
cursorPosition,
setCursorPosition,
})}
</Dropdown>
</div>
);
}
export default VariablesDropdown;

View File

@@ -0,0 +1,74 @@
.variables-popover-container {
.url-input-trigger {
width: 100%;
.url-input-field {
width: 100%;
}
}
.variables-popover-anchor-wrap {
width: 100%;
}
}
.variables-popover-content {
// antd Modal uses z-index ~1000; popover must sit above it.
z-index: 1100;
padding: 4px 0;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
min-width: var(--radix-popover-trigger-width);
}
.variables-popover-empty {
padding: 8px 12px;
color: var(--l3-foreground, #999);
font-size: 12px;
font-style: italic;
}
.variables-popover-item {
all: unset;
display: block;
box-sizing: border-box;
width: 100%;
padding: 8px 12px;
cursor: pointer;
color: var(--l1-foreground);
font-size: 13px;
line-height: 1.4;
overflow: hidden;
&:hover,
&:focus {
background-color: var(--l1-background-hover);
}
}
.variable-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
.variable-name,
.variable-source {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.variable-name {
flex: 1 1 auto;
}
.variable-source {
color: #666;
font-size: 12px;
flex: 0 1 auto;
}
}

View File

@@ -0,0 +1,111 @@
// Uses Popover (not DropdownMenu like the rest of the antd-dropdown migration):
// DropdownMenuTrigger preventDefaults pointerdown, breaking input focus and
// dismissing on every keystroke. PopoverAnchor is a passive positioning element.
import { ReactNode, useRef, useState } from 'react';
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
import { Typography } from '@signozhq/ui/typography';
import './VariablesPopover.styles.scss';
interface VariablesPopoverProps {
onVariableSelect: (variableName: string, cursorPosition?: number) => void;
variables: VariableItem[];
children: (props: {
onVariableSelect: (variableName: string, cursorPosition?: number) => void;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
cursorPosition: number | null;
setCursorPosition: (position: number | null) => void;
}) => ReactNode;
}
interface VariableItem {
name: string;
source: string;
}
function VariablesPopover({
onVariableSelect,
variables,
children,
}: VariablesPopoverProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
const anchorRef = useRef<HTMLDivElement>(null);
const handleOpenChange = (open: boolean): void => {
// Accept "close" events from the popover (outside-click, Esc) but ignore
// opens — opening is driven by the input's onFocus in the consumer.
if (!open) {
setIsOpen(false);
}
};
return (
<div className="variables-popover-container">
<Popover open={isOpen} onOpenChange={handleOpenChange} modal={false}>
<PopoverAnchor asChild>
<div className="variables-popover-anchor-wrap" ref={anchorRef}>
{children({
onVariableSelect,
isOpen,
setIsOpen,
cursorPosition,
setCursorPosition,
})}
</div>
</PopoverAnchor>
<PopoverContent
align="start"
sideOffset={4}
className="variables-popover-content"
onOpenAutoFocus={(e): void => e.preventDefault()}
onCloseAutoFocus={(e): void => e.preventDefault()}
onInteractOutside={(e): void => {
// Keep the popover open while interacting with the anchor (the input),
// otherwise typing/clicking the input would close it immediately.
const target = e.target as Node | null;
if (target && anchorRef.current?.contains(target)) {
e.preventDefault();
}
}}
onFocusOutside={(e): void => {
const target = e.target as Node | null;
if (target && anchorRef.current?.contains(target)) {
e.preventDefault();
}
}}
>
{variables.length === 0 ? (
<div className="variables-popover-empty">No variables available</div>
) : (
variables.map((v) => (
<button
key={v.name}
type="button"
className="variables-popover-item"
onMouseDown={(e): void => {
// Prevent the input from losing focus when clicking an item.
e.preventDefault();
}}
onClick={(): void => {
onVariableSelect(`{{${v.name}}}`, cursorPosition || undefined);
setIsOpen(false);
}}
>
<div className="variable-row">
<Typography.Text className="variable-name">{`{{${v.name}}}`}</Typography.Text>
<Typography.Text className="variable-source">
{v.source}
</Typography.Text>
</div>
</button>
))
)}
</PopoverContent>
</Popover>
</div>
);
}
export default VariablesPopover;

View File

@@ -204,7 +204,7 @@ const processContextLinks = (
};
/**
* Transforms context variables into the format expected by VariablesDropdown
* Transforms context variables into the format expected by VariablesPopover
* @param variables - Array of context variables from useContextVariables
* @returns Array of transformed variables with proper source descriptions
*/

View File

@@ -1,6 +1,7 @@
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { ChevronDown } from '@signozhq/icons';
import { Button, ColorPicker, Dropdown, MenuProps, Space } from 'antd';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { Button, ColorPicker, Space } from 'antd';
import type { Color } from 'antd/es/color-picker';
import useDebounce from 'hooks/useDebounce';
@@ -26,7 +27,7 @@ function ColorSelector({
setColorFromPicker(hex);
};
const items: MenuProps['items'] = [
const items: MenuItem[] = [
{
key: 'Red',
label: <CustomColor color="Red" />,
@@ -62,7 +63,7 @@ function ColorSelector({
];
return (
<Dropdown menu={{ items }} trigger={['click']}>
<DropdownMenuSimple menu={{ items }}>
<Button
onClick={(e): void => e.preventDefault()}
className="color-selector-button"
@@ -72,7 +73,7 @@ function ColorSelector({
<ChevronDown size="md" />
</Space>
</Button>
</Dropdown>
</DropdownMenuSimple>
);
}

View File

@@ -1,9 +1,8 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Check, ChevronDown, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { Input } from '@signozhq/ui/input';
import type { MenuProps } from 'antd';
import { Dropdown } from 'antd';
import { useListServiceAccounts } from 'api/generated/services/serviceaccount';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
@@ -134,7 +133,7 @@ function ServiceAccountsSettings(): JSX.Element {
const totalCount = allAccounts.length;
const filterMenuItems: MenuProps['items'] = [
const filterMenuItems: MenuItem[] = [
{
key: FilterMode.All,
label: (
@@ -231,10 +230,9 @@ function ServiceAccountsSettings(): JSX.Element {
) : (
<div className="sa-settings__list-section">
<div className="sa-settings__controls">
<Dropdown
<DropdownMenuSimple
menu={{ items: filterMenuItems }}
trigger={['click']}
overlayClassName="sa-settings-filter-dropdown"
className="sa-settings-filter-dropdown"
>
<Button
variant="solid"
@@ -247,7 +245,7 @@ function ServiceAccountsSettings(): JSX.Element {
className="sa-settings-filter-trigger__chevron"
/>
</Button>
</Dropdown>
</DropdownMenuSimple>
<div className="sa-settings__search">
<Input

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import userEvent from '@testing-library/user-event';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
@@ -129,6 +130,7 @@ describe('ServiceAccountsSettings (integration)', () => {
});
it('filter dropdown to "Active" hides DISABLED accounts', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<NuqsTestingAdapter>
<ServiceAccountsSettings />
@@ -137,10 +139,10 @@ describe('ServiceAccountsSettings (integration)', () => {
await screen.findByText('CI Bot');
fireEvent.click(screen.getByRole('button', { name: /All accounts/i }));
await user.click(screen.getByRole('button', { name: /All accounts/i }));
const activeOption = await screen.findByText(/Active ⎯/i);
fireEvent.click(activeOption);
await user.click(activeOption);
await screen.findByText('CI Bot');
expect(screen.queryByText('Legacy Bot')).not.toBeInTheDocument();

View File

@@ -662,7 +662,7 @@
}
}
&:not(.pinned):hover,
&:not(.pinned).is-hovered,
&.dropdown-open {
flex: 0 0 240px;
max-width: 240px;

View File

@@ -1,5 +1,6 @@
import {
MouseEvent,
ReactNode,
useCallback,
useEffect,
useMemo,
@@ -25,7 +26,14 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button, Dropdown, MenuProps, Modal, Tooltip } from 'antd';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@signozhq/ui/dropdown-menu';
import { Button, MenuProps, Modal, Tooltip } from 'antd';
import logEvent from 'api/common/logEvent';
import { Logout } from 'api/utils';
import updateUserPreference from 'api/v1/user/preferences/name/update';
@@ -162,7 +170,9 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const [hasScroll, setHasScroll] = useState(false);
const navTopSectionRef = useRef<HTMLDivElement>(null);
const sidenavRef = useRef<HTMLDivElement>(null);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const isDropdownOpenRef = useRef(false);
const [isHovered, setIsHovered] = useState(false);
const [pinnedMenuItems, setPinnedMenuItems] = useState<SidebarItem[]>([]);
@@ -175,9 +185,27 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
}, []);
const handleMouseLeave = useCallback(() => {
// When the dropdown is open its content renders in a portal outside
// the sidenav, which causes the browser to fire mouseleave on the
// sidenav. Keep the sidenav expanded in that case.
if (isDropdownOpenRef.current) {
return;
}
setIsHovered(false);
}, []);
const handleDropdownOpenChange = useCallback((open: boolean): void => {
isDropdownOpenRef.current = open;
setIsDropdownOpen(open);
if (!open) {
// Re-sync hover state on close: the cursor may have moved to the
// portal content (outside .sideNav), so mouseleave never fired.
requestAnimationFrame(() => {
setIsHovered(sidenavRef.current?.matches(':hover') ?? false);
});
}
}, []);
const checkScroll = useCallback((): void => {
if (navTopSectionRef.current) {
const { scrollHeight, clientHeight, scrollTop } = navTopSectionRef.current;
@@ -959,9 +987,11 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
return (
<div className={cx('sidenav-container', isPinned && 'pinned')}>
<div
ref={sidenavRef}
className={cx(
'sideNav',
isPinned && 'pinned',
isHovered && 'is-hovered',
isDropdownOpen && 'dropdown-open',
)}
onMouseEnter={handleMouseEnter}
@@ -1182,46 +1212,95 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
{isAIAssistantEnabled && renderNavItems([aiAssistantMenuItem], false)}
<div className="nav-dropdown-item">
<Dropdown
menu={{
items: helpSupportDropdownMenuItems,
onClick: handleHelpSupportMenuItemClick,
}}
placement="topLeft"
overlayClassName="nav-dropdown-overlay help-support-dropdown"
trigger={['click']}
onOpenChange={(open): void => setIsDropdownOpen(open)}
>
<div className="nav-item">
<div className="nav-item-data" data-testid="help-support-nav-item">
<div className="nav-item-icon">{helpSupportMenuItem.icon}</div>
<DropdownMenu onOpenChange={handleDropdownOpenChange}>
<DropdownMenuTrigger asChild>
<div className="nav-item">
<div className="nav-item-data" data-testid="help-support-nav-item">
<div className="nav-item-icon">{helpSupportMenuItem.icon}</div>
<div className="nav-item-label">{helpSupportMenuItem.label}</div>
<div className="nav-item-label">{helpSupportMenuItem.label}</div>
</div>
</div>
</div>
</Dropdown>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
className="nav-dropdown-overlay help-support-dropdown"
>
{helpSupportDropdownMenuItems.map((item, idx) => {
if ('type' in item) {
// eslint-disable-next-line react/no-array-index-key
return <DropdownMenuSeparator key={`help-sep-${idx}`} />;
}
return (
<DropdownMenuItem
key={String(item.key)}
leftIcon={item.icon}
onClick={(e): void =>
handleHelpSupportMenuItemClick({
...item,
key: String(item.key),
domEvent: e.nativeEvent,
} as unknown as SidebarItem)
}
>
{item.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="nav-dropdown-item">
<Dropdown
menu={{
items: userSettingsDropdownMenuItems,
onClick: handleSettingsMenuItemClick,
}}
placement="topLeft"
overlayClassName="nav-dropdown-overlay settings-dropdown"
trigger={['click']}
onOpenChange={(open): void => setIsDropdownOpen(open)}
>
<div className={cx('nav-item', isSettingsPage && 'active')}>
<div className="nav-item-active-marker" />
<div className="nav-item-data" data-testid="settings-nav-item">
<div className="nav-item-icon">{userSettingsMenuItem.icon}</div>
<DropdownMenu onOpenChange={handleDropdownOpenChange}>
<DropdownMenuTrigger asChild>
<div className={cx('nav-item', isSettingsPage && 'active')}>
<div className="nav-item-active-marker" />
<div className="nav-item-data" data-testid="settings-nav-item">
<div className="nav-item-icon">{userSettingsMenuItem.icon}</div>
<div className="nav-item-label">{userSettingsMenuItem.label}</div>
<div className="nav-item-label">{userSettingsMenuItem.label}</div>
</div>
</div>
</div>
</Dropdown>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
className="nav-dropdown-overlay settings-dropdown"
>
{(userSettingsDropdownMenuItems ?? []).map((item, idx) => {
if (!item) {
return null;
}
if ('type' in item && item.type === 'divider') {
// eslint-disable-next-line react/no-array-index-key
return <DropdownMenuSeparator key={`settings-sep-${idx}`} />;
}
const settingsItem = item as {
key?: string | number;
label?: ReactNode;
icon?: ReactNode;
disabled?: boolean;
};
return (
<DropdownMenuItem
key={String(settingsItem.key)}
leftIcon={settingsItem.icon}
disabled={settingsItem.disabled}
onClick={(e): void =>
handleSettingsMenuItemClick({
key: String(settingsItem.key),
domEvent: e.nativeEvent,
} as unknown as SidebarItem)
}
>
{settingsItem.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>

View File

@@ -8,8 +8,10 @@
border-color: var(--l1-border);
margin: 0;
}
.dropdown-icon {
margin-right: 4px;
.dropdown-trigger-wrapper {
display: flex;
justify-content: center;
align-items: center;
}
}
.dropdown-menu {

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Divider, Dropdown, MenuProps, Switch, Tooltip } from 'antd';
import { Button, Divider, Switch, Tooltip } from 'antd';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Copy, Ellipsis, PenLine, Trash2 } from '@signozhq/icons';
import {
@@ -11,7 +12,6 @@ import {
} from 'pages/AlertDetails/hooks';
import CopyToClipboard from 'periscope/components/CopyToClipboard';
import { useAlertRule } from 'providers/Alert';
import { CSSProperties } from 'styled-components';
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
import { AlertDef } from 'types/api/alerts/def';
@@ -20,16 +20,6 @@ import RenameModal from './RenameModal';
import './ActionButtons.styles.scss';
const menuItemStyle: CSSProperties = {
fontSize: '14px',
letterSpacing: '0.14px',
};
const menuItemStyleV2: CSSProperties = {
fontSize: '13px',
letterSpacing: '0.13px',
};
function AlertActionButtons({
ruleId,
alertDetails,
@@ -68,9 +58,7 @@ function AlertActionButtons({
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
const finalMenuItemStyle = isV2Alert ? menuItemStyleV2 : menuItemStyle;
const menuItems: MenuProps['items'] = [
const menuItems: MenuItem[] = [
...(!isV2Alert
? [
{
@@ -78,7 +66,6 @@ function AlertActionButtons({
label: 'Rename',
icon: <PenLine size={16} color={Color.BG_VANILLA_400} />,
onClick: handleRename,
style: finalMenuItemStyle,
},
]
: []),
@@ -87,17 +74,13 @@ function AlertActionButtons({
label: 'Duplicate',
icon: <Copy size={16} color={Color.BG_VANILLA_400} />,
onClick: handleAlertDuplicate,
style: finalMenuItemStyle,
},
{
key: 'delete-rule',
label: 'Delete',
icon: <Trash2 size={16} color={Color.BG_CHERRY_400} />,
onClick: handleAlertDelete,
style: {
...finalMenuItemStyle,
color: Color.BG_CHERRY_400,
},
danger: true,
},
];
@@ -138,16 +121,21 @@ function AlertActionButtons({
<Divider type="vertical" />
<Dropdown trigger={['click']} menu={{ items: menuItems }}>
<Tooltip title="More options">
<Ellipsis
size={16}
color={isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}
cursor="pointer"
className="dropdown-icon"
/>
</Tooltip>
</Dropdown>
<DropdownMenuSimple menu={{ items: menuItems }}>
<span className="dropdown-trigger-wrapper">
<Tooltip title="More options">
<Button
type="text"
icon={
<Ellipsis
size={16}
color={isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}
/>
}
/>
</Tooltip>
</span>
</DropdownMenuSimple>
</div>
<RenameModal

View File

@@ -1,47 +0,0 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
import Spinner from 'components/Spinner';
import DashboardContainerV2 from 'container/DashboardContainerV2';
function DashboardPageV2(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
const { data, isLoading, isError, error, refetch } = useGetDashboardV2({
id: dashboardId,
});
const dashboard = data?.data;
const name = dashboard?.spec?.display?.name;
useEffect(() => {
if (name) {
document.title = name;
}
}, [name]);
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;
}
if (isError) {
return (
<div style={{ padding: 24 }}>
<Typography.Title>Failed to load dashboard</Typography.Title>
<Typography.Text>{(error as Error)?.message}</Typography.Text>
</div>
);
}
return (
<DashboardContainerV2
dashboard={dashboard}
onRefetch={(): void => {
refetch();
}}
/>
);
}
export default DashboardPageV2;

View File

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

View File

@@ -1,14 +1,6 @@
import { useMemo, useState } from 'react';
import {
Button,
Divider,
Dropdown,
Form,
MenuProps,
Space,
Switch,
Tooltip,
} from 'antd';
import { Button, Divider, Form, Space, Switch, Tooltip } from 'antd';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import cx from 'classnames';
import { FilterSelect } from 'components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions';
import { QueryParams } from 'constants/query';
@@ -44,16 +36,22 @@ function FunnelStep({
const [isAddDetailsModalOpen, setIsAddDetailsModalOpen] =
useState<boolean>(false);
const latencyPointerItems: MenuProps['items'] = LatencyPointers.map(
(option) => ({
key: option.value,
label: option.key,
style:
option.value === stepData.latency_pointer
? { backgroundColor: 'var(--bg-slate-100)' }
: {},
}),
);
const latencyPointerItems: MenuItem[] = [
{
type: 'radio-group',
value: stepData.latency_pointer,
onChange: (value): void =>
onStepChange(index, {
latency_pointer: value as FunnelStepData['latency_pointer'],
}),
children: LatencyPointers.map((option) => ({
type: 'radio',
key: option.value,
label: option.key,
value: option.value,
})),
},
];
const updatedCurrentQuery = useMemo(
() => ({
@@ -212,17 +210,18 @@ function FunnelStep({
</div>
<div className="latency-pointer">
<div className="latency-pointer__label">Latency pointer</div>
<Dropdown
menu={{
items: latencyPointerItems,
onClick: ({ key }): void =>
onStepChange(index, {
latency_pointer: key as FunnelStepData['latency_pointer'],
}),
}}
trigger={['click']}
disabled={!hasEditPermission}
>
{hasEditPermission ? (
<DropdownMenuSimple menu={{ items: latencyPointerItems }}>
<Space>
{
LatencyPointers.find(
(option) => option.value === stepData.latency_pointer,
)?.key
}
<ChevronDown size={14} color="var(--bg-vanilla-400)" />
</Space>
</DropdownMenuSimple>
) : (
<Space>
{
LatencyPointers.find(
@@ -231,7 +230,7 @@ function FunnelStep({
}
<ChevronDown size={14} color="var(--bg-vanilla-400)" />
</Space>
</Dropdown>
)}
</div>
</div>
</Form>

239
migration-v2.md Normal file
View File

@@ -0,0 +1,239 @@
in container/GeneralSettings/styles.ts.
npx tsc --noEmit passes clean; oxlint shows 0 errors on the 8 changed files (only pre-existing warnings).
Note: a few SCSS selectors targeting .ant-dropdown-menu (e.g., in MembersSettings.styles.scss, ServiceAccountsSettings.styles.scss)
are now dead since the rendered DOM is radix-based — left as-is, matching the convention from your earlier migration commits.
Worth a follow-up SCSS cleanup pass if you want.
✻ Sautéed for 15m 34s
can you make a list ? of components you have migrated also the list of these components ?
⏺ Migrated components — antd Dropdown → @signozhq/ui/dropdown-menu
Files I migrated in this session
┌─────┬────────────────────────────────────────────────────────────────────────────┬────────────────────────────────────────────┐
│ # │ File │ Approach │
├─────┼────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────┤
│ 1 │ src/components/TimePreferenceDropDown/index.tsx │ DropdownMenuSimple (top-level onClick │
│ │ │ lifted onto each item) │
├─────┼────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────┤
│ │ │ DropdownMenuSimple (×2 — dashboards + │
│ 2 │ src/container/MetricsExplorer/MetricDetails/DashboardsAndAlertsPopover.tsx │ alerts), placement="bottomLeft" → │
│ │ │ align="start" │
├─────┼────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────┤
│ 3 │ src/container/MembersSettings/MembersSettings.tsx │ DropdownMenuSimple + MenuItem type, │
│ │ │ overlayClassName → className │
├─────┼────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────┤
│ 4 │ src/container/ServiceAccountsSettings/ServiceAccountsSettings.tsx │ DropdownMenuSimple + MenuItem type │
├─────┼────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────┤
│ 5 │ src/components/QueryBuilderV2/QueryV2/QueryV2.tsx │ DropdownMenuSimple, │
│ │ │ placement="bottomRight" → align="end" │
├─────┼────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────┤
│ │ │ Composable API (DropdownMenu + Trigger + │
│ 6 │ src/container/NewWidget/RightContainer/ContextLinks/VariablesDropdown.tsx │ Content + Item) — controlled open + │
│ │ │ modal={false} │
├─────┼────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────┤
│ │ │ Composable API — replaced dropdownRender │
│ 7 │ src/container/LogDetailedView/BodyTitleRenderer.tsx │ with inner <div │
│ │ │ data-log-detail-ignore="true"> wrapper │
├─────┼────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────┤
│ 8 │ src/container/CustomDomainSettings/CustomDomainSettings.tsx │ Composable API — dropdownRender replaced │
│ │ │ by raw JSX inside DropdownMenuContent │
├─────┼────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────┤
│ 9 │ src/container/GeneralSettings/styles.ts │ Removed dead styled(Dropdown) export + │
│ │ │ antd import │
└─────┴────────────────────────────────────────────────────────────────────────────┴────────────────────────────────────────────┘
Files that were already migrated (before this session)
┌─────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────┐
│ File │ Migrated in │
├─────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ src/components/DropDown/DropDown.tsx │ chore: migrate dropdown │
├─────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ src/components/ExplorerCard/ExplorerCard.tsx │ chore: migrate dropdown │
├─────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ src/components/ResizeTable/DynamicColumnTable.tsx │ chore: migrate dropdown │
├─────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ src/container/Download/Download.tsx │ chore: migrate dropdown │
├─────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ src/container/GridCardLayout/WidgetHeader/index.tsx (+ utils.ts) │ chore: migrate dropdown │
├─────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ src/container/ListOfDashboard/DashboardsList.tsx │ chore: migrate dropdown │
├─────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ src/container/NewWidget/LeftContainer/ExplorerColumnsRenderer.tsx │ chore: migrate dropdown │
├─────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ src/container/NewWidget/RightContainer/Threshold/ColorSelector.tsx │ chore: migrate dropdown │
├─────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ src/container/SideNav/SideNav.tsx │ chore: migrate dropdown + fix: side nav issue │
├─────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx │ chore: migrate dropdown │
├─────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ src/pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelStep.tsx │ chore: migrate dropdown │
├─────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ src/container/AIAssistant/components/ConversationItem/ConversationItem.tsx │ prior commit │
├─────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ src/pages/TraceDetailsV3/TraceDetailsHeader/TraceOptionsMenu.tsx │ prior commit │
├─────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ src/periscope/components/PrettyView/PrettyView.tsx │ prior commit │
├─────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┤
│ src/periscope/components/DataViewer/DataViewer.tsx │ prior commit │
└─────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────┘
Files intentionally NOT touched
┌──────────────────────────────────────────────────────────────────────────────┬────────────────────────────────────────────────┐
│ File │ Reason │
├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤
│ src/container/GeneralSettings/__tests__/GeneralSettings.test.tsx │ Test file — out of scope │
├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤
│ src/components/QuickFilters/tests/QuickFilters.test.tsx │ Test file │
├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤
│ src/components/NewSelect/__test__/*.test.tsx (4 files) │ Test files │
├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤
│ │ False positive — "Dropdown" only in │
│ src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.tsx │ comments/var names; imports AutoComplete, Spin │
│ │ from antd │
├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤
│ src/components/NewSelect/CustomSelect.tsx │ False positive — "Dropdown" only in handler │
│ │ names; imports Select from antd │
├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤
│ │ Configures antd's theme token for Dropdown │
│ src/hooks/useDarkMode/index.tsx │ component — needed as long as any antd │
│ │ component still themes against it; not an │
│ │ instance of <Dropdown> │
└──────────────────────────────────────────────────────────────────────────────┴────────────────────────────────────────────────┘
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
what i wanted it
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
⏵⏵ accept edits on (shift+tab to cycle) · PR #11376 Update available! Run: brew upgrade claude-code
Files intentionally NOT touched
┌──────────────────────────────────────────────────────────────────────────────┬────────────────────────────────────────────────┐
│ File │ Reason │
├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤
│ src/container/GeneralSettings/__tests__/GeneralSettings.test.tsx │ Test file — out of scope │
├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤
│ src/components/QuickFilters/tests/QuickFilters.test.tsx │ Test file │
├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤
│ src/components/NewSelect/__test__/*.test.tsx (4 files) │ Test files │
├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤
│ │ False positive — "Dropdown" only in │
│ src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.tsx │ comments/var names; imports AutoComplete, Spin │
│ │ from antd │
├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤
│ src/components/NewSelect/CustomSelect.tsx │ False positive — "Dropdown" only in handler │
│ │ names; imports Select from antd │
├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤
│ │ Configures antd's theme token for Dropdown │
│ src/hooks/useDarkMode/index.tsx │ component — needed as long as any antd │
│ │ component still themes against it; not an │
│ │ instance of <Dropdown> │
└──────────────────────────────────────────────────────────────────────────────┴────────────────────────────────────────────────┘
After this session — production source has zero remaining <Dropdown> from antd. The only references left are aliases of
DropdownMenuSimple as Dropdown and unrelated identifiers (variable names, comments, theme tokens).
what i wanted is to give me a route , breakdown what components you have migrate just now and how can i verify them ?
Searched for 16 patterns, read 1 file (ctrl+o to expand)
⏺ I have enough info. Here's the verification guide:
Migration verification guide
How to test each one
Run the dev server (pnpm dev), navigate to each route, click the trigger element, and verify:
- Menu opens with the same items as before
- Clicking an item fires the right action and closes the menu
- Clicking outside closes the menu
- Pressing Esc closes the menu
- No console errors
---
1. TimePreferenceDropDown
Route: /dashboard/<any-dashboardId>/<any-widgetId> (edit/create widget flow)
Where to find it: New/Edit widget page → right panel → "Visualization settings" → time-selection button (Globe icon + dropdown).
Also appears in Full View of any dashboard panel.
Verify: Click the globe-prefixed button → menu of time preferences opens → pick one → button label updates.
---
2. DashboardsAndAlertsPopover
Route: /metrics-explorer/summary → click any metric row to open Metric Details
Verify: In the details header, click the small "N dashboard(s)" or "N alert rule(s)" pill → dropdown lists linked dashboards /
alerts → clicking a link opens it in a new tab.
---
3. MembersSettings
Route: /settings/members
Verify: Top-left "All members ⎯ N" button → dropdown opens with three filter rows (All / Pending invites / Deleted) → selecting one
filters the table and the check-mark moves.
---
4. ServiceAccountsSettings
Route: /settings/service-accounts
Verify: Same filter dropdown pattern as #3 → opens filter menu → clicking filters the SA list.
---
5. QueryV2 (Query Builder V2 actions dropdown)
Route: /logs/logs-explorer, /traces-explorer, /metrics-explorer/explorer, /meter/explorer — anywhere you see the new query builder.
Verify: Add 2+ queries → on each query row click the ⋯ (ellipsis) icon at the right of the query header → menu with "Clone" and (if
>1 query) "Delete" appears with icons → clicking each performs the action.
---
6. VariablesDropdown
Route: /dashboard/<dashboardId>/<widgetId> → right panel → Context Links section
Verify: In any URL/value input, focus the input → dropdown of {{variable}} suggestions opens → click a suggestion → the variable is
inserted at the cursor and the dropdown closes. Outside-click and Esc close the dropdown.
▎ ⚠️ Behavior note for this one: radix's trigger toggles on click. So clicking an already-open input may close it (different from
▎ antd, where it was driven purely by focus). Focus-to-open and item-select still work.
---
7. BodyTitleRenderer
Route: /logs/logs-explorer → run any query that returns logs with JSON body → click a log row to open the Log Detail drawer →
expand the Body tree
Verify: Hover any field name → a gear icon appears → click it → menu shows "Filter for …", "Filter out …", and (if JSON body flag
enabled and key isn't an array) "Group by …" → clicking applies the filter. Most importantly, the log detail drawer should NOT
close when interacting with the dropdown (that's what the data-log-detail-ignore attribute protects).
---
8. CustomDomainSettings
Route: /settings (General settings — visible only to cloud admins)
Verify: Top card shows your workspace URL with a chevron-down button → click it → "All Workspace URLs" dropdown opens with the
active host marked by a check and others showing an external-link icon → links open in a new tab.
---
9. GeneralSettings/styles.ts (dead-code removal)
Route: N/A — no UI behavior. Just an unused styled(Dropdown) export removed.
Verify: pnpm build succeeds and /settings still renders.
---
Quick smoke command
pnpm dev
# then visit, in order:
# /settings/members
# /settings/service-accounts
# /settings (cloud admin)
# /metrics-explorer/summary → open any metric
# /logs/logs-explorer → open a log, expand body, hover a field
# /logs/logs-explorer → add 2 queries, click ⋯
# /dashboard/<id>/<widgetId> → context links + visualization time picker
If all 8 dropdowns open, items fire, and outside-click/Esc close them, the migration is good.