mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-10 00:20:40 +01:00
Compare commits
6 Commits
worktree-j
...
nv/list-v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10cfa4853f | ||
|
|
c31642fe71 | ||
|
|
d3d4ba6c93 | ||
|
|
83a0a98c5d | ||
|
|
499b391284 | ||
|
|
66ed26723a |
@@ -3148,6 +3148,8 @@ components:
|
||||
type: string
|
||||
image:
|
||||
type: string
|
||||
legacy:
|
||||
type: boolean
|
||||
locked:
|
||||
type: boolean
|
||||
name:
|
||||
@@ -3180,6 +3182,7 @@ components:
|
||||
- name
|
||||
- tags
|
||||
- spec
|
||||
- legacy
|
||||
- pinned
|
||||
type: object
|
||||
DashboardtypesListedDashboardV2:
|
||||
@@ -3193,6 +3196,8 @@ components:
|
||||
type: string
|
||||
image:
|
||||
type: string
|
||||
legacy:
|
||||
type: boolean
|
||||
locked:
|
||||
type: boolean
|
||||
name:
|
||||
@@ -3223,6 +3228,7 @@ components:
|
||||
- name
|
||||
- tags
|
||||
- spec
|
||||
- legacy
|
||||
type: object
|
||||
DashboardtypesListedDashboardV2Spec:
|
||||
properties:
|
||||
|
||||
@@ -5005,6 +5005,10 @@ export interface DashboardtypesListedDashboardForUserV2DTO {
|
||||
* @type string
|
||||
*/
|
||||
image?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
legacy: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -5080,6 +5084,10 @@ export interface DashboardtypesListedDashboardV2DTO {
|
||||
* @type string
|
||||
*/
|
||||
image?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
legacy: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import ActionsPopover from './ActionsPopover';
|
||||
|
||||
const baseProps = {
|
||||
link: '/dashboard/abc',
|
||||
dashboardId: 'abc',
|
||||
dashboardName: 'My Dashboard',
|
||||
createdBy: 'someone-else@signoz.io',
|
||||
isLocked: false,
|
||||
tags: [],
|
||||
canEdit: true,
|
||||
onView: jest.fn(),
|
||||
};
|
||||
|
||||
describe('ActionsPopover', () => {
|
||||
it('shows the full set of actions for a v2 dashboard', async () => {
|
||||
render(<ActionsPopover {...baseProps} />);
|
||||
await userEvent.click(screen.getByTestId('dashboard-action-icon'));
|
||||
|
||||
await screen.findByTestId('dashboard-action-view');
|
||||
expect(screen.getByTestId('dashboard-action-view')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('dashboard-action-open-new-tab'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-action-copy-link')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-action-rename')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-action-edit-tags')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-action-duplicate')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-action-delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps only Delete for a legacy dashboard', async () => {
|
||||
render(<ActionsPopover {...baseProps} isLegacy />);
|
||||
await userEvent.click(screen.getByTestId('dashboard-action-icon'));
|
||||
|
||||
// Delete is the one action that still applies to a legacy blob.
|
||||
await screen.findByTestId('dashboard-action-delete');
|
||||
expect(screen.getByTestId('dashboard-action-delete')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByTestId('dashboard-action-view')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('dashboard-action-open-new-tab'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('dashboard-action-copy-link'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('dashboard-action-rename'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('dashboard-action-edit-tags'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('dashboard-action-duplicate'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('dashboard-action-lock')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,10 @@ interface Props {
|
||||
// Edit permission (edit_dashboard). Read actions show regardless; edit actions are hidden without it.
|
||||
canEdit: boolean;
|
||||
onView: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
// A legacy (pre-v2) dashboard has no v2 spec, so the actions that operate on
|
||||
// one (view, open, copy link, rename, edit tags, duplicate, lock) don't apply —
|
||||
// only Delete is kept.
|
||||
isLegacy?: boolean;
|
||||
}
|
||||
|
||||
function ActionsPopover({
|
||||
@@ -57,6 +61,7 @@ function ActionsPopover({
|
||||
tags,
|
||||
canEdit,
|
||||
onView,
|
||||
isLegacy = false,
|
||||
}: Props): JSX.Element {
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
@@ -109,128 +114,132 @@ function ActionsPopover({
|
||||
// row's onClick, which would navigate to the dashboard.
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- wrapper only guards propagation, not an interactive control
|
||||
<div className={styles.content} onClick={(e): void => e.stopPropagation()}>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Expand size={14} />}
|
||||
onClick={onView}
|
||||
testId="dashboard-action-view"
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<SquareArrowOutUpRight size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
openInNewTab(link);
|
||||
}}
|
||||
testId="dashboard-action-open-new-tab"
|
||||
>
|
||||
Open in New Tab
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Link2 size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setCopy(getAbsoluteUrl(link));
|
||||
}}
|
||||
testId="dashboard-action-copy-link"
|
||||
>
|
||||
Copy Link
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Tooltip
|
||||
placement="left"
|
||||
title={
|
||||
isLocked ? 'This dashboard is locked, so it cannot be renamed.' : ''
|
||||
}
|
||||
>
|
||||
<span className={styles.menuItemWrap}>
|
||||
{!isLegacy && (
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Expand size={14} />}
|
||||
onClick={onView}
|
||||
testId="dashboard-action-view"
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<SquareArrowOutUpRight size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
openInNewTab(link);
|
||||
}}
|
||||
testId="dashboard-action-open-new-tab"
|
||||
>
|
||||
Open in New Tab
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Link2 size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setCopy(getAbsoluteUrl(link));
|
||||
}}
|
||||
testId="dashboard-action-copy-link"
|
||||
>
|
||||
Copy Link
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Tooltip
|
||||
placement="left"
|
||||
title={
|
||||
isLocked ? 'This dashboard is locked, so it cannot be renamed.' : ''
|
||||
}
|
||||
>
|
||||
<span className={styles.menuItemWrap}>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<PenLine size={14} />}
|
||||
disabled={isLocked}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!isLocked) {
|
||||
setIsRenameOpen(true);
|
||||
}
|
||||
}}
|
||||
testId="dashboard-action-rename"
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Tooltip
|
||||
placement="left"
|
||||
title={
|
||||
isLocked
|
||||
? 'This dashboard is locked, so its tags cannot be edited.'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<span className={styles.menuItemWrap}>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Tag size={14} />}
|
||||
disabled={isLocked}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!isLocked) {
|
||||
setIsEditTagsOpen(true);
|
||||
}
|
||||
}}
|
||||
testId="dashboard-action-edit-tags"
|
||||
>
|
||||
{tags.length > 0 ? 'Edit Tags' : 'Add Tags'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<PenLine size={14} />}
|
||||
disabled={isLocked}
|
||||
prefix={<Copy size={14} />}
|
||||
loading={isCloning}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!isLocked) {
|
||||
setIsRenameOpen(true);
|
||||
}
|
||||
runClone();
|
||||
}}
|
||||
testId="dashboard-action-rename"
|
||||
testId="dashboard-action-duplicate"
|
||||
>
|
||||
Rename
|
||||
Duplicate
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Tooltip
|
||||
placement="left"
|
||||
title={
|
||||
isLocked
|
||||
? 'This dashboard is locked, so its tags cannot be edited.'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<span className={styles.menuItemWrap}>
|
||||
)}
|
||||
{canToggleLock && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Tag size={14} />}
|
||||
disabled={isLocked}
|
||||
prefix={<LockKeyhole size={14} />}
|
||||
loading={isTogglingLock}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!isLocked) {
|
||||
setIsEditTagsOpen(true);
|
||||
}
|
||||
runLockToggle();
|
||||
}}
|
||||
testId="dashboard-action-edit-tags"
|
||||
testId="dashboard-action-lock"
|
||||
>
|
||||
{tags.length > 0 ? 'Edit Tags' : 'Add Tags'}
|
||||
{isLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Copy size={14} />}
|
||||
loading={isCloning}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
runClone();
|
||||
}}
|
||||
testId="dashboard-action-duplicate"
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
)}
|
||||
{canToggleLock && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<LockKeyhole size={14} />}
|
||||
loading={isTogglingLock}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
runLockToggle();
|
||||
}}
|
||||
testId="dashboard-action-lock"
|
||||
>
|
||||
{isLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canEdit && (
|
||||
<DeleteActionItem
|
||||
@@ -238,6 +247,7 @@ function ActionsPopover({
|
||||
dashboardName={dashboardName}
|
||||
createdBy={createdBy}
|
||||
isLocked={isLocked}
|
||||
showDivider={!isLegacy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,9 @@ interface Props {
|
||||
dashboardName: string;
|
||||
createdBy: string;
|
||||
isLocked: boolean;
|
||||
// Delete sits below the other actions, so it leads with a divider. When it's
|
||||
// the only item (a legacy dashboard), the divider is suppressed.
|
||||
showDivider?: boolean;
|
||||
}
|
||||
|
||||
function DeleteActionItem({
|
||||
@@ -30,6 +33,7 @@ function DeleteActionItem({
|
||||
dashboardName,
|
||||
createdBy,
|
||||
isLocked,
|
||||
showDivider = true,
|
||||
}: Props): JSX.Element {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
const { user } = useAppContext();
|
||||
@@ -100,7 +104,7 @@ function DeleteActionItem({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
{showDivider && <Divider />}
|
||||
<Tooltip placement="left" title={tooltip}>
|
||||
<span className={styles.menuItemWrap}>
|
||||
<Button
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.legacyBadge {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tagsWithActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -71,6 +75,17 @@
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
/* Wrap so the tooltip has a hoverable target even when the pin button is disabled
|
||||
(a legacy dashboard can't be pinned). */
|
||||
.pinButtonWrap {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.pinButtonWrap button:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pinButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import type { DashboardListItem } from '../../utils/helpers';
|
||||
|
||||
import DashboardRow from './DashboardRow';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockTogglePin = jest.fn();
|
||||
jest.mock('../../hooks/usePinDashboard', () => ({
|
||||
usePinDashboard: (): { togglePin: jest.Mock; isUpdating: boolean } => ({
|
||||
togglePin: mockTogglePin,
|
||||
isUpdating: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
// Stub the actions menu so this suite stays focused on the row; the isLegacy
|
||||
// wiring is asserted via the recorded prop, and the menu itself is covered by
|
||||
// ActionsPopover's own tests.
|
||||
jest.mock('../ActionsPopover/ActionsPopover', () => ({
|
||||
__esModule: true,
|
||||
default: ({ isLegacy }: { isLegacy?: boolean }): JSX.Element => (
|
||||
<div data-testid="actions-popover" data-legacy={String(!!isLegacy)} />
|
||||
),
|
||||
}));
|
||||
|
||||
const makeDashboard = (
|
||||
overrides: Partial<DashboardListItem> = {},
|
||||
): DashboardListItem =>
|
||||
({
|
||||
id: 'dash-1',
|
||||
legacy: false,
|
||||
pinned: false,
|
||||
locked: false,
|
||||
image: '',
|
||||
createdBy: 'alice@signoz.io',
|
||||
updatedBy: 'alice@signoz.io',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
tags: [],
|
||||
spec: { display: { name: 'My Dashboard' } },
|
||||
...overrides,
|
||||
}) as unknown as DashboardListItem;
|
||||
|
||||
const renderRow = (dashboard: DashboardListItem): void => {
|
||||
render(
|
||||
<DashboardRow
|
||||
dashboard={dashboard}
|
||||
index={0}
|
||||
canEdit
|
||||
showUpdatedAt={false}
|
||||
showUpdatedBy={false}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('DashboardRow', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('a v2 dashboard', () => {
|
||||
it('navigates on click and shows no legacy badge', async () => {
|
||||
renderRow(makeDashboard());
|
||||
|
||||
expect(screen.queryByTestId('dashboard-legacy-0')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-pin-0')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('actions-popover')).toHaveAttribute(
|
||||
'data-legacy',
|
||||
'false',
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('dashboard-title-0'));
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('legacy-dashboard-id')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a legacy dashboard', () => {
|
||||
it('badges the row, disables pin and gates the actions menu', () => {
|
||||
renderRow(makeDashboard({ legacy: true }));
|
||||
|
||||
expect(screen.getByTestId('dashboard-legacy-0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-pin-0')).toBeDisabled();
|
||||
expect(screen.getByTestId('actions-popover')).toHaveAttribute(
|
||||
'data-legacy',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the explanatory dialog instead of navigating', async () => {
|
||||
renderRow(makeDashboard({ legacy: true }));
|
||||
|
||||
await userEvent.click(screen.getByTestId('dashboard-title-0'));
|
||||
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('legacy-dashboard-id')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import TagBadge from 'components/TagBadge/TagBadge';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -18,6 +20,7 @@ import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
|
||||
import type { DashboardListItem } from '../../utils/helpers';
|
||||
import { lastUpdatedLabel, tagsToStrings } from '../../utils/helpers';
|
||||
import ActionsPopover from '../ActionsPopover/ActionsPopover';
|
||||
import LegacyDashboardDialog from '../LegacyDashboardDialog/LegacyDashboardDialog';
|
||||
|
||||
import styles from './DashboardRow.module.scss';
|
||||
|
||||
@@ -42,6 +45,11 @@ function DashboardRow({
|
||||
const markViewed = useDashboardViewsStore((s) => s.markViewed);
|
||||
const { togglePin, isUpdating } = usePinDashboard();
|
||||
|
||||
const [isLegacyDialogOpen, setIsLegacyDialogOpen] = useState(false);
|
||||
|
||||
// A legacy dashboard is stored in the pre-v2 shape and has no v2 spec, so it
|
||||
// can't be opened in the new experience — clicking it explains this instead.
|
||||
const isLegacy = !!dashboard.legacy;
|
||||
const isPinned = !!dashboard.pinned;
|
||||
const id = dashboard.id;
|
||||
const name = dashboard.spec?.display?.name ?? '';
|
||||
@@ -67,9 +75,17 @@ function DashboardRow({
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
if (isLegacy) {
|
||||
setIsLegacyDialogOpen(true);
|
||||
void logEvent('Dashboard List: Clicked on legacy dashboard', {
|
||||
dashboardId: id,
|
||||
dashboardName: name,
|
||||
});
|
||||
return;
|
||||
}
|
||||
markViewed(id);
|
||||
safeNavigate(link, { newTab: isModifierKeyPressed(event) });
|
||||
logEvent('Dashboard List: Clicked on dashboard', {
|
||||
void logEvent('Dashboard List: Clicked on dashboard', {
|
||||
dashboardId: id,
|
||||
dashboardName: name,
|
||||
});
|
||||
@@ -77,9 +93,17 @@ function DashboardRow({
|
||||
|
||||
const onTogglePin = (event: React.MouseEvent<HTMLElement>): void => {
|
||||
event.stopPropagation();
|
||||
if (isLegacy) {
|
||||
return;
|
||||
}
|
||||
togglePin(id, isPinned);
|
||||
};
|
||||
|
||||
const pinLabel = isPinned ? 'Unpin dashboard' : 'Pin dashboard';
|
||||
const pinTooltip = isLegacy
|
||||
? "This dashboard isn't available in the new experience, so it can't be pinned"
|
||||
: pinLabel;
|
||||
|
||||
// Only long titles are truncated, so only they need the full-name tooltip;
|
||||
// wrapping conditionally avoids an empty hanging tooltip for short names.
|
||||
const titleLink = (
|
||||
@@ -95,120 +119,144 @@ function DashboardRow({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.row} onClick={onClickHandler}>
|
||||
<div className={styles.titleWithAction}>
|
||||
<div className={styles.titleBlock}>
|
||||
{name.length > 50 ? (
|
||||
<TooltipSimple title={name} side="bottom" disableHoverableContent>
|
||||
{titleLink}
|
||||
<>
|
||||
<div className={styles.row} onClick={onClickHandler}>
|
||||
<div className={styles.titleWithAction}>
|
||||
<div className={styles.titleBlock}>
|
||||
{name.length > 50 ? (
|
||||
<TooltipSimple title={name} side="bottom" disableHoverableContent>
|
||||
{titleLink}
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
titleLink
|
||||
)}
|
||||
{isLegacy && (
|
||||
<Badge
|
||||
color="amber"
|
||||
variant="outline"
|
||||
className={styles.legacyBadge}
|
||||
testId={`dashboard-legacy-${index}`}
|
||||
>
|
||||
Legacy
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.tagsWithActions}>
|
||||
{tags.length > 0 && (
|
||||
<div className={styles.tags}>
|
||||
{tags.slice(0, 3).map((tag) => (
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<TagBadge key={tags[3]}>+{tags.length - 3}</TagBadge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLocked && (
|
||||
<TooltipSimple
|
||||
title="This dashboard is locked"
|
||||
side="top"
|
||||
disableHoverableContent
|
||||
>
|
||||
<span
|
||||
className={styles.lockIcon}
|
||||
data-testid={`dashboard-lock-${index}`}
|
||||
>
|
||||
<LockKeyhole size={14} />
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
titleLink
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.tagsWithActions}>
|
||||
{tags.length > 0 && (
|
||||
<div className={styles.tags}>
|
||||
{tags.slice(0, 3).map((tag) => (
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<TagBadge key={tags[3]}>+{tags.length - 3}</TagBadge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLocked && (
|
||||
<TooltipSimple
|
||||
title="This dashboard is locked"
|
||||
side="top"
|
||||
disableHoverableContent
|
||||
>
|
||||
<span className={styles.lockIcon} data-testid={`dashboard-lock-${index}`}>
|
||||
<LockKeyhole size={14} />
|
||||
<TooltipSimple title={pinTooltip} side="top" disableHoverableContent>
|
||||
<span className={styles.pinButtonWrap}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={cx(styles.pinButton, {
|
||||
[styles.pinButtonOn]: isPinned && !isLegacy,
|
||||
})}
|
||||
aria-label={pinLabel}
|
||||
data-testid={`dashboard-pin-${index}`}
|
||||
disabled={isUpdating || isLegacy}
|
||||
onClick={onTogglePin}
|
||||
>
|
||||
{isPinned ? (
|
||||
<>
|
||||
<Pin size={14} className={styles.pinnedIcon} />
|
||||
<PinOff size={14} className={styles.unpinIcon} />
|
||||
</>
|
||||
) : (
|
||||
<Pin size={14} />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
<TooltipSimple
|
||||
title={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
|
||||
side="top"
|
||||
disableHoverableContent
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={cx(styles.pinButton, { [styles.pinButtonOn]: isPinned })}
|
||||
aria-label={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
|
||||
data-testid={`dashboard-pin-${index}`}
|
||||
disabled={isUpdating}
|
||||
onClick={onTogglePin}
|
||||
>
|
||||
{isPinned ? (
|
||||
<>
|
||||
<Pin size={14} className={styles.pinnedIcon} />
|
||||
<PinOff size={14} className={styles.unpinIcon} />
|
||||
</>
|
||||
) : (
|
||||
<Pin size={14} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
|
||||
<ActionsPopover
|
||||
link={link}
|
||||
dashboardId={id}
|
||||
dashboardName={name}
|
||||
createdBy={createdBy}
|
||||
isLocked={isLocked}
|
||||
tags={tags}
|
||||
canEdit={canEdit}
|
||||
onView={onClickHandler}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.details}>
|
||||
<div className={styles.createdAt}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text>{formattedCreatedAt}</Typography.Text>
|
||||
<ActionsPopover
|
||||
link={link}
|
||||
dashboardId={id}
|
||||
dashboardName={name}
|
||||
createdBy={createdBy}
|
||||
isLocked={isLocked}
|
||||
tags={tags}
|
||||
canEdit={canEdit}
|
||||
onView={onClickHandler}
|
||||
isLegacy={isLegacy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{createdBy && (
|
||||
<div className={styles.createdBy}>
|
||||
<div className={styles.avatar}>
|
||||
<Typography.Text className={styles.avatarText}>
|
||||
{createdBy.substring(0, 1).toUpperCase()}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text className={styles.byLabel}>{createdBy}</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUpdatedAt && (
|
||||
<div className={styles.details}>
|
||||
<div className={styles.createdAt}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text>{lastUpdatedLabel(updatedAt)}</Typography.Text>
|
||||
<Typography.Text>{formattedCreatedAt}</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updatedBy && showUpdatedBy && (
|
||||
<div className={styles.updatedBy}>
|
||||
<Typography.Text className={styles.byLabel}>
|
||||
Last Updated By -
|
||||
</Typography.Text>
|
||||
<div className={styles.avatar}>
|
||||
<Typography.Text className={styles.avatarText}>
|
||||
{updatedBy.substring(0, 1).toUpperCase()}
|
||||
</Typography.Text>
|
||||
{createdBy && (
|
||||
<div className={styles.createdBy}>
|
||||
<div className={styles.avatar}>
|
||||
<Typography.Text className={styles.avatarText}>
|
||||
{createdBy.substring(0, 1).toUpperCase()}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text className={styles.byLabel}>{createdBy}</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text className={styles.byLabel}>{updatedBy}</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{showUpdatedAt && (
|
||||
<div className={styles.createdAt}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text>{lastUpdatedLabel(updatedAt)}</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updatedBy && showUpdatedBy && (
|
||||
<div className={styles.updatedBy}>
|
||||
<Typography.Text className={styles.byLabel}>
|
||||
Last Updated By -
|
||||
</Typography.Text>
|
||||
<div className={styles.avatar}>
|
||||
<Typography.Text className={styles.avatarText}>
|
||||
{updatedBy.substring(0, 1).toUpperCase()}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text className={styles.byLabel}>{updatedBy}</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isLegacy && (
|
||||
<LegacyDashboardDialog
|
||||
open={isLegacyDialogOpen}
|
||||
dashboardId={id}
|
||||
dashboardName={name}
|
||||
onClose={(): void => setIsLegacyDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
|
||||
strong {
|
||||
color: var(--l1-foreground);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.idField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.idLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.idRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 6px 6px 12px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.idValue {
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import LegacyDashboardDialog from './LegacyDashboardDialog';
|
||||
|
||||
const mockCopy = jest.fn();
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): [unknown, (value: string) => void] => [{}, mockCopy],
|
||||
}));
|
||||
|
||||
const mockToastSuccess = jest.fn();
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: (message: string): void => mockToastSuccess(message) },
|
||||
}));
|
||||
|
||||
const mockContactSupport = jest.fn();
|
||||
jest.mock('container/Integrations/utils', () => ({
|
||||
handleContactSupport: (isCloud: boolean): void => mockContactSupport(isCloud),
|
||||
}));
|
||||
|
||||
const DASHBOARD_ID = '0f9a1b2c-3d4e-5f6a-7b8c-9d0e1f2a3b4c';
|
||||
|
||||
describe('LegacyDashboardDialog', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const setup = (open = true): void => {
|
||||
render(
|
||||
<LegacyDashboardDialog
|
||||
open={open}
|
||||
dashboardId={DASHBOARD_ID}
|
||||
dashboardName="My Legacy Dashboard"
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
it('surfaces the dashboard name and id', () => {
|
||||
setup();
|
||||
expect(screen.getByText('My Legacy Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('legacy-dashboard-id')).toHaveTextContent(
|
||||
DASHBOARD_ID,
|
||||
);
|
||||
});
|
||||
|
||||
it('copies the dashboard id and confirms with a toast', async () => {
|
||||
setup();
|
||||
await userEvent.click(screen.getByTestId('legacy-dashboard-copy-id'));
|
||||
expect(mockCopy).toHaveBeenCalledWith(DASHBOARD_ID);
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('Dashboard ID copied');
|
||||
});
|
||||
|
||||
it('routes the user to support', async () => {
|
||||
setup();
|
||||
await userEvent.click(screen.getByTestId('legacy-dashboard-contact-support'));
|
||||
expect(mockContactSupport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
setup(false);
|
||||
expect(screen.queryByTestId('legacy-dashboard-id')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ArrowUpRight, Copy } from '@signozhq/icons';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import styles from './LegacyDashboardDialog.module.scss';
|
||||
|
||||
interface LegacyDashboardDialogProps {
|
||||
open: boolean;
|
||||
dashboardId: string;
|
||||
dashboardName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explains why a legacy (pre-v2) dashboard can't be opened in the new experience
|
||||
* and hands the user the dashboard ID to share with support. Legacy rows are
|
||||
* surfaced by the list API with `legacy: true` but have no v2 spec to render.
|
||||
*/
|
||||
function LegacyDashboardDialog({
|
||||
open,
|
||||
dashboardId,
|
||||
dashboardName,
|
||||
onClose,
|
||||
}: LegacyDashboardDialogProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
|
||||
const onCopyId = (): void => {
|
||||
copyToClipboard(dashboardId);
|
||||
toast.success('Dashboard ID copied');
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="This dashboard isn't available in the new experience"
|
||||
open={open}
|
||||
width="narrow"
|
||||
onOpenChange={(next): void => {
|
||||
if (!next) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
testId="legacy-dashboard-close"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
suffix={<ArrowUpRight size={14} />}
|
||||
onClick={(): void => handleContactSupport(!!isCloudUser)}
|
||||
testId="legacy-dashboard-contact-support"
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.body}>
|
||||
<Typography.Text className={styles.description}>
|
||||
<strong>{dashboardName || 'This dashboard'}</strong> hasn't been
|
||||
migrated to the new dashboard experience yet, so it can't be opened
|
||||
here. Share the dashboard ID below with support and we'll help you
|
||||
move it over.
|
||||
</Typography.Text>
|
||||
|
||||
<div className={styles.idField}>
|
||||
<Typography.Text className={styles.idLabel}>Dashboard ID</Typography.Text>
|
||||
<div className={styles.idRow}>
|
||||
<Typography.Text
|
||||
className={styles.idValue}
|
||||
data-testid="legacy-dashboard-id"
|
||||
>
|
||||
{dashboardId}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
prefix={<Copy size={14} />}
|
||||
aria-label="Copy dashboard ID"
|
||||
onClick={onCopyId}
|
||||
testId="legacy-dashboard-copy-id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default LegacyDashboardDialog;
|
||||
@@ -71,7 +71,7 @@ func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, params *das
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dashboardtypes.NewListableDashboardV2(dashboards, total, tagsByDashboard, allTags)
|
||||
return dashboardtypes.NewListableDashboardV2(dashboards, total, tagsByDashboard, allTags), nil
|
||||
}
|
||||
|
||||
func (module *module) ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error) {
|
||||
@@ -90,7 +90,7 @@ func (module *module) ListForUserV2(ctx context.Context, orgID valuer.UUID, user
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dashboardtypes.NewListableDashboardForUserV2(rows, total, tagsByDashboard, allTags)
|
||||
return dashboardtypes.NewListableDashboardForUserV2(rows, total, tagsByDashboard, allTags), nil
|
||||
}
|
||||
|
||||
func (module *module) fetchDashboardTags(ctx context.Context, orgID valuer.UUID, dashboardIDs []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, []*tagtypes.Tag, error) {
|
||||
|
||||
@@ -64,6 +64,13 @@ type (
|
||||
ListableDashboard []*GettableDashboard
|
||||
)
|
||||
|
||||
// readString reads a string field from the untyped data blob, yielding "" when
|
||||
// the key is absent, null, or not a string.
|
||||
func (d StorableDashboardData) readString(key string) string {
|
||||
s, _ := d[key].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func NewStorableDashboardFromDashboard(dashboard *Dashboard) (*StorableDashboard, error) {
|
||||
dashboardID, err := valuer.NewUUID(dashboard.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -122,6 +122,10 @@ type listedDashboardV2 struct {
|
||||
Image string `json:"image,omitempty"`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
|
||||
Spec listedDashboardV2Spec `json:"spec" required:"true"`
|
||||
// Legacy marks a dashboard whose stored data is not yet in the v2 (perses)
|
||||
// schema. Such rows are extracted best-effort from the v1 shape so the list
|
||||
// still surfaces them; callers should route them to the legacy view.
|
||||
Legacy bool `json:"legacy" required:"true"`
|
||||
}
|
||||
|
||||
type listedDashboardV2Spec struct {
|
||||
@@ -144,6 +148,41 @@ func newListedDashboardV2(v2 *DashboardV2) *listedDashboardV2 {
|
||||
}
|
||||
}
|
||||
|
||||
// newListedDashboardForList builds the list view for a single dashboard. A row
|
||||
// whose stored data is not in the v2 (perses) schema is extracted best-effort
|
||||
// from the v1 shape and flagged Legacy, so one legacy or malformed dashboard
|
||||
// can't fail the whole list.
|
||||
func newListedDashboardForList(storable *StorableDashboard, tags []*tagtypes.Tag) *listedDashboardV2 {
|
||||
if v2, err := storable.ToDashboardV2(tags); err == nil {
|
||||
return newListedDashboardV2(v2)
|
||||
}
|
||||
return newLegacyListedDashboardV2(storable, tags)
|
||||
}
|
||||
|
||||
// newLegacyListedDashboardV2 pulls the display-relevant fields out of a v1
|
||||
// dashboard blob. Column-backed fields (name, source, timestamps, …) come
|
||||
// straight off the row; title/description/image/version are read leniently from
|
||||
// the untyped v1 data, defaulting to zero when absent or of the wrong type.
|
||||
func newLegacyListedDashboardV2(storable *StorableDashboard, tags []*tagtypes.Tag) *listedDashboardV2 {
|
||||
return &listedDashboardV2{
|
||||
Identifiable: storable.Identifiable,
|
||||
TimeAuditable: storable.TimeAuditable,
|
||||
UserAuditable: storable.UserAuditable,
|
||||
OrgID: storable.OrgID,
|
||||
Locked: storable.Locked,
|
||||
Source: storable.Source,
|
||||
SchemaVersion: storable.Data.readString("version"),
|
||||
Name: storable.Name,
|
||||
Image: storable.Data.readString("image"),
|
||||
Tags: tagtypes.NewGettableTagsFromTags(tags),
|
||||
Spec: listedDashboardV2Spec{Display: Display{
|
||||
Name: storable.Data.readString("title"),
|
||||
Description: storable.Data.readString("description"),
|
||||
}},
|
||||
Legacy: true,
|
||||
}
|
||||
}
|
||||
|
||||
type ListableDashboardV2 struct {
|
||||
Dashboards []*listedDashboardV2 `json:"dashboards" required:"true" nullable:"false"`
|
||||
Total int64 `json:"total" required:"true"`
|
||||
@@ -151,21 +190,17 @@ type ListableDashboardV2 struct {
|
||||
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) (*ListableDashboardV2, error) {
|
||||
func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) *ListableDashboardV2 {
|
||||
items := make([]*listedDashboardV2, len(dashboards))
|
||||
for i, d := range dashboards {
|
||||
v2, err := d.ToDashboardV2(tagsByEntity[d.ID])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items[i] = newListedDashboardV2(v2)
|
||||
items[i] = newListedDashboardForList(d, tagsByEntity[d.ID])
|
||||
}
|
||||
return &ListableDashboardV2{
|
||||
Dashboards: items,
|
||||
Total: total,
|
||||
Tags: tagtypes.NewGettableTagsFromTags(allTags),
|
||||
ReservedKeywords: ReservedFilterKeys(),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// listedDashboardForUserV2 is a listed dashboard plus the calling user's pin
|
||||
@@ -190,15 +225,11 @@ type StorableDashboardWithPinInfo struct {
|
||||
Pinned bool
|
||||
}
|
||||
|
||||
func NewListableDashboardForUserV2(rows []*StorableDashboardWithPinInfo, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) (*ListableDashboardForUserV2, error) {
|
||||
func NewListableDashboardForUserV2(rows []*StorableDashboardWithPinInfo, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) *ListableDashboardForUserV2 {
|
||||
items := make([]*listedDashboardForUserV2, len(rows))
|
||||
for i, r := range rows {
|
||||
v2, err := r.Dashboard.ToDashboardV2(tagsByEntity[r.Dashboard.ID])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items[i] = &listedDashboardForUserV2{
|
||||
listedDashboardV2: *newListedDashboardV2(v2),
|
||||
listedDashboardV2: *newListedDashboardForList(r.Dashboard, tagsByEntity[r.Dashboard.ID]),
|
||||
Pinned: r.Pinned,
|
||||
}
|
||||
}
|
||||
@@ -207,5 +238,5 @@ func NewListableDashboardForUserV2(rows []*StorableDashboardWithPinInfo, total i
|
||||
Total: total,
|
||||
Tags: tagtypes.NewGettableTagsFromTags(allTags),
|
||||
ReservedKeywords: ReservedFilterKeys(),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +305,146 @@ func TestNextCloneDisplayName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLegacyListedDashboardV2(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
dashboardID := valuer.GenerateUUID()
|
||||
createdAt := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
|
||||
updatedAt := time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("extracts display fields from a well-formed v1 blob", func(t *testing.T) {
|
||||
storable := &StorableDashboard{
|
||||
Identifiable: types.Identifiable{ID: dashboardID},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: createdAt, UpdatedAt: updatedAt},
|
||||
UserAuditable: types.UserAuditable{CreatedBy: "alice", UpdatedBy: "bob"},
|
||||
OrgID: orgID,
|
||||
Locked: true,
|
||||
Source: SourceUser,
|
||||
Name: "legacy-dashboard",
|
||||
Data: StorableDashboardData{
|
||||
"title": "Legacy Title",
|
||||
"description": "an old v1 dashboard",
|
||||
"image": "data:image/png;base64,xyz",
|
||||
"version": "v5",
|
||||
"widgets": []any{},
|
||||
},
|
||||
}
|
||||
listed := newLegacyListedDashboardV2(storable, nil)
|
||||
|
||||
assert.True(t, listed.Legacy, "a non-v2 dashboard must be flagged legacy")
|
||||
assert.Equal(t, storable.Identifiable, listed.Identifiable)
|
||||
assert.Equal(t, storable.TimeAuditable, listed.TimeAuditable)
|
||||
assert.Equal(t, storable.UserAuditable, listed.UserAuditable)
|
||||
assert.Equal(t, orgID, listed.OrgID)
|
||||
assert.True(t, listed.Locked)
|
||||
assert.Equal(t, SourceUser, listed.Source)
|
||||
assert.Equal(t, "legacy-dashboard", listed.Name, "name comes off the column, not the blob")
|
||||
assert.Equal(t, "v5", listed.SchemaVersion)
|
||||
assert.Equal(t, "data:image/png;base64,xyz", listed.Image)
|
||||
assert.Equal(t, "Legacy Title", listed.Spec.Display.Name)
|
||||
assert.Equal(t, "an old v1 dashboard", listed.Spec.Display.Description)
|
||||
assert.Empty(t, listed.Tags, "v1 dashboards predate tags; nil converts to an empty, non-nil slice")
|
||||
})
|
||||
|
||||
t.Run("yields zero values for absent or wrongly-typed fields", func(t *testing.T) {
|
||||
storable := &StorableDashboard{
|
||||
OrgID: orgID,
|
||||
Source: SourceUser,
|
||||
Data: StorableDashboardData{
|
||||
"title": 42, // wrong type
|
||||
"version": []any{"v5"}, // wrong type
|
||||
"image": nil, // null
|
||||
},
|
||||
}
|
||||
|
||||
listed := newLegacyListedDashboardV2(storable, nil)
|
||||
|
||||
assert.True(t, listed.Legacy)
|
||||
assert.Empty(t, listed.Spec.Display.Name)
|
||||
assert.Empty(t, listed.SchemaVersion)
|
||||
assert.Empty(t, listed.Image)
|
||||
assert.Empty(t, listed.Tags, "nil tags convert to an empty, non-nil slice")
|
||||
})
|
||||
|
||||
t.Run("tolerates entirely empty data", func(t *testing.T) {
|
||||
storable := &StorableDashboard{OrgID: orgID, Source: SourceUser, Name: "bare"}
|
||||
|
||||
listed := newLegacyListedDashboardV2(storable, nil)
|
||||
|
||||
assert.True(t, listed.Legacy)
|
||||
assert.Equal(t, "bare", listed.Name)
|
||||
assert.Empty(t, listed.Spec.Display.Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewListableDashboardV2MixedSchemas(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
v2Dashboard := newTestDashboardV2(t, orgID, SourceUser)
|
||||
v2Storable, err := v2Dashboard.ToStorableDashboard()
|
||||
require.NoError(t, err)
|
||||
|
||||
v1Storable := &StorableDashboard{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
OrgID: orgID,
|
||||
Source: SourceUser,
|
||||
Name: "legacy-dashboard",
|
||||
Data: StorableDashboardData{
|
||||
"title": "Legacy Title",
|
||||
"version": "v5",
|
||||
"widgets": []any{},
|
||||
},
|
||||
}
|
||||
|
||||
tagsByEntity := map[valuer.UUID][]*tagtypes.Tag{
|
||||
v2Storable.ID: v2Dashboard.Tags,
|
||||
}
|
||||
|
||||
listable := NewListableDashboardV2([]*StorableDashboard{v2Storable, v1Storable}, 2, tagsByEntity, nil)
|
||||
|
||||
require.Len(t, listable.Dashboards, 2, "a single legacy dashboard must not drop rows from the list")
|
||||
assert.Equal(t, int64(2), listable.Total)
|
||||
|
||||
v2Row := listable.Dashboards[0]
|
||||
assert.False(t, v2Row.Legacy, "a v2 dashboard is not legacy")
|
||||
assert.Equal(t, SchemaVersion, v2Row.SchemaVersion)
|
||||
assert.Equal(t, "Test Dashboard", v2Row.Spec.Display.Name)
|
||||
|
||||
v1Row := listable.Dashboards[1]
|
||||
assert.True(t, v1Row.Legacy, "a v1 dashboard is flagged legacy")
|
||||
assert.Equal(t, "v5", v1Row.SchemaVersion)
|
||||
assert.Equal(t, "Legacy Title", v1Row.Spec.Display.Name)
|
||||
assert.Equal(t, "legacy-dashboard", v1Row.Name)
|
||||
}
|
||||
|
||||
func TestNewListableDashboardForUserV2MixedSchemas(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
v2Dashboard := newTestDashboardV2(t, orgID, SourceUser)
|
||||
v2Storable, err := v2Dashboard.ToStorableDashboard()
|
||||
require.NoError(t, err)
|
||||
|
||||
v1Storable := &StorableDashboard{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
OrgID: orgID,
|
||||
Source: SourceUser,
|
||||
Name: "legacy-dashboard",
|
||||
Data: StorableDashboardData{"title": "Legacy Title", "version": "v5"},
|
||||
}
|
||||
|
||||
rows := []*StorableDashboardWithPinInfo{
|
||||
{Dashboard: v2Storable, Pinned: true},
|
||||
{Dashboard: v1Storable, Pinned: false},
|
||||
}
|
||||
|
||||
listable := NewListableDashboardForUserV2(rows, 2, nil, nil)
|
||||
|
||||
require.Len(t, listable.Dashboards, 2)
|
||||
assert.False(t, listable.Dashboards[0].Legacy)
|
||||
assert.True(t, listable.Dashboards[0].Pinned)
|
||||
assert.True(t, listable.Dashboards[1].Legacy)
|
||||
assert.False(t, listable.Dashboards[1].Pinned)
|
||||
}
|
||||
|
||||
func TestDashboardV2StorableRoundTrip(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
original := newTestDashboardV2(t, orgID, SourceIntegration)
|
||||
|
||||
Reference in New Issue
Block a user