mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-31 21:40:42 +00:00
Compare commits
1 Commits
feat/react
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b37231f31a |
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Dot } from '@signozhq/icons';
|
||||
import { Dot, Globe, Inbox, SquarePen } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { Globe, Inbox, SquarePen } from '@signozhq/icons';
|
||||
|
||||
import AnnouncementsModal from './AnnouncementsModal';
|
||||
import FeedbackModal from './FeedbackModal';
|
||||
|
||||
@@ -18,11 +18,9 @@ import {
|
||||
import ROUTES from 'constants/routes';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
buildExplorerNavigationUrl,
|
||||
findSavedViewInLists,
|
||||
openSavedView,
|
||||
openSavedViewByKey,
|
||||
@@ -232,23 +230,6 @@ describe('findSavedViewInLists', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExplorerNavigationUrl', () => {
|
||||
it('encodes composite query and view selectors', () => {
|
||||
const url = buildExplorerNavigationUrl(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
{ queryType: 'builder' } as never,
|
||||
{
|
||||
[QueryParams.panelTypes]: PANEL_TYPES.LIST,
|
||||
[QueryParams.viewKey]: 'view-1',
|
||||
},
|
||||
);
|
||||
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain(`${QueryParams.compositeQuery}=`);
|
||||
expect(url).toContain(`${QueryParams.viewKey}=`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openSavedView', () => {
|
||||
beforeEach(() => {
|
||||
mockedNavigate.mockClear();
|
||||
|
||||
@@ -12,7 +12,7 @@ import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource'
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'container/SavedViews/constants';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
|
||||
|
||||
type SavedViewSourceHint = SavedViewSourcePage;
|
||||
|
||||
@@ -72,26 +72,6 @@ export function explorerRouteForSourcePage(
|
||||
return SOURCEPAGE_VS_ROUTES[sourcePage] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an explorer URL the same way `redirectWithQueryBuilderData` does —
|
||||
* without inheriting stale query params from the current page's `urlQuery`.
|
||||
*/
|
||||
export function buildExplorerNavigationUrl(
|
||||
route: string,
|
||||
query: Query,
|
||||
searchParams: Record<string, unknown>,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
params.set(key, JSON.stringify(value));
|
||||
});
|
||||
return `${route}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function openSavedView(view: SavedviewtypesSavedViewDTO): void {
|
||||
const route = view.source ? explorerRouteForSourcePage(view.source) : null;
|
||||
if (!route) {
|
||||
|
||||
@@ -38,8 +38,7 @@ import { useAppContext } from 'providers/App/App';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { isIngestionActive } from 'utils/app';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { isIngestionActive, isModifierKeyPressed } from 'utils/app';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import crackerUrl from '@/assets/Icons/cracker.svg';
|
||||
|
||||
@@ -77,6 +77,11 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.noFilters {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { CalendarClock } from '@signozhq/icons';
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
|
||||
import { useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { SavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import { SAVED_VIEW_UPDATED_AT_FORMAT } from './constants';
|
||||
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
|
||||
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
|
||||
import SavedViewHoverCardDetails from './SavedViewHoverCardDetails';
|
||||
import { SavedViewHoverCardProps } from './types';
|
||||
|
||||
import styles from './SavedViewHoverCard.module.scss';
|
||||
@@ -18,23 +13,11 @@ function SavedViewHoverCard({
|
||||
onCardLeave,
|
||||
onClose,
|
||||
}: SavedViewHoverCardProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const view = target?.view;
|
||||
const querySummaries = useMemo(
|
||||
() => (view ? getSavedViewQuerySummaries(view) : []),
|
||||
[view],
|
||||
);
|
||||
const hasQueryNames = querySummaries.length > 1;
|
||||
const copyValue = querySummaries
|
||||
.filter(({ expression }) => expression.length > 0)
|
||||
.map(({ queryName, expression }) =>
|
||||
hasQueryNames ? `${queryName}: ${expression}` : expression,
|
||||
)
|
||||
.join('\n');
|
||||
const { allowed: canReadAll } = useAuthZ([SavedViewReadPermission]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={!!view}
|
||||
open={!!target}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
@@ -55,71 +38,17 @@ function SavedViewHoverCard({
|
||||
onOpenAutoFocus={(event): void => event.preventDefault()}
|
||||
onCloseAutoFocus={(event): void => event.preventDefault()}
|
||||
>
|
||||
{view && (
|
||||
{target && (
|
||||
<div
|
||||
className={styles.content}
|
||||
onMouseEnter={onCardEnter}
|
||||
onMouseLeave={onCardLeave}
|
||||
data-testid="saved-view-hover-card"
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={styles.name}>
|
||||
{view.spec.displayName}
|
||||
</Typography.Text>
|
||||
{copyValue && (
|
||||
<CopyButton
|
||||
value={copyValue}
|
||||
ariaLabel="Copy query"
|
||||
testId="saved-view-hover-card-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.query} data-testid="saved-view-hover-card-query">
|
||||
{querySummaries.length === 0 ? (
|
||||
<Typography.Text className={styles.noFilters}>
|
||||
No filters
|
||||
</Typography.Text>
|
||||
) : (
|
||||
querySummaries.map(({ queryName, expression, metric }) => (
|
||||
<div key={queryName} className={styles.queryItem}>
|
||||
{hasQueryNames && (
|
||||
<Typography.Text className={styles.queryName}>
|
||||
{queryName}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{metric && (
|
||||
<Typography.Text className={styles.metric}>{metric}</Typography.Text>
|
||||
)}
|
||||
{expression && (
|
||||
<Typography.Text className={styles.expression}>
|
||||
<SavedViewHighlightedExpression expression={expression} />
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.updated}>
|
||||
<Typography.Text className={styles.sectionTitle}>
|
||||
Last updated
|
||||
</Typography.Text>
|
||||
{view.updatedBy && (
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{view.updatedBy}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{view.updatedAt && (
|
||||
<div className={styles.meta}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
view.updatedAt,
|
||||
SAVED_VIEW_UPDATED_AT_FORMAT,
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SavedViewHoverCardDetails
|
||||
viewId={target.viewId}
|
||||
canReadAll={canReadAll}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
|
||||
121
frontend/src/container/SavedViews/SavedViewHoverCardDetails.tsx
Normal file
121
frontend/src/container/SavedViews/SavedViewHoverCardDetails.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useMemo } from 'react';
|
||||
import { CalendarClock } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
|
||||
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import {
|
||||
SAVED_VIEW_LOAD_FAILED_NAME,
|
||||
SAVED_VIEW_UPDATED_AT_FORMAT,
|
||||
} from './constants';
|
||||
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
|
||||
import SavedViewHoverCardSkeleton from './SavedViewHoverCardSkeleton';
|
||||
import { SavedViewHoverCardDetailsProps } from './types';
|
||||
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
|
||||
|
||||
import styles from './SavedViewHoverCard.module.scss';
|
||||
|
||||
function SavedViewHoverCardDetails({
|
||||
viewId,
|
||||
}: SavedViewHoverCardDetailsProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const { data, isLoading, isError } = useGetSavedView({ id: viewId });
|
||||
const view = data?.data;
|
||||
const querySummaries = useMemo(
|
||||
() => (view ? getSavedViewQuerySummaries(view) : []),
|
||||
[view],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <SavedViewHoverCardSkeleton />;
|
||||
}
|
||||
|
||||
if (isError || !view) {
|
||||
return (
|
||||
<Typography.Text
|
||||
className={styles.error}
|
||||
data-testid="saved-view-hover-card-error"
|
||||
>
|
||||
{SAVED_VIEW_LOAD_FAILED_NAME}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
const hasQueryNames = querySummaries.length > 1;
|
||||
const copyValue = querySummaries
|
||||
.filter(({ expression }) => expression.length > 0)
|
||||
.map(({ queryName, expression }) =>
|
||||
hasQueryNames ? `${queryName}: ${expression}` : expression,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={styles.name}>
|
||||
{view.spec.displayName}
|
||||
</Typography.Text>
|
||||
{copyValue && (
|
||||
<CopyButton
|
||||
value={copyValue}
|
||||
ariaLabel="Copy query"
|
||||
testId="saved-view-hover-card-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.query} data-testid="saved-view-hover-card-query">
|
||||
{querySummaries.length === 0 ? (
|
||||
<Typography.Text className={styles.noFilters}>No filters</Typography.Text>
|
||||
) : (
|
||||
querySummaries.map(({ queryName, expression, metric }) => (
|
||||
<div key={queryName} className={styles.queryItem}>
|
||||
{hasQueryNames && (
|
||||
<Typography.Text className={styles.queryName}>
|
||||
{queryName}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{metric && (
|
||||
<Typography.Text className={styles.metric}>{metric}</Typography.Text>
|
||||
)}
|
||||
{expression && (
|
||||
<Typography.Text className={styles.expression}>
|
||||
<SavedViewHighlightedExpression expression={expression} />
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.updated}>
|
||||
<Typography.Text className={styles.sectionTitle}>
|
||||
Last updated
|
||||
</Typography.Text>
|
||||
{view.updatedBy && (
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{view.updatedBy}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{view.updatedAt && (
|
||||
<div className={styles.meta}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
view.updatedAt,
|
||||
SAVED_VIEW_UPDATED_AT_FORMAT,
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withAuthZContent(SavedViewHoverCardDetails, {
|
||||
checks: ({ viewId, canReadAll }) =>
|
||||
canReadAll ? [] : [buildSavedViewReadPermission(viewId)],
|
||||
fallbackOnLoading: <SavedViewHoverCardSkeleton />,
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
|
||||
function SavedViewHoverCardSkeleton(): JSX.Element {
|
||||
return (
|
||||
<Skeleton
|
||||
active
|
||||
paragraph={{ rows: 3, width: ['100%', '80%', '60%'] }}
|
||||
testId="saved-view-hover-card-loading"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewHoverCardSkeleton;
|
||||
@@ -54,9 +54,8 @@
|
||||
&.isActive {
|
||||
background-color: var(--l3-background);
|
||||
|
||||
.rowName {
|
||||
--button-variant-ghost-color: var(--l1-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
.rowLink {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,18 +75,32 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rowName {
|
||||
--button-height: 100%;
|
||||
--button-padding: 0 var(--spacing-6);
|
||||
--button-justify-content: flex-start;
|
||||
--button-font-size: var(--periscope-font-size-base);
|
||||
--button-variant-ghost-color: var(--l2-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l2-foreground);
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
|
||||
.rowLink {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0 var(--spacing-6);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--l2-foreground);
|
||||
text-decoration: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--button-focus-ring);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--l2-foreground-hover);
|
||||
}
|
||||
}
|
||||
|
||||
// nowrap fills the row before the ellipsis; line clamping alone stops at the last whole word.
|
||||
.rowLinkLabel {
|
||||
min-width: 0;
|
||||
letter-spacing: -0.005em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
||||
@@ -65,11 +65,11 @@ function SavedViewsList({
|
||||
|
||||
const rowHover = useMemo<SavedViewsListRowHoverProps>(
|
||||
() => ({
|
||||
onRowEnter: (view, row): void => {
|
||||
onRowEnter: (viewId, row): void => {
|
||||
const listTop = listRef.current?.getBoundingClientRect().top ?? 0;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
onHoverCardRowEnter({
|
||||
view,
|
||||
viewId,
|
||||
anchorTop: rowRect.top - listTop,
|
||||
anchorHeight: rowRect.height,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import cx from 'classnames';
|
||||
import { Undo2 } from '@signozhq/icons';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import SavedViewsRowButton from './SavedViewsRowButton';
|
||||
import SavedViewsRowLink from './SavedViewsRowLink';
|
||||
import SavedViewsRowMenu from './SavedViewsRowMenu';
|
||||
import { SavedViewsListRowProps } from './types';
|
||||
|
||||
@@ -18,42 +16,17 @@ function SavedViewsListRow({
|
||||
onAction,
|
||||
hover,
|
||||
}: SavedViewsListRowProps): JSX.Element {
|
||||
const name = view.spec.displayName;
|
||||
const readPermission = buildSavedViewReadPermission(view.id);
|
||||
// Same cached check as the button; gates the hover card.
|
||||
const { isLoading, deniedPermissions } = useAuthZ([readPermission]);
|
||||
const canRead = !isLoading && deniedPermissions.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.row, { [styles.isActive]: isActive })}
|
||||
data-testid="saved-views-row"
|
||||
data-view-id={view.id}
|
||||
onMouseEnter={(event): void => {
|
||||
if (canRead) {
|
||||
hover.onRowEnter(view, event.currentTarget);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(event): void =>
|
||||
hover.onRowEnter(view.id, event.currentTarget)
|
||||
}
|
||||
onMouseLeave={hover.onRowLeave}
|
||||
>
|
||||
<div className={styles.rowName}>
|
||||
<AuthZButton
|
||||
checks={[readPermission]}
|
||||
size="md"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
width="100%"
|
||||
textOverflow="ellipsis"
|
||||
onClick={(): void => {
|
||||
if (!isActive) {
|
||||
onSelect(view);
|
||||
}
|
||||
}}
|
||||
testId="saved-views-row-name"
|
||||
>
|
||||
{name}
|
||||
</AuthZButton>
|
||||
</div>
|
||||
<SavedViewsRowLink view={view} isActive={isActive} onSelect={onSelect} />
|
||||
{isActive && (
|
||||
<SavedViewsRowButton
|
||||
icon={<Undo2 size={14} />}
|
||||
|
||||
@@ -5,7 +5,11 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
import { SavedViewListPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import {
|
||||
SavedViewListPermission,
|
||||
SavedViewReadPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import SavedViewsList from './SavedViewsList';
|
||||
@@ -21,6 +25,8 @@ function SavedViewsPanel({
|
||||
onClose?: () => void;
|
||||
}): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
// Batched with the list check, so the hover card reads it from cache.
|
||||
useAuthZ([SavedViewReadPermission]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
57
frontend/src/container/SavedViews/SavedViewsRowLink.tsx
Normal file
57
frontend/src/container/SavedViews/SavedViewsRowLink.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { MouseEvent, useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AppLink } from 'lib/router/AppLink';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
|
||||
import { SavedViewsRowLinkProps } from './types';
|
||||
import { getSavedViewPath } from './utils/getSavedViewPath';
|
||||
|
||||
import styles from './SavedViewsList.module.scss';
|
||||
|
||||
function SavedViewsRowLink({
|
||||
view,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: SavedViewsRowLinkProps): JSX.Element {
|
||||
const { pathname } = useAppLocation();
|
||||
const timeSearch = useTimeSearchParams();
|
||||
const path = useMemo(
|
||||
() => getSavedViewPath(pathname, view, timeSearch),
|
||||
[pathname, view, timeSearch],
|
||||
);
|
||||
|
||||
// Opening in a new tab or window is left to the browser.
|
||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {
|
||||
if (isModifierKeyPressed(event) || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!isActive) {
|
||||
onSelect(view);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
to={path}
|
||||
className={styles.rowLink}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={handleClick}
|
||||
data-testid="saved-views-row-name"
|
||||
>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="base"
|
||||
weight="normal"
|
||||
truncate={1}
|
||||
className={styles.rowLinkLabel}
|
||||
>
|
||||
{view.spec.displayName}
|
||||
</Typography.Text>
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsRowLink;
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -16,7 +18,7 @@ import {
|
||||
SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS,
|
||||
} from '../constants';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
@@ -139,9 +141,11 @@ function advance(ms: number): void {
|
||||
});
|
||||
}
|
||||
|
||||
function openCard(name: string): void {
|
||||
// Resolves once the card has fetched its view.
|
||||
async function openCard(name: string): Promise<HTMLElement> {
|
||||
fireEvent.mouseEnter(row(name));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
return screen.findByTestId('saved-view-hover-card-query');
|
||||
}
|
||||
|
||||
describe('SavedViewHoverCard', () => {
|
||||
@@ -168,7 +172,8 @@ describe('SavedViewHoverCard', () => {
|
||||
expect(card()).toBeNull();
|
||||
|
||||
advance(1);
|
||||
expect(card()).toHaveTextContent('Errors');
|
||||
expect(card()).toBeInTheDocument();
|
||||
await waitFor(() => expect(card()).toHaveTextContent('Errors'));
|
||||
});
|
||||
|
||||
it('does not open when the pointer leaves before the delay', async () => {
|
||||
@@ -184,7 +189,7 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
it('stays open while the pointer moves into the card', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
await openCard('Errors');
|
||||
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
fireEvent.mouseEnter(card() as HTMLElement);
|
||||
@@ -198,7 +203,7 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
it('closes on leaving the row and reopens for the next row after the delay', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
await openCard('Errors');
|
||||
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
fireEvent.mouseEnter(row('Slow spans'));
|
||||
@@ -206,12 +211,12 @@ describe('SavedViewHoverCard', () => {
|
||||
expect(card()).toBeNull();
|
||||
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
expect(card()).toHaveTextContent('Slow spans');
|
||||
await waitFor(() => expect(card()).toHaveTextContent('Slow spans'));
|
||||
});
|
||||
|
||||
it('closes when the row menu is pressed', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
await openCard('Errors');
|
||||
|
||||
fireEvent.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
|
||||
|
||||
@@ -222,19 +227,17 @@ describe('SavedViewHoverCard', () => {
|
||||
describe('content', () => {
|
||||
it('shows the filter, the updater and the update time', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
const query = await openCard('Errors');
|
||||
|
||||
const content = card() as HTMLElement;
|
||||
expect(
|
||||
within(content).getByTestId('saved-view-hover-card-query'),
|
||||
).toHaveTextContent('has_error = true');
|
||||
expect(query).toHaveTextContent('has_error = true');
|
||||
expect(content).toHaveTextContent('editor@signoz.io');
|
||||
expect(content).toHaveTextContent('Sep 28, 2026');
|
||||
});
|
||||
|
||||
it('copies the filter expression', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
await openCard('Errors');
|
||||
|
||||
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
|
||||
|
||||
@@ -243,9 +246,8 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
it('names each query when there are several, and copies them per line', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors and slow');
|
||||
const query = await openCard('Errors and slow');
|
||||
|
||||
const query = screen.getByTestId('saved-view-hover-card-query');
|
||||
expect(query).toHaveTextContent('A');
|
||||
expect(query).toHaveTextContent('B');
|
||||
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
|
||||
@@ -257,21 +259,74 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
it('says there are no filters, with nothing to copy', async () => {
|
||||
await renderPanel();
|
||||
openCard('Everything');
|
||||
const query = await openCard('Everything');
|
||||
|
||||
expect(screen.getByTestId('saved-view-hover-card-query')).toHaveTextContent(
|
||||
'No filters',
|
||||
);
|
||||
expect(query).toHaveTextContent('No filters');
|
||||
expect(screen.queryByTestId('saved-view-hover-card-copy')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the metric of a metrics query', async () => {
|
||||
await renderPanel(SavedviewtypesSourceDTO.metrics, DataSource.METRICS);
|
||||
openCard('Request rate');
|
||||
const query = await openCard('Request rate');
|
||||
|
||||
const query = screen.getByTestId('saved-view-hover-card-query');
|
||||
expect(query).toHaveTextContent('app.requests · rate · sum');
|
||||
expect(query).not.toHaveTextContent('No filters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetching', () => {
|
||||
it('shows the query from the view fetch, not from the list', async () => {
|
||||
server.use(
|
||||
rest.get(API, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: [{ ...errors, spec: { ...errors.spec, queries: [] } }],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
const query = await openCard('Errors');
|
||||
|
||||
expect(query).toHaveTextContent('has_error = true');
|
||||
});
|
||||
|
||||
it('fetches the view again on every open', async () => {
|
||||
let fetches = 0;
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) => {
|
||||
fetches += 1;
|
||||
return res(ctx.json({ status: 'success', data: errors }));
|
||||
}),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
await openCard('Errors');
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS);
|
||||
expect(card()).toBeNull();
|
||||
await openCard('Errors');
|
||||
|
||||
await waitFor(() => expect(fetches).toBe(2));
|
||||
});
|
||||
|
||||
it('says the view failed to load when its fetch fails', async () => {
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('saved-view-hover-card-error'),
|
||||
).resolves.toHaveTextContent('View failed to load');
|
||||
expect(screen.queryByTestId('saved-view-hover-card-copy')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AuthtypesTransactionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
@@ -9,34 +13,42 @@ import {
|
||||
buildSavedViewUpdatePermission,
|
||||
SavedViewCreatePermission,
|
||||
SavedViewListPermission,
|
||||
SavedViewReadPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS } from '../constants';
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
const HOVER_CARD_TIMEOUT = { timeout: SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS * 3 };
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
|
||||
|
||||
function renderPanel(url = explorerUrl(PATH, {})): void {
|
||||
renderWithExplorerProviders(
|
||||
function renderPanel(
|
||||
url = explorerUrl(PATH, {}),
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
@@ -68,6 +80,37 @@ async function openRowMenu(name: string): Promise<void> {
|
||||
fireEvent.click(within(rowNamed(name)).getByTestId('saved-views-row-menu'));
|
||||
}
|
||||
|
||||
// Records every check, answered by `isGranted`.
|
||||
function recordChecks(
|
||||
isGranted: (transaction: AuthtypesTransactionDTO) => boolean = (): boolean =>
|
||||
true,
|
||||
): AuthtypesTransactionDTO[] {
|
||||
const checks: AuthtypesTransactionDTO[] = [];
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
checks.push(...payload);
|
||||
return res(ctx.json(authzMockResponse(payload, payload.map(isGranted))));
|
||||
}),
|
||||
);
|
||||
return checks;
|
||||
}
|
||||
|
||||
const readSelectors = (checks: AuthtypesTransactionDTO[]): string[] =>
|
||||
checks
|
||||
.filter((transaction) => transaction.relation === 'read')
|
||||
.map((transaction) => transaction.object.selector);
|
||||
|
||||
async function hoverRow(name: string): Promise<HTMLElement> {
|
||||
await screen.findAllByTestId('saved-views-row');
|
||||
fireEvent.mouseEnter(rowNamed(name));
|
||||
return screen.findByTestId(
|
||||
'saved-view-hover-card',
|
||||
undefined,
|
||||
HOVER_CARD_TIMEOUT,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolves once the row's check is back, so its state is final.
|
||||
async function settledRow(testId: string): Promise<HTMLElement> {
|
||||
await waitFor(
|
||||
@@ -98,21 +141,51 @@ describe('SavedViews - AuthZ', () => {
|
||||
expect(screen.queryByTestId('saved-views-search')).toBeNull();
|
||||
});
|
||||
|
||||
it('disables only the views it cannot read', async () => {
|
||||
it('runs no read check per row', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
const checks = recordChecks();
|
||||
renderPanel();
|
||||
|
||||
// The menu checks go out after any check the rows made on mount.
|
||||
await openRowMenu('Errors');
|
||||
await settledRow('saved-views-row-menu-item-delete');
|
||||
expect(checks.map((transaction) => transaction.relation)).toContain('delete');
|
||||
expect(readSelectors(checks)).toStrictEqual(['*']);
|
||||
});
|
||||
|
||||
it('opens a view it cannot read, then the header says no access', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
const { history } = renderWithExplorerProviders(
|
||||
<>
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
onToggleViews={jest.fn()}
|
||||
/>
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
</>,
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
await screen.findAllByTestId('saved-views-row');
|
||||
const nameOf = (view: string): HTMLElement =>
|
||||
within(rowNamed(view)).getByTestId('saved-views-row-name');
|
||||
const name = within(rowNamed('Errors')).getByTestId('saved-views-row-name');
|
||||
expectUsable(name);
|
||||
await userEvent.click(name);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(nameOf('Errors')).toHaveAttribute('aria-disabled', 'true'),
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(errors.id),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-busy'),
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'No view access',
|
||||
),
|
||||
);
|
||||
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('keeps the row menu on a view it cannot read', async () => {
|
||||
@@ -133,6 +206,58 @@ describe('SavedViews - AuthZ', () => {
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('shows the denied callout in the hover card of a view it cannot read, without fetching it', async () => {
|
||||
let fetches = 0;
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(
|
||||
setupAuthzDeny(
|
||||
SavedViewReadPermission,
|
||||
buildSavedViewReadPermission(errors.id),
|
||||
),
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) => {
|
||||
fetches += 1;
|
||||
return res(ctx.json({ status: 'success', data: errors }));
|
||||
}),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
const card = await hoverRow('Errors');
|
||||
await expect(
|
||||
within(card).findByText(/not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(within(card).queryByTestId('saved-view-hover-card-query')).toBeNull();
|
||||
expect(fetches).toBe(0);
|
||||
});
|
||||
|
||||
it('skips the read check of the hovered view with read on every view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const checks = recordChecks();
|
||||
renderPanel();
|
||||
|
||||
const card = await hoverRow('Errors');
|
||||
|
||||
await expect(
|
||||
within(card).findByTestId('saved-view-hover-card-query'),
|
||||
).resolves.toHaveTextContent('has_error = true');
|
||||
expect(readSelectors(checks)).toStrictEqual(['*']);
|
||||
});
|
||||
|
||||
it('checks read on the hovered view without read on every view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const checks = recordChecks(
|
||||
(transaction) =>
|
||||
transaction.relation !== 'read' || transaction.object.selector !== '*',
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
const card = await hoverRow('Errors');
|
||||
|
||||
await expect(
|
||||
within(card).findByTestId('saved-view-hover-card-query'),
|
||||
).resolves.toHaveTextContent('has_error = true');
|
||||
expect(readSelectors(checks)).toStrictEqual(['*', errors.id]);
|
||||
});
|
||||
|
||||
it('disables creating without create', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
server.use(setupAuthzDeny(SavedViewCreatePermission));
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
fireEvent,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
@@ -14,6 +19,7 @@ import {
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
withGlobalTime,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
@@ -53,6 +59,9 @@ const row = (name: string): HTMLElement =>
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
const rowName = (name: string): HTMLElement =>
|
||||
within(row(name)).getByTestId('saved-views-row-name');
|
||||
|
||||
describe('SavedViewsPanel list', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
@@ -85,9 +94,8 @@ describe('SavedViewsPanel list', () => {
|
||||
const { history } = renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
|
||||
await waitFor(() => expectUsable(name));
|
||||
await userEvent.click(name);
|
||||
await waitFor(() => expectUsable(rowName('Slow spans')));
|
||||
await userEvent.click(rowName('Slow spans'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
@@ -96,6 +104,60 @@ describe('SavedViewsPanel list', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('links each row to its view in the current time range', async () => {
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
renderWithExplorerProviders(
|
||||
withGlobalTime(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
{ selectedTime: '1h' },
|
||||
),
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const url = new URL(
|
||||
rowName('Slow spans').getAttribute('href') as string,
|
||||
window.location.origin,
|
||||
);
|
||||
|
||||
expect(url.pathname).toBe(PATH);
|
||||
expect(url.searchParams.get(QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(alsoMine.id),
|
||||
);
|
||||
expect(url.searchParams.get(QueryParams.panelTypes)).toBe(
|
||||
JSON.stringify(alsoMine.spec.panelType),
|
||||
);
|
||||
expect(url.searchParams.has(QueryParams.compositeQuery)).toBe(true);
|
||||
expect(url.searchParams.get(QueryParams.relativeTime)).toBe('1h');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Ctrl', { ctrlKey: true }],
|
||||
['Cmd', { metaKey: true }],
|
||||
['Shift', { shiftKey: true }],
|
||||
])('leaves a %s click on a row to the browser', async (_key, modifiers) => {
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
const { history } = renderPanel();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const before = history.location.search;
|
||||
|
||||
let defaultPrevented: boolean | undefined;
|
||||
const recordClick = (event: MouseEvent): void => {
|
||||
defaultPrevented = event.defaultPrevented;
|
||||
// jsdom cannot open a new tab.
|
||||
event.preventDefault();
|
||||
};
|
||||
document.addEventListener('click', recordClick);
|
||||
fireEvent.click(rowName('Slow spans'), modifiers);
|
||||
document.removeEventListener('click', recordClick);
|
||||
|
||||
expect(defaultPrevented).toBe(false);
|
||||
expect(history.location.search).toBe(before);
|
||||
});
|
||||
|
||||
it('marks the open view and clears it from its row', async () => {
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
const { history } = renderPanel(viewUrl(PATH, mine));
|
||||
@@ -120,9 +182,8 @@ describe('SavedViewsPanel list', () => {
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const before = history.location.search;
|
||||
|
||||
await userEvent.click(
|
||||
within(row('Errors')).getByTestId('saved-views-row-name'),
|
||||
);
|
||||
expect(rowName('Errors')).toHaveAttribute('aria-current', 'page');
|
||||
await userEvent.click(rowName('Errors'));
|
||||
|
||||
expect(history.location.search).toBe(before);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
withGlobalTime,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const mockCopy = jest.fn();
|
||||
@@ -32,6 +34,8 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
const START_MS = 1700000000000;
|
||||
const END_MS = 1700003600000;
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const slow = makeView({
|
||||
@@ -119,9 +123,22 @@ describe('SavedViewsPanel row menu', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('copies a link that opens the view with its query and tab', async () => {
|
||||
it('copies a link that opens the view with its query, tab and time range', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
renderWithExplorerProviders(
|
||||
withGlobalTime(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
{
|
||||
selectedTime: 'custom',
|
||||
minTime: START_MS * NANO_SECOND_MULTIPLIER,
|
||||
maxTime: END_MS * NANO_SECOND_MULTIPLIER,
|
||||
},
|
||||
),
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Copy link');
|
||||
|
||||
@@ -137,6 +154,9 @@ describe('SavedViewsPanel row menu', () => {
|
||||
expect(
|
||||
decodeURIComponent(link.searchParams.get(QueryParams.compositeQuery) ?? ''),
|
||||
).toContain('duration_nano > 1000');
|
||||
expect(link.searchParams.get(QueryParams.startTime)).toBe(String(START_MS));
|
||||
expect(link.searchParams.get(QueryParams.endTime)).toBe(String(END_MS));
|
||||
expect(link.searchParams.has(QueryParams.relativeTime)).toBe(false);
|
||||
expect(toast.success).toHaveBeenCalledWith('Link copied', expect.anything());
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { TestRouter } from 'tests/router';
|
||||
import { getAppContextMock } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { __setSearchParamsGetterForTest } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
@@ -160,6 +161,23 @@ export function viewUrl(
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
export function withGlobalTime(
|
||||
ui: ReactElement,
|
||||
globalTime: Partial<GlobalReducer>,
|
||||
): ReactElement {
|
||||
const state = store.getState();
|
||||
return (
|
||||
<Provider
|
||||
store={mockStore({
|
||||
...state,
|
||||
globalTime: { ...state.globalTime, ...globalTime },
|
||||
})}
|
||||
>
|
||||
{ui}
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// What the explorer page does around the header: writes its default query to a
|
||||
// bare url, and sets the provider's tab from the url (the provider alone seeds
|
||||
// it from the raw, still JSON-quoted param).
|
||||
|
||||
@@ -12,11 +12,10 @@ import {
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { buildExplorerNavigationUrl } from 'container/AIAssistant/components/ActionsSection/utils/openSavedView';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
|
||||
import {
|
||||
SAVED_VIEW_SCHEMA_VERSION,
|
||||
@@ -24,7 +23,7 @@ import {
|
||||
} from '../constants';
|
||||
import { SavedViewEventKeys, SavedViewEvents } from '../events';
|
||||
import { UseSavedViewRowActionsResult } from '../types';
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { getSavedViewPath } from '../utils/getSavedViewPath';
|
||||
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
|
||||
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
|
||||
|
||||
@@ -43,6 +42,7 @@ export function useSavedViewRowActions(
|
||||
): UseSavedViewRowActionsResult {
|
||||
const queryClient = useQueryClient();
|
||||
const { pathname } = useAppLocation();
|
||||
const timeSearch = useTimeSearchParams();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
|
||||
@@ -52,17 +52,15 @@ export function useSavedViewRowActions(
|
||||
|
||||
const copyViewLink = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
const path = buildExplorerNavigationUrl(pathname, getSavedViewQuery(view), {
|
||||
[QueryParams.panelTypes]: view.spec.panelType,
|
||||
[QueryParams.viewKey]: view.id,
|
||||
});
|
||||
copyToClipboard(getAbsoluteUrl(path));
|
||||
copyToClipboard(
|
||||
getAbsoluteUrl(getSavedViewPath(pathname, view, timeSearch)),
|
||||
);
|
||||
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.LinkCopied, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
},
|
||||
[pathname, copyToClipboard, logSavedViewEvent],
|
||||
[pathname, timeSearch, copyToClipboard, logSavedViewEvent],
|
||||
);
|
||||
|
||||
const copyViewId = useCallback(
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
savedViewsListResponse,
|
||||
type SavedViewSource,
|
||||
unreadableViewId,
|
||||
withLongName,
|
||||
} from './__story_mockdata__/savedViews';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
@@ -73,7 +74,13 @@ const controls = {
|
||||
unreadableView: toggleControl('Unreadable view', {
|
||||
group: VIEW,
|
||||
description:
|
||||
'Denies `read` on the first view created by others alone, on top of the Access controls: its row greys out, and with Open view on `created-by-others` the header shows No view access.',
|
||||
'Denies `read` on the first view created by others, and so on `saved-view:*`, on top of the Access controls: its row still opens it, and the header then shows No view access, as with Open view on `created-by-others`. Its hover card shows the permission callout in place of the query.',
|
||||
value: false,
|
||||
}),
|
||||
viewPending: toggleControl('View fetch pending', {
|
||||
group: VIEW,
|
||||
description:
|
||||
'Holds every `GET /saved_views/:id` open, so a hover card stays on its loading skeleton.',
|
||||
value: false,
|
||||
}),
|
||||
createdByMe: countControl('Created by me', {
|
||||
@@ -87,6 +94,12 @@ const controls = {
|
||||
value: 4,
|
||||
max: SAVED_VIEWS_MAX,
|
||||
}),
|
||||
longNames: toggleControl('Long names', {
|
||||
group: LIST,
|
||||
description:
|
||||
'Lengthens every view name past the sidebar width, so rows and the open view name truncate.',
|
||||
value: false,
|
||||
}),
|
||||
};
|
||||
|
||||
type SavedViewsValues = MockControlValues<typeof controls>;
|
||||
@@ -99,7 +112,7 @@ const viewDenied = (
|
||||
): boolean =>
|
||||
transaction.relation === 'read' &&
|
||||
transaction.object.resource.kind === 'saved-view' &&
|
||||
transaction.object.selector === deniedId;
|
||||
[deniedId, '*'].includes(transaction.object.selector);
|
||||
|
||||
const definition = defineStoryMocks({
|
||||
controls,
|
||||
@@ -107,7 +120,14 @@ const definition = defineStoryMocks({
|
||||
values: SavedViewsValues & AuthzArgs,
|
||||
response,
|
||||
): RequestHandler[] => {
|
||||
const { source, openView, createdByMe, createdByOthers } = values;
|
||||
const {
|
||||
source,
|
||||
openView,
|
||||
viewPending,
|
||||
createdByMe,
|
||||
createdByOthers,
|
||||
longNames,
|
||||
} = values;
|
||||
|
||||
// What the panel writes stays in this handler set until a control changes.
|
||||
const created: SavedviewtypesSavedViewDTO[] = [];
|
||||
@@ -118,8 +138,14 @@ const definition = defineStoryMocks({
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): SavedviewtypesSavedViewDTO => ({ ...view, ...updated.get(view.id) });
|
||||
|
||||
const named = (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): SavedviewtypesSavedViewDTO => (longNames ? withLongName(view) : view);
|
||||
|
||||
const findView = (id: string): SavedviewtypesSavedViewDTO | undefined => {
|
||||
const view = created.find((item) => item.id === id) ?? savedViewById(id);
|
||||
const stored = savedViewById(id);
|
||||
const view =
|
||||
created.find((item) => item.id === id) ?? (stored && named(stored));
|
||||
|
||||
return view && !deleted.has(id) ? current(view) : undefined;
|
||||
};
|
||||
@@ -143,7 +169,7 @@ const definition = defineStoryMocks({
|
||||
|
||||
return {
|
||||
...list,
|
||||
data: [...(list.data ?? []), ...created]
|
||||
data: [...(list.data ?? []).map(named), ...created]
|
||||
.filter((view) => !deleted.has(view.id))
|
||||
.map(current),
|
||||
};
|
||||
@@ -151,6 +177,10 @@ const definition = defineStoryMocks({
|
||||
),
|
||||
|
||||
rest.get(`${API}/:id`, (req, res, ctx) => {
|
||||
if (viewPending) {
|
||||
return res(ctx.delay('infinite'));
|
||||
}
|
||||
|
||||
if (openError) {
|
||||
return res(ctx.status(openError.status), ctx.json(openError.body));
|
||||
}
|
||||
@@ -274,7 +304,9 @@ const isPending = (element: HTMLElement): boolean =>
|
||||
element.hasAttribute('data-loading');
|
||||
|
||||
const isDisabled = (element: HTMLElement): boolean =>
|
||||
element.hasAttribute('disabled') || element.hasAttribute('data-disabled');
|
||||
element.hasAttribute('disabled') ||
|
||||
element.hasAttribute('data-disabled') ||
|
||||
element.getAttribute('aria-disabled') === 'true';
|
||||
|
||||
export const expectDisabled = (
|
||||
find: FindElement,
|
||||
@@ -302,6 +334,30 @@ export const rowName =
|
||||
(): HTMLElement =>
|
||||
within(row).getByTestId('saved-views-row-name');
|
||||
|
||||
const hoverRow = async (row: HTMLElement): Promise<HTMLElement> => {
|
||||
await userEvent.hover(row);
|
||||
|
||||
return screen.findByTestId('saved-view-hover-card', undefined, UNTIL_LOADED);
|
||||
};
|
||||
|
||||
const searchViews = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
const search = await within(canvasElement).findByTestId(
|
||||
'saved-views-search',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
);
|
||||
|
||||
await userEvent.type(search, text);
|
||||
};
|
||||
|
||||
const clickWhenEnabled = async (find: FindElement): Promise<void> => {
|
||||
await expectDisabled(find, false);
|
||||
await userEvent.click(find());
|
||||
};
|
||||
|
||||
export const openRowMenu = async (row: HTMLElement): Promise<void> => {
|
||||
await userEvent.click(within(row).getByTestId('saved-views-row-menu'));
|
||||
await screen.findByRole('menu', undefined, UNTIL_LOADED);
|
||||
@@ -327,3 +383,115 @@ export const openSaveChangesMenu = async (
|
||||
|
||||
export const saveChangesItem = (key: 'save-as-new' | 'update'): FindElement =>
|
||||
byTestId(`saved-views-save-changes-item-${key}`);
|
||||
|
||||
type PlayContext = {
|
||||
canvasElement: HTMLElement;
|
||||
args: { source: SavedViewSource };
|
||||
};
|
||||
|
||||
export const hoverFirstRow = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
const card = await hoverRow(row);
|
||||
|
||||
await within(card).findByTestId(
|
||||
'saved-view-hover-card-query',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
);
|
||||
};
|
||||
|
||||
export const hoverFirstRowLoading = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
const card = await hoverRow(row);
|
||||
|
||||
await within(card).findByTestId(
|
||||
'saved-view-hover-card-loading',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
);
|
||||
};
|
||||
|
||||
export const searchWithNoResults = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await searchViews(canvasElement, 'nothing like this');
|
||||
await screen.findByTestId('saved-views-list-no-results');
|
||||
};
|
||||
|
||||
export const openCreateModal = async (): Promise<void> => {
|
||||
await clickWhenEnabled(byTestId('saved-views-create'));
|
||||
await screen.findByTestId('save-view-modal');
|
||||
};
|
||||
|
||||
export const openEditDetailsModal = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await openRowMenu(row);
|
||||
await clickWhenEnabled(rowMenuItem('editDetails'));
|
||||
await screen.findByTestId('save-view-modal');
|
||||
};
|
||||
|
||||
export const openSaveAsNewModal = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openSaveChangesMenu(canvasElement);
|
||||
await clickWhenEnabled(saveChangesItem('save-as-new'));
|
||||
await screen.findByTestId('save-view-modal');
|
||||
};
|
||||
|
||||
export const openDeleteDialog = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await openRowMenu(row);
|
||||
await clickWhenEnabled(rowMenuItem('delete'));
|
||||
await screen.findByTestId('delete-saved-view-confirm');
|
||||
};
|
||||
|
||||
const findUnreadableRow = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<HTMLElement> => {
|
||||
const rows = await findRows(canvasElement);
|
||||
const deniedId = unreadableViewId(args.source);
|
||||
|
||||
return rows.find(
|
||||
(row) => row.getAttribute('data-view-id') === deniedId,
|
||||
) as HTMLElement;
|
||||
};
|
||||
|
||||
export const hoverUnreadableRow = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
const card = await hoverRow(await findUnreadableRow(context));
|
||||
|
||||
await expect(
|
||||
await within(card).findByText(/not authorized/i, undefined, UNTIL_LOADED),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
within(card).queryByTestId('saved-view-hover-card-query'),
|
||||
).toBeNull();
|
||||
};
|
||||
|
||||
export const openUnreadableView = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
const { canvasElement } = context;
|
||||
const denied = await findUnreadableRow(context);
|
||||
|
||||
await userEvent.click(rowName(denied)());
|
||||
await expect(
|
||||
await within(canvasElement).findByText(
|
||||
'No view access',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
),
|
||||
).toBeVisible();
|
||||
};
|
||||
|
||||
@@ -6,10 +6,17 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import {
|
||||
findRows,
|
||||
hoverFirstRow,
|
||||
hoverFirstRowLoading,
|
||||
openCreateModal,
|
||||
openDeleteDialog,
|
||||
openEditDetailsModal,
|
||||
openRowMenu,
|
||||
openSaveAsNewModal,
|
||||
openSaveChangesMenu,
|
||||
SavedViewsSidebar,
|
||||
savedViewsMocks,
|
||||
searchWithNoResults,
|
||||
sidebarCanvas,
|
||||
} from './SavedViews.stories.mocks';
|
||||
|
||||
@@ -78,3 +85,48 @@ export const SaveChangesMenu: Story = {
|
||||
await openSaveChangesMenu(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Views whose names run past the sidebar, with one of them open: each row and
|
||||
* the open view name truncate.
|
||||
*/
|
||||
export const LongNames: Story = {
|
||||
args: { longNames: true, openView: 'created-by-me' },
|
||||
};
|
||||
|
||||
/** The hover card of the first row: its query, who updated it and when. */
|
||||
export const HoverCard: Story = {
|
||||
play: hoverFirstRow,
|
||||
};
|
||||
|
||||
/** The hover card while its view is still loading: a skeleton in place of the query. */
|
||||
export const HoverCardLoading: Story = {
|
||||
args: { viewPending: true },
|
||||
play: hoverFirstRowLoading,
|
||||
};
|
||||
|
||||
/** A search that matches no view: the list says so in place of the rows. */
|
||||
export const SearchNoResults: Story = {
|
||||
play: searchWithNoResults,
|
||||
};
|
||||
|
||||
/** The + in the header opens Save view for the current query. */
|
||||
export const CreateModal: Story = {
|
||||
play: openCreateModal,
|
||||
};
|
||||
|
||||
/** Edit details from a row's menu: the modal starts on the view's name. */
|
||||
export const EditDetailsModal: Story = {
|
||||
play: openEditDetailsModal,
|
||||
};
|
||||
|
||||
/** Save as new view from the ✓ menu on unsaved changes. */
|
||||
export const SaveAsNewModal: Story = {
|
||||
args: { openView: 'unsaved-changes' },
|
||||
play: openSaveAsNewModal,
|
||||
};
|
||||
|
||||
/** Delete from a row's menu asks to confirm before the view goes. */
|
||||
export const DeleteDialog: Story = {
|
||||
play: openDeleteDialog,
|
||||
};
|
||||
|
||||
@@ -209,6 +209,18 @@ export const createdSavedView = (
|
||||
spec,
|
||||
});
|
||||
|
||||
const LONG_NAME_SUFFIX = ', every environment and region, last 30 days';
|
||||
|
||||
export const withLongName = (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): SavedviewtypesSavedViewDTO => ({
|
||||
...view,
|
||||
spec: {
|
||||
...view.spec,
|
||||
displayName: `${view.spec.displayName}${LONG_NAME_SUFFIX}`,
|
||||
},
|
||||
});
|
||||
|
||||
export const savedViewResponse = (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): GetSavedView200 => ({ status: 'success', data: view });
|
||||
|
||||
@@ -4,14 +4,15 @@ import { expect, waitFor, within } from 'storybook/test';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { unreadableViewId } from '../__story_mockdata__/savedViews';
|
||||
import {
|
||||
byTestId,
|
||||
expectDisabled,
|
||||
expectPending,
|
||||
findRows,
|
||||
hoverUnreadableRow,
|
||||
openRowMenu,
|
||||
openSaveChangesMenu,
|
||||
openUnreadableView,
|
||||
rowMenuItem,
|
||||
rowName,
|
||||
saveChangesItem,
|
||||
@@ -99,21 +100,18 @@ export const NoCreateSaveMenu: Story = {
|
||||
};
|
||||
|
||||
/**
|
||||
* `read` denied on one view made by someone else: its name is disabled and its
|
||||
* hover card never opens, while every other view still opens.
|
||||
* `read` denied on one view made by someone else: the list checks no `read`, so
|
||||
* its row opens it like any other, and the header then says No view access.
|
||||
*/
|
||||
export const NoRead: Story = {
|
||||
args: { unreadableView: true },
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const rows = await findRows(canvasElement);
|
||||
const deniedId = unreadableViewId(args.source);
|
||||
play: openUnreadableView,
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
rows.map((row) =>
|
||||
expectDisabled(rowName(row), row.getAttribute('data-view-id') === deniedId),
|
||||
),
|
||||
);
|
||||
},
|
||||
/** The hover card of the unreadable view: the permission callout in place of its query. */
|
||||
export const NoReadHoverCard: Story = {
|
||||
args: { unreadableView: true },
|
||||
play: hoverUnreadableRow,
|
||||
};
|
||||
|
||||
/** The unreadable view open from its link: the header names it No view access. */
|
||||
|
||||
@@ -128,6 +128,11 @@ export interface SavedViewsListRowProps {
|
||||
hover: SavedViewsListRowHoverProps;
|
||||
}
|
||||
|
||||
export type SavedViewsRowLinkProps = Pick<
|
||||
SavedViewsListRowProps,
|
||||
'view' | 'isActive' | 'onSelect'
|
||||
>;
|
||||
|
||||
export type SavedViewRowActionKey =
|
||||
| 'copyLink'
|
||||
| 'copyId'
|
||||
@@ -181,7 +186,7 @@ export interface UseSavedViewRowActionsResult {
|
||||
}
|
||||
|
||||
export interface SavedViewHoverCardTarget {
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
viewId: string;
|
||||
anchorTop: number;
|
||||
anchorHeight: number;
|
||||
}
|
||||
@@ -202,8 +207,14 @@ export interface SavedViewHoverCardProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface SavedViewHoverCardDetailsProps {
|
||||
viewId: string;
|
||||
/** `read` is granted on every view, so the view needs no check of its own. */
|
||||
canReadAll: boolean;
|
||||
}
|
||||
|
||||
export interface SavedViewsListRowHoverProps {
|
||||
onRowEnter: (view: SavedviewtypesSavedViewDTO, row: HTMLElement) => void;
|
||||
onRowEnter: (viewId: string, row: HTMLElement) => void;
|
||||
onRowLeave: () => void;
|
||||
onMenuOpen: () => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { makeView } from '../../__tests__/savedViewsTestUtils';
|
||||
import { getSavedViewPath } from '../getSavedViewPath';
|
||||
|
||||
const view = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
|
||||
function paramsOf(path: string): URLSearchParams {
|
||||
return new URL(path, window.location.origin).searchParams;
|
||||
}
|
||||
|
||||
describe('getSavedViewPath', () => {
|
||||
it('opens the view on the given explorer in the given time range', () => {
|
||||
const path = getSavedViewPath(
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
view,
|
||||
'startTime=1700000000000&endTime=1700003600000',
|
||||
);
|
||||
const params = paramsOf(path);
|
||||
|
||||
expect(path.startsWith(`${ROUTES.TRACES_EXPLORER}?`)).toBe(true);
|
||||
expect(params.get(QueryParams.viewKey)).toBe(JSON.stringify(view.id));
|
||||
expect(params.get(QueryParams.startTime)).toBe('1700000000000');
|
||||
expect(params.get(QueryParams.endTime)).toBe('1700003600000');
|
||||
});
|
||||
|
||||
it('adds no time range when there is none', () => {
|
||||
const path = getSavedViewPath(ROUTES.TRACES_EXPLORER, view, '');
|
||||
|
||||
expect(path.endsWith('&')).toBe(false);
|
||||
expect(paramsOf(path).has(QueryParams.relativeTime)).toBe(false);
|
||||
});
|
||||
});
|
||||
17
frontend/src/container/SavedViews/utils/getSavedViewPath.ts
Normal file
17
frontend/src/container/SavedViews/utils/getSavedViewPath.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
|
||||
|
||||
import { getSavedViewQuery } from './getSavedViewQuery';
|
||||
|
||||
export function getSavedViewPath(
|
||||
pathname: string,
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
timeSearch: string,
|
||||
): string {
|
||||
const path = buildExplorerNavigationUrl(pathname, getSavedViewQuery(view), {
|
||||
[QueryParams.panelTypes]: view.spec.panelType,
|
||||
[QueryParams.viewKey]: view.id,
|
||||
});
|
||||
return timeSearch ? `${path}&${timeSearch}` : path;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { useTimeSearchParams } from '../useTimeSearchParams';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
|
||||
let mockGlobalTime = {
|
||||
selectedTime: '30m',
|
||||
@@ -12,12 +12,13 @@ import {
|
||||
IOption,
|
||||
IResourceAttribute,
|
||||
IResourceAttributeProps,
|
||||
OperatorValues,
|
||||
Tags,
|
||||
} from 'hooks/useResourceAttribute/types';
|
||||
import { decode } from 'js-base64';
|
||||
import { getCurrentLocation } from 'lib/router/navigation';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { OperatorValues, Tags } from 'hooks/useResourceAttribute/types';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { whilelistedKeys } from './config';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { timeParamsFromGlobalTime } from '../utils/timeUrlParams';
|
||||
import { timeParamsFromGlobalTime } from 'utils/timeUrlParams';
|
||||
|
||||
/** Active time window as a query string (no leading `?`), or `''` when unset. */
|
||||
export function useTimeSearchParams(): string {
|
||||
@@ -6,6 +6,7 @@ export const SavedViewCreatePermission = buildPermission(
|
||||
'create',
|
||||
'saved-view:*',
|
||||
);
|
||||
export const SavedViewReadPermission = buildPermission('read', 'saved-view:*');
|
||||
|
||||
export const buildSavedViewReadPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('read', `saved-view:${id}`);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
|
||||
import { getPanelBuilderQuery } from '../Panels/utils/getPanelBuilderQuery';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { useTimeSearchParams } from './useTimeSearchParams';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
import logEvent from '@/api/common/logEvent';
|
||||
import { DashboardDetailEvents } from '../../constants/events';
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
parseNewPanelTarget,
|
||||
} from '../DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
|
||||
import { useTimeSearchParams } from '../DashboardContainer/hooks/useTimeSearchParams';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
import { createDefaultPanel } from '../DashboardContainer/patchOps';
|
||||
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
|
||||
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/hooks/useSeedVariableSelection';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { buildExplorerNavigationUrl } from '../buildExplorerNavigationUrl';
|
||||
|
||||
describe('buildExplorerNavigationUrl', () => {
|
||||
it('encodes composite query and view selectors', () => {
|
||||
const url = buildExplorerNavigationUrl(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
{ queryType: 'builder' } as never,
|
||||
{
|
||||
[QueryParams.panelTypes]: PANEL_TYPES.LIST,
|
||||
[QueryParams.viewKey]: 'view-1',
|
||||
},
|
||||
);
|
||||
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain(`${QueryParams.compositeQuery}=`);
|
||||
expect(url).toContain(`${QueryParams.viewKey}=`);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { timeParamsFromGlobalTime } from '../timeUrlParams';
|
||||
import { timeParamsFromGlobalTime } from 'utils/timeUrlParams';
|
||||
|
||||
describe('timeParamsFromGlobalTime', () => {
|
||||
it('emits relativeTime for a relative selection', () => {
|
||||
22
frontend/src/utils/buildExplorerNavigationUrl.ts
Normal file
22
frontend/src/utils/buildExplorerNavigationUrl.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
/**
|
||||
* Builds an explorer URL the same way `redirectWithQueryBuilderData` does —
|
||||
* without inheriting stale query params from the current page's `urlQuery`.
|
||||
*/
|
||||
export function buildExplorerNavigationUrl(
|
||||
route: string,
|
||||
query: Query,
|
||||
searchParams: Record<string, unknown>,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
params.set(key, JSON.stringify(value));
|
||||
});
|
||||
return `${route}?${params.toString()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user