Compare commits

...

3 Commits

Author SHA1 Message Date
aks07
1d6fb4c3ca test(e2e): route tab test uses the metrics explorer tab 2026-10-16 13:32:37 +05:30
aks07
bbf1855269 test(saved-views): update stories and tests for the flag on 2026-10-16 12:41:53 +05:30
aks07
f156b7cc0a feat(saved-views): turn on saved views and the bottom strip
the flag code stays for the cleanup, old views urls redirect to their explorer
2026-10-16 12:01:20 +05:30
12 changed files with 82 additions and 306 deletions

View File

@@ -300,20 +300,17 @@ describe('PrivateRoute', () => {
);
});
it('should redirect /logs-save-views to /logs/saved-views', () => {
renderPrivateRoute({ initialRoute: '/logs-save-views' });
it.each([
['/logs-save-views', ROUTES.LOGS_EXPLORER],
['/traces-save-views', ROUTES.TRACES_EXPLORER],
[ROUTES.LOGS_SAVE_VIEWS, ROUTES.LOGS_EXPLORER],
[ROUTES.TRACES_SAVE_VIEWS, ROUTES.TRACES_EXPLORER],
[ROUTES.METRICS_EXPLORER_VIEWS, ROUTES.METRICS_EXPLORER_EXPLORER],
[ROUTES.METER_EXPLORER_VIEWS, ROUTES.METER_EXPLORER],
])('should redirect the old views route %s to %s', (from, to) => {
renderPrivateRoute({ initialRoute: from });
expect(screen.getByTestId('location-display')).toHaveTextContent(
'/logs/saved-views',
);
});
it('should redirect /traces-save-views to /traces/saved-views', () => {
renderPrivateRoute({ initialRoute: '/traces-save-views' });
expect(screen.getByTestId('location-display')).toHaveTextContent(
'/traces/saved-views',
);
expect(screen.getByTestId('location-display').textContent).toBe(to);
});
it('should redirect /settings/access-tokens to /settings/service-accounts', () => {
@@ -1635,11 +1632,6 @@ describe('PrivateRoute', () => {
['LIST_LICENSES', ROUTES.LIST_LICENSES, USER_ROLES.EDITOR as ROLES],
['ONBOARDING', ROUTES.ONBOARDING, USER_ROLES.VIEWER as ROLES],
['APPLICATION', ROUTES.APPLICATION, USER_ROLES.ANONYMOUS as ROLES],
[
'METRICS_EXPLORER_VIEWS',
ROUTES.METRICS_EXPLORER_VIEWS,
USER_ROLES.ANONYMOUS as ROLES,
],
];
it.each(authzRouteRolePairs)(

View File

@@ -513,8 +513,12 @@ export const LIST_LICENSES: AppRoutes = {
export const oldNewRoutesMapping: Record<string, string> = {
'/pipelines': '/logs/pipelines',
'/logs-explorer': '/logs/logs-explorer',
'/logs-save-views': '/logs/saved-views',
'/traces-save-views': '/traces/saved-views',
'/logs-save-views': '/logs/logs-explorer',
'/traces-save-views': '/traces-explorer',
'/logs/saved-views': '/logs/logs-explorer',
'/traces/saved-views': '/traces-explorer',
'/metrics-explorer/views': '/metrics-explorer/explorer',
'/meter/explorer/views': '/meter/explorer',
'/settings/access-tokens': '/settings/service-accounts',
'/settings/api-keys': '/settings/service-accounts',
'/messaging-queues': '/messaging-queues/overview',

View File

@@ -14,6 +14,7 @@ import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
import * as useHandleExplorerTabChangeHooks from 'hooks/useHandleExplorerTabChange';
import * as appContextHooks from 'providers/App/App';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import * as timezoneHooks from 'providers/Timezone';
import store from 'store';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
@@ -147,7 +148,9 @@ function renderExplorer(): void {
<MemoryRouter>
<Provider store={store}>
<ErrorModalProvider>
<Explorer />
<PreferenceContextProvider>
<Explorer />
</PreferenceContextProvider>
</ErrorModalProvider>
</Provider>
</MemoryRouter>

View File

@@ -1,11 +1,3 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useState } from 'react';
export function useSavedViewEnabled(): boolean {
const [isEnabled] = useState(
() => getLocalStorageKey(LOCALSTORAGE.SAVED_VIEW_ENABLED) === 'true',
);
return isEnabled;
return true;
}

View File

@@ -1,47 +0,0 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
logsSavedViewsResponse,
SAVED_VIEW_MAX,
SAVED_VIEWS_PAGE_SIZE,
savedViewWriteResponse,
} from './__story_mockdata__/savedViews';
const LIST = 'Views · list';
export const savedViewsMocks = defineStoryMocks({
controls: {
views: countControl('Saved views', {
group: LIST,
description: `The views the org saved for logs. Past ${SAVED_VIEWS_PAGE_SIZE} the table paginates.`,
value: 4,
max: SAVED_VIEW_MAX,
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v1/explorer/views',
response.json(() => logsSavedViewsResponse(values.views)),
),
rest.put(
'http://localhost/api/v1/explorer/views/:viewId',
response.json(savedViewWriteResponse),
),
rest.delete(
'http://localhost/api/v1/explorer/views/:viewId',
response.json(savedViewWriteResponse),
),
],
config: () => ({ route: ROUTES.LOGS_SAVE_VIEWS }),
});

View File

@@ -1,88 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { savedViewsMocks } from './SavedViews.stories.mocks';
import LogsModulePage from '../../LogsModulePage';
type SavedViewsArgs = PageStoryArgs<typeof savedViewsMocks>;
const pageStory = storyMocks(savedViewsMocks, { layout: 'app' });
/**
* Saved views on the logs explorer, and the actions the legacy editor role gates.
*
* Route: `/logs/saved-views`.
*/
const meta = {
title: 'Pages/Logs/Saved Views',
tags: ['role-gated', 'play'],
component: LogsModulePage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<SavedViewsArgs>;
export default meta;
type Story = StoryObj<SavedViewsArgs>;
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/** Row actions are per-view icons, so a story that opens one picks the first. */
const openFirstAction = async (
canvasElement: HTMLElement,
testId: string,
): Promise<void> => {
const [action] = await within(canvasElement).findAllByTestId(
testId,
undefined,
untilLoaded,
);
await userEvent.click(action);
};
/**
* The views tab: every view the org saved for logs, who saved it and when, with
* the colour the view picker shows it under.
*/
export const Default: Story = {};
/** A workspace where nobody has saved a view yet. */
export const NoViews: Story = {
args: { views: 0 },
};
/** Enough views to paginate, which is the only place the second page shows up. */
export const Paginated: Story = {
args: { views: 8 },
};
/** Renaming a view: its label and the colour the picker shows it under. */
export const EditView: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstAction(canvasElement, 'edit-view');
await screen.findByText('Edit view details', undefined, untilLoaded);
},
};
/** The confirmation a view has to pass before it is deleted. */
export const DeleteView: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstAction(canvasElement, 'delete-view');
await screen.findByText(
/are you sure you want to delete/i,
undefined,
untilLoaded,
);
},
};
/** A viewer: the views are listed, none of them editable. */
export const ViewerAccess: Story = {
args: { access: 'viewer' },
};

View File

@@ -64,6 +64,7 @@ import {
metricTemporalityOf,
metricTypeOf,
metricsSavedViewsResponse,
metricsSavedViewsV2Response,
metricsStatsResponse,
metricsTreemapResponse,
REDUCTION_RULE_MAX,
@@ -517,6 +518,11 @@ export const metricsMocks = defineStoryMocks({
response.json(() => metricsSavedViewsResponse(values.savedViews)),
),
rest.get(
'http://localhost/api/v2/saved_views',
response.json(() => metricsSavedViewsV2Response(values.savedViews)),
),
rest.post(
'http://localhost/api/v5/query_range',
response.json(async (req) => {

View File

@@ -16,6 +16,7 @@ import type {
InspectMetrics200,
ListMetricReductionRules200,
ListMetrics200,
ListSavedViews200,
MetricreductionruletypesGettableReductionRuleDTO,
MetricsexplorertypesStatDTO,
MetricsexplorertypesTreemapEntryDTO,
@@ -24,7 +25,12 @@ import {
MetricreductionruletypesMatchTypeDTO,
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal as MetricsSignal,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5RequestTypeDTO,
SavedviewtypesPanelTypeDTO,
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { explorerView } from 'mocks-server/__mockdata__/explorer_views';
import type { AllViewsProps, ViewProps } from 'types/api/saveViews/types';
@@ -785,6 +791,33 @@ export const metricsSavedViewsResponse = (count: number): AllViewsProps => ({
})),
});
export const metricsSavedViewsV2Response = (
count: number,
): ListSavedViews200 => ({
status: 'success',
data: VIEW_NAMES.slice(0, count).map((displayName, index) => ({
id: `storybook-metrics-view-${index + 1}`,
name: `storybook-metrics-view-${index + 1}`,
source: SavedviewtypesSourceDTO.metrics,
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
createdAt: '2026-08-20T09:00:00Z',
createdBy: 'ada@signoz.io',
updatedAt: '2026-08-20T09:00:00Z',
updatedBy: 'ada@signoz.io',
spec: {
displayName,
panelType: SavedviewtypesPanelTypeDTO.graph,
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
queries: [
{
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
spec: { name: 'A', signal: MetricsSignal.metrics },
},
],
},
})),
});
const FIELD_KEYS = [
'service.name',
'deployment.environment',

View File

@@ -10,7 +10,6 @@ import {
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
act,
cleanup,
fireEvent,
render,
@@ -316,99 +315,17 @@ describe('TracesExplorer -', () => {
});
});
it('test for explorer options', async () => {
const { getByText, getByTestId } = renderWithTracesExplorerRouter(
<TracesExplorer />,
['/traces-explorer/?panelType=list&selectedExplorerView=list'],
);
// assert explorer options - action btns
[
'Save this view',
'Create an Alert',
'Add to Dashboard',
'Select a view',
].forEach((val) => expect(getByText(val)).toBeInTheDocument());
const hideExplorerOption = getByTestId('hide-toolbar');
expect(hideExplorerOption).toBeInTheDocument();
fireEvent.click(hideExplorerOption);
// explorer options should hide and show btn should be present
await expect(
screen.findByTestId('show-explorer-option'),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('hide-toolbar')).toBeNull();
// show explorer options
const showExplorerOption = screen.getByTestId('show-explorer-option');
expect(showExplorerOption).toBeInTheDocument();
fireEvent.click(showExplorerOption);
// explorer options should show and hide btn should be present
await expect(
screen.findByTestId('hide-toolbar'),
).resolves.toBeInTheDocument();
});
it('select a view options - assert and save this view', async () => {
const { container } = renderWithTracesExplorerRouter(<TracesExplorer />, [
'/traces-explorer/?panelType=list&selectedExplorerView=list',
]);
const viewSearchInput = await waitFor(() => {
const el = container.querySelector(
'.view-options .ant-select-selection-search-input',
) as HTMLElement;
expect(el).toBeInTheDocument();
return el;
});
fireEvent.mouseDown(viewSearchInput);
// The saved-views request has to land and re-render the dropdown before the
// option exists; findByRole's 1s default is not enough on a loaded runner.
await expect(
screen.findByRole('option', { name: 'R-test panel' }, { timeout: 5000 }),
).resolves.toBeInTheDocument();
// save this view
fireEvent.click(await screen.findByText('Save this view'));
const saveViewModalInput = await screen.findByPlaceholderText(
'e.g. External http method view',
);
expect(saveViewModalInput).toBeInTheDocument();
const saveViewModal = await waitFor(() => {
const el = document.querySelector('.ant-modal-content') as HTMLElement;
expect(el).toBeInTheDocument();
return el;
});
await act(async () =>
fireEvent.change(saveViewModalInput, { target: { value: 'test view' } }),
);
expect(saveViewModalInput).toHaveValue('test view');
await act(async () => {
fireEvent.click(within(saveViewModal).getByTestId('save-view-btn'));
});
await waitFor(() => {
expect(successNotification).toHaveBeenCalledWith({
message: 'View Saved Successfully',
});
});
}, 15000);
it('create a dashboard btn assert', async () => {
renderWithTracesExplorerRouter(<TracesExplorer />, [
'/traces-explorer/?panelType=list&selectedExplorerView=list',
]);
const createDashboardBtn = await screen.findByText('Add to Dashboard');
expect(createDashboardBtn).toBeInTheDocument();
const createDashboardBtn = await screen.findByTestId(
'explorer-add-to-dashboard',
);
await waitFor(() =>
expect(createDashboardBtn).not.toHaveAttribute('aria-disabled', 'true'),
);
fireEvent.click(createDashboardBtn);
const createDashboardModal = (await screen.findByRole(
@@ -431,8 +348,10 @@ describe('TracesExplorer -', () => {
'/traces-explorer/?panelType=list&selectedExplorerView=list',
]);
const createAlertBtn = await screen.findByText('Create an Alert');
expect(createAlertBtn).toBeInTheDocument();
const createAlertBtn = await screen.findByTestId('explorer-create-alert');
await waitFor(() =>
expect(createAlertBtn).not.toHaveAttribute('aria-disabled', 'true'),
);
fireEvent.click(createAlertBtn);
expect(historyPush).toHaveBeenCalledWith(

View File

@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ROUTES from 'constants/routes';
import { toast } from '@signozhq/ui/sonner';
import { screen, userEvent, waitFor } from 'storybook/test';
import { screen, userEvent } from 'storybook/test';
import type { GlobalMockArgs } from '../globals';
@@ -14,10 +14,10 @@ function EmptyPage(): JSX.Element {
const untilLoaded = { timeout: 10_000 };
/**
* The strip along the foot of the shell, behind the `SAVED_VIEW_ENABLED` flag:
* what the page puts on the left, the build version when it puts nothing, and
* the shell's own Ask Noz and Support on the right. Support follows the chat
* support the workspace has, the way the floating bubble it replaces did.
* The strip along the foot of the shell: what the page puts on the left, the
* build version when it puts nothing, and the shell's own Ask Noz and Support
* on the right. Support follows the chat support the workspace has, the way
* the floating bubble it replaces did.
*
* Route: every page inside the app shell; these stories use `/home`.
*/
@@ -78,38 +78,6 @@ export const Tooltips: Story = {
args: { noz: true, support: 'pylon', tooltipsOpen: true },
};
/**
* The flag off, on a cloud trial without a card: no strip, and chat support is
* the floating bubble in the corner.
*/
export const WithoutStrip: Story = {
args: { bottomStrip: false, noz: true, support: 'add-card' },
};
/** The Add Credit Card modal, opened from the floating bubble with the flag off. */
export const WithoutStripAddCardModal: Story = {
args: { bottomStrip: false, noz: true, support: 'add-card' },
play: async (): Promise<void> => {
// The bubble is an icon with no label or test id of its own.
const bubble = await waitFor(() => {
const found = document.querySelector<HTMLElement>(
'.chat-support-gateway-btn',
);
if (!found) {
throw new Error('the floating support bubble never rendered');
}
return found;
}, untilLoaded);
await userEvent.click(bubble);
await screen.findByRole('dialog', {
name: /Add Credit Card for Chat Support/i,
});
},
};
/** Raises a toast and waits for it, so the shot is taken with it on screen. */
const raiseToast: NonNullable<Story['play']> = async () => {
toast.success('Service account deleted');
@@ -117,16 +85,10 @@ const raiseToast: NonNullable<Story['play']> = async () => {
};
/**
* A toast with the strip on. Sonner pins itself to the foot of the viewport, so
* A toast above the strip. Sonner pins itself to the foot of the viewport, so
* without an offset it lands on top of Ask Noz and Support.
*/
export const WithToast: Story = {
args: { noz: true, support: 'pylon' },
play: raiseToast,
};
/** The same toast with the flag off, back at sonner's own inset from the viewport. */
export const WithoutStripToast: Story = {
args: { bottomStrip: false, noz: true, support: 'pylon' },
play: raiseToast,
};

View File

@@ -34,7 +34,7 @@ export const PATH = {
k8s: '/infrastructure-monitoring/kubernetes',
login: '/login',
metricsSummary: '/metrics-explorer/summary',
metricsViews: '/metrics-explorer/views',
metricsExplorer: '/metrics-explorer/explorer',
roles: '/settings/roles',
roleCreate: '/settings/roles/new',
services: '/services',

View File

@@ -11,13 +11,13 @@ test.describe('Routing — route matching', () => {
await routing.goto(PATH.metricsSummary);
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
await routing.routeTab(PATH.metricsViews).click();
await routing.waitForPath(PATH.metricsViews);
await expect(routing.activeRouteTab(PATH.metricsViews)).toBeVisible();
await routing.routeTab(PATH.metricsExplorer).click();
await routing.waitForPath(PATH.metricsExplorer);
await expect(routing.activeRouteTab(PATH.metricsExplorer)).toBeVisible();
await routing.reload();
expect(routing.url().pathname).toBe(PATH.metricsViews);
await expect(routing.activeRouteTab(PATH.metricsViews)).toBeVisible();
expect(routing.url().pathname).toBe(PATH.metricsExplorer);
await expect(routing.activeRouteTab(PATH.metricsExplorer)).toBeVisible();
});
test('TC-10 a prefix-route deep link loads with the right tab selected', async ({