Compare commits

..

3 Commits

Author SHA1 Message Date
Abhi Kumar
30e5b68e43 fix(dashboards-v2): refine New Panel modal footer + section labels
- Keep the footer visible at all times; the "Add Panel" confirm is disabled
  until a panel type is selected (instead of hiding the whole footer).
- Footer layout: the section selector fills the available width and the
  confirm button takes its natural width (no more 50/50 split).
- Add a "Select panel type" label above the panel-type grid, matching the
  existing "Add panel to" label.
2026-07-02 20:11:41 +05:30
Abhi Kumar
093eda7deb feat(dashboards-v2): choose target section when adding a panel
Add a section picker to the New Panel modal so a panel can be placed in
any section (or the untitled root), not just the one the "Add panel"
action was triggered from. Sections are read from the cached dashboard
query via useDashboardSections (no refetch, no prop-drilling).

Picking a panel type now selects it (highlighted card) and reveals a
footer holding the section dropdown and an "Add Panel" button split
50/50; confirming shows a brief loader before navigating to the editor.
The chosen section's layoutIndex is threaded through
useCreatePanel.createPanel.
2026-07-02 17:23:05 +05:30
Abhi Kumar
77f7a2cceb fix: ui fixes for panel selection modal 2026-07-02 14:30:25 +05:30
19 changed files with 451 additions and 197 deletions

View File

@@ -52,8 +52,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const { patchAsync } = useOptimisticPatch();
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const {
isPickerOpen,
openPicker,
closePicker,
createPanel,
targetLayoutIndex,
} = useCreatePanel();
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
@@ -153,6 +158,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
open={isPickerOpen}
onClose={closePicker}
onSelect={createPanel}
defaultLayoutIndex={targetLayoutIndex}
/>
</section>
);

View File

@@ -1,22 +1,69 @@
.panelTypeSection {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.grid {
align-self: stretch;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.typeButton {
.panelTypeCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
padding: 12px;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--l1-border);
gap: 12px;
cursor: pointer;
font: inherit;
border-radius: 4px;
color: var(--l1-foreground);
cursor: pointer;
text-align: left;
transition:
transform 180ms ease,
border-color 180ms ease;
&:hover {
border-color: var(--bg-robin-500);
background-color: var(--l2-background-hover);
border-color: var(--bg-robin-400);
}
&:active {
transform: translateY(2px);
}
}
.panelTypeCardSelected {
border-color: var(--bg-robin-400);
background-color: var(--l2-background-hover);
box-shadow: inset 0 0 0 1px var(--bg-robin-400);
}
.footerActions {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 8px;
width: 100%;
}
.footerPicker {
// Take all the width left over by the (natural-width) confirm button.
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.pickerLabel {
color: var(--l3-foreground);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
}

View File

@@ -1,45 +1,135 @@
import { Modal } from 'antd';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import cx from 'classnames';
import { useDashboardSections } from '../../../hooks/useDashboardSections';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from './constants';
import SectionPicker from './SectionPicker';
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
import styles from './PanelTypeSelectionModal.module.scss';
import { Plus } from '@signozhq/icons';
interface PanelTypeSelectionModalProps {
open: boolean;
onClose: () => void;
onSelect: (panelKind: PanelKind) => void;
onSelect: (panelKind: PanelKind, layoutIndex?: number) => void;
/** Section the picker opens on; omit → the untitled root / first section. */
defaultLayoutIndex?: number;
}
/** Fake loader shown on the confirm button before navigating to the editor. */
const CONFIRM_LOADER_MS = 500;
function PanelTypeSelectionModal({
open,
onClose,
onSelect,
defaultLayoutIndex,
}: PanelTypeSelectionModalProps): JSX.Element {
const sections = useDashboardSections();
const options = useMemo(() => buildSectionOptions(sections), [sections]);
const hasSectionPicker = options.length > 1;
const [selectedValue, setSelectedValue] = useState('');
const [selectedPanelKind, setSelectedPanelKind] = useState<PanelKind | null>(
null,
);
const [isSubmitting, setIsSubmitting] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = useCallback((): void => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
// Seed the target section on open; cancel a pending navigation on close.
useEffect(() => {
if (open) {
setSelectedValue(resolveDefaultSectionValue(options, defaultLayoutIndex));
setSelectedPanelKind(null);
setIsSubmitting(false);
} else {
clearTimer();
}
}, [open, options, defaultLayoutIndex, clearTimer]);
useEffect(() => clearTimer, [clearTimer]);
const handleConfirm = (): void => {
if (selectedPanelKind === null || isSubmitting) {
return;
}
setIsSubmitting(true);
const layoutIndex = selectedValue === '' ? undefined : Number(selectedValue);
timerRef.current = setTimeout(() => {
onSelect(selectedPanelKind, layoutIndex);
}, CONFIRM_LOADER_MS);
};
// Footer is always shown; the confirm button is disabled until a panel type is picked.
const footer = (
<div className={styles.footerActions}>
{hasSectionPicker && (
<div className={styles.footerPicker}>
<span className={styles.pickerLabel}>Add panel to</span>
<SectionPicker
options={options}
value={selectedValue}
onChange={setSelectedValue}
/>
</div>
)}
<Button
color="primary"
size="md"
disabled={selectedPanelKind === null}
loading={isSubmitting}
prefix={<Plus size={16} />}
onClick={handleConfirm}
testId="panel-type-confirm"
>
Add Panel
</Button>
</div>
);
return (
<Modal
<DialogWrapper
open={open}
title="Select a panel type"
onCancel={onClose}
footer={null}
destroyOnClose
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="New Panel"
footer={footer}
>
<div className={styles.grid}>
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
<Button
key={panelKind}
type="button"
variant="ghost"
className={styles.typeButton}
data-testid={`panel-type-${panelKind}`}
onClick={(): void => onSelect(panelKind)}
>
<Icon size={14} />
{label}
</Button>
))}
<div className={styles.panelTypeSection}>
<span className={styles.pickerLabel}>Select panel type</span>
<div className={styles.grid}>
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
<button
key={panelKind}
type="button"
className={cx(styles.panelTypeCard, {
[styles.panelTypeCardSelected]: panelKind === selectedPanelKind,
})}
data-testid={`panel-type-${panelKind}`}
aria-pressed={panelKind === selectedPanelKind}
onClick={(): void => setSelectedPanelKind(panelKind)}
>
<Icon size={24} color={Color.BG_ROBIN_400} />
{label}
</button>
))}
</div>
</div>
</Modal>
</DialogWrapper>
);
}

View File

@@ -0,0 +1,55 @@
.select {
width: 100%;
}
.dropdown {
min-width: 260px;
}
.rootIcon {
color: var(--bg-robin-400);
flex-shrink: 0;
}
.sectionIcon {
color: var(--l3-foreground);
flex-shrink: 0;
}
.triggerValue {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.triggerLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.optionRow {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0;
}
.optionText {
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.optionLabel {
color: var(--l1-foreground);
line-height: 1.2;
}
.optionDescription {
color: var(--l3-foreground);
font-size: 12px;
line-height: 1.2;
}

View File

@@ -0,0 +1,65 @@
import { useMemo } from 'react';
// eslint-disable-next-line signoz/no-antd-components
import { Select } from 'antd';
import type { SectionOption } from './types';
import styles from './SectionPicker.module.scss';
interface SectionPickerProps {
options: SectionOption[];
value: string;
onChange: (value: string) => void;
}
function SectionPicker({
options,
value,
onChange,
}: SectionPickerProps): JSX.Element {
// `selectedLabel` (one line) shows in the trigger; `label` (two lines) in the list.
const selectOptions = useMemo(
() =>
options.map((option) => {
const iconClass = option.isRoot ? styles.rootIcon : styles.sectionIcon;
return {
value: option.value,
selectedLabel: (
<span className={styles.triggerValue}>
<option.Icon size={16} className={iconClass} />
<span className={styles.triggerLabel}>{option.label}</span>
</span>
),
label: (
<span
className={styles.optionRow}
data-testid={`panel-section-option-${option.layoutIndex}`}
>
<option.Icon size={16} className={iconClass} />
<span className={styles.optionText}>
<span className={styles.optionLabel}>{option.label}</span>
<span className={styles.optionDescription}>{option.description}</span>
</span>
</span>
),
};
}),
[options],
);
return (
<Select<string>
className={styles.select}
popupClassName={styles.dropdown}
value={value}
onChange={onChange}
data-testid="panel-section-select"
optionLabelProp="selectedLabel"
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement ?? document.body
}
options={selectOptions}
/>
);
}
export default SectionPicker;

View File

@@ -14,3 +14,16 @@ export interface PanelType {
/** Icon component — the consumer renders it and controls size/color/etc. */
Icon: ComponentType<IconProps>;
}
export interface SectionOption {
/** The section's `layoutIndex`, stringified for the Select value. */
value: string;
layoutIndex: number;
/** Section title, or "Dashboard (root)" for the untitled top-level layout. */
label: string;
/** Caption under the label. */
description: string;
/** Untitled top-level layout (has no section header). */
isRoot: boolean;
Icon: ComponentType<IconProps>;
}

View File

@@ -0,0 +1,41 @@
import { LayoutDashboard, Rows2 } from '@signozhq/icons';
import type { DashboardSection } from '../../../utils';
import type { SectionOption } from './types';
const ROOT_LABEL = 'Dashboard (root)';
const ROOT_DESCRIPTION = 'Top level — no section';
const SECTION_DESCRIPTION = 'Section';
/** Maps dashboard sections to section-picker options (untitled → "root"). */
export function buildSectionOptions(
sections: DashboardSection[],
): SectionOption[] {
return sections.map((section) => {
const isRoot = !section.title;
return {
value: String(section.layoutIndex),
layoutIndex: section.layoutIndex,
label: isRoot ? ROOT_LABEL : (section.title as string),
description: isRoot ? ROOT_DESCRIPTION : SECTION_DESCRIPTION,
isRoot,
Icon: isRoot ? LayoutDashboard : Rows2,
};
});
}
/**
* Picks the option the picker should open on: the section the "Add panel" was
* triggered from when present and still valid, otherwise the first option.
*/
export function resolveDefaultSectionValue(
options: SectionOption[],
defaultLayoutIndex: number | undefined,
): string {
const fallback = options[0]?.value ?? '';
if (defaultLayoutIndex === undefined) {
return fallback;
}
const target = String(defaultLayoutIndex);
return options.some((option) => option.value === target) ? target : fallback;
}

View File

@@ -29,8 +29,13 @@ interface SectionProps {
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const {
isPickerOpen,
openPicker,
closePicker,
createPanel,
targetLayoutIndex,
} = useCreatePanel();
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
@@ -141,6 +146,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
open={isPickerOpen}
onClose={closePicker}
onSelect={createPanel}
defaultLayoutIndex={targetLayoutIndex}
/>
<ConfirmDeleteDialog
open={isDeleteOpen}

View File

@@ -12,7 +12,10 @@ interface UseCreatePanelResult {
/** Pass the target section's layout index; omit → last/new section. */
openPicker: (layoutIndex?: number) => void;
closePicker: () => void;
createPanel: (panelKind: PanelKind) => void;
/** The section the picker was opened against — seeds its section dropdown. */
targetLayoutIndex: number | undefined;
/** `layoutIndex` overrides the opened-against target (the dropdown's choice). */
createPanel: (panelKind: PanelKind, layoutIndex?: number) => void;
}
/**
@@ -38,16 +41,23 @@ export function useCreatePanel(): UseCreatePanelResult {
}, []);
const createPanel = useCallback(
(panelKind: PanelKind): void => {
(panelKind: PanelKind, targetIndex?: number): void => {
setIsPickerOpen(false);
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId: NEW_PANEL_ID,
});
safeNavigate(`${path}${newPanelSearch(panelKind, layoutIndex)}`);
const target = targetIndex ?? layoutIndex;
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
},
[safeNavigate, dashboardId, layoutIndex],
);
return { isPickerOpen, openPicker, closePicker, createPanel };
return {
isPickerOpen,
openPicker,
closePicker,
targetLayoutIndex: layoutIndex,
createPanel,
};
}

View File

@@ -0,0 +1,21 @@
import { useMemo } from 'react';
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
import { useDashboardStore } from '../store/useDashboardStore';
import { type DashboardSection, layoutsToSections } from '../utils';
/**
* The current dashboard's sections, read from the already-loaded dashboard
* query. The page fetches via useGetDashboardV2 keyed by id, so this reuses that
* cache (no extra request) instead of prop-drilling the section list.
*/
export function useDashboardSections(): DashboardSection[] {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { data } = useGetDashboardV2({ id: dashboardId });
const spec = data?.data?.spec;
return useMemo(
() => layoutsToSections(spec?.layouts, spec?.panels),
[spec?.layouts, spec?.panels],
);
}

View File

@@ -60,21 +60,6 @@
}
}
// The Badge is already pill-rounded, so an 18x18 box renders a circle for a
// single digit and a capsule for more.
.eventsBadge {
// l3 background (!important beats the Badge's [data-color] rule).
--badge-background: var(--l3-background) !important;
--badge-padding: 0 5px;
// Static count, not interactive — cancel the Badge's hover background change.
--badge-hover-background: var(--badge-background) !important;
margin-left: 6px;
min-width: 18px;
height: 18px;
vertical-align: middle;
}
.tabsScroll {
flex: 1;
min-height: 0;

View File

@@ -1,5 +1,4 @@
import { useCallback, useMemo } from 'react';
import { Badge } from '@signozhq/ui/badge';
import {
TabsContent,
TabsList,
@@ -282,8 +281,6 @@ function SpanDetailsContent({
// .map((key) => ({ key, value: allAttrs[key] }));
// }, [selectedSpan]);
const eventsCount = selectedSpan.events?.length || 0;
return (
<div className={styles.body}>
<div className={styles.detailsSection}>
@@ -400,10 +397,7 @@ function SpanDetailsContent({
<Bookmark size={14} /> Overview
</TabsTrigger>
<TabsTrigger value="events" variant="secondary">
<ScrollText size={14} /> Events
<Badge color="secondary" className={styles.eventsBadge}>
{eventsCount}
</Badge>
<ScrollText size={14} /> Events ({selectedSpan.events?.length || 0})
</TabsTrigger>
<TabsTrigger value="logs" variant="secondary">
<List size={14} /> Logs

View File

@@ -19,7 +19,6 @@
.backBtn {
flex-shrink: 0;
border: 1px solid var(--l1-border);
}
.traceIdSection {
@@ -27,11 +26,6 @@
align-items: center;
gap: 8px;
flex-shrink: 0;
// Tabular figures so the trace ID's digits line up at a fixed width.
:global(.key-value-label__value) {
font-variant-numeric: tabular-nums;
}
}
.filterSection {

View File

@@ -133,7 +133,7 @@ function TraceDetailsHeader({
<Button
variant="solid"
color="secondary"
size="icon"
size="md"
className={styles.backBtn}
onClick={handlePreviousBtnClick}
aria-label="Back"

View File

@@ -1,8 +1,10 @@
import { useCallback, useRef, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { ArrowRightFromLine, Search, X } from '@signozhq/icons';
import { useCopyToClipboard } from 'react-use';
import { ChevronsRight, Copy, Search, X } from '@signozhq/icons';
import { Switch } from '@signozhq/ui/switch';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { toast } from '@signozhq/ui/sonner';
import { Button } from '@signozhq/ui/button';
import {
TooltipRoot,
@@ -19,7 +21,6 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { uniqBy } from 'lodash-es';
import NozButton from 'pages/TraceDetailsV3/TraceDetailsHeader/NozButton';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import {
@@ -88,6 +89,7 @@ function Filters({
onExpand: () => void;
onCollapse: () => void;
}): JSX.Element {
const [, setCopy] = useCopyToClipboard();
const [filters, setFilters] = useState<TagFilter>(
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
);
@@ -299,7 +301,20 @@ function Filters({
<div className={styles.pillPopover}>
<div className={styles.pillPopoverHeader}>
<Typography.Text>Search query</Typography.Text>
<CopyButton value={expression} size={12} />
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void => {
setCopy(expression);
toast.success('Copied to clipboard', {
richColors: false,
position: 'top-right',
});
}}
>
<Copy size={12} />
</Button>
</div>
<div className={styles.pillPopoverExpression}>{expression}</div>
</div>
@@ -406,7 +421,7 @@ function Filters({
color="secondary"
onClick={onCollapse}
>
<ArrowRightFromLine size={14} />
<ChevronsRight size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>Collapse filters</TooltipContent>

View File

@@ -1,52 +0,0 @@
// Square copy button whose icon cross-fades between copy and check.
.copyButton {
flex-shrink: 0;
}
// Both icons occupy the same box; only one is visible at a time.
.iconStack {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon {
position: absolute;
inset: 0;
transition:
opacity 300ms ease,
filter 300ms ease,
transform 300ms ease;
}
// Idle state: copy visible, check blurred/rotated/faded out.
.copyIcon {
opacity: 1;
filter: blur(0);
transform: rotate(0deg);
}
.checkIcon {
opacity: 0;
filter: blur(4px);
transform: rotate(-90deg);
// Green checkmark to signal a successful copy; eases in a touch slower.
color: var(--bg-forest-500);
transition-duration: 500ms;
}
// Copied state: copy fades/blurs/rotates out, check animates in.
.iconStack[data-copied='true'] {
.copyIcon {
opacity: 0;
filter: blur(4px);
transform: rotate(90deg);
}
.checkIcon {
opacity: 1;
filter: blur(0);
transform: rotate(0deg);
}
}

View File

@@ -1,71 +0,0 @@
import { CSSProperties, useCallback } from 'react';
import { Check, Copy } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import cx from 'classnames';
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
import styles from './CopyButton.module.scss';
export interface CopyButtonProps {
/** Text written to the clipboard on click. */
value: string;
/** Icon size in px. Default 14. */
size?: number;
/** Accessible label for the idle (not-yet-copied) state. Default "Copy". */
ariaLabel?: string;
/** Extra class merged onto the button. */
className?: string;
/** Fired after a successful copy (e.g. to show a toast). */
onCopy?: () => void;
testId?: string;
}
/**
* Square, icon-only copy button. Shows a copy icon that cross-fades to a
* checkmark (blur + rotate + fade) on copy, reverting after 2s. The checkmark
* uses the hover-state icon colour.
*/
function CopyButton({
value,
size = 14,
ariaLabel = 'Copy',
className,
onCopy,
testId,
}: CopyButtonProps): JSX.Element {
const { copyToClipboard, isCopied } = useCopyToClipboard();
const handleClick = useCallback((): void => {
copyToClipboard(value);
onCopy?.();
}, [copyToClipboard, value, onCopy]);
const stackStyle: CSSProperties = { width: size, height: size };
return (
<Button
variant="ghost"
color="secondary"
size="icon"
className={cx(styles.copyButton, className)}
onClick={handleClick}
aria-label={isCopied ? 'Copied' : ariaLabel}
testId={testId}
>
<span className={styles.iconStack} style={stackStyle} data-copied={isCopied}>
<Copy size={size} className={cx(styles.icon, styles.copyIcon)} />
<Check size={size} className={cx(styles.icon, styles.checkIcon)} />
</span>
</Button>
);
}
CopyButton.defaultProps = {
size: 14,
ariaLabel: 'Copy',
className: undefined,
onCopy: undefined,
testId: undefined,
};
export default CopyButton;

View File

@@ -22,6 +22,23 @@
--dropdown-menu-content-z-index: 1000;
}
&__copy-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: transparent;
color: var(--l2-foreground);
cursor: pointer;
&:hover {
color: var(--l1-foreground);
background: var(--l3-background);
}
}
// Shared content container — no scroll, each view handles its own
&__content {
flex: 1;

View File

@@ -1,11 +1,13 @@
import { useMemo, useState } from 'react';
import { ChevronDown } from '@signozhq/icons';
import { useCopyToClipboard } from 'react-use';
import { ChevronDown, Copy } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { JsonView } from 'periscope/components/JsonView';
import { PrettyView, PrettyViewProps } from 'periscope/components/PrettyView';
import { PrettyView } from 'periscope/components/PrettyView';
import { PrettyViewProps } from 'periscope/components/PrettyView';
import './DataViewer.styles.scss';
@@ -31,6 +33,7 @@ function DataViewer({
prettyViewProps,
}: DataViewerProps): JSX.Element {
const [viewMode, setViewMode] = useState<ViewMode>('pretty');
const [, setCopy] = useCopyToClipboard();
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
@@ -48,6 +51,14 @@ function DataViewer({
}
};
const handleCopy = (): void => {
const text = JSON.stringify(data, null, 2);
setCopy(text);
toast.success('Copied to clipboard', {
position: 'top-right',
});
};
const currentLabel =
VIEW_MODE_OPTIONS.find((opt) => opt.value === viewMode)?.label ?? 'Pretty';
@@ -83,7 +94,14 @@ function DataViewer({
{currentLabel}
</Button>
</Dropdown>
<CopyButton value={jsonString} ariaLabel="Copy JSON" />
<button
type="button"
className="data-viewer__copy-btn"
onClick={handleCopy}
aria-label="Copy JSON"
>
<Copy size={14} />
</button>
</div>
<div className="data-viewer__content">