mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-21 20:20:44 +01:00
Compare commits
7 Commits
refactor/v
...
chore/dash
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18db419644 | ||
|
|
a6e09083d7 | ||
|
|
e97d127af2 | ||
|
|
fbbceffbeb | ||
|
|
74bbcb20d5 | ||
|
|
bdb8b6a675 | ||
|
|
b5a1fb2106 |
21
.github/CODEOWNERS
vendored
21
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: {} } },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -44,7 +44,7 @@ jest.mock(
|
||||
},
|
||||
);
|
||||
jest.mock(
|
||||
'container/NewWidget/LeftContainer/WidgetGraph/PlotTag',
|
||||
'components/PlotTag/PlotTag',
|
||||
() =>
|
||||
function MockPlotTag(props: any): JSX.Element {
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
|
||||
export interface VariablesSettingsTab {
|
||||
resetState: () => void;
|
||||
}
|
||||
|
||||
export type VariablesSettingsTabHandle =
|
||||
MutableRefObject<VariablesSettingsTab | null>;
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1,7 +0,0 @@
|
||||
export type TVariableMode = 'VIEW' | 'EDIT' | 'ADD';
|
||||
|
||||
export const VariableModes = {
|
||||
VIEW: 'VIEW',
|
||||
EDIT: 'EDIT',
|
||||
ADD: 'ADD',
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
Reference in New Issue
Block a user