Compare commits

...

7 Commits

Author SHA1 Message Date
Abhi Kumar
18db419644 refactor(widgets): split types/api/dashboard/getAll into widget and variable types
getAll.ts was the last place the V1 dashboard entity and the chart-spec types
shared a home. Its 155 importers were overwhelmingly reaching for `Widgets`,
which has nothing to do with the dashboards API: ~49 of them are APM, Celery,
Messaging Queues, API Monitoring and the query/uPlot libs.

  Widgets, IBaseWidget, LegendPosition, ContextLink*  -> types/api/widgets/widget
  IDashboardVariable, TVariableQueryType, Variable*   -> types/api/dashboard/variables

Everything else in the file was dead and is deleted with it: Dashboard,
DashboardData, WidgetRow, PayloadProps, DashboardTemplate, PromQLWidgets and
IQueryBuilderTagFilterItems. The last reference to the Dashboard entity was
hasColumnWidthsChanged, which became unreachable when the V1 store went — it
compared against store column widths nothing wrote.

types/api/dashboard/ now holds only variable types, and no V1 dashboard entity
type exists anywhere in the frontend.
2026-08-21 23:49:59 +05:30
Abhi Kumar
a6e09083d7 refactor(widgets): consolidate the widget-card stack under container/WidgetCard
Rendering a V1 `Widgets` spec as a self-contained chart card was spread across
five sibling directories named after the old dashboard grid, even though the
dashboard grid is gone and the remaining consumers are APM, Celery, Messaging
Queues, API Monitoring and Meter Explorer. Group them under one name that says
what they do.

  container/GridCardLayout/GridCard      -> container/WidgetCard/Card
  container/GridCardLayout/WidgetHeader  -> container/WidgetCard/Header
  container/GridCardLayout/EmptyWidget   -> container/WidgetCard/EmptyWidget
  container/GridCardLayout/use*.ts       -> container/WidgetCard/hooks/
  container/PanelWrapper                 -> container/WidgetCard/PanelWrapper
  container/GridTableComponent           -> container/WidgetCard/TablePanel
  container/GridValueComponent           -> container/WidgetCard/ValuePanel

container/GridPanelSwitch is gone: its index/types were already unreferenced,
and generateGridTitle — a ReactNode-to-string helper with no widget-card
connection — moves to utils/.

Also closes the last two lib -> container back-edges left by the chart-layer
move. CustomCheckBox lived in the card's FullView tree but its only consumer is
ChartManager, so it moves into lib/visualization beside it, and the
ExtendedChartDataset type it needs joins the other chart types.
lib/visualization now imports nothing from container/.
2026-08-21 21:23:09 +05:30
Abhi Kumar
e97d127af2 refactor(charts): move the shared chart layer to lib/visualization
The chart layer lived under container/DashboardContainer/visualization even
though twelve areas import it — V2 dashboards, Alerts, Billing, Infra K8s,
Metrics and Meter Explorer, API Monitoring, TimeSeriesView and lib/uPlotV2 —
and nothing V1-dashboard-specific was left around it. Move it next to
lib/uPlotV2, the chart foundation it builds on.

  container/DashboardContainer/visualization -> lib/visualization

Only the reusable half goes to lib. The three V1 panel adapters (Bar,
TimeSeries, Histogram) and usePanelContextMenu implement container/PanelWrapper's
interface and are reachable only through it, so they move there instead:

  lib/visualization/panels/{Bar,TimeSeries,Histogram}Panel
    -> container/PanelWrapper/panels/
  lib/visualization/hooks/usePanelContextMenu
    -> container/PanelWrapper/hooks/

That drops the lib -> container back-edges from 9 to 1 (a CustomCheckbox import
that resolves when the widget-card stack moves).

Splitting them also required splitting container/PanelWrapper/constants, which
mixed the panel-type registry with plain visualization constants. The latter
(DEFAULT_BUCKET_COUNT, histogramBucketSizes, NULL_*) now live in
lib/visualization/constants, which additionally removes a V2 -> V1 PanelWrapper
dependency from the V2 histogram panel.

container/DashboardContainer is now empty and deleted: no V1 dashboard
container remains.
2026-08-21 20:41:54 +05:30
Abhi Kumar
fbbceffbeb chore(dashboard): retire the V1 dashboard store
Nothing has written `dashboardData` since the V1 page was removed, so every
read of it returned undefined and every value derived from it was effectively a
constant. Replace those reads with the values they already had, then delete the
store.

- useCreateAlerts: drop `dashboardName`/`dashboardId` from its logEvent
  payloads; both were always undefined and neither affected the created alert.
- Celery explorer nav and the aggregate drilldown: stop passing `dashboardData`
  into getUpdatedQuery, where it only fed getDashboardVariables(undefined).
- useNavigateToExplorerPages: the read appeared only in a dep array.
- FullView: `version` was always 'v3' and the graph was never locked.
- FullView / WidgetGraphComponent: drop the column-width writes. Nothing read
  store.columnWidths — every consumer reads widget.columnWidths or a prop.
- TimeSeries/Bar panels: with no dashboard id there is no stored cursor-sync
  preference, so derive syncMode from the panel-mode gate alone and pass the
  default tooltip filter mode. Behaviour is unchanged: DASHBOARD_VIEW keeps
  Crosshair, STANDALONE_VIEW keeps None.
- ChartManager: the dashboard was never locked, so isGraphDisabled is false.

Also removes the hooks left unreferenced once the V1 widget page went
(useTransformDashboardVariables, useVariablesFromUrl,
useDashboardFromLocalStorage, normalizeUrlValue), which knip cannot flag
because their own tests kept them reachable.

Unrelated flake fixed on the way: QuerySearch's mount test waited on "any"
key-suggestion call, so a debounced fetch leaking from an earlier test could
satisfy it and the assertion then depended on jest's file ordering. It now
waits for the mount call itself.
2026-08-21 20:23:34 +05:30
Abhi Kumar
74bbcb20d5 feat(dashboard): show a legacy notice for unmigrated public dashboards
Public dashboards whose stored data never reached v6 were rendered by the V1
container, the last V1 dashboard renderer in the app. Replace it with a notice
so there is a single rendering path.

- useGetResolvedPublicDashboard resolves to a `Legacy` marker on the v2
  endpoint's `dashboard_invalid_data` code instead of fetching v1. Any other v2
  error still propagates, so a transient failure is not reported as "legacy".
- Extract the page's branded full-page state into PublicDashboardMessage and
  render it for both the legacy and unavailable cases. Public viewers are
  anonymous, so the notice points at the dashboard owner rather than offering
  retry-migration or support actions.
- Delete container/PublicDashboardContainer and the v1 public data APIs.
- Panel.tsx was the only producer of `publicQueryMeta`, so drop that parameter
  and its branch from GetMetricQueryRange and useGetQueryRange. Removing it
  left `isInfraMonitoring` trailing and unused (it already was, just not
  reported) so that goes too; no caller passed either.

Behaviour change: a viewer of an unmigrated public dashboard now sees the
notice instead of charts, until an admin re-runs the migration.
2026-08-21 19:27:50 +05:30
Abhi Kumar
bdb8b6a675 chore(dashboard): retire the V1 panel editor and its widget route
`ROUTES.DASHBOARD_WIDGET` (`/dashboard/:dashboardId/:widgetId`) was still
registered but no UI linked to it, and its page fetched
`GET /api/v1/dashboards/{id}`, which the backend answers 501. V2 serves panel
editing at `/dashboard/:dashboardId/panel/:panelId`.

Extract what other features still need out of container/NewWidget first, then
delete the route, the page and the container.

Rehomed:
- RightContainer/Threshold/types    -> types/api/widgets/threshold
- RightContainer/types + format categories -> constants/formats/*
  (fixing the alertFomatCategories spelling)
- RightContainer/timeItems          -> constants/timePreference
- RightContainer/ContextLinks/*     -> utils/contextLinks/*
- LeftContainer/QueryTypeTag        -> components/QueryTypeTag
- LeftContainer/WidgetGraph/PlotTag -> components/PlotTag
- LeftContainer/WidgetGraph/util    -> lib/query/populateMultipleResults
- QuerySection/QueryBuilder/{promQL,ClickHouse} ->
  container/QueryBuilder/rawQueryEditors/{PromQL,ClickHouse}
- the four externally-used utils exports -> lib/query/panelQuery

Also:
- Meter Explorer's "Add to dashboard" built a V1 editor URL, so it landed on
  the 501 page. It now uses useGetExportToDashboardLink like the logs, traces
  and metrics explorers, and generateExportToDashboardLink is gone.
- Drop the FullView "Switch to Edit Mode" button and the WidgetHeader
  Edit/Delete/Clone items: all were gated on V1 state nothing populates, so
  they never rendered.
- Delete the V1 write path (useUpdateDashboard, api/v1/dashboards/id/update)
  and the orphaned bootstrap chain.
- Extract ColumnUnit to types/api/widgets/columnUnit to break the
  getAll <-> threshold import cycle the type move would otherwise have created.
2026-08-21 19:02:55 +05:30
Abhi Kumar
b5a1fb2106 chore(dashboard): remove dead V1 dashboard code
V2 is the only implementation serving /dashboard and /dashboard/:id, so
the V1 page bodies and everything reachable only from them are dead.
Remove that cluster and rehome the pieces other features still use.

- point the routes straight at the V2 pages and drop the V1 shims
- delete container/ListOfDashboard, the non-visualization half of
  container/DashboardContainer, and the GridCardLayout grid shell
- rescue shared code before its folder went: the variable dependency
  graph to lib/dashboardVariables/dependencyGraph.ts, uniqueOptions into
  NewSelect, panel-type items to GridCardLayout/panelTypeItems.tsx
- drop two unreachable menu affordances: panel Delete/Clone, which no
  live caller lists in headerMenuList, and the Dashboard Variables
  drilldown submenu, route-gated to /dashboard/:id which V2 serves
  through its own drilldown
- V2 cycle detection uses V2's own dependency helpers rather than
  casting its form model into V1 types

container/DashboardContainer/visualization is deliberately untouched:
it is the shared chart layer, not V1 code.
2026-08-21 17:30:25 +05:30
589 changed files with 1447 additions and 38742 deletions

21
.github/CODEOWNERS vendored
View File

@@ -152,32 +152,21 @@ go.mod @therealpandey
## Dashboard Types
/frontend/src/api/types/dashboard/ @SigNoz/pulse-frontend
/frontend/src/types/api/dashboard/ @SigNoz/pulse-frontend
/frontend/src/types/api/widgets/ @SigNoz/pulse-frontend
## Dashboard List
## Widget Card
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
/frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend
# Dashboard Widget Page
/frontend/src/pages/DashboardWidget/ @SigNoz/pulse-frontend
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
## Dashboard Page
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
/frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend
/frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend
/frontend/src/container/WidgetCard/ @SigNoz/pulse-frontend
## Public Dashboard Page
/frontend/src/pages/PublicDashboard/ @SigNoz/pulse-frontend
/frontend/src/container/PublicDashboardContainer/ @SigNoz/pulse-frontend
## Dashboard Libs + Components
/frontend/src/lib/uPlotV2/ @SigNoz/pulse-frontend
/frontend/src/lib/visualization/ @SigNoz/pulse-frontend
/frontend/src/lib/dashboard/ @SigNoz/pulse-frontend
/frontend/src/lib/dashboardVariables/ @SigNoz/pulse-frontend
/frontend/src/components/NewSelect/ @SigNoz/pulse-frontend

View File

@@ -94,17 +94,12 @@ export const OnboardingV2 = Loadable(
export const DashboardsListPage = Loadable(
() =>
import(
/* webpackChunkName: "DashboardsListPage" */ 'pages/DashboardsListPage'
/* webpackChunkName: "DashboardsListPage" */ 'pages/DashboardsListPageV2'
),
);
export const DashboardPage = Loadable(
() => import(/* webpackChunkName: "DashboardPage" */ 'pages/DashboardPage'),
);
export const DashboardWidget = Loadable(
() =>
import(/* webpackChunkName: "DashboardWidgetPage" */ 'pages/DashboardWidget'),
() => import(/* webpackChunkName: "DashboardPage" */ 'pages/DashboardPageV2'),
);
export const DashboardPanelEditorPage = Loadable(

View File

@@ -13,7 +13,6 @@ import {
DashboardPage,
DashboardPanelEditorPage,
DashboardsListPage,
DashboardWidget,
EditRulesPage,
ErrorDetails,
ForgotPassword,
@@ -183,13 +182,6 @@ const routes: AppRoutes[] = [
isPrivate: false,
key: 'PUBLIC_DASHBOARD',
},
{
path: ROUTES.DASHBOARD_WIDGET,
exact: true,
component: DashboardWidget,
isPrivate: true,
key: 'DASHBOARD_WIDGET',
},
{
path: ROUTES.DASHBOARD_PANEL_EDITOR,
exact: true,

View File

@@ -1,36 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { CreatePublicDashboardProps } from 'types/api/dashboard/public/create';
/**
* @deprecated Use the generated `useCreatePublicDashboard` hook (or `createPublicDashboard` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const createPublicDashboard = async (
props: CreatePublicDashboardProps,
): Promise<SuccessResponseV2<CreatePublicDashboardProps>> => {
const { dashboardId, timeRangeEnabled = false, defaultTimeRange = DEFAULT_TIME_RANGE } = props;
try {
const response = await axios.post(
`/dashboards/${dashboardId}/public`,
{ timeRangeEnabled, defaultTimeRange },
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default createPublicDashboard;

View File

@@ -1,27 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { GetPublicDashboardDataProps, PayloadProps,PublicDashboardDataProps } from 'types/api/dashboard/public/get';
/**
* @deprecated Use the generated `useGetPublicDashboardData` hook (or `getPublicDashboardData` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const getPublicDashboardData = async (props: GetPublicDashboardDataProps): Promise<SuccessResponseV2<PublicDashboardDataProps>> => {
try {
const response = await axios.get<PayloadProps>(`/public/dashboards/${props.id}`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getPublicDashboardData;

View File

@@ -1,27 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { GetPublicDashboardMetaProps, PayloadProps,PublicDashboardMetaProps } from 'types/api/dashboard/public/getMeta';
/**
* @deprecated Use the generated `useGetPublicDashboard` hook (or `getPublicDashboard` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const getPublicDashboardMeta = async (props: GetPublicDashboardMetaProps): Promise<SuccessResponseV2<PublicDashboardMetaProps>> => {
try {
const response = await axios.get<PayloadProps>(`/dashboards/${props.id}/public`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getPublicDashboardMeta;

View File

@@ -1,34 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { MetricRangePayloadV5 } from 'api/v5/v5';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { GetPublicDashboardWidgetDataProps } from 'types/api/dashboard/public/getWidgetData';
/**
* @deprecated Use the generated `useGetPublicDashboardWidgetQueryRange` hook (or `getPublicDashboardWidgetQueryRange` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const getPublicDashboardWidgetData = async (props: GetPublicDashboardWidgetDataProps): Promise<SuccessResponseV2<MetricRangePayloadV5>> => {
try {
const response = await axios.get(`/public/dashboards/${props.id}/widgets/${props.index}/query_range`, {
params: {
startTime: props.startTime,
endTime: props.endTime,
},
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getPublicDashboardWidgetData;

View File

@@ -1,29 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps,RevokePublicDashboardAccessProps } from 'types/api/dashboard/public/delete';
/**
* @deprecated Use the generated `useDeletePublicDashboard` hook (or `deletePublicDashboard` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const revokePublicDashboardAccess = async (
props: RevokePublicDashboardAccessProps,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.delete<PayloadProps>(`/dashboards/${props.id}/public`);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default revokePublicDashboardAccess;

View File

@@ -1,36 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { UpdatePublicDashboardProps } from 'types/api/dashboard/public/update';
/**
* @deprecated Use the generated `useUpdatePublicDashboard` hook (or `updatePublicDashboard` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const updatePublicDashboard = async (
props: UpdatePublicDashboardProps,
): Promise<SuccessResponseV2<UpdatePublicDashboardProps>> => {
const { dashboardId, timeRangeEnabled = false, defaultTimeRange = DEFAULT_TIME_RANGE } = props;
try {
const response = await axios.put(
`/dashboards/${dashboardId}/public`,
{ timeRangeEnabled, defaultTimeRange },
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default updatePublicDashboard;

View File

@@ -1,23 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/dashboard/create';
import { Dashboard } from 'types/api/dashboard/getAll';
const create = async (props: Props): Promise<SuccessResponseV2<Dashboard>> => {
try {
const response = await axios.post<PayloadProps>('/dashboards', {
...props,
});
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default create;

View File

@@ -1,19 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Dashboard, PayloadProps } from 'types/api/dashboard/getAll';
const getAll = async (): Promise<SuccessResponseV2<Dashboard[]>> => {
try {
const response = await axios.get<PayloadProps>('/dashboards');
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getAll;

View File

@@ -1,21 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/dashboard/delete';
const deleteDashboard = async (
props: Props,
): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.delete<PayloadProps>(`/dashboards/${props.id}`);
return {
httpStatusCode: response.status,
data: null,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default deleteDashboard;

View File

@@ -1,20 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/dashboard/get';
import { Dashboard } from 'types/api/dashboard/getAll';
const get = async (props: Props): Promise<SuccessResponseV2<Dashboard>> => {
try {
const response = await axios.get<PayloadProps>(`/dashboards/${props.id}`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default get;

View File

@@ -1,23 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/dashboard/lockUnlock';
const lock = async (props: Props): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.put<PayloadProps>(
`/dashboards/${props.id}/lock`,
{ lock: props.lock },
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default lock;

View File

@@ -1,23 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
import { PayloadProps, Props } from 'types/api/dashboard/update';
const update = async (props: Props): Promise<SuccessResponseV2<Dashboard>> => {
try {
const response = await axios.put<PayloadProps>(`/dashboards/${props.id}`, {
...props.data,
});
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default update;

View File

@@ -1,176 +0,0 @@
export default function ApacheIcon(): JSX.Element {
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7112 1.05739C8.3796 1.30831 8.08524 1.60498 7.83691 1.93853L8.17977 2.58567C8.40218 2.26279 8.64677 1.95576 8.91177 1.66681C8.93063 1.64581 8.94091 1.63596 8.94091 1.63596L8.91177 1.66681C8.66003 1.95965 8.43081 2.27111 8.22606 2.59853C8.67305 2.56672 9.11808 2.51165 9.55934 2.43353C9.60936 2.25525 9.62374 2.06886 9.60168 1.88502C9.57962 1.70118 9.52154 1.52349 9.43077 1.3621C9.43077 1.3621 9.09991 0.828957 8.7112 1.05739Z"
fill="url(#paint0_linear_2061_4195)"
/>
<path
d="M7.55932 6.49365L7.42432 6.51722L7.49332 6.50651C7.51432 6.50265 7.53703 6.49837 7.55932 6.49365Z"
fill="#BE202E"
/>
<path
opacity="0.35"
d="M7.55932 6.49365L7.42432 6.51722L7.49332 6.50651C7.51432 6.50265 7.53703 6.49837 7.55932 6.49365Z"
fill="#BE202E"
/>
<path
d="M7.67407 5.92858L7.6955 5.92558C7.72464 5.9213 7.75336 5.91616 7.78122 5.91016L7.67493 5.92816L7.67407 5.92858Z"
fill="#BE202E"
/>
<path
opacity="0.35"
d="M7.67407 5.92858L7.6955 5.92558C7.72464 5.9213 7.75336 5.91616 7.78122 5.91016L7.67493 5.92816L7.67407 5.92858Z"
fill="#BE202E"
/>
<path
d="M7.16872 4.25736C7.273 4.0625 7.37858 3.87221 7.48543 3.6865C7.59629 3.49393 7.70829 3.3075 7.82143 3.12721L7.84115 3.09507C7.95315 2.91793 8.066 2.74779 8.17972 2.58464L7.83686 1.9375L7.75886 2.03393C7.65986 2.15736 7.55743 2.29107 7.452 2.43036C7.33329 2.58893 7.21115 2.75779 7.08729 2.93564C6.97286 3.09979 6.85672 3.27164 6.74058 3.44993C6.64158 3.60121 6.54258 3.75721 6.444 3.91707L6.43286 3.93507L6.879 4.8145C6.97443 4.62707 7.071 4.44136 7.16872 4.25736Z"
fill="url(#paint1_linear_2061_4195)"
/>
<path
d="M5.13606 9.22519C5.07692 9.38748 5.01763 9.55305 4.95821 9.72191L4.95563 9.72919L4.93035 9.80076C4.89049 9.91476 4.85535 10.015 4.77563 10.2503C4.92444 10.3467 5.0416 10.4847 5.11249 10.6472C5.10283 10.4581 5.01907 10.2804 4.87935 10.1526C5.16043 10.1981 5.44862 10.1654 5.71236 10.0581C5.97609 9.95074 6.20521 9.77291 6.37463 9.54405C6.40102 9.50088 6.42464 9.45607 6.44535 9.40991C6.37459 9.49741 6.28141 9.56408 6.17575 9.6028C6.07008 9.64152 5.95589 9.65084 5.84535 9.62976C6.20937 9.49539 6.51802 9.24316 6.72221 8.91319C6.76978 8.83691 6.81563 8.75376 6.86278 8.66162C6.6974 8.84328 6.48756 8.97872 6.25393 9.05463C6.02029 9.13053 5.77091 9.14426 5.53035 9.09448L5.16949 9.13391C5.15706 9.16434 5.14721 9.19476 5.13606 9.22519Z"
fill="url(#paint2_linear_2061_4195)"
/>
<path
d="M5.30448 8.417C5.38248 8.21528 5.46276 8.01157 5.54533 7.80586C5.62419 7.60871 5.70519 7.41057 5.78833 7.21143C5.87148 7.01228 5.95633 6.81228 6.0429 6.61143C6.1309 6.40828 6.22076 6.20557 6.31248 6.00328C6.40419 5.801 6.49633 5.60257 6.5889 5.408C6.62262 5.33714 6.65662 5.26643 6.6909 5.19586C6.75005 5.07414 6.80962 4.95343 6.86962 4.83371C6.87262 4.82728 6.87605 4.82086 6.87948 4.81443L6.4329 3.93457L6.41105 3.97014C6.30733 4.14157 6.20362 4.313 6.10205 4.49128C6.00048 4.66957 5.89848 4.853 5.80205 5.03814C5.7189 5.19443 5.63776 5.35157 5.55862 5.50957L5.51148 5.606C5.41419 5.80614 5.32633 5.99943 5.24705 6.18543C5.15705 6.396 5.07762 6.59686 5.00876 6.788C4.9629 6.91357 4.92305 7.03486 4.88362 7.151C4.85233 7.25043 4.82276 7.34986 4.79448 7.451C4.72762 7.68471 4.67048 7.91771 4.62305 8.15L5.07133 9.035C5.13076 8.87671 5.19162 8.71614 5.2539 8.55328L5.30448 8.417Z"
fill="url(#paint3_linear_2061_4195)"
/>
<path
d="M4.615 8.18082C4.55868 8.46014 4.51975 8.74268 4.49843 9.02682C4.49843 9.03668 4.49843 9.04653 4.49629 9.05639C4.3547 8.8778 4.1801 8.72808 3.982 8.61539C4.24526 8.95089 4.41819 9.34823 4.48429 9.76953C4.28982 9.78674 4.0942 9.75338 3.91643 9.67268C4.05057 9.80984 4.21714 9.91096 4.40072 9.96668C4.14974 10.0146 3.9168 10.1307 3.72743 10.3022C3.97375 10.178 4.25059 10.1271 4.525 10.1557C4.21858 11.024 3.91129 11.9827 3.604 13.0001C3.64664 12.988 3.6856 12.9655 3.71739 12.9346C3.74918 12.9037 3.7728 12.8654 3.78615 12.8231C3.841 12.6388 4.20443 11.4298 4.77443 9.84068L4.82372 9.70439L4.83743 9.66625C4.89772 9.49968 4.96015 9.32968 5.02472 9.15625L5.06758 9.03753V9.03539L4.62143 8.15039C4.61929 8.16025 4.61672 8.17053 4.615 8.18082Z"
fill="url(#paint4_linear_2061_4195)"
/>
<path
d="M6.94843 4.89059L6.90986 4.96987C6.87129 5.04959 6.83214 5.13102 6.79243 5.21416C6.74957 5.30416 6.70671 5.3963 6.66386 5.49059C6.64157 5.53816 6.621 5.58573 6.59743 5.63416C6.53057 5.7793 6.46286 5.92916 6.39429 6.08373C6.30857 6.27402 6.22286 6.47173 6.13714 6.67687C6.054 6.87259 5.96971 7.07516 5.88429 7.28459C5.80314 7.48459 5.721 7.68973 5.63786 7.90002C5.56386 8.08888 5.48914 8.28273 5.41371 8.48159C5.40986 8.49145 5.40643 8.50087 5.403 8.51073C5.32814 8.70873 5.25257 8.91187 5.17629 9.12016L5.17114 9.1343L5.532 9.09487L5.51057 9.09102C6.039 8.98351 6.52026 8.7126 6.88629 8.31659C7.0686 8.1182 7.22683 7.89896 7.35771 7.66345C7.47147 7.46033 7.57252 7.25036 7.66029 7.03473C7.74386 6.83245 7.824 6.61387 7.90114 6.37645C7.79448 6.43086 7.68084 6.47037 7.56343 6.49388C7.54157 6.49859 7.52057 6.50287 7.49657 6.50673C7.47257 6.51059 7.45071 6.51445 7.42757 6.51745C7.80325 6.36305 8.10458 6.06924 8.26843 5.69759C8.12155 5.79773 7.95733 5.86966 7.78414 5.90973C7.75586 5.91616 7.72757 5.92087 7.69843 5.92516L7.677 5.92816C7.80484 5.87656 7.92565 5.80903 8.03657 5.72716C8.05843 5.71087 8.07943 5.69373 8.1 5.67573C8.13129 5.64873 8.16086 5.62045 8.18914 5.59002C8.20714 5.57116 8.22471 5.55145 8.24186 5.53087C8.28293 5.48179 8.32059 5.42996 8.35457 5.37573C8.36529 5.35859 8.376 5.34145 8.38629 5.32345C8.39957 5.29773 8.41243 5.27245 8.42486 5.24716C8.481 5.13402 8.526 5.03288 8.56157 4.94459C8.57957 4.90173 8.595 4.85888 8.60829 4.82116C8.61386 4.80616 8.619 4.79116 8.62371 4.7783C8.63786 4.73545 8.64943 4.69816 8.65843 4.66473C8.66941 4.62595 8.67828 4.58661 8.685 4.54688C8.67015 4.5586 8.65454 4.56934 8.63829 4.57902C8.4822 4.66169 8.31419 4.71953 8.14029 4.75045H8.13257L8.08157 4.75859L8.09057 4.75473L6.957 4.87902L6.94843 4.89059Z"
fill="url(#paint5_linear_2061_4195)"
/>
<path
d="M8.22475 2.59871C8.12404 2.75343 8.01389 2.92914 7.89561 3.12843L7.87675 3.16014C7.77446 3.33157 7.66604 3.52114 7.55147 3.72886C7.45261 3.90771 7.34989 4.10014 7.24332 4.30614C7.15018 4.48586 7.05418 4.67671 6.95532 4.87871L8.08889 4.75443C8.33914 4.65567 8.55501 4.48583 8.70989 4.26586C8.74804 4.211 8.78618 4.15357 8.82432 4.09443C8.94089 3.91271 9.05489 3.71257 9.15689 3.51371C9.25018 3.33322 9.33429 3.14813 9.40889 2.95914C9.44758 2.86102 9.48092 2.76087 9.50875 2.65914C9.52932 2.58028 9.54561 2.50571 9.55804 2.43457C9.11675 2.51236 8.67172 2.56715 8.22475 2.59871Z"
fill="url(#paint6_linear_2061_4195)"
/>
<path
d="M7.49234 6.50635C7.46963 6.5102 7.44648 6.51406 7.42334 6.51706C7.44648 6.51406 7.47134 6.5102 7.49234 6.50635Z"
fill="#BE202E"
/>
<path
opacity="0.35"
d="M7.49234 6.50635C7.46963 6.5102 7.44648 6.51406 7.42334 6.51706C7.44648 6.51406 7.47134 6.5102 7.49234 6.50635Z"
fill="#BE202E"
/>
<path
d="M7.49234 6.50635C7.46963 6.5102 7.44648 6.51406 7.42334 6.51706C7.44648 6.51406 7.47134 6.5102 7.49234 6.50635Z"
fill="url(#paint7_linear_2061_4195)"
/>
<defs>
<linearGradient
id="paint0_linear_2061_4195"
x1="7.26579"
y1="1.16584"
x2="9.7782"
y2="0.46843"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F69923" />
<stop offset="0.312" stopColor="#F79A23" />
<stop offset="0.838" stopColor="#E97826" />
</linearGradient>
<linearGradient
id="paint1_linear_2061_4195"
x1="1.76109"
y1="12.4382"
x2="6.87606"
y2="1.48267"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.323" stopColor="#9E2064" />
<stop offset="0.63" stopColor="#C92037" />
<stop offset="0.751" stopColor="#CD2335" />
<stop offset="1" stopColor="#E97826" />
</linearGradient>
<linearGradient
id="paint2_linear_2061_4195"
x1="3.47784"
y1="11.6287"
x2="6.5258"
y2="5.10046"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#282662" />
<stop offset="0.095" stopColor="#662E8D" />
<stop offset="0.788" stopColor="#9F2064" />
<stop offset="0.949" stopColor="#CD2032" />
</linearGradient>
<linearGradient
id="paint3_linear_2061_4195"
x1="1.94596"
y1="11.7748"
x2="7.06094"
y2="0.819304"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.323" stopColor="#9E2064" />
<stop offset="0.63" stopColor="#C92037" />
<stop offset="0.751" stopColor="#CD2335" />
<stop offset="1" stopColor="#E97826" />
</linearGradient>
<linearGradient
id="paint4_linear_2061_4195"
x1="2.46768"
y1="11.0455"
x2="5.15578"
y2="5.28798"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#282662" />
<stop offset="0.095" stopColor="#662E8D" />
<stop offset="0.788" stopColor="#9F2064" />
<stop offset="0.949" stopColor="#CD2032" />
</linearGradient>
<linearGradient
id="paint5_linear_2061_4195"
x1="3.08048"
y1="12.3044"
x2="8.19546"
y2="1.3489"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.323" stopColor="#9E2064" />
<stop offset="0.63" stopColor="#C92037" />
<stop offset="0.751" stopColor="#CD2335" />
<stop offset="1" stopColor="#E97826" />
</linearGradient>
<linearGradient
id="paint6_linear_2061_4195"
x1="2.70679"
y1="12.9581"
x2="7.82195"
y2="2.00223"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.323" stopColor="#9E2064" />
<stop offset="0.63" stopColor="#C92037" />
<stop offset="0.751" stopColor="#CD2335" />
<stop offset="1" stopColor="#E97826" />
</linearGradient>
<linearGradient
id="paint7_linear_2061_4195"
x1="3.41759"
y1="12.4619"
x2="8.53277"
y2="1.50629"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.323" stopColor="#9E2064" />
<stop offset="0.63" stopColor="#C92037" />
<stop offset="0.751" stopColor="#CD2335" />
<stop offset="1" stopColor="#E97826" />
</linearGradient>
</defs>
</svg>
);
}

View File

@@ -1,28 +0,0 @@
export default function DockerIcon(): JSX.Element {
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_2061_4220)">
<path
d="M13.574 5.82107C13.2652 5.60679 12.5575 5.52644 12.0042 5.63358C11.9398 5.09788 11.6439 4.62915 11.1292 4.21399L10.8332 3.99971L10.6274 4.30774C10.37 4.70951 10.2413 5.27198 10.2799 5.80768C10.2928 5.99517 10.3571 6.32998 10.5502 6.62461C10.37 6.73175 9.99686 6.86567 9.50789 6.86567H0.204685L0.17895 6.97281C0.0888774 7.5085 0.0888774 9.18254 1.14401 10.4682C1.9418 11.4458 3.12561 11.9414 4.68258 11.9414C8.05386 11.9414 10.5502 10.3209 11.7211 7.38797C12.1843 7.40136 13.1751 7.38797 13.677 6.38354C13.6898 6.35676 13.7156 6.30319 13.8056 6.10231L13.8571 5.99517L13.574 5.82107ZM7.6421 2.04443H6.22668V3.38367H7.6421V2.04443ZM7.6421 3.65151H6.22668V4.99074H7.6421V3.65151ZM5.96933 3.65151H4.5539V4.99074H5.96933V3.65151ZM4.29655 3.65151H2.88113V4.99074H4.29655V3.65151ZM2.62378 5.25859H1.20835V6.59782H2.62378V5.25859ZM4.29655 5.25859H2.88113V6.59782H4.29655V5.25859ZM5.96933 5.25859H4.5539V6.59782H5.96933V5.25859ZM7.6421 5.25859H6.22668V6.59782H7.6421V5.25859ZM9.31488 5.25859H7.89945V6.59782H9.31488V5.25859Z"
fill="#2396ED"
/>
</g>
<defs>
<clipPath id="clip0_2061_4220">
<rect
width="13.7143"
height="13.7143"
fill="white"
transform="translate(0.142822 0.143066)"
/>
</clipPath>
</defs>
</svg>
);
}

View File

@@ -1,36 +0,0 @@
export default function ElasticSearchIcon(): JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.38041 6.94508L9.70241 8.45858L13.0524 5.52158C13.1019 5.27892 13.1255 5.03171 13.1229 4.78408C13.1236 3.9846 12.8681 3.20592 12.3941 2.56216C11.92 1.9184 11.2522 1.44339 10.4885 1.20675C9.72487 0.970101 8.9055 0.984259 8.15047 1.24714C7.39544 1.51003 6.74446 2.00782 6.29291 2.66758L5.73291 5.56008L6.38041 6.94508Z"
fill="#FED10A"
/>
<path
d="M2.943 10.4593C2.89356 10.7062 2.86994 10.9575 2.8725 11.2093C2.87361 12.012 3.13177 12.7932 3.60915 13.4385C4.08654 14.0838 4.75804 14.5592 5.52528 14.7952C6.29252 15.0311 7.11514 15.0151 7.87262 14.7495C8.63009 14.4839 9.28259 13.9827 9.7345 13.3193L10.2845 10.4398L9.55 9.02928L6.215 7.50928L2.943 10.4593Z"
fill="#24BBB1"
/>
<path
d="M2.92394 4.71302L5.19994 5.25002L5.69994 2.66552C5.39077 2.43015 5.01365 2.30134 4.62509 2.29839C4.23654 2.29544 3.8575 2.41852 3.54479 2.64916C3.23208 2.8798 3.00256 3.20559 2.89063 3.57768C2.77869 3.94978 2.79039 4.34813 2.92394 4.71302Z"
fill="#EF5098"
/>
<path
d="M2.72503 5.2583C2.23266 5.42016 1.80253 5.73058 1.49379 6.14687C1.18505 6.56317 1.01286 7.0649 1.00091 7.58305C0.988962 8.1012 1.13784 8.61033 1.42706 9.04041C1.71628 9.4705 2.13164 9.80042 2.61603 9.9848L5.81003 7.0998L5.22653 5.8498L2.72503 5.2583Z"
fill="#17A8E0"
/>
<path
d="M10.312 13.3197C10.6209 13.5543 10.9975 13.6825 11.3853 13.6851C11.7732 13.6877 12.1514 13.5646 12.4634 13.3342C12.7755 13.1038 13.0044 12.7785 13.116 12.407C13.2276 12.0356 13.2159 11.6379 13.0825 11.2737L10.812 10.7412L10.312 13.3197Z"
fill="#93C83E"
/>
<path
d="M10.7735 10.145L13.2735 10.7285C13.7666 10.5672 14.1976 10.257 14.507 9.84058C14.8165 9.42415 14.9891 8.922 15.0013 8.40333C15.0134 7.88467 14.8643 7.375 14.5747 6.94457C14.2851 6.51414 13.8691 6.18413 13.384 6L10.1135 8.8665L10.7735 10.145Z"
fill="#0779A1"
/>
</svg>
);
}

View File

@@ -1,27 +0,0 @@
function HerokuIcon(): JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_2061_4245)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.6285 0H2.10787C1.31283 0 0.666748 0.644312 0.666748 1.44112V14.5602C0.666748 15.3557 1.31283 16 2.10787 16H13.6285C14.4237 16 15.0678 15.3557 15.0678 14.5602V1.44112C15.0678 0.644312 14.4237 0 13.6285 0ZM14.2677 14.5602C14.2677 14.9135 13.9815 15.1994 13.6285 15.1994H2.10787C1.75506 15.1994 1.46688 14.9135 1.46688 14.5602V1.44112C1.46688 1.08654 1.75506 0.800133 2.10787 0.800133H13.6285C13.9815 0.800133 14.2677 1.08654 14.2677 1.44112V14.5602ZM4.26699 13.6009L6.06823 12.0002L4.26699 10.3999V13.6009ZM10.7719 7.11485C10.4481 6.78927 9.8569 6.40038 8.86819 6.40038C7.78342 6.40038 6.66611 6.68302 5.86752 6.94177V2.40082H4.26704V9.33907L5.39807 8.82667C5.41688 8.81826 7.24025 8.00086 8.86819 8.00086C9.68027 8.00086 9.86022 8.44796 9.86885 8.82158V13.6009H11.4676V8.801C11.4693 8.6983 11.4592 7.81051 10.7719 7.11485ZM8.66761 5.00027H10.2681C10.9912 4.1811 11.3597 3.30903 11.4677 2.40066H9.86881C9.69063 3.30726 9.29643 4.17424 8.66761 5.00027Z"
fill="#6762A6"
/>
</g>
<defs>
<clipPath id="clip0_2061_4245">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);
}
export default HerokuIcon;

File diff suppressed because one or more lines are too long

View File

@@ -1,68 +0,0 @@
export default function MongoDBIcon(): JSX.Element {
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.26568 13.0004L6.94382 12.8937C6.94382 12.8937 6.98668 11.2651 6.39739 11.1507C6.01168 10.7015 6.45439 -8.02403 7.86439 11.0868C7.59687 11.2225 7.39217 11.4564 7.29311 11.7395C7.24001 12.1577 7.23082 12.5803 7.26568 13.0004Z"
fill="url(#paint0_linear_2061_4238)"
/>
<path
d="M7.43957 11.4272C8.29654 10.7821 8.95282 9.90701 9.33214 8.90369C9.71147 7.90037 9.79826 6.81 9.58243 5.75931C8.95243 2.98002 7.46058 2.06631 7.29986 1.71745C7.1612 1.50022 7.04284 1.27068 6.94629 1.03174L7.065 8.7756C7.065 8.7756 6.819 11.1422 7.43957 11.4272Z"
fill="url(#paint1_linear_2061_4238)"
/>
<path
d="M6.78015 11.5296C6.78015 11.5296 4.15687 9.74286 4.30858 6.58214C4.32273 5.62928 4.54121 4.69054 4.94926 3.82935C5.3573 2.96816 5.94542 2.20456 6.67387 1.59014C6.759 1.51782 6.82663 1.42715 6.87168 1.32493C6.91674 1.22272 6.93805 1.11163 6.93401 1C7.0973 1.35143 7.07072 6.247 7.08787 6.81957C7.1543 9.04686 6.96401 11.1091 6.78015 11.5296Z"
fill="url(#paint2_linear_2061_4238)"
/>
<defs>
<linearGradient
id="paint0_linear_2061_4238"
x1="5.1021"
y1="7.10853"
x2="8.80138"
y2="8.36386"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.231" stopColor="#999875" />
<stop offset="0.563" stopColor="#9B9977" />
<stop offset="0.683" stopColor="#A09F7E" />
<stop offset="0.768" stopColor="#A9A889" />
<stop offset="0.837" stopColor="#B7B69A" />
<stop offset="0.896" stopColor="#C9C7B0" />
<stop offset="0.948" stopColor="#DEDDCB" />
<stop offset="0.994" stopColor="#F8F6EB" />
<stop offset="1" stopColor="#FBF9EF" />
</linearGradient>
<linearGradient
id="paint1_linear_2061_4238"
x1="6.45855"
y1="0.976404"
x2="8.09399"
y2="11.1888"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#48A547" />
<stop offset="1" stopColor="#3F9143" />
</linearGradient>
<linearGradient
id="paint2_linear_2061_4238"
x1="4.08293"
y1="6.89501"
x2="8.47176"
y2="5.42519"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41A247" />
<stop offset="0.352" stopColor="#4BA74B" />
<stop offset="0.956" stopColor="#67B554" />
<stop offset="1" stopColor="#69B655" />
</linearGradient>
</defs>
</svg>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -1,22 +0,0 @@
function NginxIcon(): JSX.Element {
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.97775 1H7.00561C7.14837 1.068 7.28743 1.14353 7.42218 1.22629C8.97332 2.11829 10.5245 3.01057 12.0756 3.90314C12.1356 3.93517 12.1846 3.9845 12.2163 4.04472C12.2479 4.10493 12.2607 4.17327 12.253 4.24086C12.2496 6.12186 12.253 8.00243 12.2509 9.88257C12.2307 9.9723 12.1759 10.0504 12.0983 10.0999C10.4489 11.0496 8.79932 11.9987 7.14961 12.9473C7.10963 12.9778 7.06144 12.9956 7.01125 12.9984C6.96106 13.0012 6.91118 12.9889 6.86803 12.9631C5.21661 12.0163 3.56675 11.0677 1.91846 10.1174C1.86489 10.0921 1.82003 10.0515 1.78952 10.0007C1.75901 9.94988 1.74423 9.89119 1.74703 9.832C1.74703 7.95143 1.74703 6.071 1.74703 4.19071C1.74299 4.13178 1.75661 4.07299 1.78616 4.02184C1.8157 3.97069 1.85983 3.92951 1.91289 3.90357C3.46203 3.01271 5.01118 2.12129 6.56032 1.22929C6.69832 1.15043 6.83375 1.06686 6.97775 1Z"
fill="#019639"
/>
<path
d="M3.90007 4.65924C3.90007 6.21039 3.90007 7.76167 3.90007 9.3131C3.89809 9.39901 3.91326 9.48446 3.94468 9.56445C3.9761 9.64444 4.02315 9.71736 4.08307 9.77896C4.19794 9.89243 4.34824 9.96309 4.5089 9.97916C4.66956 9.99522 4.83087 9.95572 4.96593 9.86724C5.05634 9.80581 5.13035 9.72321 5.18152 9.62662C5.23269 9.53003 5.25946 9.4224 5.2595 9.3131C5.2595 8.19024 5.25736 7.06739 5.2595 5.94453C6.28322 7.17024 7.30907 8.39424 8.33707 9.61653C8.4799 9.76122 8.65676 9.86772 8.85145 9.92628C9.04614 9.98484 9.25242 9.99357 9.45136 9.95167C9.59184 9.924 9.71973 9.85198 9.81624 9.74622C9.91275 9.64045 9.97278 9.50652 9.9875 9.3641C9.98979 7.78096 9.98979 6.19796 9.9875 4.6151C9.97275 4.44614 9.8952 4.28884 9.77016 4.17425C9.64512 4.05966 9.48168 3.99609 9.31207 3.99609C9.14247 3.99609 8.97902 4.05966 8.85399 4.17425C8.72895 4.28884 8.6514 4.44614 8.63665 4.6151C8.63665 5.75596 8.62979 6.89553 8.63665 8.03596C7.63122 6.85053 6.63822 5.65481 5.63665 4.4651C5.5046 4.29578 5.32979 4.16474 5.13023 4.08549C4.93067 4.00624 4.71359 3.98165 4.50136 4.01424C4.34059 4.03214 4.19154 4.10704 4.08124 4.22536C3.97093 4.34369 3.90666 4.49761 3.90007 4.65924Z"
fill="white"
/>
</svg>
);
}
export default NginxIcon;

File diff suppressed because one or more lines are too long

View File

@@ -1,60 +0,0 @@
export default function RedisIcon(): JSX.Element {
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_2061_4282)">
<path
d="M13.3198 10.158C12.5879 10.5395 8.79654 12.0984 7.98938 12.5192C7.18221 12.94 6.73382 12.936 6.09616 12.6312C5.45855 12.3263 1.42388 10.6966 0.697072 10.3492C0.333858 10.1756 0.142822 10.029 0.142822 9.8906V8.50438C0.142822 8.50438 5.3955 7.3609 6.24348 7.05667C7.09141 6.75244 7.38563 6.74145 8.10723 7.00578C8.82895 7.2702 13.1439 8.0487 13.8571 8.30992L13.8568 9.67653C13.8569 9.81356 13.6923 9.96388 13.3198 10.158Z"
fill="#912626"
/>
<path
d="M13.3195 8.77972C12.5877 9.16104 8.79642 10.72 7.98926 11.1407C7.18215 11.5616 6.73376 11.5575 6.09615 11.2527C5.45849 10.9481 1.42397 9.31805 0.697221 8.97086C-0.029529 8.62345 -0.0447433 8.38436 0.66915 8.10482C1.38304 7.82518 5.39544 6.25098 6.24353 5.94675C7.09145 5.64263 7.38561 5.63154 8.10722 5.89597C8.82888 6.16029 12.5975 7.66034 13.3106 7.9215C14.024 8.18298 14.0513 8.39829 13.3195 8.77972Z"
fill="#C6302B"
/>
<path
d="M13.3198 7.91483C12.5879 8.29637 8.79654 9.85519 7.98938 10.2762C7.18221 10.6968 6.73382 10.6928 6.09616 10.388C5.4585 10.0833 1.42388 8.45338 0.697072 8.10597C0.333858 7.9324 0.142822 7.78604 0.142822 7.64756V6.26119C0.142822 6.26119 5.3955 5.11776 6.24348 4.81353C7.09141 4.50929 7.38563 4.49826 8.10723 4.76263C8.829 5.02701 13.144 5.80535 13.8571 6.06661L13.8568 7.43338C13.8569 7.57036 13.6923 7.72069 13.3198 7.91483Z"
fill="#912626"
/>
<path
d="M13.3195 6.53701C12.5877 6.91844 8.79642 8.47726 7.98926 8.89817C7.18215 9.31892 6.73376 9.3148 6.09615 9.00998C5.45849 8.70537 1.42397 7.07541 0.697221 6.72816C-0.029529 6.38085 -0.0447433 6.14171 0.66915 5.86207C1.38304 5.58258 5.39549 4.00828 6.24353 3.7041C7.09145 3.39992 7.38561 3.38889 8.10722 3.65326C8.82888 3.91758 12.5975 5.41753 13.3106 5.6788C14.024 5.94023 14.0513 6.15558 13.3195 6.53701Z"
fill="#C6302B"
/>
<path
d="M13.3198 5.58855C12.5879 5.96998 8.79654 7.5289 7.98938 7.94987C7.18221 8.37062 6.73382 8.36649 6.09616 8.06167C5.4585 7.75701 1.42388 6.12705 0.697072 5.7798C0.333858 5.60607 0.142822 5.45965 0.142822 5.32133V3.9349C0.142822 3.9349 5.3955 2.79153 6.24348 2.48735C7.09141 2.18307 7.38563 2.17214 8.10723 2.43646C8.829 2.70083 13.144 3.47917 13.8571 3.74044L13.8568 5.10715C13.8569 5.24403 13.6923 5.39435 13.3198 5.58855Z"
fill="#912626"
/>
<path
d="M13.3195 4.21078C12.5876 4.59221 8.79639 6.15113 7.98923 6.57188C7.18212 6.99263 6.73373 6.98851 6.09612 6.68385C5.45852 6.37903 1.42394 4.74917 0.697248 4.40187C-0.0295558 4.05462 -0.0447165 3.81542 0.669123 3.53583C1.38302 3.2563 5.39546 1.68221 6.2435 1.37792C7.09143 1.07369 7.38559 1.06276 8.1072 1.32714C8.82886 1.59151 12.5975 3.09146 13.3106 3.35272C14.0239 3.61394 14.0513 3.82935 13.3195 4.21078Z"
fill="#C6302B"
/>
<path
d="M8.67572 2.86218L7.49661 2.9846L7.23267 3.61974L6.80635 2.91099L5.44483 2.78863L6.46076 2.42226L6.15594 1.85986L7.1071 2.23186L8.00378 1.93829L7.76142 2.51981L8.67572 2.86218ZM7.16228 5.94351L4.96172 5.03081L8.11494 4.54679L7.16228 5.94351ZM4.11138 3.21522C5.04219 3.21522 5.79674 3.50772 5.79674 3.86847C5.79674 4.22933 5.04219 4.52177 4.11138 4.52177C3.18058 4.52177 2.42603 4.22927 2.42603 3.86847C2.42603 3.50772 3.18058 3.21522 4.11138 3.21522Z"
fill="white"
/>
<path
d="M10.0693 3.0357L11.9355 3.77316L10.0709 4.50993L10.0693 3.0357Z"
fill="#621B1C"
/>
<path
d="M8.00464 3.85234L10.0693 3.03564L10.0709 4.50988L9.86844 4.58906L8.00464 3.85234Z"
fill="#9A2928"
/>
</g>
<defs>
<clipPath id="clip0_2061_4282">
<rect
width="13.7143"
height="13.7143"
fill="white"
transform="translate(0.142822 0.143066)"
/>
</clipPath>
</defs>
</svg>
);
}

View File

@@ -8,7 +8,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { X } from '@signozhq/icons';
import { Widgets } from 'types/api/dashboard/getAll';
import { Widgets } from 'types/api/widgets/widget';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';

View File

@@ -5,16 +5,16 @@ import { useHistory, useLocation } from 'react-router-dom';
import { Color } from '@signozhq/design-tokens';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ViewMenuAction } from 'container/GridCardLayout/config';
import GridCard from 'container/GridCardLayout/GridCard';
import { Card } from 'container/GridCardLayout/styles';
import { ViewMenuAction } from 'container/WidgetCard/config';
import GridCard from 'container/WidgetCard/Card';
import { Card } from 'container/WidgetCard/styles';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import { isEmpty } from 'lodash-es';
import { getStartAndEndTimesInMilliseconds } from 'pages/MessagingQueues/MessagingQueuesUtils';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { Widgets } from 'types/api/dashboard/getAll';
import { Widgets } from 'types/api/widgets/widget';
import { GlobalReducer } from 'types/reducer/globalTime';
import { CaptureDataProps } from '../CeleryTaskDetail/CeleryTaskDetail';

View File

@@ -5,15 +5,15 @@ import { useHistory, useLocation } from 'react-router-dom';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ViewMenuAction } from 'container/GridCardLayout/config';
import GridCard from 'container/GridCardLayout/GridCard';
import { Card } from 'container/GridCardLayout/styles';
import { ViewMenuAction } from 'container/WidgetCard/config';
import GridCard from 'container/WidgetCard/Card';
import { Card } from 'container/WidgetCard/styles';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { getStartAndEndTimesInMilliseconds } from 'pages/MessagingQueues/MessagingQueuesUtils';
import { UpdateTimeInterval } from 'store/actions';
import { Widgets } from 'types/api/dashboard/getAll';
import { Widgets } from 'types/api/widgets/widget';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { CaptureDataProps } from '../CeleryTaskDetail/CeleryTaskDetail';

View File

@@ -4,7 +4,7 @@ import { useSelector } from 'react-redux';
import { Card } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { CardContainer } from 'container/GridCardLayout/styles';
import { CardContainer } from 'container/WidgetCard/styles';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import { AppState } from 'store/reducers';

View File

@@ -1,7 +1,7 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { getWidgetQuery } from 'pages/MessagingQueues/MQDetails/MetricPage/MetricPageUtil';
import { Widgets } from 'types/api/dashboard/getAll';
import { Widgets } from 'types/api/widgets/widget';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 as uuidv4 } from 'uuid';

View File

@@ -6,9 +6,9 @@ import { Col, Row } from 'antd';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ViewMenuAction } from 'container/GridCardLayout/config';
import GridCard from 'container/GridCardLayout/GridCard';
import { Card } from 'container/GridCardLayout/styles';
import { ViewMenuAction } from 'container/WidgetCard/config';
import GridCard from 'container/WidgetCard/Card';
import { Card } from 'container/WidgetCard/styles';
import { Button } from 'container/MetricsApplication/Tabs/styles';
import { useGraphClickHandler } from 'container/MetricsApplication/Tabs/util';
import { useIsDarkMode } from 'hooks/useDarkMode';

View File

@@ -8,7 +8,7 @@ import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { getQueryPayloadFromWidgetsData } from 'pages/Celery/CeleryOverview/CeleryOverviewUtils';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { Widgets } from 'types/api/widgets/widget';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { GlobalReducer } from 'types/reducer/globalTime';

View File

@@ -1,7 +1,7 @@
import { QueryParams } from 'constants/query';
import { History, Location } from 'history';
import getRenderer from 'lib/uPlotLib/utils/getRenderer';
import { Widgets } from 'types/api/dashboard/getAll';
import { Widgets } from 'types/api/widgets/widget';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuidv4 } from 'uuid';

View File

@@ -4,10 +4,9 @@ import { useSelector } from 'react-redux';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import useUpdatedQuery from 'container/WidgetCard/hooks/useResolveQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { AppState } from 'store/reducers';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
@@ -80,7 +79,6 @@ export function useNavigateToExplorer(): (
);
const { getUpdatedQuery } = useUpdatedQuery();
const { dashboardData } = useDashboardStore();
const { notifications } = useNotifications();
return useCallback(
@@ -112,7 +110,6 @@ export function useNavigateToExplorer(): (
panelTypes: PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME',
},
dashboardData,
})
.then((query) => {
preparedQuery = query;
@@ -136,13 +133,6 @@ export function useNavigateToExplorer(): (
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
},
[
prepareQuery,
minTime,
maxTime,
getUpdatedQuery,
dashboardData,
notifications,
],
[prepareQuery, minTime, maxTime, getUpdatedQuery, notifications],
);
}

View File

@@ -28,7 +28,7 @@ import {
} from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { generateGridTitle } from 'container/GridPanelSwitch/utils';
import { generateGridTitle } from 'utils/generateGridTitle';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import isEqual from 'lodash-es/isEqual';

View File

@@ -1,585 +0,0 @@
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import configureStore from 'redux-mock-store';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import VariableItem from '../../../container/DashboardContainer/DashboardVariablesSelection/VariableItem';
// Mock the dashboard variables query
jest.mock('api/dashboard/variables/dashboardVariablesQuery', () => ({
__esModule: true,
default: jest.fn(() =>
Promise.resolve({
payload: {
variableValues: ['option1', 'option2', 'option3', 'option4'],
},
}),
),
}));
// Mock scrollIntoView which isn't available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Constants
const TEST_VARIABLE_NAME = 'test_variable';
const TEST_VARIABLE_ID = 'test-var-id';
// Create a mock store
const mockStore = configureStore([])({
globalTime: {
minTime: Date.now() - 3600000, // 1 hour ago
maxTime: Date.now(),
},
});
// Test data
const createMockVariable = (
overrides: Partial<IDashboardVariable> = {},
): IDashboardVariable => ({
id: TEST_VARIABLE_ID,
name: TEST_VARIABLE_NAME,
description: 'Test variable description',
type: 'QUERY',
queryValue: 'SELECT DISTINCT value FROM table',
customValue: '',
sort: 'ASC',
multiSelect: false,
showALLOption: true,
selectedValue: [],
allSelected: false,
...overrides,
});
function TestWrapper({ children }: { children: React.ReactNode }): JSX.Element {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return (
<Provider store={mockStore}>
<QueryClientProvider client={queryClient}>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 300, itemHeight: 40 }}
>
{children}
</VirtuosoMockContext.Provider>
</QueryClientProvider>
</Provider>
);
}
describe('VariableItem Integration Tests', () => {
let user: ReturnType<typeof userEvent.setup>;
let mockOnValueUpdate: jest.Mock;
beforeEach(() => {
user = userEvent.setup();
mockOnValueUpdate = jest.fn();
jest.clearAllMocks();
});
// ===== 1. INTEGRATION WITH CUSTOMSELECT =====
describe('CustomSelect Integration (VI)', () => {
it('VI-01: Single select variable integration', async () => {
const variable = createMockVariable({
multiSelect: false,
type: 'CUSTOM',
customValue: 'option1,option2,option3',
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Should render with CustomSelect
const combobox = screen.getByRole('combobox');
expect(combobox).toBeInTheDocument();
await user.click(combobox);
await waitFor(() => {
expect(screen.getByText('option1')).toBeInTheDocument();
expect(screen.getByText('option2')).toBeInTheDocument();
expect(screen.getByText('option3')).toBeInTheDocument();
});
// Select an option
const option1 = screen.getByText('option1');
await user.click(option1);
expect(mockOnValueUpdate).toHaveBeenCalledWith(
TEST_VARIABLE_NAME,
TEST_VARIABLE_ID,
'option1',
false,
);
});
});
// ===== 2. INTEGRATION WITH CUSTOMMULTISELECT =====
describe('CustomMultiSelect Integration (VI)', () => {
it('VI-02: Multi select variable integration', async () => {
const variable = createMockVariable({
multiSelect: true,
type: 'CUSTOM',
customValue: 'option1,option2,option3,option4',
showALLOption: true,
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Should render with CustomMultiSelect
const combobox = screen.getByRole('combobox');
expect(combobox).toBeInTheDocument();
await user.click(combobox);
await waitFor(() => {
// Should show ALL option
expect(screen.getByText('ALL')).toBeInTheDocument();
});
// Wait for Virtuoso to render the custom options
await waitFor(
() => {
expect(screen.getByText('option1')).toBeInTheDocument();
expect(screen.getByText('option2')).toBeInTheDocument();
expect(screen.getByText('option3')).toBeInTheDocument();
expect(screen.getByText('option4')).toBeInTheDocument();
},
{ timeout: 5000 },
);
});
});
// ===== 3. TEXTBOX VARIABLE TYPE =====
describe('Textbox Variable Integration', () => {
it('VI-03: Textbox variable handling', async () => {
const variable = createMockVariable({
type: 'TEXTBOX',
selectedValue: 'initial-value',
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Should render a regular input
const textInput = screen.getByDisplayValue('initial-value');
expect(textInput).toBeInTheDocument();
expect(textInput.tagName).toBe('INPUT');
// Clear and type new value
await user.clear(textInput);
await user.type(textInput, 'new-text-value');
// Blur the input to trigger the value update
await user.tab();
// Should call onValueUpdate after blur
await waitFor(
() => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(
TEST_VARIABLE_NAME,
TEST_VARIABLE_ID,
'new-text-value',
false,
);
},
{ timeout: 1000 },
);
});
});
// ===== 4. VALUE PERSISTENCE AND STATE MANAGEMENT =====
describe('Value Persistence and State Management', () => {
it('VI-04: All selected state handling', () => {
const variable = createMockVariable({
multiSelect: true,
type: 'CUSTOM',
customValue: 'service1,service2,service3',
selectedValue: ['service1', 'service2', 'service3'],
allSelected: true,
showALLOption: true,
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Should show "ALL" instead of individual values
expect(screen.getByText('ALL')).toBeInTheDocument();
});
it('VI-05: Dropdown behavior with temporary selections', async () => {
const variable = createMockVariable({
multiSelect: true,
type: 'CUSTOM',
customValue: 'item1,item2,item3',
selectedValue: ['item1'],
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
const combobox = screen.getByRole('combobox');
await user.click(combobox);
// Wait for dropdown to open
await waitFor(() => {
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toBeInTheDocument();
});
// Verify the component renders without crashing
expect(combobox).toBeInTheDocument();
});
});
// ===== 6. ACCESSIBILITY AND USER EXPERIENCE =====
describe('Accessibility and User Experience', () => {
it('VI-06: Variable description tooltip', async () => {
const variable = createMockVariable({
description: 'This variable controls the service selection',
type: 'CUSTOM',
customValue: 'service1,service2',
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Should show info icon
const infoIcon = document.querySelector('.info-icon');
expect(infoIcon).toBeInTheDocument();
// Hover to show tooltip
if (infoIcon) {
await user.hover(infoIcon);
}
await waitFor(() => {
expect(
screen.getByText('This variable controls the service selection'),
).toBeInTheDocument();
});
});
it('VI-07: Variable name display', () => {
const variable = createMockVariable({
name: 'service_name',
type: 'CUSTOM',
customValue: 'service1,service2',
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Should show variable name with $ prefix
expect(screen.getByText('$service_name')).toBeInTheDocument();
});
it('VI-08: Max tag count behavior', async () => {
const variable = createMockVariable({
multiSelect: true,
type: 'CUSTOM',
customValue: 'tag1,tag2,tag3,tag4,tag5,tag6,tag7',
selectedValue: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'],
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Wait for component to render
await waitFor(() => {
const combobox = screen.getByRole('combobox');
expect(combobox).toBeInTheDocument();
});
// Should show limited number of tags with "+ X more"
const tags = document.querySelectorAll('.ant-select-selection-item');
// The component should render without crashing
expect(tags.length).toBeGreaterThanOrEqual(0);
});
});
// ===== 8. SEARCH INTERACTION TESTS =====
describe('Search Interaction Tests', () => {
it('VI-14: Search persistence across dropdown open/close', async () => {
const variable = createMockVariable({
type: 'CUSTOM',
customValue: 'option1,option2,option3',
multiSelect: true,
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
const combobox = screen.getByRole('combobox');
await user.click(combobox);
await waitFor(() => {
expect(screen.getByText('ALL')).toBeInTheDocument();
});
const searchInput = document.querySelector(
'.ant-select-selection-search-input',
);
expect(searchInput).toBeInTheDocument();
if (searchInput) {
await user.type(searchInput, 'search-text');
}
// Verify search text is in input
await waitFor(() => {
expect(searchInput).toHaveValue('search-text');
});
// Press Escape to close dropdown
await user.keyboard('{Escape}');
// Dropdown should close and search text should be cleared
await waitFor(() => {
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toHaveClass('ant-select-dropdown-hidden');
expect(searchInput).toHaveValue('');
});
});
});
// ===== 9. ADVANCED KEYBOARD NAVIGATION =====
describe('Advanced Keyboard Navigation (VI)', () => {
it('VI-15: Shift + Arrow + Del chip deletion in multiselect', async () => {
const variable = createMockVariable({
type: 'CUSTOM',
customValue: 'option1,option2,option3',
multiSelect: true,
selectedValue: ['option1', 'option2', 'option3'],
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
const combobox = screen.getByRole('combobox');
await user.click(combobox);
// Navigate to chips using arrow keys
await user.keyboard('{ArrowLeft}');
// Use Shift + Arrow to navigate between chips
await user.keyboard('{Shift>}{ArrowLeft}{/Shift}');
// Use Del to delete the active chip
await user.keyboard('{Delete}');
// Note: The component may not immediately call onValueUpdate
// This test verifies the chip deletion behavior
await waitFor(() => {
// Check if a chip was removed from the selection
const selectionItems = document.querySelectorAll(
'.ant-select-selection-item',
);
expect(selectionItems.length).toBeLessThan(3);
});
});
});
// ===== 11. ADVANCED UI STATES =====
describe('Advanced UI States (VI)', () => {
it('VI-19: No data with previous value selected in variable', async () => {
const variable = createMockVariable({
type: 'CUSTOM',
customValue: '',
multiSelect: true,
selectedValue: ['previous-value'],
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Wait for component to initialize
await waitFor(() => {
const combobox = screen.getByRole('combobox');
expect(combobox).toBeInTheDocument();
});
const combobox = screen.getByRole('combobox');
await user.click(combobox);
// Should show no data message (the component may not show this exact text)
await waitFor(() => {
// Check if dropdown is empty or shows no data indication
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toBeInTheDocument();
});
// Verify the component renders without crashing
expect(combobox).toBeInTheDocument();
});
it('VI-20: Always editable accessibility in variable', async () => {
const variable = createMockVariable({
type: 'CUSTOM',
customValue: 'option1,option2',
multiSelect: true,
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
const combobox = screen.getByRole('combobox');
// Should be editable
expect(combobox).not.toBeDisabled();
await user.click(combobox);
expect(combobox).toHaveFocus();
// Should still be interactive
expect(combobox).not.toBeDisabled();
});
});
// ===== 13. DROPDOWN PERSISTENCE =====
describe('Dropdown Persistence (VI)', () => {
it('VI-24: Dropdown stays open for non-save actions in variable', async () => {
const variable = createMockVariable({
type: 'CUSTOM',
customValue: 'option1,option2,option3',
multiSelect: true,
});
render(
<TestWrapper>
<VariableItem
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
/>
</TestWrapper>,
);
// Wait for component to initialize
await waitFor(() => {
const combobox = screen.getByRole('combobox');
expect(combobox).toBeInTheDocument();
});
const combobox = screen.getByRole('combobox');
await user.click(combobox);
await waitFor(() => {
expect(screen.getByText('ALL')).toBeInTheDocument();
});
// Navigate with arrow keys (non-save action)
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
// Dropdown should still be open
await waitFor(() => {
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toBeInTheDocument();
});
// Verify the component renders without crashing
expect(combobox).toBeInTheDocument();
// Only ESC should close the dropdown
await user.keyboard('{Escape}');
await waitFor(() => {
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toHaveClass('ant-select-dropdown-hidden');
});
});
});
});

View File

@@ -1,11 +1,26 @@
import { uniqueOptions } from 'container/DashboardContainer/DashboardVariablesSelection/util';
import { OptionData } from './types';
export const SPACEKEY = ' ';
export const ALL_SELECTED_VALUE = '__ALL__'; // Constant for the special value
/** De-duplicate options by `value`, keeping the first occurrence. */
export const uniqueOptions = (options: OptionData[]): OptionData[] => {
const deduped: OptionData[] = [];
const seenValues = new Set<string>();
options.forEach((option) => {
const value = option.value || '';
if (seenValues.has(value)) {
return;
}
seenValues.add(value);
deduped.push(option);
});
return deduped;
};
export const prioritizeOrAddOptionForSingleSelect = (
options: OptionData[],
value: string,

View File

@@ -2,7 +2,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { Spline } from '@signozhq/icons';
import { EQueryType } from 'types/common/dashboard';
import QueryTypeTag from '../QueryTypeTag';
import QueryTypeTag from 'components/QueryTypeTag/QueryTypeTag';
interface IPlotTagProps {
queryType: EQueryType;

View File

@@ -17,12 +17,6 @@ jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { dashboardData: undefined } => ({
dashboardData: undefined,
}),
}));
// Shrink the suggestion-fetch debounce (300ms in prod) so these integration
// tests aren't paced by it; coalescing semantics stay intact.
jest.mock('../QuerySearch/constants', () => ({

View File

@@ -21,12 +21,6 @@ jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { dashboardData: undefined } => ({
dashboardData: undefined,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
const handleRunQuery = jest.fn();
return {
@@ -152,15 +146,16 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
/>,
);
// Wait for debounced API call (300ms debounce + some buffer)
await waitFor(() => expect(mockedGetKeysOnMount).toHaveBeenCalled(), {
timeout: 2000,
});
const lastArgs = mockedGetKeysOnMount.mock.calls[
mockedGetKeysOnMount.mock.calls.length - 1
]?.[0] as { signal: unknown; searchText: string };
expect(lastArgs).toMatchObject({ signal: DataSource.LOGS, searchText: '' });
// Wait for the mount fetch specifically. A debounced fetch from an earlier test
// can still land after mockClear(), so waiting on "any call" would let this
// assert against that one instead and make the result order-dependent.
await waitFor(
() =>
expect(mockedGetKeysOnMount).toHaveBeenCalledWith(
expect.objectContaining({ signal: DataSource.LOGS, searchText: '' }),
),
{ timeout: 2000 },
);
});
it('calls provided onRun on Mod-Enter', async () => {

View File

@@ -31,12 +31,6 @@ jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { dashboardData: undefined } => ({
dashboardData: undefined,
}),
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn().mockResolvedValue({
data: { data: { keys: {} } },

View File

@@ -1,5 +1,5 @@
import { Typography } from '@signozhq/ui/typography';
import { timeItems } from 'container/NewWidget/RightContainer/timeItems';
import { timeItems } from 'constants/timePreference';
export const menuItems = timeItems.map((item) => ({
key: item.enum,

View File

@@ -6,7 +6,7 @@ import { Typography } from '@signozhq/ui/typography';
import TimeItems, {
timePreferance,
timePreferenceType,
} from 'container/NewWidget/RightContainer/timeItems';
} from 'constants/timePreference';
import { menuItems } from './config';

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { CircleAlert } from '@signozhq/icons';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { ThresholdProps } from 'types/api/widgets/threshold';
import { getBackgroundColorAndThresholdCheck } from './utils';

View File

@@ -1,5 +1,5 @@
import { evaluateThresholdWithConvertedValue } from 'container/GridTableComponent/utils';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { evaluateThresholdWithConvertedValue } from 'container/WidgetCard/TablePanel/utils';
import { ThresholdProps } from 'types/api/widgets/threshold';
function doesValueSatisfyThreshold(
rawValue: number,

View File

@@ -1,6 +1,6 @@
import Uplot from 'components/Uplot';
import GridTableComponent from 'container/GridTableComponent';
import GridValueComponent from 'container/GridValueComponent';
import GridTableComponent from 'container/WidgetCard/TablePanel';
import GridValueComponent from 'container/WidgetCard/ValuePanel';
import LogsPanelComponent from 'container/LogsPanelTable/LogsPanelComponent';
import TracesTableComponent from 'container/TracesTableComponent/TracesTableComponent';
import { DataSource } from 'types/common/queryBuilder';

View File

@@ -16,7 +16,6 @@ const ROUTES = {
APPLICATION: '/services',
ALL_DASHBOARD: '/dashboard',
DASHBOARD: '/dashboard/:dashboardId',
DASHBOARD_WIDGET: '/dashboard/:dashboardId/:widgetId',
DASHBOARD_PANEL_EDITOR: '/dashboard/:dashboardId/panel/:panelId',
EDIT_ALERTS: '/alerts/edit',
LIST_ALL_ALERT: '/alerts',

View File

@@ -6,7 +6,7 @@ import {
getAllEndpointsWidgetData,
getGroupByFiltersFromGroupByValues,
} from 'container/ApiMonitoring/utils';
import GridCard from 'container/GridCardLayout/GridCard';
import GridCard from 'container/WidgetCard/Card';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { isEqual } from 'lodash-es';

View File

@@ -1,7 +1,7 @@
import { Card } from 'antd';
import { ENTITY_VERSION_V5 } from 'constants/app';
import GridCard from 'container/GridCardLayout/GridCard';
import { Widgets } from 'types/api/dashboard/getAll';
import GridCard from 'container/WidgetCard/Card';
import { Widgets } from 'types/api/widgets/widget';
function MetricOverTimeGraph({
widget,

View File

@@ -11,10 +11,10 @@ import {
getStatusCodeBarChartWidgetData,
statusCodeWidgetInfo,
} from 'container/ApiMonitoring/utils';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import { handleGraphClick } from 'container/GridCardLayout/GridCard/utils';
import { useGraphClickToShowButton } from 'container/GridCardLayout/useGraphClickToShowButton';
import useNavigateToExplorerPages from 'container/GridCardLayout/useNavigateToExplorerPages';
import BarChart from 'lib/visualization/charts/BarChart/BarChart';
import { handleGraphClick } from 'container/WidgetCard/Card/utils';
import { useGraphClickToShowButton } from 'container/WidgetCard/hooks/useGraphClickToShowButton';
import useNavigateToExplorerPages from 'container/WidgetCard/hooks/useNavigateToExplorerPages';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -23,7 +23,7 @@ import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { Widgets } from 'types/api/widgets/widget';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import ErrorState from './ErrorState';

View File

@@ -1,7 +1,7 @@
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { buildBaseConfig } from 'lib/visualization/panels/utils/baseConfigBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';

View File

@@ -17,7 +17,7 @@ jest.mock('container/ApiMonitoring/utils', () => ({
getGroupByFiltersFromGroupByValues: jest.fn(),
}));
jest.mock('container/GridCardLayout/GridCard', () => ({
jest.mock('container/WidgetCard/Card', () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ customOnRowClick }) => (
<div data-testid="grid-card-mock">

View File

@@ -21,15 +21,12 @@ interface MockQueryResult {
}
// Mocks
jest.mock(
'container/DashboardContainer/visualization/charts/BarChart/BarChart',
() => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(() => <div data-testid="bar-chart-mock" />),
}),
);
jest.mock('lib/visualization/charts/BarChart/BarChart', () => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(() => <div data-testid="bar-chart-mock" />),
}));
jest.mock('components/CeleryTask/useGetGraphCustomSeries', () => ({
useGetGraphCustomSeries: (): { getCustomSeries: jest.Mock } => ({
@@ -43,7 +40,7 @@ jest.mock('components/CeleryTask/useNavigateToExplorer', () => ({
}),
}));
jest.mock('container/GridCardLayout/useGraphClickToShowButton', () => ({
jest.mock('container/WidgetCard/hooks/useGraphClickToShowButton', () => ({
useGraphClickToShowButton: (): {
componentClick: boolean;
htmlRef: HTMLElement | null;
@@ -53,7 +50,7 @@ jest.mock('container/GridCardLayout/useGraphClickToShowButton', () => ({
}),
}));
jest.mock('container/GridCardLayout/useNavigateToExplorerPages', () => ({
jest.mock('container/WidgetCard/hooks/useNavigateToExplorerPages', () => ({
__esModule: true,
default: (): { navigateToExplorerPages: jest.Mock } => ({
navigateToExplorerPages: jest.fn(),

View File

@@ -10,7 +10,7 @@ import {
} from 'components/QuickFilters/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GraphClickMetaData } from 'container/GridCardLayout/useNavigateToExplorerPages';
import { GraphClickMetaData } from 'container/WidgetCard/hooks/useNavigateToExplorerPages';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { convertNanoToMilliseconds } from 'container/MetricsExplorer/Summary/utils';
import dayjs from 'dayjs';
@@ -18,7 +18,7 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { cloneDeep } from 'lodash-es';
import { ArrowUpDown, ChevronDown, ChevronRight, Info } from '@signozhq/icons';
import { getWidgetQuery } from 'pages/MessagingQueues/MQDetails/MetricPage/MetricPageUtil';
import { Widgets } from 'types/api/dashboard/getAll';
import { Widgets } from 'types/api/widgets/widget';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import {
BaseAutocompleteData,

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo, useRef } from 'react';
import { Card, Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import BarChart from 'lib/visualization/charts/BarChart/BarChart';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';

View File

@@ -1,7 +1,7 @@
import { Color } from '@signozhq/design-tokens';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { buildBaseConfig } from 'lib/visualization/panels/utils/baseConfigBuilder';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';

View File

@@ -98,7 +98,7 @@ jest.mock('api/channels/getAll', () => ({
}));
// Mock alert format categories
jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({
jest.mock('constants/formats/alertFormatCategories', () => ({
getCategoryByOptionId: jest.fn(() => ({ name: 'bytes' })),
getCategorySelectOptionByName: jest.fn(() => [
{ label: 'Bytes', value: 'bytes' },

View File

@@ -8,7 +8,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { QueryParams } from 'constants/query';
import { useCreateAlertState } from 'container/CreateAlertV2/context';
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag';
import PlotTag from 'components/PlotTag/PlotTag';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { AppState } from 'store/reducers';

View File

@@ -44,7 +44,7 @@ jest.mock(
},
);
jest.mock(
'container/NewWidget/LeftContainer/WidgetGraph/PlotTag',
'components/PlotTag/PlotTag',
() =>
function MockPlotTag(props: any): JSX.Element {
return (

View File

@@ -1,440 +0,0 @@
.dashboard-description-container {
box-shadow: none;
border: none;
background: unset;
box-shadow: none;
color: var(--l2-foreground);
.ant-card-body {
padding: 0px;
}
.dashboard-details {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 16px 16px 0px 16px;
align-items: flex-start;
.left-section {
display: flex;
align-items: center;
gap: 8px;
width: 45%;
.dashboard-img {
height: 16px;
width: 16px;
}
.dashboard-title {
color: var(--l1-foreground);
font-family: Inter;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 150% */
letter-spacing: -0.08px;
max-width: 80%;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.public-dashboard-icon {
margin-right: 4px;
}
}
.right-section {
display: flex;
width: 55%;
justify-content: flex-end;
flex-wrap: wrap;
align-items: center;
gap: 14px;
.icons {
display: flex;
align-items: center;
width: 32px;
height: 34px;
padding: 6px;
justify-content: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.icons:hover {
background-color: unset;
}
.configure-button {
display: flex;
align-items: center;
width: 93px;
height: 34px;
padding: 6px;
justify-content: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.add-panel-btn {
display: flex;
width: 119px;
height: 34px;
padding: 5.937px 11.875px;
justify-content: center;
align-items: center;
color: var(--primary-foreground);
background: var(--primary-background);
font-family: Inter;
font-size: 11.875px;
font-style: normal;
font-weight: 500;
line-height: 17.812px; /* 150% */
}
}
}
.dashboard-tags {
display: flex;
gap: 6px;
padding: 16px 16px 0px 16px;
flex-wrap: wrap;
.tag {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
text-align: center;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
margin-inline-end: 0px;
}
}
.dashboard-description-section {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
padding: 20px 16px 0px 16px;
/* Preserve author-entered line breaks in the description. */
white-space: pre-wrap;
overflow-wrap: anywhere;
a {
color: var(--accent-primary);
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
}
.dashboard-variables {
padding: 16px 16px 18px 16px;
}
}
.dashboard-description {
display: -webkit-box;
-webkit-line-clamp: 2; /* Show up to 2 lines */
-webkit-box-orient: vertical;
text-overflow: ellipsis;
overflow: hidden;
}
.dashboard-settings {
width: 191px;
height: 302px;
flex-shrink: 0;
.ant-popover-inner {
padding: 0px;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
) !important;
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
.menu-content {
display: flex;
flex-direction: column;
section {
display: flex;
flex-direction: column;
align-items: start;
.ant-btn {
display: flex;
width: 100%;
height: unset;
padding: 8px;
align-items: center;
gap: 12px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
border-top: none;
.ant-btn-icon {
margin-inline-end: 0px;
}
}
}
.section-1,
.section-2 {
border-bottom: 1px solid var(--l1-border);
}
.delete-dashboard .ant-btn {
color: var(--bg-cherry-400) !important;
}
}
}
.rename-dashboard {
.ant-modal-content {
width: 384px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
padding: 0px;
.ant-modal-header {
height: 52px;
padding: 16px;
background: var(--l2-background);
border-bottom: 1px solid var(--l1-border);
margin-bottom: 0px;
.ant-modal-title {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
width: 349px;
height: 20px;
}
}
.ant-modal-body {
padding: 16px;
.dashboard-content {
display: flex;
flex-direction: column;
gap: 8px;
.name-text {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
}
.dashboard-name-input {
display: flex;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
align-self: stretch;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
}
.ant-modal-footer {
padding: 16px;
margin-top: 0px;
.dashboard-rename {
display: flex;
flex-direction: row-reverse;
gap: 12px;
.cancel-btn {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 2px;
background: var(--l1-border);
.ant-btn-icon {
margin-inline-end: 0px;
}
}
.rename-btn {
display: flex;
align-items: center;
display: flex;
width: 169px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 2px;
background: var(--primary-background);
.ant-btn-icon {
margin-inline-end: 0px;
}
}
}
}
}
}
.section-naming {
.ant-modal-content {
width: 384px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
padding: 0px;
.ant-modal-header {
height: 52px;
padding: 16px;
background: var(--l2-background);
border-bottom: 1px solid var(--l1-border);
margin-bottom: 0px;
.ant-modal-title {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
width: 349px;
height: 20px;
}
}
.ant-modal-body {
padding: 16px;
.section-naming-content {
display: flex;
flex-direction: column;
gap: 8px;
.name-text {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
}
.section-name-input {
display: flex;
width: 320px;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
align-self: stretch;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
}
.ant-modal-footer {
padding: 16px;
margin-top: 0px;
.dashboard-rename {
display: flex;
flex-direction: row-reverse;
gap: 12px;
.cancel-btn {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 2px;
background: var(--l1-border);
.ant-btn-icon {
margin-inline-end: 0px;
}
}
.rename-btn {
display: flex;
align-items: center;
display: flex;
width: 140px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 2px;
background: var(--primary-background);
.ant-btn-icon {
margin-inline-end: 0px;
}
}
}
}
}
}

View File

@@ -1,43 +0,0 @@
.settings-container-root {
.ant-drawer-wrapper-body {
border-left: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
.ant-drawer-header {
height: 48px;
border-bottom: 1px solid var(--l1-border);
padding: 14px 14px 14px 11px;
.ant-drawer-header-title {
gap: 16px;
.ant-drawer-title {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding-left: 16px;
border-left: 1px solid var(--l1-border);
}
.ant-drawer-close {
height: 16px;
width: 16px;
margin-inline-end: 0px !important;
}
}
}
.ant-drawer-body {
padding: 16px;
&::-webkit-scrollbar {
width: 0.1rem;
}
}
}
}

View File

@@ -1,34 +0,0 @@
import { memo, PropsWithChildren, ReactElement } from 'react';
import { Drawer } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import './SettingsDrawer.styles.scss';
type SettingsDrawerProps = PropsWithChildren<{
drawerTitle: string;
isOpen: boolean;
onClose: () => void;
}>;
function SettingsDrawer({
children,
drawerTitle,
isOpen,
onClose,
}: SettingsDrawerProps): JSX.Element {
return (
<Drawer
title={drawerTitle}
placement="right"
width="50%"
onClose={onClose}
open={isOpen}
rootClassName="settings-container-root"
>
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
<OverlayScrollbar>{children as ReactElement}</OverlayScrollbar>
</Drawer>
);
}
export default memo(SettingsDrawer);

View File

@@ -1,235 +0,0 @@
import { ReactNode } from 'react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import {
getDashboardById,
getNonIntegrationDashboardById,
} from 'mocks-server/__mockdata__/dashboards';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
resetDashboard,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { Dashboard } from 'types/api/dashboard/getAll';
import DashboardDescription from '..';
function DashboardBootstrapWrapper({
dashboardId,
children,
}: {
dashboardId: string;
children: ReactNode;
}): JSX.Element {
useDashboardBootstrap(dashboardId);
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{children}</>;
}
interface MockSafeNavigateReturn {
safeNavigate: jest.MockedFunction<(url: string) => void>;
}
const DASHBOARD_TEST_ID = 'dashboard-title';
const DASHBOARD_TITLE_TEXT = 'thor';
const DASHBOARD_PATH = '/dashboard/4';
const mockSafeNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
}));
jest.mock(
'container/TopNav/DateTimeSelectionV2/index.tsx',
() =>
function MockDateTimeSelection(): JSX.Element {
return <div>MockDateTimeSelection</div>;
},
);
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): MockSafeNavigateReturn => ({
safeNavigate: mockSafeNavigate,
}),
}));
describe('Dashboard landing page actions header tests', () => {
beforeEach(() => {
mockSafeNavigate.mockClear();
sessionStorage.clear();
resetDashboard();
});
it('unlock dashboard should be disabled for integrations created dashboards', async () => {
const mockLocation = {
pathname: `${process.env.FRONTEND_API_ENDPOINT}${DASHBOARD_PATH}`,
search: '',
};
(useLocation as jest.Mock).mockReturnValue(mockLocation);
const { getByTestId } = render(
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
<DashboardBootstrapWrapper dashboardId="4">
<DashboardDescription
handle={{
active: false,
enter: (): Promise<void> => Promise.resolve(),
exit: (): Promise<void> => Promise.resolve(),
node: { current: null },
}}
/>
</DashboardBootstrapWrapper>
</MemoryRouter>,
);
await waitFor(() =>
expect(getByTestId(DASHBOARD_TEST_ID)).toHaveTextContent(
DASHBOARD_TITLE_TEXT,
),
);
const dashboardSettingsTrigger = getByTestId('options');
await fireEvent.click(dashboardSettingsTrigger);
const lockUnlockButton = screen.getByTestId('lock-unlock-dashboard');
await waitFor(() => expect(lockUnlockButton).toBeDisabled());
});
it('unlock dashboard should not be disabled for non integration created dashboards', async () => {
const mockLocation = {
pathname: `${process.env.FRONTEND_API_ENDPOINT}${DASHBOARD_PATH}`,
search: '',
};
(useLocation as jest.Mock).mockReturnValue(mockLocation);
server.use(
rest.get('http://localhost/api/v1/dashboards/4', (_, res, ctx) =>
res(ctx.status(200), ctx.json(getNonIntegrationDashboardById)),
),
);
const { getByTestId } = render(
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
<DashboardBootstrapWrapper dashboardId="4">
<DashboardDescription
handle={{
active: false,
enter: (): Promise<void> => Promise.resolve(),
exit: (): Promise<void> => Promise.resolve(),
node: { current: null },
}}
/>
</DashboardBootstrapWrapper>
</MemoryRouter>,
);
await waitFor(() =>
expect(getByTestId(DASHBOARD_TEST_ID)).toHaveTextContent(
DASHBOARD_TITLE_TEXT,
),
);
const dashboardSettingsTrigger = getByTestId('options');
await fireEvent.click(dashboardSettingsTrigger);
const lockUnlockButton = screen.getByTestId('lock-unlock-dashboard');
await waitFor(() => expect(lockUnlockButton).not.toBeDisabled());
});
it('should navigate to base dashboard list URL when no saved params exist', async () => {
const user = userEvent.setup();
const mockLocation = {
pathname: DASHBOARD_PATH,
search: '',
};
(useLocation as jest.Mock).mockReturnValue(mockLocation);
const { getByText } = render(
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
<DashboardBootstrapWrapper dashboardId="4">
<DashboardDescription
handle={{
active: false,
enter: (): Promise<void> => Promise.resolve(),
exit: (): Promise<void> => Promise.resolve(),
node: { current: null },
}}
/>
</DashboardBootstrapWrapper>
</MemoryRouter>,
);
await waitFor(() =>
expect(screen.getByTestId(DASHBOARD_TEST_ID)).toHaveTextContent(
DASHBOARD_TITLE_TEXT,
),
);
const dashboardButton = getByText('Dashboard /');
await user.click(dashboardButton);
expect(mockSafeNavigate).toHaveBeenCalledWith('/dashboard');
});
it('should navigate to dashboard list with saved query params when present', async () => {
const user = userEvent.setup();
const savedParams = 'columnKey=createdAt&order=ascend&page=2&search=foo';
sessionStorage.setItem('dashboardsListQueryParams', savedParams);
const mockLocation = {
pathname: DASHBOARD_PATH,
search: '',
};
(useLocation as jest.Mock).mockReturnValue(mockLocation);
useDashboardStore.setState({
dashboardData: getDashboardById.data as unknown as Dashboard,
layouts: [],
panelMap: {},
setPanelMap: jest.fn(),
setLayouts: jest.fn(),
setDashboardData: jest.fn(),
columnWidths: {},
});
const { getByText } = render(
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
<DashboardDescription
handle={{
active: false,
enter: (): Promise<void> => Promise.resolve(),
exit: (): Promise<void> => Promise.resolve(),
node: { current: null },
}}
/>
</MemoryRouter>,
);
await waitFor(() =>
expect(screen.getByTestId(DASHBOARD_TEST_ID)).toHaveTextContent(
DASHBOARD_TITLE_TEXT,
),
);
const dashboardButton = getByText('Dashboard /');
await user.click(dashboardButton);
expect(mockSafeNavigate).toHaveBeenCalledWith({
pathname: '/dashboard',
search: `?${savedParams}`,
});
});
});

View File

@@ -1,621 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { Layout } from 'react-grid-layout';
import { useTranslation } from 'react-i18next';
import { useCopyToClipboard } from 'react-use';
import {
Check,
ClipboardCopy,
Ellipsis,
FileJson,
FolderKanban,
Fullscreen,
Globe,
LockKeyhole,
PenLine,
Plus,
X,
} from '@signozhq/icons';
import { Button, Card, Modal, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboardMeta';
import { useLockDashboard } from 'hooks/dashboard/useLockDashboard';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import useComponentPermission from 'hooks/useComponentPermission';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import {
selectIsDashboardLocked,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { sortLayout } from 'providers/Dashboard/util';
import { DashboardData } from 'types/api/dashboard/getAll';
import { Props } from 'types/api/dashboard/update';
import { ROLES, USER_ROLES } from 'types/roles';
import { linkifyText } from 'utils/linkifyText';
import { ComponentTypes } from 'utils/permission';
import { v4 as uuid } from 'uuid';
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import DashboardSettings from '../DashboardSettings';
import { Base64Icons } from '../DashboardSettings/General/utils';
import DashboardVariableSelection from '../DashboardVariablesSelection';
import PanelTypeSelectionModal from '../PanelTypeSelectionModal';
import SettingsDrawer from './SettingsDrawer';
import { VariablesSettingsTab } from './types';
import {
DEFAULT_ROW_NAME,
downloadObjectAsJson,
sanitizeDashboardData,
} from './utils';
import './Description.styles.scss';
interface DashboardDescriptionProps {
handle: FullScreenHandle;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { handle } = props;
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const {
dashboardData,
panelMap,
setPanelMap,
layouts,
setLayouts,
setDashboardData,
} = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const handleDashboardLockToggle = useLockDashboard();
const variablesSettingsTabHandle = useRef<VariablesSettingsTab>(null);
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const isPublicDashboardEnabled = isCloudUser || isEnterpriseSelfHostedUser;
const selectedData = dashboardData
? {
...dashboardData.data,
uuid: dashboardData.id,
}
: ({} as DashboardData);
const { dashboardVariables } = useDashboardVariables();
const {
title = '',
description,
tags,
image = Base64Icons[0],
} = selectedData || {};
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const [sectionName, setSectionName] = useState<string>(DEFAULT_ROW_NAME);
const updateDashboardMutation = useUpdateDashboard();
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const [isDashboardSettingsOpen, setIsDashbordSettingsOpen] =
useState<boolean>(false);
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
useState<boolean>(false);
const [isPanelNameModalOpen, setIsPanelNameModalOpen] =
useState<boolean>(false);
const [isPublicDashboard, setIsPublicDashboard] = useState<boolean>(false);
let isAuthor = false;
if (dashboardData && user && user.email) {
isAuthor = dashboardData?.createdBy === user?.email;
}
let permissions: ComponentTypes[] = ['add_panel'];
if (isDashboardLocked) {
permissions = ['add_panel_locked_dashboard'];
}
const { notifications } = useNotifications();
const userRole: ROLES | null =
dashboardData?.createdBy === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: user.role;
const [addPanelPermission] = useComponentPermission(permissions, userRole);
const onEmptyWidgetHandler = useCallback(() => {
setIsPanelTypeSelectionModalOpen(true);
logEvent('Dashboard Detail: Add new panel clicked', {
dashboardId: dashboardData?.id,
dashboardName: dashboardData?.data.title,
numberOfPanels: dashboardData?.data.widgets?.length,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setIsPanelTypeSelectionModalOpen]);
const handleLockDashboardToggle = (): void => {
setIsDashbordSettingsOpen(false);
handleDashboardLockToggle(!isDashboardLocked);
};
const onNameChangeHandler = (): void => {
if (!dashboardData) {
return;
}
const updatedDashboard: Props = {
id: dashboardData.id,
data: {
...dashboardData.data,
title: updatedTitle,
},
};
updateDashboardMutation.mutate(updatedDashboard, {
onSuccess: (updatedDashboard) => {
notifications.success({
message: 'Dashboard renamed successfully',
});
setIsRenameDashboardOpen(false);
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
}
},
onError: () => {
setIsRenameDashboardOpen(true);
},
});
};
const [state, setCopy] = useCopyToClipboard();
const { t } = useTranslation(['dashboard', 'common']);
// used to set the initial value for the updatedTitle
// the context value is sometimes not available during the initial render
// due to which the updatedTitle is set to some previous value
useEffect(() => {
if (dashboardData) {
setUpdatedTitle(dashboardData.data.title);
}
}, [dashboardData]);
useEffect(() => {
if (state.error) {
notifications.error({
message: t('something_went_wrong', {
ns: 'common',
}),
});
}
if (state.value) {
notifications.success({
message: t('success', {
ns: 'common',
}),
});
}
}, [state.error, state.value, t, notifications]);
function handleAddRow(): void {
if (!dashboardData) {
return;
}
const id = uuid();
const newRowWidgetMap: { widgets: Layout[]; collapsed: boolean } = {
widgets: [],
collapsed: false,
};
const currentRowIdx = 0;
for (let j = currentRowIdx; j < layouts.length; j++) {
if (!panelMap[layouts[j].i]) {
newRowWidgetMap.widgets.push(layouts[j]);
} else {
break;
}
}
const updatedDashboard: Props = {
id: dashboardData.id,
data: {
...dashboardData.data,
layout: [
{
i: id,
w: 12,
minW: 12,
minH: 1,
maxH: 1,
x: 0,
h: 1,
y: 0,
},
...layouts.filter((e) => e.i !== PANEL_TYPES.EMPTY_WIDGET),
],
panelMap: { ...panelMap, [id]: newRowWidgetMap },
widgets: [
...(dashboardData.data.widgets || []),
{
id,
title: sectionName,
description: '',
panelTypes: PANEL_GROUP_TYPES.ROW,
},
],
},
};
updateDashboardMutation.mutate(updatedDashboard, {
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
if (updatedDashboard.data.data.layout) {
setLayouts(sortLayout(updatedDashboard.data.data.layout));
}
setDashboardData(updatedDashboard.data);
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
}
setIsPanelNameModalOpen(false);
setSectionName(DEFAULT_ROW_NAME);
},
});
}
const {
data: publicDashboardResponse,
isLoading: isLoadingPublicDashboardData,
isFetching: isFetchingPublicDashboardData,
error: errorPublicDashboardData,
isError: isErrorPublicDashboardData,
} = useGetPublicDashboardMeta(
dashboardData?.id || '',
!!dashboardData?.id && isPublicDashboardEnabled,
);
useEffect(() => {
if (!isLoadingPublicDashboardData && !isFetchingPublicDashboardData) {
if (isErrorPublicDashboardData) {
const errorDetails = errorPublicDashboardData?.getErrorDetails();
if (errorDetails?.error?.code === 'public_dashboard_not_found') {
setIsPublicDashboard(false);
}
} else {
const publicDashboardData = publicDashboardResponse?.data;
if (publicDashboardData?.publicPath) {
setIsPublicDashboard(true);
}
}
}
}, [
isLoadingPublicDashboardData,
isFetchingPublicDashboardData,
isErrorPublicDashboardData,
errorPublicDashboardData,
publicDashboardResponse?.data,
]);
const onConfigureClick = useCallback((): void => {
setIsSettingsDrawerOpen(true);
}, []);
const onSettingsDrawerClose = useCallback((): void => {
setIsSettingsDrawerOpen(false);
// good use case for a state library like Jotai
if (variablesSettingsTabHandle.current) {
variablesSettingsTabHandle.current.resetState();
}
}, []);
return (
<Card className="dashboard-description-container">
<DashboardHeader />
<section className="dashboard-details">
<div className="left-section">
<img src={image} alt="dashboard-img" className="dashboard-img" />
<Tooltip title={title.length > 30 ? title : ''}>
<Typography.Text
className="dashboard-title"
data-testid="dashboard-title"
>
{title}
</Typography.Text>
</Tooltip>
{isPublicDashboard && (
<Tooltip title="This dashboard is publicly accessible">
<Globe size={14} className="public-dashboard-icon" />
</Tooltip>
)}
{isDashboardLocked && (
<Tooltip title="This dashboard is locked">
<LockKeyhole size={14} className="lock-dashboard-icon" />
</Tooltip>
)}
</div>
<div className="right-section">
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
<Popover
open={isDashboardSettingsOpen}
arrow={false}
onOpenChange={(visible): void => setIsDashbordSettingsOpen(visible)}
rootClassName="dashboard-settings"
content={
<div className="menu-content">
<section className="section-1">
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
<Tooltip
title={
dashboardData?.createdBy === 'integration' &&
'Dashboards created by integrations cannot be unlocked'
}
>
<Button
type="text"
icon={<LockKeyhole size={14} />}
disabled={dashboardData?.createdBy === 'integration'}
onClick={handleLockDashboardToggle}
data-testid="lock-unlock-dashboard"
>
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Button>
</Tooltip>
)}
{!isDashboardLocked && editDashboard && (
<Button
type="text"
icon={<PenLine size={14} />}
onClick={(): void => {
setIsRenameDashboardOpen(true);
setIsDashbordSettingsOpen(false);
}}
>
Rename
</Button>
)}
<Button
type="text"
icon={<Fullscreen size={14} />}
onClick={handle.enter}
>
Full screen
</Button>
</section>
<section className="section-2">
{!isDashboardLocked && addPanelPermission && (
<Button
type="text"
icon={<FolderKanban size={14} />}
onClick={(): void => {
setIsPanelNameModalOpen(true);
setIsDashbordSettingsOpen(false);
}}
>
New section
</Button>
)}
<Button
type="text"
icon={<FileJson size={14} />}
onClick={(): void => {
downloadObjectAsJson(
sanitizeDashboardData(selectedData),
selectedData.title,
);
setIsDashbordSettingsOpen(false);
}}
>
Export JSON
</Button>
<Button
type="text"
icon={<ClipboardCopy size={14} />}
onClick={(): void => {
setCopy(
JSON.stringify(sanitizeDashboardData(selectedData), null, 2),
);
setIsDashbordSettingsOpen(false);
}}
>
Copy as JSON
</Button>
</section>
<section className="delete-dashboard">
<DeleteButton
createdBy={dashboardData?.createdBy || ''}
name={dashboardData?.data.title || ''}
id={String(dashboardData?.id) || ''}
isLocked={isDashboardLocked}
routeToListPage
/>
</section>
</div>
}
trigger="click"
placement="bottomRight"
>
<Button
icon={<Ellipsis size={14} />}
type="text"
className="icons"
data-testid="options"
/>
</Popover>
{!isDashboardLocked && editDashboard && (
<>
<Button
type="text"
className="configure-button"
icon={<ConfigureIcon />}
data-testid="show-drawer"
onClick={onConfigureClick}
>
Configure
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={onSettingsDrawerClose}
>
<DashboardSettings
variablesSettingsTabHandle={variablesSettingsTabHandle}
/>
</SettingsDrawer>
</>
)}
{!isDashboardLocked && addPanelPermission && (
<Button
className="add-panel-btn"
onClick={onEmptyWidgetHandler}
icon={<Plus size="md" />}
type="primary"
data-testid="add-panel-header"
>
New Panel
</Button>
)}
</div>
</section>
{(tags?.length || 0) > 0 && (
<div className="dashboard-tags">
{tags?.map((tag) => (
<Badge key={tag} className="tag" color="vanilla">
{tag}
</Badge>
))}
</div>
)}
{!isEmpty(description) && (
<section className="dashboard-description-section">
{linkifyText(description ?? '')}
</section>
)}
{!isEmpty(dashboardVariables) && (
<section className="dashboard-variables">
<DashboardVariableSelection />
</section>
)}
<PanelTypeSelectionModal />
<Modal
open={isRenameDashboardOpen}
title="Rename Dashboard"
onOk={(): void => {
// handle update dashboard here
}}
onCancel={(): void => {
setIsRenameDashboardOpen(false);
}}
rootClassName="rename-dashboard"
footer={
<div className="dashboard-rename">
<Button
type="primary"
icon={<Check size={14} />}
className="rename-btn"
onClick={onNameChangeHandler}
disabled={updateDashboardMutation.isLoading}
>
Rename Dashboard
</Button>
<Button
type="text"
icon={<X size={14} />}
className="cancel-btn"
onClick={(): void => setIsRenameDashboardOpen(false)}
>
Cancel
</Button>
</div>
}
>
<div className="dashboard-content">
<Typography.Text className="name-text">Enter a new name</Typography.Text>
<Input
data-testid="dashboard-name"
className="dashboard-name-input"
value={updatedTitle}
onChange={(e): void => setUpdatedTitle(e.target.value)}
/>
</div>
</Modal>
<Modal
open={isPanelNameModalOpen}
title="New Section"
rootClassName="section-naming"
onOk={(): void => handleAddRow()}
onCancel={(): void => {
setIsPanelNameModalOpen(false);
setSectionName(DEFAULT_ROW_NAME);
}}
footer={
<div className="dashboard-rename">
<Button
type="primary"
icon={<Check size={14} />}
className="rename-btn"
onClick={(): void => handleAddRow()}
disabled={updateDashboardMutation.isLoading}
>
Create Section
</Button>
<Button
type="text"
icon={<X size={14} />}
className="cancel-btn"
onClick={(): void => {
setIsPanelNameModalOpen(false);
setSectionName(DEFAULT_ROW_NAME);
}}
>
Cancel
</Button>
</div>
}
>
<div className="section-naming-content">
<Typography.Text className="name-text">Enter Section name</Typography.Text>
<Input
data-testid="section-name"
className="section-name-input"
value={sectionName}
onChange={(e): void => setSectionName(e.target.value)}
/>
</div>
</Modal>
</Card>
);
}
export default DashboardDescription;

View File

@@ -1,8 +0,0 @@
import { MutableRefObject } from 'react';
export interface VariablesSettingsTab {
resetState: () => void;
}
export type VariablesSettingsTabHandle =
MutableRefObject<VariablesSettingsTab | null>;

View File

@@ -1,40 +0,0 @@
import { DashboardData, IDashboardVariable } from 'types/api/dashboard/getAll';
export function sanitizeDashboardData(
selectedData: DashboardData,
): DashboardData {
if (!selectedData?.variables) {
return selectedData;
}
const updatedVariables = Object.entries(selectedData.variables).reduce(
(acc, [key, value]) => {
const { selectedValue: _selectedValue, ...rest } = value;
acc[key] = rest;
return acc;
},
{} as Record<string, IDashboardVariable>,
);
return {
...selectedData,
variables: updatedVariables,
};
}
export function downloadObjectAsJson(
exportObj: unknown,
exportName: string,
): void {
const dataStr = `data:text/json;charset=utf-8,${encodeURIComponent(
JSON.stringify(exportObj),
)}`;
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute('href', dataStr);
downloadAnchorNode.setAttribute('download', `${exportName}.json`);
document.body.appendChild(downloadAnchorNode); // required for firefox
downloadAnchorNode.click();
downloadAnchorNode.remove();
}
export const DEFAULT_ROW_NAME = 'Sample Row';

View File

@@ -1,123 +0,0 @@
.delete-variable-name {
font-weight: 700;
color: rgb(207, 19, 34);
font-style: italic;
}
.apply-to-all-variable-name {
font-weight: 700;
color: var(--bg-robin-400);
font-style: italic;
}
.dashboard-variable-settings-table {
.variable-name-drag {
display: flex;
align-items: center;
gap: 10px;
.ant-table-cell {
padding: 0px !important;
border: none !important;
color: var(--bg-robin-400);
font-family: 'Space Mono';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
}
.variable-description-actions {
display: flex;
align-items: center;
gap: 10px;
flex: 1 0 0;
.variable-description {
color: var(--bg-sienna-400);
font-family: 'Space Mono';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
.actions-btns {
position: absolute;
right: 10px;
display: none;
&:hover {
display: inline-flex;
}
.edit-variable-button {
width: 26px;
height: 22px;
border-radius: 2px;
display: flex;
padding: 4px 6px;
align-items: center;
gap: 3px;
background: var(--l1-border);
}
.delete-variable-button {
width: 26px;
height: 22px;
border-radius: 2px;
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
display: flex;
padding: 4px 6px;
align-items: center;
gap: 3px;
color: red;
}
.apply-to-all-button {
width: min-content;
height: 22px;
border-radius: 2px;
display: flex;
padding: 0px 6px;
align-items: center;
gap: 3px;
background: var(--l1-border);
}
}
}
.ant-table-cell-row-hover {
.actions-btns {
display: inline-flex;
}
}
.ant-table-thead {
.ant-table-cell {
padding: 8px 12px;
border: 1px solid var(--l1-border);
background: unset;
}
.ant-table-cell::before {
display: none;
}
}
.ant-table-tbody {
.ant-table-cell {
padding: 14px;
border: 1px solid var(--l1-border);
}
}
.ant-table-row {
.ant-table-cell:nth-child(even) {
background: color-mix(in srgb, var(--l1-border) 40%, transparent);
}
}
}

View File

@@ -1,62 +0,0 @@
.settings-tabs {
.ant-tabs-nav-list {
height: 32px;
flex-shrink: 0;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
transition: opacity 0.1s !important;
.ant-tabs-tab + .ant-tabs-tab {
margin: 0px;
}
.ant-tabs-tab:not(:last-child) {
border-right: 1px solid var(--l1-border) !important;
}
.overview-btn {
width: 114px;
display: flex;
align-items: center;
justify-content: center;
}
.variables-btn {
width: 114px;
display: flex;
align-items: center;
justify-content: center;
}
.public-dashboard-btn {
width: 150px;
display: flex;
align-items: center;
justify-content: center;
&.disabled-btn {
opacity: 0.5;
cursor: not-allowed;
}
}
.ant-tabs-ink-bar {
display: none;
}
.ant-tabs-tab-active {
.overview-btn,
.variables-btn,
.public-dashboard-btn {
color: var(--primary-background);
background: var(--l1-border);
}
}
}
.ant-tabs-nav::before {
border-bottom: none;
}
}

View File

@@ -1,31 +0,0 @@
.dynamic-variable-container {
margin: 24px 0;
.dynamic-variable-config-container {
display: grid;
grid-template-columns: 1fr 32px 16px 200px;
gap: 16px;
align-items: center;
width: 100%;
.ant-select {
.ant-select-selector {
border-radius: 4px;
border: 1px solid var(--l1-border);
}
}
.ant-input {
border-radius: 4px;
border: 1px solid var(--l1-border);
max-width: 300px;
}
.dynamic-variable-from-text {
font-family: 'Space Mono';
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
}
}

View File

@@ -1,230 +0,0 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import CustomSelect from 'components/NewSelect/CustomSelect';
import TextToolTip from 'components/TextToolTip';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import { Info } from '@signozhq/icons';
import { FieldKey } from 'types/api/dynamicVariables/getFieldKeys';
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
import './DynamicVariable.styles.scss';
enum AttributeSource {
ALL_TELEMETRY = 'All telemetry',
LOGS = 'Logs',
METRICS = 'Metrics',
TRACES = 'Traces',
}
function DynamicVariable({
setDynamicVariablesSelectedValue,
dynamicVariablesSelectedValue,
errorAttributeKeyMessage,
}: {
setDynamicVariablesSelectedValue: Dispatch<
SetStateAction<
| {
name: string;
value: string;
}
| undefined
>
>;
dynamicVariablesSelectedValue:
| {
name: string;
value: string;
}
| undefined;
errorAttributeKeyMessage?: string;
}): JSX.Element {
const sources = [
AttributeSource.ALL_TELEMETRY,
AttributeSource.LOGS,
AttributeSource.TRACES,
AttributeSource.METRICS,
];
const [attributeSource, setAttributeSource] = useState<AttributeSource>();
const [attributes, setAttributes] = useState<Record<string, FieldKey[]>>({});
const [selectedAttribute, setSelectedAttribute] = useState<string>();
const [apiSearchText, setApiSearchText] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>();
const [isRetryableError, setIsRetryableError] = useState<boolean>(true);
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
const [filteredAttributes, setFilteredAttributes] = useState<
Record<string, FieldKey[]>
>({});
useEffect(() => {
if (dynamicVariablesSelectedValue?.name) {
setSelectedAttribute(dynamicVariablesSelectedValue.name);
}
if (dynamicVariablesSelectedValue?.value) {
setAttributeSource(dynamicVariablesSelectedValue.value as AttributeSource);
}
}, [
dynamicVariablesSelectedValue?.name,
dynamicVariablesSelectedValue?.value,
]);
const { data, error, isLoading, refetch } = useGetFieldKeys({
signal:
attributeSource === AttributeSource.ALL_TELEMETRY
? undefined
: (attributeSource?.toLowerCase() as 'traces' | 'logs' | 'metrics'),
name: debouncedApiSearchText,
});
const isComplete = useMemo(() => data?.data?.complete === true, [data]);
useEffect(() => {
if (data) {
const newAttributes = data.data?.keys ?? {};
setAttributes(newAttributes);
setFilteredAttributes(newAttributes);
}
}, [data]);
// Handle error from useGetFieldKeys
useEffect(() => {
if (error) {
// Check if error is retryable (5xx) or not (4xx)
const isRetryable = checkIfRetryableError(error);
setIsRetryableError(isRetryable);
}
}, [error]);
// refetch when attributeSource changes
useEffect(() => {
if (attributeSource) {
refetch();
}
}, [attributeSource, refetch, debouncedApiSearchText]);
// Handle search based on whether we have complete data or not
const handleSearch = useCallback(
(text: string) => {
if (isComplete) {
// If complete is true, do client-side filtering
if (!text) {
setFilteredAttributes(attributes);
return;
}
const filtered: Record<string, FieldKey[]> = {};
Object.keys(attributes).forEach((key) => {
if (key.toLowerCase().includes(text.toLowerCase())) {
filtered[key] = attributes[key];
}
});
setFilteredAttributes(filtered);
} else {
// If complete is false, debounce the API call
setApiSearchText(text);
}
},
[attributes, isComplete],
);
// update setDynamicVariablesSelectedValue with debounce when attribute and source is selected
useEffect(() => {
if (selectedAttribute || attributeSource) {
setDynamicVariablesSelectedValue({
name: selectedAttribute || dynamicVariablesSelectedValue?.name || '',
value:
attributeSource ||
dynamicVariablesSelectedValue?.value ||
AttributeSource.ALL_TELEMETRY,
});
}
}, [
selectedAttribute,
attributeSource,
setDynamicVariablesSelectedValue,
dynamicVariablesSelectedValue?.name,
dynamicVariablesSelectedValue?.value,
]);
const isDarkMode = useIsDarkMode();
const errorText = (error as any)?.message || errorMessage;
return (
<div className="dynamic-variable-container">
<div className="dynamic-variable-config-container">
<CustomSelect
placeholder="Select a field"
options={Object.keys(filteredAttributes).map((key) => ({
label: key,
value: key,
}))}
loading={isLoading}
status={errorText ? 'error' : undefined}
onChange={(value): void => {
setSelectedAttribute(value);
}}
showSearch
errorMessage={errorText as any}
value={selectedAttribute || dynamicVariablesSelectedValue?.name}
onSearch={handleSearch}
onRetry={(): void => {
// reset error message
setErrorMessage(undefined);
setIsRetryableError(true);
refetch();
}}
showRetryButton={isRetryableError}
/>
<Typography className="dynamic-variable-from-text">from</Typography>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<TextToolTip
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
useFilledIcon={false}
outlinedIcon={
<Info
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginTop: 1,
}}
/>
}
/>
</span>
<Select
placeholder="Source"
defaultValue={AttributeSource.ALL_TELEMETRY}
options={sources.map((source) => ({ label: source, value: source }))}
onChange={(value): void => setAttributeSource(value as AttributeSource)}
value={attributeSource || dynamicVariablesSelectedValue?.value}
/>
</div>
{errorAttributeKeyMessage && (
<div>
<Typography.Text color="warning">
{errorAttributeKeyMessage}
</Typography.Text>
</div>
)}
</div>
);
}
DynamicVariable.defaultProps = {
errorAttributeKeyMessage: '',
};
export default DynamicVariable;

View File

@@ -1,382 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import DynamicVariable from '../DynamicVariable';
// Mock scrollIntoView since it's not available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Mock dependencies
jest.mock('hooks/dynamicVariables/useGetFieldKeys', () => ({
useGetFieldKeys: jest.fn(),
}));
jest.mock('hooks/useDebounce', () => ({
__esModule: true,
default: (value: any): any => value, // Return the same value without debouncing for testing
}));
describe('DynamicVariable Component', () => {
const mockSetDynamicVariablesSelectedValue = jest.fn();
const ATTRIBUTE_PLACEHOLDER = 'Select a field';
const LOADING_TEXT = 'Refreshing values...';
const DEFAULT_PROPS = {
setDynamicVariablesSelectedValue: mockSetDynamicVariablesSelectedValue,
dynamicVariablesSelectedValue: undefined,
errorAttributeKeyMessage: '',
};
const mockFieldKeysResponse = {
data: {
keys: {
'service.name': [],
'http.status_code': [],
duration: [],
},
complete: true,
},
httpStatusCode: 200,
};
beforeEach(() => {
jest.clearAllMocks();
// Default mock implementation
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: mockFieldKeysResponse,
error: null,
isLoading: false,
refetch: jest.fn(),
});
});
// Helper function to get the attribute select element
const getAttributeSelect = (): HTMLElement =>
screen.getAllByRole('combobox')[0];
// Helper function to get the source select element
const getSourceSelect = (): HTMLElement => screen.getAllByRole('combobox')[1];
it('renders with default state', () => {
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Check for main components
expect(screen.getByText(ATTRIBUTE_PLACEHOLDER)).toBeInTheDocument();
expect(screen.getByText('All telemetry')).toBeInTheDocument();
expect(screen.getByText('from')).toBeInTheDocument();
});
it('uses existing values from dynamicVariablesSelectedValue prop', () => {
const selectedValue = {
name: 'service.name',
value: 'Logs',
};
render(
<DynamicVariable
setDynamicVariablesSelectedValue={mockSetDynamicVariablesSelectedValue}
dynamicVariablesSelectedValue={selectedValue}
/>,
);
// Verify values are set
expect(screen.getByText('service.name')).toBeInTheDocument();
expect(screen.getByText('Logs')).toBeInTheDocument();
});
it('shows loading state when fetching data', () => {
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: null,
error: null,
isLoading: true,
refetch: jest.fn(),
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the CustomSelect dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Should show loading state
expect(screen.getByText(LOADING_TEXT)).toBeInTheDocument();
});
it('shows error message when API fails', () => {
const errorMessage = 'Failed to fetch field keys';
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: null,
error: { message: errorMessage },
isLoading: false,
refetch: jest.fn(),
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the CustomSelect dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Should show error message
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
it('updates filteredAttributes when data is loaded', async () => {
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the CustomSelect dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Wait for options to appear in the dropdown
await waitFor(() => {
// Looking for option-content elements inside the CustomSelect dropdown
const options = document.querySelectorAll('.option-content');
expect(options.length).toBeGreaterThan(0);
// Check if all expected options are present
let foundServiceName = false;
let foundHttpStatusCode = false;
let foundDuration = false;
options.forEach((option) => {
const text = option.textContent?.trim();
if (text === 'service.name') {
foundServiceName = true;
}
if (text === 'http.status_code') {
foundHttpStatusCode = true;
}
if (text === 'duration') {
foundDuration = true;
}
});
expect(foundServiceName).toBe(true);
expect(foundHttpStatusCode).toBe(true);
expect(foundDuration).toBe(true);
});
});
it('calls setDynamicVariablesSelectedValue when attribute is selected', async () => {
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the attribute dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Wait for options to appear, then click on service.name
await waitFor(() => {
// Need to find the option-item containing service.name
const serviceNameOption = screen.getByText('service.name');
expect(serviceNameOption).not.toBeNull();
expect(serviceNameOption?.textContent).toBe('service.name');
// Click on the option-item that contains service.name
const optionElement = serviceNameOption?.closest('.option-item');
if (optionElement) {
fireEvent.click(optionElement);
}
});
// Check if the setter was called with the correct value
expect(mockSetDynamicVariablesSelectedValue).toHaveBeenCalledWith({
name: 'service.name',
value: 'All telemetry',
});
});
it('calls setDynamicVariablesSelectedValue when source is selected', () => {
const mockRefetch = jest.fn();
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: mockFieldKeysResponse,
error: null,
isLoading: false,
refetch: mockRefetch,
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Get the Select component
const select = screen
.getByText('All telemetry')
.closest('div[class*="ant-select"]');
expect(select).toBeInTheDocument();
// Directly call the onChange handler by simulating the Select's onChange
// Find the props.onChange of the Select component and call it directly
fireEvent.mouseDown(select as HTMLElement);
// Use a more specific selector to find the "Logs" option
const optionsContainer = document.querySelector(
'.rc-virtual-list-holder-inner',
);
expect(optionsContainer).not.toBeNull();
// Find the option with Logs text content
const logsOption = Array.from(
optionsContainer?.querySelectorAll('.ant-select-item-option-content') || [],
)
.find((element) => element.textContent === 'Logs')
?.closest('.ant-select-item-option');
expect(logsOption).not.toBeNull();
// Click on it
if (logsOption) {
fireEvent.click(logsOption);
}
// Check if the setter was called with the correct value
expect(mockSetDynamicVariablesSelectedValue).toHaveBeenCalledWith(
expect.objectContaining({
value: 'Logs',
}),
);
});
it('filters attributes locally when complete is true', async () => {
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the attribute dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Mock the filter function behavior
const attributeKeys = Object.keys(mockFieldKeysResponse.data.keys);
// Only "http.status_code" should match the filter
const expectedFilteredKeys = attributeKeys.filter((key) =>
key.includes('http'),
);
// Verify our expected filtering logic
expect(expectedFilteredKeys).toContain('http.status_code');
expect(expectedFilteredKeys).not.toContain('service.name');
expect(expectedFilteredKeys).not.toContain('duration');
// Now verify the component's filtering ability by inputting the search text
const inputElement = screen
.getAllByRole('combobox')[0]
.querySelector('input');
if (inputElement) {
fireEvent.change(inputElement, { target: { value: 'http' } });
}
});
it('triggers API call when complete is false and search text changes', async () => {
const mockRefetch = jest.fn();
// Set up the mock to indicate that data is not complete
// and needs to be fetched from the server
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: {
data: {
keys: {
'http.status_code': [],
},
complete: false, // This indicates server-side filtering is needed
},
httpStatusCode: 200,
},
error: null,
isLoading: false,
refetch: mockRefetch,
});
// Render with Logs as the initial source
render(
<DynamicVariable
{...DEFAULT_PROPS}
dynamicVariablesSelectedValue={{
name: '',
value: 'Logs',
}}
/>,
);
// Clear any initial calls
mockRefetch.mockClear();
// Now test the search functionality
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Find the input element and simulate typing
const inputElement = document.querySelector(
'.ant-select-selection-search-input',
);
if (inputElement) {
// Simulate typing in the search input
fireEvent.change(inputElement, { target: { value: 'http' } });
// Verify that the input has the correct value
expect((inputElement as HTMLInputElement).value).toBe('http');
// Wait for the effect to run and verify refetch was called
await waitFor(
() => {
expect(mockRefetch).toHaveBeenCalled();
},
{ timeout: 3000 },
); // Increase timeout to give more time for the effect to run
}
});
it('triggers refetch when attributeSource changes', async () => {
const mockRefetch = jest.fn();
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: mockFieldKeysResponse,
error: null,
isLoading: false,
refetch: mockRefetch,
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Clear any initial calls
mockRefetch.mockClear();
// Find and click on the source select to open dropdown
const sourceSelectElement = getSourceSelect();
fireEvent.mouseDown(sourceSelectElement);
// Find and click on the "Metrics" option
const metricsOption = screen.getByText('Metrics');
fireEvent.click(metricsOption);
// Wait for the effect to run
await waitFor(() => {
// Verify that refetch was called after source selection
expect(mockRefetch).toHaveBeenCalled();
});
});
it('shows retry button when error occurs', () => {
const mockRefetch = jest.fn();
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: null,
error: { message: 'Failed to fetch field keys' },
isLoading: false,
refetch: mockRefetch,
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the attribute dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Find and click reload icon (retry button)
const reloadIcon = screen.getByTestId('retry-button');
fireEvent.click(reloadIcon);
// Should trigger refetch
expect(mockRefetch).toHaveBeenCalled();
});
});

View File

@@ -1,420 +0,0 @@
.query-container {
display: flex;
flex-flow: row wrap;
min-width: 0;
gap: 1rem;
margin-bottom: 1rem;
flex-direction: column;
}
.variable-item-container {
display: flex;
flex-direction: column;
border-radius: 3px;
border: 1px solid var(--l1-border);
.all-variables {
display: flex;
padding: 10px 16px;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--l1-border);
.all-variables-btn {
display: flex;
align-items: center;
height: 24px;
padding: 0px;
color: var(--muted-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
}
.all-variables-btn:hover {
background-color: unset !important;
}
}
.variable-item-content {
padding: 12px 16px 20px 16px;
display: flex;
flex-direction: column;
gap: 20px;
.variable-name-section {
flex-direction: column;
gap: 8px;
.typography-variables {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
.name-input {
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
padding: 6px 6px 6px 8px;
}
}
.variable-description-section {
flex-direction: column;
gap: 8px;
.typography-variables {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
.description-input {
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
padding: 6px 6px 6px 8px;
}
}
.variable-type-section {
justify-content: space-between;
margin-bottom: 0px;
align-items: center;
.variable-type-label-container {
display: flex;
align-items: center;
gap: 8px;
}
.typography-variables {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
.variable-type-btn-group {
display: grid;
grid-template-columns: max-content max-content max-content max-content;
height: 32px;
flex-shrink: 0;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
.ant-btn {
width: 114px;
height: 32px;
flex-shrink: 0;
border-radius: 2px 0px 0px 2px;
width: 100%;
}
.variable-type-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
.sidenav-beta-tag {
margin-left: 4px;
}
div {
display: flex;
align-items: center;
}
}
.variable-type-btn + .variable-type-btn {
border-left: 1px solid var(--l1-border);
}
.selected {
background: var(--l1-border);
}
}
}
.sort-values-section {
justify-content: space-between;
margin-bottom: 0;
.typography-variables {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.typography-sort {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
letter-spacing: -0.06px;
}
.sort-input {
border-radius: 2px;
border: 1px solid var(--l1-border);
.ant-select-selector {
width: 192px;
height: 32px;
padding: 6px 6px 6px 8px;
background: var(--l3-background);
}
}
}
.multiple-values-section {
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0;
.typography-variables {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
width: 339px;
}
}
.all-option-section {
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0;
.typography-variables {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
width: 339px;
}
}
.default-value-section {
display: grid;
grid-template-columns: max-content 1fr;
.typography-variables {
display: block;
}
.default-value-description {
display: block;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
}
.dynamic-variable-section {
justify-content: space-between;
margin-bottom: 0;
.typography-variables {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
width: 339px;
}
}
.variable-textbox-section {
justify-content: space-between;
margin-bottom: 0;
align-items: center;
.typography-variables {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
.default-input {
display: flex;
height: 32px;
padding: 6px 6px 6px 8px;
align-items: flex-start;
gap: 4px;
flex: 1 0 0;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
width: 342px;
}
}
.variable-custom-section {
margin-bottom: 0px;
.custom-collapse {
width: 100%;
border-radius: 3px 3px 0px 0px;
border: 1px solid var(--l1-border);
.ant-collapse-item {
border-bottom: none;
}
.ant-collapse-header {
height: 38px;
border-radius: 3px 3px 0px 0px;
background: var(--l3-background);
align-items: center;
padding: 12px;
gap: 8px;
.ant-collapse-expand-icon {
padding-inline-end: 0px;
}
.ant-collapse-header-text {
color: var(--bg-robin-400);
font-family: 'Space Mono';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
display: flex;
padding: 1px 2px;
align-items: center;
gap: 10px;
border-radius: 2px;
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
}
}
.ant-collapse-content {
border-top: none;
.ant-collapse-content-box {
padding: 0px;
}
.comma-input {
height: 109px;
border: none;
}
}
}
}
.variables-preview-section {
display: flex;
flex-direction: column;
margin-bottom: 0px;
border-radius: 0px 0px 3px 3px;
border: 1px solid var(--l1-border);
height: 108px;
margin-top: -20px;
border-collapse: collapse;
gap: 5px;
flex-flow: column;
.typography-variables {
color: var(--bg-robin-400);
font-family: 'Space Mono';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
display: inline-flex;
padding: 1px 2px;
align-items: center;
gap: 10px;
border-radius: 0px 0px 2px 0px;
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
}
.preview-values {
padding: 4.5px 11px;
display: flex;
overflow-y: auto;
flex-flow: wrap;
gap: 8px;
[data-slot='badge'] {
height: 30px;
color: var(--l1-foreground);
font-family: 'Space Mono';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
display: inline-flex;
letter-spacing: -0.07px;
align-items: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
margin-inline-end: 0px;
}
}
}
}
}
.variable-item-footer {
margin-top: 12px;
display: flex;
flex-direction: row-reverse;
.footer-btn-discard {
display: flex;
align-items: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
height: 34px;
padding: 4px 8px 4px 10px;
box-shadow: none;
}
.footer-btn-save {
display: flex;
align-items: center;
border-radius: 2px;
border: 1px solid var(--primary-background);
background: var(--primary-background);
width: 123px;
height: 34px;
padding: 4px 8px 4px 10px;
box-shadow: none;
}
}

View File

@@ -1,828 +0,0 @@
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import {
IDashboardVariable,
TSortVariableValuesType,
VariableSortTypeArr,
} from 'types/api/dashboard/getAll';
import VariableItem from './VariableItem';
// Mock modules
jest.mock('api/dashboard/variables/dashboardVariablesQuery', () => ({
__esModule: true,
default: jest.fn().mockResolvedValue({
payload: {
variableValues: ['value1', 'value2', 'value3'],
},
}),
}));
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('test-uuid'),
}));
// Mock functions
const onCancel = jest.fn();
const onSave = jest.fn();
const validateName = jest.fn(() => true);
const validateAttributeKey = jest.fn(() => true);
// Mode constant
const VARIABLE_MODE = 'ADD';
// Common text constants
const TEXT = {
INCLUDE_ALL_VALUES: 'Include an option for ALL values',
ENABLE_MULTI_VALUES: 'Enable multiple values to be checked',
VARIABLE_EXISTS: 'Variable name already exists',
VARIABLE_WHITESPACE: 'Variable name cannot contain whitespaces',
ATTRIBUTE_KEY_EXISTS: 'A variable with this attribute key already exists',
SORT_VALUES: 'Sort Values',
DEFAULT_VALUE: 'Default Value',
ALL_VARIABLES: 'All variables',
DISCARD: 'Discard',
OPTIONS: 'Options',
QUERY: 'Query',
TEXTBOX: 'Textbox',
CUSTOM: 'Custom',
DYNAMIC: 'Dynamic',
};
// Common test constants
const VARIABLE_DEFAULTS = {
sort: VariableSortTypeArr[0] as TSortVariableValuesType,
multiSelect: false,
showALLOption: false,
};
// Common variable properties
const TEST_VAR_NAMES = {
VAR1: 'variable1',
VAR2: 'variable2',
VAR3: 'variable3',
};
const TEST_VAR_IDS = {
VAR1: 'var1',
VAR2: 'var2',
VAR3: 'var3',
};
const TEST_VAR_DESCRIPTIONS = {
VAR1: 'Variable 1',
VAR2: 'Variable 2',
VAR3: 'Variable 3',
};
// Common UI elements
const SAVE_BUTTON_TEXT = 'Save Variable';
const UNIQUE_NAME_PLACEHOLDER = 'Unique name of the variable';
// Basic variable data for testing
const basicVariableData: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Variable 1',
type: 'QUERY',
queryValue: 'SELECT * FROM test',
...VARIABLE_DEFAULTS,
order: 0,
};
// Helper function to render VariableItem with common props
const renderVariableItem = (
variableData: IDashboardVariable = basicVariableData,
existingVariables: Record<string, IDashboardVariable> = {},
validateNameFn = validateName,
validateAttributeKeyFn = validateAttributeKey,
): void => {
render(
<VariableItem
variableData={variableData}
existingVariables={existingVariables}
onCancel={onCancel}
onSave={onSave}
validateName={validateNameFn}
validateAttributeKey={validateAttributeKeyFn}
mode={VARIABLE_MODE}
/>,
);
};
// Helper function to find button by text within its span
const findButtonByText = (text: string): HTMLElement | null => {
const buttons = screen.getAllByRole('button');
return buttons.find((button) => button.textContent?.includes(text)) || null;
};
describe('VariableItem Component', () => {
// Test SQL query patterns
const SQL_PATTERN_DOT = 'SELECT * FROM test WHERE env = {{.variable2}}';
const SQL_PATTERN_DOLLAR = 'SELECT * FROM test WHERE env = $variable2';
const SQL_PATTERN_BRACKET = 'SELECT * FROM test WHERE service = [[variable3]]';
const SQL_PATTERN_BRACES = 'SELECT * FROM test WHERE app = {{variable1}}';
const SQL_PATTERN_NO_VARS = 'SELECT * FROM test WHERE env = "prod"';
const SQL_PATTERN_DOT_VAR1 =
'SELECT * FROM test WHERE service = {{.variable1}}';
// Error message text constant
const CIRCULAR_DEPENDENCY_ERROR = /Cannot save: Circular dependency detected/;
// Test functions and utilities
const createVariable = (
id: string,
name: string,
description: string,
queryValue: string,
order: number,
): IDashboardVariable => ({
id,
name,
description,
type: 'QUERY',
queryValue,
...VARIABLE_DEFAULTS,
order,
});
beforeEach(() => {
jest.clearAllMocks();
});
it('renders without crashing', () => {
renderVariableItem();
expect(screen.getByText(TEXT.ALL_VARIABLES)).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Variable Type')).toBeInTheDocument();
});
describe('Variable Name Validation', () => {
it('shows error when variable name already exists', () => {
// Set validateName to return false (name exists)
const mockValidateName = jest.fn().mockReturnValue(false);
renderVariableItem({ ...basicVariableData, name: '' }, {}, mockValidateName);
// Enter a name that already exists
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'existingVariable' } });
// Error message should be displayed
expect(screen.getByText(TEXT.VARIABLE_EXISTS)).toBeInTheDocument();
// We won't check for button disabled state as it might be inconsistent in tests
});
it('allows save when current variable name is used', () => {
// Mock validate to return false for all other names but true for own name
const mockValidateName = jest
.fn()
.mockImplementation((name) => name === TEST_VAR_NAMES.VAR1);
renderVariableItem(basicVariableData, {}, mockValidateName);
// Enter the current variable name
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: TEST_VAR_NAMES.VAR1 } });
// Error should not be visible
expect(screen.queryByText(TEXT.VARIABLE_EXISTS)).not.toBeInTheDocument();
});
it('shows error when variable name contains whitespace', () => {
renderVariableItem({ ...basicVariableData, name: '' });
// Enter a name with whitespace
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'variable name' } });
// Error message should be displayed
expect(screen.getByText(TEXT.VARIABLE_WHITESPACE)).toBeInTheDocument();
// Save button should be disabled
const saveButton = screen.getByRole('button', { name: /save variable/i });
expect(saveButton).toBeDisabled();
});
it('allows variable name without whitespace', () => {
renderVariableItem({ ...basicVariableData, name: '' });
// Enter a valid name without whitespace
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'variable.name' } });
// Error should not be visible
expect(screen.queryByText(TEXT.VARIABLE_WHITESPACE)).not.toBeInTheDocument();
});
it('validates whitespace in auto-generated name for dynamic variables', () => {
// Create a dynamic variable with empty name
const dynamicVariable: IDashboardVariable = {
...basicVariableData,
name: '',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service name', // Contains whitespace
dynamicVariablesSource: 'All telemetry',
};
renderVariableItem(dynamicVariable);
// Error message should be displayed for auto-generated name
expect(screen.getByText(TEXT.VARIABLE_WHITESPACE)).toBeInTheDocument();
});
});
describe('Dynamic Variable Attribute Key Validation', () => {
it('shows error when attribute key already exists', async () => {
// Mock validateAttributeKey to return false (attribute key exists)
const mockValidateAttributeKey = jest.fn().mockReturnValue(false);
// Create a dynamic variable
const dynamicVariable: IDashboardVariable = {
...basicVariableData,
name: 'test-variable',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'All telemetry',
};
renderVariableItem(
dynamicVariable,
{},
validateName,
mockValidateAttributeKey,
);
// Switch to Dynamic type to trigger the validation
const dynamicButton = findButtonByText(TEXT.DYNAMIC);
if (dynamicButton) {
fireEvent.click(dynamicButton);
}
// Error message should be displayed
await waitFor(() => {
expect(screen.getByText(TEXT.ATTRIBUTE_KEY_EXISTS)).toBeInTheDocument();
});
// Save button should be disabled
const saveButton = screen.getByRole('button', { name: /save variable/i });
expect(saveButton).toBeDisabled();
});
it('allows saving when attribute key is unique', async () => {
// Mock validateAttributeKey to return true (attribute key is unique)
const mockValidateAttributeKey = jest.fn().mockReturnValue(true);
// Create a dynamic variable
const dynamicVariable: IDashboardVariable = {
...basicVariableData,
name: 'test-variable',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'All telemetry',
};
renderVariableItem(
dynamicVariable,
{},
validateName,
mockValidateAttributeKey,
);
// Switch to Dynamic type
const dynamicButton = findButtonByText(TEXT.DYNAMIC);
if (dynamicButton) {
fireEvent.click(dynamicButton);
}
// Error should not be visible
await waitFor(() => {
expect(
screen.queryByText(TEXT.ATTRIBUTE_KEY_EXISTS),
).not.toBeInTheDocument();
});
// Save button should not be disabled due to attribute key error
const saveButton = screen.getByRole('button', { name: /save variable/i });
expect(saveButton).not.toBeDisabled();
});
it('allows same attribute key for current variable being edited', async () => {
// Mock validateAttributeKey to return true for same variable
const mockValidateAttributeKey = jest.fn().mockImplementation(
(attributeKey, currentVariableId) =>
// Allow if it's the same variable ID
currentVariableId === TEST_VAR_IDS.VAR1,
);
// Create a dynamic variable
const dynamicVariable: IDashboardVariable = {
...basicVariableData,
id: TEST_VAR_IDS.VAR1,
name: 'test-variable',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'All telemetry',
};
renderVariableItem(
dynamicVariable,
{},
validateName,
mockValidateAttributeKey,
);
// Error should not be visible
await waitFor(() => {
expect(
screen.queryByText(TEXT.ATTRIBUTE_KEY_EXISTS),
).not.toBeInTheDocument();
});
});
it('does not validate attribute key for non-dynamic variables', async () => {
// Mock validateAttributeKey to return false (would show error for dynamic)
const mockValidateAttributeKey = jest.fn().mockReturnValue(false);
// Create a non-dynamic variable
const queryVariable: IDashboardVariable = {
...basicVariableData,
name: 'test-variable',
type: 'QUERY',
};
renderVariableItem(
queryVariable,
{},
validateName,
mockValidateAttributeKey,
);
// No error should be displayed for query variables
expect(
screen.queryByText(TEXT.ATTRIBUTE_KEY_EXISTS),
).not.toBeInTheDocument();
// validateAttributeKey should not be called for non-dynamic variables
expect(mockValidateAttributeKey).not.toHaveBeenCalled();
});
});
describe('Variable Type Switching', () => {
it('switches to CUSTOM variable type correctly', () => {
renderVariableItem();
// Find the Query button
const queryButton = findButtonByText(TEXT.QUERY);
expect(queryButton).toBeInTheDocument();
expect(queryButton).toHaveClass('selected');
// Find and click Custom button
const customButton = findButtonByText(TEXT.CUSTOM);
expect(customButton).toBeInTheDocument();
if (customButton) {
fireEvent.click(customButton);
}
// Custom button should now be selected
expect(customButton).toHaveClass('selected');
expect(queryButton).not.toHaveClass('selected');
// Custom options input should appear
expect(screen.getByText(TEXT.OPTIONS)).toBeInTheDocument();
});
it('switches to TEXTBOX variable type correctly', () => {
renderVariableItem();
// Find and click Textbox button
const textboxButton = findButtonByText(TEXT.TEXTBOX);
expect(textboxButton).toBeInTheDocument();
if (textboxButton) {
fireEvent.click(textboxButton);
}
// Textbox button should now be selected
expect(textboxButton).toHaveClass('selected');
// Default Value input should appear
expect(screen.getByText(TEXT.DEFAULT_VALUE)).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Enter a default value (if any)...'),
).toBeInTheDocument();
});
});
describe('MultiSelect and ALL Option', () => {
it('enables ALL option only when multiSelect is enabled', async () => {
renderVariableItem();
// Initially, ALL option should not be visible
expect(screen.queryByText(TEXT.INCLUDE_ALL_VALUES)).not.toBeInTheDocument();
// Enable multiple values
const multipleValuesSwitch = screen
.getByText(TEXT.ENABLE_MULTI_VALUES)
.closest('.multiple-values-section')
?.querySelector('button');
expect(multipleValuesSwitch).toBeInTheDocument();
if (multipleValuesSwitch) {
fireEvent.click(multipleValuesSwitch);
}
// Now ALL option should be visible
await waitFor(() => {
expect(screen.getByText(TEXT.INCLUDE_ALL_VALUES)).toBeInTheDocument();
});
// Disable multiple values
if (multipleValuesSwitch) {
fireEvent.click(multipleValuesSwitch);
}
// ALL option should be hidden again
await waitFor(() => {
expect(screen.queryByText(TEXT.INCLUDE_ALL_VALUES)).not.toBeInTheDocument();
});
});
it('disables ALL option when multiSelect is disabled', async () => {
// Create variable with multiSelect and showALLOption both enabled
const variable: IDashboardVariable = {
...basicVariableData,
multiSelect: true,
showALLOption: true,
};
renderVariableItem(variable);
// ALL option should be visible initially
expect(screen.getByText(TEXT.INCLUDE_ALL_VALUES)).toBeInTheDocument();
// Disable multiple values
const multipleValuesSwitch = screen
.getByText(TEXT.ENABLE_MULTI_VALUES)
.closest('.multiple-values-section')
?.querySelector('button');
if (multipleValuesSwitch) {
fireEvent.click(multipleValuesSwitch);
}
// ALL option should be hidden
await waitFor(() => {
expect(screen.queryByText(TEXT.INCLUDE_ALL_VALUES)).not.toBeInTheDocument();
});
// Check that when saving, showALLOption is set to false
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
fireEvent.click(saveButton);
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
multiSelect: false,
showALLOption: false,
}),
expect.anything(), // widgetIds prop
);
});
});
describe('Cancel and Navigation', () => {
it('calls onCancel when clicking All Variables button', () => {
renderVariableItem();
// Click All variables button
const allVariablesButton = screen.getByText(TEXT.ALL_VARIABLES);
fireEvent.click(allVariablesButton);
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('calls onCancel when clicking Discard button', () => {
renderVariableItem();
// Click Discard button
const discardButton = screen.getByText(TEXT.DISCARD);
fireEvent.click(discardButton);
expect(onCancel).toHaveBeenCalledTimes(1);
});
});
describe('Cyclic Dependency Detection', () => {
// Common function to render the component with variables and click save
const renderAndSave = async (
variableData: IDashboardVariable,
existingVariables: Record<string, IDashboardVariable>,
): Promise<void> => {
renderVariableItem(variableData, existingVariables);
// Fill in the variable name if it's not already populated
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
if (nameInput.getAttribute('value') === '') {
fireEvent.change(nameInput, { target: { value: variableData.name || '' } });
}
// Click save button to trigger the dependency check
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
fireEvent.click(saveButton);
};
// Common expectations for finding circular dependency error
const expectCircularDependencyError = async (): Promise<void> => {
await waitFor(() => {
expect(screen.getByText(CIRCULAR_DEPENDENCY_ERROR)).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
};
// Test for cyclic dependency detection
it('detects circular dependency and shows error message', async () => {
// Create variables with circular dependency
const variable1 = createVariable(
TEST_VAR_IDS.VAR1,
TEST_VAR_NAMES.VAR1,
TEST_VAR_DESCRIPTIONS.VAR1,
SQL_PATTERN_DOT,
0,
);
const variable2 = createVariable(
TEST_VAR_IDS.VAR2,
TEST_VAR_NAMES.VAR2,
TEST_VAR_DESCRIPTIONS.VAR2,
SQL_PATTERN_DOT_VAR1,
1,
);
const existingVariables = {
[TEST_VAR_IDS.VAR2]: variable2,
};
await renderAndSave(variable1, existingVariables);
await expectCircularDependencyError();
});
// Test for saving with no circular dependency
it('allows saving when no circular dependency exists', async () => {
// Create variables without circular dependency
const variable1 = createVariable(
TEST_VAR_IDS.VAR1,
TEST_VAR_NAMES.VAR1,
TEST_VAR_DESCRIPTIONS.VAR1,
SQL_PATTERN_NO_VARS,
0,
);
const variable2 = createVariable(
TEST_VAR_IDS.VAR2,
TEST_VAR_NAMES.VAR2,
TEST_VAR_DESCRIPTIONS.VAR2,
SQL_PATTERN_DOT_VAR1,
1,
);
const existingVariables = {
[TEST_VAR_IDS.VAR2]: variable2,
};
await renderAndSave(variable1, existingVariables);
// Verify the onSave function was called
await waitFor(() => {
expect(onSave).toHaveBeenCalled();
});
});
// Test with multiple variable formats in query
it('detects circular dependency with different variable formats', async () => {
// Create variables with circular dependency using different formats
const variable1 = createVariable(
TEST_VAR_IDS.VAR1,
TEST_VAR_NAMES.VAR1,
TEST_VAR_DESCRIPTIONS.VAR1,
SQL_PATTERN_DOLLAR,
0,
);
const variable2 = createVariable(
TEST_VAR_IDS.VAR2,
TEST_VAR_NAMES.VAR2,
TEST_VAR_DESCRIPTIONS.VAR2,
SQL_PATTERN_BRACKET,
1,
);
const variable3 = createVariable(
TEST_VAR_IDS.VAR3,
TEST_VAR_NAMES.VAR3,
TEST_VAR_DESCRIPTIONS.VAR3,
SQL_PATTERN_BRACES,
2,
);
const existingVariables = {
[TEST_VAR_IDS.VAR2]: variable2,
[TEST_VAR_IDS.VAR3]: variable3,
};
await renderAndSave(variable1, existingVariables);
await expectCircularDependencyError();
});
});
describe('Textbox Variable Default Value Handling', () => {
it('saves textbox variable with defaultValue and selectedValue set to textboxValue', async () => {
const user = userEvent.setup();
const textboxVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Textbox Variable',
type: 'TEXTBOX',
textboxValue: 'my-default-value',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(textboxVariable);
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with defaultValue and selectedValue equal to textboxValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: 'my-default-value',
defaultValue: 'my-default-value',
selectedValue: 'my-default-value',
}),
expect.anything(),
);
});
it('saves textbox variable with empty values when textboxValue is empty', async () => {
const user = userEvent.setup();
const textboxVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Textbox Variable',
type: 'TEXTBOX',
textboxValue: '',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(textboxVariable);
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with empty defaultValue and selectedValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: '',
defaultValue: '',
selectedValue: '',
}),
expect.anything(),
);
});
it('updates textbox defaultValue and selectedValue when user changes textboxValue input', async () => {
const user = userEvent.setup();
const textboxVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Textbox Variable',
type: 'TEXTBOX',
textboxValue: 'initial-value',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(textboxVariable);
// Change the textbox value
const textboxInput = screen.getByPlaceholderText(
'Enter a default value (if any)...',
);
await user.clear(textboxInput);
await user.type(textboxInput, 'updated-value');
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with the updated defaultValue and selectedValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: 'updated-value',
defaultValue: 'updated-value',
selectedValue: 'updated-value',
}),
expect.anything(),
);
});
it('non-textbox variables use variableDefaultValue instead of textboxValue', async () => {
const user = userEvent.setup();
const queryVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Query Variable',
type: 'QUERY',
queryValue: 'SELECT * FROM test',
textboxValue: 'should-not-be-used',
defaultValue: 'query-default-value',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(queryVariable);
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with defaultValue not being textboxValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'QUERY',
defaultValue: 'query-default-value',
}),
expect.anything(),
);
// Verify that defaultValue is NOT the textboxValue
const savedVariable = onSave.mock.calls[0][1];
expect(savedVariable.defaultValue).not.toBe('should-not-be-used');
});
it('switching to textbox type sets defaultValue and selectedValue correctly on save', async () => {
const user = userEvent.setup();
// Start with a QUERY variable
const queryVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Variable',
type: 'QUERY',
queryValue: 'SELECT * FROM test',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(queryVariable);
// Switch to TEXTBOX type
const textboxButton = findButtonByText(TEXT.TEXTBOX);
expect(textboxButton).toBeInTheDocument();
if (textboxButton) {
await user.click(textboxButton);
}
// Enter a default value in the textbox input
const textboxInput = screen.getByPlaceholderText(
'Enter a default value (if any)...',
);
await user.type(textboxInput, 'new-textbox-default');
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with type TEXTBOX and correct defaultValue and selectedValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: 'new-textbox-default',
defaultValue: 'new-textbox-default',
selectedValue: 'new-textbox-default',
}),
expect.anything(),
);
});
});
});

View File

@@ -1,851 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { orange } from '@ant-design/colors';
import { Color } from '@signozhq/design-tokens';
import { Button, Collapse, Input, Select } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import cx from 'classnames';
import Editor from 'components/Editor';
import { CustomSelect } from 'components/NewSelect';
import TextToolTip from 'components/TextToolTip';
import { PANEL_GROUP_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useWidgetsByDynamicVariableId } from 'hooks/dashboard/useWidgetsByDynamicVariableId';
import { getWidgetsHavingDynamicVariableAttribute } from 'hooks/dashboard/utils';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import { isEmpty, map } from 'lodash-es';
import {
ArrowLeft,
Check,
ClipboardType,
DatabaseZap,
Info,
LayoutList,
Pyramid,
X,
} from '@signozhq/icons';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { AppState } from 'store/reducers';
import {
IDashboardVariable,
TSortVariableValuesType,
TVariableQueryType,
VariableSortTypeArr,
Widgets,
} from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as generateUUID } from 'uuid';
import {
buildDependencies,
buildDependencyGraph,
} from '../../../DashboardVariablesSelection/util';
import { variablePropsToPayloadVariables } from '../../../utils';
import { TVariableMode } from '../types';
import DynamicVariable from './DynamicVariable/DynamicVariable';
import { LabelContainer, VariableItemRow } from './styles';
import { WidgetSelector } from './WidgetSelector';
import './VariableItem.styles.scss';
const { Option } = Select;
interface VariableItemProps {
variableData: IDashboardVariable;
existingVariables: Record<string, IDashboardVariable>;
onCancel: () => void;
onSave: (
mode: TVariableMode,
variableData: IDashboardVariable,
widgetIds?: string[],
) => void;
validateName: (arg0: string) => boolean;
validateAttributeKey: (
attributeKey: string,
currentVariableId?: string,
) => boolean;
mode: TVariableMode;
}
function VariableItem({
variableData,
existingVariables,
onCancel,
onSave,
validateName,
validateAttributeKey,
mode,
}: VariableItemProps): JSX.Element {
const [variableName, setVariableName] = useState<string>(
variableData.name || '',
);
const [hasUserManuallyChangedName, setHasUserManuallyChangedName] =
useState<boolean>(false);
const [variableDescription, setVariableDescription] = useState<string>(
variableData.description || '',
);
const [queryType, setQueryType] = useState<TVariableQueryType>(
variableData.type || 'DYNAMIC',
);
const [variableQueryValue, setVariableQueryValue] = useState<string>(
variableData.queryValue || '',
);
const [variableCustomValue, setVariableCustomValue] = useState<string>(
variableData.customValue || '',
);
const [variableTextboxValue, setVariableTextboxValue] = useState<string>(
variableData.textboxValue || '',
);
const [variableSortType, setVariableSortType] =
useState<TSortVariableValuesType>(
variableData.sort || VariableSortTypeArr[0],
);
const [variableMultiSelect, setVariableMultiSelect] = useState<boolean>(
variableData.multiSelect || false,
);
const [variableShowALLOption, setVariableShowALLOption] = useState<boolean>(
variableData.showALLOption || false,
);
const [previewValues, setPreviewValues] = useState<string[]>([]);
const [variableDefaultValue, setVariableDefaultValue] = useState<string>(
(variableData.defaultValue as string) || '',
);
const isDarkMode = useIsDarkMode();
const [dynamicVariablesSelectedValue, setDynamicVariablesSelectedValue] =
useState<{ name: string; value: string }>();
// Error messages
const [errorName, setErrorName] = useState<boolean>(false);
const [errorNameMessage, setErrorNameMessage] = useState<string>('');
const [errorAttributeKey, setErrorAttributeKey] = useState<boolean>(false);
const [errorAttributeKeyMessage, setErrorAttributeKeyMessage] =
useState<string>('');
const [errorPreview, setErrorPreview] = useState<string | null>(null);
useEffect(() => {
if (
variableData.dynamicVariablesAttribute &&
variableData.dynamicVariablesSource
) {
setDynamicVariablesSelectedValue({
name: variableData.dynamicVariablesAttribute,
value: variableData.dynamicVariablesSource,
});
}
}, [
variableData.dynamicVariablesAttribute,
variableData.dynamicVariablesSource,
]);
// Validate attribute key uniqueness for dynamic variables
useEffect(() => {
if (queryType === 'DYNAMIC' && dynamicVariablesSelectedValue?.name) {
if (
!validateAttributeKey(dynamicVariablesSelectedValue.name, variableData.id)
) {
setErrorAttributeKey(true);
setErrorAttributeKeyMessage(
'A variable with this attribute key already exists',
);
} else {
setErrorAttributeKey(false);
setErrorAttributeKeyMessage('');
}
} else {
setErrorAttributeKey(false);
setErrorAttributeKeyMessage('');
}
}, [
queryType,
dynamicVariablesSelectedValue?.name,
validateAttributeKey,
variableData.id,
]);
// Auto-set variable name to selected attribute name in creation mode when user hasn't manually changed it
useEffect(() => {
if (
mode === 'ADD' && // Only in creation mode
queryType === 'DYNAMIC' && // Only for dynamic variables
dynamicVariablesSelectedValue?.name && // Attribute is selected
!hasUserManuallyChangedName // User hasn't manually changed the name
) {
const newName = dynamicVariablesSelectedValue.name;
setVariableName(newName);
// Trigger validation for the auto-set name
if (/\s/.test(newName)) {
setErrorName(true);
setErrorNameMessage('Variable name cannot contain whitespaces');
} else if (!validateName(newName)) {
setErrorName(true);
setErrorNameMessage('Variable name already exists');
} else {
setErrorName(false);
setErrorNameMessage('');
}
}
}, [
mode,
queryType,
dynamicVariablesSelectedValue?.name,
hasUserManuallyChangedName,
validateName,
]);
const REQUIRED_NAME_MESSAGE = 'Variable name is required';
// Initialize error state for empty name
useEffect(() => {
if (!variableName.trim()) {
setErrorName(true);
}
}, [variableName]);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { data: fieldValues } = useGetFieldValues({
signal:
dynamicVariablesSelectedValue?.value === 'All telemetry'
? undefined
: (dynamicVariablesSelectedValue?.value?.toLowerCase() as
| 'traces'
| 'logs'
| 'metrics'),
name: dynamicVariablesSelectedValue?.name || '',
enabled:
!!dynamicVariablesSelectedValue?.name &&
!!dynamicVariablesSelectedValue?.value,
startUnixMilli: minTime,
endUnixMilli: maxTime,
});
const [selectedWidgets, setSelectedWidgets] = useState<string[]>([]);
const { dashboardData } = useDashboardStore();
const widgetsByDynamicVariableId = useWidgetsByDynamicVariableId();
useEffect(() => {
if (variableData?.id && variableData.id in widgetsByDynamicVariableId) {
setSelectedWidgets(widgetsByDynamicVariableId[variableData.id] || []);
} else if (dynamicVariablesSelectedValue?.name) {
const widgets = getWidgetsHavingDynamicVariableAttribute(
dynamicVariablesSelectedValue?.name,
(dashboardData?.data?.widgets?.filter(
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
) || []) as Widgets[],
variableData.name,
);
setSelectedWidgets(widgets || []);
}
}, [
dynamicVariablesSelectedValue?.name,
dashboardData,
variableData.id,
variableData.name,
widgetsByDynamicVariableId,
]);
useEffect(() => {
if (queryType === 'CUSTOM') {
setPreviewValues(
sortValues(
commaValuesParser(variableCustomValue),
variableSortType,
) as never,
);
}
if (queryType === 'QUERY') {
setPreviewValues((prev) => sortValues(prev, variableSortType) as never);
}
}, [
queryType,
variableCustomValue,
variableData.customValue,
variableData.type,
variableSortType,
]);
useEffect(() => {
if (
queryType === 'DYNAMIC' &&
fieldValues &&
dynamicVariablesSelectedValue?.name &&
dynamicVariablesSelectedValue?.value
) {
setPreviewValues(
sortValues(
fieldValues.data?.normalizedValues || [],
variableSortType,
) as never,
);
}
}, [
fieldValues,
variableSortType,
queryType,
dynamicVariablesSelectedValue?.name,
dynamicVariablesSelectedValue?.value,
]);
const variableValue = useMemo(() => {
if (queryType === 'TEXTBOX') {
return variableTextboxValue;
}
if (variableMultiSelect) {
let value = variableData.selectedValue;
if (isEmpty(value)) {
if (variableData.showALLOption) {
if (variableDefaultValue) {
value = variableDefaultValue;
} else {
value = previewValues;
}
} else if (variableDefaultValue) {
value = variableDefaultValue;
} else {
value = previewValues?.[0];
}
}
return value;
}
if (isEmpty(variableData.selectedValue)) {
if (variableDefaultValue) {
return variableDefaultValue;
}
return previewValues?.[0]?.toString();
}
return variableData.selectedValue || variableDefaultValue;
}, [
variableMultiSelect,
variableData.selectedValue,
variableData.showALLOption,
variableDefaultValue,
variableTextboxValue,
queryType,
previewValues,
]);
const handleSave = (): void => {
// Check for cyclic dependencies
const newVariable = {
name: variableName,
description: variableDescription,
type: queryType,
queryValue: variableQueryValue,
customValue: variableCustomValue,
textboxValue: variableTextboxValue,
multiSelect: variableMultiSelect,
showALLOption: queryType === 'DYNAMIC' ? true : variableShowALLOption,
sort: variableSortType,
// the reason we need to do this is because defaultValues are treated differently in case of textbox type
// They are the exact same and not like the other types where defaultValue is a separate field
defaultValue:
queryType === 'TEXTBOX' ? variableTextboxValue : variableDefaultValue,
modificationUUID: generateUUID(),
id: variableData.id || generateUUID(),
order: variableData.order,
...(queryType === 'DYNAMIC' && {
dynamicVariablesAttribute: dynamicVariablesSelectedValue?.name,
dynamicVariablesSource: dynamicVariablesSelectedValue?.value,
}),
selectedValue: variableValue,
allSelected: variableData.allSelected,
};
const allVariables = [...Object.values(existingVariables), newVariable];
const dependencies = buildDependencies(allVariables);
const { hasCycle, cycleNodes } = buildDependencyGraph(dependencies);
if (hasCycle) {
setErrorPreview(
`Cannot save: Circular dependency detected between variables: ${cycleNodes?.join(
' → ',
)}`,
);
return;
}
onSave(mode, newVariable, selectedWidgets);
};
// Fetches the preview values for the SQL variable query
const handleQueryResult = (response: any): void => {
if (response?.payload?.variableValues) {
setPreviewValues(
sortValues(
response.payload?.variableValues || [],
variableSortType,
) as never,
);
} else {
setPreviewValues([]);
}
};
const { isFetching: previewLoading, refetch: runQuery } = useQuery(
[REACT_QUERY_KEY.DASHBOARD_BY_ID, variableData.name, variableName],
{
enabled: false,
queryFn: () =>
dashboardVariablesQuery({
query: variableQueryValue || '',
variables: variablePropsToPayloadVariables(existingVariables),
}),
refetchOnWindowFocus: false,
onSuccess: (response) => {
setErrorPreview(null);
handleQueryResult(response);
},
onError: (error: {
details: {
error: string;
};
}) => {
const { details } = error;
if (details.error) {
let message = details.error;
if ((details.error ?? '').toString().includes('Syntax error:')) {
message =
'Please make sure query is valid and dependent variables are selected';
}
setErrorPreview(message);
}
},
},
);
const handleTestRunQuery = useCallback(() => {
runQuery();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className="variable-item-container">
<div className="all-variables">
<Button
type="text"
className="all-variables-btn"
icon={<ArrowLeft size={14} />}
onClick={onCancel}
>
All variables
</Button>
</div>
<div className="variable-item-content">
<VariableItemRow className="variable-name-section">
<LabelContainer>
<Typography className="typography-variables">Name</Typography>
</LabelContainer>
<div>
<Input
placeholder="Unique name of the variable"
value={variableName}
className="name-input"
onChange={({ target: { value } }): void => {
setVariableName(value);
setHasUserManuallyChangedName(true); // Mark that user has manually changed the name
// Check for empty name
if (!value.trim()) {
setErrorName(true);
setErrorNameMessage(REQUIRED_NAME_MESSAGE);
}
// Check for whitespace in name
else if (/\s/.test(value)) {
setErrorName(true);
setErrorNameMessage('Variable name cannot contain whitespaces');
}
// Check for duplicate name
else if (!validateName(value) && value !== variableData.name) {
setErrorName(true);
setErrorNameMessage('Variable name already exists');
}
// No errors
else {
setErrorName(false);
setErrorNameMessage('');
}
}}
/>
<div>
<Typography.Text color="warning">{errorNameMessage}</Typography.Text>
</div>
</div>
</VariableItemRow>
<VariableItemRow className="variable-description-section">
<LabelContainer>
<Typography className="typography-variables">Description</Typography>
</LabelContainer>
<Input.TextArea
value={variableDescription}
placeholder="Enter a description for the variable"
className="description-input"
rows={3}
onChange={(e): void => setVariableDescription(e.target.value)}
/>
</VariableItemRow>
<VariableItemRow className="variable-type-section">
<LabelContainer className="variable-type-label-container">
<Typography className="typography-variables">Variable Type</Typography>
<TextToolTip
text="Learn more about supported variable types"
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
urlText="here"
useFilledIcon={false}
outlinedIcon={
<Info
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
}
/>
</LabelContainer>
<div className="variable-type-btn-group">
<Button
type="text"
icon={<Pyramid size={14} />}
className={cx(
'variable-type-btn',
queryType === 'DYNAMIC' ? 'selected' : '',
)}
onClick={(): void => {
setQueryType('DYNAMIC');
setPreviewValues([]);
// Reset manual change flag if no name is entered
if (!variableName.trim()) {
setHasUserManuallyChangedName(false);
}
}}
>
Dynamic
<Badge color="robin" className="sidenav-beta-tag">
Beta
</Badge>
</Button>
<Button
type="text"
icon={<ClipboardType size={14} />}
className={cx(
'variable-type-btn',
queryType === 'TEXTBOX' ? 'selected' : '',
)}
onClick={(): void => {
setQueryType('TEXTBOX');
setPreviewValues([]);
// Reset manual change flag if no name is entered
if (!variableName.trim()) {
setHasUserManuallyChangedName(false);
}
}}
>
Textbox
</Button>
<Button
type="text"
icon={<LayoutList size={14} />}
className={cx(
'variable-type-btn',
queryType === 'CUSTOM' ? 'selected' : '',
)}
onClick={(): void => {
setQueryType('CUSTOM');
setPreviewValues([]);
// Reset manual change flag if no name is entered
if (!variableName.trim()) {
setHasUserManuallyChangedName(false);
}
}}
>
Custom
</Button>
<Button
type="text"
icon={<DatabaseZap size={14} />}
className={cx(
'variable-type-btn',
queryType === 'QUERY' ? 'selected' : '',
)}
onClick={(): void => {
setQueryType('QUERY');
setPreviewValues([]);
// Reset manual change flag if no name is entered
if (!variableName.trim()) {
setHasUserManuallyChangedName(false);
}
}}
>
Query
<Badge color="amber" className="sidenav-beta-tag">
Not Recommended
</Badge>
<div onClick={(e): void => e.stopPropagation()}>
<TextToolTip
text="Learn why we don't recommend"
url="https://signoz.io/docs/userguide/manage-variables/#why-avoid-clickhouse-query-variables"
urlText="here"
useFilledIcon={false}
outlinedIcon={
<Info
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
}
/>
</div>
</Button>
</div>
</VariableItemRow>
{queryType === 'DYNAMIC' && (
<div className="variable-dynamic-section">
<DynamicVariable
setDynamicVariablesSelectedValue={setDynamicVariablesSelectedValue}
dynamicVariablesSelectedValue={dynamicVariablesSelectedValue}
errorAttributeKeyMessage={errorAttributeKeyMessage}
/>
</div>
)}
{queryType === 'QUERY' && (
<div className="query-container">
<LabelContainer>
<Typography>Query</Typography>
</LabelContainer>
<div style={{ flex: 1, position: 'relative' }}>
<Editor
language="sql"
value={variableQueryValue}
onChange={(e): void => setVariableQueryValue(e)}
height="240px"
options={{
fontSize: 13,
wordWrap: 'on',
lineNumbers: 'off',
glyphMargin: false,
folding: false,
lineDecorationsWidth: 0,
lineNumbersMinChars: 0,
minimap: {
enabled: false,
},
}}
/>
<Button
type="primary"
size="small"
onClick={handleTestRunQuery}
style={{
position: 'absolute',
bottom: 0,
}}
loading={previewLoading}
>
Test Run Query
</Button>
</div>
</div>
)}
{queryType === 'CUSTOM' && (
<VariableItemRow className="variable-custom-section">
<Collapse
collapsible="header"
rootClassName="custom-collapse"
defaultActiveKey={['1']}
items={[
{
key: '1',
label: 'Options',
children: (
<Input.TextArea
value={variableCustomValue}
placeholder="Enter options separated by commas."
rootClassName="comma-input"
onChange={(e): void => {
setVariableCustomValue(e.target.value);
setPreviewValues(
sortValues(
commaValuesParser(e.target.value),
variableSortType,
) as never,
);
}}
/>
),
},
]}
/>
</VariableItemRow>
)}
{queryType === 'TEXTBOX' && (
<VariableItemRow className="variable-textbox-section">
<LabelContainer>
<Typography className="typography-variables">Default Value</Typography>
</LabelContainer>
<Input
value={variableTextboxValue}
className="default-input"
onChange={(e): void => {
setVariableTextboxValue(e.target.value);
}}
placeholder="Enter a default value (if any)..."
style={{ width: 400 }}
/>
</VariableItemRow>
)}
{(queryType === 'QUERY' ||
queryType === 'CUSTOM' ||
queryType === 'DYNAMIC') && (
<>
<VariableItemRow className="variables-preview-section">
<LabelContainer style={{ width: '100%' }}>
<Typography className="typography-variables">
Preview of Values
</Typography>
</LabelContainer>
<div className="preview-values">
{errorPreview ? (
<Typography style={{ color: orange[5] }}>{errorPreview}</Typography>
) : (
map(previewValues, (value, idx) => (
<Badge key={`${value}${idx}`} color="vanilla">
{value.toString()}
</Badge>
))
)}
</div>
</VariableItemRow>
<VariableItemRow className="sort-values-section">
<LabelContainer>
<Typography className="typography-variables">Sort Values</Typography>
</LabelContainer>
<Select
defaultActiveFirstOption
defaultValue={VariableSortTypeArr[0]}
value={variableSortType}
onChange={(value: TSortVariableValuesType): void =>
setVariableSortType(value)
}
className="sort-input"
>
<Option value={VariableSortTypeArr[0]}>Disabled</Option>
<Option value={VariableSortTypeArr[1]}>Ascending</Option>
<Option value={VariableSortTypeArr[2]}>Descending</Option>
</Select>
</VariableItemRow>
<VariableItemRow className="multiple-values-section">
<LabelContainer>
<Typography className="typography-variables">
Enable multiple values to be checked
</Typography>
</LabelContainer>
<Switch
value={variableMultiSelect}
onChange={(e): void => {
setVariableMultiSelect(e);
if (!e) {
setVariableShowALLOption(false);
}
}}
/>
</VariableItemRow>
{variableMultiSelect && queryType !== 'DYNAMIC' && (
<VariableItemRow className="all-option-section">
<LabelContainer>
<Typography className="typography-variables">
Include an option for ALL values
</Typography>
</LabelContainer>
<Switch
value={variableShowALLOption}
onChange={(e): void => setVariableShowALLOption(e)}
/>
</VariableItemRow>
)}
<VariableItemRow className="default-value-section">
<LabelContainer>
<Typography className="typography-variables">Default Value</Typography>
<Typography className="default-value-description">
{queryType === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value'}
</Typography>
</LabelContainer>
<CustomSelect
placeholder="Select a default value"
value={variableDefaultValue}
onChange={(value): void => setVariableDefaultValue(value)}
options={previewValues.map((value) => ({
label: value,
value,
}))}
/>
</VariableItemRow>
</>
)}
{queryType === 'DYNAMIC' && (
<VariableItemRow className="dynamic-variable-section">
<LabelContainer>
<Typography className="typography-variables">
Select Panels to apply this variable
</Typography>
</LabelContainer>
<WidgetSelector
selectedWidgets={selectedWidgets}
setSelectedWidgets={setSelectedWidgets}
/>
</VariableItemRow>
)}
</div>
</div>
<div className="variable-item-footer">
<VariableItemRow>
<Button
type="default"
onClick={onCancel}
icon={<X size={14} />}
className="footer-btn-discard"
>
Discard
</Button>
<Button
type="primary"
onClick={handleSave}
disabled={errorName || errorAttributeKey}
icon={<Check size={14} />}
className="footer-btn-save"
>
Save Variable
</Button>
</VariableItemRow>
</div>
</>
);
}
export default VariableItem;

View File

@@ -1,64 +0,0 @@
import React from 'react';
import { CustomMultiSelect } from 'components/NewSelect';
import { PANEL_GROUP_TYPES } from 'constants/queryBuilder';
import { generateGridTitle } from 'container/GridPanelSwitch/utils';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { WidgetRow, Widgets } from 'types/api/dashboard/getAll';
export function WidgetSelector({
selectedWidgets,
setSelectedWidgets,
}: {
selectedWidgets: string[];
setSelectedWidgets: (widgets: string[]) => void;
}): JSX.Element {
const { dashboardData } = useDashboardStore();
// Get layout IDs for cross-referencing
const layoutIds = new Set(
(dashboardData?.data?.layout || []).map((item) => item.i),
);
// Filter and deduplicate widgets by ID, keeping only those with layout entries
// and excluding row widgets since they are not panels that can have variables
const widgets = Object.values(
(dashboardData?.data?.widgets || []).reduce(
(acc: Record<string, WidgetRow | Widgets>, widget: WidgetRow | Widgets) => {
if (
widget.id &&
layoutIds.has(widget.id) &&
widget.panelTypes !== PANEL_GROUP_TYPES.ROW
) {
acc[widget.id] = widget;
}
return acc;
},
{},
),
);
// Filter selectedWidgets to only include widgets that are present in the current layout
const validSelectedWidgets = selectedWidgets.filter((widgetId) =>
layoutIds.has(widgetId),
);
// Update selectedWidgets if any invalid widgets were removed
React.useEffect(() => {
if (validSelectedWidgets.length !== selectedWidgets.length) {
setSelectedWidgets(validSelectedWidgets);
}
}, [validSelectedWidgets, selectedWidgets.length, setSelectedWidgets]);
return (
<CustomMultiSelect
placeholder="Select Panels"
options={widgets.map((widget: WidgetRow | Widgets) => ({
label: generateGridTitle(widget.title),
value: widget.id,
}))}
value={validSelectedWidgets}
onChange={(value): void => setSelectedWidgets(value as string[])}
showLabels
/>
);
}

View File

@@ -1,11 +0,0 @@
import { Row } from 'antd';
import styled from 'styled-components';
export const VariableItemRow = styled(Row)`
gap: 1rem;
margin-bottom: 1rem;
`;
export const LabelContainer = styled.div`
width: 200px;
`;

View File

@@ -1,328 +0,0 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Dashboard } from 'types/api/dashboard/getAll';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard';
// ---------------------------------------------------------------------------
// Shared fixture helpers
// ---------------------------------------------------------------------------
const EMPTY_BUILDER = {
queryData: [] as any,
queryFormulas: [],
queryTraceOperator: [],
};
const BASE_WIDGET = {
opacity: '1',
nullZeroValues: 'null',
timePreferance: 'GLOBAL_TIME' as const,
softMin: null,
softMax: null,
selectedLogFields: null,
selectedTracesFields: null,
};
const DEFAULT_QUERY_DATA = {
queryName: 'q1',
// In QB v5, expression holds the query label (A/B/C), not a filter expression
expression: 'A',
dataSource: DataSource.METRICS,
functions: [],
groupBy: [],
filters: { items: [] as any[], op: 'AND' as const },
legend: '',
disabled: false,
having: [],
limit: null,
stepInterval: null,
orderBy: [],
selectColumns: [],
source: '' as const,
};
/**
* Build a dashboard with a single builder widget.
* Only supply the fields your test actually cares about.
*/
const buildBuilderDashboard = (
filterExpression: string,
queryDataOverrides: Record<string, any> = {},
): Dashboard => ({
id: 'dash1',
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: {
title: 'Test Dashboard',
widgets: [
{
...BASE_WIDGET,
id: 'widget-1',
panelTypes: PANEL_TYPES.TIME_SERIES,
title: 'Widget 1',
description: '',
query: {
id: 'query1',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
{
...DEFAULT_QUERY_DATA,
...queryDataOverrides,
filter: { expression: filterExpression },
},
],
queryFormulas: [],
queryTraceOperator: [],
},
unit: '',
},
},
],
variables: {},
},
});
const buildClickhouseDashboard = (query: string): Dashboard => ({
id: 'dash-ch',
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: {
title: 'CH',
widgets: [
{
...BASE_WIDGET,
id: 'w1',
panelTypes: PANEL_TYPES.TIME_SERIES,
title: '',
description: '',
query: {
id: 'q1',
queryType: EQueryType.CLICKHOUSE,
promql: [],
clickhouse_sql: [{ name: 'A', query, legend: '', disabled: false }],
builder: EMPTY_BUILDER,
unit: '',
},
},
],
variables: {},
},
});
const buildPromqlDashboard = (query: string): Dashboard => ({
id: 'dash-prom',
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: {
title: 'PromQL Dashboard',
widgets: [
{
...BASE_WIDGET,
id: 'widget-prom',
panelTypes: PANEL_TYPES.TIME_SERIES,
title: 'PromQL Widget',
description: '',
query: {
id: 'query-prom',
queryType: EQueryType.PROM,
promql: [{ name: 'A', query, legend: '', disabled: false }],
clickhouse_sql: [],
builder: EMPTY_BUILDER,
unit: '',
},
},
],
variables: {},
},
});
/** Run removeVariableReferencesFromDashboard on a single-widget clickhouse dashboard and return the cleaned SQL. */
const chQuery = (sql: string, varName: string): string => {
const result = removeVariableReferencesFromDashboard(
buildClickhouseDashboard(sql),
varName,
);
return (result!.data.widgets![0] as any).query.clickhouse_sql[0].query;
};
/** Extract the first builder queryData from a cleaned dashboard. */
const firstBuilderQueryData = (dashboard: Dashboard | undefined): any =>
(dashboard!.data.widgets![0] as any).query.builder.queryData[0];
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('removeVariableReferencesFromDashboard', () => {
describe('builder filter expression cleanup', () => {
it('removes a variable clause from filter.expression', () => {
const dashboard = buildBuilderDashboard(
"service.name IN $service AND env = 'prod'",
);
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
expect(firstBuilderQueryData(result).filter.expression).toBe("env = 'prod'");
});
it('leaves no dangling AND/OR after removing a variable clause', () => {
const dashboard = buildBuilderDashboard(
"service.name IN $service AND env = 'prod'",
);
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
const { expression } = firstBuilderQueryData(result).filter;
expect(expression).toBe("env = 'prod'");
expect(expression).not.toMatch(/^\s*(AND|OR)/i);
expect(expression).not.toMatch(/(AND|OR)\s*$/i);
});
it('does not remove $environment clause when deleting $env', () => {
const dashboard = buildBuilderDashboard(
'env = $env AND deployment.environment = $environment',
);
const result = removeVariableReferencesFromDashboard(dashboard, 'env');
expect(firstBuilderQueryData(result).filter.expression).toBe(
'deployment.environment = $environment',
);
});
it('leaves literal filter expressions untouched when removing a variable', () => {
const dashboard = buildBuilderDashboard(
"service.name = 'api-gateway' AND env = 'prod'",
);
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
expect(firstBuilderQueryData(result).filter.expression).toBe(
"service.name = 'api-gateway' AND env = 'prod'",
);
});
it('removes only the variable clause, preserving a literal clause on the same key', () => {
const dashboard = buildBuilderDashboard(
"service.name IN $service AND service.name = 'api-gateway'",
);
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
expect(firstBuilderQueryData(result).filter.expression).toBe(
"service.name = 'api-gateway'",
);
});
it('returns filter.expression unchanged when the variable has no clauses in it', () => {
const dashboard = buildBuilderDashboard("env = 'prod'");
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
expect(firstBuilderQueryData(result).filter.expression).toBe("env = 'prod'");
});
});
describe('PromQL query cleanup', () => {
it('removes variable placeholder from a promql query', () => {
const result = removeVariableReferencesFromDashboard(
buildPromqlDashboard('sum(rate(http_requests_total{$service}[5m]))'),
'service',
);
const widget = result!.data.widgets![0] as any;
expect(widget.query.promql[0].query).toBe(
'sum(rate(http_requests_total{}[5m]))',
);
});
it('strips only the variable token inside a PromQL label matcher (token-only path)', () => {
const result = removeVariableReferencesFromDashboard(
buildPromqlDashboard('up{env="$env", job="api"}'),
'env',
);
const widget = result!.data.widgets![0] as any;
expect(widget.query.promql[0].query).toBe('up{env="", job="api"}');
});
});
describe('ClickHouse SQL query cleanup', () => {
it('removes a quoted variable clause and its WHERE keyword', () => {
expect(
chQuery(
"SELECT count() FROM signoz_logs WHERE service_name = '$service'",
'service',
),
).toBe('SELECT count() FROM signoz_logs');
});
it('removes a middle clause: AND env={{.env}} AND', () => {
expect(
chQuery('SELECT count() FROM t WHERE a=1 AND env={{.env}} AND b=2', 'env'),
).toBe('SELECT count() FROM t WHERE a=1 AND b=2');
});
it('removes the first clause: env={{.env}} AND rest', () => {
expect(
chQuery('SELECT count() FROM t WHERE env={{.env}} AND b=2', 'env'),
).toBe('SELECT count() FROM t WHERE b=2');
});
it('removes the last clause: rest AND env=$env', () => {
expect(chQuery('SELECT count() FROM t WHERE a=1 AND env=$env', 'env')).toBe(
'SELECT count() FROM t WHERE a=1',
);
});
it('removes a clause with double-bracket syntax: service=[[svc]]', () => {
expect(chQuery('SELECT count() FROM t WHERE service=[[svc]]', 'svc')).toBe(
'SELECT count() FROM t',
);
});
it('falls back to token-only strip for a bare variable in SELECT', () => {
expect(chQuery('SELECT $metric FROM table', 'metric')).toBe(
'SELECT FROM table',
);
});
});
describe('edge cases', () => {
it('is idempotent — calling twice produces the same result', () => {
const dashboard = buildBuilderDashboard(
"service.name IN $service AND env = 'prod'",
);
const once = removeVariableReferencesFromDashboard(dashboard, 'service');
const twice = removeVariableReferencesFromDashboard(once, 'service');
expect(twice).toStrictEqual(once);
});
it('handles a dashboard with no widgets without throwing', () => {
const dashboard: Dashboard = {
id: 'dash-empty',
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: { title: 'Empty Dashboard', widgets: undefined, variables: {} },
};
expect(() =>
removeVariableReferencesFromDashboard(dashboard, 'service'),
).not.toThrow();
});
});
});

View File

@@ -1,336 +0,0 @@
import {
convertFiltersToExpressionWithExistingQuery,
createVariablePlaceholderRegExp,
removeKeysFromExpression,
removeVariableFromExpression,
} from 'components/QueryBuilderV2/utils';
import { cloneDeep, isArray, isEmpty } from 'lodash-es';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
import {
IBuilderQuery,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
/**
* Updates the query filters in a builder query by appending new tag filters
*/
const updateQueryFilters = (
queryData: IBuilderQuery,
filter: TagFilterItem,
): IBuilderQuery => {
const existingFilters = queryData.filters?.items || [];
// addition | update
const currentFilterKey = filter.key?.key;
const valueToAdd = filter.value.toString();
const newItems: TagFilterItem[] = [];
existingFilters.forEach((existingFilter) => {
const newFilter = cloneDeep(existingFilter);
if (
newFilter.key?.key === currentFilterKey &&
!(isArray(newFilter.value) && newFilter.value.includes(valueToAdd)) &&
newFilter.value !== valueToAdd
) {
if (isEmpty(newFilter.value)) {
newFilter.value = valueToAdd;
newFilter.op = 'IN';
} else {
newFilter.value = (
isArray(newFilter.value)
? [...newFilter.value, valueToAdd]
: [newFilter.value, valueToAdd]
) as string[] | string;
newFilter.op = 'IN';
}
}
newItems.push(newFilter);
});
// if yet the filter key doesn't get added then add it
if (!newItems.find((item) => item.key?.key === currentFilterKey)) {
newItems.push(filter);
}
const newFilterToUpdate = {
...queryData.filters,
items: newItems,
op: queryData.filters?.op || 'AND',
};
return {
...queryData,
...convertFiltersToExpressionWithExistingQuery(
newFilterToUpdate,
queryData.filter?.expression,
),
};
};
/**
* Updates a single widget by adding filters to its query
*/
const updateSingleWidget = (
widget: Widgets,
filter: TagFilterItem,
): Widgets => {
if (!widget.query?.builder?.queryData || isEmpty(filter)) {
return widget;
}
return {
...widget,
query: {
...widget.query,
builder: {
...widget.query.builder,
queryData: widget.query.builder.queryData.map((queryData) =>
updateQueryFilters(queryData, filter),
),
},
},
};
};
const removeIfPresent = (
queryData: IBuilderQuery,
filter: TagFilterItem,
): IBuilderQuery => {
const existingFilters = queryData.filters?.items || [];
// addition | update
const currentFilterKey = filter.key?.key;
const valueToAdd = filter.value.toString();
const newItems: TagFilterItem[] = [];
existingFilters.forEach((existingFilter) => {
const newFilter = cloneDeep(existingFilter);
if (newFilter.key?.key === currentFilterKey) {
if (isArray(newFilter.value) && newFilter.value.includes(valueToAdd)) {
newFilter.value = newFilter.value.filter((value) => value !== valueToAdd);
} else if (newFilter.value === valueToAdd) {
return;
}
}
newItems.push(newFilter);
});
return {
...queryData,
filters: {
...queryData.filters,
items: newItems,
op: queryData.filters?.op || 'AND',
},
filter: {
...queryData.filter,
expression: removeKeysFromExpression(
queryData.filter?.expression ?? '',
filter.key?.key ? [filter.key.key] : [],
true,
),
},
};
};
const updateAfterRemoval = (
widget: Widgets,
filter: TagFilterItem,
): Widgets => {
if (!widget.query?.builder?.queryData || isEmpty(filter)) {
return widget;
}
// remove the filters where the current filter is available as value as this widget is not selected anymore, hence removal
return {
...widget,
query: {
...widget.query,
builder: {
...widget.query.builder,
queryData: widget.query.builder.queryData.map((queryData) =>
removeIfPresent(queryData, filter),
),
},
},
};
};
const removeVariablePlaceholders = (
text: string | undefined,
variableName: string,
): string => {
if (!text) {
return '';
}
const tokenPattern = createVariablePlaceholderRegExp(variableName);
// Step 1: attempt clause-aware removal for SQL WHERE patterns.
// Strips the entire `key op $var` unit plus its adjacent AND/OR so we
// never leave a dangling `key = ` in unquoted ClickHouse SQL clauses.
// Handles three shapes:
// (a) preceding conjunction: AND key = $var
// (b) following conjunction: key = $var AND
// (c) standalone clause: key = $var (end of expression)
const escapedToken = tokenPattern.source;
const clausePattern = new RegExp(
// (a) conjunction before the clause
`\\s*\\b(?:AND|OR)\\b\\s+[\\w."'\\[\\]]+\\s*(?:=|!=|<>|LIKE|ILIKE|IN|NOT\\s+IN)\\s*'?${escapedToken}'?` +
// (b)+(c) clause first, optional conjunction after
`|[\\w."'\\[\\]]+\\s*(?:=|!=|<>|LIKE|ILIKE|IN|NOT\\s+IN)\\s*'?${escapedToken}'?(?:\\s*\\b(?:AND|OR)\\b)?`,
'gi',
);
const withClauseRemoval = text.replace(clausePattern, '');
if (withClauseRemoval !== text) {
return withClauseRemoval
.replace(/\s{2,}/g, ' ')
.replace(/\bWHERE\s*$/i, '')
.trim();
}
// Step 2: fallback — bare variable usage outside a key-op-value pattern
// (e.g. SELECT $metric, LIMIT $n). Token-only removal is correct here.
return text
.replace(tokenPattern, '')
.replace(/\s{2,}/g, ' ')
.trim();
};
const removeVariableReferencesFromQueryData = (
queryData: IBuilderQuery,
variableName: string,
): IBuilderQuery => {
const updatedFilter = queryData.filter?.expression
? {
...queryData.filter,
expression: removeVariableFromExpression(
queryData.filter.expression,
variableName,
),
}
: queryData.filter;
return { ...queryData, filter: updatedFilter };
};
const removeVariableReferencesFromWidget = (
widget: Widgets,
variableName: string,
): Widgets => {
let updatedWidget = { ...widget };
if (updatedWidget.query?.builder?.queryData) {
updatedWidget = {
...updatedWidget,
query: {
...updatedWidget.query,
builder: {
...updatedWidget.query.builder,
queryData: updatedWidget.query.builder.queryData.map((queryData) =>
removeVariableReferencesFromQueryData(queryData, variableName),
),
},
},
};
}
if (updatedWidget.query?.promql) {
updatedWidget = {
...updatedWidget,
query: {
...updatedWidget.query,
promql: updatedWidget.query.promql.map((promqlQuery) => ({
...promqlQuery,
query: removeVariablePlaceholders(promqlQuery.query, variableName),
})),
},
};
}
if (updatedWidget.query?.clickhouse_sql) {
updatedWidget = {
...updatedWidget,
query: {
...updatedWidget.query,
clickhouse_sql: updatedWidget.query.clickhouse_sql.map((sqlQuery) => ({
...sqlQuery,
query: removeVariablePlaceholders(sqlQuery.query, variableName),
})),
},
};
}
return updatedWidget;
};
export const removeVariableReferencesFromDashboard = (
dashboard: Dashboard | undefined,
variableName: string,
): Dashboard | undefined => {
if (!dashboard || !variableName) {
return dashboard;
}
const updatedDashboard = cloneDeep(dashboard);
if (updatedDashboard.data.widgets) {
updatedDashboard.data.widgets = updatedDashboard.data.widgets.map(
(widget) => {
if ('query' in widget) {
return removeVariableReferencesFromWidget(widget as Widgets, variableName);
}
return widget;
},
);
}
return updatedDashboard;
};
/**
* A function that takes a dashboard configuration and a list of tag filters
* and returns an updated dashboard with the filters appended to widget queries.
*
* @param dashboard The dashboard configuration
* @param filters Array of tag filters to apply to widgets
* @param widgetIds Optional array of widget IDs to filter which widgets get updated
* @returns Updated dashboard configuration with filters applied
*/
export const addTagFiltersToDashboard = (
dashboard: Dashboard | undefined,
filter: TagFilterItem,
widgetIds?: string[],
applyToAll?: boolean,
): Dashboard | undefined => {
if (!dashboard || isEmpty(filter)) {
return dashboard;
}
// Create a deep copy to avoid mutating the original dashboard
const updatedDashboard = cloneDeep(dashboard);
// Process each widget to add filters
if (updatedDashboard.data.widgets) {
updatedDashboard.data.widgets = updatedDashboard.data.widgets.map(
(widget) => {
// Only apply to widgets with 'query' property
if ('query' in widget) {
// If widgetIds is provided, only update widgets with matching IDs
if (!applyToAll && widgetIds && !widgetIds.includes(widget.id)) {
// removal if needed
return updateAfterRemoval(widget as Widgets, filter);
}
return updateSingleWidget(widget as Widgets, filter);
}
return widget;
},
);
}
return updatedDashboard;
};

View File

@@ -1,537 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { GripVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
import type { DragEndEvent, UniqueIdentifier } from '@dnd-kit/core';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { arrayMove, SortableContext, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button, Modal, Row, RowProps, Space, Table, Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { VariablesSettingsTabHandle } from 'container/DashboardContainer/DashboardDescription/types';
import { convertVariablesToDbFormat } from 'container/DashboardContainer/DashboardVariablesSelection/util';
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { toast } from '@signozhq/ui/sonner';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard';
import { TVariableMode } from './types';
import VariableItem from './VariableItem/VariableItem';
import '../DashboardSettings.styles.scss';
function TableRow({ children, ...props }: RowProps): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
// @ts-expect-error
id: props['data-row-key'],
});
const style: React.CSSProperties = {
...props.style,
transform: CSS.Transform.toString(transform && { ...transform, scaleY: 1 }),
transition,
...(isDragging ? { position: 'relative', zIndex: 9999 } : {}),
};
return (
<tr {...props} ref={setNodeRef} style={style} {...attributes}>
{React.Children.map(children, (child) => {
const childElement = child as React.ReactElement;
if (childElement.key === 'name') {
return React.cloneElement(childElement, {
key: 'name-with-drag',
children: (
<div className="variable-name-drag">
<GripVertical
ref={setActivatorNodeRef as unknown as React.Ref<SVGSVGElement>}
style={{ touchAction: 'none', cursor: 'move' }}
size="md"
{...listeners}
/>
{child}
</div>
),
});
}
return childElement;
})}
</tr>
);
}
function VariablesSettings({
variablesSettingsTabHandle,
}: {
variablesSettingsTabHandle: VariablesSettingsTabHandle;
}): JSX.Element {
const variableToDelete = useRef<IDashboardVariable | null>(null);
const [deleteVariableModal, setDeleteVariableModal] = useState(false);
const variableToApplyToAll = useRef<IDashboardVariable | null>(null);
const [applyToAllModal, setApplyToAllModal] = useState(false);
const { t } = useTranslation(['dashboard']);
const { dashboardData, setDashboardData } = useDashboardStore();
const { dashboardVariables } = useDashboardVariables();
const [variablesTableData, setVariablesTableData] = useState<any>([]);
const [variblesOrderArr, setVariablesOrderArr] = useState<number[]>([]);
const [existingVariableNamesMap, setExistingVariableNamesMap] = useState<
Record<string, string>
>({});
const [variableViewMode, setVariableViewMode] = useState<null | TVariableMode>(
null,
);
const [variableEditData, setVariableEditData] =
useState<null | IDashboardVariable>(null);
const onDoneVariableViewMode = (): void => {
setVariableViewMode(null);
setVariableEditData(null);
};
const onVariableViewModeEnter = (
viewType: TVariableMode,
varData: IDashboardVariable,
): void => {
setVariableEditData(varData);
setVariableViewMode(viewType);
};
useEffect(() => {
if (!variablesSettingsTabHandle) {
return;
}
variablesSettingsTabHandle.current = {
resetState: onDoneVariableViewMode,
};
}, [variablesSettingsTabHandle]);
const updateMutation = useUpdateDashboard();
const addDynamicVariableToPanels = useAddDynamicVariableToPanels();
useEffect(() => {
const tableRowData = [];
const variableOrderArr = [];
const variableNamesMap = {};
for (const [key, value] of Object.entries(dashboardVariables)) {
const { order, id, name } = value;
tableRowData.push({
key,
name: key,
...dashboardVariables[key],
id,
});
if (name) {
// @ts-expect-error
variableNamesMap[name] = name;
}
if (order) {
variableOrderArr.push(order);
}
}
tableRowData.sort((a, b) => a.order - b.order);
variableOrderArr.sort((a, b) => a - b);
setVariablesTableData(tableRowData);
setVariablesOrderArr(variableOrderArr);
setExistingVariableNamesMap(variableNamesMap);
}, [dashboardVariables]);
const updateVariables = (
updatedVariablesData: IDashboardVariables,
currentRequestedId?: string,
widgetIds?: string[],
applyToAll?: boolean,
): void => {
if (!dashboardData) {
return;
}
const newDashboard =
(currentRequestedId &&
updatedVariablesData[currentRequestedId || '']?.type === 'DYNAMIC' &&
addDynamicVariableToPanels(
dashboardData,
updatedVariablesData[currentRequestedId || ''],
widgetIds,
applyToAll,
)) ||
dashboardData;
updateMutation.mutateAsync(
{
id: dashboardData.id,
data: {
...newDashboard.data,
variables: updatedVariablesData,
},
},
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
toast.success(t('variable_updated_successfully'));
}
},
},
);
};
const getVariableOrder = (): number => {
if (variblesOrderArr && variblesOrderArr.length > 0) {
return variblesOrderArr[variblesOrderArr.length - 1] + 1;
}
return 0;
};
const onVariableSaveHandler = (
mode: TVariableMode,
variableData: IDashboardVariable,
widgetIds?: string[],
applyToAll?: boolean,
): void => {
const updatedVariableData = {
...variableData,
order: variableData?.order >= 0 ? variableData.order : getVariableOrder(),
};
const newVariablesArr = variablesTableData.map(
(variable: IDashboardVariable) => {
if (variable.id === updatedVariableData.id) {
return updatedVariableData;
}
return variable;
},
);
if (mode === 'ADD') {
newVariablesArr.push(updatedVariableData);
}
const variables = convertVariablesToDbFormat(newVariablesArr);
setVariablesTableData(newVariablesArr);
updateVariables(variables, variableData?.id, widgetIds, applyToAll);
onDoneVariableViewMode();
};
const onVariableDeleteHandler = (variable: IDashboardVariable): void => {
variableToDelete.current = variable;
setDeleteVariableModal(true);
};
const handleDeleteConfirm = (): void => {
if (!dashboardData || !variableToDelete.current) {
setDeleteVariableModal(false);
return;
}
const newVariablesArr = variablesTableData.filter(
(variable: IDashboardVariable) =>
variable.id !== variableToDelete?.current?.id,
);
const updatedVariables = convertVariablesToDbFormat(newVariablesArr);
const cleanedDashboard =
removeVariableReferencesFromDashboard(
dashboardData,
variableToDelete.current.name || '',
) || dashboardData;
updateMutation.mutateAsync(
{
id: dashboardData.id,
data: {
...cleanedDashboard.data,
variables: updatedVariables,
},
},
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
toast.success(t('variable_updated_successfully'));
}
},
},
);
variableToDelete.current = null;
setDeleteVariableModal(false);
};
const handleDeleteCancel = (): void => {
variableToDelete.current = null;
setDeleteVariableModal(false);
};
const onApplyToAllHandler = (variable: IDashboardVariable): void => {
variableToApplyToAll.current = variable;
setApplyToAllModal(true);
};
const handleApplyToAllConfirm = (): void => {
if (variableToApplyToAll.current) {
onVariableSaveHandler(
variableViewMode || 'EDIT',
variableToApplyToAll.current,
[],
true,
);
}
variableToApplyToAll.current = null;
setApplyToAllModal(false);
};
const handleApplyToAllCancel = (): void => {
variableToApplyToAll.current = null;
setApplyToAllModal(false);
};
const validateVariableName = (name: string): boolean =>
!existingVariableNamesMap[name];
const validateAttributeKey = (
attributeKey: string,
currentVariableId?: string,
): boolean => {
// Check if any other dynamic variable already uses this attribute key
const isDuplicateAttributeKey = Object.values(dashboardVariables).some(
(variable: IDashboardVariable) =>
variable.type === 'DYNAMIC' &&
variable.dynamicVariablesAttribute === attributeKey &&
variable.id !== currentVariableId, // Exclude current variable being edited
);
return !isDuplicateAttributeKey;
};
const columns = [
{
title: 'Variable',
dataIndex: 'name',
width: '50%',
key: 'name',
},
{
title: 'Description',
width: '50%',
key: 'description',
render: (variable: IDashboardVariable): JSX.Element => (
<div className="variable-description-actions">
<Typography.Text className="variable-description">
{variable.description}
</Typography.Text>
<Space className="actions-btns">
{variable.type === 'DYNAMIC' && (
<Button
type="text"
onClick={(): void => onApplyToAllHandler(variable)}
className="apply-to-all-button"
loading={updateMutation.isLoading}
>
<Typography.Text>Apply to all</Typography.Text>
</Button>
)}
<Button
type="text"
onClick={(): void => onVariableViewModeEnter('EDIT', variable)}
className="edit-variable-button"
>
<PenLine size={14} />
</Button>
<Button
type="text"
onClick={(): void => {
if (variable) {
onVariableDeleteHandler(variable);
}
}}
className="delete-variable-button"
>
<Trash2 size={14} />
</Button>
</Space>
</div>
),
},
];
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
// https://docs.dndkit.com/api-documentation/sensors/pointer#activation-constraints
distance: 1,
},
}),
);
const onDragEnd = ({ active, over }: DragEndEvent): void => {
if (active.id !== over?.id) {
const activeIndex = variablesTableData.findIndex(
(i: { key: UniqueIdentifier }) => i.key === active.id,
);
const overIndex = variablesTableData.findIndex(
(i: { key: UniqueIdentifier | undefined }) => i.key === over?.id,
);
const updatedVariables: IDashboardVariable[] = arrayMove(
variablesTableData,
activeIndex,
overIndex,
);
const reArrangedVariables = {};
for (let index = 0; index < updatedVariables.length; index += 1) {
const variableName = updatedVariables[index].name;
if (variableName) {
// @ts-expect-error
reArrangedVariables[variableName] = {
...updatedVariables[index],
order: index,
};
}
}
updateVariables(reArrangedVariables);
setVariablesTableData(updatedVariables);
}
};
return (
<>
{variableViewMode ? (
<VariableItem
variableData={{ ...variableEditData } as IDashboardVariable}
existingVariables={dashboardVariables}
onSave={onVariableSaveHandler}
onCancel={onDoneVariableViewMode}
validateName={validateVariableName}
validateAttributeKey={validateAttributeKey}
mode={variableViewMode}
/>
) : (
<>
<Row
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
padding: '0.5rem 0',
position: 'absolute',
top: '-56px',
right: '0px',
zIndex: '1',
}}
>
<Button
data-testid="add-new-variable"
type="primary"
onClick={(): void =>
onVariableViewModeEnter('ADD', {} as IDashboardVariable)
}
>
<Flex align="center" justify="center" gap={4}>
<Plus size="md" /> Add Variable
</Flex>
</Button>
</Row>
<DndContext
sensors={sensors}
modifiers={[restrictToVerticalAxis]}
onDragEnd={onDragEnd}
>
<SortableContext
// rowKey array
items={variablesTableData.map((variable: { key: any }) => variable.key)}
>
<Table
components={{
body: {
row: TableRow,
},
}}
rowKey="key"
columns={columns}
pagination={false}
dataSource={variablesTableData}
className="dashboard-variable-settings-table"
/>
</SortableContext>
</DndContext>
</>
)}
<Modal
title="Delete variable"
centered
open={deleteVariableModal}
onOk={handleDeleteConfirm}
onCancel={handleDeleteCancel}
okButtonProps={{ loading: updateMutation.isLoading }}
>
<Typography.Text>
Are you sure you want to delete variable{' '}
<span className="delete-variable-name">
{variableToDelete?.current?.name}
</span>
?
</Typography.Text>
</Modal>
<Modal
title="Apply variable to all panels"
centered
open={applyToAllModal}
onOk={handleApplyToAllConfirm}
onCancel={handleApplyToAllCancel}
okText="Apply to all"
cancelText="Cancel"
>
<Typography.Text>
Are you sure you want to apply variable{' '}
<span className="apply-to-all-variable-name">
{variableToApplyToAll?.current?.name}
</span>{' '}
to all panels? This action may affect panels where this variable is not
applicable.
</Typography.Text>
</Modal>
</>
);
}
export default VariablesSettings;

View File

@@ -1,7 +0,0 @@
export type TVariableMode = 'VIEW' | 'EDIT' | 'ADD';
export const VariableModes = {
VIEW: 'VIEW',
EDIT: 'EDIT',
ADD: 'ADD',
};

View File

@@ -1,49 +0,0 @@
.badgesContainer {
display: flex;
align-items: center;
flex-flow: wrap;
gap: 6px;
}
.badgeContainer {
color: var(--bg-sienna-400);
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.52px;
height: 24px;
flex-shrink: 0;
border-radius: 50px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
padding: 2px 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.inputContainer {
> div {
margin: 0;
}
padding-left: 0px !important;
padding-right: 0px !important;
}
.tagsInput {
display: flex;
border: none;
padding: 0px;
width: 183px;
height: 24px;
padding: 3px 1px;
flex-shrink: 0;
}
.editInput {
.ant-form-item {
margin-bottom: 0px;
}
}

View File

@@ -1,115 +0,0 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { Col, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import Input from 'components/Input';
import styles from './AddBadges.module.scss';
function AddTags({ tags, setTags }: AddTagsProps): JSX.Element {
const [inputValue, setInputValue] = useState<string>('');
const [editInputIndex, setEditInputIndex] = useState(-1);
const [editInputValue, setEditInputValue] = useState('');
const handleInputConfirm = (): void => {
if (inputValue) {
setTags([...tags, inputValue]);
}
setInputValue('');
};
const handleEditInputConfirm = (): void => {
const newTags = [...tags];
newTags[editInputIndex] = editInputValue;
setTags(newTags);
setEditInputIndex(-1);
setInputValue('');
};
const handleClose = (removedTag: string): void => {
const newTags = tags.filter((tag) => tag !== removedTag);
setTags(newTags);
};
const onChangeHandler = (
value: string,
func: Dispatch<SetStateAction<string>>,
): void => {
func(value);
};
return (
<div className={styles.badgesContainer}>
{tags.map((tag, index) => {
if (editInputIndex === index) {
return (
<Col key={tag} lg={4} className={styles.editInput}>
<Input
size="small"
value={editInputValue}
onChangeHandler={(event): void =>
onChangeHandler(event.target.value, setEditInputValue)
}
onBlurHandler={handleEditInputConfirm}
onPressEnterHandler={handleEditInputConfirm}
/>
</Col>
);
}
const isLongTag = tag.length > 20;
const tagElem = (
<Badge
key={tag}
color="vanilla"
className={styles.badgeContainer}
closable
onClose={(e): void => {
e.preventDefault();
handleClose(tag);
}}
>
<span
onDoubleClick={(e): void => {
setEditInputIndex(index);
setEditInputValue(tag);
e.preventDefault();
}}
>
{isLongTag ? `${tag.slice(0, 20)}...` : tag}
</span>
</Badge>
);
return isLongTag ? (
<Tooltip title={tag} key={tag}>
{tagElem}
</Tooltip>
) : (
tagElem
);
})}
<Col className={styles.inputContainer}>
<Input
type="text"
value={inputValue}
rootClassName={styles.tagsInput}
placeholder="Start typing your tag name"
onChangeHandler={(event): void =>
onChangeHandler(event.target.value, setInputValue)
}
onBlurHandler={handleInputConfirm}
onPressEnterHandler={handleInputConfirm}
/>
</Col>
</div>
);
}
interface AddTagsProps {
tags: string[];
setTags: Dispatch<SetStateAction<string[]>>;
}
export default AddTags;

View File

@@ -1,227 +0,0 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 16px;
}
.overviewSettings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
}
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.crossPanelSyncSectionTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.crossPanelSyncSectionHeader {
display: flex;
align-items: center;
gap: 6px;
align-self: flex-start;
}
.crossPanelSyncInfoIcon {
cursor: help;
color: var(--l3-foreground);
}
.crossPanelSyncTooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 300px;
}
.crossPanelSyncTooltipTitle {
font-size: 14px;
}
.crossPanelSyncTooltipDescription {
font-size: 12px;
line-height: 1.5;
}
.crossPanelSyncTooltipDocLink {
display: flex;
align-items: center;
gap: 4px;
color: var(--primary-background);
font-size: 12px;
margin-top: 4px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px solid var(--l1-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
.nameIconInput {
display: flex;
}
.dashboardImageInput {
:global(.ant-select-selector) {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l3-background) !important;
:global(.ant-select-selection-item) {
display: flex;
align-items: center;
}
}
&:global(.ant-select-dropdown) {
padding: 0px !important;
}
:global(.ant-select-item) {
padding: 0px;
align-items: center;
justify-content: center;
:global(.ant-select-item-option-content) {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.dashboardName {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
margin-bottom: 0.5rem;
}
.descriptionTextArea {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.overviewSettingsFooter {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
}
.unsaved {
display: flex;
align-items: center;
gap: 8px;
}
.unsavedDot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsavedChanges {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.footerActionBtns {
display: flex;
gap: 8px;
}
.discardBtn {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.saveBtn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@@ -1,299 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Col, Input, Select, Space, Tooltip } from 'antd';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Typography } from '@signozhq/ui/typography';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, ExternalLink, SolidInfoCircle, X } from '@signozhq/icons';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import styles from './GeneralSettings.module.scss';
import { Button } from './styles';
import { Base64Icons } from './utils';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { getAbsoluteUrl } from 'utils/basePath';
const { Option } = Select;
function GeneralDashboardSettings(): JSX.Element {
const { dashboardData, setDashboardData } = useDashboardStore();
const updateDashboardMutation = useUpdateDashboard();
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(
dashboardData?.id,
);
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
useSyncTooltipFilterMode(dashboardData?.id);
const selectedData = dashboardData?.data;
const {
title = '',
tags = [],
description = '',
image = Base64Icons[0],
} = selectedData || {};
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const [updatedTags, setUpdatedTags] = useState<string[]>(tags || []);
const [updatedDescription, setUpdatedDescription] = useState(
description || '',
);
const [updatedImage, setUpdatedImage] = useState<string>(image);
const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] =
useState<number>(0);
const { t } = useTranslation('common');
const onSaveHandler = (): void => {
if (!dashboardData) {
return;
}
updateDashboardMutation.mutate(
{
id: dashboardData.id,
data: {
...dashboardData.data,
description: updatedDescription,
tags: updatedTags,
title: updatedTitle,
image: updatedImage,
},
},
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
}
},
onError: () => {},
},
);
};
useEffect(() => {
let numberOfUnsavedChanges = 0;
const initialValues = [title, description, tags, image];
const updatedValues = [
updatedTitle,
updatedDescription,
updatedTags,
updatedImage,
];
initialValues.forEach((val, index) => {
if (!isEqual(val, updatedValues[index])) {
numberOfUnsavedChanges += 1;
}
});
setNumberOfUnsavedChanges(numberOfUnsavedChanges);
}, [
description,
image,
tags,
title,
updatedDescription,
updatedImage,
updatedTags,
updatedTitle,
]);
const discardHandler = (): void => {
setUpdatedTitle(title);
setUpdatedImage(image);
setUpdatedTags(tags);
setUpdatedDescription(description);
};
return (
<div className={styles.overviewContent}>
<Col className={styles.overviewSettings}>
<Space
direction="vertical"
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: '21px',
}}
>
<div>
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
defaultActiveFirstOption
data-testid="dashboard-image"
suffixIcon={null}
rootClassName={styles.dashboardImageInput}
value={updatedImage}
onChange={(value: string): void => setUpdatedImage(value)}
>
{Base64Icons.map((icon) => (
<Option value={icon} key={icon}>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</Option>
))}
</Select>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
value={updatedTitle}
onChange={(e): void => setUpdatedTitle(e.target.value)}
/>
</section>
</div>
<div>
<Typography className={styles.dashboardName}>Description</Typography>
<Input.TextArea
data-testid="dashboard-desc"
rows={6}
value={updatedDescription}
className={styles.descriptionTextArea}
onChange={(e): void => setUpdatedDescription(e.target.value)}
/>
</div>
<div>
<Typography className={styles.dashboardName}>Tags</Typography>
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
</div>
</Space>
</Col>
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
<div className={styles.crossPanelSyncSectionHeader}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<Tooltip
title={
<div className={styles.crossPanelSyncTooltipContent}>
<strong className={styles.crossPanelSyncTooltipTitle}>
Cross-Panel Sync
</strong>
<span className={styles.crossPanelSyncTooltipDescription}>
Sync crosshair and tooltip across all the dashboard panels
</span>
<a
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
target="_blank"
rel="noopener noreferrer"
className={styles.crossPanelSyncTooltipDocLink}
>
Learn more
<ExternalLink size={12} />
</a>
</div>
}
placement="top"
mouseEnterDelay={0.5}
>
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
</Tooltip>
</div>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Sync Mode
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<ToggleGroupSimple
type="single"
value={cursorSyncMode}
onChange={(value: string): void => {
setCursorSyncMode(value as DashboardCursorSync);
}}
items={[
{ value: DashboardCursorSync.None, label: 'No Sync' },
{ value: DashboardCursorSync.Crosshair, label: 'Crosshair' },
{ value: DashboardCursorSync.Tooltip, label: 'Tooltip' },
]}
/>
</div>
{cursorSyncMode === DashboardCursorSync.Tooltip && (
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Synced Tooltip Series
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Show only series that intersect on group-by, or every series with the
matching ones highlighted
</Typography.Text>
</div>
<ToggleGroupSimple
type="single"
value={syncTooltipFilterMode}
onChange={(value: string): void => {
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
path: getAbsoluteUrl(window.location.pathname),
mode: value,
});
setSyncTooltipFilterMode(value as SyncTooltipFilterMode);
}}
items={[
{ value: SyncTooltipFilterMode.All, label: 'All' },
{ value: SyncTooltipFilterMode.Filtered, label: 'Filtered' },
]}
/>
</div>
)}
</Col>
{numberOfUnsavedChanges > 0 && (
<div className={styles.overviewSettingsFooter}>
<div className={styles.unsaved}>
<div className={styles.unsavedDot} />
<Typography.Text className={styles.unsavedChanges}>
{numberOfUnsavedChanges} unsaved change
{numberOfUnsavedChanges > 1 && 's'}
</Typography.Text>
</div>
<div className={styles.footerActionBtns}>
<Button
disabled={updateDashboardMutation.isLoading}
icon={<X size={14} />}
onClick={discardHandler}
type="text"
className={styles.discardBtn}
>
Discard
</Button>
<Button
style={{
margin: '16px 0',
}}
disabled={updateDashboardMutation.isLoading}
loading={updateDashboardMutation.isLoading}
icon={<Check size={14} />}
data-testid="save-dashboard-config"
onClick={onSaveHandler}
type="primary"
className={styles.saveBtn}
>
{t('save')}
</Button>
</div>
</div>
)}
</div>
);
}
export default GeneralDashboardSettings;

View File

@@ -1,20 +0,0 @@
import { Button as ButtonComponent, Drawer } from 'antd';
import styled from 'styled-components';
export const Container = styled.div`
margin-top: 0.5rem;
`;
export const Button = styled(ButtonComponent)`
&&& {
display: flex;
align-items: center;
}
`;
export const DrawerContainer = styled(Drawer)`
.ant-drawer-header {
padding: 0;
border: none;
}
`;

File diff suppressed because one or more lines are too long

View File

@@ -1,109 +0,0 @@
.public-dashboard-setting-container {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
.public-dashboard-setting-content {
display: flex;
flex-direction: column;
gap: 8px;
.public-dashboard-setting-content-title {
margin-bottom: 16px;
}
.timerange-enabled-checkbox {
margin-bottom: 8px;
}
.default-time-range-select {
margin-bottom: 8px;
.default-time-range-select-label {
margin-bottom: 4px;
.default-time-range-select-label-text {
font-size: 12px;
font-weight: 500;
}
}
.ant-select-selector {
display: flex;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
.ant-select-selection-item {
display: flex;
align-items: center;
.list-item-image {
height: 16px;
width: 16px;
}
}
}
}
.public-dashboard-url {
.url-label-container {
margin-bottom: 4px;
.url-label {
font-size: 12px;
font-weight: 500;
}
}
.url-container {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
border-radius: 4px;
padding: 0px 4px;
.url-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.default-time-range-select-dropdown {
width: 200px;
}
.public-dashboard-setting-callout {
margin-top: 12px;
background: color-mix(in srgb, var(--primary-background) 10%, transparent);
padding: 12px 8px;
border-radius: 3px;
.public-dashboard-setting-callout-text {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 400;
color: var(--text-robin-300);
}
}
}
.public-dashboard-setting-actions {
margin-top: 32px;
display: flex;
gap: 8px;
justify-content: flex-end;
}
}

View File

@@ -1,434 +0,0 @@
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import { fireEvent, within } from '@testing-library/react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { StatusCodes } from 'http-status-codes';
import {
publishedPublicDashboardMeta,
unpublishedPublicDashboardMeta,
} from 'mocks-server/__mockdata__/publicDashboard';
import { rest, server } from 'mocks-server/server';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { USER_ROLES } from 'types/roles';
import PublicDashboardSetting from '../index';
// Mock dependencies
jest.mock('providers/Dashboard/store/useDashboardStore');
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: jest.fn(),
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
const mockUseDashboard = jest.mocked(useDashboardStore);
const mockUseCopyToClipboard = jest.mocked(useCopyToClipboard);
const mockToast = jest.mocked(toast);
// Test constants
const MOCK_DASHBOARD_ID = 'test-dashboard-id';
const MOCK_PUBLIC_PATH = '/public/dashboard/test-dashboard-id';
const DASHBOARD_VARIABLES_WARNING =
"Dashboard variables won't work in public dashboards";
// Use wildcard pattern to match both relative and absolute URLs in MSW
const publicDashboardURL = `*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`;
const mockDashboardData = {
id: MOCK_DASHBOARD_ID,
data: {
title: 'Test Dashboard',
widgets: [],
layout: [],
panelMap: {},
variables: {},
},
};
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
});
const mockSetCopyPublicDashboardURL = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Mock window.open
window.open = jest.fn();
// Mock useDashboardStore
mockUseDashboard.mockReturnValue({
dashboardData: mockDashboardData,
} as unknown as ReturnType<typeof useDashboardStore>);
// Mock useCopyToClipboard
mockUseCopyToClipboard.mockReturnValue([
undefined,
mockSetCopyPublicDashboardURL,
] as unknown as ReturnType<typeof useCopyToClipboard>);
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
describe('PublicDashboardSetting', () => {
describe('Unpublished Dashboard', () => {
it('Unpublished dashboard should be handled correctly', async () => {
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(
ctx.status(StatusCodes.NOT_FOUND),
ctx.json(unpublishedPublicDashboardMeta),
),
),
);
render(<PublicDashboardSetting />);
await waitFor(() => {
expect(
screen.getByText(
/This dashboard is private. Publish it to make it accessible to anyone with the link./i,
),
).toBeInTheDocument();
});
await expect(
screen.findByRole('checkbox', { name: /enable time range/i }),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/default time range/i),
).resolves.toBeInTheDocument();
expect(screen.getByText(/Last 30 minutes/i)).toBeInTheDocument();
await waitFor(() => {
expect(
screen.getByText(new RegExp(DASHBOARD_VARIABLES_WARNING, 'i')),
).toBeInTheDocument();
});
await expect(
screen.findByRole('button', { name: /publish dashboard/i }),
).resolves.toBeInTheDocument();
});
});
describe('Published Dashboard', () => {
it('Published dashboard should be handled correctly', async () => {
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
);
render(<PublicDashboardSetting />);
await waitFor(() => {
expect(
screen.getByText(
/This dashboard is publicly accessible. Anyone with the link can view it./i,
),
).toBeInTheDocument();
});
await expect(
screen.findByRole('checkbox', { name: /enable time range/i }),
).resolves.toBeChecked();
await waitFor(() => {
expect(screen.getByText(/default time range/i)).toBeInTheDocument();
});
expect(screen.getByText(/Last 30 minutes/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/Public Dashboard URL/i)).toBeInTheDocument();
});
await expect(
screen.findByRole('button', { name: /update published dashboard/i }),
).resolves.toBeInTheDocument();
await expect(
screen.findByRole('button', { name: /unpublish dashboard/i }),
).resolves.toBeInTheDocument();
});
});
describe('Time Range Settings', () => {
beforeEach(() => {
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
);
});
it('should toggle time range enabled when checkbox is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<PublicDashboardSetting />);
// Wait for checkbox to be rendered and verify initial state
const checkbox = await screen.findByRole('checkbox', {
name: /enable time range/i,
});
expect(checkbox).toBeChecked();
await user.click(checkbox);
await waitFor(() => {
expect(checkbox).not.toBeChecked();
});
});
it('should update default time range when select value changes', async () => {
render(<PublicDashboardSetting />);
const selectContainer = await screen.findByTestId(
'default-time-range-select-dropdown',
);
const combobox = within(selectContainer).getByRole('combobox');
fireEvent.mouseDown(combobox);
await screen.findByRole('listbox');
const option = await screen.findByText(/Last 1 hour/i, {
selector: '.ant-select-item-option-content',
});
fireEvent.click(option);
await waitFor(() => {
expect(
within(selectContainer).getByText(/Last 1 hour/i),
).toBeInTheDocument();
});
});
});
describe('Create Public Dashboard', () => {
it('should call create API when publish button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let createApiCalled = false;
server.use(
rest.get(publicDashboardURL, (_req, res, ctx) =>
res(
ctx.status(StatusCodes.OK),
ctx.json({
data: {
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
publicPath: '',
},
}),
),
),
rest.post(publicDashboardURL, async (req, res, ctx) => {
const body = await req.json();
createApiCalled = true;
expect(body).toStrictEqual({
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
});
return res(
ctx.status(StatusCodes.CREATED),
ctx.json({
data: {
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
publicPath: MOCK_PUBLIC_PATH,
},
}),
);
}),
);
render(<PublicDashboardSetting />);
// Find and click publish button
const publishButton = await screen.findByRole('button', {
name: /publish dashboard/i,
});
await user.click(publishButton);
await waitFor(() => {
expect(createApiCalled).toBe(true);
expect(mockToast.success).toHaveBeenCalledWith(
'Public dashboard created successfully',
);
});
});
});
describe('Update Public Dashboard', () => {
it('should call update API when update button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let updateApiCalled = false;
let capturedRequestBody: {
timeRangeEnabled: boolean;
defaultTimeRange: string;
} | null = null;
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
rest.put(publicDashboardURL, async (req, res, ctx) => {
const body = await req.json();
updateApiCalled = true;
capturedRequestBody = body;
return res(ctx.status(StatusCodes.NO_CONTENT), ctx.json({}));
}),
);
render(<PublicDashboardSetting />);
// Wait for API response and component update
const updateButton = await screen.findByRole(
'button',
{ name: /update published dashboard/i },
{ timeout: 5000 },
);
await user.click(updateButton);
await waitFor(() => {
expect(updateApiCalled).toBe(true);
expect(capturedRequestBody).toStrictEqual({
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
});
expect(mockToast.success).toHaveBeenCalledWith(
'Public dashboard updated successfully',
);
});
});
});
describe('Revoke Public Dashboard Access', () => {
it('should call revoke API when unpublish button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let revokeApiCalled = false;
let capturedDashboardId: string | null = null;
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
rest.delete(publicDashboardURL, (req, res, ctx) => {
revokeApiCalled = true;
// Extract dashboard ID from URL: /api/v1/dashboards/{id}/public
const urlMatch = req.url.pathname.match(
/\/api\/v1\/dashboards\/([^/]+)\/public/,
);
capturedDashboardId = urlMatch ? urlMatch[1] : null;
return res(ctx.status(StatusCodes.NO_CONTENT), ctx.json({}));
}),
);
render(<PublicDashboardSetting />);
// Wait for API response and component update
const unpublishButton = await screen.findByRole(
'button',
{ name: /unpublish dashboard/i },
{ timeout: 5000 },
);
await user.click(unpublishButton);
await waitFor(() => {
expect(revokeApiCalled).toBe(true);
expect(capturedDashboardId).toBe(MOCK_DASHBOARD_ID);
expect(mockToast.success).toHaveBeenCalledWith(
'Dashboard unpublished successfully',
);
});
});
});
describe('Non-admin user permissions', () => {
it('should disable "Publish dashboard" button for non-admin users', async () => {
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(
ctx.status(StatusCodes.NOT_FOUND),
ctx.json(unpublishedPublicDashboardMeta),
),
),
);
render(<PublicDashboardSetting />, {}, { role: USER_ROLES.VIEWER });
const publishButton = await screen.findByRole('button', {
name: /publish dashboard/i,
});
expect(publishButton).toBeInTheDocument();
expect(publishButton).toBeDisabled();
expect(publishButton).toHaveAttribute('disabled');
});
describe('Published dashboard buttons for non-admin users', () => {
beforeEach(() => {
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
);
});
it('should disable "Unpublish dashboard" button for non-admin users', async () => {
render(<PublicDashboardSetting />, {}, { role: USER_ROLES.VIEWER });
const unpublishButton = await screen.findByRole('button', {
name: /unpublish dashboard/i,
});
expect(unpublishButton).toBeInTheDocument();
expect(unpublishButton).toBeDisabled();
expect(unpublishButton).toHaveAttribute('disabled');
});
it('should disable "Update published dashboard" button for non-admin users', async () => {
render(<PublicDashboardSetting />, {}, { role: USER_ROLES.VIEWER });
const updateButton = await screen.findByRole('button', {
name: /update published dashboard/i,
});
expect(updateButton).toBeInTheDocument();
expect(updateButton).toBeDisabled();
expect(updateButton).toHaveAttribute('disabled');
});
});
});
});

View File

@@ -1,378 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation } from 'react-query';
import { useCopyToClipboard } from 'react-use';
import { Checkbox } from '@signozhq/ui/checkbox';
import { toast } from '@signozhq/ui/sonner';
import { Button, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import createPublicDashboardAPI from 'api/dashboard/public/createPublicDashboard';
import revokePublicDashboardAccessAPI from 'api/dashboard/public/revokePublicDashboardAccess';
import updatePublicDashboardAPI from 'api/dashboard/public/updatePublicDashboard';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboardMeta';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import {
Copy,
ExternalLink,
Globe,
Info,
LoaderCircle,
Trash,
} from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { PublicDashboardMetaProps } from 'types/api/dashboard/public/getMeta';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import './PublicDashboard.styles.scss';
export const TIME_RANGE_PRESETS_OPTIONS = [
{
label: 'Last 5 minutes',
value: '5m',
},
{
label: 'Last 15 minutes',
value: '15m',
},
{
label: 'Last 30 minutes',
value: '30m',
},
{
label: 'Last 1 hour',
value: '1h',
},
{
label: 'Last 6 hours',
value: '6h',
},
{
label: 'Last 1 day',
value: '24h',
},
];
const showErrorNotification = (error: APIError): void => {
toast.error(error.getErrorCode(), {
description: error.getErrorMessage(),
});
};
function PublicDashboardSetting(): JSX.Element {
const [publicDashboardData, setPublicDashboardData] = useState<
PublicDashboardMetaProps | undefined
>(undefined);
const [timeRangeEnabled, setTimeRangeEnabled] = useState(true);
const [defaultTimeRange, setDefaultTimeRange] = useState(DEFAULT_TIME_RANGE);
const [, setCopyPublicDashboardURL] = useCopyToClipboard();
const { dashboardData } = useDashboardStore();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const isPublicDashboardEnabled = isCloudUser || isEnterpriseSelfHostedUser;
const { user } = useAppContext();
const isAdmin = user?.role === USER_ROLES.ADMIN;
const handleDefaultTimeRange = useCallback((value: string): void => {
setDefaultTimeRange(value);
}, []);
const handleTimeRangeEnabled = useCallback((): void => {
setTimeRangeEnabled((prev) => !prev);
}, []);
const {
data: publicDashboardResponse,
isLoading: isLoadingPublicDashboard,
isFetching: isFetchingPublicDashboard,
refetch: refetchPublicDashboard,
error: errorPublicDashboard,
} = useGetPublicDashboardMeta(
dashboardData?.id || '',
!!dashboardData?.id && isPublicDashboardEnabled,
);
const isPublicDashboard = !!publicDashboardData?.publicPath;
useEffect(() => {
if (publicDashboardResponse?.data) {
setPublicDashboardData(publicDashboardResponse?.data);
}
if (errorPublicDashboard) {
console.error('Error getting public dashboard', errorPublicDashboard);
setPublicDashboardData(undefined);
setTimeRangeEnabled(true);
setDefaultTimeRange(DEFAULT_TIME_RANGE);
}
}, [publicDashboardResponse, errorPublicDashboard]);
useEffect(() => {
if (publicDashboardResponse?.data) {
setTimeRangeEnabled(
publicDashboardResponse?.data?.timeRangeEnabled || false,
);
setDefaultTimeRange(
publicDashboardResponse?.data?.defaultTimeRange || DEFAULT_TIME_RANGE,
);
}
}, [publicDashboardResponse]);
const {
mutate: createPublicDashboard,
isLoading: isLoadingCreatePublicDashboard,
data: createPublicDashboardResponse,
} = useMutation(createPublicDashboardAPI, {
onSuccess: () => {
toast.success('Public dashboard created successfully');
},
onError: (error: APIError) => {
showErrorNotification(error);
},
});
const {
mutate: updatePublicDashboard,
isLoading: isLoadingUpdatePublicDashboard,
data: updatePublicDashboardResponse,
} = useMutation(updatePublicDashboardAPI, {
onSuccess: () => {
toast.success('Public dashboard updated successfully');
},
onError: (error: APIError) => {
showErrorNotification(error);
},
});
const {
mutate: revokePublicDashboardAccess,
isLoading: isLoadingRevokePublicDashboardAccess,
data: revokePublicDashboardAccessResponse,
} = useMutation(revokePublicDashboardAccessAPI, {
onSuccess: () => {
toast.success('Dashboard unpublished successfully');
},
onError: (error: APIError) => {
showErrorNotification(error);
},
});
const handleCreatePublicDashboard = (): void => {
if (!dashboardData) {
return;
}
createPublicDashboard({
dashboardId: dashboardData.id,
timeRangeEnabled,
defaultTimeRange,
});
};
const handleUpdatePublicDashboard = (): void => {
if (!dashboardData) {
return;
}
updatePublicDashboard({
dashboardId: dashboardData.id,
timeRangeEnabled,
defaultTimeRange,
});
};
const handleRevokePublicDashboardAccess = (): void => {
if (!dashboardData) {
return;
}
revokePublicDashboardAccess({
id: dashboardData.id,
});
};
useEffect(() => {
if (
(createPublicDashboardResponse &&
createPublicDashboardResponse.httpStatusCode === 201) ||
(updatePublicDashboardResponse &&
updatePublicDashboardResponse.httpStatusCode === 204) ||
(revokePublicDashboardAccessResponse &&
revokePublicDashboardAccessResponse.httpStatusCode === 204)
) {
refetchPublicDashboard();
}
}, [
createPublicDashboardResponse,
updatePublicDashboardResponse,
revokePublicDashboardAccessResponse,
refetchPublicDashboard,
]);
const handleCopyPublicDashboardURL = (): void => {
if (!publicDashboardResponse?.data?.publicPath) {
return;
}
try {
setCopyPublicDashboardURL(
getAbsoluteUrl(publicDashboardResponse?.data?.publicPath ?? ''),
);
toast.success('Copied Public Dashboard URL successfully');
} catch (error) {
console.error('Error copying public dashboard URL', error);
}
};
const publicDashboardURL = useMemo(
() => getAbsoluteUrl(publicDashboardResponse?.data?.publicPath ?? ''),
[publicDashboardResponse],
);
const isLoading =
isLoadingCreatePublicDashboard ||
isLoadingUpdatePublicDashboard ||
isLoadingRevokePublicDashboardAccess ||
isLoadingPublicDashboard;
return (
<div className="public-dashboard-setting-container">
<div className="public-dashboard-setting-content">
<Typography.Title
level={5}
className="public-dashboard-setting-content-title"
>
{isPublicDashboard
? 'This dashboard is publicly accessible. Anyone with the link can view it.'
: 'This dashboard is private. Publish it to make it accessible to anyone with the link.'}
</Typography.Title>
<div className="timerange-enabled-checkbox">
<Checkbox
id="enable-time-range"
value={timeRangeEnabled}
onChange={handleTimeRangeEnabled}
>
Enable time range
</Checkbox>
</div>
<div className="default-time-range-select">
<div className="default-time-range-select-label">
<Typography.Text className="default-time-range-select-label-text">
Default time range
</Typography.Text>
</div>
<Select
placeholder="Select default time range"
options={TIME_RANGE_PRESETS_OPTIONS}
value={defaultTimeRange}
onChange={handleDefaultTimeRange}
data-testid="default-time-range-select-dropdown"
className="default-time-range-select-dropdown"
/>
</div>
{isPublicDashboard && (
<div className="public-dashboard-url">
<div className="url-label-container">
<Typography.Text className="url-label">
Public Dashboard URL
</Typography.Text>
</div>
<div className="url-container">
<Typography.Text className="url-text">
{publicDashboardURL}
</Typography.Text>
<Button
type="link"
className="url-copy-btn periscope-btn ghost"
icon={<Copy size={12} />}
onClick={handleCopyPublicDashboardURL}
/>
<Button
type="link"
className="periscope-btn ghost"
icon={<ExternalLink size={12} />}
onClick={(): void => {
if (publicDashboardURL) {
openInNewTab(publicDashboardURL);
}
}}
/>
</div>
</div>
)}
<div className="public-dashboard-setting-callout">
<Typography.Text className="public-dashboard-setting-callout-text">
<Info size={12} className="public-dashboard-setting-callout-icon" />{' '}
Dashboard variables won&apos;t work in public dashboards
</Typography.Text>
</div>
<div className="public-dashboard-setting-actions">
{!isPublicDashboard ? (
<Button
type="primary"
className="create-public-dashboard-btn periscope-btn primary"
disabled={isLoading || !isAdmin}
onClick={handleCreatePublicDashboard}
loading={
isLoadingCreatePublicDashboard ||
isFetchingPublicDashboard ||
isLoadingPublicDashboard
}
icon={
isLoadingCreatePublicDashboard ||
isFetchingPublicDashboard ||
isLoadingPublicDashboard ? (
<LoaderCircle className="animate-spin" size={14} />
) : (
<Globe size={14} />
)
}
>
Publish dashboard
</Button>
) : (
<>
<Button
type="default"
className="periscope-btn secondary"
disabled={isLoading || !isAdmin}
onClick={handleRevokePublicDashboardAccess}
loading={isLoadingRevokePublicDashboardAccess}
icon={<Trash size={14} />}
>
Unpublish dashboard
</Button>
<Button
type="primary"
className="create-public-dashboard-btn periscope-btn primary"
disabled={isLoading || !isAdmin}
onClick={handleUpdatePublicDashboard}
loading={isLoadingUpdatePublicDashboard}
icon={<Globe size={14} />}
>
Update published dashboard
</Button>
</>
)}
</div>
</div>
</div>
);
}
export default PublicDashboardSetting;

View File

@@ -1,79 +0,0 @@
import { Button, Tabs, Tooltip } from 'antd';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { Braces, Globe, Table } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import { VariablesSettingsTabHandle } from '../DashboardDescription/types';
import DashboardVariableSettings from './DashboardVariableSettings';
import GeneralDashboardSettings from './General';
import PublicDashboardSetting from './PublicDashboard';
import './DashboardSettingsContent.styles.scss';
function DashboardSettings({
variablesSettingsTabHandle,
}: {
variablesSettingsTabHandle: VariablesSettingsTabHandle;
}): JSX.Element {
const { user } = useAppContext();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
const publicDashboardItem = {
label: (
<Tooltip
title={
user?.role !== USER_ROLES.ADMIN
? 'Only admins can publish / manage public dashboards'
: ''
}
placement="right"
>
<Button
type="text"
icon={<Globe size={14} />}
className={`public-dashboard-btn ${
user?.role !== USER_ROLES.ADMIN ? 'disabled-btn' : ''
}`}
>
Publish
</Button>
</Tooltip>
),
key: 'public-dashboard',
children: <PublicDashboardSetting />,
disabled: user?.role !== USER_ROLES.ADMIN,
};
const items = [
{
label: (
<Button type="text" icon={<Table size={14} />} className="overview-btn">
Overview
</Button>
),
key: 'general',
children: <GeneralDashboardSettings />,
},
{
label: (
<Button type="text" icon={<Braces size={14} />} className="variables-btn">
Variables
</Button>
),
key: 'variables',
children: (
<DashboardVariableSettings
variablesSettingsTabHandle={variablesSettingsTabHandle}
/>
),
},
...(enablePublicDashboard ? [publicDashboardItem] : []),
];
return <Tabs items={items} animated className="settings-tabs" />;
}
export default DashboardSettings;

View File

@@ -1,69 +0,0 @@
import { memo, useEffect, useMemo } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import { VariableItemProps } from './VariableItem';
import { customVariableSelectStrategy } from './variableSelectStrategy/customVariableSelectStrategy';
type CustomVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'onValueUpdate'
>;
function CustomVariableInput({
variableData,
onValueUpdate,
}: CustomVariableInputProps): JSX.Element {
const optionsData: (string | number | boolean)[] = useMemo(() => {
return sortValues(
commaValuesParser(variableData.customValue || ''),
variableData.sort,
) as (string | number | boolean)[];
}, [variableData.customValue, variableData.sort]);
const {
value,
defaultValue,
enableSelectAll,
onChange,
onDropdownVisibleChange,
handleClear,
applyDefaultIfNeeded,
} = useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy: customVariableSelectStrategy,
});
// Apply default on mount — options are available synchronously for custom variables
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(applyDefaultIfNeeded, []);
const selectOptions = useMemo(
() =>
optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
})),
[optionsData],
);
return (
<SelectVariableInput
variableId={variableData.id}
options={selectOptions}
value={value}
onChange={onChange}
onDropdownVisibleChange={onDropdownVisibleChange}
onClear={handleClear}
enableSelectAll={enableSelectAll}
defaultValue={defaultValue}
isMultiSelect={variableData.multiSelect}
/>
);
}
export default memo(CustomVariableInput);

View File

@@ -1,113 +0,0 @@
.variable-item {
display: flex;
align-items: center;
.variable-name {
display: flex;
min-width: 56px;
height: 32px;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--bg-robin-300);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
.info-icon {
margin-left: 4px;
color: var(--l2-foreground);
}
}
.variable-value {
display: flex;
min-width: 120px;
height: 32px;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
border-left: none;
background: var(--l2-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
&:hover,
&:focus-within {
outline: 1px solid var(--bg-robin-400);
}
}
.variable-select {
.ant-select-selector {
overflow-y: hidden !important;
}
.ant-select-item {
display: flex;
align-items: center;
gap: 8px;
}
.rc-virtual-list-holder {
[data-testid='option-ALL'] {
border-bottom: 1px solid var(--l1-border);
padding-bottom: 12px;
margin-bottom: 8px;
}
}
.all-label {
display: flex;
gap: 16px;
}
.dropdown-checkbox-label {
display: grid;
grid-template-columns: 24px 1fr;
}
.dropdown-value {
display: grid;
grid-template-columns: 1fr max-content;
.option-text {
padding: 0 8px;
}
.toggle-tag-label {
padding-left: 8px;
right: 40px;
font-weight: 500;
}
}
}
}
.dropdown-styles {
min-width: 400px;
max-width: 500px;
}
.cycle-error-alert {
margin-bottom: 12px;
padding: 4px 12px;
font-size: 12px;
}
.dashboard-variables-selection-container {
display: flex;
flex-wrap: wrap;
gap: 12px;
}

View File

@@ -1,181 +0,0 @@
import { memo, useCallback, useEffect, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Row } from 'antd';
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
import { updateLocalStorageDashboardVariable } from 'hooks/dashboard/useDashboardFromLocalStorage';
import {
useDashboardVariables,
useDashboardVariablesSelector,
} from 'hooks/dashboard/useDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import {
enqueueDescendantsOfVariable,
enqueueFetchOfAllVariables,
initializeVariableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { useShallow } from 'zustand/react/shallow';
import VariableItem from './VariableItem';
import './DashboardVariableSelection.styles.scss';
function DashboardVariableSelection(): JSX.Element | null {
const { dashboardId, setDashboardData } = useDashboardStore(
useShallow((s) => ({
dashboardId: s.dashboardData?.id ?? '',
setDashboardData: s.setDashboardData,
})),
);
const { updateUrlVariable } = useVariablesFromUrl();
const { dashboardVariables } = useDashboardVariables();
const sortedVariablesArray = useDashboardVariablesSelector(
(state) => state.sortedVariablesArray,
);
const dynamicVariableOrder = useDashboardVariablesSelector(
(state) => state.dynamicVariableOrder,
);
const dependencyData = useDashboardVariablesSelector(
(state) => state.dependencyData,
);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// Memoize the order key to avoid unnecessary triggers
const variableOrderKey = useMemo(() => {
const queryVariableOrderKey = dependencyData?.order?.join(',') ?? '';
const dynamicVariableOrderKey = dynamicVariableOrder?.join(',') ?? '';
return `${queryVariableOrderKey}|${dynamicVariableOrderKey}`;
}, [dependencyData?.order, dynamicVariableOrder]);
// Initialize fetch store then start a new fetch cycle.
// Runs on dependency order changes, and time range changes.
useEffect(() => {
const allVariableNames = sortedVariablesArray
.map((v) => v.name)
.filter((name): name is string => !!name);
initializeVariableFetchStore(allVariableNames);
enqueueFetchOfAllVariables();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variableOrderKey, minTime, maxTime]);
// Performance optimization: For dynamic variables with allSelected=true, we don't store
// individual values in localStorage since we can always derive them from available options.
// This makes localStorage much lighter and more efficient.
const onValueUpdate = useCallback(
(
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
): void => {
// For dynamic variables, only store in localStorage when NOT allSelected
// This makes localStorage much lighter by avoiding storing all individual values
const variable = dashboardVariables[id] || dashboardVariables[name];
const isDynamic = variable.type === 'DYNAMIC';
updateLocalStorageDashboardVariable(
dashboardId,
name,
value,
allSelected,
isDynamic,
);
if (allSelected) {
updateUrlVariable(name || id, ALL_SELECTED_VALUE);
} else {
updateUrlVariable(name || id, value);
}
// Synchronously update the external store with the new variable value so that
// child variables see the updated parent value when they refetch, rather than
// waiting for setDashboardData → useEffect → updateDashboardVariablesStore.
const updatedVariables = { ...dashboardVariables };
if (updatedVariables[id]) {
updatedVariables[id] = {
...updatedVariables[id],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
if (updatedVariables[name]) {
updatedVariables[name] = {
...updatedVariables[name],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
updateDashboardVariablesStore({ dashboardId, variables: updatedVariables });
setDashboardData((prev) => {
if (prev) {
const oldVariables = { ...prev?.data.variables };
// this is added to handle case where we have two different
// schemas for variable response
if (oldVariables?.[id]) {
oldVariables[id] = {
...oldVariables[id],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
if (oldVariables?.[name]) {
oldVariables[name] = {
...oldVariables[name],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
return {
...prev,
data: {
...prev?.data,
variables: {
...oldVariables,
},
},
};
}
return prev;
});
// Cascade: enqueue query-type descendants for refetching.
// Safe to call synchronously now that the store already has the updated value.
enqueueDescendantsOfVariable(name);
},
[dashboardId, dashboardVariables, updateUrlVariable, setDashboardData],
);
return (
<Row className="dashboard-variables-selection-container">
{sortedVariablesArray.map((variable) => {
const key = `${variable.name}${variable.id}${variable.order}`;
return (
<VariableItem
key={key}
existingVariables={dashboardVariables}
variableData={variable}
onValueUpdate={onValueUpdate}
/>
);
})}
</Row>
);
}
export default memo(DashboardVariableSelection);

View File

@@ -1,327 +0,0 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
import useDebounce from 'hooks/useDebounce';
import { AppState } from 'store/reducers';
import { SuccessResponseV2 } from 'types/api';
import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
import { GlobalReducer } from 'types/reducer/globalTime';
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from '../../../constants/queryCacheTime';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import {
buildExistingDynamicVariableQuery,
extractErrorMessage,
getOptionsForDynamicVariable,
mergeUniqueStrings,
settleVariableFetch,
} from './util';
import { VariableItemProps } from './VariableItem';
import { dynamicVariableSelectStrategy } from './variableSelectStrategy/dynamicVariableSelectStrategy';
import './DashboardVariableSelection.styles.scss';
type DynamicVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'onValueUpdate' | 'existingVariables'
>;
function DynamicVariableInput({
variableData,
onValueUpdate,
existingVariables,
}: DynamicVariableInputProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const [isRetryableError, setIsRetryableError] = useState<boolean>(true);
const [isComplete, setIsComplete] = useState<boolean>(false);
const [filteredOptionsData, setFilteredOptionsData] = useState<
(string | number | boolean)[]
>([]);
const [relatedValues, setRelatedValues] = useState<string[]>([]);
const [originalRelatedValues, setOriginalRelatedValues] = useState<string[]>(
[],
);
// Track dropdown open state for auto-checking new values
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
const [apiSearchText, setApiSearchText] = useState<string>('');
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
const allAvailableOptionStrings = useMemo(
() => mergeUniqueStrings(optionsData, relatedValues),
[optionsData, relatedValues],
);
const {
value,
tempSelection,
setTempSelection,
handleClear,
enableSelectAll,
defaultValue,
applyDefaultIfNeeded,
onChange,
onDropdownVisibleChange,
} = useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy: dynamicVariableSelectStrategy,
allAvailableOptionStrings,
});
// Create a dependency key from all dynamic variables
const dynamicVariablesKey = useMemo(() => {
if (!existingVariables) {
return 'no_variables';
}
const dynamicVars = Object.values(existingVariables)
.filter((v) => v.type === 'DYNAMIC')
.map(
(v) => `${v.name || 'unnamed'}:${JSON.stringify(v.selectedValue || null)}`,
)
.join('|');
return dynamicVars || 'no_dynamic_variables';
}, [existingVariables]);
const { maxTime, minTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const {
variableFetchCycleId,
isVariableSettled,
isVariableFetching,
hasVariableFetchedOnce,
isVariableWaitingForDependencies,
variableDependencyWaitMessage,
} = useVariableFetchState(variableData.name || '');
const existingQuery = useMemo(
() =>
buildExistingDynamicVariableQuery(
existingVariables,
variableData.id,
!!variableData.dynamicVariablesAttribute,
),
[existingVariables, variableData.id, variableData.dynamicVariablesAttribute],
);
// Wrap the hook's onDropdownVisibleChange to also track isDropdownOpen and handle cleanup
const handleSelectDropdownVisibilityChange = useCallback(
(visible: boolean): void => {
setIsDropdownOpen(visible);
onDropdownVisibleChange(visible);
if (!visible) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
setApiSearchText('');
}
},
[onDropdownVisibleChange, optionsData, originalRelatedValues],
);
const handleQuerySuccess = useCallback(
(data: SuccessResponseV2<FieldValueResponse>): void => {
const newNormalizedValues = data.data?.normalizedValues || [];
const newRelatedValues = data.data?.relatedValues || [];
if (!debouncedApiSearchText) {
setOptionsData(newNormalizedValues);
setIsComplete(data.data?.complete || false);
}
setFilteredOptionsData(newNormalizedValues);
setRelatedValues(newRelatedValues);
setOriginalRelatedValues(newRelatedValues);
// Sync temp selection with latest API values when ALL is active and dropdown is open
if (variableData.allSelected && isDropdownOpen) {
const latestValues = mergeUniqueStrings(
newNormalizedValues,
newRelatedValues,
);
const currentStrings = Array.isArray(tempSelection)
? tempSelection.map((v) => v.toString())
: tempSelection
? [tempSelection.toString()]
: [];
const areSame =
currentStrings.length === latestValues.length &&
latestValues.every((v) => currentStrings.includes(v));
if (!areSame) {
setTempSelection(latestValues);
}
}
// Apply default if no value is selected (e.g., new variable, first load)
if (!debouncedApiSearchText) {
applyDefaultIfNeeded(
mergeUniqueStrings(newNormalizedValues, newRelatedValues),
);
}
settleVariableFetch(variableData.name, 'complete');
},
[
debouncedApiSearchText,
variableData.allSelected,
variableData.name,
isDropdownOpen,
tempSelection,
setTempSelection,
applyDefaultIfNeeded,
],
);
const handleQueryError = useCallback(
(error: { message?: string } | null): void => {
if (error) {
setErrorMessage(extractErrorMessage(error));
setIsRetryableError(checkIfRetryableError(error));
}
settleVariableFetch(variableData.name, 'failure');
},
[variableData.name],
);
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || `variable_${variableData.id}`,
dynamicVariablesKey,
minTime,
maxTime,
debouncedApiSearchText,
variableData.dynamicVariablesSource,
variableData.dynamicVariablesAttribute,
variableFetchCycleId,
],
{
/*
* enabled if
* - we have dynamic variable source and attribute defined (ALWAYS)
* - AND
* - we're either still fetching variable options
* - OR
* - if variable is in idle state and we have already fetched options for it
**/
enabled:
!!variableData.dynamicVariablesSource &&
!!variableData.dynamicVariablesAttribute &&
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
queryFn: ({ signal }) =>
getFieldValues(
variableData.dynamicVariablesSource?.toLowerCase() === 'all telemetry'
? undefined
: (variableData.dynamicVariablesSource?.toLowerCase() as
| 'traces'
| 'logs'
| 'metrics'),
variableData.dynamicVariablesAttribute,
debouncedApiSearchText,
minTime,
maxTime,
existingQuery,
signal,
),
onSuccess: handleQuerySuccess,
onError: handleQueryError,
},
);
const handleRetry = useCallback((): void => {
setErrorMessage(null);
setIsRetryableError(true);
refetch();
}, [refetch]);
const handleSearch = useCallback(
(text: string) => {
if (isComplete) {
if (!text) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
return;
}
const lowerText = text.toLowerCase();
setFilteredOptionsData(
optionsData.filter((option) =>
option.toString().toLowerCase().includes(lowerText),
),
);
setRelatedValues(
originalRelatedValues.filter((val) =>
val.toLowerCase().includes(lowerText),
),
);
} else {
setApiSearchText(text);
}
},
[isComplete, optionsData, originalRelatedValues],
);
const selectOptions = useMemo(
() =>
getOptionsForDynamicVariable(filteredOptionsData || [], relatedValues || []),
[filteredOptionsData, relatedValues],
);
return (
<SelectVariableInput
variableId={variableData.id}
options={selectOptions}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleSelectDropdownVisibilityChange}
onClear={handleClear}
enableSelectAll={enableSelectAll}
defaultValue={defaultValue}
isMultiSelect={variableData.multiSelect}
// dynamic variable specific + API related props
loading={isLoading}
errorMessage={errorMessage}
onRetry={handleRetry}
isDynamicVariable
showRetryButton={isRetryableError}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
onSearch={handleSearch}
waiting={isVariableWaitingForDependencies}
waitingMessage={variableDependencyWaitMessage}
/>
);
}
export default memo(DynamicVariableInput);

View File

@@ -1,267 +0,0 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import { isArray, isEmpty } from 'lodash-es';
import { AppState } from 'store/reducers';
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from '../../../constants/queryCacheTime';
import { variablePropsToPayloadVariables } from '../utils';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import { areArraysEqual, settleVariableFetch } from './util';
import { VariableItemProps } from './VariableItem';
import { queryVariableSelectStrategy } from './variableSelectStrategy/queryVariableSelectStrategy';
type QueryVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'existingVariables' | 'onValueUpdate'
>;
function QueryVariableInput({
variableData,
existingVariables,
onValueUpdate,
}: QueryVariableInputProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const { maxTime, minTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const {
variableFetchCycleId,
isVariableSettled,
isVariableFetching,
hasVariableFetchedOnce,
isVariableWaitingForDependencies,
variableDependencyWaitMessage,
} = useVariableFetchState(variableData.name || '');
const {
tempSelection,
setTempSelection,
value,
defaultValue,
enableSelectAll,
onChange,
onDropdownVisibleChange,
handleClear,
getDefaultValue,
} = useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy: queryVariableSelectStrategy,
});
const getOptions = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
(variablesRes: VariableResponseProps | null): void => {
try {
setErrorMessage(null);
// This is just a check given the previously undefined typed name prop. Not significant
// This will be changed when we change the schema
// TODO: @AshwinBhatkal Perses
if (!variableData.name) {
return;
}
// if the response is not an array, premature return
if (
!variablesRes?.variableValues ||
!Array.isArray(variablesRes?.variableValues)
) {
return;
}
const sortedNewOptions = sortValues(
variablesRes.variableValues,
variableData.sort,
);
const sortedOldOptions = sortValues(optionsData, variableData.sort);
// if options are the same as before, no need to update state or check for selected value validity
// ! selectedValue needs to be set in the first pass though, as options are initially empty array and we need to apply default if needed
// Expecatation is that when oldOptions are not empty, then there is always some selectedValue
if (areArraysEqual(sortedNewOptions, sortedOldOptions)) {
return;
}
setOptionsData(sortedNewOptions);
let isSelectedValueMissingInNewOptions = false;
// Check if currently selected value(s) are present in the new options list
if (isArray(variableData.selectedValue)) {
isSelectedValueMissingInNewOptions = variableData.selectedValue.some(
(val) => !sortedNewOptions.includes(val),
);
} else if (
variableData.selectedValue &&
!sortedNewOptions.includes(variableData.selectedValue)
) {
isSelectedValueMissingInNewOptions = true;
}
// If multi-select with ALL option enabled, and ALL is currently selected, we want to maintain that state and select all new options
// This block does not depend on selected value because of ALL and also because we would only come here if options are different from the previous
if (
variableData.multiSelect &&
variableData.showALLOption &&
variableData.allSelected &&
isSelectedValueMissingInNewOptions
) {
onValueUpdate(variableData.name, variableData.id, sortedNewOptions, true);
// Update tempSelection to maintain ALL state when dropdown is open
if (tempSelection !== undefined) {
setTempSelection(sortedNewOptions.map((option) => option.toString()));
}
return;
}
const value = variableData.selectedValue;
let allSelected = false;
if (variableData.multiSelect) {
const { selectedValue } = variableData;
allSelected =
sortedNewOptions.length > 0 &&
Array.isArray(selectedValue) &&
sortedNewOptions.every((option) => selectedValue.includes(option));
}
if (
variableData.name &&
variableData.id &&
!isEmpty(variableData.selectedValue)
) {
onValueUpdate(variableData.name, variableData.id, value, allSelected);
} else {
const defaultValue = getDefaultValue(sortedNewOptions);
if (defaultValue !== undefined) {
onValueUpdate(
variableData.name,
variableData.id,
defaultValue,
allSelected,
);
}
}
} catch (e) {
console.error(e);
}
},
[
variableData,
optionsData,
onValueUpdate,
tempSelection,
setTempSelection,
getDefaultValue,
],
);
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || '',
`${minTime}`,
`${maxTime}`,
variableFetchCycleId,
],
{
/*
* enabled if
* - we're either still fetching variable options
* - OR
* - if variable is in idle state and we have already fetched options for it
**/
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
queryFn: ({ signal }) =>
dashboardVariablesQuery(
{
query: variableData.queryValue || '',
variables: variablePropsToPayloadVariables(existingVariables),
},
signal,
),
refetchOnWindowFocus: false,
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onSuccess: (response) => {
getOptions(response.payload);
settleVariableFetch(variableData.name, 'complete');
},
onError: (error: {
details: {
error: string;
};
}) => {
const { details } = error;
if (details.error) {
let message = details.error;
if ((details.error ?? '').toString().includes('Syntax error:')) {
message =
'Please make sure query is valid and dependent variables are selected';
}
setErrorMessage(message);
}
settleVariableFetch(variableData.name, 'failure');
},
},
);
const handleRetry = useCallback((): void => {
setErrorMessage(null);
refetch();
}, [refetch]);
const selectOptions = useMemo(
() =>
optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
})),
[optionsData],
);
return (
<SelectVariableInput
variableId={variableData.id}
options={selectOptions}
value={value}
onChange={onChange}
onDropdownVisibleChange={onDropdownVisibleChange}
onClear={handleClear}
enableSelectAll={enableSelectAll}
defaultValue={defaultValue}
isMultiSelect={variableData.multiSelect}
// query variable specific, API related props
loading={isLoading}
errorMessage={errorMessage}
onRetry={handleRetry}
waiting={isVariableWaitingForDependencies}
waitingMessage={variableDependencyWaitMessage}
/>
);
}
export default memo(QueryVariableInput);

Some files were not shown because too many files have changed in this diff Show More