mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-14 22:20:31 +01:00
Compare commits
1 Commits
base-path-
...
feat/authz
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ff065d861 |
@@ -88,6 +88,7 @@
|
||||
"chartjs-adapter-date-fns": "^2.0.0",
|
||||
"chartjs-plugin-annotation": "^1.4.0",
|
||||
"classnames": "2.3.2",
|
||||
"clsx": "2.1.1",
|
||||
"color": "^4.2.1",
|
||||
"color-alpha": "1.1.3",
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -153,6 +154,7 @@
|
||||
"stream": "^0.0.2",
|
||||
"style-loader": "1.3.0",
|
||||
"styled-components": "^5.3.11",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"terser-webpack-plugin": "^5.2.5",
|
||||
"timestamp-nano": "^1.0.0",
|
||||
"ts-node": "^10.2.1",
|
||||
@@ -290,4 +292,4 @@
|
||||
"on-headers": "^1.1.0",
|
||||
"tmp": "0.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Button, ButtonProps } from '@signozhq/button';
|
||||
import { Tooltip, TooltipProvider } from '@signozhq/tooltip';
|
||||
import { cn } from 'lib/cn';
|
||||
|
||||
export type ButtonWithTooltipProps = ButtonProps & {
|
||||
tooltipTitle?: string;
|
||||
tooltipDisabled?: boolean;
|
||||
};
|
||||
|
||||
export function ButtonWithTooltip({
|
||||
children,
|
||||
tooltipTitle,
|
||||
tooltipDisabled,
|
||||
onClick,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonWithTooltipProps): JSX.Element {
|
||||
if (tooltipDisabled || !tooltipTitle) {
|
||||
return <Button {...props}>{children}</Button>;
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<Button
|
||||
{...props}
|
||||
className={cn('disabled:pointer-events-auto', props.className)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<Button {...props} onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Button } from '@signozhq/button';
|
||||
|
||||
import { AuthZRelation } from '../../hooks/useAuthZ/types';
|
||||
import { parsePermission } from '../../hooks/useAuthZ/utils';
|
||||
import {
|
||||
ButtonWithTooltip,
|
||||
ButtonWithTooltipProps,
|
||||
} from '../ButtonWithTooltip/ButtonWithTooltip';
|
||||
import { GuardAuthZ, GuardAuthZProps } from '../GuardAuthZ/GuardAuthZ';
|
||||
|
||||
export function GuardButton<R extends AuthZRelation>({
|
||||
children,
|
||||
tooltipTitle,
|
||||
tooltipDisabled = true,
|
||||
...props
|
||||
}: ButtonWithTooltipProps &
|
||||
Omit<GuardAuthZProps<R>, 'children'> & {
|
||||
tooltipTitle?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<GuardAuthZ
|
||||
relation={props.relation}
|
||||
object={props.object}
|
||||
fallbackOnLoading={
|
||||
<Button {...props} loading={true}>
|
||||
{children}
|
||||
</Button>
|
||||
}
|
||||
fallbackOnNoPermissions={({ requiredPermissionName }): JSX.Element => {
|
||||
const { relation, object } = parsePermission(requiredPermissionName);
|
||||
|
||||
return (
|
||||
<ButtonWithTooltip
|
||||
{...props}
|
||||
disabled={true}
|
||||
tooltipTitle={
|
||||
tooltipTitle || `You don't have ${relation}:${object} permission.`
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</ButtonWithTooltip>
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ButtonWithTooltip
|
||||
tooltipDisabled={tooltipDisabled}
|
||||
tooltipTitle={tooltipTitle}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ButtonWithTooltip>
|
||||
</GuardAuthZ>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,15 @@ function OnNoPermissionsFallback(response: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If you want to guard a route, you can use this function to create a guarded route.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* createGuardedRoute(Component, 'read', 'dashboard:{id}'); // use when route has a dynamic parameter like /dashboard/:id
|
||||
* createGuardedRoute(Component, 'list', 'dashboards');
|
||||
* ```
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export function createGuardedRoute<P extends object, R extends AuthZRelation>(
|
||||
Component: ComponentType<P>,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Layout } from 'react-grid-layout';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Button as SignozButton } from '@signozhq/button';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -16,13 +17,14 @@ import {
|
||||
} from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
|
||||
import { GuardButton } from 'components/PermissionlessButton/PermissionlessButton';
|
||||
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboardMeta';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { buildObjectString } from 'hooks/useAuthZ/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
@@ -38,13 +40,10 @@ import {
|
||||
PenLine,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { sortLayout } from 'providers/Dashboard/util';
|
||||
import { DashboardData } from 'types/api/dashboard/getAll';
|
||||
import { Props } from 'types/api/dashboard/update';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
import { ComponentTypes } from 'utils/permission';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
|
||||
@@ -108,8 +107,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
|
||||
const updateDashboardMutation = useUpdateDashboard();
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const [isDashboardSettingsOpen, setIsDashbordSettingsOpen] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
@@ -124,27 +121,8 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
|
||||
const [isPublicDashboard, setIsPublicDashboard] = useState<boolean>(false);
|
||||
|
||||
let isAuthor = false;
|
||||
|
||||
if (selectedDashboard && user && user.email) {
|
||||
isAuthor = selectedDashboard?.createdBy === user?.email;
|
||||
}
|
||||
|
||||
let permissions: ComponentTypes[] = ['add_panel'];
|
||||
|
||||
if (isDashboardLocked) {
|
||||
permissions = ['add_panel_locked_dashboard'];
|
||||
}
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const userRole: ROLES | null =
|
||||
selectedDashboard?.createdBy === user?.email
|
||||
? (USER_ROLES.AUTHOR as ROLES)
|
||||
: user.role;
|
||||
|
||||
const [addPanelPermission] = useComponentPermission(permissions, userRole);
|
||||
|
||||
const onEmptyWidgetHandler = useCallback(() => {
|
||||
setSelectedRowWidgetId(null);
|
||||
handleToggleDashboardSlider(true);
|
||||
@@ -369,63 +347,82 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
content={
|
||||
<div className="menu-content">
|
||||
<section className="section-1">
|
||||
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
|
||||
<Tooltip
|
||||
title={
|
||||
selectedDashboard?.createdBy === 'integration' &&
|
||||
'Dashboards created by integrations cannot be unlocked'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LockKeyhole size={14} />}
|
||||
disabled={selectedDashboard?.createdBy === 'integration'}
|
||||
onClick={handleLockDashboardToggle}
|
||||
data-testid="lock-unlock-dashboard"
|
||||
>
|
||||
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<GuardButton
|
||||
relation="update"
|
||||
object={buildObjectString('dashboard', selectedDashboard?.id || '')}
|
||||
variant="ghost"
|
||||
className="w-full justify-start px-2 gap-2"
|
||||
prefixIcon={<LockKeyhole />}
|
||||
onClick={handleLockDashboardToggle}
|
||||
tooltipTitle={
|
||||
selectedDashboard?.createdBy === 'integration'
|
||||
? 'Dashboards created by integrations cannot be unlocked'
|
||||
: undefined
|
||||
}
|
||||
disabled={selectedDashboard?.createdBy === 'integration'}
|
||||
tooltipDisabled={selectedDashboard?.createdBy !== 'integration'}
|
||||
>
|
||||
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
|
||||
</GuardButton>
|
||||
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PenLine size={14} />}
|
||||
onClick={(): void => {
|
||||
setIsRenameDashboardOpen(true);
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
)}
|
||||
<GuardButton
|
||||
relation="update"
|
||||
object={buildObjectString('dashboard', selectedDashboard?.id || '')}
|
||||
variant="ghost"
|
||||
className="w-full justify-start px-2 gap-2"
|
||||
prefixIcon={<PenLine />}
|
||||
onClick={(): void => {
|
||||
setIsRenameDashboardOpen(true);
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
tooltipTitle={
|
||||
isDashboardLocked
|
||||
? `You can't rename a locked dashboard.`
|
||||
: undefined
|
||||
}
|
||||
tooltipDisabled={!isDashboardLocked}
|
||||
disabled={isDashboardLocked}
|
||||
>
|
||||
Rename
|
||||
</GuardButton>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Fullscreen size={14} />}
|
||||
<SignozButton
|
||||
variant="ghost"
|
||||
className="w-full justify-start px-2 gap-2"
|
||||
prefixIcon={<Fullscreen />}
|
||||
onClick={handle.enter}
|
||||
>
|
||||
Full screen
|
||||
</Button>
|
||||
</SignozButton>
|
||||
</section>
|
||||
<section className="section-2">
|
||||
{!isDashboardLocked && addPanelPermission && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FolderKanban size={14} />}
|
||||
onClick={(): void => {
|
||||
setIsPanelNameModalOpen(true);
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
New section
|
||||
</Button>
|
||||
)}
|
||||
<GuardButton
|
||||
relation="update"
|
||||
object={buildObjectString('dashboard', selectedDashboard?.id || '')}
|
||||
variant="ghost"
|
||||
className="w-full justify-start px-2 gap-2"
|
||||
prefixIcon={<FolderKanban />}
|
||||
tooltipTitle={
|
||||
isDashboardLocked
|
||||
? `You can't add panel to a locked dashboard.`
|
||||
: undefined
|
||||
}
|
||||
disabled={isDashboardLocked}
|
||||
tooltipDisabled={!isDashboardLocked}
|
||||
onClick={(): void => {
|
||||
setIsPanelNameModalOpen(true);
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
New section
|
||||
</GuardButton>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FileJson size={14} />}
|
||||
<GuardButton
|
||||
relation="read"
|
||||
object={buildObjectString('dashboard', selectedDashboard?.id || '')}
|
||||
variant="ghost"
|
||||
className="w-full justify-start px-2 gap-2"
|
||||
prefixIcon={<FileJson />}
|
||||
onClick={(): void => {
|
||||
downloadObjectAsJson(
|
||||
sanitizeDashboardData(selectedData),
|
||||
@@ -435,10 +432,14 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
}}
|
||||
>
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ClipboardCopy size={14} />}
|
||||
</GuardButton>
|
||||
|
||||
<GuardButton
|
||||
relation="read"
|
||||
object={buildObjectString('dashboard', selectedDashboard?.id || '')}
|
||||
variant="ghost"
|
||||
className="w-full justify-start px-2 gap-2"
|
||||
prefixIcon={<ClipboardCopy />}
|
||||
onClick={(): void => {
|
||||
setCopy(
|
||||
JSON.stringify(sanitizeDashboardData(selectedData), null, 2),
|
||||
@@ -447,7 +448,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
}}
|
||||
>
|
||||
Copy as JSON
|
||||
</Button>
|
||||
</GuardButton>
|
||||
</section>
|
||||
<section className="delete-dashboard">
|
||||
<DeleteButton
|
||||
@@ -470,39 +471,50 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
data-testid="options"
|
||||
/>
|
||||
</Popover>
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
className="configure-button"
|
||||
icon={<ConfigureIcon />}
|
||||
data-testid="show-drawer"
|
||||
onClick={onConfigureClick}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={onSettingsDrawerClose}
|
||||
>
|
||||
<DashboardSettings
|
||||
variablesSettingsTabHandle={variablesSettingsTabHandle}
|
||||
/>
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
{!isDashboardLocked && addPanelPermission && (
|
||||
<Button
|
||||
className="add-panel-btn"
|
||||
onClick={onEmptyWidgetHandler}
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
data-testid="add-panel-header"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
<GuardButton
|
||||
relation="update"
|
||||
object={buildObjectString('dashboard', selectedDashboard?.id || '')}
|
||||
variant="ghost"
|
||||
prefixIcon={<ConfigureIcon />}
|
||||
className="configure-button"
|
||||
data-testid="show-drawer"
|
||||
onClick={onConfigureClick}
|
||||
tooltipTitle={
|
||||
isDashboardLocked ? `You can't configure a locked dashboard.` : undefined
|
||||
}
|
||||
tooltipDisabled={!isDashboardLocked}
|
||||
disabled={isDashboardLocked}
|
||||
>
|
||||
Configure
|
||||
</GuardButton>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={onSettingsDrawerClose}
|
||||
>
|
||||
<DashboardSettings
|
||||
variablesSettingsTabHandle={variablesSettingsTabHandle}
|
||||
/>
|
||||
</SettingsDrawer>
|
||||
<GuardButton
|
||||
relation="update"
|
||||
object={buildObjectString('dashboard', selectedDashboard?.id || '')}
|
||||
className="add-panel-btn"
|
||||
onClick={onEmptyWidgetHandler}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefixIcon={<PlusOutlined />}
|
||||
data-testid="add-panel-header"
|
||||
tooltipTitle={
|
||||
isDashboardLocked
|
||||
? `You can't add panel to a locked dashboard.`
|
||||
: undefined
|
||||
}
|
||||
disabled={isDashboardLocked}
|
||||
tooltipDisabled={!isDashboardLocked}
|
||||
>
|
||||
New Panel
|
||||
</GuardButton>
|
||||
</div>
|
||||
</section>
|
||||
{(tags?.length || 0) > 0 && (
|
||||
|
||||
@@ -31,6 +31,15 @@ jest.mock('hooks/useSafeNavigate', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('components/PermissionlessButton/PermissionlessButton', () => ({
|
||||
GuardButton: ({ children, prefixIcon, ...props }: any): JSX.Element => (
|
||||
<button type="button" {...props}>
|
||||
{prefixIcon}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockProps: WidgetGraphComponentProps = {
|
||||
widget: {
|
||||
@@ -205,7 +214,7 @@ describe('WidgetGraphComponent', () => {
|
||||
expect(skeleton).toBeInTheDocument();
|
||||
|
||||
const moreOptionsButton = getByTestId('widget-header-options');
|
||||
fireEvent.mouseEnter(moreOptionsButton);
|
||||
fireEvent.click(moreOptionsButton);
|
||||
|
||||
const menu = await findByRole('menu');
|
||||
expect(menu).toBeInTheDocument();
|
||||
|
||||
@@ -39,12 +39,8 @@
|
||||
}
|
||||
.widget-header-more-options {
|
||||
visibility: hidden;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
font: 14px;
|
||||
font-weight: 600;
|
||||
padding: 8px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.widget-header-more-options-visible {
|
||||
@@ -55,6 +51,13 @@
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.widget-header-menu-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.widget-header-container {
|
||||
.ant-input-group-addon {
|
||||
@@ -76,3 +79,14 @@
|
||||
.info-tooltip {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.widget-header-popover {
|
||||
.ant-popover-inner {
|
||||
padding: 0px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background) !important;
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,15 @@ const render = (ui: React.ReactElement): ReturnType<typeof rtlRender> =>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
jest.mock('components/PermissionlessButton/PermissionlessButton', () => ({
|
||||
GuardButton: ({ children, prefixIcon, ...props }: any): JSX.Element => (
|
||||
<button type="button" {...props}>
|
||||
{prefixIcon}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useCreateAlerts', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => jest.fn()),
|
||||
@@ -479,7 +488,7 @@ describe('WidgetHeader', () => {
|
||||
|
||||
const moreOptionsIcon = await screen.findByTestId(WIDGET_HEADER_OPTIONS_ID);
|
||||
expect(moreOptionsIcon).toBeInTheDocument();
|
||||
await userEvent.hover(moreOptionsIcon);
|
||||
await userEvent.click(moreOptionsIcon);
|
||||
|
||||
await screen.findByText(CREATE_ALERTS_TEXT);
|
||||
|
||||
@@ -513,7 +522,7 @@ describe('WidgetHeader', () => {
|
||||
expect(useCreateAlerts).toHaveBeenCalledWith(mockWidget, 'dashboardView');
|
||||
|
||||
const moreOptionsIcon = await screen.findByTestId(WIDGET_HEADER_OPTIONS_ID);
|
||||
await userEvent.hover(moreOptionsIcon);
|
||||
await userEvent.click(moreOptionsIcon);
|
||||
|
||||
const createAlertsMenuItem = await screen.findByText(CREATE_ALERTS_TEXT);
|
||||
|
||||
|
||||
@@ -2,25 +2,30 @@ import { ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import {
|
||||
AlertOutlined,
|
||||
CloudDownloadOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
EditFilled,
|
||||
FullscreenOutlined,
|
||||
InfoCircleOutlined,
|
||||
MoreOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Dropdown, Input, MenuProps, Tooltip, Typography } from 'antd';
|
||||
import {
|
||||
ClipboardCopy,
|
||||
CloudDownload,
|
||||
Expand,
|
||||
Pencil,
|
||||
Trash,
|
||||
} from '@signozhq/icons';
|
||||
import { Input, Popover, Tooltip, Typography } from 'antd';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import ErrorPopover from 'components/ErrorPopover/ErrorPopover';
|
||||
import { GuardButton } from 'components/PermissionlessButton/PermissionlessButton';
|
||||
import Spinner from 'components/Spinner';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import useGetResolvedText from 'hooks/dashboard/useGetResolvedText';
|
||||
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
|
||||
import { buildObjectString } from 'hooks/useAuthZ/utils';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
@@ -35,10 +40,10 @@ import APIError from 'types/api/error';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { buildAbsolutePath } from 'utils/app';
|
||||
|
||||
import { cn } from '../../../lib/cn';
|
||||
import { errorTooltipPosition } from './config';
|
||||
import { MENUITEM_KEYS_VS_LABELS, MenuItemKeys } from './contants';
|
||||
import { MenuItem } from './types';
|
||||
import { generateMenuList, isTWidgetOptions } from './utils';
|
||||
import { isTWidgetOptions } from './utils';
|
||||
|
||||
import './WidgetHeader.styles.scss';
|
||||
|
||||
@@ -62,6 +67,149 @@ interface IWidgetHeaderProps {
|
||||
setSearchTerm: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
interface WidgetActionsMenuProps {
|
||||
isViewVisible: boolean;
|
||||
isEditVisible: boolean;
|
||||
isCloneVisible: boolean;
|
||||
isDownloadVisible: boolean;
|
||||
isDeleteVisible: boolean;
|
||||
isCreateAlertsVisible: boolean;
|
||||
dashboardId: string;
|
||||
isFetching: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
onActionClick: (key: MenuItemKeys) => void;
|
||||
}
|
||||
|
||||
function WidgetActionsMenu({
|
||||
isViewVisible,
|
||||
isEditVisible,
|
||||
isCloneVisible,
|
||||
isDownloadVisible,
|
||||
isDeleteVisible,
|
||||
isCreateAlertsVisible,
|
||||
dashboardId,
|
||||
isFetching,
|
||||
canEdit,
|
||||
canDelete,
|
||||
onActionClick,
|
||||
}: WidgetActionsMenuProps): JSX.Element {
|
||||
return (
|
||||
<div className="widget-header-menu-content" role="menu">
|
||||
{isViewVisible && (
|
||||
<GuardButton
|
||||
variant="ghost"
|
||||
prefixIcon={<Expand />}
|
||||
role="menuitem"
|
||||
relation="read"
|
||||
object={buildObjectString('dashboard', dashboardId)}
|
||||
disabled={isFetching}
|
||||
className="w-full gap-2 px-2 justify-start"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onActionClick(MenuItemKeys.View);
|
||||
}}
|
||||
>
|
||||
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.View]}
|
||||
</GuardButton>
|
||||
)}
|
||||
{isEditVisible && (
|
||||
<GuardButton
|
||||
variant="ghost"
|
||||
prefixIcon={<Pencil />}
|
||||
role="menuitem"
|
||||
relation="update"
|
||||
object={buildObjectString('dashboard', dashboardId)}
|
||||
disabled={!canEdit}
|
||||
className="w-full gap-2 px-2 justify-start"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onActionClick(MenuItemKeys.Edit);
|
||||
}}
|
||||
>
|
||||
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.Edit]}
|
||||
</GuardButton>
|
||||
)}
|
||||
{isCloneVisible && (
|
||||
<GuardButton
|
||||
variant="ghost"
|
||||
prefixIcon={<ClipboardCopy />}
|
||||
role="menuitem"
|
||||
relation="update"
|
||||
object={buildObjectString('dashboard', dashboardId)}
|
||||
disabled={!canEdit}
|
||||
className="w-full gap-2 px-2 justify-start"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onActionClick(MenuItemKeys.Clone);
|
||||
}}
|
||||
>
|
||||
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.Clone]}
|
||||
</GuardButton>
|
||||
)}
|
||||
{isDownloadVisible && (
|
||||
<GuardButton
|
||||
variant="ghost"
|
||||
prefixIcon={<CloudDownload />}
|
||||
role="menuitem"
|
||||
relation="read"
|
||||
object={buildObjectString('dashboard', dashboardId)}
|
||||
className="w-full gap-2 px-2 justify-start"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onActionClick(MenuItemKeys.Download);
|
||||
}}
|
||||
>
|
||||
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.Download]}
|
||||
</GuardButton>
|
||||
)}
|
||||
{isDeleteVisible && (
|
||||
<GuardButton
|
||||
variant="ghost"
|
||||
prefixIcon={<Trash />}
|
||||
role="menuitem"
|
||||
relation="update"
|
||||
object={buildObjectString('dashboard', dashboardId)}
|
||||
disabled={!canDelete}
|
||||
className="w-full gap-2 px-2 justify-start text-bg-cherry-500"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onActionClick(MenuItemKeys.Delete);
|
||||
}}
|
||||
>
|
||||
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.Delete]}
|
||||
</GuardButton>
|
||||
)}
|
||||
{isCreateAlertsVisible && (
|
||||
<GuardButton
|
||||
variant="ghost"
|
||||
prefixIcon={<AlertOutlined />}
|
||||
role="menuitem"
|
||||
relation="read"
|
||||
object={buildObjectString('dashboard', dashboardId)}
|
||||
className="w-full gap-2 px-2 justify-start"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onActionClick(MenuItemKeys.CreateAlerts);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.CreateAlerts]}
|
||||
<SquareArrowOutUpRight size={10} />
|
||||
</span>
|
||||
</GuardButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetHeader({
|
||||
title,
|
||||
widget,
|
||||
@@ -125,8 +273,8 @@ function WidgetHeader({
|
||||
],
|
||||
);
|
||||
|
||||
const onMenuItemSelectHandler: MenuProps['onClick'] = useCallback(
|
||||
({ key }: { key: string }): void => {
|
||||
const onMenuItemSelectHandler = useCallback(
|
||||
(key: string): void => {
|
||||
if (isTWidgetOptions(key)) {
|
||||
const functionToCall = keyMethodMapping[key];
|
||||
|
||||
@@ -144,86 +292,37 @@ function WidgetHeader({
|
||||
user.role,
|
||||
);
|
||||
|
||||
const actions = useMemo(
|
||||
(): MenuItem[] => [
|
||||
{
|
||||
key: MenuItemKeys.View,
|
||||
icon: <FullscreenOutlined />,
|
||||
label: MENUITEM_KEYS_VS_LABELS[MenuItemKeys.View],
|
||||
isVisible: headerMenuList?.includes(MenuItemKeys.View) || false,
|
||||
disabled: queryResponse.isFetching,
|
||||
},
|
||||
{
|
||||
key: MenuItemKeys.Edit,
|
||||
icon: <EditFilled />,
|
||||
label: MENUITEM_KEYS_VS_LABELS[MenuItemKeys.Edit],
|
||||
isVisible: headerMenuList?.includes(MenuItemKeys.Edit) || false,
|
||||
disabled: !editWidget,
|
||||
},
|
||||
{
|
||||
key: MenuItemKeys.Clone,
|
||||
icon: <CopyOutlined />,
|
||||
label: MENUITEM_KEYS_VS_LABELS[MenuItemKeys.Clone],
|
||||
isVisible: headerMenuList?.includes(MenuItemKeys.Clone) || false,
|
||||
disabled: !editWidget,
|
||||
},
|
||||
{
|
||||
key: MenuItemKeys.Download,
|
||||
icon: <CloudDownloadOutlined />,
|
||||
label: MENUITEM_KEYS_VS_LABELS[MenuItemKeys.Download],
|
||||
isVisible: widget.panelTypes === PANEL_TYPES.TABLE,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
key: MenuItemKeys.Delete,
|
||||
icon: <DeleteOutlined />,
|
||||
label: MENUITEM_KEYS_VS_LABELS[MenuItemKeys.Delete],
|
||||
isVisible: headerMenuList?.includes(MenuItemKeys.Delete) || false,
|
||||
disabled: !deleteWidget,
|
||||
danger: true,
|
||||
},
|
||||
{
|
||||
key: MenuItemKeys.CreateAlerts,
|
||||
icon: <AlertOutlined />,
|
||||
label: (
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.CreateAlerts]}
|
||||
<SquareArrowOutUpRight size={10} />
|
||||
</span>
|
||||
),
|
||||
isVisible: headerMenuList?.includes(MenuItemKeys.CreateAlerts) || false,
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
headerMenuList,
|
||||
queryResponse.isFetching,
|
||||
editWidget,
|
||||
deleteWidget,
|
||||
widget.panelTypes,
|
||||
],
|
||||
const dashboardResourceId = useMemo(() => {
|
||||
const pathname = globalThis.window?.location?.pathname || '';
|
||||
return pathname.match(/\/dashboard\/([^/]+)/)?.[1] || String(widget.id);
|
||||
}, [widget.id]);
|
||||
const isViewVisible = headerMenuList?.includes(MenuItemKeys.View) || false;
|
||||
const isEditVisible = headerMenuList?.includes(MenuItemKeys.Edit) || false;
|
||||
const isCloneVisible = headerMenuList?.includes(MenuItemKeys.Clone) || false;
|
||||
const isDeleteVisible = headerMenuList?.includes(MenuItemKeys.Delete) || false;
|
||||
const isCreateAlertsVisible =
|
||||
headerMenuList?.includes(MenuItemKeys.CreateAlerts) || false;
|
||||
const isDownloadVisible = widget.panelTypes === PANEL_TYPES.TABLE;
|
||||
const hasVisibleActions =
|
||||
isViewVisible ||
|
||||
isEditVisible ||
|
||||
isCloneVisible ||
|
||||
isDeleteVisible ||
|
||||
isCreateAlertsVisible ||
|
||||
isDownloadVisible;
|
||||
const onWidgetActionClick = useCallback(
|
||||
(key: MenuItemKeys): void => {
|
||||
onMenuItemSelectHandler(key);
|
||||
setIsWidgetActionsOpen(false);
|
||||
},
|
||||
[onMenuItemSelectHandler],
|
||||
);
|
||||
|
||||
const updatedMenuList = useMemo(() => generateMenuList(actions), [actions]);
|
||||
|
||||
const [showGlobalSearch, setShowGlobalSearch] = useState(false);
|
||||
const [isWidgetActionsOpen, setIsWidgetActionsOpen] = useState(false);
|
||||
|
||||
const globalSearchAvailable = widget.panelTypes === PANEL_TYPES.TABLE;
|
||||
|
||||
const menu = useMemo(
|
||||
() => ({
|
||||
items: updatedMenuList,
|
||||
onClick: onMenuItemSelectHandler,
|
||||
}),
|
||||
[updatedMenuList, onMenuItemSelectHandler],
|
||||
);
|
||||
|
||||
const { truncatedText, fullText } = useGetResolvedText({
|
||||
text: widget.title as string,
|
||||
maxLength: 100,
|
||||
@@ -319,15 +418,41 @@ function WidgetHeader({
|
||||
/>
|
||||
)}
|
||||
|
||||
{menu && Array.isArray(menu.items) && menu.items.length > 0 && (
|
||||
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
|
||||
<MoreOutlined
|
||||
{hasVisibleActions && (
|
||||
<Popover
|
||||
open={isWidgetActionsOpen}
|
||||
onOpenChange={setIsWidgetActionsOpen}
|
||||
arrow={false}
|
||||
rootClassName="widget-header-popover"
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
content={
|
||||
<WidgetActionsMenu
|
||||
isViewVisible={isViewVisible}
|
||||
isEditVisible={isEditVisible}
|
||||
isCloneVisible={isCloneVisible}
|
||||
isDownloadVisible={isDownloadVisible}
|
||||
isDeleteVisible={isDeleteVisible}
|
||||
isCreateAlertsVisible={isCreateAlertsVisible}
|
||||
dashboardId={dashboardResourceId}
|
||||
isFetching={queryResponse.isFetching}
|
||||
canEdit={editWidget}
|
||||
canDelete={deleteWidget}
|
||||
onActionClick={onWidgetActionClick}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-testid="widget-header-options"
|
||||
className={`widget-header-more-options ${
|
||||
globalSearchAvailable ? 'widget-header-more-options-visible' : ''
|
||||
}`}
|
||||
className={cn(
|
||||
'widget-header-more-options',
|
||||
globalSearchAvailable && 'widget-header-more-options-visible',
|
||||
)}
|
||||
prefixIcon={<MoreOutlined />}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -43,7 +43,6 @@ import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/Gene
|
||||
import dayjs from 'dayjs';
|
||||
import useDashboardsListQueryParams from 'hooks/dashboard/useDashboardsListQueryParams';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
@@ -72,7 +71,6 @@ import {
|
||||
// #TODO: lucide will be removing brand icons like Github in future, in that case we can use simple icons
|
||||
// see more: https://github.com/lucide-icons/lucide/issues/94
|
||||
import { handleContactSupport } from 'pages/Integrations/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
@@ -83,6 +81,8 @@ import {
|
||||
} from 'types/api/dashboard/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { GuardButton } from '../../components/PermissionlessButton/PermissionlessButton';
|
||||
import { buildObjectString } from '../../hooks/useAuthZ/utils';
|
||||
import DashboardTemplatesModal from './DashboardTemplates/DashboardTemplatesModal';
|
||||
import ImportJSON from './ImportJSON';
|
||||
import { RequestDashboardBtn } from './RequestDashboardBtn';
|
||||
@@ -105,7 +105,6 @@ function DashboardsList(): JSX.Element {
|
||||
refetch: refetchDashboardList,
|
||||
} = useGetAllDashboard();
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const {
|
||||
dashboardsListQueryParams,
|
||||
@@ -117,10 +116,6 @@ function DashboardsList(): JSX.Element {
|
||||
const [searchString, setSearchString] = useState<string>(
|
||||
dashboardsListQueryParams.search || '',
|
||||
);
|
||||
const [action, createNewDashboard] = useComponentPermission(
|
||||
['action', 'create_new_dashboards'],
|
||||
user.role,
|
||||
);
|
||||
|
||||
const [
|
||||
showNewDashboardTemplatesModal,
|
||||
@@ -435,78 +430,87 @@ function DashboardsList(): JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action && (
|
||||
<Popover
|
||||
trigger="click"
|
||||
content={
|
||||
<div className="dashboard-action-content">
|
||||
<section className="section-1">
|
||||
<Button
|
||||
type="text"
|
||||
className="action-btn"
|
||||
icon={<Expand size={12} />}
|
||||
onClick={onClickHandler}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className="action-btn"
|
||||
icon={<SquareArrowOutUpRight size={12} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
window.open(getLink(), '_blank');
|
||||
}}
|
||||
>
|
||||
Open in New Tab
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className="action-btn"
|
||||
icon={<Link2 size={12} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setCopy(`${window.location.origin}${getLink()}`);
|
||||
}}
|
||||
>
|
||||
Copy Link
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className="action-btn"
|
||||
icon={<FileJson size={12} />}
|
||||
onClick={handleJsonExport}
|
||||
>
|
||||
Export JSON
|
||||
</Button>
|
||||
</section>
|
||||
<section className="section-2">
|
||||
<DeleteButton
|
||||
name={dashboard.name}
|
||||
id={dashboard.id}
|
||||
isLocked={dashboard.isLocked}
|
||||
createdBy={dashboard.createdBy}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
rootClassName="dashboard-actions"
|
||||
>
|
||||
<EllipsisVertical
|
||||
className="dashboard-action-icon"
|
||||
size={14}
|
||||
data-testid="dashboard-action-icon"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
<Popover
|
||||
trigger="click"
|
||||
content={
|
||||
<div
|
||||
className="dashboard-action-content"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<section className="section-1">
|
||||
<GuardButton
|
||||
variant="link"
|
||||
prefixIcon={<Expand size={12} className="mr-2" />}
|
||||
onClick={onClickHandler}
|
||||
relation="read"
|
||||
className="gap-2 px-2 justify-start"
|
||||
object={buildObjectString('dashboard', dashboard.id)}
|
||||
>
|
||||
View
|
||||
</GuardButton>
|
||||
<GuardButton
|
||||
variant="link"
|
||||
relation="read"
|
||||
object={buildObjectString('dashboard', dashboard.id)}
|
||||
className="gap-2 px-2 justify-start"
|
||||
prefixIcon={<SquareArrowOutUpRight size={12} className="mr-2" />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
window.open(getLink(), '_blank');
|
||||
}}
|
||||
>
|
||||
Open in New Tab
|
||||
</GuardButton>
|
||||
<GuardButton
|
||||
variant="link"
|
||||
relation="read"
|
||||
object={buildObjectString('dashboard', dashboard.id)}
|
||||
className="gap-2 px-2 justify-start"
|
||||
prefixIcon={<Link2 size={12} className="mr-2" />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setCopy(`${window.location.origin}${getLink()}`);
|
||||
}}
|
||||
>
|
||||
Copy Link
|
||||
</GuardButton>
|
||||
<GuardButton
|
||||
variant="link"
|
||||
relation="read"
|
||||
object={buildObjectString('dashboard', dashboard.id)}
|
||||
className="gap-2 px-2 justify-start"
|
||||
prefixIcon={<FileJson size={12} className="mr-2" />}
|
||||
onClick={handleJsonExport}
|
||||
>
|
||||
Export JSON
|
||||
</GuardButton>
|
||||
</section>
|
||||
<section className="section-2">
|
||||
<DeleteButton
|
||||
name={dashboard.name}
|
||||
id={dashboard.id}
|
||||
isLocked={dashboard.isLocked}
|
||||
createdBy={dashboard.createdBy}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
rootClassName="dashboard-actions"
|
||||
>
|
||||
<EllipsisVertical
|
||||
className="dashboard-action-icon"
|
||||
size={14}
|
||||
data-testid="dashboard-action-icon"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="dashboard-details">
|
||||
<div className="dashboard-created-at">
|
||||
@@ -559,6 +563,19 @@ function DashboardsList(): JSX.Element {
|
||||
|
||||
const getCreateDashboardItems = useMemo(() => {
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
label: (
|
||||
<div
|
||||
className="create-dashboard-menu-item"
|
||||
onClick={(): void => {
|
||||
onNewDashboardHandler();
|
||||
}}
|
||||
>
|
||||
<LayoutGrid size={14} /> Create dashboard
|
||||
</div>
|
||||
),
|
||||
key: '0',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div
|
||||
@@ -594,24 +611,8 @@ function DashboardsList(): JSX.Element {
|
||||
},
|
||||
];
|
||||
|
||||
if (createNewDashboard) {
|
||||
menuItems.unshift({
|
||||
label: (
|
||||
<div
|
||||
className="create-dashboard-menu-item"
|
||||
onClick={(): void => {
|
||||
onNewDashboardHandler();
|
||||
}}
|
||||
>
|
||||
<LayoutGrid size={14} /> Create dashboard
|
||||
</div>
|
||||
),
|
||||
key: '0',
|
||||
});
|
||||
}
|
||||
|
||||
return menuItems;
|
||||
}, [createNewDashboard, onNewDashboardHandler]);
|
||||
}, [onNewDashboardHandler]);
|
||||
|
||||
const showPaginationItem = (total: number, range: number[]): JSX.Element => (
|
||||
<>
|
||||
@@ -719,41 +720,41 @@ function DashboardsList(): JSX.Element {
|
||||
</Typography.Text>
|
||||
</section>
|
||||
|
||||
{createNewDashboard && (
|
||||
<section className="actions">
|
||||
<Dropdown
|
||||
overlayClassName="new-dashboard-menu"
|
||||
menu={{ items: getCreateDashboardItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
className="new-dashboard"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Dashboard List: New dashboard clicked', {});
|
||||
}}
|
||||
>
|
||||
New Dashboard
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Button
|
||||
type="text"
|
||||
className="learn-more"
|
||||
data-testid="learn-more"
|
||||
<section className="actions">
|
||||
<Dropdown
|
||||
overlayClassName="new-dashboard-menu"
|
||||
menu={{ items: getCreateDashboardItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<GuardButton
|
||||
relation="create"
|
||||
object="dashboards"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefixIcon={<Plus />}
|
||||
onClick={(): void => {
|
||||
window.open(
|
||||
'https://signoz.io/docs/userguide/manage-dashboards?utm_source=product&utm_medium=dashboard-list-empty-state',
|
||||
'_blank',
|
||||
);
|
||||
logEvent('Dashboard List: New dashboard clicked', {});
|
||||
}}
|
||||
>
|
||||
Learn more
|
||||
</Button>
|
||||
<ArrowUpRight size={16} className="learn-more-arrow" />
|
||||
</section>
|
||||
)}
|
||||
New Dashboard
|
||||
</GuardButton>
|
||||
</Dropdown>
|
||||
<Button
|
||||
type="text"
|
||||
className="learn-more"
|
||||
data-testid="learn-more"
|
||||
onClick={(): void => {
|
||||
window.open(
|
||||
'https://signoz.io/docs/userguide/manage-dashboards?utm_source=product&utm_medium=dashboard-list-empty-state',
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
>
|
||||
Learn more
|
||||
</Button>
|
||||
<ArrowUpRight size={16} className="learn-more-arrow" />
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -764,25 +765,25 @@ function DashboardsList(): JSX.Element {
|
||||
value={searchString}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
{createNewDashboard && (
|
||||
<Dropdown
|
||||
overlayClassName="new-dashboard-menu"
|
||||
menu={{ items: getCreateDashboardItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
<Dropdown
|
||||
overlayClassName="new-dashboard-menu"
|
||||
menu={{ items: getCreateDashboardItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<GuardButton
|
||||
relation="create"
|
||||
object="dashboards"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefixIcon={<Plus />}
|
||||
onClick={(): void => {
|
||||
logEvent('Dashboard List: New dashboard clicked', {});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
className="periscope-btn primary btn"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Dashboard List: New dashboard clicked', {});
|
||||
}}
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
New dashboard
|
||||
</GuardButton>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{dashboards.length === 0 ? (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import { Modal, Tooltip, Typography } from 'antd';
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import { Trash } from '@signozhq/icons';
|
||||
import { Modal, Typography } from 'antd';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useDeleteDashboard } from 'hooks/dashboard/useDeleteDashboard';
|
||||
@@ -11,8 +12,10 @@ import history from 'lib/history';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { ButtonWithTooltip } from '../../../components/ButtonWithTooltip/ButtonWithTooltip';
|
||||
import { useAuthZ } from '../../../hooks/useAuthZ/useAuthZ';
|
||||
import { buildPermission } from '../../../hooks/useAuthZ/utils';
|
||||
import { Data } from '../DashboardsList';
|
||||
import { TableLinkText } from './styles';
|
||||
|
||||
import './DeleteButton.styles.scss';
|
||||
|
||||
@@ -88,7 +91,20 @@ export function DeleteButton({
|
||||
routeToListPage,
|
||||
]);
|
||||
|
||||
const getDeleteTooltipContent = (): string => {
|
||||
const canDeletePermission = buildPermission('assignee', `role:signoz-admin`);
|
||||
const canDeleteProps = useAuthZ([canDeletePermission]);
|
||||
const canDelete =
|
||||
!isLocked && canDeleteProps.permissions?.[canDeletePermission]?.isGranted;
|
||||
|
||||
const tooltipMessage = useMemo(() => {
|
||||
if (canDeleteProps.error) {
|
||||
return canDeleteProps.error.message;
|
||||
}
|
||||
|
||||
if (canDelete) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isLocked) {
|
||||
if (user.role === USER_ROLES.ADMIN || isAuthor) {
|
||||
return t('dashboard:locked_dashboard_delete_tooltip_admin_author');
|
||||
@@ -97,27 +113,30 @@ export function DeleteButton({
|
||||
return t('dashboard:locked_dashboard_delete_tooltip_editor');
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
return `You don't have delete:dashboard:${id} permission.`;
|
||||
}, [canDeleteProps.error, canDelete, isLocked, user, isAuthor, id, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip placement="left" title={getDeleteTooltipContent()}>
|
||||
<TableLinkText
|
||||
type="danger"
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isLocked) {
|
||||
openConfirmationDialog();
|
||||
}
|
||||
}}
|
||||
className="delete-btn"
|
||||
disabled={isLocked || (user.role === USER_ROLES.VIEWER && !isAuthor)}
|
||||
>
|
||||
<DeleteOutlined /> Delete dashboard
|
||||
</TableLinkText>
|
||||
</Tooltip>
|
||||
<ButtonWithTooltip
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isLocked) {
|
||||
openConfirmationDialog();
|
||||
}
|
||||
}}
|
||||
loading={canDeleteProps.isLoading}
|
||||
className="w-full gap-2 px-2 justify-start"
|
||||
disabled={!canDelete}
|
||||
prefixIcon={<Trash className="mr-2" />}
|
||||
tooltipTitle={tooltipMessage}
|
||||
tooltipDisabled={canDeleteProps.isLoading || canDelete}
|
||||
>
|
||||
Delete dashboard
|
||||
</ButtonWithTooltip>
|
||||
|
||||
{contextHolder}
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,10 @@ export default {
|
||||
name: 'dashboards',
|
||||
type: 'metaresources',
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
type: 'role',
|
||||
},
|
||||
],
|
||||
relations: {
|
||||
create: ['metaresources'],
|
||||
@@ -18,6 +22,7 @@ export default {
|
||||
list: ['metaresources'],
|
||||
read: ['user', 'role', 'organization', 'metaresource'],
|
||||
update: ['user', 'role', 'organization', 'metaresource'],
|
||||
assignee: ['role'],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -14,7 +14,7 @@ type ResourceTypeMap = {
|
||||
|
||||
type RelationName = keyof RelationsByType;
|
||||
|
||||
type ResourcesForRelation<R extends RelationName> = Extract<
|
||||
export type ResourcesForRelation<R extends RelationName> = Extract<
|
||||
Resource,
|
||||
{ type: RelationsByType[R][number] }
|
||||
>['name'];
|
||||
|
||||
@@ -3,9 +3,9 @@ import permissionsType from './permissions.type';
|
||||
import {
|
||||
AuthZObject,
|
||||
AuthZRelation,
|
||||
AuthZResource,
|
||||
BrandedPermission,
|
||||
ResourceName,
|
||||
ResourcesForRelation,
|
||||
ResourceType,
|
||||
} from './types';
|
||||
|
||||
@@ -19,11 +19,11 @@ export function buildPermission<R extends AuthZRelation>(
|
||||
return `${relation}${PermissionSeparator}${object}` as BrandedPermission;
|
||||
}
|
||||
|
||||
export function buildObjectString(
|
||||
resource: AuthZResource,
|
||||
export function buildObjectString<R extends 'delete' | 'read' | 'update'>(
|
||||
resource: ResourcesForRelation<R>,
|
||||
objectId: string,
|
||||
): `${AuthZResource}${typeof ObjectSeparator}${string}` {
|
||||
return `${resource}${ObjectSeparator}${objectId}` as const;
|
||||
): AuthZObject<R> {
|
||||
return `${resource}${ObjectSeparator}${objectId}` as AuthZObject<R>;
|
||||
}
|
||||
|
||||
export function parsePermission(
|
||||
|
||||
6
frontend/src/lib/cn.ts
Normal file
6
frontend/src/lib/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -764,3 +764,7 @@ notifications - 2050
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div[data-radix-popper-content-wrapper] {
|
||||
z-index: 5000 !important;
|
||||
}
|
||||
|
||||
@@ -8426,16 +8426,16 @@ clone@^1.0.2:
|
||||
resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
|
||||
integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==
|
||||
|
||||
clsx@2.1.1, clsx@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
|
||||
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
|
||||
|
||||
clsx@^1.1.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz"
|
||||
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
|
||||
|
||||
clsx@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
|
||||
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
|
||||
|
||||
cmdk@^0.2.1:
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/cmdk/-/cmdk-0.2.1.tgz#aa8e1332bb0b8d8484e793017c82537351188d9a"
|
||||
@@ -18922,6 +18922,11 @@ table@^6.0.9:
|
||||
string-width "^4.2.3"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
tailwind-merge@3.5.0:
|
||||
version "3.5.0"
|
||||
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz#06502f4496ba15151445d97d916a26564d50d1ca"
|
||||
integrity sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==
|
||||
|
||||
tailwind-merge@^2.5.2:
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5"
|
||||
|
||||
Reference in New Issue
Block a user