Compare commits

..

33 Commits

Author SHA1 Message Date
SagarRajput-7
b61376b904 feat(base-path): migrate remaining pattern for window.location.origin + path 2026-04-20 14:18:59 +05:30
SagarRajput-7
18429b3b91 feat(base-path): replace window.open with openInNewTab for internal paths 2026-04-20 14:05:16 +05:30
SagarRajput-7
c7adb22c61 feat: code refactor around feedbacks 2026-04-20 13:34:46 +05:30
SagarRajput-7
b936e63658 feat: applied suggested patch changes 2026-04-20 12:14:15 +05:30
SagarRajput-7
727b105b6c Merge branch 'main' into base-path-config-1 2026-04-20 12:02:01 +05:30
SagarRajput-7
f3269318b7 feat: code refactor around feedbacks 2026-04-17 15:58:00 +05:30
SagarRajput-7
5088bd7499 feat: updated base path utils and fixed navigation and translations 2026-04-17 13:52:12 +05:30
SagarRajput-7
7648d4f3d3 feat: updated the html template 2026-04-16 21:55:21 +05:30
SagarRajput-7
5729a4584a feat: removed plugin and serving the index.html only as the template 2026-04-16 18:23:46 +05:30
SagarRajput-7
825d06249d feat: refactor the interceptor and added gotmpl into gitignore 2026-04-16 18:23:38 +05:30
SagarRajput-7
9034471587 feat: changed output path to dir level 2026-04-16 18:23:25 +05:30
SagarRajput-7
4cc23ead6b feat: base path config setup and plugin for gotmpl generation at build time 2026-04-16 18:19:07 +05:30
SagarRajput-7
867e27d45f Merge branch 'main' into platform-pod/issues/1775 2026-04-16 18:17:11 +05:30
grandwizard28
be37e588f8 perf(web): cache http.FileServer on provider instead of creating per-request 2026-04-16 14:53:06 +05:30
grandwizard28
057dcbe6e4 fix: remove unused files 2026-04-16 02:26:18 +05:30
grandwizard28
3a28d741a3 fix: remove unused files 2026-04-16 02:24:20 +05:30
grandwizard28
223e83154f style: formatting and test cleanup from review
Restructure Validate nil check, rename expectErr to fail with
early-return, trim trailing newlines in test assertions, remove
t.Parallel from subtests, inline short config literals, restore
struct field comments in web.Config.
2026-04-16 02:17:14 +05:30
grandwizard28
50ae51cdaa fix(web): resolve lint errors in provider and template
Fix errcheck on rw.Write in serveIndex, use ErrorContext instead of
Error in NewIndex for sloglint compliance. Move serveIndex below
ServeHTTP to order public methods before private ones.
2026-04-16 02:05:25 +05:30
grandwizard28
c8ae8476c3 style: add blank lines between logical blocks 2026-04-16 01:57:24 +05:30
grandwizard28
daaa66e1fc chore: remove redundant comments from added code 2026-04-16 01:54:14 +05:30
grandwizard28
b0717d6a69 refactor(web): use table-driven tests with named path cases
Replace for-loop path iteration with explicit table-driven test cases
for each path. Each path (root, non-existent, directory) is a named
subtest case in all three template tests.
2026-04-16 01:49:07 +05:30
grandwizard28
4aefe44313 refactor(web): rename get test helper to httpGet 2026-04-16 01:47:35 +05:30
grandwizard28
4dc6f6fe7b style(web): use raw string literals for expected test values 2026-04-16 01:44:46 +05:30
grandwizard28
d3e0c46ba2 test(web): use exact match instead of contains in template tests
Match the full expected response body in TestServeTemplatedIndex
instead of using assert.Contains.
2026-04-16 01:43:23 +05:30
grandwizard28
0fed17e11a test(web): add SPA fallback paths to no_template and invalid_template tests
Test /, /does-not-exist, and /assets in all three template test cases
to verify SPA fallback behavior (non-existent paths and directories
serve the index) regardless of template type.
2026-04-16 01:38:46 +05:30
grandwizard28
a2264b4960 refactor(web): rename test fixtures to no_template, valid_template, invalid_template
Drop the index_ prefix from test fixtures. Use web instead of w for
the variable name in test helpers.
2026-04-16 01:32:50 +05:30
grandwizard28
2740964106 test(web): add no-template and invalid-template index test cases
Add three distinct index fixtures in testdata:
- index.html: correct [[ ]] template with BaseHref
- index_no_template.html: plain HTML, no placeholders
- index_invalid_template.html: malformed template syntax

Tests verify: template substitution works, plain files pass through
unchanged, and invalid templates fall back to serving raw bytes.
Consolidate test helpers into startServer/get.
2026-04-16 01:28:37 +05:30
grandwizard28
0ca22dd7fe refactor(web): collapse testdata_basepath into testdata
Use a single testdata directory with a templated index.html for all
routerweb tests. Remove the redundant testdata_basepath directory.
2026-04-16 01:22:54 +05:30
grandwizard28
a3b6bddac8 refactor(web): make index filename configurable via web.index
Move the hardcoded indexFileName const from routerweb/provider.go to
web.Config.Index with default "index.html". This allows overriding the
SPA entrypoint file via configuration.
2026-04-16 01:19:35 +05:30
grandwizard28
d908ce321a refactor(global): rename RoutePrefix to ExternalPath, add ExternalPathTrailing
Rename RoutePrefix() to ExternalPath() to accurately reflect what it
returns: the path component of the external URL. Add
ExternalPathTrailing() which returns the path with a trailing slash,
used for HTML base href injection.
2026-04-16 01:13:16 +05:30
grandwizard28
c221a44f3d refactor(web): extract index.html templating into web.NewIndex
Move the template parsing and execution logic from routerweb provider
into pkg/web/template.go. NewIndex logs and returns raw bytes on
template failure; NewIndexE returns the error for callers that need it.

Rename BasePath to BaseHref to match the HTML attribute it populates.
Inject global.Config into routerweb via the factory closure pattern.
2026-04-16 01:08:46 +05:30
grandwizard28
22fb4daaf9 feat(web): template index.html with dynamic base href from global.external_url
Read index.html at startup, parse as Go template with [[ ]] delimiters,
execute with BasePath derived from global.external_url, and cache the
rendered bytes in memory. This injects <base href="/signoz/" /> (or
whatever the route prefix is) so the browser resolves relative URLs
correctly when SigNoz is served at a sub-path.

Inject global.Config into the routerweb provider via the factory closure
pattern. Static files (JS, CSS, images) are still served from disk
unchanged.
2026-04-16 00:58:20 +05:30
grandwizard28
1bdc059d76 feat(apiserver): derive HTTP route prefix from global.external_url
The path component of global.external_url is now used as the base path
for all HTTP routes (API and web frontend), enabling SigNoz to be served
behind a reverse proxy at a sub-path (e.g. https://example.com/signoz/).

The prefix is applied via http.StripPrefix at the outermost handler
level, requiring zero changes to route registration code. Health
endpoints (/api/v1/health, /api/v2/healthz, /api/v2/readyz,
/api/v2/livez) remain accessible without the prefix for container
healthchecks.

Removes web.prefix config in favor of the unified global.external_url
approach, avoiding the desync bugs seen in projects with separate
API/UI prefix configs (ArgoCD, Prometheus).

closes SigNoz/platform-pod#1775
2026-04-16 00:38:55 +05:30
247 changed files with 7566 additions and 14350 deletions

View File

@@ -19,11 +19,11 @@ func NewAWSCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore)
}
func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.AWS.DeploymentRegion)
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.Aws.DeploymentRegion)
u, _ := url.Parse(baseURL)
q := u.Query()
q.Set("region", req.Config.AWS.DeploymentRegion)
q.Set("region", req.Config.Aws.DeploymentRegion)
u.Fragment = "/stacks/quickcreate"
u.RawQuery = q.Encode()
@@ -39,7 +39,9 @@ func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, acc
q.Set("param_IngestionKey", req.Credentials.IngestionKey)
return &cloudintegrationtypes.ConnectionArtifact{
AWS: cloudintegrationtypes.NewAWSConnectionArtifact(u.String() + "?&" + q.Encode()), // this format is required by AWS
Aws: &cloudintegrationtypes.AWSConnectionArtifact{
ConnectionURL: u.String() + "?&" + q.Encode(), // this format is required by AWS
},
}, nil
}
@@ -122,6 +124,9 @@ func (provider *awscloudprovider) BuildIntegrationConfig(
}
return &cloudintegrationtypes.ProviderIntegrationConfig{
AWS: cloudintegrationtypes.NewAWSIntegrationConfig(account.Config.AWS.Regions, collectionStrategy),
AWS: &cloudintegrationtypes.AWSIntegrationConfig{
EnabledRegions: account.Config.AWS.Regions,
TelemetryCollectionStrategy: collectionStrategy,
},
}, nil
}

View File

@@ -66,6 +66,8 @@ module.exports = {
rules: {
// Asset migration — base-path safety
'rulesdir/no-unsupported-asset-pattern': 'error',
// Base-path safety — window.open and origin-concat patterns; upgrade to error coming PR
'rulesdir/no-raw-absolute-path': 'warn',
// Code quality rules
'prefer-const': 'error', // Enforces const for variables never reassigned

View File

@@ -0,0 +1,153 @@
'use strict';
/**
* ESLint rule: no-raw-absolute-path
*
* Catches patterns that break at runtime when the app is served from a
* sub-path (e.g. /signoz/):
*
* 1. window.open(path, '_blank')
* → use openInNewTab(path) which calls withBasePath internally
*
* 2. window.location.origin + path / `${window.location.origin}${path}`
* → use getAbsoluteUrl(path)
*
* 3. frontendBaseUrl: window.location.origin (bare origin usage)
* → use getBaseUrl() to include the base path
*
* 4. window.location.href = path
* → use withBasePath(path) or navigate() for internal navigation
*
* External URLs (first arg starts with "http") are explicitly allowed.
*/
function isOriginAccess(node) {
return (
node.type === 'MemberExpression' &&
!node.computed &&
node.property.name === 'origin' &&
node.object.type === 'MemberExpression' &&
!node.object.computed &&
node.object.property.name === 'location' &&
node.object.object.type === 'Identifier' &&
node.object.object.name === 'window'
);
}
function isHrefAccess(node) {
return (
node.type === 'MemberExpression' &&
!node.computed &&
node.property.name === 'href' &&
node.object.type === 'MemberExpression' &&
!node.object.computed &&
node.object.property.name === 'location' &&
node.object.object.type === 'Identifier' &&
node.object.object.name === 'window'
);
}
function isExternalUrl(node) {
if (node.type === 'Literal' && typeof node.value === 'string') {
return node.value.startsWith('http://') || node.value.startsWith('https://');
}
if (node.type === 'TemplateLiteral' && node.quasis.length > 0) {
const raw = node.quasis[0].value.raw;
return raw.startsWith('http://') || raw.startsWith('https://');
}
return false;
}
// window.open(withBasePath(x)) and window.open(getAbsoluteUrl(x)) are already safe.
function isSafeHelperCall(node) {
return (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
(node.callee.name === 'withBasePath' || node.callee.name === 'getAbsoluteUrl')
);
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'Disallow raw window.open and origin-concatenation patterns that miss the runtime base path',
category: 'Base Path Safety',
},
schema: [],
messages: {
windowOpen:
'Use openInNewTab(path) instead of window.open(path, "_blank") — openInNewTab prepends the base path automatically.',
originConcat:
'Use getAbsoluteUrl(path) instead of window.location.origin + path — getAbsoluteUrl prepends the base path automatically.',
originDirect:
'Use getBaseUrl() instead of window.location.origin — getBaseUrl includes the base path.',
hrefAssign:
'Use withBasePath(path) or navigate() instead of window.location.href = path — ensures the base path is included.',
},
},
create(context) {
return {
// window.open(path, ...) — allow only external first-arg URLs
CallExpression(node) {
const { callee, arguments: args } = node;
if (
callee.type !== 'MemberExpression' ||
callee.object.type !== 'Identifier' ||
callee.object.name !== 'window' ||
callee.property.name !== 'open'
)
return;
if (args.length < 1) return;
if (isExternalUrl(args[0])) return;
if (isSafeHelperCall(args[0])) return;
context.report({ node, messageId: 'windowOpen' });
},
// window.location.origin + path
BinaryExpression(node) {
if (node.operator !== '+') return;
if (isOriginAccess(node.left) || isOriginAccess(node.right)) {
context.report({ node, messageId: 'originConcat' });
}
},
// `${window.location.origin}${path}`
TemplateLiteral(node) {
if (node.expressions.some(isOriginAccess)) {
context.report({ node, messageId: 'originConcat' });
}
},
// window.location.origin used directly (not in concatenation)
// Catches: frontendBaseUrl: window.location.origin
MemberExpression(node) {
if (!isOriginAccess(node)) return;
const parent = node.parent;
// Skip if parent is BinaryExpression with + (handled by BinaryExpression visitor)
if (parent.type === 'BinaryExpression' && parent.operator === '+') return;
// Skip if inside TemplateLiteral (handled by TemplateLiteral visitor)
if (parent.type === 'TemplateLiteral') return;
context.report({ node, messageId: 'originDirect' });
},
// window.location.href = path
AssignmentExpression(node) {
if (node.operator !== '=') return;
if (!isHrefAccess(node.left)) return;
// Allow external URLs
if (isExternalUrl(node.right)) return;
// Allow safe helper calls
if (isSafeHelperCall(node.right)) return;
context.report({ node, messageId: 'hrefAssign' });
},
};
},
};

View File

@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="[[.BaseHref]]" />
<meta
http-equiv="Cache-Control"
content="no-cache, no-store, must-revalidate, max-age: 0"
@@ -59,7 +60,7 @@
<meta data-react-helmet="true" name="docusaurus_locale" content="en" />
<meta data-react-helmet="true" name="docusaurus_tag" content="default" />
<meta name="robots" content="noindex" />
<link data-react-helmet="true" rel="shortcut icon" href="/favicon.ico" />
<link data-react-helmet="true" rel="shortcut icon" href="favicon.ico" />
</head>
<body data-theme="default">
<script>
@@ -136,7 +137,7 @@
})(document, 'script');
}
</script>
<link rel="stylesheet" href="/css/uPlot.min.css" />
<link rel="stylesheet" href="css/uPlot.min.css" />
<script type="module" src="./src/index.tsx"></script>
</body>
</html>

View File

@@ -63,7 +63,6 @@
"@signozhq/radio-group": "0.0.4",
"@signozhq/resizable": "0.0.2",
"@signozhq/table": "0.3.7",
"@signozhq/tabs": "0.0.11",
"@signozhq/toggle-group": "0.0.3",
"@signozhq/ui": "0.0.5",
"@tanstack/react-table": "8.21.3",

View File

@@ -244,18 +244,12 @@ export const ShortcutsPage = Loadable(
() => import(/* webpackChunkName: "ShortcutsPage" */ 'pages/Settings'),
);
export const Integrations = Loadable(
export const InstalledIntegrations = Loadable(
() =>
import(
/* webpackChunkName: "InstalledIntegrations" */ 'pages/IntegrationsModulePage'
),
);
export const IntegrationsDetailsPage = Loadable(
() =>
import(
/* webpackChunkName: "IntegrationsDetailsPage" */ 'pages/IntegrationsDetailsPage'
),
);
export const MessagingQueuesMainPage = Loadable(
() =>

View File

@@ -18,8 +18,7 @@ import {
ForgotPassword,
Home,
InfrastructureMonitoring,
Integrations,
IntegrationsDetailsPage,
InstalledIntegrations,
LicensePage,
ListAllALertsPage,
LiveLogs,
@@ -390,17 +389,10 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'WORKSPACE_ACCESS_RESTRICTED',
},
{
path: ROUTES.INTEGRATIONS_DETAIL,
exact: true,
component: IntegrationsDetailsPage,
isPrivate: true,
key: 'INTEGRATIONS_DETAIL',
},
{
path: ROUTES.INTEGRATIONS,
exact: true,
component: Integrations,
component: InstalledIntegrations,
isPrivate: true,
key: 'INTEGRATIONS',
},

View File

@@ -2,6 +2,7 @@ import { initReactI18next } from 'react-i18next';
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
import { getBasePath } from 'utils/basePath';
import cacheBursting from '../../i18n-translations-hash.json';
@@ -24,7 +25,7 @@ i18n
const ns = namespace[0];
const pathkey = `/${language}/${ns}`;
const hash = cacheBursting[pathkey as keyof typeof cacheBursting] || '';
return `/locales/${language}/${namespace}.json?h=${hash}`;
return `${getBasePath()}locales/${language}/${namespace}.json?h=${hash}`;
},
},
react: {

View File

@@ -0,0 +1,19 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
const removeAwsIntegrationAccount = async (
accountId: string,
): Promise<SuccessResponse<Record<string, never>> | ErrorResponse> => {
const response = await axios.post(
`/cloud-integrations/aws/accounts/${accountId}/disconnect`,
);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default removeAwsIntegrationAccount;

View File

@@ -1,5 +1,6 @@
import {
interceptorRejected,
interceptorsRequestBasePath,
interceptorsRequestResponse,
interceptorsResponse,
} from 'api';
@@ -17,6 +18,7 @@ export const GeneratedAPIInstance = <T>(
return generatedAPIAxiosInstance({ ...config }).then(({ data }) => data);
};
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
generatedAPIAxiosInstance.interceptors.response.use(
interceptorsResponse,

View File

@@ -11,6 +11,7 @@ import axios, {
import { ENVIRONMENT } from 'constants/env';
import { Events } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { getBasePath } from 'utils/basePath';
import { eventEmitter } from 'utils/getEventEmitter';
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
@@ -67,6 +68,39 @@ export const interceptorsRequestResponse = (
return value;
};
// Strips the leading '/' from path and joins with base — idempotent if already prefixed.
// e.g. prependBase('/signoz/', '/api/v1/') → '/signoz/api/v1/'
function prependBase(base: string, path: string): string {
return path.startsWith(base) ? path : base + path.slice(1);
}
// Prepends the runtime base path to outgoing requests so API calls work under
// a URL prefix (e.g. /signoz/api/v1/…). No-op for root deployments and dev
// (dev baseURL is a full http:// URL, not an absolute path).
export const interceptorsRequestBasePath = (
value: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig => {
const basePath = getBasePath();
if (basePath === '/') {
return value;
}
if (value.baseURL?.startsWith('/')) {
// Production relative baseURL: '/api/v1/' → '/signoz/api/v1/'
value.baseURL = prependBase(basePath, value.baseURL);
} else if (value.baseURL?.startsWith('http')) {
// Dev absolute baseURL (VITE_FRONTEND_API_ENDPOINT): 'https://host/api/v1/' → 'https://host/signoz/api/v1/'
const url = new URL(value.baseURL);
url.pathname = prependBase(basePath, url.pathname);
value.baseURL = url.toString();
} else if (!value.baseURL && value.url?.startsWith('/')) {
// Orval-generated client (empty baseURL, path in url): '/api/signoz/v1/rules' → '/signoz/api/signoz/v1/rules'
value.url = prependBase(basePath, value.url);
}
return value;
};
export const interceptorRejected = async (
value: AxiosResponse<any>,
): Promise<AxiosResponse<any>> => {
@@ -133,6 +167,7 @@ const instance = axios.create({
});
instance.interceptors.request.use(interceptorsRequestResponse);
instance.interceptors.request.use(interceptorsRequestBasePath);
instance.interceptors.response.use(interceptorsResponse, interceptorRejected);
export const AxiosAlertManagerInstance = axios.create({
@@ -147,6 +182,7 @@ ApiV2Instance.interceptors.response.use(
interceptorRejected,
);
ApiV2Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV2Instance.interceptors.request.use(interceptorsRequestBasePath);
// axios V3
export const ApiV3Instance = axios.create({
@@ -158,6 +194,7 @@ ApiV3Instance.interceptors.response.use(
interceptorRejected,
);
ApiV3Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV3Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios V4
@@ -170,6 +207,7 @@ ApiV4Instance.interceptors.response.use(
interceptorRejected,
);
ApiV4Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV4Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios V5
@@ -182,6 +220,7 @@ ApiV5Instance.interceptors.response.use(
interceptorRejected,
);
ApiV5Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV5Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios Base
@@ -194,6 +233,7 @@ LogEventAxiosInstance.interceptors.response.use(
interceptorRejectedBase,
);
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
//
AxiosAlertManagerInstance.interceptors.response.use(
@@ -201,6 +241,7 @@ AxiosAlertManagerInstance.interceptors.response.use(
interceptorRejected,
);
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestResponse);
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestBasePath);
export { apiV1 };
export default instance;

View File

@@ -0,0 +1,88 @@
import axios from 'api';
import {
CloudAccount,
Service,
ServiceData,
UpdateServiceConfigPayload,
UpdateServiceConfigResponse,
} from 'container/CloudIntegrationPage/ServicesSection/types';
import {
AccountConfigPayload,
AccountConfigResponse,
ConnectionParams,
ConnectionUrlResponse,
} from 'types/api/integrations/aws';
export const getAwsAccounts = async (): Promise<CloudAccount[]> => {
const response = await axios.get('/cloud-integrations/aws/accounts');
return response.data.data.accounts;
};
export const getAwsServices = async (
cloudAccountId?: string,
): Promise<Service[]> => {
const params = cloudAccountId
? { cloud_account_id: cloudAccountId }
: undefined;
const response = await axios.get('/cloud-integrations/aws/services', {
params,
});
return response.data.data.services;
};
export const getServiceDetails = async (
serviceId: string,
cloudAccountId?: string,
): Promise<ServiceData> => {
const params = cloudAccountId
? { cloud_account_id: cloudAccountId }
: undefined;
const response = await axios.get(
`/cloud-integrations/aws/services/${serviceId}`,
{ params },
);
return response.data.data;
};
export const generateConnectionUrl = async (params: {
agent_config: { region: string };
account_config: { regions: string[] };
account_id?: string;
}): Promise<ConnectionUrlResponse> => {
const response = await axios.post(
'/cloud-integrations/aws/accounts/generate-connection-url',
params,
);
return response.data.data;
};
export const updateAccountConfig = async (
accountId: string,
payload: AccountConfigPayload,
): Promise<AccountConfigResponse> => {
const response = await axios.post<AccountConfigResponse>(
`/cloud-integrations/aws/accounts/${accountId}/config`,
payload,
);
return response.data;
};
export const updateServiceConfig = async (
serviceId: string,
payload: UpdateServiceConfigPayload,
): Promise<UpdateServiceConfigResponse> => {
const response = await axios.post<UpdateServiceConfigResponse>(
`/cloud-integrations/aws/services/${serviceId}/config`,
payload,
);
return response.data;
};
export const getConnectionParams = async (): Promise<ConnectionParams> => {
const response = await axios.get(
'/cloud-integrations/aws/accounts/generate-connection-params',
);
return response.data.data;
};

View File

@@ -1,70 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 142.5 145.6" style="enable-background:new 0 0 142.5 145.6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#565656;}
.st1{fill:url(#SVGID_1_);}
</style>
<g>
<path class="st0" d="M28.7,131.5c-0.3,7.9-6.6,14.1-14.4,14.1C6.1,145.6,0,139,0,130.9s6.6-14.7,14.7-14.7c3.6,0,7.2,1.6,10.2,4.4
l-2.3,2.9c-2.3-2-5.1-3.4-7.9-3.4c-5.9,0-10.8,4.8-10.8,10.8c0,6.1,4.6,10.8,10.4,10.8c5.2,0,9.3-3.8,10.2-8.8H12.6v-3.5h16.1
V131.5z"/>
<path class="st0" d="M42.3,129.5h-2.2c-2.4,0-4.4,2-4.4,4.4v11.4h-3.9v-19.6H35v1.6c1.1-1.1,2.7-1.6,4.6-1.6h4.2L42.3,129.5z"/>
<path class="st0" d="M63.7,145.3h-3.4v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4V145.3z M59.7,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
C57.1,141.2,59.1,139.3,59.7,137z"/>
<path class="st0" d="M71.5,124.7v1.1h6.2v3.4h-6.2v16.1h-3.8v-20.5c0-4.3,3.1-6.8,7-6.8h4.7l-1.6,3.7h-3.1
C72.9,121.6,71.5,123,71.5,124.7z"/>
<path class="st0" d="M98.5,145.3h-3.3v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4v19.6H98.5z M94.5,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
C92,141.2,93.9,139.3,94.5,137z"/>
<path class="st0" d="M119.4,133.8v11.5h-3.9v-11.6c0-2.4-2-4.4-4.4-4.4c-2.5,0-4.4,2-4.4,4.4v11.6h-3.9v-19.6h3.2v1.7
c1.4-1.3,3.3-2,5.2-2C115.8,125.5,119.4,129.2,119.4,133.8z"/>
<path class="st0" d="M142.4,145.3h-3.3v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4v19.6H142.4z M138.4,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
C135.9,141.2,137.8,139.3,138.4,137z"/>
</g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="71.25" y1="10.4893" x2="71.25" y2="113.3415" gradientTransform="matrix(1 0 0 -1 0 148.6)">
<stop offset="0" style="stop-color:#FCEE1F"/>
<stop offset="1" style="stop-color:#F15B2A"/>
</linearGradient>
<path class="st1" d="M122.9,49.9c-0.2-1.9-0.5-4.1-1.1-6.5c-0.6-2.4-1.6-5-2.9-7.8c-1.4-2.7-3.1-5.6-5.4-8.3
c-0.9-1.1-1.9-2.1-2.9-3.2c1.6-6.3-1.9-11.8-1.9-11.8c-6.1-0.4-9.9,1.9-11.3,2.9c-0.2-0.1-0.5-0.2-0.7-0.3c-1-0.4-2.1-0.8-3.2-1.2
c-1.1-0.3-2.2-0.7-3.3-0.9c-1.1-0.3-2.3-0.5-3.5-0.7c-0.2,0-0.4-0.1-0.6-0.1C83.5,3.6,75.9,0,75.9,0c-8.7,5.6-10.4,13.1-10.4,13.1
s0,0.2-0.1,0.4c-0.5,0.1-0.9,0.3-1.4,0.4c-0.6,0.2-1.3,0.4-1.9,0.7c-0.6,0.3-1.3,0.5-1.9,0.8c-1.3,0.6-2.5,1.2-3.8,1.9
c-1.2,0.7-2.4,1.4-3.5,2.2c-0.2-0.1-0.3-0.2-0.3-0.2c-11.7-4.5-22.1,0.9-22.1,0.9c-0.9,12.5,4.7,20.3,5.8,21.7
c-0.3,0.8-0.5,1.5-0.8,2.3c-0.9,2.8-1.5,5.7-1.9,8.7c-0.1,0.4-0.1,0.9-0.2,1.3c-10.8,5.3-14,16.3-14,16.3c9,10.4,19.6,11,19.6,11
l0,0c1.3,2.4,2.9,4.7,4.6,6.8c0.7,0.9,1.5,1.7,2.3,2.6c-3.3,9.4,0.5,17.3,0.5,17.3c10.1,0.4,16.7-4.4,18.1-5.5c1,0.3,2,0.6,3,0.9
c3.1,0.8,6.3,1.3,9.4,1.4c0.8,0,1.6,0,2.4,0h0.4H80h0.5H81l0,0c4.7,6.8,13.1,7.7,13.1,7.7c5.9-6.3,6.3-12.4,6.3-13.8l0,0
c0,0,0,0,0-0.1s0-0.2,0-0.2l0,0c0-0.1,0-0.2,0-0.3c1.2-0.9,2.4-1.8,3.6-2.8c2.4-2.1,4.4-4.6,6.2-7.2c0.2-0.2,0.3-0.5,0.5-0.7
c6.7,0.4,11.4-4.2,11.4-4.2c-1.1-7-5.1-10.4-5.9-11l0,0c0,0,0,0-0.1-0.1l-0.1-0.1l0,0l-0.1-0.1c0-0.4,0.1-0.8,0.1-1.3
c0.1-0.8,0.1-1.5,0.1-2.3v-0.6v-0.3v-0.1c0-0.2,0-0.1,0-0.2v-0.5v-0.6c0-0.2,0-0.4,0-0.6s0-0.4-0.1-0.6l-0.1-0.6l-0.1-0.6
c-0.1-0.8-0.3-1.5-0.4-2.3c-0.7-3-1.9-5.9-3.4-8.4c-1.6-2.6-3.5-4.8-5.7-6.8c-2.2-1.9-4.6-3.5-7.2-4.6c-2.6-1.2-5.2-1.9-7.9-2.2
c-1.3-0.2-2.7-0.2-4-0.2h-0.5h-0.1h-0.2h-0.2h-0.5c-0.2,0-0.4,0-0.5,0c-0.7,0.1-1.4,0.2-2,0.3c-2.7,0.5-5.2,1.5-7.4,2.8
c-2.2,1.3-4.1,3-5.7,4.9s-2.8,3.9-3.6,6.1c-0.8,2.1-1.3,4.4-1.4,6.5c0,0.5,0,1.1,0,1.6c0,0.1,0,0.3,0,0.4v0.4c0,0.3,0,0.5,0.1,0.8
c0.1,1.1,0.3,2.1,0.6,3.1c0.6,2,1.5,3.8,2.7,5.4s2.5,2.8,4,3.8s3,1.7,4.6,2.2c1.6,0.5,3.1,0.7,4.5,0.6c0.2,0,0.4,0,0.5,0
c0.1,0,0.2,0,0.3,0s0.2,0,0.3,0c0.2,0,0.3,0,0.5,0h0.1h0.1c0.1,0,0.2,0,0.3,0c0.2,0,0.4-0.1,0.5-0.1c0.2,0,0.3-0.1,0.5-0.1
c0.3-0.1,0.7-0.2,1-0.3c0.6-0.2,1.2-0.5,1.8-0.7c0.6-0.3,1.1-0.6,1.5-0.9c0.1-0.1,0.3-0.2,0.4-0.3c0.5-0.4,0.6-1.1,0.2-1.6
c-0.4-0.4-1-0.5-1.5-0.3C88,74,87.9,74,87.7,74.1c-0.4,0.2-0.9,0.4-1.3,0.5c-0.5,0.1-1,0.3-1.5,0.4c-0.3,0-0.5,0.1-0.8,0.1
c-0.1,0-0.3,0-0.4,0c-0.1,0-0.3,0-0.4,0s-0.3,0-0.4,0c-0.2,0-0.3,0-0.5,0c0,0-0.1,0,0,0h-0.1h-0.1c-0.1,0-0.1,0-0.2,0
s-0.3,0-0.4-0.1c-1.1-0.2-2.3-0.5-3.4-1c-1.1-0.5-2.2-1.2-3.1-2.1c-1-0.9-1.8-1.9-2.5-3.1c-0.7-1.2-1.1-2.5-1.3-3.8
c-0.1-0.7-0.2-1.4-0.1-2.1c0-0.2,0-0.4,0-0.6c0,0.1,0,0,0,0v-0.1v-0.1c0-0.1,0-0.2,0-0.3c0-0.4,0.1-0.7,0.2-1.1c0.5-3,2-5.9,4.3-8.1
c0.6-0.6,1.2-1.1,1.9-1.5c0.7-0.5,1.4-0.9,2.1-1.2c0.7-0.3,1.5-0.6,2.3-0.8s1.6-0.4,2.4-0.4c0.4,0,0.8-0.1,1.2-0.1
c0.1,0,0.2,0,0.3,0h0.3h0.2c0.1,0,0,0,0,0h0.1h0.3c0.9,0.1,1.8,0.2,2.6,0.4c1.7,0.4,3.4,1,5,1.9c3.2,1.8,5.9,4.5,7.5,7.8
c0.8,1.6,1.4,3.4,1.7,5.3c0.1,0.5,0.1,0.9,0.2,1.4v0.3V66c0,0.1,0,0.2,0,0.3c0,0.1,0,0.2,0,0.3v0.3v0.3c0,0.2,0,0.6,0,0.8
c0,0.5-0.1,1-0.1,1.5c-0.1,0.5-0.1,1-0.2,1.5s-0.2,1-0.3,1.5c-0.2,1-0.6,1.9-0.9,2.9c-0.7,1.9-1.7,3.7-2.9,5.3
c-2.4,3.3-5.7,6-9.4,7.7c-1.9,0.8-3.8,1.5-5.8,1.8c-1,0.2-2,0.3-3,0.3H81h-0.2h-0.3H80h-0.3c0.1,0,0,0,0,0h-0.1
c-0.5,0-1.1,0-1.6-0.1c-2.2-0.2-4.3-0.6-6.4-1.2c-2.1-0.6-4.1-1.4-6-2.4c-3.8-2-7.2-4.9-9.9-8.2c-1.3-1.7-2.5-3.5-3.5-5.4
s-1.7-3.9-2.3-5.9c-0.6-2-0.9-4.1-1-6.2v-0.4v-0.1v-0.1v-0.2V60v-0.1v-0.1v-0.2v-0.5V59l0,0v-0.2c0-0.3,0-0.5,0-0.8
c0-1,0.1-2.1,0.3-3.2c0.1-1.1,0.3-2.1,0.5-3.2c0.2-1.1,0.5-2.1,0.8-3.2c0.6-2.1,1.3-4.1,2.2-6c1.8-3.8,4.1-7.2,6.8-9.9
c0.7-0.7,1.4-1.3,2.2-1.9c0.3-0.3,1-0.9,1.8-1.4c0.8-0.5,1.6-1,2.5-1.4c0.4-0.2,0.8-0.4,1.3-0.6c0.2-0.1,0.4-0.2,0.7-0.3
c0.2-0.1,0.4-0.2,0.7-0.3c0.9-0.4,1.8-0.7,2.7-1c0.2-0.1,0.5-0.1,0.7-0.2c0.2-0.1,0.5-0.1,0.7-0.2c0.5-0.1,0.9-0.2,1.4-0.4
c0.2-0.1,0.5-0.1,0.7-0.2c0.2,0,0.5-0.1,0.7-0.1c0.2,0,0.5-0.1,0.7-0.1l0.4-0.1l0.4-0.1c0.2,0,0.5-0.1,0.7-0.1
c0.3,0,0.5-0.1,0.8-0.1c0.2,0,0.6-0.1,0.8-0.1c0.2,0,0.3,0,0.5-0.1h0.3h0.2h0.2c0.3,0,0.5,0,0.8-0.1h0.4c0,0,0.1,0,0,0h0.1h0.2
c0.2,0,0.5,0,0.7,0c0.9,0,1.8,0,2.7,0c1.8,0.1,3.6,0.3,5.3,0.6c3.4,0.6,6.7,1.7,9.6,3.2c2.9,1.4,5.6,3.2,7.8,5.1
c0.1,0.1,0.3,0.2,0.4,0.4c0.1,0.1,0.3,0.2,0.4,0.4c0.3,0.2,0.5,0.5,0.8,0.7c0.3,0.2,0.5,0.5,0.8,0.7c0.2,0.3,0.5,0.5,0.7,0.8
c1,1,1.9,2.1,2.7,3.1c1.6,2.1,2.9,4.2,3.9,6.2c0.1,0.1,0.1,0.2,0.2,0.4c0.1,0.1,0.1,0.2,0.2,0.4s0.2,0.5,0.4,0.7
c0.1,0.2,0.2,0.5,0.3,0.7c0.1,0.2,0.2,0.5,0.3,0.7c0.4,0.9,0.7,1.8,1,2.7c0.5,1.4,0.8,2.6,1.1,3.6c0.1,0.4,0.5,0.7,0.9,0.7
c0.5,0,0.8-0.4,0.8-0.9C123,52.7,123,51.4,122.9,49.9z"/>
</svg>
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 142.5 145.6" style="enable-background:new 0 0 142.5 145.6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#565656;}
.st1{fill:url(#SVGID_1_);}
</style>
<g>
<path class="st0" d="M28.7,131.5c-0.3,7.9-6.6,14.1-14.4,14.1C6.1,145.6,0,139,0,130.9s6.6-14.7,14.7-14.7c3.6,0,7.2,1.6,10.2,4.4
l-2.3,2.9c-2.3-2-5.1-3.4-7.9-3.4c-5.9,0-10.8,4.8-10.8,10.8c0,6.1,4.6,10.8,10.4,10.8c5.2,0,9.3-3.8,10.2-8.8H12.6v-3.5h16.1
V131.5z"/>
<path class="st0" d="M42.3,129.5h-2.2c-2.4,0-4.4,2-4.4,4.4v11.4h-3.9v-19.6H35v1.6c1.1-1.1,2.7-1.6,4.6-1.6h4.2L42.3,129.5z"/>
<path class="st0" d="M63.7,145.3h-3.4v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4V145.3z M59.7,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
C57.1,141.2,59.1,139.3,59.7,137z"/>
<path class="st0" d="M71.5,124.7v1.1h6.2v3.4h-6.2v16.1h-3.8v-20.5c0-4.3,3.1-6.8,7-6.8h4.7l-1.6,3.7h-3.1
C72.9,121.6,71.5,123,71.5,124.7z"/>
<path class="st0" d="M98.5,145.3h-3.3v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4v19.6H98.5z M94.5,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
C92,141.2,93.9,139.3,94.5,137z"/>
<path class="st0" d="M119.4,133.8v11.5h-3.9v-11.6c0-2.4-2-4.4-4.4-4.4c-2.5,0-4.4,2-4.4,4.4v11.6h-3.9v-19.6h3.2v1.7
c1.4-1.3,3.3-2,5.2-2C115.8,125.5,119.4,129.2,119.4,133.8z"/>
<path class="st0" d="M142.4,145.3h-3.3v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4v19.6H142.4z M138.4,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
C135.9,141.2,137.8,139.3,138.4,137z"/>
</g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="71.25" y1="10.4893" x2="71.25" y2="113.3415" gradientTransform="matrix(1 0 0 -1 0 148.6)">
<stop offset="0" style="stop-color:#FCEE1F"/>
<stop offset="1" style="stop-color:#F15B2A"/>
</linearGradient>
<path class="st1" d="M122.9,49.9c-0.2-1.9-0.5-4.1-1.1-6.5c-0.6-2.4-1.6-5-2.9-7.8c-1.4-2.7-3.1-5.6-5.4-8.3
c-0.9-1.1-1.9-2.1-2.9-3.2c1.6-6.3-1.9-11.8-1.9-11.8c-6.1-0.4-9.9,1.9-11.3,2.9c-0.2-0.1-0.5-0.2-0.7-0.3c-1-0.4-2.1-0.8-3.2-1.2
c-1.1-0.3-2.2-0.7-3.3-0.9c-1.1-0.3-2.3-0.5-3.5-0.7c-0.2,0-0.4-0.1-0.6-0.1C83.5,3.6,75.9,0,75.9,0c-8.7,5.6-10.4,13.1-10.4,13.1
s0,0.2-0.1,0.4c-0.5,0.1-0.9,0.3-1.4,0.4c-0.6,0.2-1.3,0.4-1.9,0.7c-0.6,0.3-1.3,0.5-1.9,0.8c-1.3,0.6-2.5,1.2-3.8,1.9
c-1.2,0.7-2.4,1.4-3.5,2.2c-0.2-0.1-0.3-0.2-0.3-0.2c-11.7-4.5-22.1,0.9-22.1,0.9c-0.9,12.5,4.7,20.3,5.8,21.7
c-0.3,0.8-0.5,1.5-0.8,2.3c-0.9,2.8-1.5,5.7-1.9,8.7c-0.1,0.4-0.1,0.9-0.2,1.3c-10.8,5.3-14,16.3-14,16.3c9,10.4,19.6,11,19.6,11
l0,0c1.3,2.4,2.9,4.7,4.6,6.8c0.7,0.9,1.5,1.7,2.3,2.6c-3.3,9.4,0.5,17.3,0.5,17.3c10.1,0.4,16.7-4.4,18.1-5.5c1,0.3,2,0.6,3,0.9
c3.1,0.8,6.3,1.3,9.4,1.4c0.8,0,1.6,0,2.4,0h0.4H80h0.5H81l0,0c4.7,6.8,13.1,7.7,13.1,7.7c5.9-6.3,6.3-12.4,6.3-13.8l0,0
c0,0,0,0,0-0.1s0-0.2,0-0.2l0,0c0-0.1,0-0.2,0-0.3c1.2-0.9,2.4-1.8,3.6-2.8c2.4-2.1,4.4-4.6,6.2-7.2c0.2-0.2,0.3-0.5,0.5-0.7
c6.7,0.4,11.4-4.2,11.4-4.2c-1.1-7-5.1-10.4-5.9-11l0,0c0,0,0,0-0.1-0.1l-0.1-0.1l0,0l-0.1-0.1c0-0.4,0.1-0.8,0.1-1.3
c0.1-0.8,0.1-1.5,0.1-2.3v-0.6v-0.3v-0.1c0-0.2,0-0.1,0-0.2v-0.5v-0.6c0-0.2,0-0.4,0-0.6s0-0.4-0.1-0.6l-0.1-0.6l-0.1-0.6
c-0.1-0.8-0.3-1.5-0.4-2.3c-0.7-3-1.9-5.9-3.4-8.4c-1.6-2.6-3.5-4.8-5.7-6.8c-2.2-1.9-4.6-3.5-7.2-4.6c-2.6-1.2-5.2-1.9-7.9-2.2
c-1.3-0.2-2.7-0.2-4-0.2h-0.5h-0.1h-0.2h-0.2h-0.5c-0.2,0-0.4,0-0.5,0c-0.7,0.1-1.4,0.2-2,0.3c-2.7,0.5-5.2,1.5-7.4,2.8
c-2.2,1.3-4.1,3-5.7,4.9s-2.8,3.9-3.6,6.1c-0.8,2.1-1.3,4.4-1.4,6.5c0,0.5,0,1.1,0,1.6c0,0.1,0,0.3,0,0.4v0.4c0,0.3,0,0.5,0.1,0.8
c0.1,1.1,0.3,2.1,0.6,3.1c0.6,2,1.5,3.8,2.7,5.4s2.5,2.8,4,3.8s3,1.7,4.6,2.2c1.6,0.5,3.1,0.7,4.5,0.6c0.2,0,0.4,0,0.5,0
c0.1,0,0.2,0,0.3,0s0.2,0,0.3,0c0.2,0,0.3,0,0.5,0h0.1h0.1c0.1,0,0.2,0,0.3,0c0.2,0,0.4-0.1,0.5-0.1c0.2,0,0.3-0.1,0.5-0.1
c0.3-0.1,0.7-0.2,1-0.3c0.6-0.2,1.2-0.5,1.8-0.7c0.6-0.3,1.1-0.6,1.5-0.9c0.1-0.1,0.3-0.2,0.4-0.3c0.5-0.4,0.6-1.1,0.2-1.6
c-0.4-0.4-1-0.5-1.5-0.3C88,74,87.9,74,87.7,74.1c-0.4,0.2-0.9,0.4-1.3,0.5c-0.5,0.1-1,0.3-1.5,0.4c-0.3,0-0.5,0.1-0.8,0.1
c-0.1,0-0.3,0-0.4,0c-0.1,0-0.3,0-0.4,0s-0.3,0-0.4,0c-0.2,0-0.3,0-0.5,0c0,0-0.1,0,0,0h-0.1h-0.1c-0.1,0-0.1,0-0.2,0
s-0.3,0-0.4-0.1c-1.1-0.2-2.3-0.5-3.4-1c-1.1-0.5-2.2-1.2-3.1-2.1c-1-0.9-1.8-1.9-2.5-3.1c-0.7-1.2-1.1-2.5-1.3-3.8
c-0.1-0.7-0.2-1.4-0.1-2.1c0-0.2,0-0.4,0-0.6c0,0.1,0,0,0,0v-0.1v-0.1c0-0.1,0-0.2,0-0.3c0-0.4,0.1-0.7,0.2-1.1c0.5-3,2-5.9,4.3-8.1
c0.6-0.6,1.2-1.1,1.9-1.5c0.7-0.5,1.4-0.9,2.1-1.2c0.7-0.3,1.5-0.6,2.3-0.8s1.6-0.4,2.4-0.4c0.4,0,0.8-0.1,1.2-0.1
c0.1,0,0.2,0,0.3,0h0.3h0.2c0.1,0,0,0,0,0h0.1h0.3c0.9,0.1,1.8,0.2,2.6,0.4c1.7,0.4,3.4,1,5,1.9c3.2,1.8,5.9,4.5,7.5,7.8
c0.8,1.6,1.4,3.4,1.7,5.3c0.1,0.5,0.1,0.9,0.2,1.4v0.3V66c0,0.1,0,0.2,0,0.3c0,0.1,0,0.2,0,0.3v0.3v0.3c0,0.2,0,0.6,0,0.8
c0,0.5-0.1,1-0.1,1.5c-0.1,0.5-0.1,1-0.2,1.5s-0.2,1-0.3,1.5c-0.2,1-0.6,1.9-0.9,2.9c-0.7,1.9-1.7,3.7-2.9,5.3
c-2.4,3.3-5.7,6-9.4,7.7c-1.9,0.8-3.8,1.5-5.8,1.8c-1,0.2-2,0.3-3,0.3H81h-0.2h-0.3H80h-0.3c0.1,0,0,0,0,0h-0.1
c-0.5,0-1.1,0-1.6-0.1c-2.2-0.2-4.3-0.6-6.4-1.2c-2.1-0.6-4.1-1.4-6-2.4c-3.8-2-7.2-4.9-9.9-8.2c-1.3-1.7-2.5-3.5-3.5-5.4
s-1.7-3.9-2.3-5.9c-0.6-2-0.9-4.1-1-6.2v-0.4v-0.1v-0.1v-0.2V60v-0.1v-0.1v-0.2v-0.5V59l0,0v-0.2c0-0.3,0-0.5,0-0.8
c0-1,0.1-2.1,0.3-3.2c0.1-1.1,0.3-2.1,0.5-3.2c0.2-1.1,0.5-2.1,0.8-3.2c0.6-2.1,1.3-4.1,2.2-6c1.8-3.8,4.1-7.2,6.8-9.9
c0.7-0.7,1.4-1.3,2.2-1.9c0.3-0.3,1-0.9,1.8-1.4c0.8-0.5,1.6-1,2.5-1.4c0.4-0.2,0.8-0.4,1.3-0.6c0.2-0.1,0.4-0.2,0.7-0.3
c0.2-0.1,0.4-0.2,0.7-0.3c0.9-0.4,1.8-0.7,2.7-1c0.2-0.1,0.5-0.1,0.7-0.2c0.2-0.1,0.5-0.1,0.7-0.2c0.5-0.1,0.9-0.2,1.4-0.4
c0.2-0.1,0.5-0.1,0.7-0.2c0.2,0,0.5-0.1,0.7-0.1c0.2,0,0.5-0.1,0.7-0.1l0.4-0.1l0.4-0.1c0.2,0,0.5-0.1,0.7-0.1
c0.3,0,0.5-0.1,0.8-0.1c0.2,0,0.6-0.1,0.8-0.1c0.2,0,0.3,0,0.5-0.1h0.3h0.2h0.2c0.3,0,0.5,0,0.8-0.1h0.4c0,0,0.1,0,0,0h0.1h0.2
c0.2,0,0.5,0,0.7,0c0.9,0,1.8,0,2.7,0c1.8,0.1,3.6,0.3,5.3,0.6c3.4,0.6,6.7,1.7,9.6,3.2c2.9,1.4,5.6,3.2,7.8,5.1
c0.1,0.1,0.3,0.2,0.4,0.4c0.1,0.1,0.3,0.2,0.4,0.4c0.3,0.2,0.5,0.5,0.8,0.7c0.3,0.2,0.5,0.5,0.8,0.7c0.2,0.3,0.5,0.5,0.7,0.8
c1,1,1.9,2.1,2.7,3.1c1.6,2.1,2.9,4.2,3.9,6.2c0.1,0.1,0.1,0.2,0.2,0.4c0.1,0.1,0.1,0.2,0.2,0.4s0.2,0.5,0.4,0.7
c0.1,0.2,0.2,0.5,0.3,0.7c0.1,0.2,0.2,0.5,0.3,0.7c0.4,0.9,0.7,1.8,1,2.7c0.5,1.4,0.8,2.6,1.1,3.6c0.1,0.4,0.5,0.7,0.9,0.7
c0.5,0,0.8-0.4,0.8-0.9C123,52.7,123,51.4,122.9,49.9z"/>
</svg>

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -1,21 +1,21 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<polygon style="fill:#FFD500;" points="382.395,228.568 291.215,228.568 330.762,10.199 129.603,283.43 220.785,283.43
181.238,501.799 "/>
<g>
<path style="fill:#3D3D3D;" d="M181.234,512c-1.355,0-2.726-0.271-4.033-0.833c-4.357-1.878-6.845-6.514-5.999-11.184
l37.371-206.353h-78.969c-3.846,0-7.367-2.164-9.103-5.597c-1.735-3.433-1.391-7.55,0.889-10.648L322.548,4.153
c2.814-3.822,7.891-5.196,12.25-3.32c4.357,1.878,6.845,6.514,5.999,11.184L303.427,218.37h78.969c3.846,0,7.367,2.164,9.103,5.597
c1.735,3.433,1.391,7.55-0.889,10.648L189.451,507.846C187.481,510.523,184.399,512,181.234,512z M149.777,273.231h71.007
c3.023,0,5.89,1.341,7.828,3.662c1.938,2.32,2.747,5.38,2.208,8.355l-31.704,175.065l163.105-221.545h-71.007
c-3.023,0-5.89-1.341-7.828-3.661c-1.938-2.32-2.747-5.38-2.208-8.355l31.704-175.065L149.777,273.231z"/>
<path style="fill:#3D3D3D;" d="M267.666,171.348c-0.604,0-1.215-0.054-1.829-0.165c-5.543-1.004-9.223-6.31-8.22-11.853l0.923-5.1
c1.003-5.543,6.323-9.225,11.852-8.219c5.543,1.004,9.223,6.31,8.22,11.853l-0.923,5.1
C276.797,167.892,272.503,171.348,267.666,171.348z"/>
<path style="fill:#3D3D3D;" d="M255.455,238.77c-0.604,0-1.215-0.054-1.83-0.165c-5.543-1.004-9.222-6.31-8.218-11.853
l7.037-38.864c1.004-5.543,6.317-9.225,11.854-8.219c5.543,1.004,9.222,6.31,8.219,11.853l-7.037,38.864
C264.587,235.314,260.293,238.77,255.455,238.77z"/>
</g>
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<polygon style="fill:#FFD500;" points="382.395,228.568 291.215,228.568 330.762,10.199 129.603,283.43 220.785,283.43
181.238,501.799 "/>
<g>
<path style="fill:#3D3D3D;" d="M181.234,512c-1.355,0-2.726-0.271-4.033-0.833c-4.357-1.878-6.845-6.514-5.999-11.184
l37.371-206.353h-78.969c-3.846,0-7.367-2.164-9.103-5.597c-1.735-3.433-1.391-7.55,0.889-10.648L322.548,4.153
c2.814-3.822,7.891-5.196,12.25-3.32c4.357,1.878,6.845,6.514,5.999,11.184L303.427,218.37h78.969c3.846,0,7.367,2.164,9.103,5.597
c1.735,3.433,1.391,7.55-0.889,10.648L189.451,507.846C187.481,510.523,184.399,512,181.234,512z M149.777,273.231h71.007
c3.023,0,5.89,1.341,7.828,3.662c1.938,2.32,2.747,5.38,2.208,8.355l-31.704,175.065l163.105-221.545h-71.007
c-3.023,0-5.89-1.341-7.828-3.661c-1.938-2.32-2.747-5.38-2.208-8.355l31.704-175.065L149.777,273.231z"/>
<path style="fill:#3D3D3D;" d="M267.666,171.348c-0.604,0-1.215-0.054-1.829-0.165c-5.543-1.004-9.223-6.31-8.22-11.853l0.923-5.1
c1.003-5.543,6.323-9.225,11.852-8.219c5.543,1.004,9.223,6.31,8.22,11.853l-0.923,5.1
C276.797,167.892,272.503,171.348,267.666,171.348z"/>
<path style="fill:#3D3D3D;" d="M255.455,238.77c-0.604,0-1.215-0.054-1.83-0.165c-5.543-1.004-9.222-6.31-8.218-11.853
l7.037-38.864c1.004-5.543,6.317-9.225,11.854-8.219c5.543,1.004,9.222,6.31,8.219,11.853l-7.037,38.864
C264.587,235.314,260.293,238.77,255.455,238.77z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,9 +0,0 @@
<svg width="929" height="8" viewBox="0 0 929 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="dotted-double-line-pattern" x="0" y="0" width="6" height="8" patternUnits="userSpaceOnUse">
<rect width="2" height="2" rx="1" fill="#242834" />
<rect y="6" width="2" height="2" rx="1" fill="#242834" />
</pattern>
</defs>
<rect width="929" height="8" fill="url(#dotted-double-line-pattern)" />
</svg>

Before

Width:  |  Height:  |  Size: 442 B

View File

@@ -25,6 +25,5 @@ import '@signozhq/popover';
import '@signozhq/radio-group';
import '@signozhq/resizable';
import '@signozhq/table';
import '@signozhq/tabs';
import '@signozhq/toggle-group';
import '@signozhq/ui';

View File

@@ -12,6 +12,7 @@ import { AppState } from 'store/reducers';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { withBasePath } from 'utils/basePath';
export interface NavigateToExplorerProps {
filters: TagFilterItem[];
@@ -133,7 +134,7 @@ export function useNavigateToExplorer(): (
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
window.open(newExplorerPath, sameTab ? '_self' : '_blank');
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
},
[
prepareQuery,

View File

@@ -1,34 +0,0 @@
.cloud-service-data-collected {
display: flex;
flex-direction: column;
gap: 16px;
.cloud-service-data-collected-table {
display: flex;
flex-direction: column;
gap: 8px;
.cloud-service-data-collected-table-heading {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
color: var(--l2-foreground);
/* Bifrost (Ancient)/Content/sm */
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.cloud-service-data-collected-table-logs {
border-radius: 6px;
border: 1px solid var(--l3-background);
background: var(--l1-background);
}
}
}

View File

@@ -3,8 +3,8 @@ import { useLocation } from 'react-router-dom';
import { toast } from '@signozhq/ui';
import { Button, Input, Radio, RadioChangeEvent, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { handleContactSupport } from 'pages/Integrations/utils';
function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
const [activeTab, setActiveTab] = useState('feedback');

View File

@@ -13,6 +13,7 @@ import GetMinMax from 'lib/getMinMax';
import { Check, Info, Link2 } from 'lucide-react';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getAbsoluteUrl } from 'utils/basePath';
const routesToBeSharedWithTime = [
ROUTES.LOGS_EXPLORER,
@@ -80,17 +81,13 @@ function ShareURLModal(): JSX.Element {
urlQuery.delete(QueryParams.relativeTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
} else {
urlQuery.delete(QueryParams.startTime);
urlQuery.delete(QueryParams.endTime);
urlQuery.set(QueryParams.relativeTime, selectedTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
}
}

View File

@@ -4,8 +4,8 @@ import { toast } from '@signozhq/ui';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { handleContactSupport } from 'pages/Integrations/utils';
import FeedbackModal from '../FeedbackModal';
@@ -31,7 +31,7 @@ jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: jest.fn(),
}));
jest.mock('container/Integrations/utils', () => ({
jest.mock('pages/Integrations/utils', () => ({
handleContactSupport: jest.fn(),
}));

View File

@@ -50,6 +50,7 @@ import {
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { openInNewTab } from 'utils/navigation';
import { v4 as uuidv4 } from 'uuid';
import { VIEW_TYPES, VIEWS } from './constants';
@@ -330,10 +331,7 @@ function HostMetricsDetails({
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
window.open(
`${window.location.origin}${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`,
'_blank',
);
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
const compositeQuery = {
...initialQueryState,
@@ -352,10 +350,7 @@ function HostMetricsDetails({
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
window.open(
`${window.location.origin}${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`,
'_blank',
);
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}
};

View File

@@ -1,4 +1,6 @@
.log-state-indicator {
padding-left: 8px;
.line {
margin: 0 8px;
min-height: 24px;

View File

@@ -1,43 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { LogType } from './LogStateIndicator';
export function getRowBackgroundColor(
isDarkMode: boolean,
logType?: string,
): string {
if (isDarkMode) {
switch (logType) {
case LogType.INFO:
return `${Color.BG_ROBIN_500}40`;
case LogType.WARN:
return `${Color.BG_AMBER_500}40`;
case LogType.ERROR:
return `${Color.BG_CHERRY_500}40`;
case LogType.TRACE:
return `${Color.BG_FOREST_400}40`;
case LogType.DEBUG:
return `${Color.BG_AQUA_500}40`;
case LogType.FATAL:
return `${Color.BG_SAKURA_500}40`;
default:
return `${Color.BG_ROBIN_500}40`;
}
}
switch (logType) {
case LogType.INFO:
return Color.BG_ROBIN_100;
case LogType.WARN:
return Color.BG_AMBER_100;
case LogType.ERROR:
return Color.BG_CHERRY_100;
case LogType.TRACE:
return Color.BG_FOREST_200;
case LogType.DEBUG:
return Color.BG_AQUA_100;
case LogType.FATAL:
return Color.BG_SAKURA_100;
default:
return Color.BG_VANILLA_300;
}
}

View File

@@ -1,114 +0,0 @@
import type { ReactElement } from 'react';
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import type { TableColumnDef } from '../../TanStackTableView/types';
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
type UseLogsTableColumnsProps = {
fields: IField[];
fontSize: FontSize;
appendTo?: 'center' | 'end';
};
export function useLogsTableColumns({
fields,
fontSize,
appendTo = 'center',
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
return useMemo<TableColumnDef<ILog>[]>(() => {
const stateIndicatorCol: TableColumnDef<ILog> = {
id: 'state-indicator',
header: '',
pin: 'left',
enableMove: false,
enableResize: false,
enableRemove: false,
canBeHidden: false,
width: { fixed: 24 },
cell: ({ row }): ReactElement => (
<LogStateIndicator
fontSize={fontSize}
severityText={row.severity_text as string}
severityNumber={row.severity_number as number}
/>
),
};
const fieldColumns: TableColumnDef<ILog>[] = fields
.filter((f): boolean => !['id', 'body', 'timestamp'].includes(f.name))
.map(
(f): TableColumnDef<ILog> => ({
id: f.name,
header: f.name,
accessorFn: (log): unknown => FlatLogData(log)[f.name],
enableRemove: true,
width: { min: 192 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
),
}),
);
const timestampCol: TableColumnDef<ILog> | null = fields.some(
(f) => f.name === 'timestamp',
)
? {
id: 'timestamp',
header: 'Timestamp',
accessorFn: (log): unknown => log.timestamp,
width: { default: 170, min: 170 },
cell: ({ value }): ReactElement => {
const ts = value as string | number;
const formatted =
typeof ts === 'string'
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
: formatTimezoneAdjustedTimestamp(
ts / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return <TanStackTable.Text>{formatted}</TanStackTable.Text>;
},
}
: null;
const bodyCol: TableColumnDef<ILog> | null = fields.some(
(f) => f.name === 'body',
)
? {
id: 'body',
header: 'Body',
accessorFn: (log): string => log.body,
canBeHidden: false,
width: { default: '100%', min: 640 },
cell: ({ value, isActive }): ReactElement => (
<TanStackTable.Text
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(value as string, {
shouldEscapeHtml: true,
}),
}}
data-active={isActive}
/>
),
}
: null;
return [
stateIndicatorCol,
...(timestampCol ? [timestampCol] : []),
...(appendTo === 'center' ? fieldColumns : []),
...(bodyCol ? [bodyCol] : []),
...(appendTo === 'end' ? fieldColumns : []),
];
}, [fields, appendTo, fontSize, formatTimezoneAdjustedTimestamp]);
}

View File

@@ -100,19 +100,16 @@ function MarkdownRenderer({
variables,
trackCopyAction,
elementDetails,
className,
}: {
markdownContent: any;
variables: any;
trackCopyAction?: boolean;
elementDetails?: Record<string, unknown>;
className?: string;
}): JSX.Element {
const interpolatedMarkdown = interpolateMarkdown(markdownContent, variables);
return (
<ReactMarkdown
className={className}
rehypePlugins={[rehypeRaw as any]}
components={{
// @ts-ignore

View File

@@ -1,135 +0,0 @@
import { ComponentProps, memo } from 'react';
import { TableComponents } from 'react-virtuoso';
import cx from 'classnames';
import TanStackRowCells from './TanStackRow';
import {
useClearRowHovered,
useSetRowHovered,
} from './TanStackTableStateContext';
import { FlatItem, TableRowContext } from './types';
import tableStyles from './TanStackTable.module.scss';
type VirtuosoTableRowProps<TData> = ComponentProps<
NonNullable<
TableComponents<FlatItem<TData>, TableRowContext<TData>>['TableRow']
>
>;
function TanStackCustomTableRow<TData>({
item,
context,
...props
}: VirtuosoTableRowProps<TData>): JSX.Element {
const rowId = item.row.id;
const rowData = item.row.original;
// Stable callbacks for hover state management
const setHovered = useSetRowHovered(rowId);
const clearHovered = useClearRowHovered(rowId);
if (item.kind === 'expansion') {
return (
<tr {...props} className={tableStyles.tableRowExpansion}>
<TanStackRowCells
row={item.row}
itemKind={item.kind}
context={context}
hasSingleColumn={context?.hasSingleColumn ?? false}
columnOrderKey={context?.columnOrderKey ?? ''}
columnVisibilityKey={context?.columnVisibilityKey ?? ''}
/>
</tr>
);
}
const isActive = context?.isRowActive?.(rowData) ?? false;
const extraClass = context?.getRowClassName?.(rowData) ?? '';
const rowStyle = context?.getRowStyle?.(rowData);
const rowClassName = cx(
tableStyles.tableRow,
isActive && tableStyles.tableRowActive,
extraClass,
);
return (
<tr
{...props}
className={rowClassName}
style={rowStyle}
onMouseEnter={setHovered}
onMouseLeave={clearHovered}
>
<TanStackRowCells
row={item.row}
itemKind={item.kind}
context={context}
hasSingleColumn={context?.hasSingleColumn ?? false}
columnOrderKey={context?.columnOrderKey ?? ''}
columnVisibilityKey={context?.columnVisibilityKey ?? ''}
/>
</tr>
);
}
// Custom comparison - only re-render when row identity or computed values change
// This looks overkill but ensures the table is stable and doesn't re-render on every change
// If you add any new prop to context, remember to update this function
// eslint-disable-next-line sonarjs/cognitive-complexity
function areTableRowPropsEqual<TData>(
prev: Readonly<VirtuosoTableRowProps<TData>>,
next: Readonly<VirtuosoTableRowProps<TData>>,
): boolean {
if (prev.item.row.id !== next.item.row.id) {
return false;
}
if (prev.item.kind !== next.item.kind) {
return false;
}
const prevData = prev.item.row.original;
const nextData = next.item.row.original;
if (prevData !== nextData) {
return false;
}
if (prev.context?.hasSingleColumn !== next.context?.hasSingleColumn) {
return false;
}
if (prev.context?.columnOrderKey !== next.context?.columnOrderKey) {
return false;
}
if (prev.context?.columnVisibilityKey !== next.context?.columnVisibilityKey) {
return false;
}
if (prev.context !== next.context) {
const prevActive = prev.context?.isRowActive?.(prevData) ?? false;
const nextActive = next.context?.isRowActive?.(nextData) ?? false;
if (prevActive !== nextActive) {
return false;
}
const prevClass = prev.context?.getRowClassName?.(prevData) ?? '';
const nextClass = next.context?.getRowClassName?.(nextData) ?? '';
if (prevClass !== nextClass) {
return false;
}
const prevStyle = prev.context?.getRowStyle?.(prevData);
const nextStyle = next.context?.getRowStyle?.(nextData);
if (prevStyle !== nextStyle) {
return false;
}
}
return true;
}
export default memo(
TanStackCustomTableRow,
areTableRowPropsEqual,
) as typeof TanStackCustomTableRow;

View File

@@ -1,260 +0,0 @@
import type {
CSSProperties,
MouseEvent as ReactMouseEvent,
TouchEvent as ReactTouchEvent,
} from 'react';
import { useCallback, useMemo } from 'react';
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
import { useSortable } from '@dnd-kit/sortable';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/popover';
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
import cx from 'classnames';
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
import { SortState, TableColumnDef } from './types';
import headerStyles from './TanStackHeaderRow.module.scss';
import tableStyles from './TanStackTable.module.scss';
type TanStackHeaderRowProps<TData = unknown> = {
column: TableColumnDef<TData>;
header?: TanStackHeader<TData, unknown>;
isDarkMode: boolean;
hasSingleColumn: boolean;
canRemoveColumn?: boolean;
onRemoveColumn?: (columnId: string) => void;
orderBy?: SortState | null;
onSort?: (sort: SortState | null) => void;
/** Last column cannot be resized */
isLastColumn?: boolean;
};
const GRIP_ICON_SIZE = 12;
const SORT_ICON_SIZE = 14;
// eslint-disable-next-line sonarjs/cognitive-complexity
function TanStackHeaderRow<TData>({
column,
header,
isDarkMode,
hasSingleColumn,
canRemoveColumn = false,
onRemoveColumn,
orderBy,
onSort,
isLastColumn = false,
}: TanStackHeaderRowProps<TData>): JSX.Element {
const columnId = column.id;
const isDragColumn = column.enableMove !== false && column.pin == null;
const isResizableColumn =
!isLastColumn &&
column.enableResize !== false &&
Boolean(header?.column.getCanResize());
const isColumnRemovable = Boolean(
canRemoveColumn && onRemoveColumn && column.enableRemove,
);
const isSortable = column.enableSort === true && Boolean(onSort);
const currentSortDirection =
orderBy?.columnName === columnId ? orderBy.order : null;
const isResizing = Boolean(header?.column.getIsResizing());
const resizeHandler = header?.getResizeHandler();
const headerText =
typeof column.header === 'string' && column.header
? column.header
: String(header?.id ?? columnId);
const headerTitleAttr = headerText.replace(/^\w/, (c) => c.toUpperCase());
const handleSortClick = useCallback((): void => {
if (!isSortable || !onSort) {
return;
}
if (currentSortDirection === null) {
onSort({ columnName: columnId, order: 'asc' });
} else if (currentSortDirection === 'asc') {
onSort({ columnName: columnId, order: 'desc' });
} else {
onSort(null);
}
}, [isSortable, onSort, currentSortDirection, columnId]);
const handleResizeStart = (
event: ReactMouseEvent<HTMLElement> | ReactTouchEvent<HTMLElement>,
): void => {
event.preventDefault();
event.stopPropagation();
resizeHandler?.(event);
};
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: columnId,
disabled: !isDragColumn,
});
const headerCellStyle = useMemo(
() =>
({
'--tanstack-header-translate-x': `${Math.round(transform?.x ?? 0)}px`,
'--tanstack-header-translate-y': `${Math.round(transform?.y ?? 0)}px`,
'--tanstack-header-transition': isResizing ? 'none' : transition || 'none',
} as CSSProperties),
[isResizing, transform?.x, transform?.y, transition],
);
const headerCellClassName = cx(
headerStyles.tanstackHeaderCell,
isDragging && headerStyles.isDragging,
isResizing && headerStyles.isResizing,
);
const headerContentClassName = cx(
headerStyles.tanstackHeaderContent,
isResizableColumn && headerStyles.hasResizeControl,
isColumnRemovable && headerStyles.hasActionControl,
isSortable && headerStyles.isSortable,
);
const thClassName = cx(
tableStyles.tableHeaderCell,
headerCellClassName,
column.id,
);
return (
<th
ref={setNodeRef}
className={thClassName}
key={columnId}
style={headerCellStyle}
data-dark-mode={isDarkMode}
data-single-column={hasSingleColumn || undefined}
>
<span className={headerContentClassName}>
{isDragColumn ? (
<span className={headerStyles.tanstackGripSlot}>
<span
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
role="button"
aria-label={`Drag ${String(
(typeof column.header === 'string' && column.header) ||
header?.id ||
columnId,
)} column`}
className={headerStyles.tanstackGripActivator}
>
<GripVertical size={GRIP_ICON_SIZE} />
</span>
</span>
) : null}
{isSortable ? (
<button
type="button"
className={cx(
'tanstack-header-title',
headerStyles.tanstackSortButton,
currentSortDirection && headerStyles.isSorted,
)}
title={headerTitleAttr}
onClick={handleSortClick}
aria-sort={
currentSortDirection === 'asc'
? 'ascending'
: currentSortDirection === 'desc'
? 'descending'
: 'none'
}
>
<span className={headerStyles.tanstackSortLabel}>
{header?.column?.columnDef
? flexRender(header.column.columnDef.header, header.getContext())
: typeof column.header === 'function'
? column.header()
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
</span>
<span className={headerStyles.tanstackSortIndicator}>
{currentSortDirection === 'asc' ? (
<ChevronUp size={SORT_ICON_SIZE} />
) : currentSortDirection === 'desc' ? (
<ChevronDown size={SORT_ICON_SIZE} />
) : null}
</span>
</button>
) : (
<span
className={cx('tanstack-header-title', headerStyles.tanstackHeaderTitle)}
title={headerTitleAttr}
>
{header?.column?.columnDef
? flexRender(header.column.columnDef.header, header.getContext())
: typeof column.header === 'function'
? column.header()
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
</span>
)}
{isColumnRemovable && (
<Popover>
<PopoverTrigger asChild>
<span
role="button"
aria-label={`Column actions for ${headerTitleAttr}`}
className={headerStyles.tanstackHeaderActionTrigger}
onMouseDown={(event): void => {
event.stopPropagation();
}}
>
<MoreOutlined />
</span>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={6}
className={headerStyles.tanstackColumnActionsContent}
>
<button
type="button"
className={headerStyles.tanstackRemoveColumnAction}
onClick={(event): void => {
event.preventDefault();
event.stopPropagation();
onRemoveColumn?.(column.id);
}}
>
<CloseOutlined
className={headerStyles.tanstackRemoveColumnActionIcon}
/>
Remove column
</button>
</PopoverContent>
</Popover>
)}
</span>
{isResizableColumn && (
<span
role="presentation"
className={headerStyles.cursorColResize}
title="Drag to resize column"
onClick={(event): void => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event): void => {
handleResizeStart(event);
}}
onTouchStart={(event): void => {
handleResizeStart(event);
}}
>
<span className={headerStyles.tanstackResizeHandleLine} />
</span>
)}
</th>
);
}
export default TanStackHeaderRow;

View File

@@ -1,136 +0,0 @@
import type { MouseEvent } from 'react';
import { memo, useCallback } from 'react';
import { Row as TanStackRowModel } from '@tanstack/react-table';
import { TanStackRowCell } from './TanStackRowCell';
import { useIsRowHovered } from './TanStackTableStateContext';
import { TableRowContext } from './types';
import tableStyles from './TanStackTable.module.scss';
type TanStackRowCellsProps<TData> = {
row: TanStackRowModel<TData>;
context: TableRowContext<TData> | undefined;
itemKind: 'row' | 'expansion';
hasSingleColumn: boolean;
columnOrderKey: string;
columnVisibilityKey: string;
};
function TanStackRowCellsInner<TData>({
row,
context,
itemKind,
hasSingleColumn,
columnOrderKey: _columnOrderKey,
columnVisibilityKey: _columnVisibilityKey,
}: TanStackRowCellsProps<TData>): JSX.Element {
const hasHovered = useIsRowHovered(row.id);
const rowData = row.original;
const visibleCells = row.getVisibleCells();
const lastCellIndex = visibleCells.length - 1;
// Stable references via destructuring, keep them as is
const onRowClick = context?.onRowClick;
const onRowClickNewTab = context?.onRowClickNewTab;
const onRowDeactivate = context?.onRowDeactivate;
const isRowActive = context?.isRowActive;
const getRowKeyData = context?.getRowKeyData;
const rowIndex = row.index;
const handleClick = useCallback(
(event: MouseEvent<HTMLTableCellElement>) => {
const keyData = getRowKeyData?.(rowIndex);
const itemKey = keyData?.itemKey ?? '';
// Handle ctrl+click or cmd+click (open in new tab)
if ((event.ctrlKey || event.metaKey) && onRowClickNewTab) {
onRowClickNewTab(rowData, itemKey);
return;
}
const isActive = isRowActive?.(rowData) ?? false;
if (isActive && onRowDeactivate) {
onRowDeactivate();
} else {
onRowClick?.(rowData, itemKey);
}
},
[
isRowActive,
onRowDeactivate,
onRowClick,
onRowClickNewTab,
rowData,
getRowKeyData,
rowIndex,
],
);
if (itemKind === 'expansion') {
const keyData = getRowKeyData?.(rowIndex);
return (
<td
colSpan={context?.colCount ?? 1}
className={tableStyles.tableCellExpansion}
>
{context?.renderExpandedRow?.(
rowData,
keyData?.finalKey ?? '',
keyData?.groupMeta,
)}
</td>
);
}
return (
<>
{visibleCells.map((cell, index) => {
const isLastCell = index === lastCellIndex;
return (
<TanStackRowCell
key={cell.id}
cell={cell}
hasSingleColumn={hasSingleColumn}
isLastCell={isLastCell}
hasHovered={hasHovered}
rowData={rowData}
onClick={handleClick}
renderRowActions={context?.renderRowActions}
/>
);
})}
</>
);
}
// Custom comparison - only re-render when row data changes
// If you add any new prop to context, remember to update this function
function areRowCellsPropsEqual<TData>(
prev: Readonly<TanStackRowCellsProps<TData>>,
next: Readonly<TanStackRowCellsProps<TData>>,
): boolean {
return (
prev.row.id === next.row.id &&
prev.itemKind === next.itemKind &&
prev.hasSingleColumn === next.hasSingleColumn &&
prev.columnOrderKey === next.columnOrderKey &&
prev.columnVisibilityKey === next.columnVisibilityKey &&
prev.context?.onRowClick === next.context?.onRowClick &&
prev.context?.onRowClickNewTab === next.context?.onRowClickNewTab &&
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
prev.context?.isRowActive === next.context?.isRowActive &&
prev.context?.getRowKeyData === next.context?.getRowKeyData &&
prev.context?.renderRowActions === next.context?.renderRowActions &&
prev.context?.renderExpandedRow === next.context?.renderExpandedRow &&
prev.context?.colCount === next.context?.colCount
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TanStackRowCells = memo(
TanStackRowCellsInner,
areRowCellsPropsEqual as any,
) as <T>(props: TanStackRowCellsProps<T>) => JSX.Element;
export default TanStackRowCells;

View File

@@ -1,88 +0,0 @@
import type { MouseEvent, ReactNode } from 'react';
import { memo } from 'react';
import type { Cell } from '@tanstack/react-table';
import { flexRender } from '@tanstack/react-table';
import { Skeleton } from 'antd';
import cx from 'classnames';
import { useShouldShowCellSkeleton } from './TanStackTableStateContext';
import tableStyles from './TanStackTable.module.scss';
import skeletonStyles from './TanStackTableSkeleton.module.scss';
export type TanStackRowCellProps<TData> = {
cell: Cell<TData, unknown>;
hasSingleColumn: boolean;
isLastCell: boolean;
hasHovered: boolean;
rowData: TData;
onClick: (event: MouseEvent<HTMLTableCellElement>) => void;
renderRowActions?: (row: TData) => ReactNode;
};
function TanStackRowCellInner<TData>({
cell,
hasSingleColumn,
isLastCell,
hasHovered,
rowData,
onClick,
renderRowActions,
}: TanStackRowCellProps<TData>): JSX.Element {
const showSkeleton = useShouldShowCellSkeleton();
return (
<td
className={cx(tableStyles.tableCell, 'tanstack-cell-' + cell.column.id)}
data-single-column={hasSingleColumn || undefined}
onClick={onClick}
>
{showSkeleton ? (
<Skeleton.Input
active
size="small"
className={skeletonStyles.cellSkeleton}
/>
) : (
flexRender(cell.column.columnDef.cell, cell.getContext())
)}
{isLastCell && hasHovered && renderRowActions && !showSkeleton && (
<span className={tableStyles.tableViewRowActions}>
{renderRowActions(rowData)}
</span>
)}
</td>
);
}
// Custom comparison - only re-render when row data changes
// If you add any new prop to context, remember to update this function
function areTanStackRowCellPropsEqual<TData>(
prev: Readonly<TanStackRowCellProps<TData>>,
next: Readonly<TanStackRowCellProps<TData>>,
): boolean {
if (next.cell.id.startsWith('skeleton-')) {
return false;
}
return (
prev.cell.id === next.cell.id &&
prev.cell.column.id === next.cell.column.id &&
Object.is(prev.cell.getValue(), next.cell.getValue()) &&
prev.hasSingleColumn === next.hasSingleColumn &&
prev.isLastCell === next.isLastCell &&
prev.hasHovered === next.hasHovered &&
prev.onClick === next.onClick &&
prev.renderRowActions === next.renderRowActions &&
prev.rowData === next.rowData
);
}
const TanStackRowCellMemo = memo(
TanStackRowCellInner,
areTanStackRowCellPropsEqual,
);
TanStackRowCellMemo.displayName = 'TanStackRowCell';
export const TanStackRowCell = TanStackRowCellMemo as typeof TanStackRowCellInner;

View File

@@ -1,100 +0,0 @@
.tanStackTable {
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
& td,
& th {
overflow: hidden;
min-width: 0;
box-sizing: border-box;
vertical-align: middle;
}
}
.tableCellText {
font-style: normal;
font-weight: 400;
letter-spacing: -0.07px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
width: auto;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--tanstack-plain-body-line-clamp, 1);
line-clamp: var(--tanstack-plain-body-line-clamp, 1);
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
color: var(--l2-foreground);
max-width: 100%;
word-break: break-all;
}
.tableViewRowActions {
position: absolute;
top: 50%;
right: 8px;
left: auto;
transform: translateY(-50%);
margin: 0;
z-index: 5;
}
.tableCell {
padding: 0.3rem;
font-style: normal;
font-weight: 400;
letter-spacing: -0.07px;
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
color: var(--l2-foreground);
}
.tableRow {
cursor: pointer;
position: relative;
overflow-anchor: none;
&:hover {
.tableCell {
background-color: var(--row-hover-bg) !important;
}
}
&.tableRowActive {
.tableCell {
background-color: var(--row-active-bg) !important;
}
}
}
.tableHeaderCell {
padding: 0.3rem;
height: 36px;
text-align: left;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
color: var(--l1-foreground);
// TODO: Remove this once background color (l1) is matching the actual background color of the page
&[data-dark-mode='true'] {
background: #0b0c0d;
}
&[data-dark-mode='false'] {
background: #fdfdfd;
}
}
.tableRowExpansion {
display: table-row;
}
.tableCellExpansion {
padding: 0.5rem;
vertical-align: top;
}

View File

@@ -1,572 +0,0 @@
import type { ComponentProps, CSSProperties } from 'react';
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from 'react';
import type { TableComponents } from 'react-virtuoso';
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
import { LoadingOutlined } from '@ant-design/icons';
import { DndContext, pointerWithin } from '@dnd-kit/core';
import {
horizontalListSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import {
ComboboxSimple,
ComboboxSimpleItem,
TooltipProvider,
} from '@signozhq/ui';
import { Pagination } from '@signozhq/ui';
import type { Row } from '@tanstack/react-table';
import {
ColumnDef,
ColumnPinningState,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { Spin } from 'antd';
import cx from 'classnames';
import { useIsDarkMode } from 'hooks/useDarkMode';
import TanStackCustomTableRow from './TanStackCustomTableRow';
import TanStackHeaderRow from './TanStackHeaderRow';
import {
ColumnVisibilitySync,
TableLoadingSync,
TanStackTableStateProvider,
} from './TanStackTableStateContext';
import {
FlatItem,
TableRowContext,
TanStackTableHandle,
TanStackTableProps,
} from './types';
import { useColumnDnd } from './useColumnDnd';
import { useColumnHandlers } from './useColumnHandlers';
import { useColumnState } from './useColumnState';
import { useEffectiveData } from './useEffectiveData';
import { useFlatItems } from './useFlatItems';
import { useRowKeyData } from './useRowKeyData';
import { useTableParams } from './useTableParams';
import { buildTanstackColumnDef } from './utils';
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
import tableStyles from './TanStackTable.module.scss';
import viewStyles from './TanStackTableView.module.scss';
const COLUMN_DND_AUTO_SCROLL = {
layoutShiftCompensation: false as const,
threshold: { x: 0.2, y: 0 },
};
const INCREASE_VIEWPORT_BY = { top: 500, bottom: 500 };
const noopColumnVisibility = (): void => {};
const paginationPageSizeItems: ComboboxSimpleItem[] = [10, 20, 30, 50, 100].map(
(value) => ({
value: value.toString(),
label: value.toString(),
displayValue: value.toString(),
}),
);
// eslint-disable-next-line sonarjs/cognitive-complexity
function TanStackTableInner<TData>(
{
data,
columns,
columnStorageKey,
columnSizing: columnSizingProp,
onColumnSizingChange,
onColumnOrderChange,
onColumnRemove,
isLoading = false,
skeletonRowCount = 10,
enableQueryParams,
pagination,
onEndReached,
getRowKey,
getItemKey,
groupBy,
getGroupKey,
getRowStyle,
getRowClassName,
isRowActive,
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
activeRowIndex,
renderExpandedRow,
getRowCanExpand,
tableScrollerProps,
plainTextCellLineClamp,
cellTypographySize,
className,
testId,
prefixPaginationContent,
suffixPaginationContent,
}: TanStackTableProps<TData>,
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
): JSX.Element {
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const isDarkMode = useIsDarkMode();
const {
page,
limit,
setPage,
setLimit,
orderBy,
setOrderBy,
expanded,
setExpanded,
} = useTableParams(enableQueryParams, {
page: pagination?.defaultPage,
limit: pagination?.defaultLimit,
});
const isGrouped = (groupBy?.length ?? 0) > 0;
const {
columnVisibility: storeVisibility,
columnSizing: storeSizing,
sortedColumns,
hideColumn,
setColumnSizing: storeSetSizing,
setColumnOrder: storeSetOrder,
} = useColumnState({
storageKey: columnStorageKey,
columns,
isGrouped,
});
// Use store values when columnStorageKey is provided, otherwise fall back to props/defaults
const effectiveColumns = columnStorageKey ? sortedColumns : columns;
const effectiveVisibility = columnStorageKey ? storeVisibility : {};
const effectiveSizing = columnStorageKey
? storeSizing
: columnSizingProp ?? {};
const effectiveData = useEffectiveData<TData>({
data,
isLoading,
limit,
skeletonRowCount,
});
const { rowKeyData, getRowKeyData } = useRowKeyData({
data: effectiveData,
isLoading,
getRowKey,
getItemKey,
groupBy,
getGroupKey,
});
const {
handleColumnSizingChange,
handleColumnOrderChange,
handleRemoveColumn,
} = useColumnHandlers({
columnStorageKey,
effectiveSizing,
storeSetSizing,
storeSetOrder,
hideColumn,
onColumnSizingChange,
onColumnOrderChange,
onColumnRemove,
});
const columnPinning = useMemo<ColumnPinningState>(
() => ({
left: effectiveColumns.filter((c) => c.pin === 'left').map((c) => c.id),
right: effectiveColumns.filter((c) => c.pin === 'right').map((c) => c.id),
}),
[effectiveColumns],
);
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
() =>
effectiveColumns.map((colDef) =>
buildTanstackColumnDef(colDef, isRowActive, getRowKeyData),
),
[effectiveColumns, isRowActive, getRowKeyData],
);
const getRowId = useCallback(
(row: TData, index: number): string => {
if (rowKeyData) {
return rowKeyData[index]?.finalKey ?? String(index);
}
const r = row as Record<string, unknown>;
if (r != null && typeof r.id !== 'undefined') {
return String(r.id);
}
return String(index);
},
[rowKeyData],
);
const tableGetRowCanExpand = useCallback(
(row: Row<TData>): boolean =>
getRowCanExpand ? getRowCanExpand(row.original) : true,
[getRowCanExpand],
);
const table = useReactTable({
data: effectiveData,
columns: tanstackColumns,
enableColumnResizing: true,
enableColumnPinning: true,
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getRowId,
enableExpanding: Boolean(renderExpandedRow),
getRowCanExpand: renderExpandedRow ? tableGetRowCanExpand : undefined,
onColumnSizingChange: handleColumnSizingChange,
onColumnVisibilityChange: noopColumnVisibility,
onExpandedChange: setExpanded,
state: {
columnSizing: effectiveSizing,
columnVisibility: effectiveVisibility,
columnPinning,
expanded,
},
});
// Keep refs to avoid recreating virtuosoComponents on every resize/render
const tableRef = useRef(table);
tableRef.current = table;
const columnsRef = useRef(effectiveColumns);
columnsRef.current = effectiveColumns;
const tableRows = table.getRowModel().rows;
const { flatItems, flatIndexForActiveRow } = useFlatItems({
tableRows,
renderExpandedRow,
expanded,
activeRowIndex,
});
// keep previous count just to avoid flashing the pagination component
const prevTotalCountRef = useRef(pagination?.total || 0);
if (pagination?.total && pagination?.total > 0) {
prevTotalCountRef.current = pagination?.total;
}
const effectiveTotalCount = !isLoading
? pagination?.total || 0
: prevTotalCountRef.current;
useEffect(() => {
if (flatIndexForActiveRow < 0) {
return;
}
virtuosoRef.current?.scrollToIndex({
index: flatIndexForActiveRow,
align: 'center',
behavior: 'auto',
});
}, [flatIndexForActiveRow]);
const { sensors, columnIds, handleDragEnd } = useColumnDnd({
columns: effectiveColumns,
onColumnOrderChange: handleColumnOrderChange,
});
const hasSingleColumn = useMemo(
() =>
effectiveColumns.filter((c) => !c.pin && c.enableRemove !== false).length <=
1,
[effectiveColumns],
);
const canRemoveColumn = !hasSingleColumn;
const flatHeaders = useMemo(
() => table.getFlatHeaders().filter((header) => !header.isPlaceholder),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tanstackColumns, columnPinning, effectiveVisibility],
);
const columnsById = useMemo(
() => new Map(effectiveColumns.map((c) => [c.id, c] as const)),
[effectiveColumns],
);
const visibleColumnsCount = table.getVisibleFlatColumns().length;
const columnOrderKey = useMemo(() => columnIds.join(','), [columnIds]);
const columnVisibilityKey = useMemo(
() =>
table
.getVisibleFlatColumns()
.map((c) => c.id)
.join(','),
// we want to explicitly have table out of this deps
// eslint-disable-next-line react-hooks/exhaustive-deps
[effectiveVisibility, columnIds],
);
const virtuosoContext = useMemo<TableRowContext<TData>>(
() => ({
getRowStyle,
getRowClassName,
isRowActive,
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
renderExpandedRow,
getRowKeyData,
colCount: visibleColumnsCount,
isDarkMode,
plainTextCellLineClamp,
hasSingleColumn,
columnOrderKey,
columnVisibilityKey,
}),
[
getRowStyle,
getRowClassName,
isRowActive,
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
renderExpandedRow,
getRowKeyData,
visibleColumnsCount,
isDarkMode,
plainTextCellLineClamp,
hasSingleColumn,
columnOrderKey,
columnVisibilityKey,
],
);
const tableHeader = useCallback(() => {
return (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragEnd={handleDragEnd}
autoScroll={COLUMN_DND_AUTO_SCROLL}
>
<SortableContext items={columnIds} strategy={horizontalListSortingStrategy}>
<tr>
{flatHeaders.map((header, index) => {
const column = columnsById.get(header.id);
if (!column) {
return null;
}
return (
<TanStackHeaderRow
key={header.id}
column={column}
header={header}
isDarkMode={isDarkMode}
hasSingleColumn={hasSingleColumn}
onRemoveColumn={handleRemoveColumn}
canRemoveColumn={canRemoveColumn}
orderBy={orderBy}
onSort={setOrderBy}
isLastColumn={index === flatHeaders.length - 1}
/>
);
})}
</tr>
</SortableContext>
</DndContext>
);
}, [
sensors,
handleDragEnd,
columnIds,
flatHeaders,
columnsById,
isDarkMode,
hasSingleColumn,
handleRemoveColumn,
canRemoveColumn,
orderBy,
setOrderBy,
]);
const handleEndReached = useCallback(
(index: number): void => {
onEndReached?.(index);
},
[onEndReached],
);
const isInfiniteScrollMode = Boolean(onEndReached);
const showInfiniteScrollLoader = isInfiniteScrollMode && isLoading;
useImperativeHandle(
forwardedRef,
(): TanStackTableHandle =>
new Proxy(
{
goToPage: (p: number): void => {
setPage(p);
virtuosoRef.current?.scrollToIndex({
index: 0,
align: 'start',
});
},
} as TanStackTableHandle,
{
get(target, prop): unknown {
if (prop in target) {
return Reflect.get(target, prop);
}
const v = (virtuosoRef.current as unknown) as Record<string, unknown>;
const value = v?.[prop as string];
if (typeof value === 'function') {
return (value as (...a: unknown[]) => unknown).bind(virtuosoRef.current);
}
return value;
},
},
),
[setPage],
);
const showPagination = Boolean(pagination && !onEndReached);
const { className: tableScrollerClassName, ...restTableScrollerProps } =
tableScrollerProps ?? {};
const cellTypographyClass = useMemo((): string | undefined => {
if (cellTypographySize === 'small') {
return viewStyles.cellTypographySmall;
}
if (cellTypographySize === 'medium') {
return viewStyles.cellTypographyMedium;
}
if (cellTypographySize === 'large') {
return viewStyles.cellTypographyLarge;
}
return undefined;
}, [cellTypographySize]);
const virtuosoClassName = useMemo(
() =>
cx(
viewStyles.tanstackTableVirtuosoScroll,
cellTypographyClass,
tableScrollerClassName,
),
[cellTypographyClass, tableScrollerClassName],
);
const virtuosoTableStyle = useMemo(
() =>
({
'--tanstack-plain-body-line-clamp': plainTextCellLineClamp,
} as CSSProperties),
[plainTextCellLineClamp],
);
type VirtuosoTableComponentProps = ComponentProps<
NonNullable<TableComponents<FlatItem<TData>, TableRowContext<TData>>['Table']>
>;
// Use refs in virtuosoComponents to keep the component reference stable during resize
// This prevents Virtuoso from re-rendering all rows when columns are resized
const virtuosoComponents = useMemo(
() => ({
Table: ({ style, children }: VirtuosoTableComponentProps): JSX.Element => (
<table className={tableStyles.tanStackTable} style={style}>
<VirtuosoTableColGroup
columns={columnsRef.current}
table={tableRef.current}
/>
{children}
</table>
),
TableRow: TanStackCustomTableRow,
}),
[],
);
return (
<div className={cx(viewStyles.tanstackTableViewWrapper, className)}>
<TanStackTableStateProvider>
<TableLoadingSync
isLoading={isLoading}
isInfiniteScrollMode={isInfiniteScrollMode}
/>
<ColumnVisibilitySync visibility={effectiveVisibility} />
<TooltipProvider>
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
className={virtuosoClassName}
ref={virtuosoRef}
{...restTableScrollerProps}
data={flatItems}
totalCount={flatItems.length}
context={virtuosoContext}
increaseViewportBy={INCREASE_VIEWPORT_BY}
initialTopMostItemIndex={
flatIndexForActiveRow >= 0 ? flatIndexForActiveRow : 0
}
fixedHeaderContent={tableHeader}
style={virtuosoTableStyle}
components={virtuosoComponents}
endReached={onEndReached ? handleEndReached : undefined}
data-testid={testId}
/>
{showInfiniteScrollLoader && (
<div
className={viewStyles.tanstackLoadingOverlay}
data-testid="tanstack-infinite-loader"
>
<Spin indicator={<LoadingOutlined spin />} tip="Loading more..." />
</div>
)}
{showPagination && pagination && (
<div className={viewStyles.paginationContainer}>
{prefixPaginationContent}
<Pagination
current={page}
pageSize={limit}
total={effectiveTotalCount}
onPageChange={(p): void => {
setPage(p);
}}
/>
<div className={viewStyles.paginationPageSize}>
<ComboboxSimple
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => setLimit(+value)}
items={paginationPageSizeItems}
/>
</div>
{suffixPaginationContent}
</div>
)}
</TooltipProvider>
</TanStackTableStateProvider>
</div>
);
}
const TanStackTableForward = forwardRef(TanStackTableInner) as <TData>(
props: TanStackTableProps<TData> & {
ref?: React.Ref<TanStackTableHandle>;
},
) => JSX.Element;
export const TanStackTableBase = memo(
TanStackTableForward,
) as typeof TanStackTableForward;

View File

@@ -1,21 +0,0 @@
.headerSkeleton {
width: 60% !important;
min-width: 50px !important;
height: 16px !important;
:global(.ant-skeleton-input) {
min-width: 50px !important;
height: 16px !important;
}
}
.cellSkeleton {
width: 80% !important;
min-width: 40px !important;
height: 14px !important;
:global(.ant-skeleton-input) {
min-width: 40px !important;
height: 14px !important;
}
}

View File

@@ -1,72 +0,0 @@
import { useMemo } from 'react';
import type { ColumnSizingState } from '@tanstack/react-table';
import { Skeleton } from 'antd';
import { TableColumnDef } from './types';
import { getColumnWidthStyle } from './utils';
import tableStyles from './TanStackTable.module.scss';
import styles from './TanStackTableSkeleton.module.scss';
type TanStackTableSkeletonProps<TData> = {
columns: TableColumnDef<TData>[];
rowCount: number;
isDarkMode: boolean;
columnSizing?: ColumnSizingState;
};
export function TanStackTableSkeleton<TData>({
columns,
rowCount,
isDarkMode,
columnSizing,
}: TanStackTableSkeletonProps<TData>): JSX.Element {
const rows = useMemo(() => Array.from({ length: rowCount }, (_, i) => i), [
rowCount,
]);
return (
<table className={tableStyles.tanStackTable}>
<colgroup>
{columns.map((column, index) => (
<col
key={column.id}
style={getColumnWidthStyle(
column,
columnSizing?.[column.id],
index === columns.length - 1,
)}
/>
))}
</colgroup>
<thead>
<tr>
{columns.map((column) => (
<th
key={column.id}
className={tableStyles.tableHeaderCell}
data-dark-mode={isDarkMode}
>
{typeof column.header === 'function' ? (
<Skeleton.Input active size="small" className={styles.headerSkeleton} />
) : (
column.header
)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((rowIndex) => (
<tr key={rowIndex} className={tableStyles.tableRow}>
{columns.map((column) => (
<td key={column.id} className={tableStyles.tableCell}>
<Skeleton.Input active size="small" className={styles.cellSkeleton} />
</td>
))}
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -1,206 +0,0 @@
/* eslint-disable no-restricted-imports */
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
} from 'react';
/* eslint-enable no-restricted-imports */
import { VisibilityState } from '@tanstack/react-table';
import { createStore, StoreApi, useStore } from 'zustand';
const CLEAR_HOVER_DELAY_MS = 100;
type TanStackTableState = {
hoveredRowId: string | null;
clearTimeoutId: ReturnType<typeof setTimeout> | null;
setHoveredRowId: (id: string | null) => void;
scheduleClearHover: (rowId: string) => void;
isLoading: boolean;
setIsLoading: (loading: boolean) => void;
isInfiniteScrollMode: boolean;
setIsInfiniteScrollMode: (enabled: boolean) => void;
columnVisibility: VisibilityState;
setColumnVisibility: (visibility: VisibilityState) => void;
};
const createTableStateStore = (): StoreApi<TanStackTableState> =>
createStore<TanStackTableState>((set, get) => ({
hoveredRowId: null,
clearTimeoutId: null,
setHoveredRowId: (id: string | null): void => {
const { clearTimeoutId } = get();
if (clearTimeoutId) {
clearTimeout(clearTimeoutId);
set({ clearTimeoutId: null });
}
set({ hoveredRowId: id });
},
scheduleClearHover: (rowId: string): void => {
const { clearTimeoutId } = get();
if (clearTimeoutId) {
clearTimeout(clearTimeoutId);
}
const timeoutId = setTimeout(() => {
const current = get().hoveredRowId;
if (current === rowId) {
set({ hoveredRowId: null, clearTimeoutId: null });
}
}, CLEAR_HOVER_DELAY_MS);
set({ clearTimeoutId: timeoutId });
},
isLoading: false,
setIsLoading: (loading: boolean): void => {
set({ isLoading: loading });
},
isInfiniteScrollMode: false,
setIsInfiniteScrollMode: (enabled: boolean): void => {
set({ isInfiniteScrollMode: enabled });
},
columnVisibility: {},
setColumnVisibility: (visibility: VisibilityState): void => {
set({ columnVisibility: visibility });
},
}));
type TableStateStore = StoreApi<TanStackTableState>;
const TanStackTableStateContext = createContext<TableStateStore | null>(null);
export function TanStackTableStateProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const storeRef = useRef<TableStateStore | null>(null);
if (!storeRef.current) {
storeRef.current = createTableStateStore();
}
return (
<TanStackTableStateContext.Provider value={storeRef.current}>
{children}
</TanStackTableStateContext.Provider>
);
}
const defaultStore = createTableStateStore();
export const useIsRowHovered = (rowId: string): boolean => {
const store = useContext(TanStackTableStateContext);
const isHovered = useStore(
store ?? defaultStore,
(s) => s.hoveredRowId === rowId,
);
return store ? isHovered : false;
};
export const useSetRowHovered = (rowId: string): (() => void) => {
const store = useContext(TanStackTableStateContext);
return useCallback(() => {
if (store) {
const current = store.getState().hoveredRowId;
if (current !== rowId) {
store.getState().setHoveredRowId(rowId);
}
}
}, [store, rowId]);
};
export const useClearRowHovered = (rowId: string): (() => void) => {
const store = useContext(TanStackTableStateContext);
return useCallback(() => {
if (store) {
store.getState().scheduleClearHover(rowId);
}
}, [store, rowId]);
};
export const useIsTableLoading = (): boolean => {
const store = useContext(TanStackTableStateContext);
return useStore(store ?? defaultStore, (s) => s.isLoading);
};
export const useSetTableLoading = (): ((loading: boolean) => void) => {
const store = useContext(TanStackTableStateContext);
return useCallback(
(loading: boolean) => {
if (store) {
store.getState().setIsLoading(loading);
}
},
[store],
);
};
export function TableLoadingSync({
isLoading,
isInfiniteScrollMode,
}: {
isLoading: boolean;
isInfiniteScrollMode: boolean;
}): null {
const store = useContext(TanStackTableStateContext);
// Sync on mount and when props change
useEffect(() => {
if (store) {
store.getState().setIsLoading(isLoading);
store.getState().setIsInfiniteScrollMode(isInfiniteScrollMode);
}
}, [isLoading, isInfiniteScrollMode, store]);
return null;
}
export const useShouldShowCellSkeleton = (): boolean => {
const store = useContext(TanStackTableStateContext);
return useStore(
store ?? defaultStore,
(s) => s.isLoading && !s.isInfiniteScrollMode,
);
};
export const useColumnVisibility = (): VisibilityState => {
const store = useContext(TanStackTableStateContext);
return useStore(store ?? defaultStore, (s) => s.columnVisibility);
};
export const useIsColumnVisible = (columnId: string): boolean => {
const store = useContext(TanStackTableStateContext);
return useStore(
store ?? defaultStore,
(s) => s.columnVisibility[columnId] !== false,
);
};
export const useSetColumnVisibility = (): ((
visibility: VisibilityState,
) => void) => {
const store = useContext(TanStackTableStateContext);
return useCallback(
(visibility: VisibilityState) => {
if (store) {
store.getState().setColumnVisibility(visibility);
}
},
[store],
);
};
export function ColumnVisibilitySync({
visibility,
}: {
visibility: VisibilityState;
}): null {
const setVisibility = useSetColumnVisibility();
useEffect(() => {
setVisibility(visibility);
}, [visibility, setVisibility]);
return null;
}
export default TanStackTableStateContext;

View File

@@ -1,42 +0,0 @@
import type { HTMLAttributes, ReactNode } from 'react';
import cx from 'classnames';
import tableStyles from './TanStackTable.module.scss';
type BaseProps = Omit<
HTMLAttributes<HTMLSpanElement>,
'children' | 'dangerouslySetInnerHTML'
> & {
className?: string;
};
type WithChildren = BaseProps & {
children: ReactNode;
dangerouslySetInnerHTML?: never;
};
type WithDangerousHtml = BaseProps & {
children?: never;
dangerouslySetInnerHTML: { __html: string };
};
export type TanStackTableTextProps = WithChildren | WithDangerousHtml;
function TanStackTableText({
children,
className,
dangerouslySetInnerHTML,
...rest
}: TanStackTableTextProps): JSX.Element {
return (
<span
className={cx(tableStyles.tableCellText, className)}
dangerouslySetInnerHTML={dangerouslySetInnerHTML}
{...rest}
>
{children}
</span>
);
}
export default TanStackTableText;

View File

@@ -1,152 +0,0 @@
.tanstackTableViewWrapper {
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
position: relative;
min-height: 0;
}
.tanstackFixedCol {
width: 32px;
min-width: 32px;
max-width: 32px;
}
.tanstackFillerCol {
width: 100%;
min-width: 0;
}
.tanstackActionsCol {
width: 0;
min-width: 0;
max-width: 0;
}
.tanstackLoadMoreContainer {
width: 100%;
min-height: 56px;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 0 12px;
flex-shrink: 0;
}
.tanstackTableVirtuoso {
width: 100%;
overflow-x: auto;
}
.tanstackTableFootLoaderCell {
text-align: center;
padding: 8px 0;
}
.tanstackTableVirtuosoScroll {
flex: 1;
width: 100%;
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: var(--bg-slate-300) transparent;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
border-radius: 9999px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
&.cellTypographySmall {
--tanstack-plain-cell-font-size: 11px;
--tanstack-plain-cell-line-height: 16px;
:global(table tr td),
:global(table thead th) {
font-size: 11px;
line-height: 16px;
letter-spacing: -0.07px;
}
}
&.cellTypographyMedium {
--tanstack-plain-cell-font-size: 13px;
--tanstack-plain-cell-line-height: 20px;
:global(table tr td),
:global(table thead th) {
font-size: 13px;
line-height: 20px;
letter-spacing: -0.07px;
}
}
&.cellTypographyLarge {
--tanstack-plain-cell-font-size: 14px;
--tanstack-plain-cell-line-height: 24px;
:global(table tr td),
:global(table thead th) {
font-size: 14px;
line-height: 24px;
letter-spacing: -0.07px;
}
}
}
.paginationContainer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 12px;
}
.paginationPageSize {
width: 80px;
--combobox-trigger-height: 2rem;
}
.tanstackLoadingOverlay {
position: absolute;
left: 50%;
bottom: 2rem;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 3;
border-radius: 8px;
padding: 8px 16px;
background: var(--l1-background);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
:global(.lightMode) .tanstackTableVirtuosoScroll {
scrollbar-color: var(--bg-vanilla-300) transparent;
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-100);
}
}

View File

@@ -1,35 +0,0 @@
import type { Table } from '@tanstack/react-table';
import type { TableColumnDef } from './types';
import { getColumnWidthStyle } from './utils';
export function VirtuosoTableColGroup<TData>({
columns,
table,
}: {
columns: TableColumnDef<TData>[];
table: Table<TData>;
}): JSX.Element {
const visibleTanstackColumns = table.getVisibleFlatColumns();
const columnDefsById = new Map(columns.map((c) => [c.id, c]));
const columnSizing = table.getState().columnSizing;
return (
<colgroup>
{visibleTanstackColumns.map((tanstackCol, index) => {
const colDef = columnDefsById.get(tanstackCol.id);
if (!colDef) {
return <col key={tanstackCol.id} />;
}
const persistedWidth = columnSizing[tanstackCol.id];
const isLastColumn = index === visibleTanstackColumns.length - 1;
return (
<col
key={tanstackCol.id}
style={getColumnWidthStyle(colDef, persistedWidth, isLastColumn)}
/>
);
})}
</colgroup>
);
}

View File

@@ -1,253 +0,0 @@
jest.mock('../TanStackTable.module.scss', () => ({
__esModule: true,
default: {
tableRow: 'tableRow',
tableRowActive: 'tableRowActive',
tableRowExpansion: 'tableRowExpansion',
},
}));
jest.mock('../TanStackRow', () => ({
__esModule: true,
default: (): JSX.Element => (
<td data-testid="mocked-row-cells">mocked cells</td>
),
}));
const mockSetRowHovered = jest.fn();
const mockClearRowHovered = jest.fn();
jest.mock('../TanStackTableStateContext', () => ({
useSetRowHovered: (_rowId: string): (() => void) => mockSetRowHovered,
useClearRowHovered: (_rowId: string): (() => void) => mockClearRowHovered,
}));
import { fireEvent, render, screen } from '@testing-library/react';
import TanStackCustomTableRow from '../TanStackCustomTableRow';
import type { FlatItem, TableRowContext } from '../types';
const makeItem = (id: string): FlatItem<{ id: string }> => ({
kind: 'row',
row: { original: { id }, id } as never,
});
const virtuosoAttrs = {
'data-index': 0,
'data-item-index': 0,
'data-known-size': 40,
} as const;
const baseContext: TableRowContext<{ id: string }> = {
colCount: 1,
hasSingleColumn: false,
columnOrderKey: 'col1',
columnVisibilityKey: 'col1',
};
describe('TanStackCustomTableRow', () => {
beforeEach(() => {
mockSetRowHovered.mockClear();
mockClearRowHovered.mockClear();
});
it('renders cells via TanStackRowCells', async () => {
render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={baseContext}
/>
</tbody>
</table>,
);
expect(await screen.findByTestId('mocked-row-cells')).toBeInTheDocument();
});
it('applies active class when isRowActive returns true', () => {
const ctx: TableRowContext<{ id: string }> = {
...baseContext,
isRowActive: (row) => row.id === '1',
};
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={ctx}
/>
</tbody>
</table>,
);
expect(container.querySelector('tr')).toHaveClass('tableRowActive');
});
it('does not apply active class when isRowActive returns false', () => {
const ctx: TableRowContext<{ id: string }> = {
...baseContext,
isRowActive: (row) => row.id === 'other',
};
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={ctx}
/>
</tbody>
</table>,
);
expect(container.querySelector('tr')).not.toHaveClass('tableRowActive');
});
it('renders expansion row with expansion class', () => {
const item: FlatItem<{ id: string }> = {
kind: 'expansion',
row: { original: { id: '1' }, id: '1' } as never,
};
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={item}
context={baseContext}
/>
</tbody>
</table>,
);
expect(container.querySelector('tr')).toHaveClass('tableRowExpansion');
});
describe('hover state management', () => {
it('calls setRowHovered on mouse enter', () => {
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={baseContext}
/>
</tbody>
</table>,
);
const row = container.querySelector('tr')!;
fireEvent.mouseEnter(row);
expect(mockSetRowHovered).toHaveBeenCalled();
});
it('calls clearRowHovered on mouse leave', () => {
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={baseContext}
/>
</tbody>
</table>,
);
const row = container.querySelector('tr')!;
fireEvent.mouseLeave(row);
expect(mockClearRowHovered).toHaveBeenCalled();
});
});
describe('virtuoso integration', () => {
it('forwards data-index attribute to tr element', () => {
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={baseContext}
/>
</tbody>
</table>,
);
const row = container.querySelector('tr')!;
expect(row).toHaveAttribute('data-index', '0');
});
it('forwards data-item-index attribute to tr element', () => {
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={baseContext}
/>
</tbody>
</table>,
);
const row = container.querySelector('tr')!;
expect(row).toHaveAttribute('data-item-index', '0');
});
it('forwards data-known-size attribute to tr element', () => {
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={baseContext}
/>
</tbody>
</table>,
);
const row = container.querySelector('tr')!;
expect(row).toHaveAttribute('data-known-size', '40');
});
});
describe('row interaction', () => {
it('applies custom style from getRowStyle in context', () => {
const ctx: TableRowContext<{ id: string }> = {
...baseContext,
getRowStyle: () => ({ backgroundColor: 'red' }),
};
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={ctx}
/>
</tbody>
</table>,
);
const row = container.querySelector('tr')!;
expect(row).toHaveStyle({ backgroundColor: 'red' });
});
it('applies custom className from getRowClassName in context', () => {
const ctx: TableRowContext<{ id: string }> = {
...baseContext,
getRowClassName: () => 'custom-row-class',
};
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={ctx}
/>
</tbody>
</table>,
);
const row = container.querySelector('tr')!;
expect(row).toHaveClass('custom-row-class');
});
});
});

View File

@@ -1,368 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TanStackHeaderRow from '../TanStackHeaderRow';
import type { TableColumnDef } from '../types';
jest.mock('@dnd-kit/sortable', () => ({
useSortable: (): any => ({
attributes: {},
listeners: {},
setNodeRef: jest.fn(),
setActivatorNodeRef: jest.fn(),
transform: null,
transition: null,
isDragging: false,
}),
}));
const col = (
id: string,
overrides?: Partial<TableColumnDef<unknown>>,
): TableColumnDef<unknown> => ({
id,
header: id,
cell: (): null => null,
...overrides,
});
const header = {
id: 'col',
column: {
getCanResize: () => true,
getIsResizing: () => false,
columnDef: { header: 'col' },
},
getResizeHandler: () => jest.fn(),
getContext: () => ({}),
} as never;
describe('TanStackHeaderRow', () => {
it('renders column title', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={col('timestamp', { header: 'timestamp' })}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(screen.getByTitle('Timestamp')).toBeInTheDocument();
});
it('shows grip icon when enableMove is not false and pin is not set', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={col('body')}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(
screen.getByRole('button', { name: /drag body/i }),
).toBeInTheDocument();
});
it('does NOT show grip icon when pin is set', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={col('indicator', { pin: 'left' })}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(
screen.queryByRole('button', { name: /drag/i }),
).not.toBeInTheDocument();
});
it('shows remove button when enableRemove and canRemoveColumn are true', async () => {
const user = userEvent.setup();
const onRemoveColumn = jest.fn();
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={col('name', { enableRemove: true })}
header={header}
isDarkMode={false}
hasSingleColumn={false}
canRemoveColumn
onRemoveColumn={onRemoveColumn}
/>
</tr>
</thead>
</table>,
);
await user.click(screen.getByRole('button', { name: /column actions/i }));
await user.click(await screen.findByText(/remove column/i));
expect(onRemoveColumn).toHaveBeenCalledWith('name');
});
it('does NOT show remove button when enableRemove is absent', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={col('name')}
header={header}
isDarkMode={false}
hasSingleColumn={false}
canRemoveColumn
onRemoveColumn={jest.fn()}
/>
</tr>
</thead>
</table>,
);
expect(
screen.queryByRole('button', { name: /column actions/i }),
).not.toBeInTheDocument();
});
describe('sorting', () => {
const sortableCol = col('sortable', { enableSort: true, header: 'Sortable' });
const sortableHeader = {
id: 'sortable',
column: {
id: 'sortable',
getCanResize: (): boolean => true,
getIsResizing: (): boolean => false,
columnDef: { header: 'Sortable', enableSort: true },
},
getResizeHandler: (): jest.Mock => jest.fn(),
getContext: (): Record<string, unknown> => ({}),
} as never;
it('calls onSort with asc when clicking unsorted column', async () => {
const user = userEvent.setup();
const onSort = jest.fn();
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={sortableCol}
header={sortableHeader}
isDarkMode={false}
hasSingleColumn={false}
onSort={onSort}
/>
</tr>
</thead>
</table>,
);
// Sort button uses the column header as title
const sortButton = screen.getByTitle('Sortable');
await user.click(sortButton);
expect(onSort).toHaveBeenCalledWith({
columnName: 'sortable',
order: 'asc',
});
});
it('calls onSort with desc when clicking asc-sorted column', async () => {
const user = userEvent.setup();
const onSort = jest.fn();
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={sortableCol}
header={sortableHeader}
isDarkMode={false}
hasSingleColumn={false}
onSort={onSort}
orderBy={{ columnName: 'sortable', order: 'asc' }}
/>
</tr>
</thead>
</table>,
);
const sortButton = screen.getByTitle('Sortable');
await user.click(sortButton);
expect(onSort).toHaveBeenCalledWith({
columnName: 'sortable',
order: 'desc',
});
});
it('calls onSort with null when clicking desc-sorted column', async () => {
const user = userEvent.setup();
const onSort = jest.fn();
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={sortableCol}
header={sortableHeader}
isDarkMode={false}
hasSingleColumn={false}
onSort={onSort}
orderBy={{ columnName: 'sortable', order: 'desc' }}
/>
</tr>
</thead>
</table>,
);
const sortButton = screen.getByTitle('Sortable');
await user.click(sortButton);
expect(onSort).toHaveBeenCalledWith(null);
});
it('shows ascending indicator when orderBy matches column with asc', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={sortableCol}
header={sortableHeader}
isDarkMode={false}
hasSingleColumn={false}
onSort={jest.fn()}
orderBy={{ columnName: 'sortable', order: 'asc' }}
/>
</tr>
</thead>
</table>,
);
const sortButton = screen.getByTitle('Sortable');
expect(sortButton).toHaveAttribute('aria-sort', 'ascending');
});
it('shows descending indicator when orderBy matches column with desc', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={sortableCol}
header={sortableHeader}
isDarkMode={false}
hasSingleColumn={false}
onSort={jest.fn()}
orderBy={{ columnName: 'sortable', order: 'desc' }}
/>
</tr>
</thead>
</table>,
);
const sortButton = screen.getByTitle('Sortable');
expect(sortButton).toHaveAttribute('aria-sort', 'descending');
});
it('does not show sort button when enableSort is false', () => {
const nonSortableCol = col('nonsort', {
enableSort: false,
header: 'Nonsort',
});
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={nonSortableCol}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
// When enableSort is false, the header text is rendered as a span, not a button
// The title 'Nonsort' exists on the span, not on a button
const titleElement = screen.getByTitle('Nonsort');
expect(titleElement.tagName.toLowerCase()).not.toBe('button');
});
});
describe('resizing', () => {
it('shows resize handle when enableResize is not false', () => {
const resizableCol = col('resizable', { enableResize: true });
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={resizableCol}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
// Resize handle has title "Drag to resize column"
expect(screen.getByTitle('Drag to resize column')).toBeInTheDocument();
});
it('hides resize handle when enableResize is false', () => {
const nonResizableCol = col('noresize', { enableResize: false });
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={nonResizableCol}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(screen.queryByTitle('Drag to resize column')).not.toBeInTheDocument();
});
});
describe('column movement', () => {
it('does not show grip when enableMove is false', () => {
const noMoveCol = col('nomove', { enableMove: false });
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={noMoveCol}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(
screen.queryByRole('button', { name: /drag/i }),
).not.toBeInTheDocument();
});
});
});

View File

@@ -1,288 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TanStackRowCells from '../TanStackRow';
import type { TableRowContext } from '../types';
const flexRenderMock = jest.fn((def: unknown) =>
typeof def === 'function' ? def({}) : def,
);
jest.mock('@tanstack/react-table', () => ({
flexRender: (def: unknown, _ctx?: unknown): unknown => flexRenderMock(def),
}));
type Row = { id: string };
function buildMockRow(
cells: { id: string }[],
rowData: Row = { id: 'r1' },
): Parameters<typeof TanStackRowCells>[0]['row'] {
return {
original: rowData,
getVisibleCells: () =>
cells.map((c, i) => ({
id: `cell-${i}`,
column: {
id: c.id,
columnDef: { cell: (): string => `content-${c.id}` },
},
getContext: (): Record<string, unknown> => ({}),
getValue: (): string => `content-${c.id}`,
})),
} as never;
}
describe('TanStackRowCells', () => {
beforeEach(() => flexRenderMock.mockClear());
it('renders a cell per visible column', () => {
const row = buildMockRow([{ id: 'col-a' }, { id: 'col-b' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={undefined}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
expect(screen.getAllByRole('cell')).toHaveLength(2);
});
it('calls onRowClick when a cell is clicked', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
// onRowClick receives (rowData, itemKey) - itemKey is empty when getRowKeyData not provided
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
});
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
const onRowDeactivate = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
onRowDeactivate,
isRowActive: () => true,
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowDeactivate).toHaveBeenCalled();
expect(onRowClick).not.toHaveBeenCalled();
});
it('does not render renderRowActions before hover', () => {
const ctx: TableRowContext<Row> = {
colCount: 1,
renderRowActions: () => <button type="button">action</button>,
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
// Row actions are not rendered until hover (useIsRowHovered returns false by default)
expect(
screen.queryByRole('button', { name: 'action' }),
).not.toBeInTheDocument();
});
it('renders expansion cell with renderExpandedRow content', async () => {
const row = {
original: { id: 'r1' },
getVisibleCells: () => [],
} as never;
const ctx: TableRowContext<Row> = {
colCount: 3,
renderExpandedRow: (r) => <div>expanded-{r.id}</div>,
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="expansion"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
expect(await screen.findByText('expanded-r1')).toBeInTheDocument();
});
describe('new tab click', () => {
it('calls onRowClickNewTab on ctrl+click', () => {
const onRowClick = jest.fn();
const onRowClickNewTab = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
onRowClickNewTab,
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0], { ctrlKey: true });
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
expect(onRowClick).not.toHaveBeenCalled();
});
it('calls onRowClickNewTab on meta+click (cmd)', () => {
const onRowClick = jest.fn();
const onRowClickNewTab = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
onRowClickNewTab,
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0], { metaKey: true });
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
expect(onRowClick).not.toHaveBeenCalled();
});
it('does not call onRowClick when modifier key is pressed', () => {
const onRowClick = jest.fn();
const onRowClickNewTab = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
onRowClickNewTab,
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0], { ctrlKey: true });
expect(onRowClick).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,440 +0,0 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
import { renderTanStackTable } from './testUtils';
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('../TanStackTable.module.scss', () => ({
__esModule: true,
default: {
tanStackTable: 'tanStackTable',
tableRow: 'tableRow',
tableRowActive: 'tableRowActive',
tableRowExpansion: 'tableRowExpansion',
tableCell: 'tableCell',
tableCellExpansion: 'tableCellExpansion',
tableHeaderCell: 'tableHeaderCell',
tableCellText: 'tableCellText',
tableViewRowActions: 'tableViewRowActions',
},
}));
describe('TanStackTableView Integration', () => {
describe('rendering', () => {
it('renders all data rows', async () => {
renderTanStackTable({});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();
expect(screen.getByText('Item 3')).toBeInTheDocument();
});
});
it('renders column headers', async () => {
renderTanStackTable({});
await waitFor(() => {
expect(screen.getByText('ID')).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Value')).toBeInTheDocument();
});
});
it('renders empty state when data is empty and not loading', async () => {
renderTanStackTable({
props: { data: [], isLoading: false },
});
// Table should still render but with no data rows
await waitFor(() => {
expect(screen.queryByText('Item 1')).not.toBeInTheDocument();
});
});
it('renders table structure when loading with no previous data', async () => {
renderTanStackTable({
props: { data: [], isLoading: true },
});
// Table should render with skeleton rows
await waitFor(() => {
expect(screen.getByRole('table')).toBeInTheDocument();
});
});
});
describe('loading states', () => {
it('keeps table mounted when loading with no data', () => {
renderTanStackTable({
props: { data: [], isLoading: true },
});
// Table should still be in the DOM for skeleton rows
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('shows loading spinner for infinite scroll when loading', () => {
renderTanStackTable({
props: { isLoading: true, onEndReached: jest.fn() },
});
expect(screen.getByTestId('tanstack-infinite-loader')).toBeInTheDocument();
});
it('does not show loading spinner for infinite scroll when not loading', () => {
renderTanStackTable({
props: { isLoading: false, onEndReached: jest.fn() },
});
expect(
screen.queryByTestId('tanstack-infinite-loader'),
).not.toBeInTheDocument();
});
it('does not show loading spinner when not in infinite scroll mode', () => {
renderTanStackTable({
props: { isLoading: true },
});
expect(
screen.queryByTestId('tanstack-infinite-loader'),
).not.toBeInTheDocument();
});
});
describe('pagination', () => {
it('renders pagination when pagination prop is provided', async () => {
renderTanStackTable({
props: {
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
},
});
await waitFor(() => {
// Look for pagination navigation or page number text
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
});
it('updates page when clicking page number', async () => {
const user = userEvent.setup();
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
renderTanStackTable({
props: {
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
enableQueryParams: true,
},
onUrlUpdate,
});
await waitFor(() => {
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
// Find page 2 button/link within pagination navigation
const nav = screen.getByRole('navigation');
const page2 = Array.from(nav.querySelectorAll('button')).find(
(btn) => btn.textContent?.trim() === '2',
);
if (!page2) {
throw new Error('Page 2 button not found in pagination');
}
await user.click(page2);
await waitFor(() => {
const lastPage = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('page'))
.filter(Boolean)
.pop();
expect(lastPage).toBe('2');
});
});
it('does not render pagination in infinite scroll mode', async () => {
renderTanStackTable({
props: {
pagination: { total: 100 },
onEndReached: jest.fn(), // This enables infinite scroll mode
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Pagination should not be visible in infinite scroll mode
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
it('renders prefixPaginationContent before pagination', async () => {
renderTanStackTable({
props: {
pagination: { total: 100 },
prefixPaginationContent: <span data-testid="prefix-content">Prefix</span>,
},
});
await waitFor(() => {
expect(screen.getByTestId('prefix-content')).toBeInTheDocument();
});
});
it('renders suffixPaginationContent after pagination', async () => {
renderTanStackTable({
props: {
pagination: { total: 100 },
suffixPaginationContent: <span data-testid="suffix-content">Suffix</span>,
},
});
await waitFor(() => {
expect(screen.getByTestId('suffix-content')).toBeInTheDocument();
});
});
});
describe('sorting', () => {
it('updates orderBy URL param when clicking sortable header', async () => {
const user = userEvent.setup();
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
renderTanStackTable({
props: { enableQueryParams: true },
onUrlUpdate,
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Find the sortable column header's sort button (ID column has enableSort: true)
const sortButton = screen.getByTitle('ID');
await user.click(sortButton);
await waitFor(() => {
const lastOrderBy = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('order_by'))
.filter(Boolean)
.pop();
expect(lastOrderBy).toBeDefined();
const parsed = JSON.parse(lastOrderBy!);
expect(parsed.columnName).toBe('id');
expect(parsed.order).toBe('asc');
});
});
it('toggles sort order on subsequent clicks', async () => {
const user = userEvent.setup();
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
renderTanStackTable({
props: { enableQueryParams: true },
onUrlUpdate,
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
const sortButton = screen.getByTitle('ID');
// First click - asc
await user.click(sortButton);
// Second click - desc
await user.click(sortButton);
await waitFor(() => {
const lastOrderBy = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('order_by'))
.filter(Boolean)
.pop();
if (lastOrderBy) {
const parsed = JSON.parse(lastOrderBy);
expect(parsed.order).toBe('desc');
}
});
});
});
describe('row selection', () => {
it('calls onRowClick with row data and itemKey', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
renderTanStackTable({
props: {
onRowClick,
getRowKey: (row) => row.id,
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
await user.click(screen.getByText('Item 1'));
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1', name: 'Item 1' }),
'1',
);
});
it('applies active class when isRowActive returns true', async () => {
renderTanStackTable({
props: {
isRowActive: (row) => row.id === '1',
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Find the row containing Item 1 and check for active class
const cell = screen.getByText('Item 1');
const row = cell.closest('tr');
expect(row).toHaveClass('tableRowActive');
});
it('calls onRowDeactivate when clicking active row', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
const onRowDeactivate = jest.fn();
renderTanStackTable({
props: {
onRowClick,
onRowDeactivate,
isRowActive: (row) => row.id === '1',
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
await user.click(screen.getByText('Item 1'));
expect(onRowDeactivate).toHaveBeenCalled();
expect(onRowClick).not.toHaveBeenCalled();
});
it('opens in new tab on ctrl+click', async () => {
const onRowClick = jest.fn();
const onRowClickNewTab = jest.fn();
renderTanStackTable({
props: {
onRowClick,
onRowClickNewTab,
getRowKey: (row) => row.id,
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Item 1'), { ctrlKey: true });
expect(onRowClickNewTab).toHaveBeenCalledWith(
expect.objectContaining({ id: '1' }),
'1',
);
expect(onRowClick).not.toHaveBeenCalled();
});
it('opens in new tab on meta+click', async () => {
const onRowClick = jest.fn();
const onRowClickNewTab = jest.fn();
renderTanStackTable({
props: {
onRowClick,
onRowClickNewTab,
getRowKey: (row) => row.id,
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Item 1'), { metaKey: true });
expect(onRowClickNewTab).toHaveBeenCalledWith(
expect.objectContaining({ id: '1' }),
'1',
);
expect(onRowClick).not.toHaveBeenCalled();
});
});
describe('row expansion', () => {
it('renders expanded content below the row when expanded', async () => {
renderTanStackTable({
props: {
renderExpandedRow: (row) => (
<div data-testid="expanded-content">Expanded: {row.name}</div>
),
getRowCanExpand: () => true,
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Find and click expand button (if available in the row)
// The expansion is controlled by TanStack Table's expanded state
// For now, just verify the renderExpandedRow prop is wired correctly
// by checking the table renders without errors
expect(screen.getByRole('table')).toBeInTheDocument();
});
});
describe('infinite scroll', () => {
it('calls onEndReached when provided', async () => {
const onEndReached = jest.fn();
renderTanStackTable({
props: {
onEndReached,
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Virtuoso will call onEndReached based on scroll position
// In mock context, we verify the prop is wired correctly
expect(onEndReached).toBeDefined();
});
it('shows loading spinner at bottom when loading in infinite scroll mode', () => {
renderTanStackTable({
props: {
isLoading: true,
onEndReached: jest.fn(),
},
});
expect(screen.getByTestId('tanstack-infinite-loader')).toBeInTheDocument();
});
it('hides pagination in infinite scroll mode', async () => {
renderTanStackTable({
props: {
pagination: { total: 100 },
onEndReached: jest.fn(),
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// When onEndReached is provided, pagination should not render
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
});
});

View File

@@ -1,94 +0,0 @@
import { ReactNode } from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import { TooltipProvider } from '@signozhq/ui';
import { render, RenderResult } from '@testing-library/react';
import { NuqsTestingAdapter, OnUrlUpdateFunction } from 'nuqs/adapters/testing';
import TanStackTable from '../index';
import type { TableColumnDef, TanStackTableProps } from '../types';
// NOTE: Test files importing this utility must add this mock at the top of their file:
// jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
// Default test data types
export type TestRow = { id: string; name: string; value: number };
export const defaultColumns: TableColumnDef<TestRow>[] = [
{
id: 'id',
header: 'ID',
accessorKey: 'id',
enableSort: true,
cell: ({ value }): string => String(value),
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
cell: ({ value }): string => String(value),
},
{
id: 'value',
header: 'Value',
accessorKey: 'value',
enableSort: true,
cell: ({ value }): string => String(value),
},
];
export const defaultData: TestRow[] = [
{ id: '1', name: 'Item 1', value: 100 },
{ id: '2', name: 'Item 2', value: 200 },
{ id: '3', name: 'Item 3', value: 300 },
];
export type RenderTanStackTableOptions<T> = {
props?: Partial<TanStackTableProps<T>>;
queryParams?: Record<string, string>;
onUrlUpdate?: OnUrlUpdateFunction;
};
export function renderTanStackTable<T = TestRow>(
options: RenderTanStackTableOptions<T> = {},
): RenderResult {
const { props = {}, queryParams, onUrlUpdate } = options;
const mergedProps = {
data: (defaultData as unknown) as T[],
columns: (defaultColumns as unknown) as TableColumnDef<T>[],
...props,
} as TanStackTableProps<T>;
return render(
<NuqsTestingAdapter searchParams={queryParams} onUrlUpdate={onUrlUpdate}>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 500, itemHeight: 50 }}
>
<TooltipProvider>
<TanStackTable<T> {...mergedProps} />
</TooltipProvider>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>,
);
}
// Helper to wrap any component with test providers (for unit tests)
export function renderWithProviders(
ui: ReactNode,
options: {
queryParams?: Record<string, string>;
onUrlUpdate?: OnUrlUpdateFunction;
} = {},
): RenderResult {
const { queryParams, onUrlUpdate } = options;
return render(
<NuqsTestingAdapter searchParams={queryParams} onUrlUpdate={onUrlUpdate}>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 500, itemHeight: 50 }}
>
<TooltipProvider>{ui}</TooltipProvider>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>,
);
}

View File

@@ -1,247 +0,0 @@
/* eslint-disable no-restricted-syntax */
import { act, renderHook } from '@testing-library/react';
import { TableColumnDef } from '../types';
import { useColumnState } from '../useColumnState';
import { useColumnStore } from '../useColumnStore';
const TEST_KEY = 'test-state';
type TestRow = { id: string; name: string };
const col = (
id: string,
overrides: Partial<TableColumnDef<TestRow>> = {},
): TableColumnDef<TestRow> => ({
id,
header: id,
cell: (): null => null,
...overrides,
});
describe('useColumnState', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
});
describe('initialization', () => {
it('initializes store from column defaults on mount', () => {
const columns = [
col('a', { defaultVisibility: true }),
col('b', { defaultVisibility: false }),
col('c'),
];
renderHook(() => useColumnState({ storageKey: TEST_KEY, columns }));
const state = useColumnStore.getState().tables[TEST_KEY];
expect(state.hiddenColumnIds).toEqual(['b']);
});
it('does not initialize without storageKey', () => {
const columns = [col('a', { defaultVisibility: false })];
renderHook(() => useColumnState({ columns }));
expect(useColumnStore.getState().tables[TEST_KEY]).toBeUndefined();
});
});
describe('columnVisibility', () => {
it('returns visibility state from hidden columns', () => {
const columns = [col('a'), col('b'), col('c')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
expect(result.current.columnVisibility).toEqual({ b: false });
});
it('applies visibilityBehavior for grouped state', () => {
const columns = [
col('ungrouped', { visibilityBehavior: 'hidden-on-expand' }),
col('grouped', { visibilityBehavior: 'hidden-on-collapse' }),
col('always'),
];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
// Not grouped
const { result: notGrouped } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: false }),
);
expect(notGrouped.current.columnVisibility).toEqual({ grouped: false });
// Grouped
const { result: grouped } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: true }),
);
expect(grouped.current.columnVisibility).toEqual({ ungrouped: false });
});
it('combines store hidden + visibilityBehavior', () => {
const columns = [
col('a', { visibilityBehavior: 'hidden-on-expand' }),
col('b'),
];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: true }),
);
expect(result.current.columnVisibility).toEqual({ a: false, b: false });
});
});
describe('sortedColumns', () => {
it('returns columns in original order when no order set', () => {
const columns = [col('a'), col('b'), col('c')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
'a',
'b',
'c',
]);
});
it('returns columns sorted by stored order', () => {
const columns = [col('a'), col('b'), col('c')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'a', 'b']);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
'c',
'a',
'b',
]);
});
it('keeps pinned columns at the start', () => {
const columns = [col('a'), col('pinned', { pin: 'left' }), col('b')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
useColumnStore.getState().setColumnOrder(TEST_KEY, ['b', 'a']);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
'pinned',
'b',
'a',
]);
});
});
describe('actions', () => {
it('hideColumn hides a column', () => {
const columns = [col('a'), col('b')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
act(() => {
result.current.hideColumn('a');
});
expect(result.current.columnVisibility).toEqual({ a: false });
});
it('showColumn shows a column', () => {
const columns = [col('a', { defaultVisibility: false })];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
expect(result.current.columnVisibility).toEqual({ a: false });
act(() => {
result.current.showColumn('a');
});
expect(result.current.columnVisibility).toEqual({});
});
it('setColumnSizing updates sizing', () => {
const columns = [col('a')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
act(() => {
result.current.setColumnSizing({ a: 200 });
});
expect(result.current.columnSizing).toEqual({ a: 200 });
});
it('setColumnOrder updates order from column array', () => {
const columns = [col('a'), col('b'), col('c')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
act(() => {
result.current.setColumnOrder([col('c'), col('a'), col('b')]);
});
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
'c',
'a',
'b',
]);
});
});
});

View File

@@ -1,296 +0,0 @@
/* eslint-disable no-restricted-syntax */
import { act, renderHook } from '@testing-library/react';
import {
useColumnOrder,
useColumnSizing,
useColumnStore,
useHiddenColumnIds,
} from '../useColumnStore';
const TEST_KEY = 'test-table';
describe('useColumnStore', () => {
beforeEach(() => {
useColumnStore.getState().tables = {};
localStorage.clear();
});
describe('initializeFromDefaults', () => {
it('initializes hidden columns from defaultVisibility: false', () => {
const columns = [
{ id: 'a', defaultVisibility: true },
{ id: 'b', defaultVisibility: false },
{ id: 'c' }, // defaults to visible
];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns as any);
});
const state = useColumnStore.getState().tables[TEST_KEY];
expect(state.hiddenColumnIds).toEqual(['b']);
expect(state.columnOrder).toEqual([]);
expect(state.columnSizing).toEqual({});
});
it('does not reinitialize if already exists', () => {
act(() => {
useColumnStore
.getState()
.initializeFromDefaults(TEST_KEY, [
{ id: 'a', defaultVisibility: false },
] as any);
useColumnStore.getState().hideColumn(TEST_KEY, 'x');
useColumnStore
.getState()
.initializeFromDefaults(TEST_KEY, [
{ id: 'b', defaultVisibility: false },
] as any);
});
const state = useColumnStore.getState().tables[TEST_KEY];
expect(state.hiddenColumnIds).toContain('a');
expect(state.hiddenColumnIds).toContain('x');
expect(state.hiddenColumnIds).not.toContain('b');
});
});
describe('hideColumn / showColumn / toggleColumn', () => {
beforeEach(() => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
});
});
it('hideColumn adds to hiddenColumnIds', () => {
act(() => {
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
});
expect(useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds).toContain(
'col1',
);
});
it('hideColumn is idempotent', () => {
act(() => {
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
});
expect(
useColumnStore
.getState()
.tables[TEST_KEY].hiddenColumnIds.filter((id) => id === 'col1'),
).toHaveLength(1);
});
it('showColumn removes from hiddenColumnIds', () => {
act(() => {
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
useColumnStore.getState().showColumn(TEST_KEY, 'col1');
});
expect(
useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds,
).not.toContain('col1');
});
it('toggleColumn toggles visibility', () => {
act(() => {
useColumnStore.getState().toggleColumn(TEST_KEY, 'col1');
});
expect(useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds).toContain(
'col1',
);
act(() => {
useColumnStore.getState().toggleColumn(TEST_KEY, 'col1');
});
expect(
useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds,
).not.toContain('col1');
});
});
describe('setColumnSizing', () => {
beforeEach(() => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
});
});
it('updates column sizing', () => {
act(() => {
useColumnStore
.getState()
.setColumnSizing(TEST_KEY, { col1: 200, col2: 300 });
});
expect(useColumnStore.getState().tables[TEST_KEY].columnSizing).toEqual({
col1: 200,
col2: 300,
});
});
});
describe('setColumnOrder', () => {
beforeEach(() => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
});
});
it('updates column order', () => {
act(() => {
useColumnStore
.getState()
.setColumnOrder(TEST_KEY, ['col2', 'col1', 'col3']);
});
expect(useColumnStore.getState().tables[TEST_KEY].columnOrder).toEqual([
'col2',
'col1',
'col3',
]);
});
});
describe('resetToDefaults', () => {
it('resets to column defaults', () => {
const columns = [
{ id: 'a', defaultVisibility: false },
{ id: 'b', defaultVisibility: true },
];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns as any);
useColumnStore.getState().showColumn(TEST_KEY, 'a');
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
useColumnStore.getState().setColumnOrder(TEST_KEY, ['b', 'a']);
useColumnStore.getState().setColumnSizing(TEST_KEY, { a: 100 });
});
act(() => {
useColumnStore.getState().resetToDefaults(TEST_KEY, columns as any);
});
const state = useColumnStore.getState().tables[TEST_KEY];
expect(state.hiddenColumnIds).toEqual(['a']);
expect(state.columnOrder).toEqual([]);
expect(state.columnSizing).toEqual({});
});
});
describe('cleanupStaleHiddenColumns', () => {
it('removes hidden column IDs that are not in validColumnIds', () => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
useColumnStore.getState().hideColumn(TEST_KEY, 'col2');
useColumnStore.getState().hideColumn(TEST_KEY, 'col3');
});
// Only col1 and col3 are valid now
act(() => {
useColumnStore
.getState()
.cleanupStaleHiddenColumns(TEST_KEY, new Set(['col1', 'col3']));
});
const state = useColumnStore.getState().tables[TEST_KEY];
expect(state.hiddenColumnIds).toEqual(['col1', 'col3']);
expect(state.hiddenColumnIds).not.toContain('col2');
});
it('does nothing when all hidden columns are valid', () => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
useColumnStore.getState().hideColumn(TEST_KEY, 'col2');
});
const stateBefore = useColumnStore.getState().tables[TEST_KEY];
const hiddenBefore = [...stateBefore.hiddenColumnIds];
act(() => {
useColumnStore
.getState()
.cleanupStaleHiddenColumns(TEST_KEY, new Set(['col1', 'col2', 'col3']));
});
const stateAfter = useColumnStore.getState().tables[TEST_KEY];
expect(stateAfter.hiddenColumnIds).toEqual(hiddenBefore);
});
it('does nothing for unknown storage key', () => {
act(() => {
useColumnStore
.getState()
.cleanupStaleHiddenColumns('unknown-key', new Set(['col1']));
});
// Should not throw or create state
expect(useColumnStore.getState().tables['unknown-key']).toBeUndefined();
});
});
describe('selector hooks', () => {
it('useHiddenColumnIds returns hidden columns', () => {
act(() => {
useColumnStore
.getState()
.initializeFromDefaults(TEST_KEY, [
{ id: 'a', defaultVisibility: false },
] as any);
});
const { result } = renderHook(() => useHiddenColumnIds(TEST_KEY));
expect(result.current).toEqual(['a']);
});
it('useHiddenColumnIds returns a stable snapshot for persisted state', () => {
localStorage.setItem(
'@signoz/table-columns/test-table',
JSON.stringify({
hiddenColumnIds: ['persisted'],
columnOrder: [],
columnSizing: {},
}),
);
const { result, rerender } = renderHook(() => useHiddenColumnIds(TEST_KEY));
const firstSnapshot = result.current;
rerender();
expect(result.current).toBe(firstSnapshot);
});
it('useColumnSizing returns sizing', () => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
useColumnStore.getState().setColumnSizing(TEST_KEY, { col1: 150 });
});
const { result } = renderHook(() => useColumnSizing(TEST_KEY));
expect(result.current).toEqual({ col1: 150 });
});
it('useColumnOrder returns order', () => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'b', 'a']);
});
const { result } = renderHook(() => useColumnOrder(TEST_KEY));
expect(result.current).toEqual(['c', 'b', 'a']);
});
it('returns empty defaults for unknown storageKey', () => {
const { result: hidden } = renderHook(() => useHiddenColumnIds('unknown'));
const { result: sizing } = renderHook(() => useColumnSizing('unknown'));
const { result: order } = renderHook(() => useColumnOrder('unknown'));
expect(hidden.current).toEqual([]);
expect(sizing.current).toEqual({});
expect(order.current).toEqual([]);
});
});
});

View File

@@ -1,239 +0,0 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import {
NuqsTestingAdapter,
OnUrlUpdateFunction,
UrlUpdateEvent,
} from 'nuqs/adapters/testing';
import { useTableParams } from '../useTableParams';
function createNuqsWrapper(
queryParams?: Record<string, string>,
onUrlUpdate?: OnUrlUpdateFunction,
): ({ children }: { children: ReactNode }) => JSX.Element {
return function NuqsWrapper({
children,
}: {
children: ReactNode;
}): JSX.Element {
return (
<NuqsTestingAdapter
searchParams={queryParams}
onUrlUpdate={onUrlUpdate}
hasMemory
>
{children}
</NuqsTestingAdapter>
);
};
}
describe('useTableParams (local mode — enableQueryParams not set)', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('returns default page=1 and limit=50', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams(), { wrapper });
expect(result.current.page).toBe(1);
expect(result.current.limit).toBe(50);
expect(result.current.orderBy).toBeNull();
});
it('respects custom defaults', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(
() => useTableParams(undefined, { page: 2, limit: 25 }),
{ wrapper },
);
expect(result.current.page).toBe(2);
expect(result.current.limit).toBe(25);
});
it('setPage updates page', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams(), { wrapper });
act(() => {
result.current.setPage(3);
});
expect(result.current.page).toBe(3);
});
it('setLimit updates limit', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams(), { wrapper });
act(() => {
result.current.setLimit(100);
});
expect(result.current.limit).toBe(100);
});
it('setOrderBy updates orderBy', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams(), { wrapper });
act(() => {
result.current.setOrderBy({ columnName: 'cpu', order: 'desc' });
});
expect(result.current.orderBy).toEqual({ columnName: 'cpu', order: 'desc' });
});
});
describe('useTableParams (URL mode — enableQueryParams set)', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('uses nuqs state when enableQueryParams=true', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams(true), { wrapper });
expect(result.current.page).toBe(1);
act(() => {
result.current.setPage(5);
jest.runAllTimers();
});
expect(result.current.page).toBe(5);
});
it('uses prefixed keys when enableQueryParams is a string', () => {
const wrapper = createNuqsWrapper({ pods_page: '2' });
const { result } = renderHook(() => useTableParams('pods', { page: 2 }), {
wrapper,
});
expect(result.current.page).toBe(2);
act(() => {
result.current.setPage(4);
jest.runAllTimers();
});
expect(result.current.page).toBe(4);
});
it('local state is ignored when enableQueryParams is set', () => {
const localWrapper = createNuqsWrapper();
const urlWrapper = createNuqsWrapper();
const { result: local } = renderHook(() => useTableParams(), {
wrapper: localWrapper,
});
const { result: url } = renderHook(() => useTableParams(true), {
wrapper: urlWrapper,
});
act(() => {
local.current.setPage(99);
});
// URL mode hook in a separate wrapper should still have its own state
expect(url.current.page).toBe(1);
});
it('reads initial page from URL params', () => {
const wrapper = createNuqsWrapper({ page: '3' });
const { result } = renderHook(() => useTableParams(true), { wrapper });
expect(result.current.page).toBe(3);
});
it('reads initial orderBy from URL params', () => {
const orderBy = JSON.stringify({ columnName: 'name', order: 'desc' });
const wrapper = createNuqsWrapper({ order_by: orderBy });
const { result } = renderHook(() => useTableParams(true), { wrapper });
expect(result.current.orderBy).toEqual({ columnName: 'name', order: 'desc' });
});
it('updates URL when setPage is called', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams(true), { wrapper });
act(() => {
result.current.setPage(5);
jest.runAllTimers();
});
const lastPage = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('page'))
.filter(Boolean)
.pop();
expect(lastPage).toBe('5');
});
it('updates URL when setOrderBy is called', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams(true), { wrapper });
act(() => {
result.current.setOrderBy({ columnName: 'value', order: 'asc' });
jest.runAllTimers();
});
const lastOrderBy = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('order_by'))
.filter(Boolean)
.pop();
expect(lastOrderBy).toBeDefined();
expect(JSON.parse(lastOrderBy!)).toEqual({
columnName: 'value',
order: 'asc',
});
});
it('uses custom param names from config object', () => {
const config = {
page: 'listPage',
limit: 'listLimit',
orderBy: 'listOrderBy',
expanded: 'listExpanded',
};
const wrapper = createNuqsWrapper({ listPage: '3' });
const { result } = renderHook(() => useTableParams(config, { page: 3 }), {
wrapper,
});
expect(result.current.page).toBe(3);
});
it('manages expanded state for row expansion', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams(true), { wrapper });
act(() => {
result.current.setExpanded({ 'row-1': true });
});
expect(result.current.expanded).toEqual({ 'row-1': true });
});
it('toggles sort order correctly: null → asc → desc → null', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams(true), { wrapper });
// Initial state
expect(result.current.orderBy).toBeNull();
// First click: null → asc
act(() => {
result.current.setOrderBy({ columnName: 'id', order: 'asc' });
});
expect(result.current.orderBy).toEqual({ columnName: 'id', order: 'asc' });
// Second click: asc → desc
act(() => {
result.current.setOrderBy({ columnName: 'id', order: 'desc' });
});
expect(result.current.orderBy).toEqual({ columnName: 'id', order: 'desc' });
// Third click: desc → null
act(() => {
result.current.setOrderBy(null);
});
expect(result.current.orderBy).toBeNull();
});
});

View File

@@ -1,179 +0,0 @@
import { TanStackTableBase } from './TanStackTable';
import TanStackTableText from './TanStackTableText';
export * from './TanStackTableStateContext';
export * from './types';
export * from './useColumnState';
export * from './useColumnStore';
export * from './useTableParams';
/**
* Virtualized data table built on TanStack Table and `react-virtuoso`: resizable and pinnable columns,
* optional drag-to-reorder headers, expandable rows, and pagination or infinite scroll.
*
* @example Minimal usage
* ```tsx
* import TanStackTable from 'components/TanStackTableView';
* import type { TableColumnDef } from 'components/TanStackTableView';
*
* type Row = { id: string; name: string };
*
* const columns: TableColumnDef<Row>[] = [
* {
* id: 'name',
* header: 'Name',
* accessorKey: 'name',
* cell: ({ value }) => <TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>,
* },
* ];
*
* function Example(): JSX.Element {
* return <TanStackTable<Row> data={rows} columns={columns} />;
* }
* ```
*
* @example Column definitions — `accessorFn`, custom header, pinned column, sortable
* ```tsx
* const columns: TableColumnDef<Row>[] = [
* {
* id: 'id',
* header: 'ID',
* accessorKey: 'id',
* pin: 'left',
* width: { min: 80, default: 120 },
* enableSort: true,
* cell: ({ value }) => <TanStackTable.Text>{String(value)}</TanStackTable.Text>,
* },
* {
* id: 'computed',
* header: () => <span>Computed</span>,
* accessorFn: (row) => row.first + row.last,
* enableMove: false,
* enableRemove: false,
* cell: ({ value }) => <TanStackTable.Text>{String(value)}</TanStackTable.Text>,
* },
* ];
* ```
*
* @example Column state persistence with store (recommended)
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* columnStorageKey="my-table-columns"
* />
* ```
*
* @example Pagination with query params. Use `enableQueryParams` object to customize param names.
* ```tsx
* <TanStackTable
* data={pageRows}
* columns={columns}
* pagination={{ total: totalCount, defaultPage: 1, defaultLimit: 20 }}
* enableQueryParams={{
* page: 'listPage',
* limit: 'listPageSize',
* orderBy: 'orderBy',
* expanded: 'listExpanded',
* }}
* prefixPaginationContent={<span>Custom prefix</span>}
* suffixPaginationContent={<span>Custom suffix</span>}
* />
* ```
*
* @example Infinite scroll — use `onEndReached` (pagination UI is hidden when set).
* ```tsx
* <TanStackTable
* data={accumulatedRows}
* columns={columns}
* onEndReached={(lastIndex) => fetchMore(lastIndex)}
* isLoading={isFetching}
* />
* ```
*
* @example Loading state and typography for plain string/number cells
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* isLoading={isFetching}
* skeletonRowCount={15}
* cellTypographySize="small"
* plainTextCellLineClamp={2}
* />
* ```
*
* @example Row styling, selection, and actions. `onRowClick` receives `(row, itemKey)`.
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* getRowKey={(row) => row.id}
* getItemKey={(row) => row.id}
* isRowActive={(row) => row.id === selectedId}
* activeRowIndex={selectedIndex}
* onRowClick={(row, itemKey) => setSelectedId(itemKey)}
* onRowClickNewTab={(row, itemKey) => openInNewTab(itemKey)}
* onRowDeactivate={() => setSelectedId(undefined)}
* getRowClassName={(row) => (row.severity === 'error' ? 'row-error' : '')}
* getRowStyle={(row) => (row.dimmed ? { opacity: 0.5 } : {})}
* renderRowActions={(row) => <Button size="small">Open</Button>}
* />
* ```
*
* @example Expandable rows. `renderExpandedRow` receives `(row, rowKey, groupMeta?)`.
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* getRowKey={(row) => row.id}
* renderExpandedRow={(row, rowKey, groupMeta) => (
* <pre>{JSON.stringify({ rowKey, groupMeta, raw: row.raw }, null, 2)}</pre>
* )}
* getRowCanExpand={(row) => Boolean(row.raw)}
* />
* ```
*
* @example Grouped rows — use `groupBy` + `getGroupKey` for group-aware key generation.
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* getRowKey={(row) => row.id}
* groupBy={[{ key: 'namespace' }, { key: 'cluster' }]}
* getGroupKey={(row) => row.meta ?? {}}
* renderExpandedRow={(row, rowKey, groupMeta) => (
* <ExpandedDetails groupMeta={groupMeta} />
* )}
* getRowCanExpand={() => true}
* />
* ```
*
* @example Imperative handle — `goToPage` plus Virtuoso methods (e.g. `scrollToIndex`)
* ```tsx
* import type { TanStackTableHandle } from 'components/TanStackTableView';
*
* const ref = useRef<TanStackTableHandle>(null);
*
* <TanStackTable ref={ref} data={data} columns={columns} pagination={{ total, defaultLimit: 20 }} />;
*
* ref.current?.goToPage(2);
* ref.current?.scrollToIndex({ index: 0, align: 'start' });
* ```
*
* @example Scroll container props and testing
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* className="my-table-wrapper"
* testId="logs-table"
* tableScrollerProps={{ className: 'my-table-scroll', 'data-testid': 'logs-scroller' }}
* />
* ```
*/
const TanStackTable = Object.assign(TanStackTableBase, {
Text: TanStackTableText,
});
export default TanStackTable;

View File

@@ -1,191 +0,0 @@
import {
CSSProperties,
Dispatch,
HTMLAttributes,
ReactNode,
SetStateAction,
} from 'react';
import type { TableVirtuosoHandle } from 'react-virtuoso';
import type {
ColumnSizingState,
Row as TanStackRowType,
} from '@tanstack/react-table';
export type SortState = { columnName: string; order: 'asc' | 'desc' };
/** Sets `--tanstack-plain-cell-*` on the scroll root via CSS module classes (no data attributes). */
export type CellTypographySize = 'small' | 'medium' | 'large';
export type TableCellContext<TData, TValue> = {
row: TData;
value: TValue;
isActive: boolean;
rowIndex: number;
isExpanded: boolean;
canExpand: boolean;
toggleExpanded: () => void;
/** Business/selection key for the row */
itemKey: string;
/** Group metadata when row is part of a grouped view */
groupMeta?: Record<string, string>;
};
export type RowKeyData = {
/** Final unique key (with duplicate suffix if needed) */
finalKey: string;
/** Business/selection key */
itemKey: string;
/** Group metadata */
groupMeta?: Record<string, string>;
};
export type TableColumnDef<
TData,
TKey extends keyof TData = any,
TValue = TData[TKey]
> = {
id: string;
header: string | (() => ReactNode);
cell: (context: TableCellContext<TData, TValue>) => ReactNode;
accessorKey?: TKey;
accessorFn?: (row: TData) => TValue;
pin?: 'left' | 'right';
enableMove?: boolean;
enableResize?: boolean;
enableRemove?: boolean;
enableSort?: boolean;
/** Default visibility when no persisted state exists. Default: true */
defaultVisibility?: boolean;
/** Whether user can hide this column. Default: true */
canBeHidden?: boolean;
/**
* Visibility behavior for grouped views:
* - 'hidden-on-expand': Hide when rows are expanded (grouped view)
* - 'hidden-on-collapse': Hide when rows are collapsed (ungrouped view)
* - 'always-visible': Always show regardless of grouping
* Default: 'always-visible'
*/
visibilityBehavior?:
| 'hidden-on-expand'
| 'hidden-on-collapse'
| 'always-visible';
width?: {
fixed?: number | string;
min?: number | string;
default?: number | string;
max?: number | string;
};
};
export type FlatItem<TData> =
| { kind: 'row'; row: TanStackRowType<TData> }
| { kind: 'expansion'; row: TanStackRowType<TData> };
export type TableRowContext<TData> = {
getRowStyle?: (row: TData) => CSSProperties;
getRowClassName?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: string) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: string) => void;
onRowDeactivate?: () => void;
renderExpandedRow?: (
row: TData,
rowKey: string,
groupMeta?: Record<string, string>,
) => ReactNode;
/** Get key data for a row by index */
getRowKeyData?: (index: number) => RowKeyData | undefined;
colCount: number;
isDarkMode?: boolean;
/** When set, primitive cell output (string/number/boolean) is wrapped with typography + line-clamp (see `plainTextCellLineClamp` on the table). */
plainTextCellLineClamp?: number;
/** Whether there's only one non-pinned column that can be removed */
hasSingleColumn: boolean;
/** Column order key for memo invalidation on reorder */
columnOrderKey: string;
/** Column visibility key for memo invalidation on visibility change */
columnVisibilityKey: string;
};
export type PaginationProps = {
total: number;
defaultPage?: number;
defaultLimit?: number;
};
export type TanstackTableQueryParamsConfig = {
page: string;
limit: string;
orderBy: string;
expanded: string;
};
export type TanStackTableProps<TData> = {
data: TData[];
columns: TableColumnDef<TData>[];
/** Storage key for column state persistence (visibility, sizing, ordering). When set, enables unified column management. */
columnStorageKey?: string;
columnSizing?: ColumnSizingState;
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
onColumnOrderChange?: (cols: TableColumnDef<TData>[]) => void;
/** Called when a column is removed via the header menu. Use this to sync with external column preferences. */
onColumnRemove?: (columnId: string) => void;
isLoading?: boolean;
/** Number of skeleton rows to show when loading with no data. Default: 10 */
skeletonRowCount?: number;
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig;
pagination?: PaginationProps;
onEndReached?: (index: number) => void;
/** Function to get the unique key for a row (before duplicate handling).
* When set, enables automatic duplicate key detection and group-aware key composition. */
getRowKey?: (row: TData) => string;
/** Function to get the business/selection key. Defaults to getRowKey result. */
getItemKey?: (row: TData) => string;
/** When set, enables group-aware key generation (prefixes rowKey with group values). */
groupBy?: Array<{ key: string }>;
/** Extract group metadata from a row. Required when groupBy is set. */
getGroupKey?: (row: TData) => Record<string, string>;
getRowStyle?: (row: TData) => CSSProperties;
getRowClassName?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: string) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: string) => void;
onRowDeactivate?: () => void;
activeRowIndex?: number;
renderExpandedRow?: (
row: TData,
rowKey: string,
groupMeta?: Record<string, string>,
) => ReactNode;
getRowCanExpand?: (row: TData) => boolean;
/**
* Primitive cell values use `--tanstack-plain-cell-*` from the scroll container when `cellTypographySize` is set.
*/
plainTextCellLineClamp?: number;
/** Optional CSS-module typography tier for the scroll root (`--tanstack-plain-cell-font-size` / line-height + header `th`). */
cellTypographySize?: CellTypographySize;
/** Spread onto the Virtuoso scroll container. `data` is omitted — reserved by Virtuoso. */
tableScrollerProps?: Omit<HTMLAttributes<HTMLDivElement>, 'data'>;
className?: string;
testId?: string;
/** Content rendered before the pagination controls */
prefixPaginationContent?: ReactNode;
/** Content rendered after the pagination controls */
suffixPaginationContent?: ReactNode;
};
export type TanStackTableHandle = TableVirtuosoHandle & {
goToPage: (page: number) => void;
};
export type TableColumnsState<TData> = {
columns: TableColumnDef<TData>[];
columnSizing: ColumnSizingState;
onColumnSizingChange: Dispatch<SetStateAction<ColumnSizingState>>;
onColumnOrderChange: (cols: TableColumnDef<TData>[]) => void;
onRemoveColumn: (id: string) => void;
};

View File

@@ -1,64 +0,0 @@
import { useCallback, useMemo } from 'react';
import {
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable';
import { TableColumnDef } from './types';
export interface UseColumnDndOptions<TData> {
columns: TableColumnDef<TData>[];
onColumnOrderChange: (columns: TableColumnDef<TData>[]) => void;
}
export interface UseColumnDndResult {
sensors: ReturnType<typeof useSensors>;
columnIds: string[];
handleDragEnd: (event: DragEndEvent) => void;
}
/**
* Sets up drag-and-drop for column reordering.
*/
export function useColumnDnd<TData>({
columns,
onColumnOrderChange,
}: UseColumnDndOptions<TData>): UseColumnDndResult {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
);
const columnIds = useMemo(() => columns.map((c) => c.id), [columns]);
const handleDragEnd = useCallback(
(event: DragEndEvent): void => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const activeCol = columns.find((c) => c.id === String(active.id));
const overCol = columns.find((c) => c.id === String(over.id));
if (
!activeCol ||
!overCol ||
activeCol.pin != null ||
overCol.pin != null ||
activeCol.enableMove === false
) {
return;
}
const oldIndex = columns.findIndex((c) => c.id === String(active.id));
const newIndex = columns.findIndex((c) => c.id === String(over.id));
if (oldIndex === -1 || newIndex === -1) {
return;
}
onColumnOrderChange(arrayMove(columns, oldIndex, newIndex));
},
[columns, onColumnOrderChange],
);
return { sensors, columnIds, handleDragEnd };
}

View File

@@ -1,76 +0,0 @@
import type { SetStateAction } from 'react';
import { useCallback } from 'react';
import type { ColumnSizingState } from '@tanstack/react-table';
import { TableColumnDef } from './types';
export interface UseColumnHandlersOptions<TData> {
/** Storage key for persisting column state (enables store mode) */
columnStorageKey?: string;
effectiveSizing: ColumnSizingState;
storeSetSizing: (sizing: ColumnSizingState) => void;
storeSetOrder: (columns: TableColumnDef<TData>[]) => void;
hideColumn: (columnId: string) => void;
onColumnSizingChange?: (sizing: ColumnSizingState) => void;
onColumnOrderChange?: (columns: TableColumnDef<TData>[]) => void;
onColumnRemove?: (columnId: string) => void;
}
export interface UseColumnHandlersResult<TData> {
handleColumnSizingChange: (updater: SetStateAction<ColumnSizingState>) => void;
handleColumnOrderChange: (columns: TableColumnDef<TData>[]) => void;
handleRemoveColumn: (columnId: string) => void;
}
/**
* Creates handlers for column state changes that delegate to either
* the store (when columnStorageKey is provided) or prop callbacks.
*/
export function useColumnHandlers<TData>({
columnStorageKey,
effectiveSizing,
storeSetSizing,
storeSetOrder,
hideColumn,
onColumnSizingChange,
onColumnOrderChange,
onColumnRemove,
}: UseColumnHandlersOptions<TData>): UseColumnHandlersResult<TData> {
const handleColumnSizingChange = useCallback(
(updater: SetStateAction<ColumnSizingState>) => {
const next =
typeof updater === 'function' ? updater(effectiveSizing) : updater;
if (columnStorageKey) {
storeSetSizing(next);
}
onColumnSizingChange?.(next);
},
[columnStorageKey, effectiveSizing, storeSetSizing, onColumnSizingChange],
);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TData>[]) => {
if (columnStorageKey) {
storeSetOrder(cols);
}
onColumnOrderChange?.(cols);
},
[columnStorageKey, storeSetOrder, onColumnOrderChange],
);
const handleRemoveColumn = useCallback(
(columnId: string) => {
if (columnStorageKey) {
hideColumn(columnId);
}
onColumnRemove?.(columnId);
},
[columnStorageKey, hideColumn, onColumnRemove],
);
return {
handleColumnSizingChange,
handleColumnOrderChange,
handleRemoveColumn,
};
}

View File

@@ -1,226 +0,0 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { ColumnSizingState, VisibilityState } from '@tanstack/react-table';
import { TableColumnDef } from './types';
import {
cleanupStaleHiddenColumns as storeCleanupStaleHiddenColumns,
hideColumn as storeHideColumn,
initializeFromDefaults as storeInitializeFromDefaults,
resetToDefaults as storeResetToDefaults,
setColumnOrder as storeSetColumnOrder,
setColumnSizing as storeSetColumnSizing,
showColumn as storeShowColumn,
toggleColumn as storeToggleColumn,
useColumnOrder as useStoreOrder,
useColumnSizing as useStoreSizing,
useHiddenColumnIds,
} from './useColumnStore';
type UseColumnStateOptions<TData> = {
storageKey?: string;
columns: TableColumnDef<TData>[];
isGrouped?: boolean;
};
type UseColumnStateResult<TData> = {
columnVisibility: VisibilityState;
columnSizing: ColumnSizingState;
/** Columns sorted by persisted order (pinned first) */
sortedColumns: TableColumnDef<TData>[];
hiddenColumnIds: string[];
hideColumn: (columnId: string) => void;
showColumn: (columnId: string) => void;
toggleColumn: (columnId: string) => void;
setColumnSizing: (sizing: ColumnSizingState) => void;
setColumnOrder: (columns: TableColumnDef<TData>[]) => void;
resetToDefaults: () => void;
};
export function useColumnState<TData>({
storageKey,
columns,
isGrouped = false,
}: UseColumnStateOptions<TData>): UseColumnStateResult<TData> {
useEffect(() => {
if (storageKey) {
storeInitializeFromDefaults(storageKey, columns);
}
// Only run on mount, not when columns change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storageKey]);
const rawHiddenColumnIds = useHiddenColumnIds(storageKey ?? '');
useEffect(
function cleanupHiddenColumnIdsNoLongerInDefinitions(): void {
if (!storageKey) {
return;
}
const validColumnIds = new Set(columns.map((c) => c.id));
storeCleanupStaleHiddenColumns(storageKey, validColumnIds);
},
[storageKey, columns],
);
const columnSizing = useStoreSizing(storageKey ?? '');
const prevColumnIdsRef = useRef<Set<string> | null>(null);
useEffect(
function autoShowNewlyAddedColumns(): void {
if (!storageKey) {
return;
}
const currentIds = new Set(columns.map((c) => c.id));
// Skip first render - just record the initial columns
if (prevColumnIdsRef.current === null) {
prevColumnIdsRef.current = currentIds;
return;
}
const prevIds = prevColumnIdsRef.current;
// Find columns that are new (in current but not in previous)
for (const id of currentIds) {
if (!prevIds.has(id) && rawHiddenColumnIds.includes(id)) {
// Column was just added and is hidden - show it
storeShowColumn(storageKey, id);
}
}
prevColumnIdsRef.current = currentIds;
},
[storageKey, columns, rawHiddenColumnIds],
);
const columnOrder = useStoreOrder(storageKey ?? '');
const columnMap = useMemo(() => new Map(columns.map((c) => [c.id, c])), [
columns,
]);
const hiddenColumnIds = useMemo(
() =>
rawHiddenColumnIds.filter((id) => {
const col = columnMap.get(id);
return col && col.canBeHidden !== false;
}),
[rawHiddenColumnIds, columnMap],
);
const columnVisibility = useMemo((): VisibilityState => {
const visibility: VisibilityState = {};
for (const id of hiddenColumnIds) {
visibility[id] = false;
}
for (const column of columns) {
if (column.visibilityBehavior === 'hidden-on-expand' && isGrouped) {
visibility[column.id] = false;
}
if (column.visibilityBehavior === 'hidden-on-collapse' && !isGrouped) {
visibility[column.id] = false;
}
}
return visibility;
}, [hiddenColumnIds, columns, isGrouped]);
const sortedColumns = useMemo((): TableColumnDef<TData>[] => {
if (columnOrder.length === 0) {
return columns;
}
const orderMap = new Map(columnOrder.map((id, i) => [id, i]));
const pinned = columns.filter((c) => c.pin != null);
const rest = columns.filter((c) => c.pin == null);
const sortedRest = [...rest].sort((a, b) => {
const ai = orderMap.get(a.id) ?? Infinity;
const bi = orderMap.get(b.id) ?? Infinity;
return ai - bi;
});
return [...pinned, ...sortedRest];
}, [columns, columnOrder]);
const hideColumn = useCallback(
(columnId: string) => {
if (!storageKey) {
return;
}
// Prevent hiding columns with canBeHidden: false
const col = columnMap.get(columnId);
if (col && col.canBeHidden === false) {
return;
}
storeHideColumn(storageKey, columnId);
},
[storageKey, columnMap],
);
const showColumn = useCallback(
(columnId: string) => {
if (storageKey) {
storeShowColumn(storageKey, columnId);
}
},
[storageKey],
);
const toggleColumn = useCallback(
(columnId: string) => {
if (!storageKey) {
return;
}
const col = columnMap.get(columnId);
const isCurrentlyHidden = hiddenColumnIds.includes(columnId);
if (col && col.canBeHidden === false && !isCurrentlyHidden) {
return;
}
storeToggleColumn(storageKey, columnId);
},
[storageKey, columnMap, hiddenColumnIds],
);
const setColumnSizing = useCallback(
(sizing: ColumnSizingState) => {
if (storageKey) {
storeSetColumnSizing(storageKey, sizing);
}
},
[storageKey],
);
const setColumnOrder = useCallback(
(cols: TableColumnDef<TData>[]) => {
if (storageKey) {
storeSetColumnOrder(
storageKey,
cols.map((c) => c.id),
);
}
},
[storageKey],
);
const resetToDefaults = useCallback(() => {
if (storageKey) {
storeResetToDefaults(storageKey, columns);
}
}, [storageKey, columns]);
return {
columnVisibility,
columnSizing,
sortedColumns,
hiddenColumnIds,
hideColumn,
showColumn,
toggleColumn,
setColumnSizing,
setColumnOrder,
resetToDefaults,
};
}

View File

@@ -1,328 +0,0 @@
import { ColumnSizingState } from '@tanstack/react-table';
import { create } from 'zustand';
import { TableColumnDef } from './types';
const STORAGE_PREFIX = '@signoz/table-columns/';
const persistedTableCache = new Map<
string,
{ raw: string; parsed: ColumnState }
>();
type ColumnState = {
hiddenColumnIds: string[];
columnOrder: string[];
columnSizing: ColumnSizingState;
};
const EMPTY_STATE: ColumnState = {
hiddenColumnIds: [],
columnOrder: [],
columnSizing: {},
};
type ColumnStoreState = {
tables: Record<string, ColumnState>;
hideColumn: (storageKey: string, columnId: string) => void;
showColumn: (storageKey: string, columnId: string) => void;
toggleColumn: (storageKey: string, columnId: string) => void;
setColumnSizing: (storageKey: string, sizing: ColumnSizingState) => void;
setColumnOrder: (storageKey: string, order: string[]) => void;
initializeFromDefaults: <TData>(
storageKey: string,
columns: TableColumnDef<TData>[],
) => void;
resetToDefaults: <TData>(
storageKey: string,
columns: TableColumnDef<TData>[],
) => void;
cleanupStaleHiddenColumns: (
storageKey: string,
validColumnIds: Set<string>,
) => void;
};
const getDefaultHiddenIds = <TData>(
columns: TableColumnDef<TData>[],
): string[] =>
columns.filter((c) => c.defaultVisibility === false).map((c) => c.id);
const getStorageKeyForTable = (tableKey: string): string =>
`${STORAGE_PREFIX}${tableKey}`;
const loadTableFromStorage = (tableKey: string): ColumnState | null => {
try {
const raw = localStorage.getItem(getStorageKeyForTable(tableKey));
if (!raw) {
persistedTableCache.delete(tableKey);
return null;
}
const cached = persistedTableCache.get(tableKey);
if (cached && cached.raw === raw) {
return cached.parsed;
}
const parsed = JSON.parse(raw) as ColumnState;
persistedTableCache.set(tableKey, { raw, parsed });
return parsed;
} catch {
persistedTableCache.delete(tableKey);
return null;
}
};
const saveTableToStorage = (tableKey: string, state: ColumnState): void => {
try {
const raw = JSON.stringify(state);
localStorage.setItem(getStorageKeyForTable(tableKey), raw);
persistedTableCache.set(tableKey, { raw, parsed: state });
} catch {
// Ignore storage errors (e.g., private browsing quota exceeded)
}
};
export const useColumnStore = create<ColumnStoreState>()((set, get) => {
return {
tables: {},
hideColumn: (storageKey, columnId): void => {
const state = get();
let table = state.tables[storageKey];
// Lazy load from storage if not in memory
if (!table) {
const persisted = loadTableFromStorage(storageKey);
if (persisted) {
table = persisted;
set({ tables: { ...state.tables, [storageKey]: table } });
} else {
return;
}
}
if (table.hiddenColumnIds.includes(columnId)) {
return;
}
const nextTable = {
...table,
hiddenColumnIds: [...table.hiddenColumnIds, columnId],
};
set({ tables: { ...get().tables, [storageKey]: nextTable } });
saveTableToStorage(storageKey, nextTable);
},
showColumn: (storageKey, columnId): void => {
const state = get();
let table = state.tables[storageKey];
if (!table) {
const persisted = loadTableFromStorage(storageKey);
if (persisted) {
table = persisted;
set({ tables: { ...state.tables, [storageKey]: table } });
} else {
return;
}
}
if (!table.hiddenColumnIds.includes(columnId)) {
return;
}
const nextTable = {
...table,
hiddenColumnIds: table.hiddenColumnIds.filter((id) => id !== columnId),
};
set({ tables: { ...get().tables, [storageKey]: nextTable } });
saveTableToStorage(storageKey, nextTable);
},
toggleColumn: (storageKey, columnId): void => {
const state = get();
let table = state.tables[storageKey];
if (!table) {
const persisted = loadTableFromStorage(storageKey);
if (persisted) {
table = persisted;
set({ tables: { ...state.tables, [storageKey]: table } });
}
}
if (!table) {
return;
}
const isHidden = table.hiddenColumnIds.includes(columnId);
if (isHidden) {
get().showColumn(storageKey, columnId);
} else {
get().hideColumn(storageKey, columnId);
}
},
setColumnSizing: (storageKey, sizing): void => {
const state = get();
let table = state.tables[storageKey];
if (!table) {
const persisted = loadTableFromStorage(storageKey);
table = persisted ?? { ...EMPTY_STATE };
}
const nextTable = {
...table,
columnSizing: sizing,
};
set({ tables: { ...get().tables, [storageKey]: nextTable } });
saveTableToStorage(storageKey, nextTable);
},
setColumnOrder: (storageKey, order): void => {
const state = get();
let table = state.tables[storageKey];
if (!table) {
const persisted = loadTableFromStorage(storageKey);
table = persisted ?? { ...EMPTY_STATE };
}
const nextTable = {
...table,
columnOrder: order,
};
set({ tables: { ...get().tables, [storageKey]: nextTable } });
saveTableToStorage(storageKey, nextTable);
},
initializeFromDefaults: (storageKey, columns): void => {
const state = get();
if (state.tables[storageKey]) {
return;
}
const persisted = loadTableFromStorage(storageKey);
if (persisted) {
set({ tables: { ...state.tables, [storageKey]: persisted } });
return;
}
const newTable: ColumnState = {
hiddenColumnIds: getDefaultHiddenIds(columns),
columnOrder: [],
columnSizing: {},
};
set({ tables: { ...state.tables, [storageKey]: newTable } });
saveTableToStorage(storageKey, newTable);
},
resetToDefaults: (storageKey, columns): void => {
const newTable: ColumnState = {
hiddenColumnIds: getDefaultHiddenIds(columns),
columnOrder: [],
columnSizing: {},
};
set({ tables: { ...get().tables, [storageKey]: newTable } });
saveTableToStorage(storageKey, newTable);
},
cleanupStaleHiddenColumns: (storageKey, validColumnIds): void => {
const state = get();
let table = state.tables[storageKey];
if (!table) {
const persisted = loadTableFromStorage(storageKey);
if (!persisted) {
return;
}
table = persisted;
}
const filteredHiddenIds = table.hiddenColumnIds.filter((id) =>
validColumnIds.has(id),
);
// Only update if something changed
if (filteredHiddenIds.length === table.hiddenColumnIds.length) {
return;
}
const nextTable = {
...table,
hiddenColumnIds: filteredHiddenIds,
};
set({ tables: { ...get().tables, [storageKey]: nextTable } });
saveTableToStorage(storageKey, nextTable);
},
};
});
// Stable empty references to avoid `Object.is` false-negatives when a key
// does not exist yet (returning a new `[]` / `{}` on every selector call
// would trigger React's useSyncExternalStore tearing detection).
const EMPTY_ARRAY: string[] = [];
const EMPTY_SIZING: ColumnSizingState = {};
export const useHiddenColumnIds = (storageKey: string): string[] =>
useColumnStore((s) => {
const table = s.tables[storageKey];
if (table) {
return table.hiddenColumnIds;
}
const persisted = loadTableFromStorage(storageKey);
return persisted?.hiddenColumnIds ?? EMPTY_ARRAY;
});
export const useColumnSizing = (storageKey: string): ColumnSizingState =>
useColumnStore((s) => {
const table = s.tables[storageKey];
if (table) {
return table.columnSizing;
}
const persisted = loadTableFromStorage(storageKey);
return persisted?.columnSizing ?? EMPTY_SIZING;
});
export const useColumnOrder = (storageKey: string): string[] =>
useColumnStore((s) => {
const table = s.tables[storageKey];
if (table) {
return table.columnOrder;
}
const persisted = loadTableFromStorage(storageKey);
return persisted?.columnOrder ?? EMPTY_ARRAY;
});
export const initializeFromDefaults = <TData>(
storageKey: string,
columns: TableColumnDef<TData>[],
): void =>
useColumnStore.getState().initializeFromDefaults(storageKey, columns);
export const hideColumn = (storageKey: string, columnId: string): void =>
useColumnStore.getState().hideColumn(storageKey, columnId);
export const showColumn = (storageKey: string, columnId: string): void =>
useColumnStore.getState().showColumn(storageKey, columnId);
export const toggleColumn = (storageKey: string, columnId: string): void =>
useColumnStore.getState().toggleColumn(storageKey, columnId);
export const setColumnSizing = (
storageKey: string,
sizing: ColumnSizingState,
): void => useColumnStore.getState().setColumnSizing(storageKey, sizing);
export const setColumnOrder = (storageKey: string, order: string[]): void =>
useColumnStore.getState().setColumnOrder(storageKey, order);
export const resetToDefaults = <TData>(
storageKey: string,
columns: TableColumnDef<TData>[],
): void => useColumnStore.getState().resetToDefaults(storageKey, columns);
export const cleanupStaleHiddenColumns = (
storageKey: string,
validColumnIds: Set<string>,
): void =>
useColumnStore
.getState()
.cleanupStaleHiddenColumns(storageKey, validColumnIds);

View File

@@ -1,43 +0,0 @@
import { useMemo, useRef } from 'react';
export interface UseEffectiveDataOptions {
data: unknown[];
isLoading: boolean;
limit?: number;
skeletonRowCount?: number;
}
/**
* Manages effective data for the table, handling loading states gracefully.
*/
export function useEffectiveData<TData>({
data,
isLoading,
limit,
skeletonRowCount = 10,
}: UseEffectiveDataOptions): TData[] {
const prevDataRef = useRef<TData[]>(data as TData[]);
const prevDataSizeRef = useRef(data.length || limit || skeletonRowCount);
// Update refs when we have real data (not loading)
if (!isLoading && data.length > 0) {
prevDataRef.current = data as TData[];
prevDataSizeRef.current = data.length;
}
return useMemo((): TData[] => {
if (data.length > 0) {
return data as TData[];
}
if (prevDataRef.current.length > 0) {
return prevDataRef.current;
}
if (isLoading) {
const fakeCount = prevDataSizeRef.current || limit || skeletonRowCount;
return Array.from({ length: fakeCount }, (_, i) => ({
id: `skeleton-${i}`,
})) as TData[];
}
return data as TData[];
}, [isLoading, data, limit, skeletonRowCount]);
}

View File

@@ -1,60 +0,0 @@
import { useMemo } from 'react';
import type { ExpandedState, Row } from '@tanstack/react-table';
import { FlatItem } from './types';
export interface UseFlatItemsOptions<TData> {
tableRows: Row<TData>[];
/** Whether row expansion is enabled, needs to be unknown since it will be a function that can be updated/modified, boolean does not work well here */
renderExpandedRow?: unknown;
expanded: ExpandedState;
/** Index of the active row (for scroll-to behavior) */
activeRowIndex?: number;
}
export interface UseFlatItemsResult<TData> {
flatItems: FlatItem<TData>[];
/** Index of active row in flatItems (-1 if not found) */
flatIndexForActiveRow: number;
}
/**
* Flattens table rows with their expansion rows into a single list.
*
* When a row is expanded, an expansion item is inserted immediately after it.
* Also computes the flat index for the active row (used for scroll-to).
*/
export function useFlatItems<TData>({
tableRows,
renderExpandedRow,
expanded,
activeRowIndex,
}: UseFlatItemsOptions<TData>): UseFlatItemsResult<TData> {
const flatItems = useMemo<FlatItem<TData>[]>(() => {
const result: FlatItem<TData>[] = [];
for (const row of tableRows) {
result.push({ kind: 'row', row });
if (renderExpandedRow && row.getIsExpanded()) {
result.push({ kind: 'expansion', row });
}
}
return result;
// expanded needs to be here, otherwise the rows are not updated when you click to expand
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableRows, renderExpandedRow, expanded]);
const flatIndexForActiveRow = useMemo(() => {
if (activeRowIndex == null || activeRowIndex < 0) {
return -1;
}
for (let i = 0; i < flatItems.length; i++) {
const item = flatItems[i];
if (item.kind === 'row' && item.row.index === activeRowIndex) {
return i;
}
}
return -1;
}, [activeRowIndex, flatItems]);
return { flatItems, flatIndexForActiveRow };
}

View File

@@ -1,79 +0,0 @@
import { useCallback, useMemo } from 'react';
export interface RowKeyDataItem {
/** Final unique key for the row (with dedup suffix if needed) */
finalKey: string;
/** Item key for tracking (may differ from finalKey) */
itemKey: string;
/** Group metadata when grouped */
groupMeta: Record<string, string> | undefined;
}
export interface UseRowKeyDataOptions<TData> {
data: TData[];
isLoading: boolean;
getRowKey?: (item: TData) => string;
getItemKey?: (item: TData) => string;
groupBy?: Array<{ key: string }>;
getGroupKey?: (item: TData) => Record<string, string>;
}
export interface UseRowKeyDataResult {
/** Array of key data for each row, undefined if getRowKey not provided or loading */
rowKeyData: RowKeyDataItem[] | undefined;
getRowKeyData: (index: number) => RowKeyDataItem | undefined;
}
/**
* Computes unique row keys with duplicate handling and group prefixes.
*/
export function useRowKeyData<TData>({
data,
isLoading,
getRowKey,
getItemKey,
groupBy,
getGroupKey,
}: UseRowKeyDataOptions<TData>): UseRowKeyDataResult {
// eslint-disable-next-line sonarjs/cognitive-complexity
const rowKeyData = useMemo((): RowKeyDataItem[] | undefined => {
if (!getRowKey || isLoading) {
return undefined;
}
const keyCount = new Map<string, number>();
return data.map(
(item, index): RowKeyDataItem => {
const itemIdentifier = getRowKey(item);
const itemKey = getItemKey?.(item) ?? itemIdentifier;
const groupMeta = groupBy?.length ? getGroupKey?.(item) : undefined;
// Build rowKey with group prefix when grouped
let rowKey: string;
if (groupBy?.length && groupMeta) {
const groupKeyStr = Object.values(groupMeta).join('-');
if (groupKeyStr && itemIdentifier) {
rowKey = `${groupKeyStr}-${itemIdentifier}`;
} else {
rowKey = groupKeyStr || itemIdentifier || String(index);
}
} else {
rowKey = itemIdentifier || String(index);
}
const count = keyCount.get(rowKey) || 0;
keyCount.set(rowKey, count + 1);
const finalKey = count > 0 ? `${rowKey}-${count}` : rowKey;
return { finalKey, itemKey, groupMeta };
},
);
}, [data, getRowKey, getItemKey, groupBy, getGroupKey, isLoading]);
const getRowKeyData = useCallback((index: number) => rowKeyData?.[index], [
rowKeyData,
]);
return { rowKeyData, getRowKeyData };
}

View File

@@ -1,194 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ExpandedState, Updater } from '@tanstack/react-table';
import { parseAsInteger, useQueryState } from 'nuqs';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { SortState, TanstackTableQueryParamsConfig } from './types';
const NUQS_OPTIONS = { history: 'push' as const };
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 50;
type Defaults = {
page?: number;
limit?: number;
orderBy?: SortState | null;
expanded?: ExpandedState;
};
type TableParamsResult = {
page: number;
limit: number;
orderBy: SortState | null;
expanded: ExpandedState;
setPage: (p: number) => void;
setLimit: (l: number) => void;
setOrderBy: (s: SortState | null) => void;
setExpanded: (updaterOrValue: Updater<ExpandedState>) => void;
};
function expandedStateToArray(state: ExpandedState): string[] {
if (typeof state === 'boolean') {
return [];
}
return Object.entries(state)
.filter(([, v]) => v)
.map(([k]) => k);
}
function arrayToExpandedState(arr: string[]): ExpandedState {
const result: Record<string, boolean> = {};
for (const id of arr) {
result[id] = true;
}
return result;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export function useTableParams(
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig,
defaults?: Defaults,
): TableParamsResult {
const pageQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_page`
: typeof enableQueryParams === 'object'
? enableQueryParams.page
: 'page';
const limitQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_limit`
: typeof enableQueryParams === 'object'
? enableQueryParams.limit
: 'limit';
const orderByQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_order_by`
: typeof enableQueryParams === 'object'
? enableQueryParams.orderBy
: 'order_by';
const expandedQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_expanded`
: typeof enableQueryParams === 'object'
? enableQueryParams.expanded
: 'expanded';
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
const orderByDefault = defaults?.orderBy ?? null;
const expandedDefault = defaults?.expanded ?? {};
const expandedDefaultArray = useMemo(
() => expandedStateToArray(expandedDefault),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const [localPage, setLocalPage] = useState(pageDefault);
const [localLimit, setLocalLimit] = useState(limitDefault);
const [localOrderBy, setLocalOrderBy] = useState<SortState | null>(
orderByDefault,
);
const [localExpanded, setLocalExpanded] = useState<ExpandedState>(
expandedDefault,
);
const [urlPage, setUrlPage] = useQueryState(
pageQueryParam,
parseAsInteger.withDefault(pageDefault).withOptions(NUQS_OPTIONS),
);
const [urlLimit, setUrlLimit] = useQueryState(
limitQueryParam,
parseAsInteger.withDefault(limitDefault).withOptions(NUQS_OPTIONS),
);
const [urlOrderBy, setUrlOrderBy] = useQueryState(
orderByQueryParam,
parseAsJsonNoValidate<SortState | null>()
.withDefault(orderByDefault as never)
.withOptions(NUQS_OPTIONS),
);
const [urlExpandedArray, setUrlExpandedArray] = useQueryState(
expandedQueryParam,
parseAsJsonNoValidate<string[]>()
.withDefault(expandedDefaultArray as never)
.withOptions(NUQS_OPTIONS),
);
// Convert URL array to ExpandedState
const urlExpanded = useMemo(
() => arrayToExpandedState(urlExpandedArray ?? []),
[urlExpandedArray],
);
// Keep ref for updater function access
const urlExpandedRef = useRef(urlExpanded);
urlExpandedRef.current = urlExpanded;
// Wrapper to convert ExpandedState to array when setting URL state
// Supports both direct values and updater functions (TanStack pattern)
const setUrlExpanded = useCallback(
(updaterOrValue: Updater<ExpandedState>): void => {
const newState =
typeof updaterOrValue === 'function'
? updaterOrValue(urlExpandedRef.current)
: updaterOrValue;
setUrlExpandedArray(expandedStateToArray(newState));
},
[setUrlExpandedArray],
);
// Wrapper for local expanded to match TanStack's Updater pattern
const handleSetLocalExpanded = useCallback(
(updaterOrValue: Updater<ExpandedState>): void => {
setLocalExpanded((prev) =>
typeof updaterOrValue === 'function'
? updaterOrValue(prev)
: updaterOrValue,
);
},
[],
);
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
const isEnabledQueryParams =
typeof enableQueryParams === 'string' ||
typeof enableQueryParams === 'object';
useEffect(() => {
if (isEnabledQueryParams) {
setUrlPage(pageDefault);
} else {
setLocalPage(pageDefault);
}
}, [
isEnabledQueryParams,
orderByDefaultMemoKey,
orderByUrlMemoKey,
pageDefault,
setUrlPage,
]);
if (enableQueryParams) {
return {
page: urlPage,
limit: urlLimit,
orderBy: urlOrderBy as SortState | null,
expanded: urlExpanded,
setPage: setUrlPage,
setLimit: setUrlLimit,
setOrderBy: setUrlOrderBy,
setExpanded: setUrlExpanded,
};
}
return {
page: localPage,
limit: localLimit,
orderBy: localOrderBy,
expanded: localExpanded,
setPage: setLocalPage,
setLimit: setLocalLimit,
setOrderBy: setLocalOrderBy,
setExpanded: handleSetLocalExpanded,
};
}

View File

@@ -1,94 +0,0 @@
import type { CSSProperties, ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { RowKeyData, TableColumnDef } from './types';
export const getColumnId = <TData>(column: TableColumnDef<TData>): string =>
column.id;
const DEFAULT_MIN_WIDTH = 192; // 12rem * 16px
export const getColumnWidthStyle = <TData>(
column: TableColumnDef<TData>,
/** Persisted width from user resizing (overrides defined width) */
persistedWidth?: number,
/** Last column always gets width: 100% and ignores other width settings */
isLastColumn?: boolean,
): CSSProperties => {
// Last column always fills remaining space
if (isLastColumn) {
return { width: '100%' };
}
const { width } = column;
if (!width) {
return {
width: persistedWidth ?? DEFAULT_MIN_WIDTH,
minWidth: DEFAULT_MIN_WIDTH,
};
}
if (width.fixed != null) {
return {
width: width.fixed,
minWidth: width.fixed,
maxWidth: width.fixed,
};
}
return {
width: persistedWidth ?? width.default ?? width.min,
minWidth: width.min ?? DEFAULT_MIN_WIDTH,
maxWidth: width.max,
};
};
const buildAccessorFn = <TData>(
colDef: TableColumnDef<TData>,
): ((row: TData) => unknown) => {
return (row: TData): unknown => {
if (colDef.accessorFn) {
return colDef.accessorFn(row);
}
if (colDef.accessorKey) {
return (row as Record<string, unknown>)[colDef.accessorKey];
}
return undefined;
};
};
export function buildTanstackColumnDef<TData>(
colDef: TableColumnDef<TData>,
isRowActive?: (row: TData) => boolean,
getRowKeyData?: (index: number) => RowKeyData | undefined,
): ColumnDef<TData> {
const isFixed = colDef.width?.fixed != null;
const headerFn =
typeof colDef.header === 'function' ? colDef.header : undefined;
return {
id: colDef.id,
header:
typeof colDef.header === 'string'
? colDef.header
: (): ReactNode => headerFn?.() ?? null,
accessorFn: buildAccessorFn(colDef),
enableResizing: colDef.enableResize !== false && !isFixed,
enableSorting: colDef.enableSort === true,
cell: ({ row, getValue }): ReactNode => {
const rowData = row.original;
const keyData = getRowKeyData?.(row.index);
return colDef.cell({
row: rowData,
value: getValue() as TData[any],
isActive: isRowActive?.(rowData) ?? false,
rowIndex: row.index,
isExpanded: row.getIsExpanded(),
canExpand: row.getCanExpand(),
toggleExpanded: (): void => {
row.toggleExpanded();
},
itemKey: keyData?.itemKey ?? '',
groupMeta: keyData?.groupMeta,
});
},
};
}

View File

@@ -7,14 +7,6 @@
[data-slot='dialog-content'] {
position: fixed;
z-index: 60;
background: var(--l1-background);
color: var(--l1-foreground);
/* Override the background and color of the dialog content from the theme */
> div {
background: var(--l1-background);
color: var(--l1-foreground);
}
}
.cmdk-section-heading [cmdk-group-heading] {
@@ -51,22 +43,6 @@
.cmdk-item {
cursor: pointer;
color: var(--l1-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px;
&:hover {
background: var(--l1-background-hover);
}
&[data-selected='true'] {
background: var(--l3-background);
color: var(--l1-foreground);
}
}
[cmdk-item] svg {

View File

@@ -65,7 +65,6 @@ const ROUTES = {
WORKSPACE_SUSPENDED: '/workspace-suspended',
SHORTCUTS: '/settings/shortcuts',
INTEGRATIONS: '/integrations',
INTEGRATIONS_DETAIL: '/integrations/:integrationId',
MESSAGING_QUEUES_BASE: '/messaging-queues',
MESSAGING_QUEUES_KAFKA: '/messaging-queues/kafka',
MESSAGING_QUEUES_KAFKA_DETAIL: '/messaging-queues/kafka/detail',

View File

@@ -9,6 +9,7 @@ import {
} from 'container/ApiMonitoring/utils';
import { UnfoldVertical } from 'lucide-react';
import { SuccessResponse } from 'types/api';
import { openInNewTab } from 'utils/navigation';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
@@ -94,20 +95,14 @@ function DependentServices({
}}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
const url = new URL(
`/services/${
record.serviceData.serviceName &&
record.serviceData.serviceName !== '-'
? record.serviceData.serviceName
: ''
}`,
window.location.origin,
);
const serviceName =
record.serviceData.serviceName && record.serviceData.serviceName !== '-'
? record.serviceData.serviceName
: '';
const urlQuery = new URLSearchParams();
urlQuery.set(QueryParams.startTime, timeRange.startTime.toString());
urlQuery.set(QueryParams.endTime, timeRange.endTime.toString());
url.search = urlQuery.toString();
window.open(url.toString(), '_blank');
openInNewTab(`/services/${serviceName}?${urlQuery.toString()}`);
},
className: 'clickable-row',
})}

View File

@@ -0,0 +1,24 @@
import {
IntegrationType,
RequestIntegrationBtn,
} from 'pages/Integrations/RequestIntegrationBtn';
import Header from './Header/Header';
import HeroSection from './HeroSection/HeroSection';
import ServicesTabs from './ServicesSection/ServicesTabs';
function CloudIntegrationPage(): JSX.Element {
return (
<div>
<Header />
<HeroSection />
<RequestIntegrationBtn
type={IntegrationType.AWS_SERVICES}
message="Can't find the AWS service you're looking for? Request more integrations"
/>
<ServicesTabs />
</div>
);
}
export default CloudIntegrationPage;

View File

@@ -3,7 +3,7 @@
justify-content: space-between;
align-items: center;
padding: 8px 18px;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid var(--l1-border);
&__navigation {
display: flex;
@@ -18,7 +18,7 @@
}
&__breadcrumb-title {
color: var(--l1-foreground);
color: var(--l2-foreground);
font-size: 14px;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
@@ -30,8 +30,8 @@
justify-content: center;
padding: 6px;
gap: 6px;
border: 1px solid var(--border);
background: var(--card);
border: 1px solid var(--l1-border);
background: var(--l3-background);
border-radius: 2px;
font-size: 12px;
line-height: 10px;
@@ -39,11 +39,9 @@
width: 113px;
height: 32px;
cursor: pointer;
color: var(--l1-foreground);
&,
&:hover {
border-color: var(--l2-border);
color: var(--l1-foreground);
color: var(--l2-foreground);
}
}
}

View File

@@ -1,13 +1,11 @@
import { Link } from 'react-router-dom';
import { Button } from '@signozhq/button';
import Breadcrumb from 'antd/es/breadcrumb';
import { Breadcrumb } from 'antd';
import ROUTES from 'constants/routes';
import { IntegrationType } from 'container/Integrations/types';
import { Blocks, LifeBuoy } from 'lucide-react';
import './Header.styles.scss';
function Header({ title }: { title: IntegrationType }): JSX.Element {
function Header(): JSX.Element {
return (
<div className="cloud-header">
<div className="cloud-header__navigation">
@@ -18,33 +16,32 @@ function Header({ title }: { title: IntegrationType }): JSX.Element {
title: (
<Link to={ROUTES.INTEGRATIONS}>
<span className="cloud-header__breadcrumb-link">
<Blocks size={16} color="var(--l2-foreground)" />
<Blocks size={16} color="var(--bg-vanilla-400)" />
<span className="cloud-header__breadcrumb-title">Integrations</span>
</span>
</Link>
),
},
{
title: <div className="cloud-header__breadcrumb-title">{title}</div>,
title: (
<div className="cloud-header__breadcrumb-title">
Amazon Web Services
</div>
),
},
]}
/>
</div>
<div className="cloud-header__actions">
<Button
variant="solid"
size="sm"
color="secondary"
onClick={(): void => {
window.open(
'https://signoz.io/blog/native-aws-integrations-with-autodiscovery/',
'_blank',
);
}}
prefixIcon={<LifeBuoy size={12} />}
<a
href="https://signoz.io/blog/native-aws-integrations-with-autodiscovery/"
target="_blank"
rel="noopener noreferrer"
className="cloud-header__help"
>
<LifeBuoy size={12} />
Get Help
</Button>
</a>
</div>
</div>
);

View File

@@ -1,10 +1,8 @@
.hero-section {
padding: 16px;
height: 308px;
padding: 26px 16px;
display: flex;
flex-direction: column;
gap: 16px;
gap: 24px;
position: relative;
overflow: hidden;
background-position: right;
@@ -32,36 +30,7 @@
flex-direction: column;
gap: 12px;
&-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
&__icon {
height: fit-content;
background-color: var(--l1-background);
padding: 12px;
border: 1px solid var(--l2-background);
border-radius: 6px;
width: 60px;
height: 60px;
}
&__title {
color: var(--l1-foreground);
font-size: 24px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.12px;
}
}
&__description {
color: var(--l2-foreground);
}
&-title {
.title {
color: var(--l1-foreground);
font-size: 24px;
font-weight: 500;
@@ -69,7 +38,7 @@
letter-spacing: -0.12px;
}
&-description {
.description {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;

View File

@@ -0,0 +1,37 @@
import { useIsDarkMode } from 'hooks/useDarkMode';
import integrationsHeroBgUrl from '@/assets/Images/integrations-hero-bg.png';
import awsDarkUrl from '@/assets/Logos/aws-dark.svg';
import AccountActions from './components/AccountActions';
import './HeroSection.style.scss';
function HeroSection(): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<div
className="hero-section"
style={
isDarkMode
? {
backgroundImage: `url('${integrationsHeroBgUrl}')`,
}
: {}
}
>
<div className="hero-section__icon">
<img src={awsDarkUrl} alt="aws-logo" />
</div>
<div className="hero-section__details">
<div className="title">Amazon Web Services</div>
<div className="description">
One-click setup for AWS monitoring with SigNoz
</div>
<AccountActions />
</div>
</div>
);
}
export default HeroSection;

View File

@@ -4,57 +4,14 @@
&-with-account {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
border-radius: 4px;
border: 1px solid var(--l3-background);
background: var(--l1-background);
.selected-cloud-integration-account-status {
display: flex;
align-items: center;
justify-content: center;
border-right: 1px solid var(--l3-background);
border-radius: none;
width: 32px;
}
&-selector-container {
display: flex;
flex-direction: row;
align-items: center;
.account-selector-label {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;
line-height: 16px;
padding: 8px 16px;
}
.account-selector {
.ant-select {
background-color: transparent !important;
border: none !important;
box-shadow: none !important;
.ant-select-selector {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
}
}
}
flex-direction: column;
gap: 10px;
}
}
&__input-skeleton {
width: 300px;
margin-bottom: 16px;
}
&__new-account-button-skeleton {
@@ -65,13 +22,11 @@
&__account-settings-button-skeleton {
width: 140px;
}
&__action-buttons {
display: flex;
align-items: center;
gap: 8px;
}
&__action-button {
font-family: 'Inter';
border-radius: 2px;
@@ -90,16 +45,11 @@
&.secondary {
display: flex;
align-items: center;
border: 1px solid var(--l1-border);
border-radius: 2px;
background: var(--l1-background);
box-shadow: none;
border: 1px solid var(--l3-background);
color: var(--l1-foreground);
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
border-radius: 2px;
background: var(--l1-border);
box-shadow: none;
}
}
}
@@ -107,27 +57,25 @@
.cloud-account-selector {
border-radius: 2px;
border: 1px solid var(--l3-background);
background: var(--l1-background);
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
.ant-select-selector {
border-color: var(--l1-border) !important;
background: var(--l1-background) !important;
background: var(--l3-background) !important;
padding: 6px 8px !important;
}
.ant-select-item-option-active {
background: var(--l3-background) !important;
}
.ant-select-selection-item {
color: var(--l1-foreground);
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;
line-height: 16px;
}
&:hover {
.ant-select-selector {
border-color: var(--l1-border) !important;
}
}
.account-option-item {
display: flex;
@@ -139,8 +87,60 @@
justify-content: center;
height: 14px;
width: 14px;
background-color: color-mix(in srgb, var(--border) 20%, transparent);
background-color: color-mix(
in srgb,
var(--border) 20%,
transparent
); /* #C0C1C3 with 0.2 opacity */
border-radius: 2px;
}
}
}
.lightMode {
.hero-section__action-button {
&.primary {
background: var(--primary-background);
color: var(--primary-foreground);
}
&.secondary {
border-color: var(--l1-border);
color: var(--l1-foreground);
background: var(--l1-background);
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
}
}
.cloud-account-selector {
background: var(--l1-background);
.ant-select-selector {
background: var(--l1-background) !important;
border-color: var(--l1-border) !important;
}
.ant-select-item-option-active {
background: var(--l3-background) !important;
}
.ant-select-selection-item {
color: var(--l1-foreground);
}
&:hover {
.ant-select-selector {
border-color: var(--l1-border) !important;
}
}
}
.account-option-item {
color: var(--l1-foreground);
&__selected {
background: var(--primary-background);
}
}
}

View File

@@ -1,23 +1,58 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import { Button } from '@signozhq/button';
import { Color } from '@signozhq/design-tokens';
import { Select, Skeleton } from 'antd';
import { SelectProps } from 'antd/lib';
import { Button, Select, Skeleton } from 'antd';
import type { SelectProps } from 'antd/lib';
import logEvent from 'api/common/logEvent';
import { useListAccounts } from 'api/generated/services/cloudintegration';
import { getAccountById } from 'container/Integrations/CloudIntegration/utils';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { useAwsAccounts } from 'hooks/integration/aws/useAwsAccounts';
import useUrlQuery from 'hooks/useUrlQuery';
import { ChevronDown, Dot, PencilLine, Plug, Plus } from 'lucide-react';
import { Check, ChevronDown } from 'lucide-react';
import { mapAccountDtoToAwsCloudAccount } from '../../mapAwsCloudAccountFromDto';
import { CloudAccount } from '../../types';
import { CloudAccount } from '../../ServicesSection/types';
import AccountSettingsModal from './AccountSettingsModal';
import CloudAccountSetupModal from './CloudAccountSetupModal';
import './AccountActions.style.scss';
interface AccountOptionItemProps {
label: React.ReactNode;
isSelected: boolean;
}
function AccountOptionItem({
label,
isSelected,
}: AccountOptionItemProps): JSX.Element {
return (
<div className="account-option-item">
{label}
{isSelected && (
<div className="account-option-item__selected">
<Check size={12} color={Color.BG_VANILLA_100} />
</div>
)}
</div>
);
}
function renderOption(
option: any,
activeAccountId: string | undefined,
): JSX.Element {
return (
<AccountOptionItem
label={option.label}
isSelected={option.value === activeAccountId}
/>
);
}
const getAccountById = (
accounts: CloudAccount[],
accountId: string,
): CloudAccount | null =>
accounts.find((account) => account.cloud_account_id === accountId) || null;
function AccountActionsRenderer({
accounts,
isLoading,
@@ -38,51 +73,55 @@ function AccountActionsRenderer({
if (isLoading) {
return (
<div className="hero-section__actions-with-account">
<Skeleton.Input active block className="hero-section__input-skeleton" />
<Skeleton.Input
active
size="large"
block
className="hero-section__input-skeleton"
/>
<div className="hero-section__action-buttons">
<Skeleton.Button
active
size="large"
className="hero-section__new-account-button-skeleton"
/>
<Skeleton.Button
active
size="large"
className="hero-section__account-settings-button-skeleton"
/>
</div>
</div>
);
}
if (accounts?.length) {
return (
<div className="hero-section__actions-with-account">
<div className="hero-section__actions-with-account-selector-container">
<div className="selected-cloud-integration-account-status">
<Dot size={24} color={Color.BG_FOREST_500} />
</div>
<div className="account-selector-label">Account:</div>
<span className="account-selector">
<Select
value={activeAccount?.providerAccountId}
options={selectOptions}
rootClassName="cloud-account-selector"
placeholder="Select AWS Account"
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
onChange={onAccountChange}
/>
</span>
</div>
<Select
value={`Account: ${activeAccount?.cloud_account_id}`}
options={selectOptions}
rootClassName="cloud-account-selector"
placeholder="Select AWS Account"
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
optionRender={(option): JSX.Element =>
renderOption(option, activeAccount?.cloud_account_id)
}
onChange={onAccountChange}
/>
<div className="hero-section__action-buttons">
<Button
variant="link"
size="sm"
color="secondary"
prefixIcon={<PencilLine size={14} />}
type="primary"
className="hero-section__action-button primary"
onClick={onIntegrationModalOpen}
>
Add New AWS Account
</Button>
<Button
type="default"
className="hero-section__action-button secondary"
onClick={onAccountSettingsModalOpen}
>
Edit Account
</Button>
<Button
variant="link"
size="sm"
color="secondary"
onClick={onIntegrationModalOpen}
prefixIcon={<Plus size={14} />}
>
Add New Account
Account Settings
</Button>
</div>
</div>
@@ -90,11 +129,8 @@ function AccountActionsRenderer({
}
return (
<Button
variant="solid"
color="primary"
prefixIcon={<Plug size={14} />}
className="hero-section__action-button primary"
onClick={onIntegrationModalOpen}
size="sm"
>
Integrate Now
</Button>
@@ -104,18 +140,7 @@ function AccountActionsRenderer({
function AccountActions(): JSX.Element {
const urlQuery = useUrlQuery();
const navigate = useNavigate();
const { data: listAccountsResponse, isLoading } = useListAccounts({
cloudProvider: INTEGRATION_TYPES.AWS,
});
const accounts = useMemo((): CloudAccount[] | undefined => {
const raw = listAccountsResponse?.data?.accounts;
if (!raw) {
return undefined;
}
return raw
.map(mapAccountDtoToAwsCloudAccount)
.filter((account): account is CloudAccount => account !== null);
}, [listAccountsResponse]);
const { data: accounts, isLoading } = useAwsAccounts();
const initialAccount = useMemo(
() =>
@@ -137,13 +162,7 @@ function AccountActions(): JSX.Element {
const latestUrlQuery = new URLSearchParams(window.location.search);
latestUrlQuery.set('cloudAccountId', initialAccount.cloud_account_id);
navigate({ search: latestUrlQuery.toString() });
return;
}
setActiveAccount(null);
const latestUrlQuery = new URLSearchParams(window.location.search);
latestUrlQuery.delete('cloudAccountId');
navigate({ search: latestUrlQuery.toString() });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialAccount]);
@@ -179,7 +198,7 @@ function AccountActions(): JSX.Element {
accounts?.length
? accounts.map((account) => ({
value: account.cloud_account_id,
label: account.providerAccountId,
label: account.cloud_account_id,
}))
: [],
[accounts],
@@ -209,10 +228,10 @@ function AccountActions(): JSX.Element {
/>
)}
{isAccountSettingsModalOpen && activeAccount && (
{isAccountSettingsModalOpen && (
<AccountSettingsModal
onClose={(): void => setIsAccountSettingsModalOpen(false)}
account={activeAccount}
account={activeAccount as CloudAccount}
setActiveAccount={setActiveAccount}
/>
)}

View File

@@ -14,13 +14,8 @@
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 14px;
&-account-info {
&-connected-account-details {
display: flex;
flex-direction: column;
gap: 8px;
&-title {
color: var(--l1-foreground);
font-size: 14px;
@@ -43,12 +38,10 @@
}
}
}
&-region-selector {
&-regions-switch {
display: flex;
flex-direction: column;
gap: 4px;
gap: 10px;
&-title {
color: var(--l1-foreground);
font-size: 14px;
@@ -56,14 +49,6 @@
line-height: 20px;
letter-spacing: -0.07px;
}
&-description {
color: var(--l2-foreground);
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
}
&-switch {
display: flex;
align-items: center;
@@ -81,17 +66,15 @@
}
}
}
&-regions-select {
margin-top: 8px;
}
}
&__footer {
padding: 16px;
display: flex;
flex-direction: row;
gap: 10px;
justify-content: space-between;
&-close-button,
&-save-button {
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-weight: 500;
@@ -100,31 +83,18 @@
}
&-close-button {
border-radius: 2px;
background: var(--l1-background);
border: 1px solid var(--l1-border);
color: var(--l1-foreground);
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
background: var(--l1-border);
border: none;
}
&-save-button {
background: var(--primary-background);
color: var(--primary-foreground);
border: none;
border-radius: 2px;
margin: 0 !important;
&:disabled {
background: var(--primary-background);
color: var(--primary-foreground);
opacity: 0.6;
border: none;
}
&:not(:disabled):hover {
background: var(--primary-background-hover);
}
border-radius: 2px;
margin: 0 !important;
}
}
.ant-modal-body {
@@ -139,3 +109,81 @@
margin: 0;
}
}
.lightMode {
.account-settings-modal {
&__title-account-id {
color: var(--l1-foreground);
}
&__body {
border-color: var(--l1-border);
&-account-info {
&-connected-account-details {
&-title {
color: var(--l1-foreground);
}
&-account-id {
color: var(--l1-foreground);
&-account-id {
color: var(--l1-foreground);
}
}
}
}
&-regions-switch {
&-title {
color: var(--l1-foreground);
}
&-switch {
&-label {
color: var(--l1-foreground);
&:hover {
color: var(--l1-foreground);
}
}
}
}
}
&__footer {
&-close-button,
&-save-button {
color: var(--l1-background);
}
&-close-button {
background: var(--l1-background);
border: 1px solid var(--l1-border);
color: var(--l1-foreground);
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
}
&-save-button {
// Keep primary button same as dark mode
background: var(--primary-background);
color: var(--primary-foreground);
&:disabled {
background: var(--primary-background);
color: var(--primary-foreground);
opacity: 0.6;
}
&:not(:disabled):hover {
background: var(--bg-robin-400);
}
}
}
}
}

View File

@@ -0,0 +1,213 @@
import { Dispatch, SetStateAction, useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { Form, Select, Switch } from 'antd';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
getRegionPreviewText,
useAccountSettingsModal,
} from 'hooks/integration/aws/useAccountSettingsModal';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import logEvent from '../../../../api/common/logEvent';
import { CloudAccount } from '../../ServicesSection/types';
import { RegionSelector } from './RegionSelector';
import RemoveIntegrationAccount from './RemoveIntegrationAccount';
import './AccountSettingsModal.style.scss';
interface AccountSettingsModalProps {
onClose: () => void;
account: CloudAccount;
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
}
function AccountSettingsModal({
onClose,
account,
setActiveAccount,
}: AccountSettingsModalProps): JSX.Element {
const {
form,
isLoading,
selectedRegions,
includeAllRegions,
isRegionSelectOpen,
isSaveDisabled,
setSelectedRegions,
setIncludeAllRegions,
setIsRegionSelectOpen,
handleIncludeAllRegionsChange,
handleSubmit,
handleClose,
} = useAccountSettingsModal({ onClose, account, setActiveAccount });
const queryClient = useQueryClient();
const urlQuery = useUrlQuery();
const handleRemoveIntegrationAccountSuccess = (): void => {
queryClient.invalidateQueries([REACT_QUERY_KEY.AWS_ACCOUNTS]);
urlQuery.delete('cloudAccountId');
handleClose();
history.replace({ search: urlQuery.toString() });
logEvent('AWS Integration: Account removed', {
id: account?.id,
cloudAccountId: account?.cloud_account_id,
});
};
const handleRegionDeselect = useCallback(
(item: string): void => {
if (selectedRegions.includes(item)) {
setSelectedRegions(selectedRegions.filter((region) => region !== item));
if (includeAllRegions) {
setIncludeAllRegions(false);
}
}
},
[
selectedRegions,
includeAllRegions,
setSelectedRegions,
setIncludeAllRegions,
],
);
const renderRegionSelector = useCallback(() => {
if (isRegionSelectOpen) {
return (
<RegionSelector
selectedRegions={selectedRegions}
setSelectedRegions={setSelectedRegions}
setIncludeAllRegions={setIncludeAllRegions}
/>
);
}
return (
<>
<div className="account-settings-modal__body-regions-switch-switch ">
<Switch
checked={includeAllRegions}
onChange={handleIncludeAllRegionsChange}
/>
<button
className="account-settings-modal__body-regions-switch-switch-label"
type="button"
onClick={(): void => handleIncludeAllRegionsChange(!includeAllRegions)}
>
Include all regions
</button>
</div>
<Select
suffixIcon={null}
placeholder="Select Region(s)"
className="cloud-account-setup-form__select account-settings-modal__body-regions-select integrations-select"
onClick={(): void => setIsRegionSelectOpen(true)}
mode="multiple"
maxTagCount={3}
value={getRegionPreviewText(selectedRegions)}
open={false}
onDeselect={handleRegionDeselect}
/>
</>
);
}, [
isRegionSelectOpen,
includeAllRegions,
handleIncludeAllRegionsChange,
selectedRegions,
handleRegionDeselect,
setSelectedRegions,
setIncludeAllRegions,
setIsRegionSelectOpen,
]);
const renderAccountDetails = useCallback(
() => (
<div className="account-settings-modal__body-account-info">
<div className="account-settings-modal__body-account-info-connected-account-details">
<div className="account-settings-modal__body-account-info-connected-account-details-title">
Connected Account details
</div>
<div className="account-settings-modal__body-account-info-connected-account-details-account-id">
AWS Account:{' '}
<span className="account-settings-modal__body-account-info-connected-account-details-account-id-account-id">
{account?.id}
</span>
</div>
</div>
</div>
),
[account?.id],
);
const modalTitle = (
<div className="account-settings-modal__title">
Account settings for{' '}
<span className="account-settings-modal__title-account-id">
{account?.id}
</span>
</div>
);
return (
<SignozModal
open
title={modalTitle}
onCancel={handleClose}
onOk={handleSubmit}
okText="Save"
okButtonProps={{
disabled: isSaveDisabled,
className: 'account-settings-modal__footer-save-button',
loading: isLoading,
}}
cancelButtonProps={{
className: 'account-settings-modal__footer-close-button',
}}
width={672}
rootClassName="account-settings-modal"
>
<Form
form={form}
layout="vertical"
initialValues={{
selectedRegions,
includeAllRegions,
}}
>
<div className="account-settings-modal__body">
{renderAccountDetails()}
<Form.Item
name="selectedRegions"
rules={[
{
validator: async (): Promise<void> => {
if (selectedRegions.length === 0) {
throw new Error('Please select at least one region to monitor');
}
},
message: 'Please select at least one region to monitor',
},
]}
>
{renderRegionSelector()}
</Form.Item>
<div className="integration-detail-content">
<RemoveIntegrationAccount
accountId={account?.id}
onRemoveIntegrationAccountSuccess={handleRemoveIntegrationAccountSuccess}
/>
</div>
</div>
</Form>
</SignozModal>
);
}
export default AccountSettingsModal;

View File

@@ -1,33 +1,4 @@
.cloud-account-setup-modal {
background: var(--l1-background);
color: var(--l1-foreground);
[data-slot='drawer-title'] {
color: var(--l1-foreground);
}
> div {
display: flex;
flex-direction: column;
overflow: hidden;
}
&__content {
flex: 1;
overflow-y: auto;
min-height: 0;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 2px;
}
}
&__footer {
padding: 16px;
margin-bottom: 16px;
}
.account-setup-modal-footer {
&__confirm-button {
background: var(--primary-background);
@@ -39,24 +10,16 @@
font-family: 'Geist Mono';
}
&__close-button {
background: var(--l1-background);
border: 1px solid var(--l1-border);
background: var(--l1-border);
border-radius: 2px;
color: var(--l1-foreground);
font-family: 'Inter';
font-size: 12px;
font-weight: 500;
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
}
}
.cloud-account-setup-form {
padding: 16px;
.disabled {
opacity: 0.4;
}
@@ -93,8 +56,6 @@
display: flex;
align-items: center;
gap: 8px;
color: var(--l1-foreground);
.retry-time {
font-family: 'Geist Mono';
font-size: 14px;
@@ -155,7 +116,7 @@
}
&__note {
padding: 12px;
color: var(--callout-primary-description);
color: var(--bg-robin-400);
font-size: 12px;
line-height: 22px;
letter-spacing: -0.06px;
@@ -183,3 +144,87 @@
}
}
}
.lightMode {
.cloud-account-setup-modal {
.account-setup-modal-footer {
&__confirm-button {
background: var(--primary-background);
color: var(--primary-foreground);
}
&__close-button {
background: var(--l1-background);
border: 1px solid var(--l1-border);
color: var(--l1-foreground);
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
}
}
.cloud-account-setup-form {
&__title {
color: var(--l1-foreground);
}
&__description {
color: var(--l1-foreground);
}
&__select {
.ant-select-selection-item {
color: var(--l1-foreground);
}
}
&__include-all-regions-switch {
color: var(--l1-foreground);
&-label {
color: var(--l1-foreground);
&:hover {
color: var(--l1-foreground);
}
}
}
&__note {
color: var(--primary-foreground);
border: 1px solid
color-mix(in srgb, var(--primary-background) 20%, transparent);
background: color-mix(in srgb, var(--primary-background) 10%, transparent);
}
&__submit-button {
background: var(--primary-background);
color: var(--primary-foreground);
}
&__alert {
&.ant-alert-error {
color: var(--danger-foreground);
border: 1px solid
color-mix(in srgb, var(--danger-background) 20%, transparent);
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
}
&.ant-alert-warning {
color: var(--warning-foreground);
border: 1px solid
color-mix(in srgb, var(--warning-background) 20%, transparent);
background: color-mix(in srgb, var(--warning-background) 10%, transparent);
}
&-message {
.retry-time {
color: var(--l1-foreground);
}
}
}
}
}
}

View File

@@ -1,7 +1,8 @@
import { useCallback } from 'react';
import { Button } from '@signozhq/button';
import { useQueryClient } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { DrawerWrapper } from '@signozhq/drawer';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useIntegrationModal } from 'hooks/integration/aws/useIntegrationModal';
import { SquareArrowOutUpRight } from 'lucide-react';
@@ -11,15 +12,19 @@ import {
ModalStateEnum,
} from '../types';
import { RegionForm } from './RegionForm';
import { RegionSelector } from './RegionSelector';
import { SuccessView } from './SuccessView';
import './CloudAccountSetupModal.style.scss';
function CloudAccountSetupModal({
onClose,
}: IntegrationModalProps): JSX.Element {
const queryClient = useQueryClient();
const {
form,
modalState,
setModalState,
isLoading,
activeView,
selectedRegions,
@@ -27,86 +32,97 @@ function CloudAccountSetupModal({
isGeneratingUrl,
setSelectedRegions,
setIncludeAllRegions,
handleIncludeAllRegionsChange,
handleRegionSelect,
handleSubmit,
handleClose,
setActiveView,
allRegions,
accountId,
selectedDeploymentRegion,
handleRegionChange,
connectionParams,
isConnectionParamsLoading,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
} = useIntegrationModal({ onClose });
const renderContent = useCallback(() => {
return (
<div className="cloud-account-setup-modal__content">
<RegionForm
form={form}
modalState={modalState}
if (modalState === ModalStateEnum.SUCCESS) {
return <SuccessView />;
}
if (activeView === ActiveViewEnum.SELECT_REGIONS) {
return (
<RegionSelector
selectedRegions={selectedRegions}
includeAllRegions={includeAllRegions}
onRegionSelect={handleRegionSelect}
onSubmit={handleSubmit}
accountId={accountId}
handleRegionChange={handleRegionChange}
connectionParams={connectionParams}
isConnectionParamsLoading={isConnectionParamsLoading}
setSelectedRegions={setSelectedRegions}
setIncludeAllRegions={setIncludeAllRegions}
onConnectionSuccess={handleConnectionSuccess}
onConnectionTimeout={handleConnectionTimeout}
onConnectionError={handleConnectionError}
/>
);
}
<div className="cloud-account-setup-modal__footer">
<Button
variant="solid"
color="primary"
prefixIcon={
<SquareArrowOutUpRight size={17} color={Color.BG_VANILLA_100} />
}
onClick={handleSubmit}
disabled={
selectedRegions.length === 0 ||
isLoading ||
isGeneratingUrl ||
modalState === ModalStateEnum.WAITING
}
>
Launch Cloud Formation Template
</Button>
</div>
</div>
return (
<RegionForm
form={form}
modalState={modalState}
setModalState={setModalState}
selectedRegions={selectedRegions}
includeAllRegions={includeAllRegions}
onIncludeAllRegionsChange={handleIncludeAllRegionsChange}
onRegionSelect={handleRegionSelect}
onSubmit={handleSubmit}
accountId={accountId}
selectedDeploymentRegion={selectedDeploymentRegion}
handleRegionChange={handleRegionChange}
connectionParams={connectionParams}
isConnectionParamsLoading={isConnectionParamsLoading}
/>
);
}, [
modalState,
activeView,
form,
setModalState,
selectedRegions,
includeAllRegions,
handleIncludeAllRegionsChange,
handleRegionSelect,
handleSubmit,
accountId,
selectedDeploymentRegion,
handleRegionChange,
connectionParams,
isConnectionParamsLoading,
setSelectedRegions,
setIncludeAllRegions,
isLoading,
isGeneratingUrl,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
]);
const getSelectedRegionsCount = useCallback(
(): number => selectedRegions.length,
[selectedRegions],
(): number =>
selectedRegions.includes('all') ? allRegions.length : selectedRegions.length,
[selectedRegions, allRegions],
);
const getModalConfig = useCallback(() => {
// Handle success state first
if (modalState === ModalStateEnum.SUCCESS) {
return {
title: 'AWS Integration',
okText: (
<div className="cloud-account-setup-success-view__footer-button">
Continue
</div>
),
block: true,
onOk: (): void => {
queryClient.invalidateQueries([REACT_QUERY_KEY.AWS_ACCOUNTS]);
handleClose();
},
cancelButtonProps: { style: { display: 'none' } },
disabled: false,
};
}
// Handle other views
const viewConfigs = {
[ActiveViewEnum.FORM]: {
title: 'Add AWS Account',
@@ -139,30 +155,35 @@ function CloudAccountSetupModal({
isLoading,
isGeneratingUrl,
activeView,
handleClose,
setActiveView,
queryClient,
]);
const modalConfig = getModalConfig();
const handleDrawerOpenChange = (open: boolean): void => {
if (!open) {
handleClose();
}
};
return (
<DrawerWrapper
open={true}
type="panel"
<SignozModal
open
className="cloud-account-setup-modal"
content={renderContent()}
onOpenChange={handleDrawerOpenChange}
direction="right"
showCloseButton
header={{
title: modalConfig.title,
title={modalConfig.title}
onCancel={handleClose}
onOk={modalConfig.onOk}
okText={modalConfig.okText}
okButtonProps={{
loading: isLoading,
disabled: selectedRegions.length === 0 || modalConfig.disabled,
className:
activeView === ActiveViewEnum.FORM
? 'cloud-account-setup-form__submit-button'
: 'account-setup-modal-footer__confirm-button',
block: activeView === ActiveViewEnum.FORM,
}}
/>
cancelButtonProps={modalConfig.cancelButtonProps}
width={672}
>
{renderContent()}
</SignozModal>
);
}

View File

@@ -1,19 +1,17 @@
import { Dispatch, SetStateAction } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Form, Select } from 'antd';
import { Form, Select, Switch } from 'antd';
import { ChevronDown } from 'lucide-react';
import { Region } from 'utils/regions';
import { popupContainer } from 'utils/selectPopupContainer';
import { RegionSelector } from './RegionSelector';
// Form section components
function RegionDeploymentSection({
regions,
selectedDeploymentRegion,
handleRegionChange,
isFormDisabled,
}: {
regions: Region[];
selectedDeploymentRegion: string | undefined;
handleRegionChange: (value: string) => void;
isFormDisabled: boolean;
}): JSX.Element {
@@ -35,8 +33,8 @@ function RegionDeploymentSection({
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
className="cloud-account-setup-form__select integrations-select"
onChange={handleRegionChange}
value={selectedDeploymentRegion}
disabled={isFormDisabled}
getPopupContainer={popupContainer}
>
{regions.flatMap((region) =>
region.subRegions.map((subRegion) => (
@@ -52,13 +50,19 @@ function RegionDeploymentSection({
}
function MonitoringRegionsSection({
includeAllRegions,
selectedRegions,
setSelectedRegions,
setIncludeAllRegions,
onIncludeAllRegionsChange,
getRegionPreviewText,
onRegionSelect,
isFormDisabled,
}: {
includeAllRegions: boolean;
selectedRegions: string[];
setSelectedRegions: Dispatch<SetStateAction<string[]>>;
setIncludeAllRegions: Dispatch<SetStateAction<boolean>>;
onIncludeAllRegionsChange: (checked: boolean) => void;
getRegionPreviewText: (regions: string[]) => string[];
onRegionSelect: () => void;
isFormDisabled: boolean;
}): JSX.Element {
return (
<div className="cloud-account-setup-form__form-group">
@@ -69,12 +73,51 @@ function MonitoringRegionsSection({
Choose only the regions you want SigNoz to monitor. You can enable all at
once, or pick specific ones:
</div>
<RegionSelector
selectedRegions={selectedRegions}
setSelectedRegions={setSelectedRegions}
setIncludeAllRegions={setIncludeAllRegions}
/>
<Form.Item
name="monitorRegions"
rules={[
{
validator: async (): Promise<void> => {
if (selectedRegions.length === 0) {
return Promise.reject();
}
return Promise.resolve();
},
message: 'Please select at least one region to monitor',
},
]}
className="cloud-account-setup-form__form-item"
>
<div className="cloud-account-setup-form__include-all-regions-switch">
<Switch
size="small"
checked={includeAllRegions}
onChange={onIncludeAllRegionsChange}
disabled={isFormDisabled}
/>
<button
className="cloud-account-setup-form__include-all-regions-switch-label"
type="button"
onClick={(): void =>
!isFormDisabled
? onIncludeAllRegionsChange(!includeAllRegions)
: undefined
}
>
Include all regions
</button>
</div>
<Select
suffixIcon={null}
placeholder="Select Region(s)"
className="cloud-account-setup-form__select integrations-select"
onClick={!isFormDisabled ? onRegionSelect : undefined}
mode="multiple"
maxTagCount={3}
value={getRegionPreviewText(selectedRegions)}
open={false}
/>
</Form.Item>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import { useRef } from 'react';
import { Form } from 'antd';
import cx from 'classnames';
import { useAccountStatus } from 'hooks/integration/aws/useAccountStatus';
import { AccountStatusResponse } from 'types/api/integrations/aws';
import { regions } from 'utils/regions';
import logEvent from '../../../../api/common/logEvent';
import { ModalStateEnum, RegionFormProps } from '../types';
import AlertMessage from './AlertMessage';
import {
ComplianceNote,
MonitoringRegionsSection,
RegionDeploymentSection,
} from './IntegrateNowFormSections';
import RenderConnectionFields from './RenderConnectionParams';
const allRegions = (): string[] =>
regions.flatMap((r) => r.subRegions.map((sr) => sr.name));
const getRegionPreviewText = (regions: string[]): string[] => {
if (regions.includes('all')) {
return allRegions();
}
return regions;
};
export function RegionForm({
form,
modalState,
setModalState,
selectedRegions,
includeAllRegions,
onIncludeAllRegionsChange,
onRegionSelect,
onSubmit,
accountId,
selectedDeploymentRegion,
handleRegionChange,
connectionParams,
isConnectionParamsLoading,
}: RegionFormProps): JSX.Element {
const startTimeRef = useRef(Date.now());
const refetchInterval = 10 * 1000;
const errorTimeout = 10 * 60 * 1000;
const { isLoading: isAccountStatusLoading } = useAccountStatus(accountId, {
refetchInterval,
enabled: !!accountId && modalState === ModalStateEnum.WAITING,
onSuccess: (data: AccountStatusResponse) => {
if (data.data.status.integration.last_heartbeat_ts_ms !== null) {
setModalState(ModalStateEnum.SUCCESS);
logEvent('AWS Integration: Account connected', {
cloudAccountId: data?.data?.cloud_account_id,
status: data?.data?.status,
});
} else if (Date.now() - startTimeRef.current >= errorTimeout) {
setModalState(ModalStateEnum.ERROR);
logEvent('AWS Integration: Account connection attempt timed out', {
id: accountId,
});
}
},
onError: () => {
setModalState(ModalStateEnum.ERROR);
},
});
const isFormDisabled =
modalState === ModalStateEnum.WAITING || isAccountStatusLoading;
return (
<Form
form={form}
className="cloud-account-setup-form"
layout="vertical"
onFinish={onSubmit}
>
<AlertMessage modalState={modalState} />
<div
className={cx(`cloud-account-setup-form__content`, {
disabled: isFormDisabled,
})}
>
<RegionDeploymentSection
regions={regions}
handleRegionChange={handleRegionChange}
isFormDisabled={isFormDisabled}
selectedDeploymentRegion={selectedDeploymentRegion}
/>
<MonitoringRegionsSection
includeAllRegions={includeAllRegions}
selectedRegions={selectedRegions}
onIncludeAllRegionsChange={onIncludeAllRegionsChange}
getRegionPreviewText={getRegionPreviewText}
onRegionSelect={onRegionSelect}
isFormDisabled={isFormDisabled}
/>
<ComplianceNote />
<RenderConnectionFields
isConnectionParamsLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
isFormDisabled={isFormDisabled}
/>
</div>
</Form>
);
}

View File

@@ -1,6 +1,5 @@
.select-all {
margin-top: 16px;
margin-bottom: 16px;
margin-bottom: 20px;
}
.regions-grid {
@@ -20,11 +19,3 @@
gap: 10px;
align-items: center;
}
.region-selector-footer {
margin-top: 36px;
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -28,12 +28,10 @@ export function RegionSelector({
<div className="region-selector">
<div className="select-all">
<Checkbox
checked={
allRegionIds.length > 0 &&
allRegionIds.every((regionId) => selectedRegions.includes(regionId))
}
checked={selectedRegions.includes('all')}
indeterminate={
selectedRegions.length > 0 && selectedRegions.length < allRegionIds.length
selectedRegions.length > 20 &&
selectedRegions.length < allRegionIds.length
}
onChange={(e): void => handleSelectAll(e.target.checked)}
>
@@ -48,7 +46,10 @@ export function RegionSelector({
{region.subRegions.map((subRegion) => (
<Checkbox
key={subRegion.id}
checked={selectedRegions.includes(subRegion.id)}
checked={
selectedRegions.includes('all') ||
selectedRegions.includes(subRegion.id)
}
onChange={(): void => handleRegionSelect(subRegion.id)}
>
{subRegion.name}

View File

@@ -0,0 +1,47 @@
.remove-integration-account {
display: flex;
justify-content: space-between;
padding: 16px;
border-radius: 4px;
border: 1px solid color-mix(in srgb, var(--bg-sakura-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sakura-500) 6%, transparent);
&__header {
display: flex;
flex-direction: column;
gap: 6px;
}
&__title {
color: var(--bg-cherry-500);
font-size: 14px;
letter-spacing: -0.07px;
}
&__subtitle {
color: var(--bg-cherry-300);
font-size: 14px;
line-height: 22px;
letter-spacing: -0.07px;
}
&__button {
display: flex;
align-items: center;
background: var(--bg-cherry-500);
border: none;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 13.3px; /* 110.833% */
padding: 9px 13px;
.ant-btn-icon {
margin-inline-end: 4px !important;
}
&:hover {
&.ant-btn-default {
color: var(--l2-foreground) !important;
}
}
}
}

View File

@@ -0,0 +1,94 @@
import { useState } from 'react';
import { useMutation } from 'react-query';
import { Button, Modal } from 'antd';
import logEvent from 'api/common/logEvent';
import removeAwsIntegrationAccount from 'api/Integrations/removeAwsIntegrationAccount';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { useNotifications } from 'hooks/useNotifications';
import { X } from 'lucide-react';
import { INTEGRATION_TELEMETRY_EVENTS } from 'pages/Integrations/utils';
import './RemoveIntegrationAccount.scss';
function RemoveIntegrationAccount({
accountId,
onRemoveIntegrationAccountSuccess,
}: {
accountId: string;
onRemoveIntegrationAccountSuccess: () => void;
}): JSX.Element {
const { notifications } = useNotifications();
const [isModalOpen, setIsModalOpen] = useState(false);
const showModal = (): void => {
setIsModalOpen(true);
};
const {
mutate: removeIntegration,
isLoading: isRemoveIntegrationLoading,
} = useMutation(removeAwsIntegrationAccount, {
onSuccess: () => {
onRemoveIntegrationAccountSuccess?.();
setIsModalOpen(false);
},
onError: () => {
notifications.error({
message: SOMETHING_WENT_WRONG,
});
},
});
const handleOk = (): void => {
logEvent(INTEGRATION_TELEMETRY_EVENTS.AWS_INTEGRATION_ACCOUNT_REMOVED, {
accountId,
});
removeIntegration(accountId);
};
const handleCancel = (): void => {
setIsModalOpen(false);
};
return (
<div className="remove-integration-account">
<div className="remove-integration-account__header">
<div className="remove-integration-account__title">Remove Integration</div>
<div className="remove-integration-account__subtitle">
Removing this integration won&apos;t delete any existing data but will stop
collecting new data from AWS.
</div>
</div>
<Button
className="remove-integration-account__button"
icon={<X size={14} />}
onClick={(): void => showModal()}
>
Remove
</Button>
<Modal
className="remove-integration-modal"
open={isModalOpen}
title="Remove integration"
onOk={handleOk}
onCancel={handleCancel}
okText="Remove Integration"
okButtonProps={{
danger: true,
disabled: isRemoveIntegrationLoading,
}}
>
<div className="remove-integration-modal__text">
Removing this account will remove all components created for sending
telemetry to SigNoz in your AWS account within the next ~15 minutes
(cloudformation stacks named signoz-integration-telemetry-collection in
enabled regions). <br />
<br />
After that, you can delete the cloudformation stack that was created
manually when connecting this account.
</div>
</Modal>
</div>
);
}
export default RemoveIntegrationAccount;

View File

@@ -1,5 +1,5 @@
import { Form, Input } from 'antd';
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
import { ConnectionParams } from 'types/api/integrations/aws';
function RenderConnectionFields({
isConnectionParamsLoading,
@@ -7,51 +7,51 @@ function RenderConnectionFields({
isFormDisabled,
}: {
isConnectionParamsLoading?: boolean;
connectionParams?: CloudintegrationtypesCredentialsDTO | null;
connectionParams?: ConnectionParams | null;
isFormDisabled?: boolean;
}): JSX.Element | null {
if (
isConnectionParamsLoading ||
(!!connectionParams?.ingestionUrl &&
!!connectionParams?.ingestionKey &&
!!connectionParams?.sigNozApiUrl &&
!!connectionParams?.sigNozApiKey)
(!!connectionParams?.ingestion_url &&
!!connectionParams?.ingestion_key &&
!!connectionParams?.signoz_api_url &&
!!connectionParams?.signoz_api_key)
) {
return null;
}
return (
<Form.Item name="connectionParams">
{!connectionParams?.ingestionUrl && (
<Form.Item name="connection_params">
{!connectionParams?.ingestion_url && (
<Form.Item
name="ingestionUrl"
name="ingestion_url"
label="Ingestion URL"
rules={[{ required: true, message: 'Please enter ingestion URL' }]}
>
<Input placeholder="Enter ingestion URL" disabled={isFormDisabled} />
</Form.Item>
)}
{!connectionParams?.ingestionKey && (
{!connectionParams?.ingestion_key && (
<Form.Item
name="ingestionKey"
name="ingestion_key"
label="Ingestion Key"
rules={[{ required: true, message: 'Please enter ingestion key' }]}
>
<Input placeholder="Enter ingestion key" disabled={isFormDisabled} />
</Form.Item>
)}
{!connectionParams?.sigNozApiUrl && (
{!connectionParams?.signoz_api_url && (
<Form.Item
name="sigNozApiUrl"
name="signoz_api_url"
label="SigNoz API URL"
rules={[{ required: true, message: 'Please enter SigNoz API URL' }]}
>
<Input placeholder="Enter SigNoz API URL" disabled={isFormDisabled} />
</Form.Item>
)}
{!connectionParams?.sigNozApiKey && (
{!connectionParams?.signoz_api_key && (
<Form.Item
name="sigNozApiKey"
name="signoz_api_key"
label="SigNoz API KEY"
rules={[{ required: true, message: 'Please enter SigNoz API Key' }]}
>

View File

@@ -0,0 +1,162 @@
.cloud-account-setup-success-view {
display: flex;
flex-direction: column;
gap: 40px;
text-align: center;
padding-top: 34px;
p,
h3,
h4 {
margin: 0;
}
&__content {
display: flex;
flex-direction: column;
gap: 14px;
.cloud-account-setup-success-view {
&__title {
h3 {
color: var(--l1-foreground);
font-size: 20px;
font-weight: 500;
line-height: 32px;
}
}
&__description {
color: var(--l2-foreground);
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
}
}
&__what-next {
display: flex;
flex-direction: column;
gap: 18px;
text-align: left;
&-title {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
}
.what-next-items-wrapper {
display: flex;
flex-direction: column;
gap: 12px;
&__item {
display: flex;
gap: 10px;
align-items: baseline;
&.ant-alert {
padding: 14px;
border-radius: 8px;
font-size: 14px;
line-height: 20px; /* 142.857% */
letter-spacing: -0.21px;
}
&.ant-alert-info {
border: 1px solid color-mix(in srgb, var(--bg-robin-600) 50%, transparent);
background: color-mix(in srgb, var(--primary-background) 20%, transparent);
color: var(--primary-foreground);
}
.what-next-item {
color: var(--bg-robin-400);
&-bullet-icon {
font-size: 20px;
line-height: 20px;
}
&-text {
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.21px;
}
}
}
}
}
&__footer {
padding-top: 18px;
.ant-btn {
background: var(--primary-background);
color: var(--primary-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
height: 36px;
}
}
}
.lottie-container {
position: absolute;
width: 743.5px;
height: 990.342px;
top: -100px;
left: -36px;
z-index: 1;
}
.lightMode {
.cloud-account-setup-success-view {
&__content {
.cloud-account-setup-success-view {
&__title {
h3 {
color: var(--l1-foreground);
}
}
&__description {
color: var(--l1-foreground);
}
}
}
&__what-next {
&-title {
color: var(--l1-foreground);
}
.what-next-items-wrapper {
&__item {
&.ant-alert-info {
border: 1px solid color-mix(in srgb, var(--bg-robin-600) 20%, transparent);
background: color-mix(
in srgb,
var(--primary-background) 10%,
transparent
);
color: var(--primary-foreground);
}
.what-next-item {
color: var(--primary-foreground);
&-text {
color: var(--primary-foreground);
}
}
}
}
}
&__footer {
.ant-btn {
background: var(--primary-background);
color: var(--primary-foreground);
&:hover {
background: var(--primary-background-hover);
}
}
}
}
}

View File

@@ -0,0 +1,75 @@
import { useState } from 'react';
import Lottie from 'react-lottie';
import { Alert } from 'antd';
import integrationsSuccess from 'assets/Lotties/integrations-success.json';
import solidCheckCircleUrl from '@/assets/Icons/solid-check-circle.svg';
import './SuccessView.style.scss';
export function SuccessView(): JSX.Element {
const [isAnimationComplete, setIsAnimationComplete] = useState(false);
const defaultOptions = {
loop: false,
autoplay: true,
animationData: integrationsSuccess,
rendererSettings: {
preserveAspectRatio: 'xMidYMid slice',
},
};
return (
<>
{!isAnimationComplete && (
<div className="lottie-container">
<Lottie
options={defaultOptions}
height={990.342}
width={743.5}
eventListeners={[
{
eventName: 'complete',
callback: (): void => setIsAnimationComplete(true),
},
]}
/>
</div>
)}
<div className="cloud-account-setup-success-view">
<div className="cloud-account-setup-success-view__icon">
<img src={solidCheckCircleUrl} alt="Success" />
</div>
<div className="cloud-account-setup-success-view__content">
<div className="cloud-account-setup-success-view__title">
<h3>🎉 Success! </h3>
<h3>Your AWS Web Service integration is all set.</h3>
</div>
<div className="cloud-account-setup-success-view__description">
<p>Your observability journey is off to a great start. </p>
<p>Now that your data is flowing, heres what you can do next:</p>
</div>
</div>
<div className="cloud-account-setup-success-view__what-next">
<h4 className="cloud-account-setup-success-view__what-next-title">
WHAT NEXT
</h4>
<div className="what-next-items-wrapper">
<Alert
message={
<div className="what-next-items-wrapper__item">
<div className="what-next-item-bullet-icon"></div>
<div className="what-next-item-text">
Set up your AWS services effortlessly under your enabled account.
</div>
</div>
}
type="info"
className="what-next-items-wrapper__item"
/>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,6 +1,6 @@
import { Dispatch, SetStateAction } from 'react';
import { FormInstance } from 'antd';
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
import { ConnectionParams } from 'types/api/integrations/aws';
export enum ActiveViewEnum {
SELECT_REGIONS = 'select-regions',
@@ -11,27 +11,23 @@ export enum ModalStateEnum {
FORM = 'form',
WAITING = 'waiting',
ERROR = 'error',
SUCCESS = 'success',
}
export interface RegionFormProps {
form: FormInstance;
modalState: ModalStateEnum;
setModalState: Dispatch<SetStateAction<ModalStateEnum>>;
selectedRegions: string[];
includeAllRegions: boolean;
onIncludeAllRegionsChange: (checked: boolean) => void;
onRegionSelect: () => void;
onSubmit: () => Promise<void>;
accountId?: string;
selectedDeploymentRegion: string | undefined;
handleRegionChange: (value: string) => void;
connectionParams?: CloudintegrationtypesCredentialsDTO;
connectionParams?: ConnectionParams;
isConnectionParamsLoading?: boolean;
setSelectedRegions: Dispatch<SetStateAction<string[]>>;
setIncludeAllRegions: Dispatch<SetStateAction<boolean>>;
onConnectionSuccess: (payload: {
cloudAccountId: string;
status?: unknown;
}) => void;
onConnectionTimeout: (payload: { id?: string }) => void;
onConnectionError: () => void;
}
export interface IntegrationModalProps {

View File

@@ -0,0 +1,50 @@
import { Link } from 'react-router-dom';
import { ServiceData } from './types';
function DashboardItem({
dashboard,
}: {
dashboard: ServiceData['assets']['dashboards'][number];
}): JSX.Element {
const content = (
<>
<div className="cloud-service-dashboard-item__title">{dashboard.title}</div>
<div className="cloud-service-dashboard-item__preview">
<img
src={dashboard.image}
alt={dashboard.title}
className="cloud-service-dashboard-item__preview-image"
/>
</div>
</>
);
return (
<div className="cloud-service-dashboard-item">
{dashboard.url ? (
<Link to={dashboard.url} className="cloud-service-dashboard-item__link">
{content}
</Link>
) : (
content
)}
</div>
);
}
function CloudServiceDashboards({
service,
}: {
service: ServiceData;
}): JSX.Element {
return (
<>
{service.assets.dashboards.map((dashboard) => (
<DashboardItem key={dashboard.id} dashboard={dashboard} />
))}
</>
);
}
export default CloudServiceDashboards;

View File

@@ -1,18 +1,13 @@
import { Table } from 'antd';
import {
CloudintegrationtypesCollectedLogAttributeDTO,
CloudintegrationtypesCollectedMetricDTO,
} from 'api/generated/services/sigNoz.schemas';
import { BarChart2, ScrollText } from 'lucide-react';
import './CloudServiceDataCollected.styles.scss';
import { ServiceData } from './types';
function CloudServiceDataCollected({
logsData,
metricsData,
}: {
logsData: CloudintegrationtypesCollectedLogAttributeDTO[] | null | undefined;
metricsData: CloudintegrationtypesCollectedMetricDTO[] | null | undefined;
logsData: ServiceData['data_collected']['logs'];
metricsData: ServiceData['data_collected']['metrics'];
}): JSX.Element {
const logsColumns = [
{
@@ -66,30 +61,24 @@ function CloudServiceDataCollected({
return (
<div className="cloud-service-data-collected">
{logsData && logsData.length > 0 && (
<div className="cloud-service-data-collected-table">
<div className="cloud-service-data-collected-table-heading">
<ScrollText size={14} />
Logs
</div>
<div className="cloud-service-data-collected__table">
<div className="cloud-service-data-collected__table-heading">Logs</div>
<Table
columns={logsColumns}
dataSource={logsData}
{...tableProps}
className="cloud-service-data-collected-table-logs"
className="cloud-service-data-collected__table-logs"
/>
</div>
)}
{metricsData && metricsData.length > 0 && (
<div className="cloud-service-data-collected-table">
<div className="cloud-service-data-collected-table-heading">
<BarChart2 size={14} />
Metrics
</div>
<div className="cloud-service-data-collected__table">
<div className="cloud-service-data-collected__table-heading">Metrics</div>
<Table
columns={metricsColumns}
dataSource={metricsData}
{...tableProps}
className="cloud-service-data-collected-table-metrics"
className="cloud-service-data-collected__table-metrics"
/>
</div>
)}

View File

@@ -0,0 +1,89 @@
.configure-service-modal {
&__body {
display: flex;
flex-direction: column;
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 14px;
&-regions-switch-switch {
display: flex;
align-items: center;
gap: 6px;
&-label {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
}
&-switch-description {
margin-top: 4px;
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
&-form-item {
&:last-child {
margin-bottom: 0px;
}
}
}
.ant-modal-body {
padding-bottom: 0;
}
.ant-modal-footer {
margin: 0;
padding-bottom: 12px;
}
}
.lightMode {
.configure-service-modal {
&__body {
border-color: var(--l1-border);
&-regions-switch-switch {
&-label {
color: var(--l1-foreground);
}
}
&-switch-description {
color: var(--l1-foreground);
}
}
.ant-btn {
&.ant-btn-default {
background: var(--l1-background);
border: 1px solid var(--l1-border);
color: var(--l1-foreground);
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
}
&.ant-btn-primary {
// Keep primary button same as dark mode
background: var(--primary-background);
color: var(--l1-background);
&:hover {
background: var(--bg-robin-400);
}
&:disabled {
opacity: 0.6;
}
}
}
}
}

View File

@@ -0,0 +1,243 @@
import { useCallback, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { Form, Switch } from 'antd';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
ServiceConfig,
SupportedSignals,
} from 'container/CloudIntegrationPage/ServicesSection/types';
import { useUpdateServiceConfig } from 'hooks/integration/aws/useUpdateServiceConfig';
import { isEqual } from 'lodash-es';
import logEvent from '../../../api/common/logEvent';
import S3BucketsSelector from './S3BucketsSelector';
import './ConfigureServiceModal.styles.scss';
export interface IConfigureServiceModalProps {
isOpen: boolean;
onClose: () => void;
serviceName: string;
serviceId: string;
cloudAccountId: string;
supportedSignals: SupportedSignals;
initialConfig?: ServiceConfig;
}
function ConfigureServiceModal({
isOpen,
onClose,
serviceName,
serviceId,
cloudAccountId,
initialConfig,
supportedSignals,
}: IConfigureServiceModalProps): JSX.Element {
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
// Track current form values
const initialValues = useMemo(
() => ({
metrics: initialConfig?.metrics?.enabled || false,
logs: initialConfig?.logs?.enabled || false,
s3Buckets: initialConfig?.logs?.s3_buckets || {},
}),
[initialConfig],
);
const [currentValues, setCurrentValues] = useState(initialValues);
const isSaveDisabled = useMemo(
() =>
// disable only if current values are same as the initial config
currentValues.metrics === initialValues.metrics &&
currentValues.logs === initialValues.logs &&
isEqual(currentValues.s3Buckets, initialValues.s3Buckets),
[currentValues, initialValues],
);
const handleS3BucketsChange = useCallback(
(bucketsByRegion: Record<string, string[]>) => {
setCurrentValues((prev) => ({
...prev,
s3Buckets: bucketsByRegion,
}));
form.setFieldsValue({ s3Buckets: bucketsByRegion });
},
[form],
);
const {
mutate: updateServiceConfig,
isLoading: isUpdating,
} = useUpdateServiceConfig();
const queryClient = useQueryClient();
const handleSubmit = useCallback(async (): Promise<void> => {
try {
const values = await form.validateFields();
setIsLoading(true);
updateServiceConfig(
{
serviceId,
payload: {
cloud_account_id: cloudAccountId,
config: {
logs: {
enabled: values.logs,
s3_buckets: values.s3Buckets,
},
metrics: {
enabled: values.metrics,
},
},
},
},
{
onSuccess: () => {
queryClient.invalidateQueries([
REACT_QUERY_KEY.AWS_SERVICE_DETAILS,
serviceId,
]);
onClose();
logEvent('AWS Integration: Service settings saved', {
cloudAccountId,
serviceId,
logsEnabled: values?.logs,
metricsEnabled: values?.metrics,
});
},
onError: (error) => {
console.error('Failed to update service config:', error);
},
},
);
} catch (error) {
console.error('Form submission failed:', error);
} finally {
setIsLoading(false);
}
}, [
form,
updateServiceConfig,
serviceId,
cloudAccountId,
queryClient,
onClose,
]);
const handleClose = useCallback(() => {
form.resetFields();
onClose();
}, [form, onClose]);
return (
<SignozModal
title={
<div className="account-settings-modal__title">Configure {serviceName}</div>
}
centered
open={isOpen}
okText="Save"
okButtonProps={{
disabled: isSaveDisabled,
className: 'account-settings-modal__footer-save-button',
loading: isLoading || isUpdating,
}}
onCancel={handleClose}
onOk={handleSubmit}
cancelText="Close"
cancelButtonProps={{
className: 'account-settings-modal__footer-close-button',
}}
width={672}
rootClassName=" configure-service-modal"
>
<Form
form={form}
layout="vertical"
initialValues={{
metrics: initialConfig?.metrics?.enabled || false,
logs: initialConfig?.logs?.enabled || false,
s3Buckets: initialConfig?.logs?.s3_buckets || {},
}}
>
<div className=" configure-service-modal__body">
{supportedSignals.metrics && (
<Form.Item
name="metrics"
valuePropName="checked"
className="configure-service-modal__body-form-item"
>
<div className="configure-service-modal__body-regions-switch-switch">
<Switch
checked={currentValues.metrics}
onChange={(checked): void => {
setCurrentValues((prev) => ({ ...prev, metrics: checked }));
form.setFieldsValue({ metrics: checked });
}}
/>
<span className="configure-service-modal__body-regions-switch-switch-label">
Metric Collection
</span>
</div>
<div className="configure-service-modal__body-switch-description">
Metric Collection is enabled for this AWS account. We recommend keeping
this enabled, but you can disable metric collection if you do not want
to monitor your AWS infrastructure.
</div>
</Form.Item>
)}
{supportedSignals.logs && (
<>
<Form.Item
name="logs"
valuePropName="checked"
className="configure-service-modal__body-form-item"
>
<div className="configure-service-modal__body-regions-switch-switch">
<Switch
checked={currentValues.logs}
onChange={(checked): void => {
setCurrentValues((prev) => ({ ...prev, logs: checked }));
form.setFieldsValue({ logs: checked });
}}
/>
<span className="configure-service-modal__body-regions-switch-switch-label">
Log Collection
</span>
</div>
<div className="configure-service-modal__body-switch-description">
To ingest logs from your AWS services, you must complete several steps
</div>
</Form.Item>
{currentValues.logs && serviceId === 's3sync' && (
<Form.Item name="s3Buckets" noStyle>
<S3BucketsSelector
initialBucketsByRegion={currentValues.s3Buckets}
onChange={handleS3BucketsChange}
/>
</Form.Item>
)}
</>
)}
</div>
</Form>
</SignozModal>
);
}
ConfigureServiceModal.defaultProps = {
initialConfig: {
metrics: { enabled: false },
logs: { enabled: false },
},
};
export default ConfigureServiceModal;

View File

@@ -1,18 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Select, Skeleton } from 'antd';
import { useListAccounts } from 'api/generated/services/cloudintegration';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { useCallback, useMemo, useState } from 'react';
import { Form, Select, Skeleton, Typography } from 'antd';
import { useAwsAccounts } from 'hooks/integration/aws/useAwsAccounts';
import useUrlQuery from 'hooks/useUrlQuery';
import { mapAccountDtoToAwsCloudAccount } from '../mapAwsCloudAccountFromDto';
import { CloudAccount } from '../types';
import './S3BucketsSelector.styles.scss';
const { Title } = Typography;
interface S3BucketsSelectorProps {
onChange?: (bucketsByRegion: Record<string, string[]>) => void;
initialBucketsByRegion?: Record<string, string[]>;
disabled?: boolean;
}
/**
@@ -22,29 +17,13 @@ interface S3BucketsSelectorProps {
function S3BucketsSelector({
onChange,
initialBucketsByRegion = {},
disabled: isSelectorDisabled = false,
}: S3BucketsSelectorProps): JSX.Element {
const cloudAccountId = useUrlQuery().get('cloudAccountId');
const { data: listAccountsResponse, isLoading } = useListAccounts({
cloudProvider: INTEGRATION_TYPES.AWS,
});
const accounts = useMemo((): CloudAccount[] | undefined => {
const raw = listAccountsResponse?.data?.accounts;
if (!raw) {
return undefined;
}
return raw
.map(mapAccountDtoToAwsCloudAccount)
.filter((account): account is CloudAccount => account !== null);
}, [listAccountsResponse]);
const { data: accounts, isLoading } = useAwsAccounts();
const [bucketsByRegion, setBucketsByRegion] = useState<
Record<string, string[]>
>(initialBucketsByRegion);
useEffect(() => {
setBucketsByRegion(initialBucketsByRegion);
}, [initialBucketsByRegion]);
// Find the active AWS account based on the URL query parameter
const activeAccount = useMemo(
() =>
@@ -102,41 +81,37 @@ function S3BucketsSelector({
return (
<div className="s3-buckets-selector">
<div className="s3-buckets-selector-title">Select S3 Buckets by Region</div>
<div className="s3-buckets-selector-content">
{allRegions.map((region) => {
const isRegionUnavailable = isRegionDisabled(region);
<Title level={5}>Select S3 Buckets by Region</Title>
return (
<div key={region} className="s3-buckets-selector-region">
<div className="s3-buckets-selector-region-header">
<div className="s3-buckets-selector-region-label">{region}</div>
{isRegionUnavailable && (
<div className="s3-buckets-selector-region-help">
Region disabled in account settings; S3 buckets here will not be
synced.
</div>
)}
</div>
<div className="s3-buckets-selector-region-select">
<Select
mode="tags"
placeholder={`Enter S3 bucket names for ${region}`}
value={bucketsByRegion[region] || []}
onChange={(value): void => handleRegionBucketsChange(region, value)}
tokenSeparators={[',']}
allowClear
disabled={isSelectorDisabled || isRegionUnavailable}
suffixIcon={null}
notFoundContent={null}
filterOption={false}
showSearch
/>
</div>
</div>
);
})}
</div>
{allRegions.map((region) => {
const disabled = isRegionDisabled(region);
return (
<Form.Item
key={region}
label={region}
{...(disabled && {
help:
'Region disabled in account settings; S3 buckets here will not be synced.',
validateStatus: 'warning',
})}
>
<Select
mode="tags"
placeholder={`Enter S3 bucket names for ${region}`}
value={bucketsByRegion[region] || []}
onChange={(value): void => handleRegionBucketsChange(region, value)}
tokenSeparators={[',']}
allowClear
disabled={disabled}
suffixIcon={null}
notFoundContent={null}
filterOption={false}
showSearch
/>
</Form.Item>
);
})}
</div>
);
}

View File

@@ -0,0 +1,189 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Tabs, TabsProps } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import Spinner from 'components/Spinner';
import CloudServiceDashboards from 'container/CloudIntegrationPage/ServicesSection/CloudServiceDashboards';
import CloudServiceDataCollected from 'container/CloudIntegrationPage/ServicesSection/CloudServiceDataCollected';
import { IServiceStatus } from 'container/CloudIntegrationPage/ServicesSection/types';
import dayjs from 'dayjs';
import { useServiceDetails } from 'hooks/integration/aws/useServiceDetails';
import useUrlQuery from 'hooks/useUrlQuery';
import logEvent from '../../../api/common/logEvent';
import ConfigureServiceModal from './ConfigureServiceModal';
const getStatus = (
logsLastReceivedTimestamp: number | undefined,
metricsLastReceivedTimestamp: number | undefined,
): { text: string; className: string } => {
if (!logsLastReceivedTimestamp && !metricsLastReceivedTimestamp) {
return { text: 'No Data Yet', className: 'service-status--no-data' };
}
const latestTimestamp = Math.max(
logsLastReceivedTimestamp || 0,
metricsLastReceivedTimestamp || 0,
);
const isStale = dayjs().diff(dayjs(latestTimestamp), 'minute') > 30;
if (isStale) {
return { text: 'Stale Data', className: 'service-status--stale-data' };
}
return { text: 'Connected', className: 'service-status--connected' };
};
function ServiceStatus({
serviceStatus,
}: {
serviceStatus: IServiceStatus | undefined;
}): JSX.Element {
const logsLastReceivedTimestamp = serviceStatus?.logs?.last_received_ts_ms;
const metricsLastReceivedTimestamp =
serviceStatus?.metrics?.last_received_ts_ms;
const { text, className } = getStatus(
logsLastReceivedTimestamp,
metricsLastReceivedTimestamp,
);
return <div className={`service-status ${className}`}>{text}</div>;
}
function getTabItems(serviceDetailsData: any): TabsProps['items'] {
const dashboards = serviceDetailsData?.assets.dashboards || [];
const dataCollected = serviceDetailsData?.data_collected || {};
const items: TabsProps['items'] = [];
if (dashboards.length) {
items.push({
key: 'dashboards',
label: `Dashboards (${dashboards.length})`,
children: <CloudServiceDashboards service={serviceDetailsData} />,
});
}
items.push({
key: 'data-collected',
label: 'Data Collected',
children: (
<CloudServiceDataCollected
logsData={dataCollected.logs || []}
metricsData={dataCollected.metrics || []}
/>
),
});
return items;
}
function ServiceDetails(): JSX.Element | null {
const urlQuery = useUrlQuery();
const cloudAccountId = urlQuery.get('cloudAccountId');
const serviceId = urlQuery.get('service');
const [isConfigureServiceModalOpen, setIsConfigureServiceModalOpen] = useState(
false,
);
const openServiceConfigModal = (): void => {
setIsConfigureServiceModalOpen(true);
logEvent('AWS Integration: Service settings viewed', {
cloudAccountId,
serviceId,
});
};
const { data: serviceDetailsData, isLoading } = useServiceDetails(
serviceId || '',
cloudAccountId || undefined,
);
const { config, supported_signals } = serviceDetailsData ?? {};
const totalSupportedSignals = Object.entries(supported_signals || {}).filter(
([, value]) => !!value,
).length;
const enabledSignals = useMemo(
() =>
Object.values(config || {}).filter((item) => item && item.enabled).length,
[config],
);
const isAnySignalConfigured = useMemo(
() => !!config?.logs?.enabled || !!config?.metrics?.enabled,
[config],
);
// log telemetry event on visiting details of a service.
useEffect(() => {
if (serviceId) {
logEvent('AWS Integration: Service viewed', {
cloudAccountId,
serviceId,
});
}
}, [cloudAccountId, serviceId]);
if (isLoading) {
return <Spinner size="large" height="50vh" />;
}
if (!serviceDetailsData) {
return null;
}
const tabItems = getTabItems(serviceDetailsData);
return (
<div className="service-details">
<div className="service-details__title-bar">
<div className="service-details__details-title">Details</div>
<div className="service-details__right-actions">
{isAnySignalConfigured && (
<ServiceStatus serviceStatus={serviceDetailsData.status} />
)}
{!!cloudAccountId &&
(isAnySignalConfigured ? (
<Button
className="configure-button configure-button--default"
onClick={openServiceConfigModal}
>
Configure ({enabledSignals}/{totalSupportedSignals})
</Button>
) : (
<Button
type="primary"
className="configure-button configure-button--primary"
onClick={openServiceConfigModal}
>
Enable Service
</Button>
))}
</div>
</div>
<div className="service-details__overview">
<MarkdownRenderer
variables={{}}
markdownContent={serviceDetailsData?.overview}
/>
</div>
<div className="service-details__tabs">
<Tabs items={tabItems} />
</div>
{isConfigureServiceModalOpen && (
<ConfigureServiceModal
isOpen
onClose={(): void => setIsConfigureServiceModalOpen(false)}
serviceName={serviceDetailsData.title}
serviceId={serviceId || ''}
cloudAccountId={cloudAccountId || ''}
initialConfig={serviceDetailsData.config}
supportedSignals={serviceDetailsData.supported_signals || {}}
/>
)}
</div>
);
}
export default ServiceDetails;

View File

@@ -0,0 +1,75 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import Spinner from 'components/Spinner';
import { useGetAccountServices } from 'hooks/integration/aws/useGetAccountServices';
import useUrlQuery from 'hooks/useUrlQuery';
import ServiceItem from './ServiceItem';
interface ServicesListProps {
cloudAccountId: string;
filter: 'all_services' | 'enabled' | 'available';
}
function ServicesList({
cloudAccountId,
filter,
}: ServicesListProps): JSX.Element {
const urlQuery = useUrlQuery();
const navigate = useNavigate();
const { data: services = [], isLoading } = useGetAccountServices(
cloudAccountId,
);
const activeService = urlQuery.get('service');
const handleActiveService = useCallback(
(serviceId: string): void => {
const latestUrlQuery = new URLSearchParams(window.location.search);
latestUrlQuery.set('service', serviceId);
navigate({ search: latestUrlQuery.toString() });
},
[navigate],
);
const filteredServices = useMemo(() => {
if (filter === 'all_services') {
return services;
}
return services.filter((service) => {
const isEnabled =
service?.config?.logs?.enabled || service?.config?.metrics?.enabled;
return filter === 'enabled' ? isEnabled : !isEnabled;
});
}, [services, filter]);
useEffect(() => {
if (activeService || !services?.length) {
return;
}
handleActiveService(services[0].id);
}, [services, activeService, handleActiveService]);
if (isLoading) {
return <Spinner size="large" height="25vh" />;
}
if (!services) {
return <div>No services found</div>;
}
return (
<div className="services-list">
{filteredServices.map((service) => (
<ServiceItem
key={service.id}
service={service}
onClick={handleActiveService}
isActive={service.id === activeService}
/>
))}
</div>
);
}
export default ServicesList;

View File

@@ -1,8 +1,4 @@
.services-tabs {
display: flex;
flex-direction: column;
height: calc(100% - 54px); /* 54px is the height of the header */
.ant-tabs-tab {
font-family: 'Inter';
padding: 16px 4px 14px;
@@ -22,60 +18,21 @@
background: var(--primary-background);
}
}
.services-section {
display: flex;
flex: 1;
min-height: 0;
gap: 10px;
&__sidebar {
width: 240px;
border-right: 1px solid var(--l2-border);
height: 100%;
width: 16%;
padding: 0 16px;
}
&__content {
flex: 1;
height: 100%;
width: 84%;
padding: 16px;
}
}
.service-details-loading,
.services-list-loading {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
padding: 12px;
.service-details-loading-item {
width: 100%;
height: 100%;
background-color: var(--muted);
}
}
.services-list-empty-message {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
color: var(--l2-foreground);
font-size: 14px;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.empty-state-svg {
height: 20px;
width: 20px;
}
}
.services-filter {
padding: 12px;
padding: 16px 0;
.ant-select-selector {
background-color: var(--l3-background) !important;
border: 1px solid var(--l1-border) !important;
@@ -89,111 +46,6 @@
}
}
.aws-services-list-view {
height: 100%;
.aws-services-list-view-sidebar {
width: 240px;
height: 100%;
border-right: 1px solid var(--l3-background);
padding: 12px;
.aws-services-list-view-sidebar-content {
display: flex;
flex-direction: column;
gap: 8px;
.aws-services-enabled {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.aws-services-not-enabled {
display: flex;
flex-direction: column;
gap: 8px;
}
.aws-services-list-view-sidebar-content-header {
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px; /* 163.636% */
letter-spacing: 0.44px;
text-transform: uppercase;
}
.aws-services-list-view-sidebar-content-item-empty-message {
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 163.636% */
letter-spacing: 0.44px;
}
.aws-services-list-view-sidebar-content-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
.aws-services-list-view-sidebar-content-item-icon {
width: 20px;
height: 20px;
}
.aws-services-list-view-sidebar-content-item-title {
color: var(--l1-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
}
&:hover {
color: var(--l1-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
}
&.active {
color: var(--l1-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
background-color: var(--l3-background);
.aws-services-list-view-sidebar-content-item-title {
color: var(--l1-foreground);
}
}
}
}
}
.aws-services-list-view-main {
flex: 1;
padding: 12px;
}
}
.service-item {
display: flex;
gap: 12px;
@@ -208,22 +60,20 @@
}
&.active {
background-color: var(--l3-background);
background-color: var(--bg-ink-100); /* keep: no semantic equivalent */
}
&__icon-wrapper {
height: 32px;
width: 32px;
height: 40px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--l3-background);
border: 1px solid var(--l1-border);
border-radius: 4px;
.service-item__icon {
width: 16px;
height: 16px;
object-fit: contain;
width: 24px;
height: 24px;
}
}
&__title {
@@ -240,13 +90,11 @@
display: flex;
flex-direction: column;
gap: 10px;
&__title-bar {
display: flex;
justify-content: space-between;
align-items: center;
height: 48px;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
.service-details__details-title {
@@ -257,7 +105,6 @@
letter-spacing: -0.07px;
text-align: left;
}
.service-details__right-actions {
display: flex;
align-items: center;
@@ -273,30 +120,19 @@
border-radius: 2px;
line-height: normal;
&--connected {
border: 1px solid
color-mix(in srgb, var(--success-background) 10%, transparent);
background: color-mix(in srgb, var(--success-background) 10%, transparent);
color: var(--callout-success-title);
border: 1px solid color-mix(in srgb, var(--bg-forest-500) 10%, transparent);
background: color-mix(in srgb, var(--bg-forest-500) 10%, transparent);
color: var(--bg-forest-400);
}
&--stale-data {
background: color-mix(
in srgb,
var(--warning-background-hover) 10%,
transparent
);
border: 1px solid
color-mix(in srgb, var(--warning-background-hover) 10%, transparent);
color: var(--callout-warning-title);
background: color-mix(in srgb, var(--bg-amber-400) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 10%, transparent);
color: var(--bg-amber-400);
}
&--no-data {
border: 1px solid
color-mix(in srgb, var(--danger-background-hover) 10%, transparent);
background: color-mix(
in srgb,
var(--danger-background-hover) 10%,
transparent
);
color: var(--callout-error-description);
border: 1px solid color-mix(in srgb, var(--bg-cherry-400) 10%, transparent);
background: color-mix(in srgb, var(--bg-cherry-400) 10%, transparent);
color: var(--bg-cherry-400);
}
}
@@ -321,28 +157,21 @@
}
}
}
&__overview {
color: var(--l2-foreground);
font-size: 14px;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
width: 100%;
padding: 8px 12px;
width: 800px;
}
&__tabs {
padding: 0px 12px 12px 8px;
.ant-tabs {
&-ink-bar {
background-color: transparent;
}
&-nav {
padding: 0;
padding: 8px 0 18px;
&-wrap {
padding: 0;
}
@@ -461,3 +290,153 @@
}
}
}
.lightMode {
.services-tabs {
.ant-tabs-tab {
&.ant-tabs-tab-active {
.ant-tabs-tab-btn {
color: var(--l1-foreground);
}
}
}
}
.services-filter {
.ant-select-selector {
background-color: var(--l1-background) !important;
border-color: var(--l1-border) !important;
color: var(--l1-foreground) !important;
}
.ant-select-arrow {
color: var(--l1-foreground);
}
}
.service-item {
&:not(:last-child) {
border-bottom: 1px solid var(--l1-border);
}
&.active {
background-color: var(--l3-background);
}
&__icon-wrapper {
background-color: var(--l1-background);
border-color: var(--l1-border);
}
&__title {
color: var(--l1-foreground);
}
}
.service-details {
&__title-bar {
border-bottom: 1px solid var(--l1-border);
.service-details__details-title {
color: var(--l1-foreground);
}
.configure-button {
color: var(--l1-foreground);
background: var(--l1-background);
border-color: var(--l1-border);
&:hover {
border-color: var(--l2-foreground);
color: var(--l1-foreground);
}
}
.service-status {
&--connected {
border: 1px solid color-mix(in srgb, var(--bg-forest-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-forest-500) 10%, transparent);
color: var(--bg-forest-500);
}
&--stale-data {
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 20%, transparent);
background: color-mix(in srgb, var(--bg-amber-400) 10%, transparent);
color: var(--bg-amber-500);
}
&--no-data {
border: 1px solid color-mix(in srgb, var(--bg-cherry-400) 20%, transparent);
background: color-mix(in srgb, var(--bg-cherry-400) 10%, transparent);
color: var(--bg-cherry-500);
}
}
}
&__overview {
color: var(--l1-foreground);
}
&__tabs {
.ant-tabs {
&-tab {
&-btn {
color: var(--l1-foreground) !important;
&[aria-selected='true'] {
color: var(--l1-foreground) !important;
}
}
&-active {
background: var(--l3-background);
}
}
&-nav-list {
border-color: var(--l1-border);
background: var(--l1-background);
}
}
}
.cloud-service {
&-dashboard-item {
&__title {
color: var(--l1-foreground);
}
}
&-data-collected {
&__table {
.ant-table {
border-color: var(--l1-border);
.ant-table-thead {
> tr > th {
color: var(--l1-foreground);
}
}
.ant-table-tbody {
> tr {
&:nth-child(odd),
&:hover > td {
background: var(--l1-background) !important;
}
> td {
color: var(--l1-foreground);
}
}
}
}
}
&__table-heading {
color: var(--l1-foreground);
}
}
}
}
}

View File

@@ -0,0 +1,124 @@
import { useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import type { SelectProps, TabsProps } from 'antd';
import { Select, Tabs } from 'antd';
import { getAwsServices } from 'api/integration/aws';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useUrlQuery from 'hooks/useUrlQuery';
import { ChevronDown } from 'lucide-react';
import ServiceDetails from './ServiceDetails';
import ServicesList from './ServicesList';
import './ServicesTabs.style.scss';
export enum ServiceFilterType {
ALL_SERVICES = 'all_services',
ENABLED = 'enabled',
AVAILABLE = 'available',
}
interface ServicesFilterProps {
cloudAccountId: string;
onFilterChange: (value: ServiceFilterType) => void;
}
function ServicesFilter({
cloudAccountId,
onFilterChange,
}: ServicesFilterProps): JSX.Element | null {
const { data: services, isLoading } = useQuery(
[REACT_QUERY_KEY.AWS_SERVICES, cloudAccountId],
() => getAwsServices(cloudAccountId),
);
const { enabledCount, availableCount } = useMemo(() => {
if (!services) {
return { enabledCount: 0, availableCount: 0 };
}
return services.reduce(
(acc, service) => {
const isEnabled =
service?.config?.logs?.enabled || service?.config?.metrics?.enabled;
return {
enabledCount: acc.enabledCount + (isEnabled ? 1 : 0),
availableCount: acc.availableCount + (isEnabled ? 0 : 1),
};
},
{ enabledCount: 0, availableCount: 0 },
);
}, [services]);
const selectOptions: SelectProps['options'] = useMemo(
() => [
{ value: 'all_services', label: `All Services (${services?.length || 0})` },
{ value: 'enabled', label: `Enabled (${enabledCount})` },
{ value: 'available', label: `Available (${availableCount})` },
],
[services, enabledCount, availableCount],
);
if (isLoading) {
return null;
}
if (!services?.length) {
return null;
}
return (
<div className="services-filter">
<Select
style={{ width: '100%' }}
defaultValue={ServiceFilterType.ALL_SERVICES}
options={selectOptions}
className="services-sidebar__select"
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
onChange={onFilterChange}
/>
</div>
);
}
function ServicesSection(): JSX.Element {
const urlQuery = useUrlQuery();
const cloudAccountId = urlQuery.get('cloudAccountId') || '';
const [activeFilter, setActiveFilter] = useState<
'all_services' | 'enabled' | 'available'
>('all_services');
return (
<div className="services-section">
<div className="services-section__sidebar">
<ServicesFilter
cloudAccountId={cloudAccountId}
onFilterChange={setActiveFilter}
/>
<ServicesList cloudAccountId={cloudAccountId} filter={activeFilter} />
</div>
<div className="services-section__content">
<ServiceDetails />
</div>
</div>
);
}
function ServicesTabs(): JSX.Element {
const tabItems: TabsProps['items'] = [
{
key: 'services',
label: 'Services For Integration',
children: <ServicesSection />,
},
];
return (
<div className="services-tabs">
<Tabs defaultActiveKey="services" items={tabItems} />
</div>
);
}
export default ServicesTabs;

View File

@@ -0,0 +1,161 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { server } from 'mocks-server/server';
import { rest, RestRequest } from 'msw'; // Import RestRequest for req.json() typing
import { UpdateServiceConfigPayload } from '../types';
import { accountsResponse, CLOUD_ACCOUNT_ID, initialBuckets } from './mockData';
import {
assertGenericModalElements,
assertS3SyncSpecificElements,
renderModal,
} from './utils';
// --- MOCKS ---
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(() => ({
get: jest.fn((paramName: string) => {
if (paramName === 'cloudAccountId') {
return CLOUD_ACCOUNT_ID;
}
return null;
}),
})),
}));
// --- TEST SUITE ---
describe('ConfigureServiceModal for S3 Sync service', () => {
jest.setTimeout(10000);
beforeEach(() => {
server.use(
rest.get(
'http://localhost/api/v1/cloud-integrations/aws/accounts',
(req, res, ctx) => res(ctx.json(accountsResponse)),
),
);
});
it('should render with logs collection switch and bucket selectors (no buckets initially selected)', async () => {
act(() => {
renderModal({}); // No initial S3 buckets, defaults to 's3sync' serviceId
});
await assertGenericModalElements(); // Use new generic assertion
await assertS3SyncSpecificElements({}); // Use new S3-specific assertion
});
it('should render with logs collection switch and bucket selectors (some buckets initially selected)', async () => {
act(() => {
renderModal(initialBuckets); // Defaults to 's3sync' serviceId
});
await assertGenericModalElements(); // Use new generic assertion
await assertS3SyncSpecificElements(initialBuckets); // Use new S3-specific assertion
});
it('should enable save button after adding a new bucket via combobox', async () => {
act(() => {
renderModal(initialBuckets); // Defaults to 's3sync' serviceId
});
await assertGenericModalElements();
await assertS3SyncSpecificElements(initialBuckets);
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
const targetCombobox = screen.getAllByRole('combobox')[0];
const newBucketName = 'a-newly-added-bucket';
act(() => {
fireEvent.change(targetCombobox, { target: { value: newBucketName } });
fireEvent.keyDown(targetCombobox, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
});
});
await waitFor(() => {
expect(screen.getByLabelText(newBucketName)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
});
});
it('should send updated bucket configuration on save', async () => {
let capturedPayload: UpdateServiceConfigPayload | null = null;
const mockUpdateConfigUrl =
'http://localhost/api/v1/cloud-integrations/aws/services/s3sync/config';
// Override POST handler specifically for this test to capture payload
server.use(
rest.post(mockUpdateConfigUrl, async (req: RestRequest, res, ctx) => {
capturedPayload = await req.json();
return res(ctx.status(200), ctx.json({ message: 'Config updated' }));
}),
);
act(() => {
renderModal(initialBuckets); // Defaults to 's3sync' serviceId
});
await assertGenericModalElements();
await assertS3SyncSpecificElements(initialBuckets);
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
const newBucketName = 'another-new-bucket';
// As before, targeting the first combobox, assumed to be for 'ap-south-1'.
const targetCombobox = screen.getAllByRole('combobox')[0];
act(() => {
fireEvent.change(targetCombobox, { target: { value: newBucketName } });
fireEvent.keyDown(targetCombobox, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
});
});
await waitFor(() => {
expect(screen.getByLabelText(newBucketName)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
act(() => {
fireEvent.click(screen.getByRole('button', { name: /save/i }));
});
});
await waitFor(() => {
expect(capturedPayload).not.toBeNull();
});
expect(capturedPayload).toEqual({
cloud_account_id: CLOUD_ACCOUNT_ID,
config: {
logs: {
enabled: true,
s3_buckets: {
'us-east-2': ['first-bucket', 'second-bucket'], // Existing buckets
'ap-south-1': [newBucketName], // Newly added bucket for the first region
},
},
metrics: {},
},
});
});
it('should not render S3 bucket region selector UI for services other than s3sync', async () => {
const otherServiceId = 'cloudwatch';
act(() => {
renderModal({}, otherServiceId);
});
await assertGenericModalElements();
await waitFor(() => {
expect(
screen.queryByRole('heading', { name: /select s3 buckets by region/i }),
).not.toBeInTheDocument();
const regions = accountsResponse.data.accounts[0]?.config?.regions || [];
regions.forEach((region) => {
expect(
screen.queryByText(`Enter S3 bucket names for ${region}`),
).not.toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,44 @@
import { IConfigureServiceModalProps } from '../ConfigureServiceModal';
const CLOUD_ACCOUNT_ID = '123456789012';
const initialBuckets = { 'us-east-2': ['first-bucket', 'second-bucket'] };
const accountsResponse = {
status: 'success',
data: {
accounts: [
{
id: 'a1b2c3d4-e5f6-7890-1234-567890abcdef',
cloud_account_id: CLOUD_ACCOUNT_ID,
config: {
regions: ['ap-south-1', 'ap-south-2', 'us-east-1', 'us-east-2'],
},
status: {
integration: {
last_heartbeat_ts_ms: 1747114366214,
},
},
},
],
},
};
const defaultModalProps: Omit<IConfigureServiceModalProps, 'initialConfig'> = {
isOpen: true,
onClose: jest.fn(),
serviceName: 'S3 Sync',
serviceId: 's3sync',
cloudAccountId: CLOUD_ACCOUNT_ID,
supportedSignals: {
logs: true,
metrics: false,
},
};
export {
accountsResponse,
CLOUD_ACCOUNT_ID,
defaultModalProps,
initialBuckets,
};

View File

@@ -0,0 +1,78 @@
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import ConfigureServiceModal from '../ConfigureServiceModal';
import { accountsResponse, defaultModalProps } from './mockData';
/**
* Renders the ConfigureServiceModal with specified S3 bucket initial configurations.
*/
const renderModal = (
initialConfigLogsS3Buckets: Record<string, string[]> = {},
serviceId = 's3sync',
): RenderResult => {
const initialConfig = {
logs: { enabled: true, s3_buckets: initialConfigLogsS3Buckets },
metrics: { enabled: false },
};
return render(
<MockQueryClientProvider>
<ConfigureServiceModal
{...defaultModalProps}
serviceId={serviceId}
initialConfig={initialConfig}
/>
</MockQueryClientProvider>,
);
};
/**
* Asserts that generic UI elements of the modal are present.
*/
const assertGenericModalElements = async (): Promise<void> => {
await waitFor(() => {
expect(screen.getByRole('switch')).toBeInTheDocument();
expect(screen.getByText(/log collection/i)).toBeInTheDocument();
expect(
screen.getByText(
/to ingest logs from your aws services, you must complete several steps/i,
),
).toBeInTheDocument();
});
};
/**
* Asserts the state of S3 bucket selectors for each region, specific to S3 Sync.
*/
const assertS3SyncSpecificElements = async (
expectedBucketsByRegion: Record<string, string[]> = {},
): Promise<void> => {
const regions = accountsResponse.data.accounts[0]?.config?.regions || [];
await waitFor(() => {
expect(
screen.getByRole('heading', { name: /select s3 buckets by region/i }),
).toBeInTheDocument();
regions.forEach((region) => {
expect(screen.getByText(region)).toBeInTheDocument();
const bucketsForRegion = expectedBucketsByRegion[region] || [];
if (bucketsForRegion.length > 0) {
bucketsForRegion.forEach((bucket) => {
expect(screen.getByText(bucket)).toBeInTheDocument();
});
} else {
expect(
screen.getByText(`Enter S3 bucket names for ${region}`),
).toBeInTheDocument();
}
});
});
};
export {
assertGenericModalElements,
assertS3SyncSpecificElements,
renderModal,
};

View File

@@ -1,40 +1,90 @@
import { ServiceData } from 'container/Integrations/types';
interface Service {
id: string;
title: string;
icon: string;
config: AWSServiceConfig;
config: ServiceConfig;
}
interface S3BucketsByRegion {
[region: string]: string[];
interface Dashboard {
id: string;
url: string;
title: string;
description: string;
image: string;
}
interface LogField {
name: string;
path: string;
type: string;
}
interface Metric {
name: string;
type: string;
unit: string;
}
interface ConfigStatus {
enabled: boolean;
}
interface DataStatus {
last_received_ts_ms: number;
last_received_from: string;
}
interface S3BucketsByRegion {
[region: string]: string[];
}
interface LogsConfig extends ConfigStatus {
s3_buckets?: S3BucketsByRegion;
}
interface AWSServiceConfig {
interface ServiceConfig {
logs: LogsConfig;
metrics: ConfigStatus;
s3_sync?: LogsConfig;
}
interface IServiceStatus {
logs: DataStatus | null;
metrics: DataStatus | null;
}
interface SupportedSignals {
metrics: boolean;
logs: boolean;
}
interface ServiceData {
id: string;
title: string;
icon: string;
overview: string;
supported_signals: SupportedSignals;
assets: {
dashboards: Dashboard[];
};
data_collected: {
logs?: LogField[];
metrics: Metric[];
};
config?: ServiceConfig;
status?: IServiceStatus;
}
interface ServiceDetailsResponse {
status: 'success';
data: ServiceData;
}
export interface AWSCloudAccountConfig {
interface CloudAccountConfig {
regions: string[];
}
export interface IntegrationStatus {
interface IntegrationStatus {
last_heartbeat_ts_ms: number;
}
@@ -45,9 +95,8 @@ interface AccountStatus {
interface CloudAccount {
id: string;
cloud_account_id: string;
config: AWSCloudAccountConfig;
config: CloudAccountConfig;
status: AccountStatus;
providerAccountId: string;
}
interface CloudAccountsData {
@@ -84,13 +133,15 @@ interface UpdateServiceConfigResponse {
}
export type {
AWSServiceConfig,
CloudAccount,
CloudAccountsData,
IServiceStatus,
S3BucketsByRegion,
Service,
ServiceConfig,
ServiceData,
ServiceDetailsResponse,
SupportedSignals,
UpdateServiceConfigPayload,
UpdateServiceConfigResponse,
};

View File

@@ -0,0 +1,64 @@
import { I18nextProvider } from 'react-i18next';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
IntegrationType,
RequestIntegrationBtn,
} from 'pages/Integrations/RequestIntegrationBtn';
import i18n from 'ReactI18';
describe('Request AWS integration', () => {
it('should render the request integration button', async () => {
let capturedPayload: any;
server.use(
rest.post('http://localhost/api/v1/event', async (req, res, ctx) => {
capturedPayload = await req.json();
return res(
ctx.status(200),
ctx.json({
statusCode: 200,
error: null,
payload: 'Event Processed Successfully',
}),
);
}),
);
act(() => {
render(
<I18nextProvider i18n={i18n}>
<RequestIntegrationBtn type={IntegrationType.AWS_SERVICES} />{' '}
</I18nextProvider>,
);
});
expect(
screen.getByText(
/can't find what youre looking for\? request more integrations/i,
),
).toBeInTheDocument();
await act(() => {
fireEvent.change(screen.getByPlaceholderText(/Enter integration name/i), {
target: { value: 's3 sync' },
});
const submitButton = screen.getByRole('button', { name: /submit/i });
expect(submitButton).toBeEnabled();
fireEvent.click(submitButton);
});
expect(capturedPayload.eventName).toBeDefined();
expect(capturedPayload.attributes).toBeDefined();
expect(capturedPayload.eventName).toBe('AWS service integration requested');
expect(capturedPayload.attributes).toEqual({
screen: 'AWS integration details',
integration: 's3 sync',
deployment_url: 'localhost',
user_email: null,
});
});
});

View File

@@ -14,6 +14,7 @@ import { IUser } from 'providers/App/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import { ROUTING_POLICIES_ROUTE } from './constants';
import { RoutingPolicyBannerProps } from './types';
@@ -387,7 +388,7 @@ export function NotificationChannelsNotFoundContent({
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
window.open(ROUTES.CHANNELS_NEW, '_blank');
openInNewTab(ROUTES.CHANNELS_NEW);
}}
>
here.

View File

@@ -15,6 +15,7 @@ import { AlertDef, Labels } from 'types/api/alerts/def';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import { openInNewTab } from 'utils/navigation';
import { popupContainer } from 'utils/selectPopupContainer';
import ChannelSelect from './ChannelSelect';
@@ -87,7 +88,7 @@ function BasicInfo({
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
ruleId: isNewRule ? 0 : alertDef?.id,
});
window.open(ROUTES.CHANNELS_NEW, '_blank');
openInNewTab(ROUTES.CHANNELS_NEW);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hasLoggedEvent = useRef(false);

View File

@@ -49,6 +49,7 @@ import {
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { openInNewTab } from 'utils/navigation';
import { v4 as uuidv4 } from 'uuid';
import ClusterEvents from '../../EntityDetailsUtils/EntityEvents';
@@ -414,10 +415,7 @@ function ClusterDetails({
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
window.open(
`${window.location.origin}${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`,
'_blank',
);
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
const compositeQuery = {
...initialQueryState,
@@ -436,10 +434,7 @@ function ClusterDetails({
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
window.open(
`${window.location.origin}${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`,
'_blank',
);
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}
};

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