Compare commits

...

26 Commits

Author SHA1 Message Date
Vinícius Lourenço
220b3e547c fix(column-view): remove unused file 2026-04-22 12:50:50 -03:00
Vinícius Lourenço
fbb14bbf83 fix(tanstack-header-row): use our version of popover 2026-04-22 10:40:05 -03:00
Vinícius Lourenço
67d1b7bc35 Merge branch 'main' into refactor/table-component 2026-04-22 10:36:54 -03:00
Vinicius Lourenço
c64cd0b692 fix(alert-rules): disable clickhouse/prompql for anomaly detection (#11038)
* refactor(alert-rules): disable clickhouse/prompql for anomaly detection

* fix(query-section): try fix issue with timing/useEffect delay

* fix(getUploadChartData): ensure there's no way to crash due to bad sync state

* fix(get-upload-chart-data): render as empty instead of skip series to be more safer
2026-04-22 13:01:42 +00:00
Vikrant Gupta
484b22c12a chore(codeowner): add @therealpandey as codeowner (#11055)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-04-22 09:51:02 +00:00
Pandey
5b599babd5 refactor(ruler): migrate frontend to generated API clients for rules and downtime schedules (#10968)
* refactor(frontend): migrate deleteDowntimeSchedule to generated client

Replace useDeleteDowntimeSchedule with useDeleteDowntimeScheduleByID
from the generated API client. Update PlannedDowntime.tsx and
PlannedDowntimeutils.ts to use pathParams pattern.

* refactor(frontend): migrate all downtime schedule APIs to generated clients

Replace hand-written getAllDowntimeSchedules, createDowntimeSchedule,
updateDowntimeSchedule with generated useListDowntimeSchedules,
createDowntimeSchedule, updateDowntimeScheduleByID from Orval clients.

Update all type references from DowntimeSchedules/Recurrence to
RuletypesGettablePlannedMaintenanceDTO/RuletypesRecurrenceDTO.
Update tests to mock generated hooks.

* refactor(frontend): migrate testAlert API to generated testRule client

Replace direct testAlertApi call in FormAlertRules with generated
testRule function. Update useTestAlertRule hook to use generated client
while maintaining backward-compatible wrapper types for CreateAlertV2
context consumers.

* refactor(frontend): migrate createAlertRule API to generated createRule client

Update useCreateAlertRule hook to use generated createRule function.
Maintain backward-compatible SuccessResponse wrapper for CreateAlertV2
context consumers.

* refactor(frontend): migrate rules APIs to generated clients

Replace hand-written getAll, get, delete, patch imports with generated
listRules, getRuleByID, deleteRuleByID, patchRuleByID from Orval
clients. Use adapter wrappers in consumers that depend on the old
SuccessResponse envelope to minimize cascading type changes.

* refactor(frontend): extract getAll adapter to shared api/alerts/getAll.ts

Remove duplicated inline getAll adapter from ListAlertRules and
AlertRules components. Replace the old hand-written getAll.ts with a
thin adapter over the generated listRules client.

* refactor(frontend): switch to generated react-query hooks directly

Replace adapter wrappers with direct usage of generated hooks:
- useListRules() in ListAlertRules, AlertRules
- useGetRuleByID() in AlertDetails hooks, EditRules
- useCreateRule re-exported as useCreateAlertRule
- useTestRule re-exported as useTestAlertRule
- Update consumers to access data.data instead of data.payload
- Remove SuccessResponse envelope wrappers

* fix(frontend): fix accidental rename, cache fragmentation, and sort mutation

- Revert setRuletypesRecurrenceDTOType back to setRecurrenceType (find-replace artifact)
- Remove duplicate isError condition in AlertDetails guard
- Replace manual useQuery(listRules) with useListRules in PlannedDowntime and hooks
- Fix showErrorNotification import path in PlannedDowntimeutils
- Use spread before sort to avoid mutating react-query cache

* refactor(frontend): delete dead re-export wrappers for rules hooks

Remove api/alerts/getAll.ts, hooks/alerts/useCreateAlertRule.ts, and
hooks/alerts/useTestAlertRule.ts — all were single-line re-exports of
generated hooks with zero unique logic. Update the one consumer
(CreateAlertV2 context) and its test to import directly from the
generated services.

* refactor(frontend): delete dead API adapters, migrate save to generated hooks

Delete 14 unused hand-written API adapter files for alerts and planned
downtime. Migrate the 3 consumers of save.ts to call createRule and
updateRuleByID from generated services directly, removing the
create/update routing layer.

* refactor(frontend): migrate updateAlertRule to generated useUpdateRuleByID

Delete hooks/alerts/useUpdateAlertRule.ts and api/alerts/updateAlertRule.ts.
Migrate CreateAlertV2 context to use useUpdateRuleByID from generated
services. Expose ruleId on context so Footer can pass it to the mutation.

* refactor(frontend): integrate convertToApiError and error modal across rules/downtime

Replace all raw (error as any)?.message casts and generic "Something
went wrong" strings with proper error handling using convertToApiError
and showErrorModal. Query errors use convertToApiError for message
extraction, mutation errors show the rich error modal.

* fix(frontend): remove downtime ID number coercion, delete routing adapter

Delete DowntimeScheduleUpdatePayload and createEditDowntimeSchedule —
the form now calls createDowntimeSchedule/updateDowntimeScheduleByID
directly, keeping the string ID from the generated schema end-to-end
instead of coercing to number.

* fix(frontend): handle null rules from API to prevent infinite spinner

The generated schema allows rules?: ...[] | null. When the API returns
null, the component treated it as still-loading. Normalize null to []
via nullish coalescing and gate loading on data presence, not rules
truthiness.

* fix(frontend): update PlannedDowntime test mock to use generated rules hook

Replace deleted api/alerts/getAll mock with api/generated/services/rules
mock returning useListRules shape.

* fix(frontend): mock useErrorModal in Footer test to fix provider error

Footer now calls useErrorModal which requires ErrorModalProvider. Add
mock to the test since it imports render from @testing-library/react
directly instead of the custom test-utils wrapper.

* refactor(frontend): adopt IngestionSettings date pattern in PlannedDowntime

Replace all (x as unknown) as Date/string double casts with the
established codebase pattern: new Date() for writes to API, dayjs()
for reads. Removes String() wraps in PlannedDowntimeList in favor of
dayjs().toISOString(). Tests use new Date() instead of double casts.

* refactor(frontend): adopt generated rule types, drop as any casts

Switch reader components (ListAlertRules, Home/AlertRules, AlertDetails,
EditRules) to use RuletypesGettableRuleDTO directly from the generated
OpenAPI client. Writer callers cast once via `as unknown as
RuletypesPostableRuleDTO` at the API boundary. Also fixes a poll bug in
ListAlert.tsx that read the old refetchData.payload shape.

* test(frontend): satisfy newly-required condition/ruleType in filterAlerts mocks

After merging the backend schema tightening, RuletypesGettableRuleDTO
requires `condition` and `ruleType`. Update the test fixtures to
include placeholder values so they type-check against the stricter
shape.

* refactor(frontend): retire GettableAlert in favor of generated type

Status and AlertHeader consume RuletypesGettableRuleDTO directly from
the generated OpenAPI client. Delete types/api/alerts/{get,getAll,patch}.ts
now that nothing imports from them.

* fix(frontend): satisfy newly-required condition and timezone fields

After merging the ruler schema tightening, RuletypesRuleConditionDTO
now requires compositeQuery/op/matchType, AlertCompositeQuery requires
queries/panelType/queryType, and Schedule requires timezone. Update
the filterAlerts test fixtures to populate the nested condition, and
default timezone to an empty string in the downtime form payload so
the generated type is satisfied (backend still validates timezone is
non-empty).

* refactor(frontend): cast timezone to string instead of empty-string default

Antd's required validation on the timezone field blocks submission when
empty, so values.timezone is guaranteed non-undefined by the time
saveHanlder runs. `?? ''` silently substituted a value the backend
rejects; `as string` matches the existing pattern used for the same
field at initialization (line 299) and surfaces drift as a type error
instead of a 400.

* refactor(frontend): key the alert-not-found message on HTTP 404

Drop the brittle comparison against the raw Go "sql: no rows in result
set" string. With the backend now wrapping sql.ErrNoRows as
TypeNotFound, GET /api/v1/rules/{id} returns HTTP 404 on a missing ID
(matching the existing OpenAPI contract). Use APIError.getHttpStatusCode
instead, and delete errorMessageReceivedFromBackend.

* refactor(frontend): show backend error message directly on rule-fetch failure

Drop the improvedErrorMessage override that hid the backend's message
behind a generic "does not exist" string. With the backend now
returning a descriptive "rule with ID: <id> does not exist" on 404, the
APIError message is the right thing to render. Inline the remaining
button label and delete the now-empty constants module.

* refactor(frontend): drop duplicative AlertDetailsStatusRenderer

The inner component re-derived alertRuleDetails from the same response
as the parent, but via the stale .payload.data path that didn't exist
on the generated GetRuleByID200 shape — so it was silently feeding
undefined to AlertHeader. The any-typed data prop hid the error from
TypeScript during the migration to generated hooks.

The parent already derives alertRuleDetails correctly and gates on
isError/isLoading before the main render, so the inner component's
loading/error branches were unreachable. Inline AlertHeader directly.

* refactor(frontend): drop optional chains on fields now required by generated types

* refactor(frontend): drop Record cast in PlannedDowntime recurrence spread

* refactor(frontend): type formData seed as Partial in PlannedDowntimeForm

* refactor(frontend): type defautlInitialValues as Partial directly

* refactor(frontend): drop as unknown from copyAlert cast in clone flow

* refactor(frontend): add dataSourceForAlertType helper keyed on generated enum

* refactor(frontend): align defautlInitialValues createdBy with createdAt as undefined

* refactor(frontend): surface BE error via convertToApiError on save alert paths

* refactor(frontend): adopt renamed RuletypesRuleDTO and created/updated field names

* fix(frontend): re-hydrate downtime edit drawer when alerts list arrives async

The formatedInitialValues memo depended only on initialValues, so when the
edit drawer opened before useListRules resolved, getAlertOptionsFromIds
returned an empty array and the form's alertRules state stayed empty —
even though the sidecar AlertRuleTags rendered a tag via selectedTags.
Submitting then sent alertIds: [] or stale values, which is why the
"edit downtime → remove silenced alert → save → delete" cleanup flow
never actually cleared the server-side association.

Adds alertOptions to the deps so the memo recomputes once the rules
list arrives, letting the useEffect below re-hydrate both selectedTags
and the form state with the correct tags.

* chore(frontend): stop logging validateFields rejection from downtime form

Form.validateFields() rejects with {values, errorFields, outOfDate}
whenever a required field is empty (e.g. Ends on). antd already renders
the field-level error inline, so console.error adds no user-facing
value and pollutes Sentry / devtools with routine typos.

Replaces the try/catch+log with a noop .catch().

* refactor(frontend): add typed converters between local alert types and generated DTOs

* refactor(frontend): add patchRulePartial wrapper for Partial rule patches

* refactor(frontend): replace inline alert DTO casts with typed converters

* refactor(frontend): inline single-use handleMutationError in alert hooks

Replaces four call sites in AlertDetails/hooks.tsx with the same
showErrorModal(convertToApiError(...)) expression directly; the helper
added no semantics beyond the type cast it wrapped. The two pure
onError arrows end up byte-identical, so the delete hook carries an
eslint-disable for sonarjs/no-identical-functions (same pattern already
used in providers/QueryBuilder.tsx).

* chore: parity with expected enum values of match type and compare operator

* chore: remove unused Typography import

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-04-22 09:07:47 +00:00
primus-bot[bot]
3080c3c493 chore(release): bump to v0.120.0 (#11052)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-04-22 07:11:54 +00:00
Vinicius Lourenço
b0e9fbe24f test(organization-traces): try unflaky both tests (#11046)
* test(organization-traces): try unflaky both tests

* fix: improve test speeds

* fix: increase timeout

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-04-22 07:11:25 +00:00
Abhi kumar
aeb71469d9 fix: minor tooltip css fix (#11053) 2026-04-22 07:04:29 +00:00
Ashwin Bhatkal
1ff9a748ee refactor: rename selectedDashboard to dashboardData (#11040)
Renames the `selectedDashboard` store field (and related setters/getters
`setSelectedDashboard`, `getSelectedDashboard`) to `dashboardData` across
the frontend. Also renames incidental locals (`updatedSelectedDashboard`,
`mockSelectedDashboard`, and the ExportPanel's local `selectedDashboardId`).
2026-04-22 05:58:21 +00:00
Vinícius Lourenço
755231c200 fix(resizing): better resizing support 2026-04-20 14:44:13 -03:00
Vinícius Lourenço
35a14617a7 fix(sonner): removed old dependency 2026-04-20 09:06:52 -03:00
Vinícius Lourenço
a202e6d461 Merge branch 'main' into refactor/table-component 2026-04-20 08:58:05 -03:00
Vinícius Lourenço
a581219957 fix(tanstack): more cleanup 2026-04-15 21:36:39 -03:00
Vinícius Lourenço
538edd06d7 refactor(table): removed deprecated api 2026-04-15 21:25:57 -03:00
Vinícius Lourenço
f94456e249 refactor(table): more cleanup 2026-04-15 20:07:28 -03:00
Vinícius Lourenço
bf49db3501 refactor(table): cleanup and fixes related to column and old preferences of logs 2026-04-15 17:35:59 -03:00
Vinícius Lourenço
0acb1e0bed docs(tanstacktable): add more docs about usage 2026-04-14 22:29:58 -03:00
Vinícius Lourenço
5861718972 refactor(tanstack-table): refine component based on infra monitoring 2026-04-14 22:23:42 -03:00
Vinícius Lourenço
7d8c8e3d7d fix(row): missing active styles 2026-04-13 19:37:13 -03:00
Vinícius Lourenço
5eae936ab4 fix(tests): use find by text 2026-04-13 19:17:34 -03:00
Vinícius Lourenço
091d61045a fix(styles): minor fixes on styles 2026-04-13 19:15:43 -03:00
Vinícius Lourenço
ed1217e5d0 chore(tests): cleanup tests and components 2026-04-13 18:57:30 -03:00
Vinícius Lourenço
0389b46836 refactor(table): optimize 2026-04-13 18:36:45 -03:00
Vinícius Lourenço
284d6f72d4 refactor(table): extract table to own component 2026-04-13 15:49:33 -03:00
Vinícius Lourenço
66abfa3be4 refactor(tanstack): move table to components & convert to css modules 2026-04-08 15:27:59 -03:00
177 changed files with 7834 additions and 4233 deletions

8
.github/CODEOWNERS vendored
View File

@@ -10,6 +10,7 @@
/frontend/src/container/OnboardingV2Container/AddDataSource/AddDataSource.tsx @makeavish
# CI
/deploy/ @therealpandey
.github @therealpandey
go.mod @therealpandey
@@ -108,6 +109,7 @@ go.mod @therealpandey
/pkg/modules/role/ @therealpandey
# IdentN Owners
/pkg/identn/ @therealpandey
/pkg/http/middleware/identn.go @therealpandey
@@ -115,6 +117,10 @@ go.mod @therealpandey
/tests/integration/ @therealpandey
# Flagger Owners
/pkg/flagger/ @therealpandey
# OpenAPI types generator
/frontend/src/api @SigNoz/frontend-maintainers
@@ -134,6 +140,7 @@ go.mod @therealpandey
/frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend
# Dashboard Widget Page
/frontend/src/pages/DashboardWidget/ @SigNoz/pulse-frontend
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
@@ -149,6 +156,7 @@ go.mod @therealpandey
/frontend/src/container/PublicDashboardContainer/ @SigNoz/pulse-frontend
## Dashboard Libs + Components
/frontend/src/lib/uPlotV2/ @SigNoz/pulse-frontend
/frontend/src/lib/dashboard/ @SigNoz/pulse-frontend
/frontend/src/lib/dashboardVariables/ @SigNoz/pulse-frontend

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.119.0
image: signoz/signoz:v0.120.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port
@@ -213,7 +213,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.2
image: signoz/signoz-otel-collector:v0.144.3
entrypoint:
- /bin/sh
command:
@@ -241,7 +241,7 @@ services:
replicas: 3
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.2
image: signoz/signoz-otel-collector:v0.144.3
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.119.0
image: signoz/signoz:v0.120.0
ports:
- "8080:8080" # signoz port
volumes:
@@ -139,7 +139,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.2
image: signoz/signoz-otel-collector:v0.144.3
entrypoint:
- /bin/sh
command:
@@ -167,7 +167,7 @@ services:
replicas: 3
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.2
image: signoz/signoz-otel-collector:v0.144.3
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.119.0}
image: signoz/signoz:${VERSION:-v0.120.0}
container_name: signoz
ports:
- "8080:8080" # signoz port
@@ -204,7 +204,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.2}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
container_name: signoz-otel-collector
entrypoint:
- /bin/sh
@@ -229,7 +229,7 @@ services:
- "4318:4318" # OTLP HTTP receiver
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.2}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
container_name: signoz-telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.119.0}
image: signoz/signoz:${VERSION:-v0.120.0}
container_name: signoz
ports:
- "8080:8080" # signoz port
@@ -132,7 +132,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.2}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
container_name: signoz-otel-collector
entrypoint:
- /bin/sh
@@ -157,7 +157,7 @@ services:
- "4318:4318" # OTLP HTTP receiver
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.2}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
container_name: signoz-telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -1,20 +0,0 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/create';
const create = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
const response = await axios.post('/rules', {
...props.data,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default create;

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
AlertRuleV2,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
export interface CreateAlertRuleResponse {
data: AlertRuleV2;
status: string;
}
const createAlertRule = async (
props: PostableAlertRuleV2,
): Promise<SuccessResponse<CreateAlertRuleResponse> | ErrorResponse> => {
const response = await axios.post(`/rules`, {
...props,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default createAlertRule;

View File

@@ -1,18 +0,0 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/delete';
const deleteAlerts = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
const response = await axios.delete(`/rules/${props.id}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data.rules,
};
};
export default deleteAlerts;

View File

@@ -1,16 +0,0 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/get';
const get = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
const response = await axios.get(`/rules/${props.id}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
};
export default get;

View File

@@ -1,24 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps } from 'types/api/alerts/getAll';
const getAll = async (): Promise<
SuccessResponse<PayloadProps> | ErrorResponse
> => {
try {
const response = await axios.get('/rules');
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data.rules,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getAll;

View File

@@ -1,29 +0,0 @@
import { AxiosAlertManagerInstance } from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import convertObjectIntoParams from 'lib/query/convertObjectIntoParams';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/getGroups';
const getGroups = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const queryParams = convertObjectIntoParams(props);
const response = await AxiosAlertManagerInstance.get(
`/alerts/groups?${queryParams}`,
);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getGroups;

View File

@@ -1,20 +0,0 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/patch';
const patch = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
const response = await axios.patch(`/rules/${props.id}`, {
...props.data,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default patch;

View File

@@ -0,0 +1,12 @@
import { patchRuleByID } from 'api/generated/services/rules';
import type { RuletypesPostableRuleDTO } from 'api/generated/services/sigNoz.schemas';
// why: patchRuleByID's generated body type is the full RuletypesPostableRuleDTO
// because the backend OpenAPI spec currently advertises PostableRule. The
// endpoint itself accepts any subset of fields. Until the backend introduces
// PatchableRule, this wrapper localizes the cast so callers stay typed.
export const patchRulePartial = (
id: string,
patch: Partial<RuletypesPostableRuleDTO>,
): ReturnType<typeof patchRuleByID> =>
patchRuleByID({ id }, patch as RuletypesPostableRuleDTO);

View File

@@ -1,20 +0,0 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/save';
const put = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
const response = await axios.put(`/rules/${props.id}`, {
...props.data,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default put;

View File

@@ -1,18 +0,0 @@
import { isEmpty } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/save';
import create from './create';
import put from './put';
const save = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
if (props.id && !isEmpty(props.id)) {
return put({ ...props });
}
return create({ ...props });
};
export default save;

View File

@@ -1,26 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/testAlert';
const testAlert = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post('/testRule', {
...props.data,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default testAlert;

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
export interface TestAlertRuleResponse {
data: {
alertCount: number;
message: string;
};
status: string;
}
const testAlertRule = async (
props: PostableAlertRuleV2,
): Promise<SuccessResponse<TestAlertRuleResponse> | ErrorResponse> => {
const response = await axios.post(`/testRule`, {
...props,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default testAlertRule;

View File

@@ -1,26 +0,0 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
export interface UpdateAlertRuleResponse {
data: string;
status: string;
}
const updateAlertRule = async (
id: string,
postableAlertRule: PostableAlertRuleV2,
): Promise<SuccessResponse<UpdateAlertRuleResponse> | ErrorResponse> => {
const response = await axios.put(`/rules/${id}`, {
...postableAlertRule,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default updateAlertRule;

View File

@@ -1,45 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { Dayjs } from 'dayjs';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { Recurrence } from './getAllDowntimeSchedules';
export interface DowntimeSchedulePayload {
name: string;
description?: string;
alertIds: string[];
schedule: {
timezone?: string;
startTime?: string | Dayjs;
endTime?: string | Dayjs;
recurrence?: Recurrence;
};
}
export interface PayloadProps {
status: string;
data: string;
}
const createDowntimeSchedule = async (
props: DowntimeSchedulePayload,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post('/downtime_schedules', {
...props,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default createDowntimeSchedule;

View File

@@ -1,19 +0,0 @@
import { useMutation, UseMutationResult } from 'react-query';
import axios from 'api';
export interface DeleteDowntimeScheduleProps {
id?: number;
}
export interface DeleteSchedulePayloadProps {
status: string;
data: string;
}
export const useDeleteDowntimeSchedule = (
props: DeleteDowntimeScheduleProps,
): UseMutationResult<DeleteSchedulePayloadProps, Error, number> =>
useMutation({
mutationKey: [props.id],
mutationFn: () => axios.delete(`/downtime_schedules/${props.id}`),
});

View File

@@ -1,51 +0,0 @@
import { useQuery, UseQueryResult } from 'react-query';
import axios from 'api';
import { AxiosError, AxiosResponse } from 'axios';
import { Option } from 'container/PlannedDowntime/PlannedDowntimeutils';
export type Recurrence = {
startTime?: string | null;
endTime?: string | null;
duration?: number | string | null;
repeatType?: string | Option | null;
repeatOn?: string[] | null;
};
type Schedule = {
timezone: string | null;
startTime: string | null;
endTime: string | null;
recurrence: Recurrence | null;
};
export interface DowntimeSchedules {
id: number;
name: string | null;
description: string | null;
schedule: Schedule | null;
alertIds: string[] | null;
createdAt: string | null;
createdBy: string | null;
updatedAt: string | null;
updatedBy: string | null;
kind: string | null;
}
export type PayloadProps = { data: DowntimeSchedules[] };
export const getAllDowntimeSchedules = async (
props?: GetAllDowntimeSchedulesPayloadProps,
): Promise<AxiosResponse<PayloadProps>> =>
axios.get('/downtime_schedules', { params: props });
export interface GetAllDowntimeSchedulesPayloadProps {
active?: boolean;
recurrence?: boolean;
}
export const useGetAllDowntimeSchedules = (
props?: GetAllDowntimeSchedulesPayloadProps,
): UseQueryResult<AxiosResponse<PayloadProps>, AxiosError> =>
useQuery<AxiosResponse<PayloadProps>, AxiosError>({
queryKey: ['getAllDowntimeSchedules', props],
queryFn: () => getAllDowntimeSchedules(props),
});

View File

@@ -1,37 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { DowntimeSchedulePayload } from './createDowntimeSchedule';
export interface DowntimeScheduleUpdatePayload {
data: DowntimeSchedulePayload;
id?: number;
}
export interface PayloadProps {
status: string;
data: string;
}
const updateDowntimeSchedule = async (
props: DowntimeScheduleUpdatePayload,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put(`/downtime_schedules/${props.id}`, {
...props.data,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default updateDowntimeSchedule;

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.6 KiB

After

Width:  |  Height:  |  Size: 6.5 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

@@ -79,7 +79,7 @@ export function useNavigateToExplorer(): (
);
const { getUpdatedQuery } = useUpdatedQuery();
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
const { notifications } = useNotifications();
return useCallback(
@@ -111,7 +111,7 @@ export function useNavigateToExplorer(): (
panelTypes: PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME',
},
selectedDashboard,
dashboardData,
})
.then((query) => {
preparedQuery = query;
@@ -140,7 +140,7 @@ export function useNavigateToExplorer(): (
minTime,
maxTime,
getUpdatedQuery,
selectedDashboard,
dashboardData,
notifications,
],
);

View File

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

View File

@@ -0,0 +1,43 @@
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

@@ -0,0 +1,114 @@
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: 300 },
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

@@ -87,8 +87,8 @@ jest.mock('hooks/useDarkMode', () => ({
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { selectedDashboard: undefined } => ({
selectedDashboard: undefined,
useDashboardStore: (): { dashboardData: undefined } => ({
dashboardData: undefined,
}),
}));

View File

@@ -0,0 +1,135 @@
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,8 +1,8 @@
.tanstack-header-cell {
.tanstackHeaderCell {
position: sticky;
top: 0;
z-index: 2;
padding: 0;
padding: 0.3rem;
transform: translate3d(
var(--tanstack-header-translate-x, 0px),
var(--tanstack-header-translate-y, 0px),
@@ -10,11 +10,11 @@
);
transition: var(--tanstack-header-transition, none);
&.is-dragging {
&.isDragging {
opacity: 0.85;
}
&.is-resizing {
&.isResizing {
background: var(--l2-background-hover);
}
@@ -22,7 +22,7 @@
background-color: var(--l2-background) !important;
}
.tanstack-header-content {
.tanstackHeaderContent {
display: flex;
align-items: center;
height: 100%;
@@ -31,20 +31,20 @@
cursor: default;
max-width: 100%;
&.has-resize-control {
&.hasResizeControl {
max-width: calc(100% - 5px);
}
&.has-action-control {
&.hasActionControl {
max-width: calc(100% - 5px);
}
&.has-resize-control.has-action-control {
&.hasResizeControl.hasActionControl {
max-width: calc(100% - 10px);
}
}
.tanstack-grip-slot {
.tanstackGripSlot {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -54,7 +54,7 @@
flex-shrink: 0;
}
.tanstack-grip-activator {
.tanstackGripActivator {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -66,7 +66,7 @@
touch-action: none;
}
.tanstack-header-action-trigger {
.tanstackHeaderActionTrigger {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -75,9 +75,11 @@
cursor: pointer;
flex-shrink: 0;
color: var(--l2-foreground);
margin-left: auto;
}
.tanstack-column-actions-content {
.tanstackColumnActionsContent {
width: 140px;
padding: 0;
background: var(--l2-background);
@@ -86,7 +88,7 @@
box-shadow: none;
}
.tanstack-remove-column-action {
.tanstackRemoveColumnAction {
display: flex;
align-items: center;
gap: 8px;
@@ -108,19 +110,19 @@
background: var(--l2-background-hover);
color: var(--l2-foreground);
.tanstack-remove-column-action-icon {
.tanstackRemoveColumnActionIcon {
color: var(--l2-foreground);
}
}
}
.tanstack-remove-column-action-icon {
.tanstackRemoveColumnActionIcon {
font-size: 11px;
color: var(--l2-foreground);
opacity: 0.95;
}
.tanstack-header-cell .cursor-col-resize {
.tanstackHeaderCell .cursorColResize {
position: absolute;
top: 0;
right: 0;
@@ -132,25 +134,84 @@
background: transparent;
}
.tanstack-header-cell.is-resizing .cursor-col-resize {
.tanstackHeaderCell.isResizing .cursorColResize {
background: var(--bg-robin-300);
}
.tanstack-resize-handle-line {
.tanstackResizeHandleLine {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 4px;
transform: translateX(-50%);
background: var(--l2-border);
opacity: 1;
pointer-events: none;
transition: background 120ms ease, width 120ms ease;
background: transparent;
}
.tanstack-header-cell.is-resizing .tanstack-resize-handle-line {
.cursorColResize:hover .tanstackResizeHandleLine {
background: var(--l2-border);
}
.tanstackHeaderCell.isResizing .tanstackResizeHandleLine {
width: 2px;
background: var(--bg-robin-500);
transition: none;
}
.tanstackSortButton {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
min-width: 0;
max-width: 100%;
flex: 1;
overflow: hidden;
&:hover {
color: var(--l2-foreground);
}
&.isSorted {
color: var(--l2-foreground);
}
}
.tanstackHeaderTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
.tanstackSortLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.tanstackSortIndicator {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 14px;
height: 14px;
color: var(--l2-foreground);
}
.isSortable {
cursor: pointer;
}

View File

@@ -0,0 +1,260 @@
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/ui';
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

@@ -0,0 +1,136 @@
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

@@ -0,0 +1,88 @@
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

@@ -0,0 +1,100 @@
.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

@@ -0,0 +1,572 @@
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

@@ -0,0 +1,21 @@
.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

@@ -0,0 +1,72 @@
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

@@ -0,0 +1,206 @@
/* 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

@@ -0,0 +1,42 @@
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

@@ -0,0 +1,152 @@
.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

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,253 @@
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

@@ -0,0 +1,368 @@
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

@@ -0,0 +1,288 @@
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

@@ -0,0 +1,440 @@
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

@@ -0,0 +1,94 @@
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

@@ -0,0 +1,247 @@
/* 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

@@ -0,0 +1,296 @@
/* 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

@@ -0,0 +1,239 @@
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

@@ -0,0 +1,179 @@
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

@@ -0,0 +1,191 @@
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

@@ -0,0 +1,64 @@
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

@@ -0,0 +1,76 @@
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

@@ -0,0 +1,226 @@
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

@@ -0,0 +1,328 @@
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

@@ -0,0 +1,43 @@
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

@@ -0,0 +1,60 @@
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

@@ -0,0 +1,79 @@
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

@@ -0,0 +1,194 @@
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

@@ -0,0 +1,135 @@
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
/**
* Parse a numeric pixel value from a number or string (e.g., 200 or "200px").
* Returns undefined for non-pixel strings like "100%" or "10rem".
*/
const parsePixelValue = (
value: number | string | undefined,
): number | undefined => {
if (value == null) {
return undefined;
}
if (typeof value === 'number') {
return value;
}
// Only parse pixel values, ignore %, rem, em, etc.
const match = /^(\d+(?:\.\d+)?)px$/.exec(value);
return match ? parseFloat(match[1]) : undefined;
};
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;
// Extract numeric size/minSize/maxSize for TanStack's resize behavior
// This ensures TanStack's internal resize state stays in sync with CSS constraints
let size: number | undefined;
let minSize: number | undefined;
let maxSize: number | undefined;
const fixedWidth = parsePixelValue(colDef.width?.fixed);
if (isFixed && fixedWidth != null) {
size = fixedWidth;
minSize = fixedWidth;
maxSize = fixedWidth;
} else {
// Match the logic in getColumnWidthStyle for initial size
const defaultSize = parsePixelValue(colDef.width?.default);
const minWidth = parsePixelValue(colDef.width?.min) ?? DEFAULT_MIN_WIDTH;
size = defaultSize ?? minWidth;
minSize = minWidth;
maxSize = parsePixelValue(colDef.width?.max);
}
return {
id: colDef.id,
size,
minSize,
maxSize,
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

@@ -1,3 +1,4 @@
import { RuletypesAlertTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { DataSource } from 'types/common/queryBuilder';
@@ -8,3 +9,17 @@ export const ALERTS_DATA_SOURCE_MAP: Record<AlertTypes, DataSource> = {
[AlertTypes.TRACES_BASED_ALERT]: DataSource.TRACES,
[AlertTypes.EXCEPTIONS_BASED_ALERT]: DataSource.TRACES,
};
export function dataSourceForAlertType(
alertType: RuletypesAlertTypeDTO | undefined,
): DataSource {
switch (alertType) {
case RuletypesAlertTypeDTO.LOGS_BASED_ALERT:
return DataSource.LOGS;
case RuletypesAlertTypeDTO.TRACES_BASED_ALERT:
case RuletypesAlertTypeDTO.EXCEPTIONS_BASED_ALERT:
return DataSource.TRACES;
default:
return DataSource.METRICS;
}
}

View File

@@ -15,8 +15,12 @@ import {
THRESHOLD_MATCH_TYPE_OPTIONS,
THRESHOLD_OPERATOR_OPTIONS,
} from '../context/constants';
import { AlertThresholdMatchType } from '../context/types';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
} from '../context/types';
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
import { normalizeMatchType, normalizeOperator } from '../utils';
import ThresholdItem from './ThresholdItem';
import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
import {
@@ -132,12 +136,15 @@ function AlertThreshold({
}
};
const normalizedOperator =
normalizeOperator(thresholdState.operator) ?? AlertThresholdOperator.IS_ABOVE;
const matchTypeOptionsWithTooltips = THRESHOLD_MATCH_TYPE_OPTIONS.map(
(option) => ({
...option,
label: (
<Tooltip
title={getMatchTypeTooltip(option.value, thresholdState.operator)}
title={getMatchTypeTooltip(option.value, normalizedOperator)}
placement="left"
overlayClassName="copyable-tooltip"
overlayStyle={{
@@ -232,7 +239,10 @@ function AlertThreshold({
/>
<Typography.Text className="sentence-text">is</Typography.Text>
<Select
value={thresholdState.operator}
value={
(normalizeOperator(thresholdState.operator) ??
thresholdState.operator) as AlertThresholdOperator
}
onChange={(value): void => {
setThresholdState({
type: 'SET_OPERATOR',
@@ -247,7 +257,10 @@ function AlertThreshold({
the threshold(s)
</Typography.Text>
<Select
value={thresholdState.matchType}
value={
(normalizeMatchType(thresholdState.matchType) ??
thresholdState.matchType) as AlertThresholdMatchType
}
onChange={(value): void => {
setThresholdState({
type: 'SET_MATCH_TYPE',

View File

@@ -11,6 +11,11 @@ import {
ANOMALY_THRESHOLD_OPERATOR_OPTIONS,
ANOMALY_TIME_DURATION_OPTIONS,
} from '../context/constants';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
} from '../context/types';
import { normalizeMatchType, normalizeOperator } from '../utils';
import { AnomalyAndThresholdProps } from './types';
import {
getQueryNames,
@@ -115,7 +120,10 @@ function AnomalyThreshold({
deviations
</Typography.Text>
<Select
value={thresholdState.operator}
value={
(normalizeOperator(thresholdState.operator) ??
thresholdState.operator) as AlertThresholdOperator
}
data-testid="operator-select"
onChange={(value): void => {
setThresholdState({
@@ -132,7 +140,10 @@ function AnomalyThreshold({
the predicted data
</Typography.Text>
<Select
value={thresholdState.matchType}
value={
(normalizeMatchType(thresholdState.matchType) ??
thresholdState.matchType) as AlertThresholdMatchType
}
data-testid="match-type-select"
onChange={(value): void => {
setThresholdState({

View File

@@ -5,6 +5,7 @@ import { useAppContext } from 'providers/App/App';
import { useCreateAlertState } from '../context';
import { AlertThresholdOperator } from '../context/types';
import { normalizeOperator } from '../utils';
import { ThresholdItemProps } from './types';
import { NotificationChannelsNotFoundContent } from './utils';
@@ -54,7 +55,7 @@ function ThresholdItem({
}, [units, threshold.unit, updateThreshold, threshold.id]);
const getOperatorSymbol = (): string => {
switch (thresholdState.operator) {
switch (normalizeOperator(thresholdState.operator)) {
case AlertThresholdOperator.IS_ABOVE:
return '>';
case AlertThresholdOperator.IS_BELOW:

View File

@@ -6,9 +6,7 @@ import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/ut
import * as useSafeNavigateHook from 'hooks/useSafeNavigate';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import * as useCreateAlertRuleHook from '../../../../hooks/alerts/useCreateAlertRule';
import * as useTestAlertRuleHook from '../../../../hooks/alerts/useTestAlertRule';
import * as useUpdateAlertRuleHook from '../../../../hooks/alerts/useUpdateAlertRule';
import * as rulesHook from '../../../../api/generated/services/rules';
import { CreateAlertProvider } from '../../context';
import CreateAlertHeader from '../CreateAlertHeader';
@@ -17,15 +15,15 @@ jest.spyOn(useSafeNavigateHook, 'useSafeNavigate').mockReturnValue({
safeNavigate: mockSafeNavigate,
});
jest.spyOn(useCreateAlertRuleHook, 'useCreateAlertRule').mockReturnValue({
jest.spyOn(rulesHook, 'useCreateRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
} as any);
jest.spyOn(useTestAlertRuleHook, 'useTestAlertRule').mockReturnValue({
jest.spyOn(rulesHook, 'useTestRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
} as any);
jest.spyOn(useUpdateAlertRuleHook, 'useUpdateAlertRule').mockReturnValue({
jest.spyOn(rulesHook, 'useUpdateRuleByID').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
} as any);

View File

@@ -34,6 +34,7 @@ export const createMockAlertContextState = (
isUpdatingAlertRule: false,
updateAlertRule: jest.fn(),
isEditMode: false,
ruleId: '',
...overrides,
});

View File

@@ -1,9 +1,15 @@
import { useCallback, useMemo } from 'react';
import { Button, toast } from '@signozhq/ui';
import { Tooltip } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Check, Loader, Send, X } from 'lucide-react';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { toPostableRuleDTO } from 'types/api/alerts/convert';
import APIError from 'types/api/error';
import { isModifierKeyPressed } from 'utils/app';
import { useCreateAlertState } from '../context';
@@ -30,9 +36,20 @@ function Footer(): JSX.Element {
updateAlertRule,
isUpdatingAlertRule,
isEditMode,
ruleId,
} = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const handleApiError = useCallback(
(error: unknown): void => {
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);
},
[showErrorModal],
);
const handleDiscard = (e: React.MouseEvent): void => {
discardAlertRule();
@@ -71,20 +88,21 @@ function Footer(): JSX.Element {
notificationSettings,
query: currentQuery,
});
testAlertRule(payload, {
onSuccess: (response) => {
if (response.payload?.data?.alertCount === 0) {
toast.error(
'No alerts found during the evaluation. This happens when rule condition is unsatisfied. You may adjust the rule threshold and retry.',
);
return;
}
toast.success('Test notification sent successfully');
testAlertRule(
{ data: toPostableRuleDTO(payload) },
{
onSuccess: (response) => {
if (response.data?.alertCount === 0) {
toast.error(
'No alerts found during the evaluation. This happens when rule condition is unsatisfied. You may adjust the rule threshold and retry.',
);
return;
}
toast.success('Test notification sent successfully');
},
onError: handleApiError,
},
onError: (error) => {
toast.error(error.message);
},
});
);
}, [
alertType,
basicAlertState,
@@ -107,25 +125,30 @@ function Footer(): JSX.Element {
query: currentQuery,
});
if (isEditMode) {
updateAlertRule(payload, {
onSuccess: () => {
toast.success('Alert rule updated successfully');
safeNavigate('/alerts');
updateAlertRule(
{
pathParams: { id: ruleId },
data: toPostableRuleDTO(payload),
},
onError: (error) => {
toast.error(error.message);
{
onSuccess: () => {
toast.success('Alert rule updated successfully');
safeNavigate('/alerts');
},
onError: handleApiError,
},
});
);
} else {
createAlertRule(payload, {
onSuccess: () => {
toast.success('Alert rule created successfully');
safeNavigate('/alerts');
createAlertRule(
{ data: toPostableRuleDTO(payload) },
{
onSuccess: () => {
toast.success('Alert rule created successfully');
safeNavigate('/alerts');
},
onError: handleApiError,
},
onError: (error) => {
toast.error(error.message);
},
});
);
}
}, [
alertType,
@@ -136,9 +159,11 @@ function Footer(): JSX.Element {
notificationSettings,
currentQuery,
isEditMode,
ruleId,
updateAlertRule,
createAlertRule,
safeNavigate,
handleApiError,
]);
const disableButtons =

View File

@@ -12,6 +12,11 @@ import Footer from '../Footer';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('providers/ErrorModalProvider', () => ({
useErrorModal: (): { showErrorModal: jest.Mock } => ({
showErrorModal: jest.fn(),
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: jest.fn(),

View File

@@ -474,9 +474,9 @@ describe('Footer utils', () => {
spec: [
{
channels: [],
matchType: '1',
matchType: 'at_least_once',
name: 'critical',
op: '1',
op: 'above',
target: 0,
targetUnit: '',
},
@@ -520,5 +520,33 @@ describe('Footer utils', () => {
expect(props.condition.compositeQuery.queryType).toBe('promql');
expect(props.ruleType).toBe('promql_rule');
});
// Backward compatibility: a rule loaded with a legacy op/matchType
// serialization ("1", ">", "eq", "above_or_equal", ...) must round-trip
// back to the backend unchanged when the user hasn't touched those
// fields. If the submit payload silently rewrites them, existing rules
// would drift away from their persisted form on every edit.
it.each([
['numeric', '1', '1'],
['symbol', '>', 'at_least_once'],
['literal', 'above', 'at_least_once'],
['short', 'eq', 'avg'],
['UI-unexposed', 'above_or_equal', 'at_least_once'],
])(
'round-trips %s op/matchType unchanged through the submit payload (%s / %s)',
(_desc, op, matchType) => {
const args: BuildCreateAlertRulePayloadArgs = {
...INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS,
thresholdState: {
...INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS.thresholdState,
operator: op,
matchType,
},
};
const props = buildCreateThresholdAlertRulePayload(args);
expect(props.condition.thresholds?.spec[0].op).toBe(op);
expect(props.condition.thresholds?.spec[0].matchType).toBe(matchType);
},
);
});
});

View File

@@ -15,6 +15,8 @@ import {
getEvaluationWindowStateFromAlertDef,
getNotificationSettingsStateFromAlertDef,
getThresholdStateFromAlertDef,
normalizeMatchType,
normalizeOperator,
parseGoTime,
} from '../utils';
@@ -314,6 +316,137 @@ describe('CreateAlertV2 utils', () => {
});
});
describe('normalizeOperator', () => {
it.each([
['1', AlertThresholdOperator.IS_ABOVE],
['above', AlertThresholdOperator.IS_ABOVE],
['>', AlertThresholdOperator.IS_ABOVE],
['2', AlertThresholdOperator.IS_BELOW],
['below', AlertThresholdOperator.IS_BELOW],
['<', AlertThresholdOperator.IS_BELOW],
['3', AlertThresholdOperator.IS_EQUAL_TO],
['equal', AlertThresholdOperator.IS_EQUAL_TO],
['eq', AlertThresholdOperator.IS_EQUAL_TO],
['=', AlertThresholdOperator.IS_EQUAL_TO],
['4', AlertThresholdOperator.IS_NOT_EQUAL_TO],
['not_equal', AlertThresholdOperator.IS_NOT_EQUAL_TO],
['not_eq', AlertThresholdOperator.IS_NOT_EQUAL_TO],
['!=', AlertThresholdOperator.IS_NOT_EQUAL_TO],
['7', AlertThresholdOperator.ABOVE_BELOW],
['outside_bounds', AlertThresholdOperator.ABOVE_BELOW],
])('maps backend alias %s to canonical enum', (alias, expected) => {
expect(normalizeOperator(alias)).toBe(expected);
});
it.each([
['5', 'above_or_equal'],
['above_or_equal', 'above_or_equal'],
['above_or_eq', 'above_or_equal'],
['>=', 'above_or_equal'],
['6', 'below_or_equal'],
['below_or_equal', 'below_or_equal'],
['below_or_eq', 'below_or_equal'],
['<=', 'below_or_equal'],
])('returns undefined for UI-unexposed alias %s (%s family)', (alias) => {
expect(normalizeOperator(alias)).toBeUndefined();
});
it('returns undefined for unknown values', () => {
expect(normalizeOperator('gibberish')).toBeUndefined();
expect(normalizeOperator(undefined)).toBeUndefined();
expect(normalizeOperator('')).toBeUndefined();
});
});
describe('normalizeMatchType', () => {
it.each([
['1', AlertThresholdMatchType.AT_LEAST_ONCE],
['at_least_once', AlertThresholdMatchType.AT_LEAST_ONCE],
['2', AlertThresholdMatchType.ALL_THE_TIME],
['all_the_times', AlertThresholdMatchType.ALL_THE_TIME],
['3', AlertThresholdMatchType.ON_AVERAGE],
['on_average', AlertThresholdMatchType.ON_AVERAGE],
['avg', AlertThresholdMatchType.ON_AVERAGE],
['4', AlertThresholdMatchType.IN_TOTAL],
['in_total', AlertThresholdMatchType.IN_TOTAL],
['sum', AlertThresholdMatchType.IN_TOTAL],
['5', AlertThresholdMatchType.LAST],
['last', AlertThresholdMatchType.LAST],
])('maps backend alias %s to canonical enum', (alias, expected) => {
expect(normalizeMatchType(alias)).toBe(expected);
});
it('returns undefined for unknown values', () => {
expect(normalizeMatchType('gibberish')).toBeUndefined();
expect(normalizeMatchType(undefined)).toBeUndefined();
expect(normalizeMatchType('')).toBeUndefined();
});
});
describe('getThresholdStateFromAlertDef backward compatibility', () => {
const buildDef = (op: string, matchType: string): PostableAlertRuleV2 => ({
...defaultPostableAlertRuleV2,
condition: {
...defaultPostableAlertRuleV2.condition,
thresholds: {
kind: 'basic',
spec: [
{
name: 'critical',
target: 1,
targetUnit: UniversalYAxisUnit.MINUTES,
channels: [],
matchType,
op,
},
],
},
},
});
// Each row covers a distinct historical serialization shape the backend
// may have persisted. The frontend must not rewrite these on load —
// otherwise opening and saving an old rule silently changes its op.
it.each([
['numeric', '1', '1'],
['literal', 'above', 'at_least_once'],
['symbol', '>', 'at_least_once'],
['short form', 'eq', 'avg'],
['mixed numeric and literal', '7', 'last'],
['UI-unexposed operator', 'above_or_equal', 'at_least_once'],
['UI-unexposed numeric operator', '5', 'at_least_once'],
])('preserves %s op/matchType verbatim (%s / %s)', (_desc, op, matchType) => {
const state = getThresholdStateFromAlertDef(buildDef(op, matchType));
expect(state.operator).toBe(op);
expect(state.matchType).toBe(matchType);
});
it('falls back to IS_ABOVE / AT_LEAST_ONCE when op and matchType are missing', () => {
const def: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
condition: {
...defaultPostableAlertRuleV2.condition,
thresholds: {
kind: 'basic',
spec: [
{
name: 'critical',
target: 1,
targetUnit: UniversalYAxisUnit.MINUTES,
channels: [],
matchType: '',
op: '',
},
],
},
},
};
const state = getThresholdStateFromAlertDef(def);
expect(state.operator).toBe(AlertThresholdOperator.IS_ABOVE);
expect(state.matchType).toBe(AlertThresholdMatchType.AT_LEAST_ONCE);
});
});
describe('getCreateAlertLocalStateFromAlertDef', () => {
it('should return the correct create alert local state for the given alert def', () => {
const args: PostableAlertRuleV2 = {

View File

@@ -10,11 +10,13 @@ import {
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import {
useCreateRule,
useTestRule,
useUpdateRuleByID,
} from 'api/generated/services/rules';
import { QueryParams } from 'constants/query';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { useCreateAlertRule } from 'hooks/alerts/useCreateAlertRule';
import { useTestAlertRule } from 'hooks/alerts/useTestAlertRule';
import { useUpdateAlertRule } from 'hooks/alerts/useUpdateAlertRule';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { AlertTypes } from 'types/api/alerts/alertTypes';
@@ -215,17 +217,14 @@ export function CreateAlertProvider(
const {
mutate: createAlertRule,
isLoading: isCreatingAlertRule,
} = useCreateAlertRule();
} = useCreateRule();
const {
mutate: testAlertRule,
isLoading: isTestingAlertRule,
} = useTestAlertRule();
const { mutate: testAlertRule, isLoading: isTestingAlertRule } = useTestRule();
const {
mutate: updateAlertRule,
isLoading: isUpdatingAlertRule,
} = useUpdateAlertRule(ruleId || '');
} = useUpdateRuleByID();
const contextValue: ICreateAlertContextProps = useMemo(
() => ({
@@ -249,6 +248,7 @@ export function CreateAlertProvider(
updateAlertRule,
isUpdatingAlertRule,
isEditMode: isEditMode || false,
ruleId: ruleId || '',
}),
[
createAlertState,
@@ -267,6 +267,7 @@ export function CreateAlertProvider(
updateAlertRule,
isUpdatingAlertRule,
isEditMode,
ruleId,
],
);

View File

@@ -1,12 +1,15 @@
import { Dispatch } from 'react';
import { UseMutateFunction } from 'react-query';
import { CreateAlertRuleResponse } from 'api/alerts/createAlertRule';
import { TestAlertRuleResponse } from 'api/alerts/testAlertRule';
import { UpdateAlertRuleResponse } from 'api/alerts/updateAlertRule';
import type {
CreateRule201,
RenderErrorResponseDTO,
RuletypesPostableRuleDTO,
TestRule200,
UpdateRuleByIDPathParameters,
} from 'api/generated/services/sigNoz.schemas';
import type { BodyType, ErrorType } from 'api/generatedAPIInstance';
import { Dayjs } from 'dayjs';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { Labels } from 'types/api/alerts/def';
export interface ICreateAlertContextProps {
@@ -24,27 +27,33 @@ export interface ICreateAlertContextProps {
setNotificationSettings: Dispatch<NotificationSettingsAction>;
isCreatingAlertRule: boolean;
createAlertRule: UseMutateFunction<
SuccessResponse<CreateAlertRuleResponse, unknown> | ErrorResponse,
Error,
PostableAlertRuleV2,
CreateRule201,
ErrorType<unknown>,
{ data: BodyType<RuletypesPostableRuleDTO> },
unknown
>;
isTestingAlertRule: boolean;
testAlertRule: UseMutateFunction<
SuccessResponse<TestAlertRuleResponse, unknown> | ErrorResponse,
Error,
PostableAlertRuleV2,
TestRule200,
ErrorType<unknown>,
{ data: BodyType<RuletypesPostableRuleDTO> },
unknown
>;
discardAlertRule: () => void;
isUpdatingAlertRule: boolean;
updateAlertRule: UseMutateFunction<
SuccessResponse<UpdateAlertRuleResponse, unknown> | ErrorResponse,
Error,
PostableAlertRuleV2,
Awaited<
ReturnType<typeof import('api/generated/services/rules').updateRuleByID>
>,
ErrorType<RenderErrorResponseDTO>,
{
pathParams: UpdateRuleByIDPathParameters;
data: BodyType<RuletypesPostableRuleDTO>;
},
unknown
>;
isEditMode: boolean;
ruleId: string;
}
export interface ICreateAlertProviderProps {
@@ -86,25 +95,28 @@ export interface Threshold {
}
export enum AlertThresholdOperator {
IS_ABOVE = '1',
IS_BELOW = '2',
IS_EQUAL_TO = '3',
IS_NOT_EQUAL_TO = '4',
ABOVE_BELOW = '7',
IS_ABOVE = 'above',
IS_BELOW = 'below',
IS_EQUAL_TO = 'equal',
IS_NOT_EQUAL_TO = 'not_equal',
ABOVE_BELOW = 'outside_bounds',
}
export enum AlertThresholdMatchType {
AT_LEAST_ONCE = '1',
ALL_THE_TIME = '2',
ON_AVERAGE = '3',
IN_TOTAL = '4',
LAST = '5',
AT_LEAST_ONCE = 'at_least_once',
ALL_THE_TIME = 'all_the_times',
ON_AVERAGE = 'on_average',
IN_TOTAL = 'in_total',
LAST = 'last',
}
export interface AlertThresholdState {
selectedQuery: string;
operator: AlertThresholdOperator;
matchType: AlertThresholdMatchType;
// Stored as a raw string so backend aliases ("1", ">", "above_or_eq", ...)
// survive a load/save round-trip. User edits from the UI write the
// canonical enum value.
operator: AlertThresholdOperator | string;
matchType: AlertThresholdMatchType | string;
evaluationWindow: string;
algorithm: string;
seasonality: string;

View File

@@ -237,6 +237,68 @@ export function getAdvancedOptionsStateFromAlertDef(
};
}
// Mirrors the backend's CompareOperator.Normalize() in
// pkg/types/ruletypes/compare.go. Maps any accepted alias to the enum value
// the dropdown understands. Returns undefined for aliases the UI does not
// expose (e.g. above_or_equal, below_or_equal) so callers can keep the raw
// value on screen instead of silently rewriting it.
export function normalizeOperator(
raw: string | undefined,
): AlertThresholdOperator | undefined {
switch (raw) {
case '1':
case 'above':
case '>':
return AlertThresholdOperator.IS_ABOVE;
case '2':
case 'below':
case '<':
return AlertThresholdOperator.IS_BELOW;
case '3':
case 'equal':
case 'eq':
case '=':
return AlertThresholdOperator.IS_EQUAL_TO;
case '4':
case 'not_equal':
case 'not_eq':
case '!=':
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
case '7':
case 'outside_bounds':
return AlertThresholdOperator.ABOVE_BELOW;
default:
return undefined;
}
}
// Mirrors the backend's MatchType.Normalize() in pkg/types/ruletypes/match.go.
export function normalizeMatchType(
raw: string | undefined,
): AlertThresholdMatchType | undefined {
switch (raw) {
case '1':
case 'at_least_once':
return AlertThresholdMatchType.AT_LEAST_ONCE;
case '2':
case 'all_the_times':
return AlertThresholdMatchType.ALL_THE_TIME;
case '3':
case 'on_average':
case 'avg':
return AlertThresholdMatchType.ON_AVERAGE;
case '4':
case 'in_total':
case 'sum':
return AlertThresholdMatchType.IN_TOTAL;
case '5':
case 'last':
return AlertThresholdMatchType.LAST;
default:
return undefined;
}
}
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AlertThresholdState {
@@ -254,11 +316,9 @@ export function getThresholdStateFromAlertDef(
})) || [],
selectedQuery: alertDef.condition.selectedQueryName || '',
operator:
(alertDef.condition.thresholds?.spec[0].op as AlertThresholdOperator) ||
AlertThresholdOperator.IS_ABOVE,
alertDef.condition.thresholds?.spec[0].op || AlertThresholdOperator.IS_ABOVE,
matchType:
(alertDef.condition.thresholds?.spec[0]
.matchType as AlertThresholdMatchType) ||
alertDef.condition.thresholds?.spec[0].matchType ||
AlertThresholdMatchType.AT_LEAST_ONCE,
};
}

View File

@@ -196,12 +196,12 @@ describe('Dashboard landing page actions header tests', () => {
(useLocation as jest.Mock).mockReturnValue(mockLocation);
useDashboardStore.setState({
selectedDashboard: (getDashboardById.data as unknown) as Dashboard,
dashboardData: (getDashboardById.data as unknown) as Dashboard,
layouts: [],
panelMap: {},
setPanelMap: jest.fn(),
setLayouts: jest.fn(),
setSelectedDashboard: jest.fn(),
setDashboardData: jest.fn(),
columnWidths: {},
});

View File

@@ -78,12 +78,12 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const {
selectedDashboard,
dashboardData,
panelMap,
setPanelMap,
layouts,
setLayouts,
setSelectedDashboard,
setDashboardData,
} = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
@@ -98,10 +98,10 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const isPublicDashboardEnabled = isCloudUser || isEnterpriseSelfHostedUser;
const selectedData = selectedDashboard
const selectedData = dashboardData
? {
...selectedDashboard.data,
uuid: selectedDashboard.id,
...dashboardData.data,
uuid: dashboardData.id,
}
: ({} as DashboardData);
const { dashboardVariables } = useDashboardVariables();
@@ -133,8 +133,8 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
let isAuthor = false;
if (selectedDashboard && user && user.email) {
isAuthor = selectedDashboard?.createdBy === user?.email;
if (dashboardData && user && user.email) {
isAuthor = dashboardData?.createdBy === user?.email;
}
let permissions: ComponentTypes[] = ['add_panel'];
@@ -146,7 +146,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { notifications } = useNotifications();
const userRole: ROLES | null =
selectedDashboard?.createdBy === user?.email
dashboardData?.createdBy === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: user.role;
@@ -155,9 +155,9 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const onEmptyWidgetHandler = useCallback(() => {
setIsPanelTypeSelectionModalOpen(true);
logEvent('Dashboard Detail: Add new panel clicked', {
dashboardId: selectedDashboard?.id,
dashboardName: selectedDashboard?.data.title,
numberOfPanels: selectedDashboard?.data.widgets?.length,
dashboardId: dashboardData?.id,
dashboardName: dashboardData?.data.title,
numberOfPanels: dashboardData?.data.widgets?.length,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setIsPanelTypeSelectionModalOpen]);
@@ -168,14 +168,14 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
};
const onNameChangeHandler = (): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
const updatedDashboard: Props = {
id: selectedDashboard.id,
id: dashboardData.id,
data: {
...selectedDashboard.data,
...dashboardData.data,
title: updatedTitle,
},
};
@@ -186,7 +186,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
});
setIsRenameDashboardOpen(false);
if (updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
setDashboardData(updatedDashboard.data);
}
},
onError: () => {
@@ -203,10 +203,10 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
// the context value is sometimes not available during the initial render
// due to which the updatedTitle is set to some previous value
useEffect(() => {
if (selectedDashboard) {
setUpdatedTitle(selectedDashboard.data.title);
if (dashboardData) {
setUpdatedTitle(dashboardData.data.title);
}
}, [selectedDashboard]);
}, [dashboardData]);
useEffect(() => {
if (state.error) {
@@ -227,7 +227,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
}, [state.error, state.value, t, notifications]);
function handleAddRow(): void {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
const id = uuid();
@@ -246,10 +246,10 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
}
const updatedDashboard: Props = {
id: selectedDashboard.id,
id: dashboardData.id,
data: {
...selectedDashboard.data,
...dashboardData.data,
layout: [
{
i: id,
@@ -265,7 +265,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
],
panelMap: { ...panelMap, [id]: newRowWidgetMap },
widgets: [
...(selectedDashboard.data.widgets || []),
...(dashboardData.data.widgets || []),
{
id,
title: sectionName,
@@ -282,7 +282,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
if (updatedDashboard.data.data.layout) {
setLayouts(sortLayout(updatedDashboard.data.data.layout));
}
setSelectedDashboard(updatedDashboard.data);
setDashboardData(updatedDashboard.data);
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
}
@@ -299,8 +299,8 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
error: errorPublicDashboardData,
isError: isErrorPublicDashboardData,
} = useGetPublicDashboardMeta(
selectedDashboard?.id || '',
!!selectedDashboard?.id && isPublicDashboardEnabled,
dashboardData?.id || '',
!!dashboardData?.id && isPublicDashboardEnabled,
);
useEffect(() => {
@@ -378,14 +378,14 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
<Tooltip
title={
selectedDashboard?.createdBy === 'integration' &&
dashboardData?.createdBy === 'integration' &&
'Dashboards created by integrations cannot be unlocked'
}
>
<Button
type="text"
icon={<LockKeyhole size={14} />}
disabled={selectedDashboard?.createdBy === 'integration'}
disabled={dashboardData?.createdBy === 'integration'}
onClick={handleLockDashboardToggle}
data-testid="lock-unlock-dashboard"
>
@@ -457,9 +457,9 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
</section>
<section className="delete-dashboard">
<DeleteButton
createdBy={selectedDashboard?.createdBy || ''}
name={selectedDashboard?.data.title || ''}
id={String(selectedDashboard?.id) || ''}
createdBy={dashboardData?.createdBy || ''}
name={dashboardData?.data.title || ''}
id={String(dashboardData?.id) || ''}
isLocked={isDashboardLocked}
routeToListPage
/>

View File

@@ -239,7 +239,7 @@ function VariableItem({
const [selectedWidgets, setSelectedWidgets] = useState<string[]>([]);
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
const widgetsByDynamicVariableId = useWidgetsByDynamicVariableId();
useEffect(() => {
@@ -248,7 +248,7 @@ function VariableItem({
} else if (dynamicVariablesSelectedValue?.name) {
const widgets = getWidgetsHavingDynamicVariableAttribute(
dynamicVariablesSelectedValue?.name,
(selectedDashboard?.data?.widgets?.filter(
(dashboardData?.data?.widgets?.filter(
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
) || []) as Widgets[],
variableData.name,
@@ -257,7 +257,7 @@ function VariableItem({
}
}, [
dynamicVariablesSelectedValue?.name,
selectedDashboard,
dashboardData,
variableData.id,
variableData.name,
widgetsByDynamicVariableId,

View File

@@ -12,17 +12,17 @@ export function WidgetSelector({
selectedWidgets: string[];
setSelectedWidgets: (widgets: string[]) => void;
}): JSX.Element {
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
// Get layout IDs for cross-referencing
const layoutIds = new Set(
(selectedDashboard?.data?.layout || []).map((item) => item.i),
(dashboardData?.data?.layout || []).map((item) => item.i),
);
// Filter and deduplicate widgets by ID, keeping only those with layout entries
// and excluding row widgets since they are not panels that can have variables
const widgets = Object.values(
(selectedDashboard?.data?.widgets || []).reduce(
(dashboardData?.data?.widgets || []).reduce(
(acc: Record<string, WidgetRow | Widgets>, widget: WidgetRow | Widgets) => {
if (
widget.id &&

View File

@@ -87,7 +87,7 @@ function VariablesSettings({
const { t } = useTranslation(['dashboard']);
const { selectedDashboard, setSelectedDashboard } = useDashboardStore();
const { dashboardData, setDashboardData } = useDashboardStore();
const { dashboardVariables } = useDashboardVariables();
const { notifications } = useNotifications();
@@ -173,7 +173,7 @@ function VariablesSettings({
widgetIds?: string[],
applyToAll?: boolean,
): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
@@ -181,16 +181,16 @@ function VariablesSettings({
(currentRequestedId &&
updatedVariablesData[currentRequestedId || '']?.type === 'DYNAMIC' &&
addDynamicVariableToPanels(
selectedDashboard,
dashboardData,
updatedVariablesData[currentRequestedId || ''],
widgetIds,
applyToAll,
)) ||
selectedDashboard;
dashboardData;
updateMutation.mutateAsync(
{
id: selectedDashboard.id,
id: dashboardData.id,
data: {
...newDashboard.data,
@@ -200,7 +200,7 @@ function VariablesSettings({
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
setDashboardData(updatedDashboard.data);
notifications.success({
message: t('variable_updated_successfully'),
});

View File

@@ -15,11 +15,11 @@ import './GeneralSettings.styles.scss';
const { Option } = Select;
function GeneralDashboardSettings(): JSX.Element {
const { selectedDashboard, setSelectedDashboard } = useDashboardStore();
const { dashboardData, setDashboardData } = useDashboardStore();
const updateDashboardMutation = useUpdateDashboard();
const selectedData = selectedDashboard?.data;
const selectedData = dashboardData?.data;
const { title = '', tags = [], description = '', image = Base64Icons[0] } =
selectedData || {};
@@ -37,15 +37,15 @@ function GeneralDashboardSettings(): JSX.Element {
const { t } = useTranslation('common');
const onSaveHandler = (): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
updateDashboardMutation.mutate(
{
id: selectedDashboard.id,
id: dashboardData.id,
data: {
...selectedDashboard.data,
...dashboardData.data,
description: updatedDescription,
tags: updatedTags,
title: updatedTitle,
@@ -55,7 +55,7 @@ function GeneralDashboardSettings(): JSX.Element {
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
setDashboardData(updatedDashboard.data);
}
},
onError: () => {},

View File

@@ -41,7 +41,7 @@ const DASHBOARD_VARIABLES_WARNING =
// Use wildcard pattern to match both relative and absolute URLs in MSW
const publicDashboardURL = `*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`;
const mockSelectedDashboard = {
const mockDashboardData = {
id: MOCK_DASHBOARD_ID,
data: {
title: 'Test Dashboard',
@@ -70,7 +70,7 @@ beforeEach(() => {
// Mock useDashboardStore
mockUseDashboard.mockReturnValue(({
selectedDashboard: mockSelectedDashboard,
dashboardData: mockDashboardData,
} as unknown) as ReturnType<typeof useDashboardStore>);
// Mock useCopyToClipboard

View File

@@ -59,7 +59,7 @@ function PublicDashboardSetting(): JSX.Element {
const [defaultTimeRange, setDefaultTimeRange] = useState(DEFAULT_TIME_RANGE);
const [, setCopyPublicDashboardURL] = useCopyToClipboard();
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
@@ -84,8 +84,8 @@ function PublicDashboardSetting(): JSX.Element {
refetch: refetchPublicDashboard,
error: errorPublicDashboard,
} = useGetPublicDashboardMeta(
selectedDashboard?.id || '',
!!selectedDashboard?.id && isPublicDashboardEnabled,
dashboardData?.id || '',
!!dashboardData?.id && isPublicDashboardEnabled,
);
const isPublicDashboard = !!publicDashboardData?.publicPath;
@@ -154,36 +154,36 @@ function PublicDashboardSetting(): JSX.Element {
});
const handleCreatePublicDashboard = (): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
createPublicDashboard({
dashboardId: selectedDashboard.id,
dashboardId: dashboardData.id,
timeRangeEnabled,
defaultTimeRange,
});
};
const handleUpdatePublicDashboard = (): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
updatePublicDashboard({
dashboardId: selectedDashboard.id,
dashboardId: dashboardData.id,
timeRangeEnabled,
defaultTimeRange,
});
};
const handleRevokePublicDashboardAccess = (): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
revokePublicDashboardAccess({
id: selectedDashboard.id,
id: dashboardData.id,
});
};

View File

@@ -26,10 +26,10 @@ import VariableItem from './VariableItem';
import './DashboardVariableSelection.styles.scss';
function DashboardVariableSelection(): JSX.Element | null {
const { dashboardId, setSelectedDashboard } = useDashboardStore(
const { dashboardId, setDashboardData } = useDashboardStore(
useShallow((s) => ({
dashboardId: s.selectedDashboard?.id ?? '',
setSelectedDashboard: s.setSelectedDashboard,
dashboardId: s.dashboardData?.id ?? '',
setDashboardData: s.setDashboardData,
})),
);
@@ -99,7 +99,7 @@ function DashboardVariableSelection(): JSX.Element | null {
// Synchronously update the external store with the new variable value so that
// child variables see the updated parent value when they refetch, rather than
// waiting for setSelectedDashboard → useEffect → updateDashboardVariablesStore.
// waiting for setDashboardData → useEffect → updateDashboardVariablesStore.
const updatedVariables = { ...dashboardVariables };
if (updatedVariables[id]) {
updatedVariables[id] = {
@@ -119,7 +119,7 @@ function DashboardVariableSelection(): JSX.Element | null {
}
updateDashboardVariablesStore({ dashboardId, variables: updatedVariables });
setSelectedDashboard((prev) => {
setDashboardData((prev) => {
if (prev) {
const oldVariables = { ...prev?.data.variables };
// this is added to handle case where we have two different
@@ -157,7 +157,7 @@ function DashboardVariableSelection(): JSX.Element | null {
// Safe to call synchronously now that the store already has the updated value.
enqueueDescendantsOfVariable(name);
},
[dashboardId, dashboardVariables, updateUrlVariable, setSelectedDashboard],
[dashboardId, dashboardVariables, updateUrlVariable, setDashboardData],
);
return (

View File

@@ -30,11 +30,11 @@ const mockVariableItemCallbacks: {
} = {};
// Mock providers/Dashboard/Dashboard
const mockSetSelectedDashboard = jest.fn();
const mockSetDashboardData = jest.fn();
const mockUpdateLocalStorageDashboardVariables = jest.fn();
interface MockDashboardStoreState {
selectedDashboard?: { id: string };
setSelectedDashboard: typeof mockSetSelectedDashboard;
dashboardData?: { id: string };
setDashboardData: typeof mockSetDashboardData;
updateLocalStorageDashboardVariables: typeof mockUpdateLocalStorageDashboardVariables;
}
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
@@ -42,8 +42,8 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
selector?: (s: Record<string, unknown>) => MockDashboardStoreState,
): MockDashboardStoreState => {
const state = {
selectedDashboard: { id: 'dash-1' },
setSelectedDashboard: mockSetSelectedDashboard,
dashboardData: { id: 'dash-1' },
setDashboardData: mockSetDashboardData,
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
};
return selector ? selector(state) : state;

View File

@@ -38,15 +38,11 @@ interface UseDashboardVariableUpdateReturn {
}
export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn => {
const {
dashboardId,
selectedDashboard,
setSelectedDashboard,
} = useDashboardStore(
const { dashboardId, dashboardData, setDashboardData } = useDashboardStore(
useShallow((s) => ({
dashboardId: s.selectedDashboard?.id ?? '',
selectedDashboard: s.selectedDashboard,
setSelectedDashboard: s.setSelectedDashboard,
dashboardId: s.dashboardData?.id ?? '',
dashboardData: s.dashboardData,
setDashboardData: s.setDashboardData,
})),
);
const addDynamicVariableToPanels = useAddDynamicVariableToPanels();
@@ -74,8 +70,8 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
isDynamic,
);
if (selectedDashboard) {
setSelectedDashboard((prev) => {
if (dashboardData) {
setDashboardData((prev) => {
if (prev) {
const oldVariables = prev?.data.variables;
// this is added to handle case where we have two different
@@ -110,7 +106,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
}
}
},
[dashboardId, selectedDashboard, setSelectedDashboard],
[dashboardId, dashboardData, setDashboardData],
);
const updateVariables = useCallback(
@@ -120,23 +116,23 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
widgetIds?: string[],
applyToAll?: boolean,
): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
const newDashboard =
(currentRequestedId &&
addDynamicVariableToPanels(
selectedDashboard,
dashboardData,
updatedVariablesData[currentRequestedId || ''],
widgetIds,
applyToAll,
)) ||
selectedDashboard;
dashboardData;
updateMutation.mutateAsync(
{
id: selectedDashboard.id,
id: dashboardData.id,
data: {
...newDashboard.data,
@@ -146,7 +142,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
setDashboardData(updatedDashboard.data);
// notifications.success({
// message: t('variable_updated_successfully'),
// });
@@ -155,12 +151,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
},
);
},
[
selectedDashboard,
addDynamicVariableToPanels,
updateMutation,
setSelectedDashboard,
],
[dashboardData, addDynamicVariableToPanels, updateMutation, setDashboardData],
);
const createVariable = useCallback(
@@ -172,13 +163,13 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
source: 'logs' | 'traces' | 'metrics' | 'all sources' = 'all sources',
// widgetId?: string,
): void => {
if (!selectedDashboard) {
if (!dashboardData) {
console.warn('No dashboard selected for variable creation');
return;
}
// Get current dashboard variables
const currentVariables = selectedDashboard.data.variables || {};
const currentVariables = dashboardData.data.variables || {};
// Create tableRowData like Dashboard Settings does
const tableRowData = [];
@@ -234,7 +225,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
const updatedVariables = convertVariablesToDbFormat(tableRowData);
updateVariables(updatedVariables, newVariable.id, [], false);
},
[selectedDashboard, updateVariables],
[dashboardData, updateVariables],
);
return {

View File

@@ -47,12 +47,12 @@ const mockDashboard = {
};
// Mock the dashboard provider with stable functions to prevent infinite loops
const mockSetSelectedDashboard = jest.fn();
const mockSetDashboardData = jest.fn();
const mockUpdateLocalStorageDashboardVariables = jest.fn();
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
selectedDashboard: mockDashboard,
setSelectedDashboard: mockSetSelectedDashboard,
dashboardData: mockDashboard,
setDashboardData: mockSetDashboardData,
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
}),
}));

View File

@@ -58,7 +58,7 @@ const mockDashboard = {
// Mock dependencies
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
selectedDashboard: mockDashboard,
dashboardData: mockDashboard,
}),
}));
@@ -154,7 +154,7 @@ describe('Panel Management Tests', () => {
// Temporarily mock the dashboard
jest.doMock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
selectedDashboard: modifiedDashboard,
dashboardData: modifiedDashboard,
}),
}));

View File

@@ -13,13 +13,13 @@ import './DashboardBreadcrumbs.styles.scss';
function DashboardBreadcrumbs(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { selectedDashboard } = useDashboardStore();
const updatedAtRef = useRef(selectedDashboard?.updatedAt);
const { dashboardData } = useDashboardStore();
const updatedAtRef = useRef(dashboardData?.updatedAt);
const selectedData = selectedDashboard
const selectedData = dashboardData
? {
...selectedDashboard.data,
uuid: selectedDashboard.id,
...dashboardData.data,
uuid: dashboardData.id,
}
: ({} as DashboardData);
@@ -31,7 +31,7 @@ function DashboardBreadcrumbs(): JSX.Element {
);
const hasDashboardBeenUpdated =
selectedDashboard?.updatedAt !== updatedAtRef.current;
dashboardData?.updatedAt !== updatedAtRef.current;
if (!hasDashboardBeenUpdated && dashboardsListQueryParamsString) {
safeNavigate({
pathname: ROUTES.ALL_DASHBOARD,
@@ -40,7 +40,7 @@ function DashboardBreadcrumbs(): JSX.Element {
} else {
safeNavigate(ROUTES.ALL_DASHBOARD);
}
}, [safeNavigate, selectedDashboard?.updatedAt]);
}, [safeNavigate, dashboardData?.updatedAt]);
return (
<div className="dashboard-breadcrumbs">

View File

@@ -35,15 +35,15 @@ jest.mock('lib/uPlotV2/hooks/useLegendsSync', () => ({
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (
selector?: (s: {
selectedDashboard: { locked: boolean } | undefined;
}) => { selectedDashboard: { locked: boolean } },
): { selectedDashboard: { locked: boolean } } => {
const mockState = { selectedDashboard: { locked: false } };
dashboardData: { locked: boolean } | undefined;
}) => { dashboardData: { locked: boolean } },
): { dashboardData: { locked: boolean } } => {
const mockState = { dashboardData: { locked: false } };
return selector ? selector(mockState) : mockState;
},
selectIsDashboardLocked: (s: {
selectedDashboard: { locked: boolean } | undefined;
}): boolean => s.selectedDashboard?.locked ?? false,
dashboardData: { locked: boolean } | undefined;
}): boolean => s.dashboardData?.locked ?? false,
}));
jest.mock('hooks/useNotifications', () => ({

View File

@@ -1,15 +1,14 @@
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import { RuletypesAlertTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { dataSourceForAlertType } from 'constants/alerts';
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export function sanitizeDefaultAlertQuery(
query: Query,
alertType: AlertTypes,
alertType: RuletypesAlertTypeDTO | undefined,
): Query {
// If there are no queries, add a default one based on the alert type
if (query.builder.queryData.length === 0) {
const dataSource = ALERTS_DATA_SOURCE_MAP[alertType];
const dataSource = dataSourceForAlertType(alertType);
query.builder.queryData.push(initialQueryBuilderFormValuesMap[dataSource]);
}
return query;

View File

@@ -24,9 +24,7 @@ function ExportPanelContainer({
}: ExportPanelProps): JSX.Element {
const { t } = useTranslation(['dashboard']);
const [selectedDashboardId, setSelectedDashboardId] = useState<string | null>(
null,
);
const [dashboardId, setDashboardId] = useState<string | null>(null);
const {
data,
@@ -55,17 +53,17 @@ function ExportPanelContainer({
const handleExportClick = useCallback((): void => {
const currentSelectedDashboard = data?.data?.find(
({ id }) => id === selectedDashboardId,
({ id }) => id === dashboardId,
);
onExport(currentSelectedDashboard || null, false);
}, [data, selectedDashboardId, onExport]);
}, [data, dashboardId, onExport]);
const handleSelect = useCallback(
(selectedDashboardValue: string): void => {
setSelectedDashboardId(selectedDashboardValue);
(selectedDashboardId: string): void => {
setDashboardId(selectedDashboardId);
},
[setSelectedDashboardId],
[setDashboardId],
);
const handleNewDashboard = useCallback(async () => {
@@ -85,10 +83,7 @@ function ExportPanelContainer({
const isDashboardLoading = isAllDashboardsLoading || createDashboardLoading;
const isDisabled =
isAllDashboardsLoading ||
!options?.length ||
!selectedDashboardId ||
isLoading;
isAllDashboardsLoading || !options?.length || !dashboardId || isLoading;
return (
<Wrapper direction="vertical">
@@ -101,7 +96,7 @@ function ExportPanelContainer({
showSearch
loading={isDashboardLoading}
disabled={isDashboardLoading}
value={selectedDashboardId}
value={dashboardId}
onSelect={handleSelect}
filterOption={filterOptions}
/>

View File

@@ -24,6 +24,12 @@ import { FormContainer, StepHeading } from './styles';
import './QuerySection.styles.scss';
const ANOMALY_QUERY_SUPPORT_CLICKHOUSE_ISSUE =
'https://github.com/SigNoz/signoz/issues/11034';
const ANOMALY_QUERY_SUPPORT_PROMQL_ISSUE =
'https://github.com/SigNoz/signoz/issues/11036';
function QuerySection({
queryCategory,
setQueryCategory,
@@ -33,6 +39,7 @@ function QuerySection({
panelType,
ruleId,
hideTitle,
isAnomalyDetection,
}: QuerySectionProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('alerts');
@@ -74,6 +81,21 @@ function QuerySection({
/>
);
const anomalyDisabledTooltip = (url: string): JSX.Element => (
<span>
Coming soon for anomaly detection.{' '}
<Typography.Link
href={url}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'inherit', textDecoration: 'underline' }}
>
Leave a thumbs-up
</Typography.Link>{' '}
to help us prioritize!
</span>
);
const tabs = [
{
label: (
@@ -88,17 +110,31 @@ function QuerySection({
},
{
label: (
<Tooltip title="ClickHouse">
<Button className="nav-btns">
<Tooltip
title={
isAnomalyDetection
? anomalyDisabledTooltip(ANOMALY_QUERY_SUPPORT_CLICKHOUSE_ISSUE)
: 'ClickHouse'
}
>
<Button className="nav-btns" disabled={isAnomalyDetection}>
<Terminal size={14} />
<Typography.Text>ClickHouse Query</Typography.Text>
</Button>
</Tooltip>
),
key: EQueryType.CLICKHOUSE,
disabled: isAnomalyDetection,
},
];
useEffect(() => {
if (isAnomalyDetection && queryCategory !== EQueryType.QUERY_BUILDER) {
setQueryCategory(EQueryType.QUERY_BUILDER);
setCurrentTab(EQueryType.QUERY_BUILDER);
}
}, [isAnomalyDetection, queryCategory, setQueryCategory]);
const items = useMemo(
() => [
{
@@ -114,19 +150,32 @@ function QuerySection({
},
{
label: (
<Tooltip title="ClickHouse">
<Button className="nav-btns">
<Tooltip
title={
isAnomalyDetection
? anomalyDisabledTooltip(ANOMALY_QUERY_SUPPORT_CLICKHOUSE_ISSUE)
: 'ClickHouse'
}
>
<Button className="nav-btns" disabled={isAnomalyDetection}>
<Terminal size={14} />
<Typography.Text>ClickHouse Query</Typography.Text>
</Button>
</Tooltip>
),
key: EQueryType.CLICKHOUSE,
disabled: isAnomalyDetection,
},
{
label: (
<Tooltip title="PromQL">
<Button className="nav-btns">
<Tooltip
title={
isAnomalyDetection
? anomalyDisabledTooltip(ANOMALY_QUERY_SUPPORT_PROMQL_ISSUE)
: 'PromQL'
}
>
<Button className="nav-btns" disabled={isAnomalyDetection}>
<PromQLIcon
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
/>
@@ -135,9 +184,10 @@ function QuerySection({
</Tooltip>
),
key: EQueryType.PROM,
disabled: isAnomalyDetection,
},
],
[isDarkMode],
[isDarkMode, isAnomalyDetection, anomalyDisabledTooltip],
);
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
@@ -205,16 +255,16 @@ function QuerySection({
}
};
const renderQuerySection = (c: EQueryType): JSX.Element | null => {
switch (c) {
case EQueryType.PROM:
return renderPromqlUI();
case EQueryType.CLICKHOUSE:
return renderChQueryUI();
case EQueryType.QUERY_BUILDER:
return renderMetricUI();
default:
return null;
if (c === EQueryType.PROM && !isAnomalyDetection) {
return renderPromqlUI();
}
if (c === EQueryType.CLICKHOUSE && !isAnomalyDetection) {
return renderChQueryUI();
}
if (c === EQueryType.QUERY_BUILDER) {
return renderMetricUI();
}
return null;
};
const step2Label = alertDef.alertType === 'METRIC_BASED_ALERT' ? '2' : '1';
@@ -241,10 +291,12 @@ interface QuerySectionProps {
panelType: PANEL_TYPES;
ruleId: string;
hideTitle?: boolean;
isAnomalyDetection?: boolean;
}
QuerySection.defaultProps = {
hideTitle: false,
isAnomalyDetection: false,
};
export default QuerySection;

View File

@@ -6,9 +6,15 @@ import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { ExclamationCircleOutlined, SaveOutlined } from '@ant-design/icons';
import { Button, FormInstance, Modal, SelectProps, Typography } from 'antd';
import saveAlertApi from 'api/alerts/save';
import testAlertApi from 'api/alerts/testAlert';
import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createRule,
testRule,
updateRuleByID,
} from 'api/generated/services/rules';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
@@ -32,13 +38,16 @@ import { isEmpty, isEqual } from 'lodash-es';
import { BellDot, ExternalLink } from 'lucide-react';
import Tabs2 from 'periscope/components/Tabs2';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { AppState } from 'store/reducers';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
import {
AlertDef,
defaultEvalWindow,
defaultMatchType,
} from 'types/api/alerts/def';
import APIError from 'types/api/error';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryFunction } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
@@ -368,6 +377,7 @@ function FormAlertRules({
redirectWithQueryBuilderData(query);
};
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const validatePromParams = useCallback((): boolean => {
let retval = true;
@@ -533,59 +543,47 @@ function FormAlertRules({
};
try {
const apiReq =
ruleId && !isEmpty(ruleId)
? { data: postableAlert, id: ruleId }
: { data: postableAlert };
const response = await saveAlertApi(apiReq);
if (response.statusCode === 200) {
logData = {
status: 'success',
statusMessage: isNewRule ? t('rule_created') : t('rule_edited'),
};
notifications.success({
message: 'Success',
description: logData.statusMessage,
});
// invalidate rule in cache
ruleCache.invalidateQueries([
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
`${ruleId}`,
]);
// eslint-disable-next-line sonarjs/no-identical-functions
setTimeout(() => {
urlQuery.delete(QueryParams.compositeQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);
safeNavigate(`${ROUTES.LIST_ALL_ALERT}?${urlQuery.toString()}`);
}, 2000);
if (ruleId && !isEmpty(ruleId)) {
await updateRuleByID(
{ id: ruleId },
toPostableRuleDTOFromAlertDef(postableAlert),
);
} else {
logData = {
status: 'error',
statusMessage: response.error || t('unexpected_error'),
};
notifications.error({
message: 'Error',
description: logData.statusMessage,
});
await createRule(toPostableRuleDTOFromAlertDef(postableAlert));
}
} catch (e) {
logData = {
status: 'error',
statusMessage: t('unexpected_error'),
status: 'success',
statusMessage: isNewRule ? t('rule_created') : t('rule_edited'),
};
notifications.error({
message: 'Error',
notifications.success({
message: 'Success',
description: logData.statusMessage,
});
// invalidate rule in cache
ruleCache.invalidateQueries([
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
`${ruleId}`,
]);
// eslint-disable-next-line sonarjs/no-identical-functions
setTimeout(() => {
urlQuery.delete(QueryParams.compositeQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);
safeNavigate(`${ROUTES.LIST_ALL_ALERT}?${urlQuery.toString()}`);
}, 2000);
} catch (e) {
const apiError = convertToApiError(e as AxiosError<RenderErrorResponseDTO>);
logData = {
status: 'error',
statusMessage: apiError?.getErrorMessage() || t('unexpected_error'),
};
showErrorModal(apiError as APIError);
}
setLoading(false);
@@ -641,39 +639,30 @@ function FormAlertRules({
let statusResponse = { status: 'failed', message: '' };
setLoading(true);
try {
const response = await testAlertApi({ data: postableAlert });
const response = await testRule(
toPostableRuleDTOFromAlertDef(postableAlert),
);
if (response.statusCode === 200) {
const { payload } = response;
if (payload?.alertCount === 0) {
notifications.error({
message: 'Error',
description: t('no_alerts_found'),
});
statusResponse = { status: 'failed', message: t('no_alerts_found') };
} else {
notifications.success({
message: 'Success',
description: t('rule_test_fired'),
});
statusResponse = { status: 'success', message: t('rule_test_fired') };
}
} else {
if (response.data?.alertCount === 0) {
notifications.error({
message: 'Error',
description: response.error || t('unexpected_error'),
description: t('no_alerts_found'),
});
statusResponse = {
status: 'failed',
message: response.error || t('unexpected_error'),
};
statusResponse = { status: 'failed', message: t('no_alerts_found') };
} else {
notifications.success({
message: 'Success',
description: t('rule_test_fired'),
});
statusResponse = { status: 'success', message: t('rule_test_fired') };
}
} catch (e) {
notifications.error({
message: 'Error',
description: t('unexpected_error'),
});
statusResponse = { status: 'failed', message: t('unexpected_error') };
const apiError = convertToApiError(e as AxiosError<RenderErrorResponseDTO>);
statusResponse = {
status: 'failed',
message: apiError?.getErrorMessage() || t('unexpected_error'),
};
showErrorModal(apiError as APIError);
}
setLoading(false);
logEvent('Alert: Test notification', {
@@ -918,6 +907,9 @@ function FormAlertRules({
panelType={panelType || PANEL_TYPES.TIME_SERIES}
key={currentQuery.queryType}
ruleId={ruleId}
isAnomalyDetection={
alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT
}
/>
<RuleOptions

View File

@@ -27,7 +27,7 @@ export default function DashboardEmptyState(): JSX.Element {
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const variablesSettingsTabHandle = useRef<VariablesSettingsTab>(null);
@@ -43,7 +43,7 @@ export default function DashboardEmptyState(): JSX.Element {
}
const userRole: ROLES | null =
selectedDashboard?.createdBy === user?.email
dashboardData?.createdBy === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: user.role;
@@ -52,9 +52,9 @@ export default function DashboardEmptyState(): JSX.Element {
const onEmptyWidgetHandler = useCallback(() => {
setIsPanelTypeSelectionModalOpen(true);
logEvent('Dashboard Detail: Add new panel clicked', {
dashboardId: selectedDashboard?.id,
dashboardName: selectedDashboard?.data.title,
numberOfPanels: selectedDashboard?.data.widgets?.length,
dashboardId: dashboardData?.id,
dashboardName: dashboardData?.data.title,
numberOfPanels: dashboardData?.data.widgets?.length,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setIsPanelTypeSelectionModalOpen]);

View File

@@ -91,7 +91,7 @@ function FullView({
setCurrentGraphRef(fullViewRef);
}, [setCurrentGraphRef]);
const { selectedDashboard, setColumnWidths } = useDashboardStore();
const { dashboardData, setColumnWidths } = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const onColumnWidthsChange = useCallback(
@@ -166,7 +166,7 @@ function FullView({
enableDrillDown,
widget,
setRequestData,
selectedDashboard,
dashboardData,
selectedPanelType,
});
@@ -344,7 +344,7 @@ function FullView({
<>
<QueryBuilderV2
panelType={selectedPanelType}
version={selectedDashboard?.data?.version || 'v3'}
version={dashboardData?.data?.version || 'v3'}
isListViewPanel={selectedPanelType === PANEL_TYPES.LIST}
signalSourceChangeEnabled
// filterConfigs={filterConfigs}

View File

@@ -19,7 +19,7 @@ export interface DrilldownQueryProps {
widget: Widgets;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
enableDrillDown: boolean;
selectedDashboard: Dashboard | undefined;
dashboardData: Dashboard | undefined;
selectedPanelType: PANEL_TYPES;
}
@@ -34,7 +34,7 @@ const useDrilldown = ({
enableDrillDown,
widget,
setRequestData,
selectedDashboard,
dashboardData,
selectedPanelType,
}: DrilldownQueryProps): UseDrilldownReturn => {
const isMounted = useRef(false);
@@ -60,11 +60,11 @@ const useDrilldown = ({
isMounted.current = true;
}, [widget, enableDrillDown, compositeQuery, redirectWithQueryBuilderData]);
const dashboardEditView = selectedDashboard?.id
const dashboardEditView = dashboardData?.id
? generateExportToDashboardLink({
query: currentQuery,
panelType: selectedPanelType,
dashboardId: selectedDashboard?.id || '',
dashboardId: dashboardData?.id || '',
widgetId: widget.id,
})
: '';

View File

@@ -163,13 +163,13 @@ const mockProps: WidgetGraphComponentProps = {
// Mock useDashabord hook
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
selectedDashboard: {
dashboardData: {
data: {
variables: [],
},
},
setLayouts: jest.fn(),
setSelectedDashboard: jest.fn(),
setDashboardData: jest.fn(),
setColumnWidths: jest.fn(),
}),
}));

View File

@@ -103,8 +103,8 @@ function WidgetGraphComponent({
const {
setLayouts,
selectedDashboard,
setSelectedDashboard,
dashboardData,
setDashboardData,
setColumnWidths,
} = useDashboardStore();
@@ -125,33 +125,33 @@ function WidgetGraphComponent({
const updateDashboardMutation = useUpdateDashboard();
const onDeleteHandler = (): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
const updatedWidgets = selectedDashboard?.data?.widgets?.filter(
const updatedWidgets = dashboardData?.data?.widgets?.filter(
(e) => e.id !== widget.id,
);
const updatedLayout =
selectedDashboard.data.layout?.filter((e) => e.i !== widget.id) || [];
dashboardData.data.layout?.filter((e) => e.i !== widget.id) || [];
const updatedSelectedDashboard: Props = {
const updatedDashboardData: Props = {
data: {
...selectedDashboard.data,
...dashboardData.data,
widgets: updatedWidgets,
layout: updatedLayout,
},
id: selectedDashboard.id,
id: dashboardData.id,
};
updateDashboardMutation.mutateAsync(updatedSelectedDashboard, {
updateDashboardMutation.mutateAsync(updatedDashboardData, {
onSuccess: (updatedDashboard) => {
if (setLayouts) {
setLayouts(updatedDashboard.data?.data?.layout || []);
}
if (setSelectedDashboard && updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
if (setDashboardData && updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
}
setDeleteModal(false);
},
@@ -159,35 +159,35 @@ function WidgetGraphComponent({
};
const onCloneHandler = async (): Promise<void> => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
const uuid = v4();
// this is added to make sure the cloned panel is of the same dimensions as the original one
const originalPanelLayout = selectedDashboard.data.layout?.find(
const originalPanelLayout = dashboardData.data.layout?.find(
(l) => l.i === widget.id,
);
const newLayoutItem = placeWidgetAtBottom(
uuid,
selectedDashboard?.data.layout || [],
dashboardData?.data.layout || [],
originalPanelLayout?.w || 6,
originalPanelLayout?.h || 6,
);
const layout = [...(selectedDashboard.data.layout || []), newLayoutItem];
const layout = [...(dashboardData.data.layout || []), newLayoutItem];
updateDashboardMutation.mutateAsync(
{
id: selectedDashboard.id,
id: dashboardData.id,
data: {
...selectedDashboard.data,
...dashboardData.data,
layout,
widgets: [
...(selectedDashboard.data.widgets || []),
...(dashboardData.data.widgets || []),
{
...{
...widget,
@@ -202,8 +202,8 @@ function WidgetGraphComponent({
if (setLayouts) {
setLayouts(updatedDashboard.data?.data?.layout || []);
}
if (setSelectedDashboard && updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
if (setDashboardData && updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
}
notifications.success({
message: 'Panel cloned successfully, redirecting to new copy.',

View File

@@ -70,16 +70,16 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
useIsFetching([REACT_QUERY_KEY.DASHBOARD_BY_ID]) > 0;
const {
selectedDashboard,
dashboardData,
layouts,
setLayouts,
panelMap,
setPanelMap,
setSelectedDashboard,
setDashboardData,
columnWidths,
} = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const { data } = selectedDashboard || {};
const { data } = dashboardData || {};
const { pathname } = useLocation();
const dispatch = useDispatch();
@@ -124,7 +124,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
}
const userRole: ROLES | null =
selectedDashboard?.createdBy === user?.email
dashboardData?.createdBy === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: user.role;
@@ -146,27 +146,27 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
useEffect(() => {
if (!logEventCalledRef.current && !isUndefined(data)) {
logEvent('Dashboard Detail: Opened', {
dashboardId: selectedDashboard?.id,
dashboardId: dashboardData?.id,
dashboardName: data.title,
numberOfPanels: data.widgets?.length,
numberOfVariables: Object.keys(dashboardVariables).length || 0,
});
logEventCalledRef.current = true;
}
}, [dashboardVariables, data, selectedDashboard?.id]);
}, [dashboardVariables, data, dashboardData?.id]);
const onSaveHandler = (): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
const updatedDashboard: Props = {
id: selectedDashboard.id,
id: dashboardData.id,
data: {
...selectedDashboard.data,
...dashboardData.data,
panelMap: { ...currentPanelMap },
layout: dashboardLayout.filter((e) => e.i !== PANEL_TYPES.EMPTY_WIDGET),
widgets: selectedDashboard?.data?.widgets?.map((widget) => {
widgets: dashboardData?.data?.widgets?.map((widget) => {
if (columnWidths?.[widget.id]) {
return {
...widget,
@@ -184,7 +184,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
if (updatedDashboard.data.data.layout) {
setLayouts(sortLayout(updatedDashboard.data.data.layout));
}
setSelectedDashboard(updatedDashboard.data);
setDashboardData(updatedDashboard.data);
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
}
},
@@ -243,7 +243,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
dashboardLayout &&
Array.isArray(dashboardLayout) &&
dashboardLayout.length > 0 &&
hasColumnWidthsChanged(columnWidths, selectedDashboard);
hasColumnWidthsChanged(columnWidths, dashboardData);
if (shouldSaveLayout || shouldSaveColumnWidths) {
onSaveHandler();
@@ -253,7 +253,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
const onSettingsModalSubmit = (): void => {
const newTitle = form.getFieldValue('title');
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
@@ -261,7 +261,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
return;
}
const currentWidget = selectedDashboard?.data?.widgets?.find(
const currentWidget = dashboardData?.data?.widgets?.find(
(e) => e.id === currentSelectRowId,
);
@@ -269,25 +269,25 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
return;
}
const updatedWidgets = selectedDashboard?.data?.widgets?.map((e) =>
const updatedWidgets = dashboardData?.data?.widgets?.map((e) =>
e.id === currentSelectRowId ? { ...e, title: newTitle } : e,
);
const updatedSelectedDashboard: Props = {
id: selectedDashboard.id,
const updatedDashboardData: Props = {
id: dashboardData.id,
data: {
...selectedDashboard.data,
...dashboardData.data,
widgets: updatedWidgets,
},
};
updateDashboardMutation.mutateAsync(updatedSelectedDashboard, {
updateDashboardMutation.mutateAsync(updatedDashboardData, {
onSuccess: (updatedDashboard) => {
if (setLayouts) {
setLayouts(updatedDashboard.data?.data?.layout || []);
}
if (setSelectedDashboard && updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
if (setDashboardData && updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
}
if (setPanelMap) {
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
@@ -311,7 +311,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
}, [currentSelectRowId, form, widgets]);
const handleRowCollapse = (id: string): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
const { updatedLayout, updatedPanelMap } = applyRowCollapse(
@@ -343,7 +343,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
};
const handleRowDelete = (): void => {
if (!selectedDashboard) {
if (!dashboardData) {
return;
}
@@ -351,34 +351,33 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
return;
}
const updatedWidgets = selectedDashboard?.data?.widgets?.filter(
const updatedWidgets = dashboardData?.data?.widgets?.filter(
(e) => e.id !== currentSelectRowId,
);
const updatedLayout =
selectedDashboard.data.layout?.filter((e) => e.i !== currentSelectRowId) ||
[];
dashboardData.data.layout?.filter((e) => e.i !== currentSelectRowId) || [];
const updatedPanelMap = { ...currentPanelMap };
delete updatedPanelMap[currentSelectRowId];
const updatedSelectedDashboard: Props = {
id: selectedDashboard.id,
const updatedDashboardData: Props = {
id: dashboardData.id,
data: {
...selectedDashboard.data,
...dashboardData.data,
widgets: updatedWidgets,
layout: updatedLayout,
panelMap: updatedPanelMap,
},
};
updateDashboardMutation.mutateAsync(updatedSelectedDashboard, {
updateDashboardMutation.mutateAsync(updatedDashboardData, {
onSuccess: (updatedDashboard) => {
if (setLayouts) {
setLayouts(updatedDashboard.data?.data?.layout || []);
}
if (setSelectedDashboard && updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
if (setDashboardData && updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
}
if (setPanelMap) {
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
@@ -390,10 +389,8 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
};
const isDashboardEmpty = useMemo(
() =>
selectedDashboard?.data.layout
? selectedDashboard?.data.layout?.length === 0
: true,
[selectedDashboard],
dashboardData?.data.layout ? dashboardData?.data.layout?.length === 0 : true,
[dashboardData],
);
let isDataAvailableInAnyWidget = false;
@@ -512,7 +509,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
widget={(currentWidget as Widgets) || ({ id, query: {} } as Widgets)}
headerMenuList={widgetActions}
variables={dashboardVariables}
// version={selectedDashboard?.data?.version}
// version={dashboardData?.data?.version}
version={ENTITY_VERSION_V5}
onDragSelect={onDragSelect}
dataAvailable={checkIfDataExists}

View File

@@ -42,14 +42,14 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const { selectedDashboard } = useDashboardStore();
const { dashboardData } = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const permissions: ComponentTypes[] = ['add_panel'];
const { user } = useAppContext();
const userRole: ROLES | null =
selectedDashboard?.createdBy === user?.email
dashboardData?.createdBy === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: user.role;
const [addPanelPermission] = useComponentPermission(permissions, userRole);
@@ -87,11 +87,11 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
icon={<Plus size={14} />}
onClick={(): void => {
// TODO: @AshwinBhatkal Simplify this check in cleanup of https://github.com/SigNoz/engineering-pod/issues/3953
if (!selectedDashboard?.id) {
if (!dashboardData?.id) {
return;
}
setSelectedRowWidgetId(selectedDashboard.id, id);
setSelectedRowWidgetId(dashboardData.id, id);
setIsPanelTypeSelectionModalOpen(true);
}}
>

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