mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-16 15:10:41 +01:00
Compare commits
3 Commits
ci/e2e-pul
...
feat/saved
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eab05736d | ||
|
|
6b56a56ede | ||
|
|
2b628c9c47 |
@@ -71,6 +71,7 @@ export const AIAssistantOpenSource = {
|
||||
Shortcut: 'shortcut',
|
||||
Cmdk: 'cmdk',
|
||||
TraceDetails: 'trace_details',
|
||||
BottomStrip: 'bottom_strip',
|
||||
} as const;
|
||||
export type AIAssistantOpenSource =
|
||||
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
import {
|
||||
AIAssistantEvents,
|
||||
AIAssistantOpenSource,
|
||||
} from 'container/AIAssistant/events';
|
||||
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
|
||||
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
|
||||
import {
|
||||
openAIAssistant,
|
||||
@@ -37,6 +44,14 @@ function AskNoz(): JSX.Element | null {
|
||||
// The drawer does not render on the Noz full page, so the button would be inert.
|
||||
const isAIAssistantPage = pathname.startsWith(ROUTES.AI_ASSISTANT_BASE);
|
||||
|
||||
const handleOpen = useCallback((): void => {
|
||||
void logEvent(AIAssistantEvents.Opened, {
|
||||
source: AIAssistantOpenSource.BottomStrip,
|
||||
currentPage: normalizePage(pathname),
|
||||
});
|
||||
openAIAssistant();
|
||||
}, [pathname]);
|
||||
|
||||
if (!isAIAssistantEnabled || isDrawerOpen || isAIAssistantPage) {
|
||||
return null;
|
||||
}
|
||||
@@ -57,7 +72,7 @@ function AskNoz(): JSX.Element | null {
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Noz size={16} />}
|
||||
onClick={(): void => openAIAssistant()}
|
||||
onClick={handleOpen}
|
||||
aria-label={
|
||||
showPendingBadge
|
||||
? `Ask Noz, ${pendingUserInputCount} ${
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
AIAssistantEvents,
|
||||
AIAssistantOpenSource,
|
||||
} from 'container/AIAssistant/events';
|
||||
import { openAIAssistant } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { render } from 'tests/test-utils';
|
||||
@@ -6,6 +11,10 @@ import { render } from 'tests/test-utils';
|
||||
import AskNoz from '../AskNoz';
|
||||
|
||||
jest.mock('hooks/useIsAIAssistantEnabled');
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('container/AIAssistant/store/useAIAssistantStore', () => ({
|
||||
openAIAssistant: jest.fn(),
|
||||
useAIAssistantStore: jest.fn(),
|
||||
@@ -91,6 +100,17 @@ describe('AskNoz', () => {
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports the open as coming from the strip', () => {
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
fireEvent.click(getByRole('button'));
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith(
|
||||
AIAssistantEvents.Opened,
|
||||
expect.objectContaining({ source: AIAssistantOpenSource.BottomStrip }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending badge', () => {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { MessageSquareText } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
BottomStripEventKeys,
|
||||
BottomStripEvents,
|
||||
BottomStripSupportType,
|
||||
} from '../events';
|
||||
|
||||
function SupportButton(): JSX.Element | null {
|
||||
const chatSupport = useChatSupport();
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
|
||||
@@ -14,7 +21,13 @@ function SupportButton(): JSX.Element | null {
|
||||
}
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (chatSupport === ChatSupportState.NeedsCard) {
|
||||
const needsCard = chatSupport === ChatSupportState.NeedsCard;
|
||||
void logEvent(BottomStripEvents.SupportClicked, {
|
||||
[BottomStripEventKeys.Type]: needsCard
|
||||
? BottomStripSupportType.AddCard
|
||||
: BottomStripSupportType.Pylon,
|
||||
});
|
||||
if (needsCard) {
|
||||
setIsAddCreditCardModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { BottomStripEvents, BottomStripSupportType } from '../../events';
|
||||
import SupportButton from '../SupportButton';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useChatSupport', () => ({
|
||||
...jest.requireActual('hooks/useChatSupport'),
|
||||
useChatSupport: jest.fn(),
|
||||
@@ -37,6 +44,9 @@ describe('SupportButton', () => {
|
||||
fireEvent.click(getByTestId(BUTTON));
|
||||
|
||||
expect(window.Pylon).toHaveBeenCalledWith('show');
|
||||
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
|
||||
type: BottomStripSupportType.Pylon,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not open the credit card modal', () => {
|
||||
@@ -64,6 +74,9 @@ describe('SupportButton', () => {
|
||||
|
||||
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
|
||||
expect(window.Pylon).not.toHaveBeenCalled();
|
||||
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
|
||||
type: BottomStripSupportType.AddCard,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
12
frontend/src/container/BottomStrip/events.ts
Normal file
12
frontend/src/container/BottomStrip/events.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export enum BottomStripEvents {
|
||||
SupportClicked = 'Bottom Strip: Support clicked',
|
||||
}
|
||||
|
||||
export enum BottomStripEventKeys {
|
||||
Type = 'type',
|
||||
}
|
||||
|
||||
export enum BottomStripSupportType {
|
||||
Pylon = 'pylon',
|
||||
AddCard = 'add_card',
|
||||
}
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
SAVED_VIEW_LOAD_FAILED_NAME,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
} from './constants';
|
||||
import { SavedViewCreatedFrom, SavedViewEvents } from './events';
|
||||
import { useActiveSavedView } from './hooks/useActiveSavedView';
|
||||
import { useSaveNewView } from './hooks/useSaveNewView';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
import { useSavedViewLogEvent } from './hooks/useSavedViewLogEvent';
|
||||
import SaveChangesMenu from './SaveChangesMenu';
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import SaveViewModal from './SaveViewModal';
|
||||
@@ -36,9 +38,25 @@ function SavedViewsHeader({
|
||||
const { revertView, clearView, createView, updateView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
|
||||
|
||||
const handleSave = useSaveNewView(createView);
|
||||
const handleClear = useCallback((): void => {
|
||||
clearView();
|
||||
logSavedViewEvent(SavedViewEvents.ViewCleared);
|
||||
}, [clearView, logSavedViewEvent]);
|
||||
|
||||
const saveNewView = useSaveNewView(createView);
|
||||
const handleSave = useCallback(
|
||||
(displayName: string): Promise<boolean> =>
|
||||
saveNewView(
|
||||
displayName,
|
||||
modalMode === 'saveAsNew'
|
||||
? SavedViewCreatedFrom.SaveAsNew
|
||||
: SavedViewCreatedFrom.Header,
|
||||
),
|
||||
[saveNewView, modalMode],
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(async (): Promise<void> => {
|
||||
if (view && (await updateView(view))) {
|
||||
@@ -110,7 +128,7 @@ function SavedViewsHeader({
|
||||
<SavedViewsIconButton
|
||||
title="Clear view"
|
||||
icon={<Undo2 size={14} />}
|
||||
onClick={clearView}
|
||||
onClick={handleClear}
|
||||
testId="saved-views-clear"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -10,8 +10,10 @@ import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
|
||||
import { SAVED_VIEWS_LIST_LOADING_ROWS } from './constants';
|
||||
import DeleteSavedViewDialog from './DeleteSavedViewDialog';
|
||||
import { SavedViewCreatedFrom, SavedViewEvents } from './events';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
import { useSavedViewHoverCard } from './hooks/useSavedViewHoverCard';
|
||||
import { useSavedViewLogEvent } from './hooks/useSavedViewLogEvent';
|
||||
import { useSavedViewRowActions } from './hooks/useSavedViewRowActions';
|
||||
import { useSaveNewView } from './hooks/useSaveNewView';
|
||||
import { useSavedViewsList } from './hooks/useSavedViewsList';
|
||||
@@ -38,6 +40,11 @@ function SavedViewsList({
|
||||
const { selectView, clearView, createView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
const saveNewView = useSaveNewView(createView);
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
const handleClear = useCallback((): void => {
|
||||
clearView();
|
||||
logSavedViewEvent(SavedViewEvents.ViewCleared);
|
||||
}, [clearView, logSavedViewEvent]);
|
||||
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
|
||||
useSavedViewsList(source, search);
|
||||
const { copyViewLink, renameView, deleteView, isRenaming, isDeleting } =
|
||||
@@ -131,7 +138,9 @@ function SavedViewsList({
|
||||
<SavedViewsEmptyState
|
||||
isSearching={search.trim().length > 0}
|
||||
isSaving={isSaving}
|
||||
onSave={saveNewView}
|
||||
onSave={(displayName): Promise<boolean> =>
|
||||
saveNewView(displayName, SavedViewCreatedFrom.EmptyState)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -149,7 +158,7 @@ function SavedViewsList({
|
||||
views={createdByMe}
|
||||
activeViewId={viewKey}
|
||||
onSelect={selectView}
|
||||
onClear={clearView}
|
||||
onClear={handleClear}
|
||||
onAction={handleAction}
|
||||
hover={rowHover}
|
||||
testId="saved-views-created-by-me"
|
||||
@@ -161,7 +170,7 @@ function SavedViewsList({
|
||||
views={createdByOthers}
|
||||
activeViewId={viewKey}
|
||||
onSelect={selectView}
|
||||
onClear={clearView}
|
||||
onClear={handleClear}
|
||||
onAction={handleAction}
|
||||
hover={rowHover}
|
||||
testId="saved-views-created-by-others"
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { act, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewCreatedFrom, SavedViewEvents } from '../events';
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const logEventMock = jest.fn();
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): void => logEventMock(...args),
|
||||
}));
|
||||
|
||||
// copy-to-clipboard falls back to window.prompt in jsdom.
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, jest.fn()],
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
const source = SavedviewtypesSourceDTO.traces;
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
|
||||
|
||||
function renderAll(
|
||||
url = explorerUrl(PATH, {}),
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<>
|
||||
<SavedViewsHeader source={source} onOpenViews={jest.fn()} />
|
||||
<SavedViewsPanel source={source} onClose={jest.fn()} />
|
||||
</>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const row = (name: string): HTMLElement =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
async function chooseFromRowMenu(
|
||||
viewName: string,
|
||||
item: string,
|
||||
): Promise<void> {
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await userEvent.click(
|
||||
within(row(viewName)).getByTestId('saved-views-row-menu'),
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
|
||||
}
|
||||
|
||||
async function submitName(name: string): Promise<void> {
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), name);
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
}
|
||||
|
||||
async function waitForView(name: string): Promise<void> {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(name),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
|
||||
);
|
||||
}
|
||||
|
||||
const callsOf = (event: SavedViewEvents): unknown[][] =>
|
||||
logEventMock.mock.calls.filter(([name]) => name === event);
|
||||
|
||||
describe('SavedViews analytics', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
logEventMock.mockClear();
|
||||
});
|
||||
|
||||
it('logs a view created from the header', async () => {
|
||||
mockSavedViewsApi([errors], { createdId: 'view-new' });
|
||||
renderAll();
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
|
||||
source,
|
||||
viewId: 'view-new',
|
||||
from: SavedViewCreatedFrom.Header,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs a view created from the empty state', async () => {
|
||||
mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
renderAll();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('saved-views-list-create'));
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
|
||||
source,
|
||||
viewId: 'view-new',
|
||||
from: SavedViewCreatedFrom.EmptyState,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not log a create that fails', async () => {
|
||||
mockSavedViewsApi([errors], { createStatus: 500 });
|
||||
renderAll();
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
expect(callsOf(SavedViewEvents.ViewCreated)).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('on a changed view', () => {
|
||||
async function openDirtyView(): Promise<void> {
|
||||
const { history } = renderAll(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
act(() => {
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await screen.findByTestId('saved-views-discard');
|
||||
}
|
||||
|
||||
it('logs an update', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
await openDirtyView();
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
|
||||
await userEvent.click(
|
||||
await screen.findByRole('menuitem', { name: 'Update selected view' }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewUpdated, {
|
||||
source,
|
||||
viewId: errors.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs a view created with save as new', async () => {
|
||||
mockSavedViewsApi([errors], { createdId: 'view-copy' });
|
||||
await openDirtyView();
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
|
||||
await userEvent.click(
|
||||
await screen.findByRole('menuitem', { name: 'Save as new view' }),
|
||||
);
|
||||
await submitName('Errors copy');
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
|
||||
source,
|
||||
viewId: 'view-copy',
|
||||
from: SavedViewCreatedFrom.SaveAsNew,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs discarded changes', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
await openDirtyView();
|
||||
logEventMock.mockClear();
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-discard'));
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ChangesDiscarded, {
|
||||
source,
|
||||
viewId: errors.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a cleared view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderAll(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-clear'));
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCleared, {
|
||||
source,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a view cleared from its row', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderAll(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
|
||||
await userEvent.click(await screen.findByTestId('saved-views-row-clear'));
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCleared, {
|
||||
source,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs only the delete when the open view is deleted', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderAll(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
|
||||
await chooseFromRowMenu('Errors', 'Delete');
|
||||
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewDeleted, {
|
||||
source,
|
||||
viewId: errors.id,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('My view'),
|
||||
);
|
||||
expect(callsOf(SavedViewEvents.ViewCleared)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('logs the row menu actions', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
renderAll();
|
||||
|
||||
await chooseFromRowMenu('Errors', 'Copy link');
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.LinkCopied, {
|
||||
source,
|
||||
viewId: errors.id,
|
||||
});
|
||||
|
||||
await chooseFromRowMenu('Errors', 'Edit details');
|
||||
await submitName(' renamed');
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
SavedViewEvents.ViewDetailsEdited,
|
||||
{ source, viewId: errors.id },
|
||||
),
|
||||
);
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Delete');
|
||||
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewDeleted, {
|
||||
source,
|
||||
viewId: slow.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
23
frontend/src/container/SavedViews/events.ts
Normal file
23
frontend/src/container/SavedViews/events.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export enum SavedViewEvents {
|
||||
ViewCreated = 'Saved Views: View created',
|
||||
ViewUpdated = 'Saved Views: View updated',
|
||||
ViewDetailsEdited = 'Saved Views: View details edited',
|
||||
ViewDeleted = 'Saved Views: View deleted',
|
||||
LinkCopied = 'Saved Views: Link copied',
|
||||
ViewCleared = 'Saved Views: View cleared',
|
||||
ChangesDiscarded = 'Saved Views: Changes discarded',
|
||||
}
|
||||
|
||||
export enum SavedViewEventKeys {
|
||||
// Injected on every event by useSavedViewLogEvent
|
||||
Source = 'source',
|
||||
|
||||
ViewId = 'viewId',
|
||||
From = 'from',
|
||||
}
|
||||
|
||||
export enum SavedViewCreatedFrom {
|
||||
Header = 'header',
|
||||
SaveAsNew = 'saveAsNew',
|
||||
EmptyState = 'emptyState',
|
||||
}
|
||||
@@ -2,14 +2,15 @@ import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import { SAVED_VIEW_TOAST_POSITION } from '../constants';
|
||||
import { SavedViewCreatedFrom } from '../events';
|
||||
import { UseSavedViewActionsResult } from '../types';
|
||||
|
||||
export function useSaveNewView(
|
||||
createView: UseSavedViewActionsResult['createView'],
|
||||
): (displayName: string) => Promise<boolean> {
|
||||
): (displayName: string, from: SavedViewCreatedFrom) => Promise<boolean> {
|
||||
return useCallback(
|
||||
async (displayName: string): Promise<boolean> => {
|
||||
if (!(await createView(displayName))) {
|
||||
async (displayName: string, from: SavedViewCreatedFrom): Promise<boolean> => {
|
||||
if (!(await createView(displayName, from))) {
|
||||
return false;
|
||||
}
|
||||
toast.success('View created', {
|
||||
|
||||
@@ -30,12 +30,18 @@ import {
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
SAVED_VIEW_URL_PARAMS,
|
||||
} from '../constants';
|
||||
import {
|
||||
SavedViewCreatedFrom,
|
||||
SavedViewEventKeys,
|
||||
SavedViewEvents,
|
||||
} from '../events';
|
||||
import { UseSavedViewActionsResult } from '../types';
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { getViewColumnsAndFormatting } from '../utils/getViewColumnsAndFormatting';
|
||||
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
|
||||
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
|
||||
import { useLastUsedView } from './useLastUsedView';
|
||||
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
|
||||
|
||||
// Columns and formatting need no handling here: with a viewKey in the url the
|
||||
// preference provider reads them from the view, and without one it falls back
|
||||
@@ -50,6 +56,7 @@ export function useSavedViewActions(
|
||||
useQueryBuilder();
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { setLastUsedView, clearLastUsedView } = useLastUsedView(source);
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
|
||||
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
|
||||
const { options } = useOptionsMenu({
|
||||
@@ -107,8 +114,11 @@ export function useSavedViewActions(
|
||||
}
|
||||
}
|
||||
selectView(view);
|
||||
logSavedViewEvent(SavedViewEvents.ChangesDiscarded, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
},
|
||||
[optionsDataSource, preferencesSlice, selectView],
|
||||
[optionsDataSource, preferencesSlice, selectView, logSavedViewEvent],
|
||||
);
|
||||
|
||||
// The query builder redirects only ever add params, so the url is rebuilt
|
||||
@@ -122,7 +132,10 @@ export function useSavedViewActions(
|
||||
}, [clearLastUsedView, pathname, safeNavigate]);
|
||||
|
||||
const createView = useCallback(
|
||||
async (displayName: string): Promise<string | undefined> => {
|
||||
async (
|
||||
displayName: string,
|
||||
from: SavedViewCreatedFrom,
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const { data } = await createSavedView({
|
||||
data: {
|
||||
@@ -137,6 +150,10 @@ export function useSavedViewActions(
|
||||
[QueryParams.viewKey]: data.id,
|
||||
});
|
||||
setLastUsedView(data.id, displayName);
|
||||
logSavedViewEvent(SavedViewEvents.ViewCreated, {
|
||||
[SavedViewEventKeys.ViewId]: data.id,
|
||||
[SavedViewEventKeys.From]: from,
|
||||
});
|
||||
return data.id;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -158,6 +175,7 @@ export function useSavedViewActions(
|
||||
stagedQuery,
|
||||
currentQuery,
|
||||
setLastUsedView,
|
||||
logSavedViewEvent,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -173,6 +191,9 @@ export function useSavedViewActions(
|
||||
},
|
||||
});
|
||||
await invalidateViews(view.id);
|
||||
logSavedViewEvent(SavedViewEvents.ViewUpdated, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -185,7 +206,7 @@ export function useSavedViewActions(
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateSavedView, source, toCurrentSpec, invalidateViews],
|
||||
[updateSavedView, source, toCurrentSpec, invalidateViews, logSavedViewEvent],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { SavedViewEventKeys, SavedViewEvents } from '../events';
|
||||
|
||||
export type SavedViewLogEvent = (
|
||||
event: SavedViewEvents,
|
||||
attributes?: Record<string, unknown>,
|
||||
) => void;
|
||||
|
||||
export function useSavedViewLogEvent(
|
||||
source: SavedviewtypesSourceDTO,
|
||||
): SavedViewLogEvent {
|
||||
const sourceRef = useRef(source);
|
||||
sourceRef.current = source;
|
||||
|
||||
return useCallback(
|
||||
(event: SavedViewEvents, attributes: Record<string, unknown> = {}): void => {
|
||||
try {
|
||||
void logEvent(event, {
|
||||
[SavedViewEventKeys.Source]: sourceRef.current,
|
||||
...attributes,
|
||||
});
|
||||
} catch {
|
||||
// No-op.
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -22,9 +22,11 @@ import {
|
||||
SAVED_VIEW_SCHEMA_VERSION,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
} from '../constants';
|
||||
import { SavedViewEventKeys, SavedViewEvents } from '../events';
|
||||
import { UseSavedViewRowActionsResult } from '../types';
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
|
||||
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
|
||||
|
||||
function showErrorToast(error: unknown, fallback: string): void {
|
||||
toast.error(
|
||||
@@ -42,6 +44,7 @@ export function useSavedViewRowActions(
|
||||
const queryClient = useQueryClient();
|
||||
const { pathname } = useLocation();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
|
||||
useUpdateSavedView();
|
||||
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
|
||||
@@ -55,8 +58,11 @@ export function useSavedViewRowActions(
|
||||
});
|
||||
copyToClipboard(getAbsoluteUrl(path));
|
||||
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.LinkCopied, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
},
|
||||
[pathname, copyToClipboard],
|
||||
[pathname, copyToClipboard, logSavedViewEvent],
|
||||
);
|
||||
|
||||
const renameView = useCallback(
|
||||
@@ -75,13 +81,16 @@ export function useSavedViewRowActions(
|
||||
});
|
||||
await invalidateSavedViews(queryClient, source, view.id);
|
||||
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.ViewDetailsEdited, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorToast(error, 'Could not update the view');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateSavedView, source, queryClient],
|
||||
[updateSavedView, source, queryClient, logSavedViewEvent],
|
||||
);
|
||||
|
||||
// The deleted view's own query is left alone: refetching it would fail
|
||||
@@ -92,13 +101,16 @@ export function useSavedViewRowActions(
|
||||
await deleteSavedView({ pathParams: { id: view.id } });
|
||||
await invalidateSavedViews(queryClient, source);
|
||||
toast.success('View deleted', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.ViewDeleted, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorToast(error, 'Could not delete the view');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[deleteSavedView, source, queryClient],
|
||||
[deleteSavedView, source, queryClient, logSavedViewEvent],
|
||||
);
|
||||
|
||||
return { copyViewLink, renameView, deleteView, isRenaming, isDeleting };
|
||||
|
||||
@@ -9,6 +9,8 @@ import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewCreatedFrom } from './events';
|
||||
|
||||
export type SavedViewSourcePage = DataSource | 'meter';
|
||||
|
||||
export type SavedViewOptions = Pick<
|
||||
@@ -48,7 +50,10 @@ export interface UseSavedViewActionsResult {
|
||||
selectView: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
revertView: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
clearView: () => void;
|
||||
createView: (displayName: string) => Promise<string | undefined>;
|
||||
createView: (
|
||||
displayName: string,
|
||||
from: SavedViewCreatedFrom,
|
||||
) => Promise<string | undefined>;
|
||||
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user