mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-16 17:00:28 +01:00
Compare commits
16 Commits
refactor/t
...
user-roles
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b80af505a3 | ||
|
|
5e122de0ba | ||
|
|
4c292ccfe2 | ||
|
|
faa964fb1d | ||
|
|
73ecbb3951 | ||
|
|
244216e49f | ||
|
|
be47fd5316 | ||
|
|
6f513b9497 | ||
|
|
1f9bfb5d97 | ||
|
|
e66dec439c | ||
|
|
ffac1a8768 | ||
|
|
b0859f3594 | ||
|
|
6cd896b49b | ||
|
|
84adb743f2 | ||
|
|
3f6e20037a | ||
|
|
4a6241acc7 |
3
.github/workflows/integrationci.yaml
vendored
3
.github/workflows/integrationci.yaml
vendored
@@ -55,8 +55,6 @@ jobs:
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
sqlite-mode:
|
||||
- wal
|
||||
clickhouse-version:
|
||||
- 25.5.6
|
||||
- 25.12.5
|
||||
@@ -104,7 +102,6 @@ jobs:
|
||||
--basetemp=./tmp/ \
|
||||
src/${{matrix.src}} \
|
||||
--sqlstore-provider ${{matrix.sqlstore-provider}} \
|
||||
--sqlite-mode ${{matrix.sqlite-mode}} \
|
||||
--postgres-version ${{matrix.postgres-version}} \
|
||||
--clickhouse-version ${{matrix.clickhouse-version}} \
|
||||
--schema-migrator-version ${{matrix.schema-migrator-version}}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -51,8 +51,6 @@ ee/query-service/tests/test-deploy/data/
|
||||
# local data
|
||||
*.backup
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
**/db
|
||||
/deploy/docker/clickhouse-setup/data/
|
||||
/deploy/docker-swarm/clickhouse-setup/data/
|
||||
|
||||
@@ -86,7 +86,7 @@ sqlstore:
|
||||
# The path to the SQLite database file.
|
||||
path: /var/lib/signoz/signoz.db
|
||||
# The journal mode for the sqlite database. Supported values: delete, wal.
|
||||
mode: wal
|
||||
mode: delete
|
||||
# The timeout for the sqlite database to wait for a lock.
|
||||
busy_timeout: 10s
|
||||
# The default transaction locking behavior. Supported values: deferred, immediate, exclusive.
|
||||
|
||||
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.118.0
|
||||
image: signoz/signoz:v0.117.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.118.0
|
||||
image: signoz/signoz:v0.117.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -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.118.0}
|
||||
image: signoz/signoz:${VERSION:-v0.117.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -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.118.0}
|
||||
image: signoz/signoz:${VERSION:-v0.117.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -327,6 +327,27 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
AuthtypesStorableRole:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
AuthtypesTransaction:
|
||||
properties:
|
||||
object:
|
||||
@@ -350,7 +371,7 @@ components:
|
||||
id:
|
||||
type: string
|
||||
role:
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
$ref: '#/components/schemas/AuthtypesStorableRole'
|
||||
roleId:
|
||||
type: string
|
||||
updatedAt:
|
||||
@@ -360,11 +381,6 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- userId
|
||||
- roleId
|
||||
- createdAt
|
||||
- updatedAt
|
||||
- role
|
||||
type: object
|
||||
AuthtypesUserWithRoles:
|
||||
properties:
|
||||
@@ -2297,6 +2313,15 @@ components:
|
||||
- status
|
||||
- error
|
||||
type: object
|
||||
RulestatehistorytypesAlertState:
|
||||
enum:
|
||||
- inactive
|
||||
- pending
|
||||
- recovering
|
||||
- firing
|
||||
- nodata
|
||||
- disabled
|
||||
type: string
|
||||
RulestatehistorytypesGettableRuleStateHistory:
|
||||
properties:
|
||||
fingerprint:
|
||||
@@ -2308,15 +2333,15 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
overallState:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
|
||||
overallStateChanged:
|
||||
type: boolean
|
||||
ruleId:
|
||||
ruleID:
|
||||
type: string
|
||||
ruleName:
|
||||
type: string
|
||||
state:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
|
||||
stateChanged:
|
||||
type: boolean
|
||||
unixMilli:
|
||||
@@ -2326,7 +2351,7 @@ components:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- ruleId
|
||||
- ruleID
|
||||
- ruleName
|
||||
- overallState
|
||||
- overallStateChanged
|
||||
@@ -2416,21 +2441,12 @@ components:
|
||||
format: int64
|
||||
type: integer
|
||||
state:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
|
||||
required:
|
||||
- state
|
||||
- start
|
||||
- end
|
||||
type: object
|
||||
RuletypesAlertState:
|
||||
enum:
|
||||
- inactive
|
||||
- pending
|
||||
- recovering
|
||||
- firing
|
||||
- nodata
|
||||
- disabled
|
||||
type: string
|
||||
ServiceaccounttypesGettableFactorAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -8453,7 +8469,7 @@ paths:
|
||||
- in: query
|
||||
name: state
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
|
||||
- in: query
|
||||
name: filterExpression
|
||||
schema:
|
||||
|
||||
@@ -193,7 +193,6 @@ uv run pytest --basetemp=./tmp/ -vv --reuse src/passwordauthn/01_register.py::te
|
||||
Tests can be configured using pytest options:
|
||||
|
||||
- `--sqlstore-provider` - Choose database provider (default: postgres)
|
||||
- `--sqlite-mode` - SQLite journal mode: `delete` or `wal` (default: delete). Only relevant when `--sqlstore-provider=sqlite`.
|
||||
- `--postgres-version` - PostgreSQL version (default: 15)
|
||||
- `--clickhouse-version` - ClickHouse version (default: 25.5.6)
|
||||
- `--zookeeper-version` - Zookeeper version (default: 3.7.1)
|
||||
@@ -203,6 +202,7 @@ Example:
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse --sqlstore-provider=postgres --postgres-version=14 src/auth/
|
||||
```
|
||||
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- **Always use the `--reuse` flag** when setting up the environment to keep containers running
|
||||
@@ -213,4 +213,3 @@ uv run pytest --basetemp=./tmp/ -vv --reuse --sqlstore-provider=postgres --postg
|
||||
- **Use descriptive test names** that clearly indicate what is being tested
|
||||
- **Leverage fixtures** for common setup and authentication
|
||||
- **Test both success and failure scenarios** to ensure robust functionality
|
||||
- **`--sqlite-mode=wal` does not work on macOS.** The integration test environment runs SigNoz inside a Linux container with the SQLite database file mounted from the macOS host. WAL mode requires shared memory between connections, and connections crossing the VM boundary (macOS host ↔ Linux container) cannot share the WAL index, resulting in `SQLITE_IOERR_SHORT_READ`. WAL mode is tested in CI on Linux only.
|
||||
|
||||
@@ -32,7 +32,7 @@ func (s Seasonality) IsValid() bool {
|
||||
}
|
||||
|
||||
type AnomaliesRequest struct {
|
||||
Params *qbtypes.QueryRangeRequest
|
||||
Params qbtypes.QueryRangeRequest
|
||||
Seasonality Seasonality
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ type anomalyQueryParams struct {
|
||||
Past3SeasonQuery qbtypes.QueryRangeRequest
|
||||
}
|
||||
|
||||
func prepareAnomalyQueryParams(req *qbtypes.QueryRangeRequest, seasonality Seasonality) *anomalyQueryParams {
|
||||
func prepareAnomalyQueryParams(req qbtypes.QueryRangeRequest, seasonality Seasonality) *anomalyQueryParams {
|
||||
start := req.Start
|
||||
end := req.End
|
||||
|
||||
|
||||
@@ -76,12 +76,12 @@ func (provider *provider) Start(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (provider *provider) Audit(ctx context.Context, event audittypes.AuditEvent) {
|
||||
if event.PrincipalAttributes.PrincipalOrgID.IsZero() {
|
||||
if event.PrincipalOrgID.IsZero() {
|
||||
provider.settings.Logger().WarnContext(ctx, "audit event dropped as org_id is zero")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := provider.licensing.GetActive(ctx, event.PrincipalAttributes.PrincipalOrgID); err != nil {
|
||||
if _, err := provider.licensing.GetActive(ctx, event.PrincipalOrgID); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
if anomalyQuery, ok := queryRangeRequest.IsAnomalyRequest(); ok {
|
||||
anomalies, err := h.handleAnomalyQuery(ctx, orgID, anomalyQuery, &queryRangeRequest)
|
||||
anomalies, err := h.handleAnomalyQuery(ctx, orgID, anomalyQuery, queryRangeRequest)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.NewInternalf(errors.CodeInternal, "failed to get anomalies: %v", err))
|
||||
return
|
||||
@@ -149,7 +149,7 @@ func (h *handler) createAnomalyProvider(seasonality anomalyV2.Seasonality) anoma
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) handleAnomalyQuery(ctx context.Context, orgID valuer.UUID, anomalyQuery *qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], queryRangeRequest *qbtypes.QueryRangeRequest) (*anomalyV2.AnomaliesResponse, error) {
|
||||
func (h *handler) handleAnomalyQuery(ctx context.Context, orgID valuer.UUID, anomalyQuery *qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], queryRangeRequest qbtypes.QueryRangeRequest) (*anomalyV2.AnomaliesResponse, error) {
|
||||
seasonality := extractSeasonality(anomalyQuery)
|
||||
provider := h.createAnomalyProvider(seasonality)
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import (
|
||||
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
|
||||
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
|
||||
baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
)
|
||||
@@ -98,6 +99,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
)
|
||||
|
||||
rm, err := makeRulesManager(
|
||||
reader,
|
||||
signoz.Cache,
|
||||
signoz.Alertmanager,
|
||||
signoz.SQLStore,
|
||||
@@ -227,7 +229,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
s.config.APIServer.Timeout.Default,
|
||||
s.config.APIServer.Timeout.Max,
|
||||
).Wrap)
|
||||
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
|
||||
r.Use(middleware.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
|
||||
r.Use(middleware.NewComment().Wrap)
|
||||
|
||||
apiHandler.RegisterRoutes(r, am)
|
||||
@@ -343,7 +345,7 @@ func (s *Server) Stop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeRulesManager(cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, ruleStateHistoryModule rulestatehistory.Module, querier querier.Querier, providerSettings factory.ProviderSettings, queryParser queryparser.QueryParser) (*baserules.Manager, error) {
|
||||
func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, ruleStateHistoryModule rulestatehistory.Module, querier querier.Querier, providerSettings factory.ProviderSettings, queryParser queryparser.QueryParser) (*baserules.Manager, error) {
|
||||
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
|
||||
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
|
||||
// create manager opts
|
||||
@@ -352,6 +354,7 @@ func makeRulesManager(cache cache.Cache, alertmanager alertmanager.Alertmanager,
|
||||
MetadataStore: metadataStore,
|
||||
Prometheus: prometheus,
|
||||
Context: context.Background(),
|
||||
Reader: ch,
|
||||
Querier: querier,
|
||||
Logger: providerSettings.Logger,
|
||||
Cache: cache,
|
||||
@@ -362,7 +365,7 @@ func makeRulesManager(cache cache.Cache, alertmanager alertmanager.Alertmanager,
|
||||
OrgGetter: orgGetter,
|
||||
RuleStore: ruleStore,
|
||||
MaintenanceStore: maintenanceStore,
|
||||
SQLStore: sqlstore,
|
||||
SqlStore: sqlstore,
|
||||
QueryParser: queryParser,
|
||||
RuleStateHistoryModule: ruleStateHistoryModule,
|
||||
}
|
||||
|
||||
@@ -5,33 +5,58 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/query-service/anomaly"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/common"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/transition"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
querierV2 "github.com/SigNoz/signoz/pkg/query-service/app/querier/v2"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/queryBuilder"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils/times"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils/timestamp"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/units"
|
||||
|
||||
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/anomaly"
|
||||
querierV5 "github.com/SigNoz/signoz/pkg/querier"
|
||||
|
||||
anomalyV2 "github.com/SigNoz/signoz/ee/anomaly"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
const (
|
||||
RuleTypeAnomaly = "anomaly_rule"
|
||||
)
|
||||
|
||||
type AnomalyRule struct {
|
||||
*baserules.BaseRule
|
||||
|
||||
mtx sync.Mutex
|
||||
|
||||
// querier is used for alerts migrated after the introduction of new query builder
|
||||
querier querier.Querier
|
||||
reader interfaces.Reader
|
||||
|
||||
provider anomaly.Provider
|
||||
// querierV2 is used for alerts created after the introduction of new metrics query builder
|
||||
querierV2 interfaces.Querier
|
||||
|
||||
// querierV5 is used for alerts migrated after the introduction of new query builder
|
||||
querierV5 querierV5.Querier
|
||||
|
||||
provider anomaly.Provider
|
||||
providerV2 anomalyV2.Provider
|
||||
|
||||
version string
|
||||
logger *slog.Logger
|
||||
@@ -45,70 +70,125 @@ func NewAnomalyRule(
|
||||
id string,
|
||||
orgID valuer.UUID,
|
||||
p *ruletypes.PostableRule,
|
||||
querier querier.Querier,
|
||||
reader interfaces.Reader,
|
||||
querierV5 querierV5.Querier,
|
||||
logger *slog.Logger,
|
||||
cache cache.Cache,
|
||||
opts ...baserules.RuleOption,
|
||||
) (*AnomalyRule, error) {
|
||||
logger.Info("creating new AnomalyRule", slog.String("rule.id", id))
|
||||
|
||||
logger.Info("creating new AnomalyRule", "rule_id", id)
|
||||
|
||||
opts = append(opts, baserules.WithLogger(logger))
|
||||
|
||||
baseRule, err := baserules.NewBaseRule(id, orgID, p, opts...)
|
||||
baseRule, err := baserules.NewBaseRule(id, orgID, p, reader, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := AnomalyRule{
|
||||
t := AnomalyRule{
|
||||
BaseRule: baseRule,
|
||||
querier: querier,
|
||||
version: p.Version,
|
||||
logger: logger.With(slog.String("rule.id", id)),
|
||||
}
|
||||
|
||||
switch p.RuleCondition.Seasonality {
|
||||
case ruletypes.SeasonalityHourly:
|
||||
r.seasonality = anomaly.SeasonalityHourly
|
||||
case ruletypes.SeasonalityDaily:
|
||||
r.seasonality = anomaly.SeasonalityDaily
|
||||
case ruletypes.SeasonalityWeekly:
|
||||
r.seasonality = anomaly.SeasonalityWeekly
|
||||
switch strings.ToLower(p.RuleCondition.Seasonality) {
|
||||
case "hourly":
|
||||
t.seasonality = anomaly.SeasonalityHourly
|
||||
case "daily":
|
||||
t.seasonality = anomaly.SeasonalityDaily
|
||||
case "weekly":
|
||||
t.seasonality = anomaly.SeasonalityWeekly
|
||||
default:
|
||||
r.seasonality = anomaly.SeasonalityDaily
|
||||
t.seasonality = anomaly.SeasonalityDaily
|
||||
}
|
||||
|
||||
r.logger.Info("using seasonality", slog.String("rule.seasonality", r.seasonality.StringValue()))
|
||||
logger.Info("using seasonality", "seasonality", t.seasonality.String())
|
||||
|
||||
if r.seasonality == anomaly.SeasonalityHourly {
|
||||
r.provider = anomaly.NewHourlyProvider(
|
||||
anomaly.WithQuerier[*anomaly.HourlyProvider](querier),
|
||||
anomaly.WithLogger[*anomaly.HourlyProvider](r.logger),
|
||||
querierOptsV2 := querierV2.QuerierOptions{
|
||||
Reader: reader,
|
||||
Cache: cache,
|
||||
KeyGenerator: queryBuilder.NewKeyGenerator(),
|
||||
}
|
||||
|
||||
t.querierV2 = querierV2.NewQuerier(querierOptsV2)
|
||||
t.reader = reader
|
||||
if t.seasonality == anomaly.SeasonalityHourly {
|
||||
t.provider = anomaly.NewHourlyProvider(
|
||||
anomaly.WithCache[*anomaly.HourlyProvider](cache),
|
||||
anomaly.WithKeyGenerator[*anomaly.HourlyProvider](queryBuilder.NewKeyGenerator()),
|
||||
anomaly.WithReader[*anomaly.HourlyProvider](reader),
|
||||
)
|
||||
} else if r.seasonality == anomaly.SeasonalityDaily {
|
||||
r.provider = anomaly.NewDailyProvider(
|
||||
anomaly.WithQuerier[*anomaly.DailyProvider](querier),
|
||||
anomaly.WithLogger[*anomaly.DailyProvider](r.logger),
|
||||
} else if t.seasonality == anomaly.SeasonalityDaily {
|
||||
t.provider = anomaly.NewDailyProvider(
|
||||
anomaly.WithCache[*anomaly.DailyProvider](cache),
|
||||
anomaly.WithKeyGenerator[*anomaly.DailyProvider](queryBuilder.NewKeyGenerator()),
|
||||
anomaly.WithReader[*anomaly.DailyProvider](reader),
|
||||
)
|
||||
} else if r.seasonality == anomaly.SeasonalityWeekly {
|
||||
r.provider = anomaly.NewWeeklyProvider(
|
||||
anomaly.WithQuerier[*anomaly.WeeklyProvider](querier),
|
||||
anomaly.WithLogger[*anomaly.WeeklyProvider](r.logger),
|
||||
} else if t.seasonality == anomaly.SeasonalityWeekly {
|
||||
t.provider = anomaly.NewWeeklyProvider(
|
||||
anomaly.WithCache[*anomaly.WeeklyProvider](cache),
|
||||
anomaly.WithKeyGenerator[*anomaly.WeeklyProvider](queryBuilder.NewKeyGenerator()),
|
||||
anomaly.WithReader[*anomaly.WeeklyProvider](reader),
|
||||
)
|
||||
}
|
||||
|
||||
return &r, nil
|
||||
if t.seasonality == anomaly.SeasonalityHourly {
|
||||
t.providerV2 = anomalyV2.NewHourlyProvider(
|
||||
anomalyV2.WithQuerier[*anomalyV2.HourlyProvider](querierV5),
|
||||
anomalyV2.WithLogger[*anomalyV2.HourlyProvider](logger),
|
||||
)
|
||||
} else if t.seasonality == anomaly.SeasonalityDaily {
|
||||
t.providerV2 = anomalyV2.NewDailyProvider(
|
||||
anomalyV2.WithQuerier[*anomalyV2.DailyProvider](querierV5),
|
||||
anomalyV2.WithLogger[*anomalyV2.DailyProvider](logger),
|
||||
)
|
||||
} else if t.seasonality == anomaly.SeasonalityWeekly {
|
||||
t.providerV2 = anomalyV2.NewWeeklyProvider(
|
||||
anomalyV2.WithQuerier[*anomalyV2.WeeklyProvider](querierV5),
|
||||
anomalyV2.WithLogger[*anomalyV2.WeeklyProvider](logger),
|
||||
)
|
||||
}
|
||||
|
||||
t.querierV5 = querierV5
|
||||
t.version = p.Version
|
||||
t.logger = logger
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) Type() ruletypes.RuleType {
|
||||
return ruletypes.RuleTypeAnomaly
|
||||
return RuleTypeAnomaly
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
|
||||
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) (*v3.QueryRangeParamsV3, error) {
|
||||
|
||||
r.logger.InfoContext(
|
||||
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
|
||||
slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()),
|
||||
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
|
||||
ctx, "prepare query range request v4", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds(),
|
||||
)
|
||||
|
||||
st, en := r.Timestamps(ts)
|
||||
start := st.UnixMilli()
|
||||
end := en.UnixMilli()
|
||||
|
||||
compositeQuery := r.Condition().CompositeQuery
|
||||
|
||||
if compositeQuery.PanelType != v3.PanelTypeGraph {
|
||||
compositeQuery.PanelType = v3.PanelTypeGraph
|
||||
}
|
||||
|
||||
// default mode
|
||||
return &v3.QueryRangeParamsV3{
|
||||
Start: start,
|
||||
End: end,
|
||||
Step: int64(math.Max(float64(common.MinAllowedStepInterval(start, end)), 60)),
|
||||
CompositeQuery: compositeQuery,
|
||||
Variables: make(map[string]interface{}, 0),
|
||||
NoCache: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) prepareQueryRangeV5(ctx context.Context, ts time.Time) (*qbtypes.QueryRangeRequest, error) {
|
||||
|
||||
r.logger.InfoContext(ctx, "prepare query range request v5", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds())
|
||||
|
||||
startTs, endTs := r.Timestamps(ts)
|
||||
start, end := startTs.UnixMilli(), endTs.UnixMilli()
|
||||
|
||||
@@ -123,14 +203,25 @@ func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbty
|
||||
}
|
||||
req.CompositeQuery.Queries = make([]qbtypes.QueryEnvelope, len(r.Condition().CompositeQuery.Queries))
|
||||
copy(req.CompositeQuery.Queries, r.Condition().CompositeQuery.Queries)
|
||||
return req
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) GetSelectedQuery() string {
|
||||
return r.Condition().GetSelectedQueryName()
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
|
||||
|
||||
params := r.prepareQueryRange(ctx, ts)
|
||||
params, err := r.prepareQueryRange(ctx, ts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.PopulateTemporality(ctx, orgID, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("internal error while setting temporality")
|
||||
}
|
||||
|
||||
anomalies, err := r.provider.GetAnomalies(ctx, orgID, &anomaly.AnomaliesRequest{
|
||||
anomalies, err := r.provider.GetAnomalies(ctx, orgID, &anomaly.GetAnomaliesRequest{
|
||||
Params: params,
|
||||
Seasonality: r.seasonality,
|
||||
})
|
||||
@@ -138,43 +229,87 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var queryResult *qbtypes.TimeSeriesData
|
||||
var queryResult *v3.Result
|
||||
for _, result := range anomalies.Results {
|
||||
if result.QueryName == r.SelectedQuery(ctx) {
|
||||
if result.QueryName == r.GetSelectedQuery() {
|
||||
queryResult = result
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if queryResult == nil {
|
||||
r.logger.WarnContext(ctx, "nil qb result", slog.Int64("ts", ts.UnixMilli()))
|
||||
return ruletypes.Vector{}, nil
|
||||
}
|
||||
|
||||
hasData := len(queryResult.Aggregations) > 0 &&
|
||||
queryResult.Aggregations[0] != nil &&
|
||||
len(queryResult.Aggregations[0].AnomalyScores) > 0
|
||||
|
||||
hasData := len(queryResult.AnomalyScores) > 0
|
||||
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
|
||||
return ruletypes.Vector{*missingDataAlert}, nil
|
||||
} else if !hasData {
|
||||
r.logger.WarnContext(ctx, "no anomaly result")
|
||||
return ruletypes.Vector{}, nil
|
||||
}
|
||||
|
||||
var resultVector ruletypes.Vector
|
||||
|
||||
scoresJSON, _ := json.Marshal(queryResult.Aggregations[0].AnomalyScores)
|
||||
// TODO(srikanthccv): this could be noisy but we do this to answer false alert requests
|
||||
r.logger.InfoContext(ctx, "anomaly scores", slog.String("anomaly.scores", string(scoresJSON)))
|
||||
scoresJSON, _ := json.Marshal(queryResult.AnomalyScores)
|
||||
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
|
||||
|
||||
for _, series := range queryResult.AnomalyScores {
|
||||
if !r.Condition().ShouldEval(series) {
|
||||
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
|
||||
continue
|
||||
}
|
||||
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
|
||||
ActiveAlerts: r.ActiveAlertsLabelFP(),
|
||||
SendUnmatched: r.ShouldSendUnmatched(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resultVector = append(resultVector, results...)
|
||||
}
|
||||
return resultVector, nil
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
|
||||
|
||||
params, err := r.prepareQueryRangeV5(ctx, ts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
anomalies, err := r.providerV2.GetAnomalies(ctx, orgID, &anomalyV2.AnomaliesRequest{
|
||||
Params: *params,
|
||||
Seasonality: anomalyV2.Seasonality{String: valuer.NewString(r.seasonality.String())},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var qbResult *qbtypes.TimeSeriesData
|
||||
for _, result := range anomalies.Results {
|
||||
if result.QueryName == r.GetSelectedQuery() {
|
||||
qbResult = result
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if qbResult == nil {
|
||||
r.logger.WarnContext(ctx, "nil qb result", "ts", ts.UnixMilli())
|
||||
}
|
||||
|
||||
queryResult := transition.ConvertV5TimeSeriesDataToV4Result(qbResult)
|
||||
|
||||
hasData := len(queryResult.AnomalyScores) > 0
|
||||
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
|
||||
return ruletypes.Vector{*missingDataAlert}, nil
|
||||
}
|
||||
|
||||
var resultVector ruletypes.Vector
|
||||
|
||||
scoresJSON, _ := json.Marshal(queryResult.AnomalyScores)
|
||||
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
|
||||
|
||||
// Filter out new series if newGroupEvalDelay is configured
|
||||
seriesToProcess := queryResult.Aggregations[0].AnomalyScores
|
||||
seriesToProcess := queryResult.AnomalyScores
|
||||
if r.ShouldSkipNewGroups() {
|
||||
filteredSeries, filterErr := r.BaseRule.FilterNewSeries(ctx, ts, seriesToProcess)
|
||||
// In case of error we log the error and continue with the original series
|
||||
if filterErr != nil {
|
||||
r.logger.ErrorContext(ctx, "error filtering new series", errors.Attr(filterErr))
|
||||
r.logger.ErrorContext(ctx, "Error filtering new series, ", errors.Attr(filterErr), "rule_name", r.Name())
|
||||
} else {
|
||||
seriesToProcess = filteredSeries
|
||||
}
|
||||
@@ -182,14 +317,10 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
|
||||
|
||||
for _, series := range seriesToProcess {
|
||||
if !r.Condition().ShouldEval(series) {
|
||||
r.logger.InfoContext(
|
||||
ctx, "not enough data points to evaluate series, skipping",
|
||||
slog.Int("series.num_points", len(series.Values)),
|
||||
slog.Int("series.required_points", r.Condition().RequiredNumPoints),
|
||||
)
|
||||
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
|
||||
continue
|
||||
}
|
||||
results, err := r.Threshold.Eval(series, r.Unit(), ruletypes.EvalData{
|
||||
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
|
||||
ActiveAlerts: r.ActiveAlertsLabelFP(),
|
||||
SendUnmatched: r.ShouldSendUnmatched(),
|
||||
})
|
||||
@@ -210,9 +341,13 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
var res ruletypes.Vector
|
||||
var err error
|
||||
|
||||
r.logger.InfoContext(ctx, "running query")
|
||||
res, err = r.buildAndRunQuery(ctx, r.OrgID(), ts)
|
||||
|
||||
if r.version == "v5" {
|
||||
r.logger.InfoContext(ctx, "running v5 query")
|
||||
res, err = r.buildAndRunQueryV5(ctx, r.OrgID(), ts)
|
||||
} else {
|
||||
r.logger.InfoContext(ctx, "running v4 query")
|
||||
res, err = r.buildAndRunQuery(ctx, r.OrgID(), ts)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -236,10 +371,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
}
|
||||
value := valueFormatter.Format(smpl.V, r.Unit())
|
||||
threshold := valueFormatter.Format(smpl.Target, smpl.TargetUnit)
|
||||
r.logger.DebugContext(
|
||||
ctx, "alert template data for rule", slog.String("formatter.name", valueFormatter.Name()),
|
||||
slog.String("alert.value", value), slog.String("alert.threshold", threshold),
|
||||
)
|
||||
r.logger.DebugContext(ctx, "Alert template data for rule", "rule_name", r.Name(), "formatter", valueFormatter.Name(), "value", value, "threshold", threshold)
|
||||
|
||||
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
|
||||
// Inject some convenience variables that are easier to remember for users
|
||||
@@ -254,34 +386,35 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
defs+text,
|
||||
"__alert_"+r.Name(),
|
||||
tmplData,
|
||||
times.Time(timestamp.FromTime(ts)),
|
||||
nil,
|
||||
)
|
||||
result, err := tmpl.Expand()
|
||||
if err != nil {
|
||||
result = fmt.Sprintf("<error expanding template: %s>", err)
|
||||
r.logger.ErrorContext(ctx, "expanding alert template failed", errors.Attr(err), slog.Any("alert.template_data", tmplData))
|
||||
r.logger.ErrorContext(ctx, "Expanding alert template failed", errors.Attr(err), "data", tmplData, "rule_name", r.Name())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
lb := ruletypes.NewBuilder(smpl.Metric...).Del(ruletypes.MetricNameLabel).Del(ruletypes.TemporalityLabel)
|
||||
resultLabels := ruletypes.NewBuilder(smpl.Metric...).Del(ruletypes.MetricNameLabel).Del(ruletypes.TemporalityLabel).Labels()
|
||||
lb := labels.NewBuilder(smpl.Metric).Del(labels.MetricNameLabel).Del(labels.TemporalityLabel)
|
||||
resultLabels := labels.NewBuilder(smpl.Metric).Del(labels.MetricNameLabel).Del(labels.TemporalityLabel).Labels()
|
||||
|
||||
for name, value := range r.Labels().Map() {
|
||||
lb.Set(name, expand(value))
|
||||
}
|
||||
|
||||
lb.Set(ruletypes.AlertNameLabel, r.Name())
|
||||
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
|
||||
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
|
||||
lb.Set(labels.AlertNameLabel, r.Name())
|
||||
lb.Set(labels.AlertRuleIdLabel, r.ID())
|
||||
lb.Set(labels.RuleSourceLabel, r.GeneratorURL())
|
||||
|
||||
annotations := make(ruletypes.Labels, 0, len(r.Annotations().Map()))
|
||||
annotations := make(labels.Labels, 0, len(r.Annotations().Map()))
|
||||
for name, value := range r.Annotations().Map() {
|
||||
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
|
||||
annotations = append(annotations, labels.Label{Name: name, Value: expand(value)})
|
||||
}
|
||||
if smpl.IsMissing {
|
||||
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
|
||||
lb.Set(ruletypes.NoDataLabel, "true")
|
||||
lb.Set(labels.AlertNameLabel, "[No data] "+r.Name())
|
||||
lb.Set(labels.NoDataLabel, "true")
|
||||
}
|
||||
|
||||
lbs := lb.Labels()
|
||||
@@ -289,17 +422,17 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
resultFPs[h] = struct{}{}
|
||||
|
||||
if _, ok := alerts[h]; ok {
|
||||
r.logger.ErrorContext(ctx, "the alert query returns duplicate records", slog.Any("alert", alerts[h]))
|
||||
err = errors.NewInternalf(errors.CodeInternal, "duplicate alert found, vector contains metrics with the same labelset after applying alert labels")
|
||||
r.logger.ErrorContext(ctx, "the alert query returns duplicate records", "rule_id", r.ID(), "alert", alerts[h])
|
||||
err = fmt.Errorf("duplicate alert found, vector contains metrics with the same labelset after applying alert labels")
|
||||
return 0, err
|
||||
}
|
||||
|
||||
alerts[h] = &ruletypes.Alert{
|
||||
Labels: lbs,
|
||||
QueryResultLabels: resultLabels,
|
||||
QueryResultLables: resultLabels,
|
||||
Annotations: annotations,
|
||||
ActiveAt: ts,
|
||||
State: ruletypes.StatePending,
|
||||
State: model.StatePending,
|
||||
Value: smpl.V,
|
||||
GeneratorURL: r.GeneratorURL(),
|
||||
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
|
||||
@@ -308,12 +441,12 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
r.logger.InfoContext(ctx, "number of alerts found", slog.Int("alert.count", len(alerts)))
|
||||
r.logger.InfoContext(ctx, "number of alerts found", "rule_name", r.Name(), "alerts_count", len(alerts))
|
||||
// alerts[h] is ready, add or update active list now
|
||||
for h, a := range alerts {
|
||||
// Check whether we already have alerting state for the identifying label set.
|
||||
// Update the last value and annotations if so, create a new alert entry otherwise.
|
||||
if alert, ok := r.Active[h]; ok && alert.State != ruletypes.StateInactive {
|
||||
if alert, ok := r.Active[h]; ok && alert.State != model.StateInactive {
|
||||
|
||||
alert.Value = a.Value
|
||||
alert.Annotations = a.Annotations
|
||||
@@ -329,76 +462,76 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
r.Active[h] = a
|
||||
}
|
||||
|
||||
itemsToAdd := []rulestatehistorytypes.RuleStateHistory{}
|
||||
itemsToAdd := []model.RuleStateHistory{}
|
||||
|
||||
// Check if any pending alerts should be removed or fire now. Write out alert timeseries.
|
||||
for fp, a := range r.Active {
|
||||
labelsJSON, err := json.Marshal(a.QueryResultLabels)
|
||||
labelsJSON, err := json.Marshal(a.QueryResultLables)
|
||||
if err != nil {
|
||||
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err), slog.Any("alert.labels", a.Labels))
|
||||
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err), "labels", a.Labels)
|
||||
}
|
||||
if _, ok := resultFPs[fp]; !ok {
|
||||
// If the alert was previously firing, keep it around for a given
|
||||
// retention time so it is reported as resolved to the AlertManager.
|
||||
if a.State == ruletypes.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > ruletypes.ResolvedRetention) {
|
||||
if a.State == model.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > ruletypes.ResolvedRetention) {
|
||||
delete(r.Active, fp)
|
||||
}
|
||||
if a.State != ruletypes.StateInactive {
|
||||
a.State = ruletypes.StateInactive
|
||||
if a.State != model.StateInactive {
|
||||
a.State = model.StateInactive
|
||||
a.ResolvedAt = ts
|
||||
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
|
||||
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
|
||||
RuleID: r.ID(),
|
||||
RuleName: r.Name(),
|
||||
State: ruletypes.StateInactive,
|
||||
State: model.StateInactive,
|
||||
StateChanged: true,
|
||||
UnixMilli: ts.UnixMilli(),
|
||||
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
|
||||
Fingerprint: a.QueryResultLabels.Hash(),
|
||||
Labels: model.LabelsString(labelsJSON),
|
||||
Fingerprint: a.QueryResultLables.Hash(),
|
||||
Value: a.Value,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if a.State == ruletypes.StatePending && ts.Sub(a.ActiveAt) >= r.HoldDuration().Duration() {
|
||||
a.State = ruletypes.StateFiring
|
||||
if a.State == model.StatePending && ts.Sub(a.ActiveAt) >= r.HoldDuration().Duration() {
|
||||
a.State = model.StateFiring
|
||||
a.FiredAt = ts
|
||||
state := ruletypes.StateFiring
|
||||
state := model.StateFiring
|
||||
if a.Missing {
|
||||
state = ruletypes.StateNoData
|
||||
state = model.StateNoData
|
||||
}
|
||||
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
|
||||
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
|
||||
RuleID: r.ID(),
|
||||
RuleName: r.Name(),
|
||||
State: state,
|
||||
StateChanged: true,
|
||||
UnixMilli: ts.UnixMilli(),
|
||||
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
|
||||
Fingerprint: a.QueryResultLabels.Hash(),
|
||||
Labels: model.LabelsString(labelsJSON),
|
||||
Fingerprint: a.QueryResultLables.Hash(),
|
||||
Value: a.Value,
|
||||
})
|
||||
}
|
||||
|
||||
// We need to change firing alert to recovering if the returned sample meets recovery threshold
|
||||
changeFiringToRecovering := a.State == ruletypes.StateFiring && a.IsRecovering
|
||||
changeFiringToRecovering := a.State == model.StateFiring && a.IsRecovering
|
||||
// We need to change recovering alerts to firing if the returned sample meets target threshold
|
||||
changeRecoveringToFiring := a.State == ruletypes.StateRecovering && !a.IsRecovering && !a.Missing
|
||||
changeRecoveringToFiring := a.State == model.StateRecovering && !a.IsRecovering && !a.Missing
|
||||
// in any of the above case we need to update the status of alert
|
||||
if changeFiringToRecovering || changeRecoveringToFiring {
|
||||
state := ruletypes.StateRecovering
|
||||
state := model.StateRecovering
|
||||
if changeRecoveringToFiring {
|
||||
state = ruletypes.StateFiring
|
||||
state = model.StateFiring
|
||||
}
|
||||
a.State = state
|
||||
r.logger.DebugContext(ctx, "converting alert state", slog.Any("alert.state", state))
|
||||
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
|
||||
r.logger.DebugContext(ctx, "converting alert state", "name", r.Name(), "state", state)
|
||||
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
|
||||
RuleID: r.ID(),
|
||||
RuleName: r.Name(),
|
||||
State: state,
|
||||
StateChanged: true,
|
||||
UnixMilli: ts.UnixMilli(),
|
||||
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
|
||||
Fingerprint: a.QueryResultLabels.Hash(),
|
||||
Labels: model.LabelsString(labelsJSON),
|
||||
Fingerprint: a.QueryResultLables.Hash(),
|
||||
Value: a.Value,
|
||||
})
|
||||
}
|
||||
@@ -413,7 +546,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
itemsToAdd[idx] = item
|
||||
}
|
||||
|
||||
_ = r.RecordRuleStateHistory(ctx, itemsToAdd)
|
||||
r.RecordRuleStateHistory(ctx, prevState, currentState, itemsToAdd)
|
||||
|
||||
return len(r.Active), nil
|
||||
}
|
||||
|
||||
@@ -2,19 +2,21 @@ package rules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/query-service/anomaly"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/anomaly"
|
||||
)
|
||||
|
||||
// mockAnomalyProvider is a mock implementation of anomaly.Provider for testing.
|
||||
@@ -22,13 +24,13 @@ import (
|
||||
// time periods (current, past period, current season, past season, past 2 seasons,
|
||||
// past 3 seasons), making it cumbersome to create mock data.
|
||||
type mockAnomalyProvider struct {
|
||||
responses []*anomaly.AnomaliesResponse
|
||||
responses []*anomaly.GetAnomaliesResponse
|
||||
callCount int
|
||||
}
|
||||
|
||||
func (m *mockAnomalyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *anomaly.AnomaliesRequest) (*anomaly.AnomaliesResponse, error) {
|
||||
func (m *mockAnomalyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *anomaly.GetAnomaliesRequest) (*anomaly.GetAnomaliesResponse, error) {
|
||||
if m.callCount >= len(m.responses) {
|
||||
return &anomaly.AnomaliesResponse{Results: []*qbtypes.TimeSeriesData{}}, nil
|
||||
return &anomaly.GetAnomaliesResponse{Results: []*v3.Result{}}, nil
|
||||
}
|
||||
resp := m.responses[m.callCount]
|
||||
m.callCount++
|
||||
@@ -47,46 +49,45 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Test anomaly no data",
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeAnomaly,
|
||||
RuleType: RuleTypeAnomaly,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
EvalWindow: evalWindow,
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &target,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
CompareOp: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &target,
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
BuilderQueries: map[string]*v3.BuilderQuery{
|
||||
"A": {
|
||||
QueryName: "A",
|
||||
Expression: "A",
|
||||
DataSource: v3.DataSourceMetrics,
|
||||
Temporality: v3.Unspecified,
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
SelectedQuery: "A",
|
||||
Seasonality: ruletypes.SeasonalityDaily,
|
||||
Seasonality: "daily",
|
||||
Thresholds: &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{{
|
||||
Name: "Test anomaly no data",
|
||||
TargetValue: &target,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
Name: "Test anomaly no data",
|
||||
TargetValue: &target,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOp: ruletypes.ValueIsAbove,
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
responseNoData := &anomaly.AnomaliesResponse{
|
||||
Results: []*qbtypes.TimeSeriesData{
|
||||
responseNoData := &anomaly.GetAnomaliesResponse{
|
||||
Results: []*v3.Result{
|
||||
{
|
||||
QueryName: "A",
|
||||
Aggregations: []*qbtypes.AggregationBucket{{
|
||||
AnomalyScores: []*qbtypes.TimeSeries{},
|
||||
}},
|
||||
QueryName: "A",
|
||||
AnomalyScores: []*v3.Series{},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -114,17 +115,23 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
|
||||
t.Run(c.description, func(t *testing.T) {
|
||||
postableRule.RuleCondition.AlertOnAbsent = c.alertOnAbsent
|
||||
|
||||
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, nil)
|
||||
options := clickhouseReader.NewOptions("primaryNamespace")
|
||||
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, nil, "", time.Second, nil, nil, options)
|
||||
|
||||
rule, err := NewAnomalyRule(
|
||||
"test-anomaly-rule",
|
||||
valuer.GenerateUUID(),
|
||||
&postableRule,
|
||||
reader,
|
||||
nil,
|
||||
logger,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
rule.provider = &mockAnomalyProvider{
|
||||
responses: []*anomaly.AnomaliesResponse{responseNoData},
|
||||
responses: []*anomaly.GetAnomaliesResponse{responseNoData},
|
||||
}
|
||||
|
||||
alertsFound, err := rule.Eval(context.Background(), evalTime)
|
||||
@@ -149,47 +156,46 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Test anomaly no data with AbsentFor",
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeAnomaly,
|
||||
RuleType: RuleTypeAnomaly,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
EvalWindow: evalWindow,
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
AlertOnAbsent: true,
|
||||
Target: &target,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
CompareOp: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
AlertOnAbsent: true,
|
||||
Target: &target,
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
BuilderQueries: map[string]*v3.BuilderQuery{
|
||||
"A": {
|
||||
QueryName: "A",
|
||||
Expression: "A",
|
||||
DataSource: v3.DataSourceMetrics,
|
||||
Temporality: v3.Unspecified,
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
SelectedQuery: "A",
|
||||
Seasonality: ruletypes.SeasonalityDaily,
|
||||
Seasonality: "daily",
|
||||
Thresholds: &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{{
|
||||
Name: "Test anomaly no data with AbsentFor",
|
||||
TargetValue: &target,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
Name: "Test anomaly no data with AbsentFor",
|
||||
TargetValue: &target,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOp: ruletypes.ValueIsAbove,
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
responseNoData := &anomaly.AnomaliesResponse{
|
||||
Results: []*qbtypes.TimeSeriesData{
|
||||
responseNoData := &anomaly.GetAnomaliesResponse{
|
||||
Results: []*v3.Result{
|
||||
{
|
||||
QueryName: "A",
|
||||
Aggregations: []*qbtypes.AggregationBucket{{
|
||||
AnomalyScores: []*qbtypes.TimeSeries{},
|
||||
}},
|
||||
QueryName: "A",
|
||||
AnomalyScores: []*v3.Series{},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -223,35 +229,32 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
|
||||
t1 := baseTime.Add(5 * time.Minute)
|
||||
t2 := t1.Add(c.timeBetweenEvals)
|
||||
|
||||
responseWithData := &anomaly.AnomaliesResponse{
|
||||
Results: []*qbtypes.TimeSeriesData{
|
||||
responseWithData := &anomaly.GetAnomaliesResponse{
|
||||
Results: []*v3.Result{
|
||||
{
|
||||
QueryName: "A",
|
||||
Aggregations: []*qbtypes.AggregationBucket{{
|
||||
AnomalyScores: []*qbtypes.TimeSeries{
|
||||
{
|
||||
Labels: []*qbtypes.Label{
|
||||
{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: "Test"},
|
||||
Value: "labels",
|
||||
},
|
||||
},
|
||||
Values: []*qbtypes.TimeSeriesValue{
|
||||
{Timestamp: baseTime.UnixMilli(), Value: 1.0},
|
||||
{Timestamp: baseTime.Add(time.Minute).UnixMilli(), Value: 1.5},
|
||||
},
|
||||
AnomalyScores: []*v3.Series{
|
||||
{
|
||||
Labels: map[string]string{"test": "label"},
|
||||
Points: []v3.Point{
|
||||
{Timestamp: baseTime.UnixMilli(), Value: 1.0},
|
||||
{Timestamp: baseTime.Add(time.Minute).UnixMilli(), Value: 1.5},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, nil, logger)
|
||||
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, nil)
|
||||
options := clickhouseReader.NewOptions("primaryNamespace")
|
||||
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, nil, "", time.Second, nil, nil, options)
|
||||
|
||||
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, reader, nil, logger, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
rule.provider = &mockAnomalyProvider{
|
||||
responses: []*anomaly.AnomaliesResponse{responseWithData, responseNoData},
|
||||
responses: []*anomaly.GetAnomaliesResponse{responseWithData, responseNoData},
|
||||
}
|
||||
|
||||
alertsFound1, err := rule.Eval(context.Background(), t1)
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -21,7 +23,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
rules := make([]baserules.Rule, 0)
|
||||
var task baserules.Task
|
||||
|
||||
ruleID := baserules.RuleIDFromTaskName(opts.TaskName)
|
||||
ruleId := baserules.RuleIdFromTaskName(opts.TaskName)
|
||||
evaluation, err := opts.Rule.Evaluation.GetEvaluation()
|
||||
if err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err)
|
||||
@@ -30,9 +32,10 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
if opts.Rule.RuleType == ruletypes.RuleTypeThreshold {
|
||||
// create a threshold rule
|
||||
tr, err := baserules.NewThresholdRule(
|
||||
ruleID,
|
||||
ruleId,
|
||||
opts.OrgID,
|
||||
opts.Rule,
|
||||
opts.Reader,
|
||||
opts.Querier,
|
||||
opts.Logger,
|
||||
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
|
||||
@@ -55,10 +58,11 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
|
||||
// create promql rule
|
||||
pr, err := baserules.NewPromRule(
|
||||
ruleID,
|
||||
ruleId,
|
||||
opts.OrgID,
|
||||
opts.Rule,
|
||||
opts.Logger,
|
||||
opts.Reader,
|
||||
opts.ManagerOpts.Prometheus,
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
|
||||
@@ -78,11 +82,13 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
|
||||
// create anomaly rule
|
||||
ar, err := NewAnomalyRule(
|
||||
ruleID,
|
||||
ruleId,
|
||||
opts.OrgID,
|
||||
opts.Rule,
|
||||
opts.Reader,
|
||||
opts.Querier,
|
||||
opts.Logger,
|
||||
opts.Cache,
|
||||
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
|
||||
@@ -99,7 +105,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
|
||||
|
||||
} else {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
|
||||
return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
|
||||
}
|
||||
|
||||
return task, nil
|
||||
@@ -107,12 +113,12 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
|
||||
// TestNotification prepares a dummy rule for given rule parameters and
|
||||
// sends a test notification. returns alert count and error (if any)
|
||||
func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.ApiError) {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
if opts.Rule == nil {
|
||||
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "rule is required")
|
||||
return 0, basemodel.BadRequest(fmt.Errorf("rule is required"))
|
||||
}
|
||||
|
||||
parsedRule := opts.Rule
|
||||
@@ -132,14 +138,15 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
if parsedRule.RuleType == ruletypes.RuleTypeThreshold {
|
||||
|
||||
// add special labels for test alerts
|
||||
parsedRule.Labels[ruletypes.RuleSourceLabel] = ""
|
||||
parsedRule.Labels[ruletypes.AlertRuleIDLabel] = ""
|
||||
parsedRule.Labels[labels.RuleSourceLabel] = ""
|
||||
parsedRule.Labels[labels.AlertRuleIdLabel] = ""
|
||||
|
||||
// create a threshold rule
|
||||
rule, err = baserules.NewThresholdRule(
|
||||
alertname,
|
||||
opts.OrgID,
|
||||
parsedRule,
|
||||
opts.Reader,
|
||||
opts.Querier,
|
||||
opts.Logger,
|
||||
baserules.WithSendAlways(),
|
||||
@@ -151,7 +158,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
|
||||
if err != nil {
|
||||
slog.Error("failed to prepare a new threshold rule for test", "name", alertname, errors.Attr(err))
|
||||
return 0, err
|
||||
return 0, basemodel.BadRequest(err)
|
||||
}
|
||||
|
||||
} else if parsedRule.RuleType == ruletypes.RuleTypeProm {
|
||||
@@ -162,6 +169,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
opts.OrgID,
|
||||
parsedRule,
|
||||
opts.Logger,
|
||||
opts.Reader,
|
||||
opts.ManagerOpts.Prometheus,
|
||||
baserules.WithSendAlways(),
|
||||
baserules.WithSendUnmatched(),
|
||||
@@ -172,7 +180,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
|
||||
if err != nil {
|
||||
slog.Error("failed to prepare a new promql rule for test", "name", alertname, errors.Attr(err))
|
||||
return 0, err
|
||||
return 0, basemodel.BadRequest(err)
|
||||
}
|
||||
} else if parsedRule.RuleType == ruletypes.RuleTypeAnomaly {
|
||||
// create anomaly rule
|
||||
@@ -180,8 +188,10 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
alertname,
|
||||
opts.OrgID,
|
||||
parsedRule,
|
||||
opts.Reader,
|
||||
opts.Querier,
|
||||
opts.Logger,
|
||||
opts.Cache,
|
||||
baserules.WithSendAlways(),
|
||||
baserules.WithSendUnmatched(),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
@@ -190,10 +200,10 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("failed to prepare a new anomaly rule for test", "name", alertname, errors.Attr(err))
|
||||
return 0, err
|
||||
return 0, basemodel.BadRequest(err)
|
||||
}
|
||||
} else {
|
||||
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to derive ruletype with given information")
|
||||
return 0, basemodel.BadRequest(fmt.Errorf("failed to derive ruletype with given information"))
|
||||
}
|
||||
|
||||
// set timestamp to current utc time
|
||||
@@ -202,7 +212,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
alertsFound, err := rule.Eval(ctx, ts)
|
||||
if err != nil {
|
||||
slog.Error("evaluating rule failed", "rule", rule.Name(), errors.Attr(err))
|
||||
return 0, err
|
||||
return 0, basemodel.InternalError(fmt.Errorf("rule evaluation failed"))
|
||||
}
|
||||
rule.SendAlerts(ctx, ts, 0, time.Minute, opts.NotifyFunc)
|
||||
|
||||
|
||||
@@ -114,8 +114,11 @@ func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
count, err := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
|
||||
require.Nil(t, err)
|
||||
count, apiErr := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
|
||||
if apiErr != nil {
|
||||
t.Logf("TestNotification error: %v, type: %s", apiErr.Err, apiErr.Typ)
|
||||
}
|
||||
require.Nil(t, apiErr)
|
||||
assert.Equal(t, tc.ExpectAlerts, count)
|
||||
|
||||
if tc.ExpectAlerts > 0 {
|
||||
@@ -265,8 +268,11 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
count, err := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
|
||||
require.Nil(t, err)
|
||||
count, apiErr := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
|
||||
if apiErr != nil {
|
||||
t.Logf("TestNotification error: %v, type: %s", apiErr.Err, apiErr.Typ)
|
||||
}
|
||||
require.Nil(t, apiErr)
|
||||
assert.Equal(t, tc.ExpectAlerts, count)
|
||||
|
||||
if tc.ExpectAlerts > 0 {
|
||||
|
||||
@@ -68,8 +68,8 @@
|
||||
"@signozhq/toggle-group": "0.0.1",
|
||||
"@signozhq/tooltip": "0.0.2",
|
||||
"@signozhq/ui": "0.0.5",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@tanstack/react-table": "8.20.6",
|
||||
"@tanstack/react-virtual": "3.11.2",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
"@uiw/codemirror-theme-github": "4.24.1",
|
||||
"@uiw/react-codemirror": "4.23.10",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#1eb4d4" viewBox="0 0 24 24"><title>Hasura</title><path d="M23.558 8.172c.707-2.152.282-6.447-1.09-8.032a.42.42 0 0 0-.664.051l-1.69 2.59a1.32 1.32 0 0 1-1.737.276C16.544 1.885 14.354 1.204 12 1.204s-4.544.68-6.378 1.853a1.326 1.326 0 0 1-1.736-.276L2.196.191A.42.42 0 0 0 1.532.14C.16 1.728-.265 6.023.442 8.172c.236.716.3 1.472.16 2.207-.137.73-.276 1.61-.276 2.223C.326 18.898 5.553 24 11.997 24c6.447 0 11.671-5.105 11.671-11.398 0-.613-.138-1.494-.276-2.223a4.47 4.47 0 0 1 .166-2.207m-11.56 13.284c-4.984 0-9.036-3.96-9.036-8.827q0-.239.014-.473c.18-3.316 2.243-6.15 5.16-7.5 1.17-.546 2.481-.848 3.864-.848s2.69.302 3.864.85c2.917 1.351 4.98 4.187 5.16 7.501q.013.236.014.473c-.003 4.864-4.057 8.824-9.04 8.824m3.915-5.43-2.31-3.91-1.98-3.26a.26.26 0 0 0-.223-.125H9.508a.26.26 0 0 0-.227.13.25.25 0 0 0 .003.254l1.895 3.109-2.542 3.787a.25.25 0 0 0-.011.259.26.26 0 0 0 .23.132h1.905a.26.26 0 0 0 .218-.116l1.375-2.096 1.233 2.088a.26.26 0 0 0 .224.127h1.878c.094 0 .18-.049.224-.127a.24.24 0 0 0 0-.251z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#ea4b71" viewBox="0 0 24 24"><title>n8n</title><path d="M21.474 5.684a2.53 2.53 0 0 0-2.447 1.895H16.13a2.526 2.526 0 0 0-2.492 2.11l-.103.624a1.26 1.26 0 0 1-1.246 1.055h-1.001a2.527 2.527 0 0 0-4.893 0H4.973a2.527 2.527 0 1 0 0 1.264h1.422a2.527 2.527 0 0 0 4.894 0h1a1.26 1.26 0 0 1 1.247 1.055l.103.623a2.526 2.526 0 0 0 2.492 2.111h.37a2.527 2.527 0 1 0 0-1.263h-.37a1.26 1.26 0 0 1-1.246-1.056l-.103-.623A2.52 2.52 0 0 0 13.96 12a2.52 2.52 0 0 0 .82-1.48l.104-.622a1.26 1.26 0 0 1 1.246-1.056h2.896a2.527 2.527 0 1 0 2.447-3.158m0 1.263a1.263 1.263 0 0 1 1.263 1.263 1.263 1.263 0 0 1-1.263 1.264A1.263 1.263 0 0 1 20.21 8.21a1.263 1.263 0 0 1 1.264-1.263m-18.948 3.79A1.263 1.263 0 0 1 3.79 12a1.263 1.263 0 0 1-1.264 1.263A1.263 1.263 0 0 1 1.263 12a1.263 1.263 0 0 1 1.263-1.263m6.316 0A1.263 1.263 0 0 1 10.105 12a1.263 1.263 0 0 1-1.263 1.263A1.263 1.263 0 0 1 7.58 12a1.263 1.263 0 0 1 1.263-1.263m10.106 3.79a1.263 1.263 0 0 1 1.263 1.263 1.263 1.263 0 0 1-1.263 1.263 1.263 1.263 0 0 1-1.264-1.263 1.263 1.263 0 0 1 1.263-1.264"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 5.8 KiB |
@@ -1,4 +1,4 @@
|
||||
import { ReactChild, useCallback, useMemo } from 'react';
|
||||
import { ReactChild, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { matchPath, Redirect, useLocation } from 'react-router-dom';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
@@ -8,10 +8,12 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import history from 'lib/history';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
|
||||
import { OrgPreference } from 'types/api/preferences/preference';
|
||||
import { Organization } from 'types/api/user/getOrganization';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { routePermission } from 'utils/permission';
|
||||
|
||||
@@ -23,7 +25,6 @@ import routes, {
|
||||
SUPPORT_ROUTE,
|
||||
} from './routes';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
const location = useLocation();
|
||||
const { pathname } = location;
|
||||
@@ -56,12 +57,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
const currentRoute = mapRoutes.get('current');
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
const orgData = useMemo(() => {
|
||||
if (org && org.length > 0 && org[0].id !== undefined) {
|
||||
return org[0];
|
||||
}
|
||||
return undefined;
|
||||
}, [org]);
|
||||
const [orgData, setOrgData] = useState<Organization | undefined>(undefined);
|
||||
|
||||
const { data: usersData, isFetching: isFetchingUsers } = useListUsers({
|
||||
query: {
|
||||
@@ -79,7 +75,214 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
return remainingUsers.length === 1;
|
||||
}, [usersData?.data]);
|
||||
|
||||
// Handle old routes - redirect to new routes
|
||||
useEffect(() => {
|
||||
if (
|
||||
isCloudUserVal &&
|
||||
!isFetchingOrgPreferences &&
|
||||
orgPreferences &&
|
||||
!isFetchingUsers &&
|
||||
usersData &&
|
||||
usersData.data
|
||||
) {
|
||||
const isOnboardingComplete = orgPreferences?.find(
|
||||
(preference: OrgPreference) =>
|
||||
preference.name === ORG_PREFERENCES.ORG_ONBOARDING,
|
||||
)?.value;
|
||||
|
||||
// Don't redirect to onboarding if workspace has issues (blocked, suspended, or restricted)
|
||||
// User needs access to settings/billing to fix payment issues
|
||||
const isWorkspaceBlocked = trialInfo?.workSpaceBlock;
|
||||
const isWorkspaceSuspended = activeLicense?.state === LicenseState.DEFAULTED;
|
||||
const isWorkspaceAccessRestricted =
|
||||
activeLicense?.state === LicenseState.TERMINATED ||
|
||||
activeLicense?.state === LicenseState.EXPIRED ||
|
||||
activeLicense?.state === LicenseState.CANCELLED;
|
||||
|
||||
const hasWorkspaceIssue =
|
||||
isWorkspaceBlocked || isWorkspaceSuspended || isWorkspaceAccessRestricted;
|
||||
|
||||
if (hasWorkspaceIssue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstUser = checkFirstTimeUser();
|
||||
if (
|
||||
isFirstUser &&
|
||||
!isOnboardingComplete &&
|
||||
// if the current route is allowed to be overriden by org onboarding then only do the same
|
||||
!ROUTES_NOT_TO_BE_OVERRIDEN.includes(pathname)
|
||||
) {
|
||||
history.push(ROUTES.ONBOARDING);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
checkFirstTimeUser,
|
||||
isCloudUserVal,
|
||||
isFetchingOrgPreferences,
|
||||
isFetchingUsers,
|
||||
orgPreferences,
|
||||
usersData,
|
||||
pathname,
|
||||
trialInfo?.workSpaceBlock,
|
||||
activeLicense?.state,
|
||||
]);
|
||||
|
||||
const navigateToWorkSpaceBlocked = useCallback((): void => {
|
||||
const isRouteEnabledForWorkspaceBlockedState =
|
||||
isAdmin &&
|
||||
(pathname === ROUTES.SETTINGS ||
|
||||
pathname === ROUTES.ORG_SETTINGS ||
|
||||
pathname === ROUTES.MEMBERS_SETTINGS ||
|
||||
pathname === ROUTES.BILLING ||
|
||||
pathname === ROUTES.MY_SETTINGS);
|
||||
|
||||
if (
|
||||
pathname &&
|
||||
pathname !== ROUTES.WORKSPACE_LOCKED &&
|
||||
!isRouteEnabledForWorkspaceBlockedState
|
||||
) {
|
||||
history.push(ROUTES.WORKSPACE_LOCKED);
|
||||
}
|
||||
}, [isAdmin, pathname]);
|
||||
|
||||
const navigateToWorkSpaceAccessRestricted = useCallback((): void => {
|
||||
if (pathname && pathname !== ROUTES.WORKSPACE_ACCESS_RESTRICTED) {
|
||||
history.push(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingActiveLicense && activeLicense) {
|
||||
const isTerminated = activeLicense.state === LicenseState.TERMINATED;
|
||||
const isExpired = activeLicense.state === LicenseState.EXPIRED;
|
||||
const isCancelled = activeLicense.state === LicenseState.CANCELLED;
|
||||
|
||||
const isWorkspaceAccessRestricted = isTerminated || isExpired || isCancelled;
|
||||
|
||||
const { platform } = activeLicense;
|
||||
|
||||
if (isWorkspaceAccessRestricted && platform === LicensePlatform.CLOUD) {
|
||||
navigateToWorkSpaceAccessRestricted();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isFetchingActiveLicense,
|
||||
activeLicense,
|
||||
navigateToWorkSpaceAccessRestricted,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingActiveLicense) {
|
||||
const shouldBlockWorkspace = trialInfo?.workSpaceBlock;
|
||||
|
||||
if (
|
||||
shouldBlockWorkspace &&
|
||||
activeLicense?.platform === LicensePlatform.CLOUD
|
||||
) {
|
||||
navigateToWorkSpaceBlocked();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isFetchingActiveLicense,
|
||||
trialInfo?.workSpaceBlock,
|
||||
activeLicense?.platform,
|
||||
navigateToWorkSpaceBlocked,
|
||||
]);
|
||||
|
||||
const navigateToWorkSpaceSuspended = useCallback((): void => {
|
||||
if (pathname && pathname !== ROUTES.WORKSPACE_SUSPENDED) {
|
||||
history.push(ROUTES.WORKSPACE_SUSPENDED);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingActiveLicense && activeLicense) {
|
||||
const shouldSuspendWorkspace =
|
||||
activeLicense.state === LicenseState.DEFAULTED;
|
||||
|
||||
if (
|
||||
shouldSuspendWorkspace &&
|
||||
activeLicense.platform === LicensePlatform.CLOUD
|
||||
) {
|
||||
navigateToWorkSpaceSuspended();
|
||||
}
|
||||
}
|
||||
}, [isFetchingActiveLicense, activeLicense, navigateToWorkSpaceSuspended]);
|
||||
|
||||
useEffect(() => {
|
||||
if (org && org.length > 0 && org[0].id !== undefined) {
|
||||
setOrgData(org[0]);
|
||||
}
|
||||
}, [org]);
|
||||
|
||||
// if the feature flag is enabled and the current route is /get-started then redirect to /get-started-with-signoz-cloud
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentRoute?.path === ROUTES.GET_STARTED &&
|
||||
featureFlags?.find((e) => e.name === FeatureKeys.ONBOARDING_V3)?.active
|
||||
) {
|
||||
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
}
|
||||
}, [currentRoute, featureFlags]);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
useEffect(() => {
|
||||
// if it is an old route navigate to the new route
|
||||
if (isOldRoute) {
|
||||
// this will be handled by the redirect component below
|
||||
return;
|
||||
}
|
||||
|
||||
// if the current route is public dashboard then don't redirect to login
|
||||
const isPublicDashboard = currentRoute?.path === ROUTES.PUBLIC_DASHBOARD;
|
||||
|
||||
if (isPublicDashboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if the current route
|
||||
if (currentRoute) {
|
||||
const { isPrivate, key } = currentRoute;
|
||||
if (isPrivate) {
|
||||
if (isLoggedInState) {
|
||||
const route = routePermission[key];
|
||||
if (route && route.find((e) => e === user.role) === undefined) {
|
||||
history.push(ROUTES.UN_AUTHORIZED);
|
||||
}
|
||||
} else {
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
|
||||
history.push(ROUTES.LOGIN);
|
||||
}
|
||||
} else if (isLoggedInState) {
|
||||
const fromPathname = getLocalStorageApi(
|
||||
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
|
||||
);
|
||||
if (fromPathname) {
|
||||
history.push(fromPathname);
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
|
||||
} else if (pathname !== ROUTES.SOMETHING_WENT_WRONG) {
|
||||
history.push(ROUTES.HOME);
|
||||
}
|
||||
} else {
|
||||
// do nothing as the unauthenticated routes are LOGIN and SIGNUP and the LOGIN container takes care of routing to signup if
|
||||
// setup is not completed
|
||||
}
|
||||
} else if (isLoggedInState) {
|
||||
const fromPathname = getLocalStorageApi(
|
||||
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
|
||||
);
|
||||
if (fromPathname) {
|
||||
history.push(fromPathname);
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
|
||||
} else {
|
||||
history.push(ROUTES.HOME);
|
||||
}
|
||||
} else {
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
|
||||
history.push(ROUTES.LOGIN);
|
||||
}
|
||||
}, [isLoggedInState, pathname, user, isOldRoute, currentRoute, location]);
|
||||
|
||||
if (isOldRoute) {
|
||||
const redirectUrl = oldNewRoutesMapping[pathname];
|
||||
return (
|
||||
@@ -93,143 +296,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
// Public dashboard - no redirect needed
|
||||
const isPublicDashboard = currentRoute?.path === ROUTES.PUBLIC_DASHBOARD;
|
||||
if (isPublicDashboard) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Check for workspace access restriction (cloud only)
|
||||
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;
|
||||
|
||||
if (!isFetchingActiveLicense && activeLicense && isCloudPlatform) {
|
||||
const isTerminated = activeLicense.state === LicenseState.TERMINATED;
|
||||
const isExpired = activeLicense.state === LicenseState.EXPIRED;
|
||||
const isCancelled = activeLicense.state === LicenseState.CANCELLED;
|
||||
const isWorkspaceAccessRestricted = isTerminated || isExpired || isCancelled;
|
||||
|
||||
if (
|
||||
isWorkspaceAccessRestricted &&
|
||||
pathname !== ROUTES.WORKSPACE_ACCESS_RESTRICTED
|
||||
) {
|
||||
return <Redirect to={ROUTES.WORKSPACE_ACCESS_RESTRICTED} />;
|
||||
}
|
||||
|
||||
// Check for workspace suspended (DEFAULTED)
|
||||
const shouldSuspendWorkspace = activeLicense.state === LicenseState.DEFAULTED;
|
||||
if (shouldSuspendWorkspace && pathname !== ROUTES.WORKSPACE_SUSPENDED) {
|
||||
return <Redirect to={ROUTES.WORKSPACE_SUSPENDED} />;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for workspace blocked (trial expired)
|
||||
if (!isFetchingActiveLicense && isCloudPlatform && trialInfo?.workSpaceBlock) {
|
||||
const isRouteEnabledForWorkspaceBlockedState =
|
||||
isAdmin &&
|
||||
(pathname === ROUTES.SETTINGS ||
|
||||
pathname === ROUTES.ORG_SETTINGS ||
|
||||
pathname === ROUTES.MEMBERS_SETTINGS ||
|
||||
pathname === ROUTES.BILLING ||
|
||||
pathname === ROUTES.MY_SETTINGS);
|
||||
|
||||
if (
|
||||
pathname !== ROUTES.WORKSPACE_LOCKED &&
|
||||
!isRouteEnabledForWorkspaceBlockedState
|
||||
) {
|
||||
return <Redirect to={ROUTES.WORKSPACE_LOCKED} />;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for onboarding redirect (cloud users, first user, onboarding not complete)
|
||||
if (
|
||||
isCloudUserVal &&
|
||||
!isFetchingOrgPreferences &&
|
||||
orgPreferences &&
|
||||
!isFetchingUsers &&
|
||||
usersData &&
|
||||
usersData.data
|
||||
) {
|
||||
const isOnboardingComplete = orgPreferences?.find(
|
||||
(preference: OrgPreference) =>
|
||||
preference.name === ORG_PREFERENCES.ORG_ONBOARDING,
|
||||
)?.value;
|
||||
|
||||
// Don't redirect to onboarding if workspace has issues
|
||||
const isWorkspaceBlocked = trialInfo?.workSpaceBlock;
|
||||
const isWorkspaceSuspended = activeLicense?.state === LicenseState.DEFAULTED;
|
||||
const isWorkspaceAccessRestricted =
|
||||
activeLicense?.state === LicenseState.TERMINATED ||
|
||||
activeLicense?.state === LicenseState.EXPIRED ||
|
||||
activeLicense?.state === LicenseState.CANCELLED;
|
||||
|
||||
const hasWorkspaceIssue =
|
||||
isWorkspaceBlocked || isWorkspaceSuspended || isWorkspaceAccessRestricted;
|
||||
|
||||
if (!hasWorkspaceIssue) {
|
||||
const isFirstUser = checkFirstTimeUser();
|
||||
if (
|
||||
isFirstUser &&
|
||||
!isOnboardingComplete &&
|
||||
!ROUTES_NOT_TO_BE_OVERRIDEN.includes(pathname) &&
|
||||
pathname !== ROUTES.ONBOARDING
|
||||
) {
|
||||
return <Redirect to={ROUTES.ONBOARDING} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for GET_STARTED → GET_STARTED_WITH_CLOUD redirect (feature flag)
|
||||
if (
|
||||
currentRoute?.path === ROUTES.GET_STARTED &&
|
||||
featureFlags?.find((e) => e.name === FeatureKeys.ONBOARDING_V3)?.active
|
||||
) {
|
||||
return <Redirect to={ROUTES.GET_STARTED_WITH_CLOUD} />;
|
||||
}
|
||||
|
||||
// Main routing logic
|
||||
if (currentRoute) {
|
||||
const { isPrivate, key } = currentRoute;
|
||||
if (isPrivate) {
|
||||
if (isLoggedInState) {
|
||||
const route = routePermission[key];
|
||||
if (route && route.find((e) => e === user.role) === undefined) {
|
||||
return <Redirect to={ROUTES.UN_AUTHORIZED} />;
|
||||
}
|
||||
} else {
|
||||
// Save current path and redirect to login
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
|
||||
return <Redirect to={ROUTES.LOGIN} />;
|
||||
}
|
||||
} else if (isLoggedInState) {
|
||||
// Non-private route, but user is logged in
|
||||
const fromPathname = getLocalStorageApi(
|
||||
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
|
||||
);
|
||||
if (fromPathname) {
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
|
||||
return <Redirect to={fromPathname} />;
|
||||
}
|
||||
if (pathname !== ROUTES.SOMETHING_WENT_WRONG) {
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
}
|
||||
}
|
||||
// Non-private route, user not logged in - let login/signup pages handle it
|
||||
} else if (isLoggedInState) {
|
||||
// Unknown route, logged in
|
||||
const fromPathname = getLocalStorageApi(
|
||||
LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT,
|
||||
);
|
||||
if (fromPathname) {
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
|
||||
return <Redirect to={fromPathname} />;
|
||||
}
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
} else {
|
||||
// Unknown route, not logged in
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, pathname);
|
||||
return <Redirect to={ROUTES.LOGIN} />;
|
||||
}
|
||||
|
||||
// NOTE: disabling this rule as there is no need to have div
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import { IAppContext, IUser } from 'providers/App/types';
|
||||
import {
|
||||
@@ -21,6 +22,19 @@ import { ROLES, USER_ROLES } from 'types/roles';
|
||||
|
||||
import PrivateRoute from '../Private';
|
||||
|
||||
// Mock history module
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
push: jest.fn(),
|
||||
location: { pathname: '/', search: '', hash: '' },
|
||||
listen: jest.fn(),
|
||||
createHref: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockHistoryPush = history.push as jest.Mock;
|
||||
|
||||
// Mock localStorage APIs
|
||||
const mockLocalStorage: Record<string, string> = {};
|
||||
jest.mock('api/browser/localstorage/get', () => ({
|
||||
@@ -53,12 +67,9 @@ jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
|
||||
// Mock react-query for users fetch
|
||||
let mockUsersData: { email: string }[] = [];
|
||||
jest.mock('api/generated/services/users', () => ({
|
||||
...jest.requireActual('api/generated/services/users'),
|
||||
useListUsers: jest.fn(() => ({
|
||||
data: { data: mockUsersData },
|
||||
isFetching: false,
|
||||
})),
|
||||
jest.mock('api/v1/user/get', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve({ data: mockUsersData })),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -225,18 +236,20 @@ function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
|
||||
}
|
||||
|
||||
// Generic assertion helpers for navigation behavior
|
||||
// Using location-based assertions since Private.tsx now uses Redirect component
|
||||
// Using these allows easier refactoring when switching from history.push to Redirect component
|
||||
|
||||
async function assertRedirectsTo(targetRoute: string): Promise<void> {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('location-display')).toHaveTextContent(targetRoute);
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(targetRoute);
|
||||
});
|
||||
}
|
||||
|
||||
function assertStaysOnRoute(expectedRoute: string): void {
|
||||
expect(screen.getByTestId('location-display')).toHaveTextContent(
|
||||
expectedRoute,
|
||||
);
|
||||
function assertNoRedirect(): void {
|
||||
expect(mockHistoryPush).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
function assertDoesNotRedirectTo(targetRoute: string): void {
|
||||
expect(mockHistoryPush).not.toHaveBeenCalledWith(targetRoute);
|
||||
}
|
||||
|
||||
function assertRendersChildren(): void {
|
||||
@@ -334,7 +347,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
assertRendersChildren();
|
||||
assertStaysOnRoute('/public/dashboard/abc123');
|
||||
assertNoRedirect();
|
||||
});
|
||||
|
||||
it('should render children for public dashboard route when logged in without redirecting', () => {
|
||||
@@ -346,7 +359,7 @@ describe('PrivateRoute', () => {
|
||||
assertRendersChildren();
|
||||
// Critical: without the isPublicDashboard early return, logged-in users
|
||||
// would be redirected to HOME due to the non-private route handling
|
||||
assertStaysOnRoute('/public/dashboard/abc123');
|
||||
assertNoRedirect();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -404,7 +417,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
assertRendersChildren();
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertNoRedirect();
|
||||
});
|
||||
|
||||
it('should redirect to unauthorized when VIEWER tries to access admin-only route /alerts/new', async () => {
|
||||
@@ -513,7 +526,7 @@ describe('PrivateRoute', () => {
|
||||
appContext: { isLoggedIn: true },
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.SOMETHING_WENT_WRONG);
|
||||
assertDoesNotRedirectTo(ROUTES.HOME);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -525,7 +538,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
// Should not redirect - login page handles its own routing
|
||||
assertStaysOnRoute(ROUTES.LOGIN);
|
||||
assertNoRedirect();
|
||||
});
|
||||
|
||||
it('should not redirect when not logged in user visits signup page', () => {
|
||||
@@ -534,7 +547,7 @@ describe('PrivateRoute', () => {
|
||||
appContext: { isLoggedIn: false },
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.SIGN_UP);
|
||||
assertNoRedirect();
|
||||
});
|
||||
|
||||
it('should not redirect when not logged in user visits password reset page', () => {
|
||||
@@ -543,7 +556,7 @@ describe('PrivateRoute', () => {
|
||||
appContext: { isLoggedIn: false },
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.PASSWORD_RESET);
|
||||
assertNoRedirect();
|
||||
});
|
||||
|
||||
it('should not redirect when not logged in user visits forgot password page', () => {
|
||||
@@ -552,7 +565,7 @@ describe('PrivateRoute', () => {
|
||||
appContext: { isLoggedIn: false },
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.FORGOT_PASSWORD);
|
||||
assertNoRedirect();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -641,7 +654,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
// Admin should be able to access settings even when workspace is blocked
|
||||
assertStaysOnRoute(ROUTES.SETTINGS);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should allow ADMIN to access /settings/billing when workspace is blocked', () => {
|
||||
@@ -657,7 +670,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.BILLING);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should allow ADMIN to access /settings/org-settings when workspace is blocked', () => {
|
||||
@@ -673,7 +686,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.ORG_SETTINGS);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should allow ADMIN to access /settings/members when workspace is blocked', () => {
|
||||
@@ -689,7 +702,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.MEMBERS_SETTINGS);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should allow ADMIN to access /settings/my-settings when workspace is blocked', () => {
|
||||
@@ -705,7 +718,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.MY_SETTINGS);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should redirect VIEWER to workspace locked even when trying to access settings', async () => {
|
||||
@@ -816,7 +829,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.WORKSPACE_LOCKED);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should not redirect self-hosted users to workspace locked even when workSpaceBlock is true', () => {
|
||||
@@ -833,7 +846,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: false,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -903,7 +916,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
});
|
||||
|
||||
it('should not redirect self-hosted users to workspace access restricted when license is terminated', () => {
|
||||
@@ -920,7 +933,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: false,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
});
|
||||
|
||||
it('should not redirect when license is ACTIVE', () => {
|
||||
@@ -937,7 +950,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
});
|
||||
|
||||
it('should not redirect when license is EVALUATING', () => {
|
||||
@@ -954,7 +967,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -990,7 +1003,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.WORKSPACE_SUSPENDED);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_SUSPENDED);
|
||||
});
|
||||
|
||||
it('should not redirect self-hosted users to workspace suspended when license is defaulted', () => {
|
||||
@@ -1007,7 +1020,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: false,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_SUSPENDED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1027,11 +1040,6 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
// Wait for the users query to complete and trigger re-render
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
@@ -1047,7 +1055,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when onboarding is already complete', async () => {
|
||||
@@ -1073,7 +1081,7 @@ describe('PrivateRoute', () => {
|
||||
|
||||
// Critical: if isOnboardingComplete check is broken (always false),
|
||||
// this test would fail because all other conditions for redirect ARE met
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding for non-cloud users', () => {
|
||||
@@ -1088,7 +1096,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: false,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when on /workspace-locked route', () => {
|
||||
@@ -1103,7 +1111,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.WORKSPACE_LOCKED);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when on /workspace-suspended route', () => {
|
||||
@@ -1118,7 +1126,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.WORKSPACE_SUSPENDED);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when workspace is blocked and accessing billing', async () => {
|
||||
@@ -1145,7 +1153,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
// Should NOT redirect to onboarding - user needs to access billing to fix payment
|
||||
assertStaysOnRoute(ROUTES.BILLING);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when workspace is blocked and accessing settings', async () => {
|
||||
@@ -1169,7 +1177,7 @@ describe('PrivateRoute', () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.SETTINGS);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when workspace is suspended (DEFAULTED)', async () => {
|
||||
@@ -1196,7 +1204,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
// Should redirect to WORKSPACE_SUSPENDED, not ONBOARDING
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_SUSPENDED);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when workspace is access restricted (TERMINATED)', async () => {
|
||||
@@ -1223,7 +1231,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
// Should redirect to WORKSPACE_ACCESS_RESTRICTED, not ONBOARDING
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
|
||||
it('should not redirect to onboarding when workspace is access restricted (EXPIRED)', async () => {
|
||||
@@ -1249,7 +1257,7 @@ describe('PrivateRoute', () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
assertDoesNotRedirectTo(ROUTES.ONBOARDING);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1291,7 +1299,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED);
|
||||
assertDoesNotRedirectTo(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
});
|
||||
|
||||
it('should not redirect when on GET_STARTED and ONBOARDING_V3 feature flag is not present', () => {
|
||||
@@ -1303,7 +1311,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED);
|
||||
assertDoesNotRedirectTo(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
});
|
||||
|
||||
it('should not redirect when on different route even if ONBOARDING_V3 is active', () => {
|
||||
@@ -1323,7 +1331,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1339,7 +1347,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should not fetch users when org data is not available', () => {
|
||||
@@ -1382,7 +1390,9 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_LOCKED);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_SUSPENDED);
|
||||
assertDoesNotRedirectTo(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1423,40 +1433,22 @@ describe('PrivateRoute', () => {
|
||||
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
|
||||
it('should allow ADMIN to access /services route', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.APPLICATION,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role: USER_ROLES.ADMIN as ROLES }),
|
||||
},
|
||||
it('should allow all roles to access /services route', () => {
|
||||
const roles = [USER_ROLES.ADMIN, USER_ROLES.EDITOR, USER_ROLES.VIEWER];
|
||||
|
||||
roles.forEach((role) => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.APPLICATION,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role: role as ROLES }),
|
||||
},
|
||||
});
|
||||
|
||||
assertDoesNotRedirectTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.APPLICATION);
|
||||
});
|
||||
|
||||
it('should allow EDITOR to access /services route', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.APPLICATION,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role: USER_ROLES.EDITOR as ROLES }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.APPLICATION);
|
||||
});
|
||||
|
||||
it('should allow VIEWER to access /services route', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.APPLICATION,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role: USER_ROLES.VIEWER as ROLES }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.APPLICATION);
|
||||
});
|
||||
|
||||
it('should redirect VIEWER from /onboarding route (admin only)', async () => {
|
||||
@@ -1486,7 +1478,7 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
|
||||
assertRendersChildren();
|
||||
assertStaysOnRoute(ROUTES.CHANNELS_NEW);
|
||||
assertDoesNotRedirectTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
|
||||
it('should allow EDITOR to access /get-started route', () => {
|
||||
@@ -1498,7 +1490,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED);
|
||||
assertDoesNotRedirectTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import AppLayout from 'container/AppLayout';
|
||||
import Hex from 'crypto-js/enc-hex';
|
||||
import HmacSHA256 from 'crypto-js/hmac-sha256';
|
||||
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { useIsDarkMode, useThemeConfig } from 'hooks/useDarkMode';
|
||||
import { useThemeConfig } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { NotificationProvider } from 'hooks/useNotifications';
|
||||
import { ResourceProvider } from 'hooks/useResourceAttribute';
|
||||
@@ -212,12 +212,6 @@ function App(): JSX.Element {
|
||||
activeLicenseFetchError,
|
||||
]);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
useEffect(() => {
|
||||
window.Pylon?.('setTheme', isDarkMode ? 'dark' : 'light');
|
||||
}, [isDarkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
|
||||
@@ -425,6 +425,39 @@ export interface AuthtypesSessionContextDTO {
|
||||
orgs?: AuthtypesOrgSessionContextDTO[] | null;
|
||||
}
|
||||
|
||||
export interface AuthtypesStorableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface AuthtypesTransactionDTO {
|
||||
object: AuthtypesObjectDTO;
|
||||
/**
|
||||
@@ -442,25 +475,25 @@ export interface AuthtypesUserRoleDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: Date;
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
role: AuthtypesRoleDTO;
|
||||
role?: AuthtypesStorableRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
roleId: string;
|
||||
roleId?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: Date;
|
||||
updatedAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
userId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesUserWithRolesDTO {
|
||||
@@ -2677,6 +2710,14 @@ export interface RenderErrorResponseDTO {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export enum RulestatehistorytypesAlertStateDTO {
|
||||
inactive = 'inactive',
|
||||
pending = 'pending',
|
||||
recovering = 'recovering',
|
||||
firing = 'firing',
|
||||
nodata = 'nodata',
|
||||
disabled = 'disabled',
|
||||
}
|
||||
export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -2688,7 +2729,7 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
|
||||
* @nullable true
|
||||
*/
|
||||
labels: Querybuildertypesv5LabelDTO[] | null;
|
||||
overallState: RuletypesAlertStateDTO;
|
||||
overallState: RulestatehistorytypesAlertStateDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -2696,12 +2737,12 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
ruleId: string;
|
||||
ruleID: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
ruleName: string;
|
||||
state: RuletypesAlertStateDTO;
|
||||
state: RulestatehistorytypesAlertStateDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -2799,17 +2840,9 @@ export interface RulestatehistorytypesGettableRuleStateWindowDTO {
|
||||
* @format int64
|
||||
*/
|
||||
start: number;
|
||||
state: RuletypesAlertStateDTO;
|
||||
state: RulestatehistorytypesAlertStateDTO;
|
||||
}
|
||||
|
||||
export enum RuletypesAlertStateDTO {
|
||||
inactive = 'inactive',
|
||||
pending = 'pending',
|
||||
recovering = 'recovering',
|
||||
firing = 'firing',
|
||||
nodata = 'nodata',
|
||||
disabled = 'disabled',
|
||||
}
|
||||
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -4580,7 +4613,7 @@ export type GetRuleHistoryTimelineParams = {
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
state?: RuletypesAlertStateDTO;
|
||||
state?: RulestatehistorytypesAlertStateDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
|
||||
@@ -13,9 +13,7 @@ export interface HostListPayload {
|
||||
orderBy?: {
|
||||
columnName: string;
|
||||
order: 'asc' | 'desc';
|
||||
} | null;
|
||||
start?: number;
|
||||
end?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TimeSeriesValue {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
.announcement-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
height: 40px;
|
||||
font-family: var(--font-sans), sans-serif;
|
||||
font-size: var(--label-base-500-font-size);
|
||||
line-height: var(--label-base-500-line-height);
|
||||
font-weight: var(--label-base-500-font-weight);
|
||||
letter-spacing: -0.065px;
|
||||
|
||||
&--warning {
|
||||
background-color: var(--callout-warning-background);
|
||||
color: var(--callout-warning-description);
|
||||
.announcement-banner__action,
|
||||
.announcement-banner__dismiss {
|
||||
background: var(--callout-warning-border);
|
||||
}
|
||||
}
|
||||
|
||||
&--info {
|
||||
background-color: var(--callout-primary-background);
|
||||
color: var(--callout-primary-description);
|
||||
.announcement-banner__action,
|
||||
.announcement-banner__dismiss {
|
||||
background: var(--callout-primary-border);
|
||||
}
|
||||
}
|
||||
|
||||
&--error {
|
||||
background-color: var(--callout-error-background);
|
||||
color: var(--callout-error-description);
|
||||
.announcement-banner__action,
|
||||
.announcement-banner__dismiss {
|
||||
background: var(--callout-error-border);
|
||||
}
|
||||
}
|
||||
|
||||
&--success {
|
||||
background-color: var(--callout-success-background);
|
||||
color: var(--callout-success-description);
|
||||
.announcement-banner__action,
|
||||
.announcement-banner__dismiss {
|
||||
background: var(--callout-success-border);
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: var(--line-height-normal);
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
}
|
||||
|
||||
&__action {
|
||||
height: 24px;
|
||||
font-size: var(--label-small-500-font-size);
|
||||
color: currentColor;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&__dismiss {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
color: currentColor;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
AnnouncementBanner,
|
||||
AnnouncementBannerProps,
|
||||
PersistedAnnouncementBanner,
|
||||
} from './index';
|
||||
|
||||
const STORAGE_KEY = 'test-banner-dismissed';
|
||||
|
||||
function renderBanner(props: Partial<AnnouncementBannerProps> = {}): void {
|
||||
render(<AnnouncementBanner message="Test message" {...props} />);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
describe('AnnouncementBanner', () => {
|
||||
it('renders message and default warning variant', () => {
|
||||
renderBanner({ message: <strong>Heads up</strong> });
|
||||
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toHaveClass('announcement-banner--warning');
|
||||
expect(alert).toHaveTextContent('Heads up');
|
||||
});
|
||||
|
||||
it.each(['warning', 'info', 'success', 'error'] as const)(
|
||||
'renders %s variant correctly',
|
||||
(type) => {
|
||||
renderBanner({ type, message: 'Test message' });
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toHaveClass(`announcement-banner--${type}`);
|
||||
},
|
||||
);
|
||||
|
||||
it('calls action onClick when action button is clicked', async () => {
|
||||
const onClick = jest.fn() as jest.MockedFunction<() => void>;
|
||||
renderBanner({ action: { label: 'Go to Settings', onClick } });
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByRole('button', { name: /go to settings/i }));
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hides dismiss button when onClose is not provided and hides icon when icon is null', () => {
|
||||
renderBanner({ onClose: undefined, icon: null });
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /dismiss/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('alert')?.querySelector('.announcement-banner__icon'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersistedAnnouncementBanner', () => {
|
||||
it('dismisses on click, calls onDismiss, and persists to localStorage', async () => {
|
||||
const onDismiss = jest.fn() as jest.MockedFunction<() => void>;
|
||||
render(
|
||||
<PersistedAnnouncementBanner
|
||||
message="Test message"
|
||||
storageKey={STORAGE_KEY}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByRole('button', { name: /dismiss/i }));
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('true');
|
||||
});
|
||||
|
||||
it('does not render when storageKey is already set in localStorage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
render(
|
||||
<PersistedAnnouncementBanner
|
||||
message="Test message"
|
||||
storageKey={STORAGE_KEY}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import {
|
||||
CircleAlert,
|
||||
CircleCheckBig,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import './AnnouncementBanner.styles.scss';
|
||||
|
||||
export type AnnouncementBannerType = 'warning' | 'info' | 'error' | 'success';
|
||||
|
||||
export interface AnnouncementBannerAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface AnnouncementBannerProps {
|
||||
message: ReactNode;
|
||||
type?: AnnouncementBannerType;
|
||||
icon?: ReactNode | null;
|
||||
action?: AnnouncementBannerAction;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_ICONS: Record<AnnouncementBannerType, ReactNode> = {
|
||||
warning: <TriangleAlert size={14} />,
|
||||
info: <Info size={14} />,
|
||||
error: <CircleAlert size={14} />,
|
||||
success: <CircleCheckBig size={14} />,
|
||||
};
|
||||
|
||||
export default function AnnouncementBanner({
|
||||
message,
|
||||
type = 'warning',
|
||||
icon,
|
||||
action,
|
||||
onClose,
|
||||
className,
|
||||
}: AnnouncementBannerProps): JSX.Element {
|
||||
const resolvedIcon = icon === null ? null : icon ?? DEFAULT_ICONS[type];
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cx(
|
||||
'announcement-banner',
|
||||
`announcement-banner--${type}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="announcement-banner__body">
|
||||
{resolvedIcon && (
|
||||
<span className="announcement-banner__icon">{resolvedIcon}</span>
|
||||
)}
|
||||
<span className="announcement-banner__message">{message}</span>
|
||||
{action && (
|
||||
<Button
|
||||
type="button"
|
||||
className="announcement-banner__action"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onClose && (
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
className="announcement-banner__dismiss"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import AnnouncementBanner, {
|
||||
AnnouncementBannerProps,
|
||||
} from './AnnouncementBanner';
|
||||
|
||||
interface PersistedAnnouncementBannerProps extends AnnouncementBannerProps {
|
||||
storageKey: string;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
function isDismissed(storageKey: string): boolean {
|
||||
return localStorage.getItem(storageKey) === 'true';
|
||||
}
|
||||
|
||||
export default function PersistedAnnouncementBanner({
|
||||
storageKey,
|
||||
onDismiss,
|
||||
...props
|
||||
}: PersistedAnnouncementBannerProps): JSX.Element | null {
|
||||
const [visible, setVisible] = useState(() => !isDismissed(storageKey));
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClose = (): void => {
|
||||
localStorage.setItem(storageKey, 'true');
|
||||
setVisible(false);
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
return <AnnouncementBanner {...props} onClose={handleClose} />;
|
||||
}
|
||||
12
frontend/src/components/AnnouncementBanner/index.ts
Normal file
12
frontend/src/components/AnnouncementBanner/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import AnnouncementBanner from './AnnouncementBanner';
|
||||
import PersistedAnnouncementBanner from './PersistedAnnouncementBanner';
|
||||
|
||||
export type {
|
||||
AnnouncementBannerAction,
|
||||
AnnouncementBannerProps,
|
||||
AnnouncementBannerType,
|
||||
} from './AnnouncementBanner';
|
||||
|
||||
export { AnnouncementBanner, PersistedAnnouncementBanner };
|
||||
|
||||
export default AnnouncementBanner;
|
||||
@@ -14,8 +14,6 @@ import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import './CreateServiceAccountModal.styles.scss';
|
||||
|
||||
@@ -30,8 +28,6 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
@@ -58,10 +54,13 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
await invalidateListServiceAccounts(queryClient);
|
||||
},
|
||||
onError: (err) => {
|
||||
const errMessage = convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
);
|
||||
showErrorModal(errMessage as APIError);
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'An error occurred';
|
||||
toast.error(`Failed to create service account: ${errMessage}`, {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -91,7 +90,7 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
showCloseButton
|
||||
width="narrow"
|
||||
className="create-sa-modal"
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<div className="create-sa-modal__content">
|
||||
<form
|
||||
|
||||
@@ -11,16 +11,6 @@ jest.mock('@signozhq/sonner', () => ({
|
||||
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const showErrorModal = jest.fn();
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('providers/ErrorModalProvider'),
|
||||
useErrorModal: jest.fn(() => ({
|
||||
showErrorModal,
|
||||
isErrorModalVisible: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const SERVICE_ACCOUNTS_ENDPOINT = '*/api/v1/service_accounts';
|
||||
|
||||
function renderModal(): ReturnType<typeof render> {
|
||||
@@ -102,13 +92,10 @@ describe('CreateServiceAccountModal', () => {
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
getErrorMessage: expect.any(Function),
|
||||
}),
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Failed to create service account/i),
|
||||
expect.anything(),
|
||||
);
|
||||
const passedError = showErrorModal.mock.calls[0][0] as any;
|
||||
expect(passedError.getErrorMessage()).toBe('Internal Server Error');
|
||||
});
|
||||
|
||||
expect(
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
|
||||
interface DeleteMemberDialogProps {
|
||||
open: boolean;
|
||||
isInvited: boolean;
|
||||
member: MemberRow | null;
|
||||
isDeleting: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
function DeleteMemberDialog({
|
||||
open,
|
||||
isInvited,
|
||||
member,
|
||||
isDeleting,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: DeleteMemberDialogProps): JSX.Element {
|
||||
const title = isInvited ? 'Revoke Invite' : 'Delete Member';
|
||||
|
||||
const body = isInvited ? (
|
||||
<>
|
||||
Are you sure you want to revoke the invite for{' '}
|
||||
<strong>{member?.email}</strong>? They will no longer be able to join the
|
||||
workspace using this invite.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Are you sure you want to delete{' '}
|
||||
<strong>{member?.name || member?.email}</strong>? This will remove their
|
||||
access to the workspace.
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={title}
|
||||
width="narrow"
|
||||
className="alert-dialog delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<p className="delete-dialog__body">{body}</p>
|
||||
|
||||
<DialogFooter className="delete-dialog__footer">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={onClose}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
disabled={isDeleting}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isDeleting ? 'Processing...' : title}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteMemberDialog;
|
||||
@@ -45,8 +45,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 32px;
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
height: 32px;
|
||||
padding: 0 var(--padding-2);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
@@ -57,13 +57,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__disabled-roles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__email-text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
@@ -83,6 +76,35 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__role-select {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--l2-background) !important;
|
||||
border-color: var(--border) !important;
|
||||
border-radius: 2px;
|
||||
padding: 0 var(--padding-2) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l1-foreground);
|
||||
line-height: 32px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -146,10 +168,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__tooltip-wrapper {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
&__footer-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,67 +1,39 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
import { LockKeyhole, RefreshCw, Trash2, X } from '@signozhq/icons';
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
LockKeyhole,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { Select } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
getResetPasswordToken,
|
||||
useDeleteUser,
|
||||
useGetUser,
|
||||
useUpdateMyUserV2,
|
||||
useUpdateUser,
|
||||
useUpdateUserDeprecated,
|
||||
} from 'api/generated/services/users';
|
||||
import { AxiosError } from 'axios';
|
||||
import { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import RolesSelect, { useRoles } from 'components/RolesSelect';
|
||||
import SaveErrorItem from 'components/ServiceAccountDrawer/SaveErrorItem';
|
||||
import type { SaveError } from 'components/ServiceAccountDrawer/utils';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
import {
|
||||
MemberRoleUpdateFailure,
|
||||
useMemberRoleManager,
|
||||
} from 'hooks/member/useMemberRoleManager';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import DeleteMemberDialog from './DeleteMemberDialog';
|
||||
import ResetLinkDialog from './ResetLinkDialog';
|
||||
import { ROLES } from 'types/roles';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import './EditMemberDrawer.styles.scss';
|
||||
|
||||
const ROOT_USER_TOOLTIP = 'This operation is not supported for the root user';
|
||||
const SELF_DELETE_TOOLTIP =
|
||||
'You cannot perform this action on your own account';
|
||||
|
||||
function getDeleteTooltip(
|
||||
isRootUser: boolean,
|
||||
isSelf: boolean,
|
||||
): string | undefined {
|
||||
if (isRootUser) {
|
||||
return ROOT_USER_TOOLTIP;
|
||||
}
|
||||
if (isSelf) {
|
||||
return SELF_DELETE_TOOLTIP;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toSaveApiError(err: unknown): APIError {
|
||||
return (
|
||||
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
|
||||
toAPIError(err as AxiosError<RenderErrorResponseDTO>)
|
||||
);
|
||||
}
|
||||
|
||||
export interface EditMemberDrawerProps {
|
||||
member: MemberRow | null;
|
||||
open: boolean;
|
||||
@@ -77,12 +49,9 @@ function EditMemberDrawer({
|
||||
onComplete,
|
||||
}: EditMemberDrawerProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const { user: currentUser } = useAppContext();
|
||||
|
||||
const [localDisplayName, setLocalDisplayName] = useState('');
|
||||
const [localRole, setLocalRole] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [selectedRole, setSelectedRole] = useState<ROLES>('VIEWER');
|
||||
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [resetLink, setResetLink] = useState<string | null>(null);
|
||||
@@ -91,247 +60,92 @@ function EditMemberDrawer({
|
||||
const [linkType, setLinkType] = useState<'invite' | 'reset' | null>(null);
|
||||
|
||||
const isInvited = member?.status === MemberStatus.Invited;
|
||||
const isDeleted = member?.status === MemberStatus.Deleted;
|
||||
const isSelf = !!member?.id && member.id === currentUser?.id;
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const {
|
||||
data: fetchedUser,
|
||||
isLoading: isFetchingUser,
|
||||
refetch: refetchUser,
|
||||
} = useGetUser(
|
||||
{ id: member?.id ?? '' },
|
||||
{ query: { enabled: open && !!member?.id } },
|
||||
);
|
||||
|
||||
const isRootUser = !!fetchedUser?.data?.isRoot;
|
||||
|
||||
const {
|
||||
roles: availableRoles,
|
||||
isLoading: rolesLoading,
|
||||
isError: rolesError,
|
||||
error: rolesErrorObj,
|
||||
refetch: refetchRoles,
|
||||
} = useRoles();
|
||||
|
||||
const {
|
||||
fetchedRoleIds,
|
||||
isLoading: isMemberRolesLoading,
|
||||
applyDiff,
|
||||
} = useMemberRoleManager(member?.id ?? '', open && !!member?.id);
|
||||
|
||||
const fetchedDisplayName =
|
||||
fetchedUser?.data?.displayName ?? member?.name ?? '';
|
||||
const fetchedUserId = fetchedUser?.data?.id;
|
||||
const fetchedUserDisplayName = fetchedUser?.data?.displayName;
|
||||
|
||||
const roleSessionRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchedUserId) {
|
||||
setLocalDisplayName(fetchedUserDisplayName ?? member?.name ?? '');
|
||||
}
|
||||
}, [fetchedUserId, fetchedUserDisplayName, member?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchedUserId) {
|
||||
setSaveErrors([]);
|
||||
}
|
||||
}, [fetchedUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!member?.id) {
|
||||
roleSessionRef.current = null;
|
||||
} else if (member.id !== roleSessionRef.current && !isMemberRolesLoading) {
|
||||
setLocalRole(fetchedRoleIds[0] ?? '');
|
||||
roleSessionRef.current = member.id;
|
||||
}
|
||||
}, [member?.id, fetchedRoleIds, isMemberRolesLoading]);
|
||||
|
||||
const isDirty =
|
||||
member !== null &&
|
||||
fetchedUser != null &&
|
||||
(localDisplayName !== fetchedDisplayName ||
|
||||
localRole !== (fetchedRoleIds[0] ?? ''));
|
||||
|
||||
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
|
||||
const { mutateAsync: updateUser } = useUpdateUser();
|
||||
const { mutate: updateUser, isLoading: isSaving } = useUpdateUserDeprecated({
|
||||
mutation: {
|
||||
onSuccess: (): void => {
|
||||
toast.success('Member details updated successfully', { richColors: true });
|
||||
onComplete();
|
||||
onClose();
|
||||
},
|
||||
onError: (err): void => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'An error occurred';
|
||||
toast.error(`Failed to update member details: ${errMessage}`, {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: deleteUser, isLoading: isDeleting } = useDeleteUser({
|
||||
mutation: {
|
||||
onSuccess: (): void => {
|
||||
toast.success(
|
||||
isInvited ? 'Invite revoked successfully' : 'Member deleted successfully',
|
||||
{ richColors: true, position: 'top-right' },
|
||||
{ richColors: true },
|
||||
);
|
||||
setShowDeleteConfirm(false);
|
||||
onComplete();
|
||||
onClose();
|
||||
},
|
||||
onError: (err): void => {
|
||||
const errMessage = convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
);
|
||||
showErrorModal(errMessage as APIError);
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'An error occurred';
|
||||
const prefix = isInvited
|
||||
? 'Failed to revoke invite'
|
||||
: 'Failed to delete member';
|
||||
toast.error(`${prefix}: ${errMessage}`, { richColors: true });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const makeRoleRetry = useCallback(
|
||||
(
|
||||
context: string,
|
||||
rawRetry: () => Promise<void>,
|
||||
) => async (): Promise<void> => {
|
||||
try {
|
||||
await rawRetry();
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== context));
|
||||
refetchUser();
|
||||
} catch (err) {
|
||||
setSaveErrors((prev) =>
|
||||
prev.map((e) =>
|
||||
e.context === context ? { ...e, apiError: toSaveApiError(err) } : e,
|
||||
),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (member) {
|
||||
setDisplayName(member.name ?? '');
|
||||
setSelectedRole(member.role);
|
||||
}
|
||||
}, [member]);
|
||||
|
||||
const isDirty =
|
||||
member !== null &&
|
||||
(displayName !== (member.name ?? '') || selectedRole !== member.role);
|
||||
|
||||
const formatTimestamp = useCallback(
|
||||
(ts: string | null | undefined): string => {
|
||||
if (!ts) {
|
||||
return '—';
|
||||
}
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
|
||||
},
|
||||
[refetchUser],
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const retryNameUpdate = useCallback(async (): Promise<void> => {
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (isSelf) {
|
||||
await updateMyUser({ data: { displayName: localDisplayName } });
|
||||
} else {
|
||||
await updateUser({
|
||||
pathParams: { id: member.id },
|
||||
data: { displayName: localDisplayName },
|
||||
});
|
||||
}
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Name update'));
|
||||
refetchUser();
|
||||
} catch (err) {
|
||||
setSaveErrors((prev) =>
|
||||
prev.map((e) =>
|
||||
e.context === 'Name update' ? { ...e, apiError: toSaveApiError(err) } : e,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [member, isSelf, localDisplayName, updateMyUser, updateUser, refetchUser]);
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
const handleSave = useCallback((): void => {
|
||||
if (!member || !isDirty) {
|
||||
return;
|
||||
}
|
||||
setSaveErrors([]);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const nameChanged = localDisplayName !== fetchedDisplayName;
|
||||
const rolesChanged = localRole !== (fetchedRoleIds[0] ?? '');
|
||||
|
||||
const namePromise = nameChanged
|
||||
? isSelf
|
||||
? updateMyUser({ data: { displayName: localDisplayName } })
|
||||
: updateUser({
|
||||
pathParams: { id: member.id },
|
||||
data: { displayName: localDisplayName },
|
||||
})
|
||||
: Promise.resolve();
|
||||
|
||||
const [nameResult, rolesResult] = await Promise.allSettled([
|
||||
namePromise,
|
||||
rolesChanged
|
||||
? applyDiff([localRole].filter(Boolean), availableRoles)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const errors: SaveError[] = [];
|
||||
|
||||
if (nameResult.status === 'rejected') {
|
||||
errors.push({
|
||||
context: 'Name update',
|
||||
apiError: toSaveApiError(nameResult.reason),
|
||||
onRetry: retryNameUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
if (rolesResult.status === 'rejected') {
|
||||
errors.push({
|
||||
context: 'Roles update',
|
||||
apiError: toSaveApiError(rolesResult.reason),
|
||||
onRetry: async (): Promise<void> => {
|
||||
const failures = await applyDiff(
|
||||
[localRole].filter(Boolean),
|
||||
availableRoles,
|
||||
);
|
||||
setSaveErrors((prev) => {
|
||||
const rest = prev.filter((e) => e.context !== 'Roles update');
|
||||
return [
|
||||
...rest,
|
||||
...failures.map((f: MemberRoleUpdateFailure) => {
|
||||
const ctx = `Role '${f.roleName}'`;
|
||||
return {
|
||||
context: ctx,
|
||||
apiError: toSaveApiError(f.error),
|
||||
onRetry: makeRoleRetry(ctx, f.onRetry),
|
||||
};
|
||||
}),
|
||||
];
|
||||
});
|
||||
refetchUser();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
for (const failure of rolesResult.value ?? []) {
|
||||
const context = `Role '${failure.roleName}'`;
|
||||
errors.push({
|
||||
context,
|
||||
apiError: toSaveApiError(failure.error),
|
||||
onRetry: makeRoleRetry(context, failure.onRetry),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setSaveErrors(errors);
|
||||
} else {
|
||||
toast.success('Member details updated successfully', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
onComplete();
|
||||
}
|
||||
|
||||
refetchUser();
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
member,
|
||||
isDirty,
|
||||
isSelf,
|
||||
localDisplayName,
|
||||
localRole,
|
||||
fetchedDisplayName,
|
||||
fetchedRoleIds,
|
||||
updateMyUser,
|
||||
updateUser,
|
||||
applyDiff,
|
||||
availableRoles,
|
||||
refetchUser,
|
||||
retryNameUpdate,
|
||||
makeRoleRetry,
|
||||
onComplete,
|
||||
]);
|
||||
updateUser({
|
||||
pathParams: { id: member.id },
|
||||
data: { id: member.id, displayName, role: selectedRole },
|
||||
});
|
||||
}, [member, isDirty, displayName, selectedRole, updateUser]);
|
||||
|
||||
const handleDelete = useCallback((): void => {
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
deleteUser({ pathParams: { id: member.id } });
|
||||
deleteUser({
|
||||
pathParams: { id: member.id },
|
||||
});
|
||||
}, [member, deleteUser]);
|
||||
|
||||
const handleGenerateResetLink = useCallback(async (): Promise<void> => {
|
||||
@@ -354,36 +168,37 @@ function EditMemberDrawer({
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
);
|
||||
showErrorModal(errMsg as APIError);
|
||||
} catch {
|
||||
toast.error('Failed to generate password reset link', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
} finally {
|
||||
setIsGeneratingLink(false);
|
||||
}
|
||||
}, [member, isInvited, onClose, showErrorModal]);
|
||||
}, [member, isInvited, setLinkType, onClose]);
|
||||
|
||||
const [copyState, copyToClipboard] = useCopyToClipboard();
|
||||
const handleCopyResetLink = useCallback((): void => {
|
||||
const handleCopyResetLink = useCallback(async (): Promise<void> => {
|
||||
if (!resetLink) {
|
||||
return;
|
||||
}
|
||||
copyToClipboard(resetLink);
|
||||
|
||||
setHasCopiedResetLink(true);
|
||||
setTimeout(() => setHasCopiedResetLink(false), 2000);
|
||||
const message =
|
||||
toast.success(
|
||||
linkType === 'invite'
|
||||
? 'Invite link copied to clipboard'
|
||||
: 'Reset link copied to clipboard';
|
||||
toast.success(message, { richColors: true, position: 'top-right' });
|
||||
: 'Reset link copied to clipboard',
|
||||
{ richColors: true },
|
||||
);
|
||||
}, [resetLink, copyToClipboard, linkType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (copyState.error) {
|
||||
toast.error('Failed to copy link', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
}, [copyState.error]);
|
||||
@@ -395,211 +210,140 @@ function EditMemberDrawer({
|
||||
|
||||
const joinedOnLabel = isInvited ? 'Invited On' : 'Joined On';
|
||||
|
||||
const formatTimestamp = useCallback(
|
||||
(ts: string | null | undefined): string => {
|
||||
if (!ts) {
|
||||
return '—';
|
||||
}
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
|
||||
},
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const drawerBody = isFetchingUser ? (
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
) : (
|
||||
<>
|
||||
<div className="edit-member-drawer__field">
|
||||
<label className="edit-member-drawer__label" htmlFor="member-name">
|
||||
Name
|
||||
</label>
|
||||
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
|
||||
<Input
|
||||
id="member-name"
|
||||
value={localDisplayName}
|
||||
onChange={(e): void => {
|
||||
setLocalDisplayName(e.target.value);
|
||||
setSaveErrors((prev) =>
|
||||
prev.filter((err) => err.context !== 'Name update'),
|
||||
);
|
||||
}}
|
||||
className="edit-member-drawer__input"
|
||||
placeholder="Enter name"
|
||||
disabled={isRootUser || isDeleted}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__field">
|
||||
<label className="edit-member-drawer__label" htmlFor="member-email">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
|
||||
<span className="edit-member-drawer__email-text">
|
||||
{member?.email || '—'}
|
||||
</span>
|
||||
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__field">
|
||||
<label className="edit-member-drawer__label" htmlFor="member-role">
|
||||
Roles
|
||||
</label>
|
||||
{isSelf || isRootUser || isDeleted ? (
|
||||
<Tooltip
|
||||
title={
|
||||
isRootUser
|
||||
? ROOT_USER_TOOLTIP
|
||||
: isDeleted
|
||||
? undefined
|
||||
: 'You cannot modify your own role'
|
||||
}
|
||||
>
|
||||
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
|
||||
<div className="edit-member-drawer__disabled-roles">
|
||||
{localRole ? (
|
||||
<Badge color="vanilla">
|
||||
{availableRoles.find((r) => r.id === localRole)?.name ?? localRole}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="edit-member-drawer__email-text">—</span>
|
||||
)}
|
||||
</div>
|
||||
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<RolesSelect
|
||||
id="member-role"
|
||||
roles={availableRoles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={refetchRoles}
|
||||
value={localRole}
|
||||
onChange={(role): void => {
|
||||
setLocalRole(role ?? '');
|
||||
setSaveErrors((prev) =>
|
||||
prev.filter(
|
||||
(err) =>
|
||||
err.context !== 'Roles update' && !err.context.startsWith("Role '"),
|
||||
),
|
||||
);
|
||||
}}
|
||||
placeholder="Select role"
|
||||
allowClear={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__meta">
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">Status</span>
|
||||
{member?.status === MemberStatus.Active ? (
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
) : member?.status === MemberStatus.Deleted ? (
|
||||
<Badge color="cherry" variant="outline">
|
||||
DELETED
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="amber" variant="outline">
|
||||
INVITED
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
|
||||
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
|
||||
</div>
|
||||
{!isInvited && (
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">Last Modified</span>
|
||||
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{saveErrors.length > 0 && (
|
||||
<div className="edit-member-drawer__save-errors">
|
||||
{saveErrors.map((e) => (
|
||||
<SaveErrorItem
|
||||
key={e.context}
|
||||
context={e.context}
|
||||
apiError={e.apiError}
|
||||
onRetry={e.onRetry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const drawerContent = (
|
||||
<div className="edit-member-drawer__layout">
|
||||
<div className="edit-member-drawer__body">{drawerBody}</div>
|
||||
<div className="edit-member-drawer__body">
|
||||
<div className="edit-member-drawer__field">
|
||||
<label className="edit-member-drawer__label" htmlFor="member-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="member-name"
|
||||
value={displayName}
|
||||
onChange={(e): void => setDisplayName(e.target.value)}
|
||||
className="edit-member-drawer__input"
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isDeleted && (
|
||||
<div className="edit-member-drawer__footer">
|
||||
<div className="edit-member-drawer__footer-left">
|
||||
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
disabled={isRootUser || isSelf}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isInvited ? 'Revoke Invite' : 'Delete Member'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<div className="edit-member-drawer__footer-divider" />
|
||||
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink || isRootUser}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink && 'Generating...'}
|
||||
{!isGeneratingLink && isInvited && 'Copy Invite Link'}
|
||||
{!isGeneratingLink && !isInvited && 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer-right">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!isDirty || isSaving || isRootUser}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Member Details'}
|
||||
</Button>
|
||||
<div className="edit-member-drawer__field">
|
||||
<label className="edit-member-drawer__label" htmlFor="member-email">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
|
||||
<span className="edit-member-drawer__email-text">
|
||||
{member?.email || '—'}
|
||||
</span>
|
||||
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="edit-member-drawer__field">
|
||||
<label className="edit-member-drawer__label" htmlFor="member-role">
|
||||
Roles
|
||||
</label>
|
||||
<Select
|
||||
id="member-role"
|
||||
value={selectedRole}
|
||||
onChange={(role): void => setSelectedRole(role as ROLES)}
|
||||
className="edit-member-drawer__role-select"
|
||||
suffixIcon={<ChevronDown size={14} />}
|
||||
getPopupContainer={popupContainer}
|
||||
>
|
||||
<Select.Option value="ADMIN">{capitalize('ADMIN')}</Select.Option>
|
||||
<Select.Option value="EDITOR">{capitalize('EDITOR')}</Select.Option>
|
||||
<Select.Option value="VIEWER">{capitalize('VIEWER')}</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__meta">
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">Status</span>
|
||||
{member?.status === MemberStatus.Active ? (
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="amber" variant="outline">
|
||||
INVITED
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
|
||||
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
|
||||
</div>
|
||||
{!isInvited && (
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">Last Modified</span>
|
||||
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer">
|
||||
<div className="edit-member-drawer__footer-left">
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isInvited ? 'Revoke Invite' : 'Delete Member'}
|
||||
</Button>
|
||||
|
||||
<div className="edit-member-drawer__footer-divider" />
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink
|
||||
? 'Generating...'
|
||||
: isInvited
|
||||
? 'Copy Invite Link'
|
||||
: 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer-right">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!isDirty || isSaving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Member Details'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const deleteDialogTitle = isInvited ? 'Revoke Invite' : 'Delete Member';
|
||||
const deleteDialogBody = isInvited ? (
|
||||
<>
|
||||
Are you sure you want to revoke the invite for{' '}
|
||||
<strong>{member?.email}</strong>? They will no longer be able to join the
|
||||
workspace using this invite.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Are you sure you want to delete{' '}
|
||||
<strong>{member?.name || member?.email}</strong>? This will remove their
|
||||
access to the workspace.
|
||||
</>
|
||||
);
|
||||
const deleteConfirmLabel = isInvited ? 'Revoke Invite' : 'Delete Member';
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerWrapper
|
||||
@@ -619,25 +363,82 @@ function EditMemberDrawer({
|
||||
className="edit-member-drawer"
|
||||
/>
|
||||
|
||||
<ResetLinkDialog
|
||||
<DialogWrapper
|
||||
open={showResetLinkDialog}
|
||||
linkType={linkType}
|
||||
resetLink={resetLink}
|
||||
hasCopied={hasCopiedResetLink}
|
||||
onClose={(): void => {
|
||||
setShowResetLinkDialog(false);
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
setShowResetLinkDialog(false);
|
||||
setLinkType(null);
|
||||
}
|
||||
}}
|
||||
onCopy={handleCopyResetLink}
|
||||
/>
|
||||
title={linkType === 'invite' ? 'Invite Link' : 'Password Reset Link'}
|
||||
showCloseButton
|
||||
width="base"
|
||||
className="reset-link-dialog"
|
||||
>
|
||||
<div className="reset-link-dialog__content">
|
||||
<p className="reset-link-dialog__description">
|
||||
{linkType === 'invite'
|
||||
? 'Share this one-time link with the team member to complete their account setup.'
|
||||
: 'This creates a one-time link the team member can use to set a new password for their SigNoz account.'}
|
||||
</p>
|
||||
<div className="reset-link-dialog__link-row">
|
||||
<div className="reset-link-dialog__link-text-wrap">
|
||||
<span className="reset-link-dialog__link-text">{resetLink}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopyResetLink}
|
||||
prefixIcon={
|
||||
hasCopiedResetLink ? <Check size={12} /> : <Copy size={12} />
|
||||
}
|
||||
className="reset-link-dialog__copy-btn"
|
||||
>
|
||||
{hasCopiedResetLink ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
|
||||
<DeleteMemberDialog
|
||||
<DialogWrapper
|
||||
open={showDeleteConfirm}
|
||||
isInvited={isInvited}
|
||||
member={member}
|
||||
isDeleting={isDeleting}
|
||||
onClose={(): void => setShowDeleteConfirm(false)}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
}}
|
||||
title={deleteDialogTitle}
|
||||
width="narrow"
|
||||
className="alert-dialog delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<p className="delete-dialog__body">{deleteDialogBody}</p>
|
||||
|
||||
<DialogFooter className="delete-dialog__footer">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setShowDeleteConfirm(false)}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
disabled={isDeleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isDeleting ? 'Processing...' : deleteConfirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogWrapper } from '@signozhq/dialog';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
|
||||
interface ResetLinkDialogProps {
|
||||
open: boolean;
|
||||
linkType: 'invite' | 'reset' | null;
|
||||
resetLink: string | null;
|
||||
hasCopied: boolean;
|
||||
onClose: () => void;
|
||||
onCopy: () => void;
|
||||
}
|
||||
|
||||
function ResetLinkDialog({
|
||||
open,
|
||||
linkType,
|
||||
resetLink,
|
||||
hasCopied,
|
||||
onClose,
|
||||
onCopy,
|
||||
}: ResetLinkDialogProps): JSX.Element {
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={linkType === 'invite' ? 'Invite Link' : 'Password Reset Link'}
|
||||
showCloseButton
|
||||
width="base"
|
||||
className="reset-link-dialog"
|
||||
>
|
||||
<div className="reset-link-dialog__content">
|
||||
<p className="reset-link-dialog__description">
|
||||
{linkType === 'invite'
|
||||
? 'Share this one-time link with the team member to complete their account setup.'
|
||||
: 'This creates a one-time link the team member can use to set a new password for their SigNoz account.'}
|
||||
</p>
|
||||
<div className="reset-link-dialog__link-row">
|
||||
<div className="reset-link-dialog__link-text-wrap">
|
||||
<span className="reset-link-dialog__link-text">{resetLink}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onCopy}
|
||||
prefixIcon={hasCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
className="reset-link-dialog__copy-btn"
|
||||
>
|
||||
{hasCopied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResetLinkDialog;
|
||||
@@ -4,18 +4,11 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
getResetPasswordToken,
|
||||
useDeleteUser,
|
||||
useGetUser,
|
||||
useSetRoleByUserID,
|
||||
useUpdateMyUserV2,
|
||||
useUpdateUser,
|
||||
useUpdateUserDeprecated,
|
||||
} from 'api/generated/services/users';
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
import {
|
||||
listRolesSuccessResponse,
|
||||
managedRoles,
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
|
||||
|
||||
@@ -51,10 +44,7 @@ jest.mock('@signozhq/dialog', () => ({
|
||||
|
||||
jest.mock('api/generated/services/users', () => ({
|
||||
useDeleteUser: jest.fn(),
|
||||
useGetUser: jest.fn(),
|
||||
useUpdateUser: jest.fn(),
|
||||
useUpdateMyUserV2: jest.fn(),
|
||||
useSetRoleByUserID: jest.fn(),
|
||||
useUpdateUserDeprecated: jest.fn(),
|
||||
getResetPasswordToken: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -79,63 +69,25 @@ jest.mock('react-use', () => ({
|
||||
],
|
||||
}));
|
||||
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
|
||||
const mockUpdateMutate = jest.fn();
|
||||
const mockDeleteMutate = jest.fn();
|
||||
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
|
||||
|
||||
const showErrorModal = jest.fn();
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('providers/ErrorModalProvider'),
|
||||
useErrorModal: jest.fn(() => ({
|
||||
showErrorModal,
|
||||
isErrorModalVisible: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockFetchedUser = {
|
||||
data: {
|
||||
id: 'user-1',
|
||||
displayName: 'Alice Smith',
|
||||
email: 'alice@signoz.io',
|
||||
status: 'active',
|
||||
userRoles: [
|
||||
{
|
||||
id: 'ur-1',
|
||||
roleId: managedRoles[0].id,
|
||||
role: managedRoles[0], // signoz-admin
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const activeMember = {
|
||||
id: 'user-1',
|
||||
name: 'Alice Smith',
|
||||
email: 'alice@signoz.io',
|
||||
role: 'ADMIN' as ROLES,
|
||||
status: MemberStatus.Active,
|
||||
joinedOn: '1700000000000',
|
||||
updatedAt: '1710000000000',
|
||||
};
|
||||
|
||||
const selfMember = {
|
||||
...activeMember,
|
||||
id: 'some-user-id',
|
||||
};
|
||||
|
||||
const rootMockFetchedUser = {
|
||||
data: {
|
||||
...mockFetchedUser.data,
|
||||
id: 'root-user-1',
|
||||
isRoot: true,
|
||||
},
|
||||
};
|
||||
|
||||
const invitedMember = {
|
||||
id: 'abc123',
|
||||
name: '',
|
||||
email: 'bob@signoz.io',
|
||||
role: 'VIEWER' as ROLES,
|
||||
status: MemberStatus.Invited,
|
||||
joinedOn: '1700000000000',
|
||||
};
|
||||
@@ -157,27 +109,8 @@ function renderDrawer(
|
||||
describe('EditMemberDrawer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
showErrorModal.mockClear();
|
||||
server.use(
|
||||
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
(useGetUser as jest.Mock).mockReturnValue({
|
||||
data: mockFetchedUser,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
(useUpdateUser as jest.Mock).mockReturnValue({
|
||||
mutateAsync: jest.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
});
|
||||
(useUpdateMyUserV2 as jest.Mock).mockReturnValue({
|
||||
mutateAsync: jest.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
});
|
||||
(useSetRoleByUserID as jest.Mock).mockReturnValue({
|
||||
mutateAsync: jest.fn().mockResolvedValue({}),
|
||||
(useUpdateUserDeprecated as jest.Mock).mockReturnValue({
|
||||
mutate: mockUpdateMutate,
|
||||
isLoading: false,
|
||||
});
|
||||
(useDeleteUser as jest.Mock).mockReturnValue({
|
||||
@@ -186,10 +119,6 @@ describe('EditMemberDrawer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders active member details and disables Save when form is not dirty', () => {
|
||||
renderDrawer();
|
||||
|
||||
@@ -201,15 +130,16 @@ describe('EditMemberDrawer', () => {
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables Save after editing name and calls updateUser on confirm', async () => {
|
||||
it('enables Save after editing name and calls update API on confirm', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockMutateAsync = jest.fn().mockResolvedValue({});
|
||||
|
||||
(useUpdateUser as jest.Mock).mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
|
||||
mutate: mockUpdateMutate.mockImplementation(() => {
|
||||
options?.mutation?.onSuccess?.();
|
||||
}),
|
||||
isLoading: false,
|
||||
});
|
||||
}));
|
||||
|
||||
renderDrawer({ onComplete });
|
||||
|
||||
@@ -223,90 +153,12 @@ describe('EditMemberDrawer', () => {
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'user-1' },
|
||||
data: { displayName: 'Alice Updated' },
|
||||
});
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not close the drawer after a successful save', async () => {
|
||||
const onClose = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer({ onClose });
|
||||
|
||||
const nameInput = screen.getByDisplayValue('Alice Smith');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'Alice Updated');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save member details/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selecting a different role calls setRole with the new role name', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockSet = jest.fn().mockResolvedValue({});
|
||||
|
||||
(useSetRoleByUserID as jest.Mock).mockReturnValue({
|
||||
mutateAsync: mockSet,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderDrawer({ onComplete });
|
||||
|
||||
// Open the roles dropdown and select signoz-editor
|
||||
await user.click(screen.getByLabelText('Roles'));
|
||||
await user.click(await screen.findByTitle('signoz-editor'));
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'user-1' },
|
||||
data: { name: 'signoz-editor' },
|
||||
});
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call removeRole when the role is changed', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockSet = jest.fn().mockResolvedValue({});
|
||||
|
||||
(useSetRoleByUserID as jest.Mock).mockReturnValue({
|
||||
mutateAsync: mockSet,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderDrawer({ onComplete });
|
||||
|
||||
// Switch from signoz-admin to signoz-viewer using single-select
|
||||
await user.click(screen.getByLabelText('Roles'));
|
||||
await user.click(await screen.findByTitle('signoz-viewer'));
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'user-1' },
|
||||
data: { name: 'signoz-viewer' },
|
||||
});
|
||||
expect(mockUpdateMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pathParams: { id: 'user-1' },
|
||||
data: expect.objectContaining({ displayName: 'Alice Updated' }),
|
||||
}),
|
||||
);
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -387,33 +239,16 @@ describe('EditMemberDrawer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('calls updateUser when saving name change for an invited member', async () => {
|
||||
it('calls update API when saving changes for an invited member', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockMutateAsync = jest.fn().mockResolvedValue({});
|
||||
|
||||
(useGetUser as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
...mockFetchedUser.data,
|
||||
id: 'abc123',
|
||||
displayName: 'Bob',
|
||||
userRoles: [
|
||||
{
|
||||
id: 'ur-2',
|
||||
roleId: managedRoles[2].id,
|
||||
role: managedRoles[2], // signoz-viewer
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
|
||||
mutate: mockUpdateMutate.mockImplementation(() => {
|
||||
options?.mutation?.onSuccess?.();
|
||||
}),
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
(useUpdateUser as jest.Mock).mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isLoading: false,
|
||||
});
|
||||
}));
|
||||
|
||||
renderDrawer({ member: { ...invitedMember, name: 'Bob' }, onComplete });
|
||||
|
||||
@@ -426,10 +261,12 @@ describe('EditMemberDrawer', () => {
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'abc123' },
|
||||
data: { displayName: 'Bob Updated' },
|
||||
});
|
||||
expect(mockUpdateMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pathParams: { id: 'abc123' },
|
||||
data: expect.objectContaining({ displayName: 'Bob Updated' }),
|
||||
}),
|
||||
);
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -443,13 +280,16 @@ describe('EditMemberDrawer', () => {
|
||||
} as ReturnType<typeof convertToApiError>);
|
||||
});
|
||||
|
||||
it('shows SaveErrorItem when updateUser fails for name change', async () => {
|
||||
it('shows API error message when updateUser fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
(useUpdateUser as jest.Mock).mockReturnValue({
|
||||
mutateAsync: jest.fn().mockRejectedValue(new Error('server error')),
|
||||
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
|
||||
mutate: mockUpdateMutate.mockImplementation(() => {
|
||||
options?.mutation?.onError?.({});
|
||||
}),
|
||||
isLoading: false,
|
||||
});
|
||||
}));
|
||||
|
||||
renderDrawer();
|
||||
|
||||
@@ -462,14 +302,16 @@ describe('EditMemberDrawer', () => {
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Name update: Something went wrong on server'),
|
||||
).toBeInTheDocument();
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
'Failed to update member details: Something went wrong on server',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows API error message when deleteUser fails for active member', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
|
||||
mutate: mockDeleteMutate.mockImplementation(() => {
|
||||
@@ -487,20 +329,16 @@ describe('EditMemberDrawer', () => {
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
getErrorMessage: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const passedError = showErrorModal.mock.calls[0][0] as any;
|
||||
expect(passedError.getErrorMessage()).toBe(
|
||||
'Something went wrong on server',
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
'Failed to delete member: Something went wrong on server',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows API error message when deleteUser fails for invited member', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
|
||||
mutate: mockDeleteMutate.mockImplementation(() => {
|
||||
@@ -518,109 +356,14 @@ describe('EditMemberDrawer', () => {
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
getErrorMessage: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const passedError = showErrorModal.mock.calls[0][0] as any;
|
||||
expect(passedError.getErrorMessage()).toBe(
|
||||
'Something went wrong on server',
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
'Failed to revoke invite: Something went wrong on server',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('self user (isSelf)', () => {
|
||||
it('disables Delete button when viewing own profile', () => {
|
||||
renderDrawer({ member: selfMember });
|
||||
expect(
|
||||
screen.getByRole('button', { name: /delete member/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not open delete confirm dialog when Delete is clicked while disabled (isSelf)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderDrawer({ member: selfMember });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /delete member/i }));
|
||||
|
||||
expect(
|
||||
screen.queryByText(/are you sure you want to delete/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps name input enabled when viewing own profile', () => {
|
||||
renderDrawer({ member: selfMember });
|
||||
expect(screen.getByDisplayValue('Alice Smith')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('keeps Reset Link button enabled when viewing own profile', () => {
|
||||
renderDrawer({ member: selfMember });
|
||||
expect(
|
||||
screen.getByRole('button', { name: /generate password reset link/i }),
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('root user', () => {
|
||||
beforeEach(() => {
|
||||
(useGetUser as jest.Mock).mockReturnValue({
|
||||
data: rootMockFetchedUser,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('disables name input for root user', () => {
|
||||
renderDrawer();
|
||||
expect(screen.getByDisplayValue('Alice Smith')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Delete button for root user', () => {
|
||||
renderDrawer();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /delete member/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Reset Link button for root user', () => {
|
||||
renderDrawer();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /generate password reset link/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Save button for root user', () => {
|
||||
renderDrawer();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save member details/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not open delete confirm dialog when Delete is clicked while disabled (root)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderDrawer();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /delete member/i }));
|
||||
|
||||
expect(
|
||||
screen.queryByText(/are you sure you want to delete/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not call getResetPasswordToken when Reset Link is clicked while disabled (root)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderDrawer();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /generate password reset link/i }),
|
||||
);
|
||||
|
||||
expect(mockGetResetPasswordToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Generate Password Reset Link', () => {
|
||||
beforeEach(() => {
|
||||
mockCopyToClipboard.mockClear();
|
||||
|
||||
@@ -10,12 +10,7 @@ import APIError from 'types/api/error';
|
||||
import './ErrorContent.styles.scss';
|
||||
|
||||
interface ErrorContentProps {
|
||||
error:
|
||||
| APIError
|
||||
| {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
error: APIError;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -25,15 +20,7 @@ function ErrorContent({ error, icon }: ErrorContentProps): JSX.Element {
|
||||
errors: errorMessages,
|
||||
code: errorCode,
|
||||
message: errorMessage,
|
||||
} =
|
||||
error && 'error' in error
|
||||
? error?.error?.error || {}
|
||||
: {
|
||||
url: undefined,
|
||||
errors: [],
|
||||
code: error.code || 500,
|
||||
message: error.message || 'Something went wrong',
|
||||
};
|
||||
} = error?.error?.error || {};
|
||||
return (
|
||||
<section className="error-content">
|
||||
{/* Summary Header */}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
|
||||
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useGlobalTimeStore } from 'store/globalTime/globalTimeStore';
|
||||
import { createCustomTimeRange } from 'store/globalTime/utils';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
/**
|
||||
* Adapter component that syncs Redux global time state to Zustand store.
|
||||
* This component should be rendered once at the app level.
|
||||
*
|
||||
* It reads from the Redux globalTime reducer and updates the Zustand store
|
||||
* to provide a migration path from Redux to Zustand.
|
||||
*/
|
||||
export function GlobalTimeStoreAdapter(): null {
|
||||
const globalTime = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const setSelectedTime = useGlobalTimeStore((s) => s.setSelectedTime);
|
||||
|
||||
useEffect(() => {
|
||||
// Convert the selectedTime to the new format
|
||||
// If it's 'custom', store the min/max times in the custom format
|
||||
const selectedTime =
|
||||
globalTime.selectedTime === 'custom'
|
||||
? createCustomTimeRange(globalTime.minTime, globalTime.maxTime)
|
||||
: (globalTime.selectedTime as Time);
|
||||
|
||||
// Find refresh interval from Redux state
|
||||
const refreshOption = refreshIntervalOptions.find(
|
||||
(option) => option.key === globalTime.selectedAutoRefreshInterval,
|
||||
);
|
||||
|
||||
const refreshInterval =
|
||||
!globalTime.isAutoRefreshDisabled && refreshOption ? refreshOption.value : 0;
|
||||
|
||||
setSelectedTime(selectedTime, refreshInterval);
|
||||
}, [
|
||||
globalTime.selectedTime,
|
||||
globalTime.isAutoRefreshDisabled,
|
||||
globalTime.selectedAutoRefreshInterval,
|
||||
globalTime.minTime,
|
||||
globalTime.maxTime,
|
||||
setSelectedTime,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { act, render, renderHook } from '@testing-library/react';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
|
||||
import { useGlobalTimeStore } from 'store/globalTime/globalTimeStore';
|
||||
import { createCustomTimeRange } from 'store/globalTime/utils';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { GlobalTimeStoreAdapter } from '../GlobalTimeStoreAdapter';
|
||||
|
||||
const mockStore = configureStore<Partial<AppState>>([]);
|
||||
|
||||
const randomTime = 1700000000000000000;
|
||||
|
||||
describe('GlobalTimeStoreAdapter', () => {
|
||||
let store: MockStoreEnhanced<Partial<AppState>>;
|
||||
|
||||
const createGlobalTimeState = (
|
||||
overrides: Partial<GlobalReducer> = {},
|
||||
): GlobalReducer => ({
|
||||
minTime: randomTime,
|
||||
maxTime: randomTime,
|
||||
loading: false,
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: true,
|
||||
selectedAutoRefreshInterval: 'off',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset Zustand store before each test
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
act(() => {
|
||||
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render null because it just an adapter', () => {
|
||||
store = mockStore({
|
||||
globalTime: createGlobalTimeState(),
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should sync relative time from Redux to Zustand store', () => {
|
||||
store = mockStore({
|
||||
globalTime: createGlobalTimeState({
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: true,
|
||||
selectedAutoRefreshInterval: 'off',
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.selectedTime).toBe('15m');
|
||||
expect(result.current.refreshInterval).toBe(0);
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should sync custom time from Redux to Zustand store', () => {
|
||||
store = mockStore({
|
||||
globalTime: createGlobalTimeState({
|
||||
selectedTime: 'custom',
|
||||
minTime: randomTime,
|
||||
maxTime: randomTime,
|
||||
isAutoRefreshDisabled: true,
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.selectedTime).toBe(
|
||||
createCustomTimeRange(randomTime, randomTime),
|
||||
);
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should sync refresh interval when auto refresh is enabled', () => {
|
||||
store = mockStore({
|
||||
globalTime: createGlobalTimeState({
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: '5s',
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.selectedTime).toBe('15m');
|
||||
expect(result.current.refreshInterval).toBe(5000); // 5s = 5000ms
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should set refreshInterval to 0 when auto refresh is disabled', () => {
|
||||
store = mockStore({
|
||||
globalTime: createGlobalTimeState({
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: true,
|
||||
selectedAutoRefreshInterval: '5s', // Even with interval set, should be 0 when disabled
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.refreshInterval).toBe(0);
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should update Zustand store when Redux state changes', () => {
|
||||
store = mockStore({
|
||||
globalTime: createGlobalTimeState({
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Verify initial state
|
||||
let zustandState = renderHook(() => useGlobalTimeStore());
|
||||
expect(zustandState.result.current.selectedTime).toBe('15m');
|
||||
|
||||
// Update Redux store
|
||||
const newStore = mockStore({
|
||||
globalTime: createGlobalTimeState({
|
||||
selectedTime: '1h',
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: '30s',
|
||||
}),
|
||||
});
|
||||
|
||||
rerender(
|
||||
<Provider store={newStore}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Verify updated state
|
||||
zustandState = renderHook(() => useGlobalTimeStore());
|
||||
expect(zustandState.result.current.selectedTime).toBe('1h');
|
||||
expect(zustandState.result.current.refreshInterval).toBe(30000); // 30s = 30000ms
|
||||
expect(zustandState.result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle various refresh interval options', () => {
|
||||
const testCases = [
|
||||
{ key: '5s', expectedValue: 5000 },
|
||||
{ key: '10s', expectedValue: 10000 },
|
||||
{ key: '30s', expectedValue: 30000 },
|
||||
{ key: '1m', expectedValue: 60000 },
|
||||
{ key: '5m', expectedValue: 300000 },
|
||||
];
|
||||
|
||||
testCases.forEach(({ key, expectedValue }) => {
|
||||
store = mockStore({
|
||||
globalTime: createGlobalTimeState({
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: key,
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.refreshInterval).toBe(expectedValue);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unknown refresh interval by setting 0', () => {
|
||||
store = mockStore({
|
||||
globalTime: createGlobalTimeState({
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: 'unknown-interval',
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.refreshInterval).toBe(0);
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,6 @@ import { Select } from 'antd';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { cloneDeep, debounce } from 'lodash-es';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { ROLES } from 'types/roles';
|
||||
import { EMAIL_REGEX } from 'utils/app';
|
||||
@@ -41,8 +40,6 @@ function InviteMembersModal({
|
||||
onClose,
|
||||
onComplete,
|
||||
}: InviteMembersModalProps): JSX.Element {
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
|
||||
const [rows, setRows] = useState<InviteRow[]>(() => [
|
||||
EMPTY_ROW(),
|
||||
EMPTY_ROW(),
|
||||
@@ -200,18 +197,17 @@ function InviteMembersModal({
|
||||
})),
|
||||
});
|
||||
}
|
||||
toast.success('Invites sent successfully', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
toast.success('Invites sent successfully', { richColors: true });
|
||||
resetAndClose();
|
||||
onComplete?.();
|
||||
} catch (err) {
|
||||
showErrorModal(err as APIError);
|
||||
const apiErr = err as APIError;
|
||||
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
|
||||
toast.error(errorMessage, { richColors: true });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [validateAllUsers, rows, resetAndClose, onComplete, showErrorModal]);
|
||||
}, [rows, onComplete, resetAndClose, validateAllUsers]);
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
|
||||
@@ -228,7 +224,7 @@ function InviteMembersModal({
|
||||
showCloseButton
|
||||
width="wide"
|
||||
className="invite-members-modal"
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<div className="invite-members-modal__content">
|
||||
<div className="invite-members-modal__table">
|
||||
@@ -330,7 +326,6 @@ function InviteMembersModal({
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
|
||||
</Button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
@@ -21,16 +22,6 @@ jest.mock('@signozhq/sonner', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const showErrorModal = jest.fn();
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('providers/ErrorModalProvider'),
|
||||
useErrorModal: jest.fn(() => ({
|
||||
showErrorModal,
|
||||
isErrorModalVisible: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockSendInvite = jest.mocked(sendInvite);
|
||||
const mockInviteUsers = jest.mocked(inviteUsers);
|
||||
|
||||
@@ -43,7 +34,6 @@ const defaultProps = {
|
||||
describe('InviteMembersModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
showErrorModal.mockClear();
|
||||
mockSendInvite.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { data: 'test', status: 'success' },
|
||||
@@ -164,10 +154,9 @@ describe('InviteMembersModal', () => {
|
||||
describe('error handling', () => {
|
||||
it('shows BE message on single invite 409', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const error = makeApiError(
|
||||
'An invite already exists for this email: single@signoz.io',
|
||||
mockSendInvite.mockRejectedValue(
|
||||
makeApiError('An invite already exists for this email: single@signoz.io'),
|
||||
);
|
||||
mockSendInvite.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
@@ -182,16 +171,18 @@ describe('InviteMembersModal', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'An invite already exists for this email: single@signoz.io',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows BE message on bulk invite 409', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const error = makeApiError(
|
||||
'An invite already exists for this email: alice@signoz.io',
|
||||
mockInviteUsers.mockRejectedValue(
|
||||
makeApiError('An invite already exists for this email: alice@signoz.io'),
|
||||
);
|
||||
mockInviteUsers.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
@@ -210,17 +201,18 @@ describe('InviteMembersModal', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'An invite already exists for this email: alice@signoz.io',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows BE message on generic error', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const error = makeApiError(
|
||||
'Internal server error',
|
||||
StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
mockSendInvite.mockRejectedValue(
|
||||
makeApiError('Internal server error', StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
);
|
||||
mockSendInvite.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
@@ -235,7 +227,10 @@ describe('InviteMembersModal', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'Internal server error',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
border: 1px solid var(--bg-vanilla-400);
|
||||
background: var(--bg-vanilla-400);
|
||||
|
||||
.ant-btn-default {
|
||||
}
|
||||
|
||||
.copy-log-btn {
|
||||
border-left: 1px solid var(--bg-vanilla-400);
|
||||
border-color: var(--bg-vanilla-400) !important;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
.log-state-indicator {
|
||||
padding-left: 8px;
|
||||
|
||||
.line {
|
||||
margin: 0 8px;
|
||||
min-height: 24px;
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
|
||||
import { LogType } from './LogStateIndicator';
|
||||
|
||||
export function getRowBackgroundColor(
|
||||
isDarkMode: boolean,
|
||||
logType?: string,
|
||||
): string {
|
||||
if (isDarkMode) {
|
||||
switch (logType) {
|
||||
case LogType.INFO:
|
||||
return `${Color.BG_ROBIN_500}40`;
|
||||
case LogType.WARN:
|
||||
return `${Color.BG_AMBER_500}40`;
|
||||
case LogType.ERROR:
|
||||
return `${Color.BG_CHERRY_500}40`;
|
||||
case LogType.TRACE:
|
||||
return `${Color.BG_FOREST_400}40`;
|
||||
case LogType.DEBUG:
|
||||
return `${Color.BG_AQUA_500}40`;
|
||||
case LogType.FATAL:
|
||||
return `${Color.BG_SAKURA_500}40`;
|
||||
default:
|
||||
return `${Color.BG_ROBIN_500}40`;
|
||||
}
|
||||
}
|
||||
switch (logType) {
|
||||
case LogType.INFO:
|
||||
return Color.BG_ROBIN_100;
|
||||
case LogType.WARN:
|
||||
return Color.BG_AMBER_100;
|
||||
case LogType.ERROR:
|
||||
return Color.BG_CHERRY_100;
|
||||
case LogType.TRACE:
|
||||
return Color.BG_FOREST_200;
|
||||
case LogType.DEBUG:
|
||||
return Color.BG_AQUA_100;
|
||||
case LogType.FATAL:
|
||||
return Color.BG_SAKURA_100;
|
||||
default:
|
||||
return Color.BG_VANILLA_300;
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,13 @@ export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties {
|
||||
letterSpacing: '-0.07px',
|
||||
marginBottom: '0px',
|
||||
minWidth: '10rem',
|
||||
width: 'auto',
|
||||
width: '10rem',
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultTableStyle: CSSProperties = {
|
||||
minWidth: '40rem',
|
||||
maxWidth: '90rem',
|
||||
};
|
||||
|
||||
export const defaultListViewPanelStyle: CSSProperties = {
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import type { TableColumnDef } from '../../TanStackTableView/types';
|
||||
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
||||
|
||||
type UseLogsTableColumnsProps = {
|
||||
fields: IField[];
|
||||
fontSize: FontSize;
|
||||
appendTo?: 'center' | 'end';
|
||||
};
|
||||
|
||||
export function useLogsTableColumns({
|
||||
fields,
|
||||
fontSize,
|
||||
appendTo = 'center',
|
||||
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
return useMemo<TableColumnDef<ILog>[]>(() => {
|
||||
const stateIndicatorCol: TableColumnDef<ILog> = {
|
||||
id: 'state-indicator',
|
||||
header: '',
|
||||
pin: 'left',
|
||||
enableMove: false,
|
||||
enableResize: false,
|
||||
enableRemove: false,
|
||||
canBeHidden: false,
|
||||
width: { fixed: 24 },
|
||||
cell: ({ row }): ReactElement => (
|
||||
<LogStateIndicator
|
||||
fontSize={fontSize}
|
||||
severityText={row.severity_text as string}
|
||||
severityNumber={row.severity_number as number}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const fieldColumns: TableColumnDef<ILog>[] = fields
|
||||
.filter((f): boolean => !['id', 'body', 'timestamp'].includes(f.name))
|
||||
.map(
|
||||
(f): TableColumnDef<ILog> => ({
|
||||
id: f.name,
|
||||
header: f.name,
|
||||
accessorFn: (log): unknown => FlatLogData(log)[f.name],
|
||||
enableRemove: true,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const timestampCol: TableColumnDef<ILog> | null = fields.some(
|
||||
(f) => f.name === 'timestamp',
|
||||
)
|
||||
? {
|
||||
id: 'timestamp',
|
||||
header: 'Timestamp',
|
||||
accessorFn: (log): unknown => log.timestamp,
|
||||
width: { default: 170, min: 170 },
|
||||
cell: ({ value }): ReactElement => {
|
||||
const ts = value as string | number;
|
||||
const formatted =
|
||||
typeof ts === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
ts / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
return <TanStackTable.Text>{formatted}</TanStackTable.Text>;
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
const bodyCol: TableColumnDef<ILog> | null = fields.some(
|
||||
(f) => f.name === 'body',
|
||||
)
|
||||
? {
|
||||
id: 'body',
|
||||
header: 'Body',
|
||||
accessorFn: (log): string => log.body,
|
||||
canBeHidden: false,
|
||||
width: { default: '100%', min: 640 },
|
||||
cell: ({ value, isActive }): ReactElement => (
|
||||
<TanStackTable.Text
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getSanitizedLogBody(value as string, {
|
||||
shouldEscapeHtml: true,
|
||||
}),
|
||||
}}
|
||||
data-active={isActive}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: null;
|
||||
|
||||
return [
|
||||
stateIndicatorCol,
|
||||
...(timestampCol ? [timestampCol] : []),
|
||||
...(appendTo === 'center' ? fieldColumns : []),
|
||||
...(bodyCol ? [bodyCol] : []),
|
||||
...(appendTo === 'end' ? fieldColumns : []),
|
||||
];
|
||||
}, [fields, appendTo, fontSize, formatTimezoneAdjustedTimestamp]);
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
p {
|
||||
.ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,6 @@
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
margin: 0;
|
||||
padding: 0px !important;
|
||||
&.small {
|
||||
font-size: 11px !important;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { TableColumnsType as ColumnsType, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
@@ -43,7 +43,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
const bodyColumnStyle = useMemo(
|
||||
() => ({
|
||||
...defaultTableStyle,
|
||||
...(fields.length > 2 ? { width: 'auto' } : {}),
|
||||
...(fields.length > 2 ? { width: '50rem' } : {}),
|
||||
}),
|
||||
[fields.length],
|
||||
);
|
||||
@@ -59,18 +59,18 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
key: name,
|
||||
render: (field): ColumnTypeRender<Record<string, unknown>> => ({
|
||||
props: {
|
||||
style: {
|
||||
...(isListViewPanel
|
||||
? defaultListViewPanelStyle
|
||||
: getDefaultCellStyle(isDarkMode)),
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: linesPerRow,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
style: isListViewPanel
|
||||
? defaultListViewPanelStyle
|
||||
: getDefaultCellStyle(isDarkMode),
|
||||
},
|
||||
children: <p className={cx('paragraph', fontSize)}>{field}</p>,
|
||||
children: (
|
||||
<Typography.Paragraph
|
||||
ellipsis={{ rows: linesPerRow }}
|
||||
className={cx('paragraph', fontSize)}
|
||||
>
|
||||
{field}
|
||||
</Typography.Paragraph>
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -123,7 +123,9 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
return {
|
||||
children: (
|
||||
<div className="table-timestamp">
|
||||
<p className={cx('text', fontSize)}>{date}</p>
|
||||
<Typography.Paragraph ellipsis className={cx('text', fontSize)}>
|
||||
{date}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -210,7 +210,7 @@ function MembersTable({
|
||||
index % 2 === 0 ? 'members-table-row--tinted' : ''
|
||||
}
|
||||
onRow={(record): React.HTMLAttributes<HTMLElement> => {
|
||||
const isClickable = !!onRowClick;
|
||||
const isClickable = onRowClick && record.status !== MemberStatus.Deleted;
|
||||
return {
|
||||
onClick: (): void => {
|
||||
if (isClickable) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import MembersTable, { MemberRow } from '../MembersTable';
|
||||
|
||||
@@ -8,6 +9,7 @@ const mockActiveMembers: MemberRow[] = [
|
||||
id: 'user-1',
|
||||
name: 'Alice Smith',
|
||||
email: 'alice@signoz.io',
|
||||
role: 'ADMIN' as ROLES,
|
||||
status: MemberStatus.Active,
|
||||
joinedOn: '1700000000000',
|
||||
},
|
||||
@@ -15,6 +17,7 @@ const mockActiveMembers: MemberRow[] = [
|
||||
id: 'user-2',
|
||||
name: 'Bob Jones',
|
||||
email: 'bob@signoz.io',
|
||||
role: 'VIEWER' as ROLES,
|
||||
status: MemberStatus.Active,
|
||||
joinedOn: null,
|
||||
},
|
||||
@@ -24,6 +27,7 @@ const mockInvitedMember: MemberRow = {
|
||||
id: 'inv-abc',
|
||||
name: '',
|
||||
email: 'charlie@signoz.io',
|
||||
role: 'EDITOR' as ROLES,
|
||||
status: MemberStatus.Invited,
|
||||
joinedOn: null,
|
||||
};
|
||||
@@ -43,11 +47,12 @@ describe('MembersTable', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders member rows with name, email, and ACTIVE status', () => {
|
||||
it('renders member rows with name, email, role badge, and ACTIVE status', () => {
|
||||
render(<MembersTable {...defaultProps} data={mockActiveMembers} />);
|
||||
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('ACTIVE')).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -62,6 +67,7 @@ describe('MembersTable', () => {
|
||||
|
||||
expect(screen.getByText('INVITED')).toBeInTheDocument();
|
||||
expect(screen.getByText('charlie@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByText('Editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRowClick with the member data when a row is clicked', async () => {
|
||||
@@ -86,13 +92,14 @@ describe('MembersTable', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders DELETED badge and calls onRowClick when a deleted member row is clicked', async () => {
|
||||
it('renders DELETED badge and does not call onRowClick when a deleted member row is clicked', async () => {
|
||||
const onRowClick = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const deletedMember: MemberRow = {
|
||||
id: 'user-del',
|
||||
name: 'Dave Deleted',
|
||||
email: 'dave@signoz.io',
|
||||
role: 'VIEWER' as ROLES,
|
||||
status: MemberStatus.Deleted,
|
||||
joinedOn: null,
|
||||
};
|
||||
@@ -108,7 +115,7 @@ describe('MembersTable', () => {
|
||||
|
||||
expect(screen.getByText('DELETED')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Dave Deleted'));
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect(onRowClick).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'user-del' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,35 +1,21 @@
|
||||
.quick-filters-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
|
||||
.quick-filters-settings-container {
|
||||
flex: 0 0 0;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
align-self: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border-right: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
.overlay-scrollbar {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
width: 342px;
|
||||
background: var(--bg-slate-500);
|
||||
|
||||
@@ -88,13 +88,3 @@
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
|
||||
.roles-single-select {
|
||||
.ant-select-selector {
|
||||
min-height: 32px;
|
||||
background-color: var(--l2-background) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 2px;
|
||||
padding: 2px var(--padding-2) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +85,7 @@ interface BaseProps {
|
||||
interface SingleProps extends BaseProps {
|
||||
mode?: 'single';
|
||||
value?: string;
|
||||
onChange?: (role: string | undefined) => void;
|
||||
allowClear?: boolean;
|
||||
onChange?: (role: string) => void;
|
||||
}
|
||||
|
||||
interface MultipleProps extends BaseProps {
|
||||
@@ -155,15 +154,14 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const { value, onChange, allowClear = true } = props as SingleProps;
|
||||
const { value, onChange } = props as SingleProps;
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
value={value || undefined}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
allowClear={allowClear}
|
||||
className={cx('roles-single-select', className)}
|
||||
className={cx('roles-select', className)}
|
||||
loading={loading}
|
||||
notFoundContent={notFoundContent}
|
||||
options={options}
|
||||
|
||||
@@ -17,8 +17,6 @@ import { AxiosError } from 'axios';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import KeyCreatedPhase from './KeyCreatedPhase';
|
||||
import KeyFormPhase from './KeyFormPhase';
|
||||
@@ -29,7 +27,6 @@ import './AddKeyModal.styles.scss';
|
||||
|
||||
function AddKeyModal(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
|
||||
const [isAddKeyOpen, setIsAddKeyOpen] = useQueryState(
|
||||
SA_QUERY_PARAMS.ADD_KEY,
|
||||
@@ -84,11 +81,11 @@ function AddKeyModal(): JSX.Element {
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorModal(
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
) as APIError,
|
||||
);
|
||||
)?.getErrorMessage() || 'Failed to create key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -154,7 +151,7 @@ function AddKeyModal(): JSX.Element {
|
||||
width="base"
|
||||
className="add-key-modal"
|
||||
showCloseButton
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
{phase === Phase.FORM && (
|
||||
<KeyFormPhase
|
||||
|
||||
@@ -16,12 +16,9 @@ import type {
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
function DeleteAccountModal(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
const [accountId, setAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useQueryState(
|
||||
SA_QUERY_PARAMS.DELETE_SA,
|
||||
@@ -48,11 +45,11 @@ function DeleteAccountModal(): JSX.Element {
|
||||
await invalidateListServiceAccounts(queryClient);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorModal(
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
) as APIError,
|
||||
);
|
||||
)?.getErrorMessage() || 'Failed to delete service account';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -82,7 +79,7 @@ function DeleteAccountModal(): JSX.Element {
|
||||
width="narrow"
|
||||
className="alert-dialog sa-delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<p className="sa-delete-dialog__body">
|
||||
Are you sure you want to delete <strong>{accountName}</strong>? This action
|
||||
|
||||
@@ -17,9 +17,7 @@ import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { RevokeKeyContent } from '../RevokeKeyModal';
|
||||
import EditKeyForm from './EditKeyForm';
|
||||
@@ -43,7 +41,6 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
const open = !!editKeyId && !!selectedAccountId;
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
const [isRevokeConfirmOpen, setIsRevokeConfirmOpen] = useState(false);
|
||||
|
||||
const {
|
||||
@@ -81,11 +78,11 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorModal(
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
) as APIError,
|
||||
);
|
||||
)?.getErrorMessage() || 'Failed to update key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -105,13 +102,12 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
});
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
onError: (error) => {
|
||||
showErrorModal(
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
) as APIError,
|
||||
);
|
||||
)?.getErrorMessage() || 'Failed to revoke key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -164,7 +160,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
isRevokeConfirmOpen ? 'alert-dialog delete-dialog' : 'edit-key-modal'
|
||||
}
|
||||
showCloseButton={!isRevokeConfirmOpen}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
{isRevokeConfirmOpen ? (
|
||||
<RevokeKeyContent
|
||||
|
||||
@@ -165,17 +165,7 @@ function KeysTab({
|
||||
return (
|
||||
<div className="keys-tab__empty">
|
||||
<KeyRound size={24} className="keys-tab__empty-icon" />
|
||||
<p className="keys-tab__empty-text">
|
||||
No keys. Start by creating one.{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/manage/administrator-guide/iam/service-accounts/#step-3-generate-an-api-key"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="keys-tab__learn-more"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
<p className="keys-tab__empty-text">No keys. Start by creating one.</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="keys-tab__learn-more"
|
||||
|
||||
@@ -16,8 +16,8 @@ interface OverviewTabProps {
|
||||
account: ServiceAccountRow;
|
||||
localName: string;
|
||||
onNameChange: (v: string) => void;
|
||||
localRole: string;
|
||||
onRoleChange: (v: string | undefined) => void;
|
||||
localRoles: string[];
|
||||
onRolesChange: (v: string[]) => void;
|
||||
isDisabled: boolean;
|
||||
availableRoles: AuthtypesRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
@@ -31,8 +31,8 @@ function OverviewTab({
|
||||
account,
|
||||
localName,
|
||||
onNameChange,
|
||||
localRole,
|
||||
onRoleChange,
|
||||
localRoles,
|
||||
onRolesChange,
|
||||
isDisabled,
|
||||
availableRoles,
|
||||
rolesLoading,
|
||||
@@ -96,10 +96,15 @@ function OverviewTab({
|
||||
{isDisabled ? (
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<div className="sa-drawer__disabled-roles">
|
||||
{localRole ? (
|
||||
<Badge color="vanilla">
|
||||
{availableRoles.find((r) => r.id === localRole)?.name ?? localRole}
|
||||
</Badge>
|
||||
{localRoles.length > 0 ? (
|
||||
localRoles.map((roleId) => {
|
||||
const role = availableRoles.find((r) => r.id === roleId);
|
||||
return (
|
||||
<Badge key={roleId} color="vanilla">
|
||||
{role?.name ?? roleId}
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="sa-drawer__input-text">—</span>
|
||||
)}
|
||||
@@ -109,14 +114,15 @@ function OverviewTab({
|
||||
) : (
|
||||
<RolesSelect
|
||||
id="sa-roles"
|
||||
mode="multiple"
|
||||
roles={availableRoles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={onRefetchRoles}
|
||||
value={localRole}
|
||||
onChange={onRoleChange}
|
||||
placeholder="Select role"
|
||||
value={localRoles}
|
||||
onChange={onRolesChange}
|
||||
placeholder="Select roles"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,6 @@ import type {
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
export interface RevokeKeyContentProps {
|
||||
isRevoking: boolean;
|
||||
@@ -58,7 +56,6 @@ export function RevokeKeyContent({
|
||||
|
||||
function RevokeKeyModal(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
|
||||
const [revokeKeyId, setRevokeKeyId] = useQueryState(
|
||||
SA_QUERY_PARAMS.REVOKE_KEY,
|
||||
@@ -86,11 +83,11 @@ function RevokeKeyModal(): JSX.Element {
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorModal(
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
) as APIError,
|
||||
);
|
||||
)?.getErrorMessage() || 'Failed to revoke key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -118,7 +115,7 @@ function RevokeKeyModal(): JSX.Element {
|
||||
width="narrow"
|
||||
className="alert-dialog delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<RevokeKeyContent
|
||||
isRevoking={isRevoking}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
@@ -8,9 +8,7 @@ import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { Pagination, Skeleton } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
getGetServiceAccountRolesQueryKey,
|
||||
getListServiceAccountsQueryKey,
|
||||
useDeleteServiceAccountRole,
|
||||
useGetServiceAccount,
|
||||
useListServiceAccountKeys,
|
||||
useUpdateServiceAccount,
|
||||
@@ -25,10 +23,7 @@ import {
|
||||
ServiceAccountStatus,
|
||||
toServiceAccountRow,
|
||||
} from 'container/ServiceAccountsSettings/utils';
|
||||
import {
|
||||
RoleUpdateFailure,
|
||||
useServiceAccountRoleManager,
|
||||
} from 'hooks/serviceAccount/useServiceAccountRoleManager';
|
||||
import { useServiceAccountRoleManager } from 'hooks/serviceAccount/useServiceAccountRoleManager';
|
||||
import {
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
@@ -37,7 +32,7 @@ import {
|
||||
useQueryState,
|
||||
} from 'nuqs';
|
||||
import APIError from 'types/api/error';
|
||||
import { retryOn429, toAPIError } from 'utils/errorUtils';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import AddKeyModal from './AddKeyModal';
|
||||
import DeleteAccountModal from './DeleteAccountModal';
|
||||
@@ -54,13 +49,6 @@ export interface ServiceAccountDrawerProps {
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
function toSaveApiError(err: unknown): APIError {
|
||||
return (
|
||||
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
|
||||
toAPIError(err as AxiosError<RenderErrorResponseDTO>)
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function ServiceAccountDrawer({
|
||||
onSuccess,
|
||||
@@ -92,7 +80,7 @@ function ServiceAccountDrawer({
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const [localName, setLocalName] = useState('');
|
||||
const [localRole, setLocalRole] = useState('');
|
||||
const [localRoles, setLocalRoles] = useState<string[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
|
||||
|
||||
@@ -115,35 +103,21 @@ function ServiceAccountDrawer({
|
||||
[accountData],
|
||||
);
|
||||
|
||||
const {
|
||||
currentRoles,
|
||||
isLoading: isRolesLoading,
|
||||
applyDiff,
|
||||
} = useServiceAccountRoleManager(selectedAccountId ?? '');
|
||||
|
||||
const roleSessionRef = useRef<string | null>(null);
|
||||
const { currentRoles, applyDiff } = useServiceAccountRoleManager(
|
||||
selectedAccountId ?? '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (account?.id) {
|
||||
setLocalName(account?.name ?? '');
|
||||
setKeysPage(1);
|
||||
}
|
||||
setSaveErrors([]);
|
||||
}, [account?.id, account?.name, setKeysPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (account?.id) {
|
||||
setSaveErrors([]);
|
||||
}
|
||||
}, [account?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!account?.id) {
|
||||
roleSessionRef.current = null;
|
||||
} else if (account.id !== roleSessionRef.current && !isRolesLoading) {
|
||||
setLocalRole(currentRoles[0]?.id ?? '');
|
||||
roleSessionRef.current = account.id;
|
||||
}
|
||||
}, [account?.id, currentRoles, isRolesLoading]);
|
||||
setLocalRoles(currentRoles.map((r) => r.id).filter(Boolean) as string[]);
|
||||
}, [currentRoles]);
|
||||
|
||||
const isDeleted =
|
||||
account?.status?.toUpperCase() === ServiceAccountStatus.Deleted;
|
||||
@@ -151,7 +125,8 @@ function ServiceAccountDrawer({
|
||||
const isDirty =
|
||||
account !== null &&
|
||||
(localName !== (account.name ?? '') ||
|
||||
localRole !== (currentRoles[0]?.id ?? ''));
|
||||
JSON.stringify([...localRoles].sort()) !==
|
||||
JSON.stringify([...currentRoles.map((r) => r.id).filter(Boolean)].sort()));
|
||||
|
||||
const {
|
||||
roles: availableRoles,
|
||||
@@ -179,26 +154,12 @@ function ServiceAccountDrawer({
|
||||
|
||||
// the retry for this mutation is safe due to the api being idempotent on backend
|
||||
const { mutateAsync: updateMutateAsync } = useUpdateServiceAccount();
|
||||
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
|
||||
mutation: {
|
||||
retry: retryOn429,
|
||||
},
|
||||
});
|
||||
|
||||
const executeRolesOperation = useCallback(
|
||||
async (accountId: string): Promise<RoleUpdateFailure[]> => {
|
||||
if (localRole === '' && currentRoles[0]?.id) {
|
||||
await deleteRole({
|
||||
pathParams: { id: accountId, rid: currentRoles[0].id },
|
||||
});
|
||||
await queryClient.invalidateQueries(
|
||||
getGetServiceAccountRolesQueryKey({ id: accountId }),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
return applyDiff([localRole].filter(Boolean), availableRoles);
|
||||
},
|
||||
[localRole, currentRoles, availableRoles, applyDiff, deleteRole, queryClient],
|
||||
const toSaveApiError = useCallback(
|
||||
(err: unknown): APIError =>
|
||||
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
|
||||
toAPIError(err as AxiosError<RenderErrorResponseDTO>),
|
||||
[],
|
||||
);
|
||||
|
||||
const retryNameUpdate = useCallback(async (): Promise<void> => {
|
||||
@@ -220,7 +181,14 @@ function ServiceAccountDrawer({
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [account, localName, updateMutateAsync, refetchAccount, queryClient]);
|
||||
}, [
|
||||
account,
|
||||
localName,
|
||||
updateMutateAsync,
|
||||
refetchAccount,
|
||||
queryClient,
|
||||
toSaveApiError,
|
||||
]);
|
||||
|
||||
const handleNameChange = useCallback((name: string): void => {
|
||||
setLocalName(name);
|
||||
@@ -243,39 +211,26 @@ function ServiceAccountDrawer({
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearRoleErrors = useCallback((): void => {
|
||||
setSaveErrors((prev) =>
|
||||
prev.filter(
|
||||
(e) => e.context !== 'Roles update' && !e.context.startsWith("Role '"),
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const failuresToSaveErrors = useCallback(
|
||||
(failures: RoleUpdateFailure[]): SaveError[] =>
|
||||
failures.map((f) => {
|
||||
const ctx = `Role '${f.roleName}'`;
|
||||
return {
|
||||
context: ctx,
|
||||
apiError: toSaveApiError(f.error),
|
||||
onRetry: makeRoleRetry(ctx, f.onRetry),
|
||||
};
|
||||
}),
|
||||
[makeRoleRetry],
|
||||
[toSaveApiError],
|
||||
);
|
||||
|
||||
const retryRolesUpdate = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const failures = await executeRolesOperation(selectedAccountId ?? '');
|
||||
const failures = await applyDiff(localRoles, availableRoles);
|
||||
if (failures.length === 0) {
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Roles update'));
|
||||
} else {
|
||||
setSaveErrors((prev) => {
|
||||
const rest = prev.filter((e) => e.context !== 'Roles update');
|
||||
return [...rest, ...failuresToSaveErrors(failures)];
|
||||
const roleErrors = failures.map((f) => {
|
||||
const ctx = `Role '${f.roleName}'`;
|
||||
return {
|
||||
context: ctx,
|
||||
apiError: toSaveApiError(f.error),
|
||||
onRetry: makeRoleRetry(ctx, f.onRetry),
|
||||
};
|
||||
});
|
||||
return [...rest, ...roleErrors];
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -285,7 +240,7 @@ function ServiceAccountDrawer({
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [selectedAccountId, executeRolesOperation, failuresToSaveErrors]);
|
||||
}, [localRoles, availableRoles, applyDiff, toSaveApiError, makeRoleRetry]);
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!account || !isDirty) {
|
||||
@@ -304,7 +259,7 @@ function ServiceAccountDrawer({
|
||||
|
||||
const [nameResult, rolesResult] = await Promise.allSettled([
|
||||
namePromise,
|
||||
executeRolesOperation(account.id),
|
||||
applyDiff(localRoles, availableRoles),
|
||||
]);
|
||||
|
||||
const errors: SaveError[] = [];
|
||||
@@ -324,7 +279,14 @@ function ServiceAccountDrawer({
|
||||
onRetry: retryRolesUpdate,
|
||||
});
|
||||
} else {
|
||||
errors.push(...failuresToSaveErrors(rolesResult.value));
|
||||
for (const failure of rolesResult.value) {
|
||||
const context = `Role '${failure.roleName}'`;
|
||||
errors.push({
|
||||
context,
|
||||
apiError: toSaveApiError(failure.error),
|
||||
onRetry: makeRoleRetry(context, failure.onRetry),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
@@ -332,7 +294,6 @@ function ServiceAccountDrawer({
|
||||
} else {
|
||||
toast.success('Service account updated successfully', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
onSuccess({ closeDrawer: false });
|
||||
}
|
||||
@@ -346,14 +307,17 @@ function ServiceAccountDrawer({
|
||||
account,
|
||||
isDirty,
|
||||
localName,
|
||||
localRoles,
|
||||
availableRoles,
|
||||
updateMutateAsync,
|
||||
executeRolesOperation,
|
||||
applyDiff,
|
||||
refetchAccount,
|
||||
onSuccess,
|
||||
queryClient,
|
||||
toSaveApiError,
|
||||
retryNameUpdate,
|
||||
makeRoleRetry,
|
||||
retryRolesUpdate,
|
||||
failuresToSaveErrors,
|
||||
]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
@@ -445,11 +409,8 @@ function ServiceAccountDrawer({
|
||||
account={account}
|
||||
localName={localName}
|
||||
onNameChange={handleNameChange}
|
||||
localRole={localRole}
|
||||
onRoleChange={(role): void => {
|
||||
setLocalRole(role ?? '');
|
||||
clearRoleErrors();
|
||||
}}
|
||||
localRoles={localRoles}
|
||||
onRolesChange={setLocalRoles}
|
||||
isDisabled={isDeleted}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
|
||||
@@ -139,18 +139,18 @@ describe('ServiceAccountDrawer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('changing roles enables Save; clicking Save sends role add request without delete', async () => {
|
||||
it('changing roles enables Save; clicking Save sends updated roles in payload', async () => {
|
||||
const updateSpy = jest.fn();
|
||||
const roleSpy = jest.fn();
|
||||
const deleteSpy = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
|
||||
roleSpy(await req.json());
|
||||
rest.put(SA_ENDPOINT, async (req, res, ctx) => {
|
||||
updateSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => {
|
||||
deleteSpy();
|
||||
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
|
||||
roleSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
@@ -167,12 +167,12 @@ describe('ServiceAccountDrawer', () => {
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).not.toHaveBeenCalled();
|
||||
expect(roleSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: '019c24aa-2248-7585-a129-4188b3473c27',
|
||||
}),
|
||||
);
|
||||
expect(deleteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -350,7 +350,7 @@ describe('ServiceAccountDrawer – save-error UX', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('role add failure shows SaveErrorItem with the role name context', async () => {
|
||||
it('role update failure shows SaveErrorItem with the role name context', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
@@ -390,42 +390,6 @@ describe('ServiceAccountDrawer – save-error UX', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('role add retries on 429 then succeeds without showing an error', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let roleAddCallCount = 0;
|
||||
|
||||
// First call → 429, second call → 200
|
||||
server.use(
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) => {
|
||||
roleAddCallCount += 1;
|
||||
if (roleAddCallCount === 1) {
|
||||
return res(ctx.status(429), ctx.json({ message: 'Too Many Requests' }));
|
||||
}
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
await user.click(screen.getByLabelText('Roles'));
|
||||
await user.click(await screen.findByTitle('signoz-viewer'));
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
// Retried after 429 — at least 2 calls, no error shown
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(roleAddCallCount).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
expect(screen.queryByText(/role assign failed/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking Retry on a name-update error re-triggers the request; on success the error item is removed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { ComponentProps, memo } from 'react';
|
||||
import { TableComponents } from 'react-virtuoso';
|
||||
import cx from 'classnames';
|
||||
|
||||
import TanStackRowCells from './TanStackRow';
|
||||
import {
|
||||
useClearRowHovered,
|
||||
useSetRowHovered,
|
||||
} from './TanStackTableStateContext';
|
||||
import { FlatItem, TableRowContext } from './types';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
|
||||
type VirtuosoTableRowProps<TData> = ComponentProps<
|
||||
NonNullable<
|
||||
TableComponents<FlatItem<TData>, TableRowContext<TData>>['TableRow']
|
||||
>
|
||||
>;
|
||||
|
||||
function TanStackCustomTableRow<TData>({
|
||||
item,
|
||||
context,
|
||||
...props
|
||||
}: VirtuosoTableRowProps<TData>): JSX.Element {
|
||||
const rowId = item.row.id;
|
||||
const rowData = item.row.original;
|
||||
|
||||
// Stable callbacks for hover state management
|
||||
const setHovered = useSetRowHovered(rowId);
|
||||
const clearHovered = useClearRowHovered(rowId);
|
||||
|
||||
if (item.kind === 'expansion') {
|
||||
return (
|
||||
<tr {...props} className={tableStyles.tableRowExpansion}>
|
||||
<TanStackRowCells
|
||||
row={item.row}
|
||||
itemKind={item.kind}
|
||||
context={context}
|
||||
hasSingleColumn={context?.hasSingleColumn ?? false}
|
||||
columnOrderKey={context?.columnOrderKey ?? ''}
|
||||
columnVisibilityKey={context?.columnVisibilityKey ?? ''}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const isActive = context?.isRowActive?.(rowData) ?? false;
|
||||
const extraClass = context?.getRowClassName?.(rowData) ?? '';
|
||||
const rowStyle = context?.getRowStyle?.(rowData);
|
||||
|
||||
const rowClassName = cx(
|
||||
tableStyles.tableRow,
|
||||
isActive && tableStyles.tableRowActive,
|
||||
extraClass,
|
||||
);
|
||||
|
||||
return (
|
||||
<tr
|
||||
{...props}
|
||||
className={rowClassName}
|
||||
style={rowStyle}
|
||||
onMouseEnter={setHovered}
|
||||
onMouseLeave={clearHovered}
|
||||
>
|
||||
<TanStackRowCells
|
||||
row={item.row}
|
||||
itemKind={item.kind}
|
||||
context={context}
|
||||
hasSingleColumn={context?.hasSingleColumn ?? false}
|
||||
columnOrderKey={context?.columnOrderKey ?? ''}
|
||||
columnVisibilityKey={context?.columnVisibilityKey ?? ''}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom comparison - only re-render when row identity or computed values change
|
||||
// This looks overkill but ensures the table is stable and doesn't re-render on every change
|
||||
// If you add any new prop to context, remember to update this function
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function areTableRowPropsEqual<TData>(
|
||||
prev: Readonly<VirtuosoTableRowProps<TData>>,
|
||||
next: Readonly<VirtuosoTableRowProps<TData>>,
|
||||
): boolean {
|
||||
if (prev.item.row.id !== next.item.row.id) {
|
||||
return false;
|
||||
}
|
||||
if (prev.item.kind !== next.item.kind) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevData = prev.item.row.original;
|
||||
const nextData = next.item.row.original;
|
||||
|
||||
if (prevData !== nextData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.context?.hasSingleColumn !== next.context?.hasSingleColumn) {
|
||||
return false;
|
||||
}
|
||||
if (prev.context?.columnOrderKey !== next.context?.columnOrderKey) {
|
||||
return false;
|
||||
}
|
||||
if (prev.context?.columnVisibilityKey !== next.context?.columnVisibilityKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.context !== next.context) {
|
||||
const prevActive = prev.context?.isRowActive?.(prevData) ?? false;
|
||||
const nextActive = next.context?.isRowActive?.(nextData) ?? false;
|
||||
if (prevActive !== nextActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevClass = prev.context?.getRowClassName?.(prevData) ?? '';
|
||||
const nextClass = next.context?.getRowClassName?.(nextData) ?? '';
|
||||
if (prevClass !== nextClass) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevStyle = prev.context?.getRowStyle?.(prevData);
|
||||
const nextStyle = next.context?.getRowStyle?.(nextData);
|
||||
if (prevStyle !== nextStyle) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default memo(
|
||||
TanStackCustomTableRow,
|
||||
areTableRowPropsEqual,
|
||||
) as typeof TanStackCustomTableRow;
|
||||
@@ -1,214 +0,0 @@
|
||||
.tanstackHeaderCell {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 0.3rem;
|
||||
transform: translate3d(
|
||||
var(--tanstack-header-translate-x, 0px),
|
||||
var(--tanstack-header-translate-y, 0px),
|
||||
0
|
||||
);
|
||||
transition: var(--tanstack-header-transition, none);
|
||||
|
||||
&.isDragging {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&.isResizing {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.tanstackHeaderContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
max-width: 100%;
|
||||
|
||||
&.hasResizeControl {
|
||||
max-width: calc(100% - 5px);
|
||||
}
|
||||
|
||||
&.hasActionControl {
|
||||
max-width: calc(100% - 5px);
|
||||
}
|
||||
|
||||
&.hasResizeControl.hasActionControl {
|
||||
max-width: calc(100% - 10px);
|
||||
}
|
||||
}
|
||||
|
||||
.tanstackGripSlot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tanstackGripActivator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: grab;
|
||||
color: var(--l2-foreground);
|
||||
opacity: 1;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.tanstackHeaderActionTrigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tanstackColumnActionsContent {
|
||||
width: 140px;
|
||||
padding: 0;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.tanstackRemoveColumnAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
justify-content: flex-start;
|
||||
cursor: pointer;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background-hover);
|
||||
color: var(--l2-foreground);
|
||||
|
||||
.tanstackRemoveColumnActionIcon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tanstackRemoveColumnActionIcon {
|
||||
font-size: 11px;
|
||||
color: var(--l2-foreground);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.tanstackHeaderCell .cursorColResize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 5px;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
touch-action: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tanstackHeaderCell.isResizing .cursorColResize {
|
||||
background: var(--bg-robin-300);
|
||||
}
|
||||
|
||||
.tanstackResizeHandleLine {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 4px;
|
||||
transform: translateX(-50%);
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
transition: background 120ms ease, width 120ms ease;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
import type {
|
||||
CSSProperties,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
TouchEvent as ReactTouchEvent,
|
||||
} from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/popover';
|
||||
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
|
||||
import cx from 'classnames';
|
||||
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
|
||||
|
||||
import { SortState, TableColumnDef } from './types';
|
||||
|
||||
import headerStyles from './TanStackHeaderRow.module.scss';
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
|
||||
type TanStackHeaderRowProps<TData = unknown> = {
|
||||
column: TableColumnDef<TData>;
|
||||
header?: TanStackHeader<TData, unknown>;
|
||||
isDarkMode: boolean;
|
||||
hasSingleColumn: boolean;
|
||||
canRemoveColumn?: boolean;
|
||||
onRemoveColumn?: (columnId: string) => void;
|
||||
orderBy?: SortState | null;
|
||||
onSort?: (sort: SortState | null) => void;
|
||||
/** Last column cannot be resized */
|
||||
isLastColumn?: boolean;
|
||||
};
|
||||
|
||||
const GRIP_ICON_SIZE = 12;
|
||||
|
||||
const SORT_ICON_SIZE = 14;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function TanStackHeaderRow<TData>({
|
||||
column,
|
||||
header,
|
||||
isDarkMode,
|
||||
hasSingleColumn,
|
||||
canRemoveColumn = false,
|
||||
onRemoveColumn,
|
||||
orderBy,
|
||||
onSort,
|
||||
isLastColumn = false,
|
||||
}: TanStackHeaderRowProps<TData>): JSX.Element {
|
||||
const columnId = column.id;
|
||||
const isDragColumn = column.enableMove !== false && column.pin == null;
|
||||
const isResizableColumn =
|
||||
!isLastColumn &&
|
||||
column.enableResize !== false &&
|
||||
Boolean(header?.column.getCanResize());
|
||||
const isColumnRemovable = Boolean(
|
||||
canRemoveColumn && onRemoveColumn && column.enableRemove,
|
||||
);
|
||||
const isSortable = column.enableSort === true && Boolean(onSort);
|
||||
const currentSortDirection =
|
||||
orderBy?.columnName === columnId ? orderBy.order : null;
|
||||
const isResizing = Boolean(header?.column.getIsResizing());
|
||||
const resizeHandler = header?.getResizeHandler();
|
||||
const headerText =
|
||||
typeof column.header === 'string' && column.header
|
||||
? column.header
|
||||
: String(header?.id ?? columnId);
|
||||
const headerTitleAttr = headerText.replace(/^\w/, (c) => c.toUpperCase());
|
||||
|
||||
const handleSortClick = useCallback((): void => {
|
||||
if (!isSortable || !onSort) {
|
||||
return;
|
||||
}
|
||||
if (currentSortDirection === null) {
|
||||
onSort({ columnName: columnId, order: 'asc' });
|
||||
} else if (currentSortDirection === 'asc') {
|
||||
onSort({ columnName: columnId, order: 'desc' });
|
||||
} else {
|
||||
onSort(null);
|
||||
}
|
||||
}, [isSortable, onSort, currentSortDirection, columnId]);
|
||||
|
||||
const handleResizeStart = (
|
||||
event: ReactMouseEvent<HTMLElement> | ReactTouchEvent<HTMLElement>,
|
||||
): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resizeHandler?.(event);
|
||||
};
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: columnId,
|
||||
disabled: !isDragColumn,
|
||||
});
|
||||
const headerCellStyle = useMemo(
|
||||
() =>
|
||||
({
|
||||
'--tanstack-header-translate-x': `${Math.round(transform?.x ?? 0)}px`,
|
||||
'--tanstack-header-translate-y': `${Math.round(transform?.y ?? 0)}px`,
|
||||
'--tanstack-header-transition': isResizing ? 'none' : transition || 'none',
|
||||
} as CSSProperties),
|
||||
[isResizing, transform?.x, transform?.y, transition],
|
||||
);
|
||||
const headerCellClassName = cx(
|
||||
headerStyles.tanstackHeaderCell,
|
||||
isDragging && headerStyles.isDragging,
|
||||
isResizing && headerStyles.isResizing,
|
||||
);
|
||||
const headerContentClassName = cx(
|
||||
headerStyles.tanstackHeaderContent,
|
||||
isResizableColumn && headerStyles.hasResizeControl,
|
||||
isColumnRemovable && headerStyles.hasActionControl,
|
||||
isSortable && headerStyles.isSortable,
|
||||
);
|
||||
|
||||
const thClassName = cx(
|
||||
tableStyles.tableHeaderCell,
|
||||
headerCellClassName,
|
||||
column.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<th
|
||||
ref={setNodeRef}
|
||||
className={thClassName}
|
||||
key={columnId}
|
||||
style={headerCellStyle}
|
||||
data-dark-mode={isDarkMode}
|
||||
data-single-column={hasSingleColumn || undefined}
|
||||
>
|
||||
<span className={headerContentClassName}>
|
||||
{isDragColumn ? (
|
||||
<span className={headerStyles.tanstackGripSlot}>
|
||||
<span
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
role="button"
|
||||
aria-label={`Drag ${String(
|
||||
(typeof column.header === 'string' && column.header) ||
|
||||
header?.id ||
|
||||
columnId,
|
||||
)} column`}
|
||||
className={headerStyles.tanstackGripActivator}
|
||||
>
|
||||
<GripVertical size={GRIP_ICON_SIZE} />
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
{isSortable ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
'tanstack-header-title',
|
||||
headerStyles.tanstackSortButton,
|
||||
currentSortDirection && headerStyles.isSorted,
|
||||
)}
|
||||
title={headerTitleAttr}
|
||||
onClick={handleSortClick}
|
||||
aria-sort={
|
||||
currentSortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: currentSortDirection === 'desc'
|
||||
? 'descending'
|
||||
: 'none'
|
||||
}
|
||||
>
|
||||
<span className={headerStyles.tanstackSortLabel}>
|
||||
{header?.column?.columnDef
|
||||
? flexRender(header.column.columnDef.header, header.getContext())
|
||||
: typeof column.header === 'function'
|
||||
? column.header()
|
||||
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
<span className={headerStyles.tanstackSortIndicator}>
|
||||
{currentSortDirection === 'asc' ? (
|
||||
<ChevronUp size={SORT_ICON_SIZE} />
|
||||
) : currentSortDirection === 'desc' ? (
|
||||
<ChevronDown size={SORT_ICON_SIZE} />
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={cx('tanstack-header-title', headerStyles.tanstackHeaderTitle)}
|
||||
title={headerTitleAttr}
|
||||
>
|
||||
{header?.column?.columnDef
|
||||
? flexRender(header.column.columnDef.header, header.getContext())
|
||||
: typeof column.header === 'function'
|
||||
? column.header()
|
||||
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
)}
|
||||
{isColumnRemovable && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
role="button"
|
||||
aria-label={`Column actions for ${headerTitleAttr}`}
|
||||
className={headerStyles.tanstackHeaderActionTrigger}
|
||||
onMouseDown={(event): void => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<MoreOutlined />
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className={headerStyles.tanstackColumnActionsContent}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={headerStyles.tanstackRemoveColumnAction}
|
||||
onClick={(event): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onRemoveColumn?.(column.id);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined
|
||||
className={headerStyles.tanstackRemoveColumnActionIcon}
|
||||
/>
|
||||
Remove column
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</span>
|
||||
{isResizableColumn && (
|
||||
<span
|
||||
role="presentation"
|
||||
className={headerStyles.cursorColResize}
|
||||
title="Drag to resize column"
|
||||
onClick={(event): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(event): void => {
|
||||
handleResizeStart(event);
|
||||
}}
|
||||
onTouchStart={(event): void => {
|
||||
handleResizeStart(event);
|
||||
}}
|
||||
>
|
||||
<span className={headerStyles.tanstackResizeHandleLine} />
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export default TanStackHeaderRow;
|
||||
@@ -1,136 +0,0 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { Row as TanStackRowModel } from '@tanstack/react-table';
|
||||
|
||||
import { TanStackRowCell } from './TanStackRowCell';
|
||||
import { useIsRowHovered } from './TanStackTableStateContext';
|
||||
import { TableRowContext } from './types';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
|
||||
type TanStackRowCellsProps<TData> = {
|
||||
row: TanStackRowModel<TData>;
|
||||
context: TableRowContext<TData> | undefined;
|
||||
itemKind: 'row' | 'expansion';
|
||||
hasSingleColumn: boolean;
|
||||
columnOrderKey: string;
|
||||
columnVisibilityKey: string;
|
||||
};
|
||||
|
||||
function TanStackRowCellsInner<TData>({
|
||||
row,
|
||||
context,
|
||||
itemKind,
|
||||
hasSingleColumn,
|
||||
columnOrderKey: _columnOrderKey,
|
||||
columnVisibilityKey: _columnVisibilityKey,
|
||||
}: TanStackRowCellsProps<TData>): JSX.Element {
|
||||
const hasHovered = useIsRowHovered(row.id);
|
||||
const rowData = row.original;
|
||||
const visibleCells = row.getVisibleCells();
|
||||
const lastCellIndex = visibleCells.length - 1;
|
||||
|
||||
// Stable references via destructuring, keep them as is
|
||||
const onRowClick = context?.onRowClick;
|
||||
const onRowClickNewTab = context?.onRowClickNewTab;
|
||||
const onRowDeactivate = context?.onRowDeactivate;
|
||||
const isRowActive = context?.isRowActive;
|
||||
const getRowKeyData = context?.getRowKeyData;
|
||||
const rowIndex = row.index;
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<HTMLTableCellElement>) => {
|
||||
const keyData = getRowKeyData?.(rowIndex);
|
||||
const itemKey = keyData?.itemKey ?? '';
|
||||
|
||||
// Handle ctrl+click or cmd+click (open in new tab)
|
||||
if ((event.ctrlKey || event.metaKey) && onRowClickNewTab) {
|
||||
onRowClickNewTab(rowData, itemKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive = isRowActive?.(rowData) ?? false;
|
||||
if (isActive && onRowDeactivate) {
|
||||
onRowDeactivate();
|
||||
} else {
|
||||
onRowClick?.(rowData, itemKey);
|
||||
}
|
||||
},
|
||||
[
|
||||
isRowActive,
|
||||
onRowDeactivate,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
rowData,
|
||||
getRowKeyData,
|
||||
rowIndex,
|
||||
],
|
||||
);
|
||||
|
||||
if (itemKind === 'expansion') {
|
||||
const keyData = getRowKeyData?.(rowIndex);
|
||||
return (
|
||||
<td
|
||||
colSpan={context?.colCount ?? 1}
|
||||
className={tableStyles.tableCellExpansion}
|
||||
>
|
||||
{context?.renderExpandedRow?.(
|
||||
rowData,
|
||||
keyData?.finalKey ?? '',
|
||||
keyData?.groupMeta,
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleCells.map((cell, index) => {
|
||||
const isLastCell = index === lastCellIndex;
|
||||
return (
|
||||
<TanStackRowCell
|
||||
key={cell.id}
|
||||
cell={cell}
|
||||
hasSingleColumn={hasSingleColumn}
|
||||
isLastCell={isLastCell}
|
||||
hasHovered={hasHovered}
|
||||
rowData={rowData}
|
||||
onClick={handleClick}
|
||||
renderRowActions={context?.renderRowActions}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom comparison - only re-render when row data changes
|
||||
// If you add any new prop to context, remember to update this function
|
||||
function areRowCellsPropsEqual<TData>(
|
||||
prev: Readonly<TanStackRowCellsProps<TData>>,
|
||||
next: Readonly<TanStackRowCellsProps<TData>>,
|
||||
): boolean {
|
||||
return (
|
||||
prev.row.id === next.row.id &&
|
||||
prev.itemKind === next.itemKind &&
|
||||
prev.hasSingleColumn === next.hasSingleColumn &&
|
||||
prev.columnOrderKey === next.columnOrderKey &&
|
||||
prev.columnVisibilityKey === next.columnVisibilityKey &&
|
||||
prev.context?.onRowClick === next.context?.onRowClick &&
|
||||
prev.context?.onRowClickNewTab === next.context?.onRowClickNewTab &&
|
||||
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
|
||||
prev.context?.isRowActive === next.context?.isRowActive &&
|
||||
prev.context?.getRowKeyData === next.context?.getRowKeyData &&
|
||||
prev.context?.renderRowActions === next.context?.renderRowActions &&
|
||||
prev.context?.renderExpandedRow === next.context?.renderExpandedRow &&
|
||||
prev.context?.colCount === next.context?.colCount
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const TanStackRowCells = memo(
|
||||
TanStackRowCellsInner,
|
||||
areRowCellsPropsEqual as any,
|
||||
) as <T>(props: TanStackRowCellsProps<T>) => JSX.Element;
|
||||
|
||||
export default TanStackRowCells;
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { MouseEvent, ReactNode } from 'react';
|
||||
import { memo } from 'react';
|
||||
import type { Cell } from '@tanstack/react-table';
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { useShouldShowCellSkeleton } from './TanStackTableStateContext';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
import skeletonStyles from './TanStackTableSkeleton.module.scss';
|
||||
|
||||
export type TanStackRowCellProps<TData> = {
|
||||
cell: Cell<TData, unknown>;
|
||||
hasSingleColumn: boolean;
|
||||
isLastCell: boolean;
|
||||
hasHovered: boolean;
|
||||
rowData: TData;
|
||||
onClick: (event: MouseEvent<HTMLTableCellElement>) => void;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
};
|
||||
|
||||
function TanStackRowCellInner<TData>({
|
||||
cell,
|
||||
hasSingleColumn,
|
||||
isLastCell,
|
||||
hasHovered,
|
||||
rowData,
|
||||
onClick,
|
||||
renderRowActions,
|
||||
}: TanStackRowCellProps<TData>): JSX.Element {
|
||||
const showSkeleton = useShouldShowCellSkeleton();
|
||||
|
||||
return (
|
||||
<td
|
||||
className={cx(tableStyles.tableCell, 'tanstack-cell-' + cell.column.id)}
|
||||
data-single-column={hasSingleColumn || undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
{showSkeleton ? (
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={skeletonStyles.cellSkeleton}
|
||||
/>
|
||||
) : (
|
||||
flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
)}
|
||||
{isLastCell && hasHovered && renderRowActions && !showSkeleton && (
|
||||
<span className={tableStyles.tableViewRowActions}>
|
||||
{renderRowActions(rowData)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom comparison - only re-render when row data changes
|
||||
// If you add any new prop to context, remember to update this function
|
||||
function areTanStackRowCellPropsEqual<TData>(
|
||||
prev: Readonly<TanStackRowCellProps<TData>>,
|
||||
next: Readonly<TanStackRowCellProps<TData>>,
|
||||
): boolean {
|
||||
if (next.cell.id.startsWith('skeleton-')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
prev.cell.id === next.cell.id &&
|
||||
prev.cell.column.id === next.cell.column.id &&
|
||||
Object.is(prev.cell.getValue(), next.cell.getValue()) &&
|
||||
prev.hasSingleColumn === next.hasSingleColumn &&
|
||||
prev.isLastCell === next.isLastCell &&
|
||||
prev.hasHovered === next.hasHovered &&
|
||||
prev.onClick === next.onClick &&
|
||||
prev.renderRowActions === next.renderRowActions &&
|
||||
prev.rowData === next.rowData
|
||||
);
|
||||
}
|
||||
|
||||
const TanStackRowCellMemo = memo(
|
||||
TanStackRowCellInner,
|
||||
areTanStackRowCellPropsEqual,
|
||||
);
|
||||
|
||||
TanStackRowCellMemo.displayName = 'TanStackRowCell';
|
||||
|
||||
export const TanStackRowCell = TanStackRowCellMemo as typeof TanStackRowCellInner;
|
||||
@@ -1,100 +0,0 @@
|
||||
.tanStackTable {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
|
||||
& td,
|
||||
& th {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.tableCellText {
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.07px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
width: auto;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: var(--tanstack-plain-body-line-clamp, 1);
|
||||
line-clamp: var(--tanstack-plain-body-line-clamp, 1);
|
||||
font-size: var(--tanstack-plain-cell-font-size, 14px);
|
||||
line-height: var(--tanstack-plain-cell-line-height, 18px);
|
||||
color: var(--l2-foreground);
|
||||
max-width: 100%;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tableViewRowActions {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
left: auto;
|
||||
transform: translateY(-50%);
|
||||
margin: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.tableCell {
|
||||
padding: 0.3rem;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.07px;
|
||||
font-size: var(--tanstack-plain-cell-font-size, 14px);
|
||||
line-height: var(--tanstack-plain-cell-line-height, 18px);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.tableRow {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow-anchor: none;
|
||||
|
||||
&:hover {
|
||||
.tableCell {
|
||||
background-color: var(--row-hover-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.tableRowActive {
|
||||
.tableCell {
|
||||
background-color: var(--row-active-bg) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tableHeaderCell {
|
||||
padding: 0.3rem;
|
||||
height: 36px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
// TODO: Remove this once background color (l1) is matching the actual background color of the page
|
||||
&[data-dark-mode='true'] {
|
||||
background: #0b0c0d;
|
||||
}
|
||||
|
||||
&[data-dark-mode='false'] {
|
||||
background: #fdfdfd;
|
||||
}
|
||||
}
|
||||
|
||||
.tableRowExpansion {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.tableCellExpansion {
|
||||
padding: 0.5rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
@@ -1,572 +0,0 @@
|
||||
import type { ComponentProps, CSSProperties } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import type { TableComponents } from 'react-virtuoso';
|
||||
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { DndContext, pointerWithin } from '@dnd-kit/core';
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
} from '@dnd-kit/sortable';
|
||||
import {
|
||||
ComboboxSimple,
|
||||
ComboboxSimpleItem,
|
||||
TooltipProvider,
|
||||
} from '@signozhq/ui';
|
||||
import { Pagination } from '@signozhq/ui';
|
||||
import type { Row } from '@tanstack/react-table';
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnPinningState,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { Spin } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import TanStackCustomTableRow from './TanStackCustomTableRow';
|
||||
import TanStackHeaderRow from './TanStackHeaderRow';
|
||||
import {
|
||||
ColumnVisibilitySync,
|
||||
TableLoadingSync,
|
||||
TanStackTableStateProvider,
|
||||
} from './TanStackTableStateContext';
|
||||
import {
|
||||
FlatItem,
|
||||
TableRowContext,
|
||||
TanStackTableHandle,
|
||||
TanStackTableProps,
|
||||
} from './types';
|
||||
import { useColumnDnd } from './useColumnDnd';
|
||||
import { useColumnHandlers } from './useColumnHandlers';
|
||||
import { useColumnState } from './useColumnState';
|
||||
import { useEffectiveData } from './useEffectiveData';
|
||||
import { useFlatItems } from './useFlatItems';
|
||||
import { useRowKeyData } from './useRowKeyData';
|
||||
import { useTableParams } from './useTableParams';
|
||||
import { buildTanstackColumnDef } from './utils';
|
||||
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
import viewStyles from './TanStackTableView.module.scss';
|
||||
|
||||
const COLUMN_DND_AUTO_SCROLL = {
|
||||
layoutShiftCompensation: false as const,
|
||||
threshold: { x: 0.2, y: 0 },
|
||||
};
|
||||
|
||||
const INCREASE_VIEWPORT_BY = { top: 500, bottom: 500 };
|
||||
|
||||
const noopColumnVisibility = (): void => {};
|
||||
|
||||
const paginationPageSizeItems: ComboboxSimpleItem[] = [10, 20, 30, 50, 100].map(
|
||||
(value) => ({
|
||||
value: value.toString(),
|
||||
label: value.toString(),
|
||||
displayValue: value.toString(),
|
||||
}),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function TanStackTableInner<TData>(
|
||||
{
|
||||
data,
|
||||
columns,
|
||||
columnStorageKey,
|
||||
columnSizing: columnSizingProp,
|
||||
onColumnSizingChange,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
isLoading = false,
|
||||
skeletonRowCount = 10,
|
||||
enableQueryParams,
|
||||
pagination,
|
||||
onEndReached,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
groupBy,
|
||||
getGroupKey,
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
activeRowIndex,
|
||||
renderExpandedRow,
|
||||
getRowCanExpand,
|
||||
tableScrollerProps,
|
||||
plainTextCellLineClamp,
|
||||
cellTypographySize,
|
||||
className,
|
||||
testId,
|
||||
prefixPaginationContent,
|
||||
suffixPaginationContent,
|
||||
}: TanStackTableProps<TData>,
|
||||
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
|
||||
): JSX.Element {
|
||||
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const {
|
||||
page,
|
||||
limit,
|
||||
setPage,
|
||||
setLimit,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
expanded,
|
||||
setExpanded,
|
||||
} = useTableParams(enableQueryParams, {
|
||||
page: pagination?.defaultPage,
|
||||
limit: pagination?.defaultLimit,
|
||||
});
|
||||
|
||||
const isGrouped = (groupBy?.length ?? 0) > 0;
|
||||
|
||||
const {
|
||||
columnVisibility: storeVisibility,
|
||||
columnSizing: storeSizing,
|
||||
sortedColumns,
|
||||
hideColumn,
|
||||
setColumnSizing: storeSetSizing,
|
||||
setColumnOrder: storeSetOrder,
|
||||
} = useColumnState({
|
||||
storageKey: columnStorageKey,
|
||||
columns,
|
||||
isGrouped,
|
||||
});
|
||||
|
||||
// Use store values when columnStorageKey is provided, otherwise fall back to props/defaults
|
||||
const effectiveColumns = columnStorageKey ? sortedColumns : columns;
|
||||
const effectiveVisibility = columnStorageKey ? storeVisibility : {};
|
||||
const effectiveSizing = columnStorageKey
|
||||
? storeSizing
|
||||
: columnSizingProp ?? {};
|
||||
|
||||
const effectiveData = useEffectiveData<TData>({
|
||||
data,
|
||||
isLoading,
|
||||
limit,
|
||||
skeletonRowCount,
|
||||
});
|
||||
|
||||
const { rowKeyData, getRowKeyData } = useRowKeyData({
|
||||
data: effectiveData,
|
||||
isLoading,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
groupBy,
|
||||
getGroupKey,
|
||||
});
|
||||
|
||||
const {
|
||||
handleColumnSizingChange,
|
||||
handleColumnOrderChange,
|
||||
handleRemoveColumn,
|
||||
} = useColumnHandlers({
|
||||
columnStorageKey,
|
||||
effectiveSizing,
|
||||
storeSetSizing,
|
||||
storeSetOrder,
|
||||
hideColumn,
|
||||
onColumnSizingChange,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
});
|
||||
|
||||
const columnPinning = useMemo<ColumnPinningState>(
|
||||
() => ({
|
||||
left: effectiveColumns.filter((c) => c.pin === 'left').map((c) => c.id),
|
||||
right: effectiveColumns.filter((c) => c.pin === 'right').map((c) => c.id),
|
||||
}),
|
||||
[effectiveColumns],
|
||||
);
|
||||
|
||||
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
|
||||
() =>
|
||||
effectiveColumns.map((colDef) =>
|
||||
buildTanstackColumnDef(colDef, isRowActive, getRowKeyData),
|
||||
),
|
||||
[effectiveColumns, isRowActive, getRowKeyData],
|
||||
);
|
||||
|
||||
const getRowId = useCallback(
|
||||
(row: TData, index: number): string => {
|
||||
if (rowKeyData) {
|
||||
return rowKeyData[index]?.finalKey ?? String(index);
|
||||
}
|
||||
const r = row as Record<string, unknown>;
|
||||
if (r != null && typeof r.id !== 'undefined') {
|
||||
return String(r.id);
|
||||
}
|
||||
return String(index);
|
||||
},
|
||||
[rowKeyData],
|
||||
);
|
||||
|
||||
const tableGetRowCanExpand = useCallback(
|
||||
(row: Row<TData>): boolean =>
|
||||
getRowCanExpand ? getRowCanExpand(row.original) : true,
|
||||
[getRowCanExpand],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: effectiveData,
|
||||
columns: tanstackColumns,
|
||||
enableColumnResizing: true,
|
||||
enableColumnPinning: true,
|
||||
columnResizeMode: 'onChange',
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId,
|
||||
enableExpanding: Boolean(renderExpandedRow),
|
||||
getRowCanExpand: renderExpandedRow ? tableGetRowCanExpand : undefined,
|
||||
onColumnSizingChange: handleColumnSizingChange,
|
||||
onColumnVisibilityChange: noopColumnVisibility,
|
||||
onExpandedChange: setExpanded,
|
||||
state: {
|
||||
columnSizing: effectiveSizing,
|
||||
columnVisibility: effectiveVisibility,
|
||||
columnPinning,
|
||||
expanded,
|
||||
},
|
||||
});
|
||||
|
||||
// Keep refs to avoid recreating virtuosoComponents on every resize/render
|
||||
const tableRef = useRef(table);
|
||||
tableRef.current = table;
|
||||
const columnsRef = useRef(effectiveColumns);
|
||||
columnsRef.current = effectiveColumns;
|
||||
|
||||
const tableRows = table.getRowModel().rows;
|
||||
|
||||
const { flatItems, flatIndexForActiveRow } = useFlatItems({
|
||||
tableRows,
|
||||
renderExpandedRow,
|
||||
expanded,
|
||||
activeRowIndex,
|
||||
});
|
||||
|
||||
// keep previous count just to avoid flashing the pagination component
|
||||
const prevTotalCountRef = useRef(pagination?.total || 0);
|
||||
if (pagination?.total && pagination?.total > 0) {
|
||||
prevTotalCountRef.current = pagination?.total;
|
||||
}
|
||||
const effectiveTotalCount = !isLoading
|
||||
? pagination?.total || 0
|
||||
: prevTotalCountRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (flatIndexForActiveRow < 0) {
|
||||
return;
|
||||
}
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: flatIndexForActiveRow,
|
||||
align: 'center',
|
||||
behavior: 'auto',
|
||||
});
|
||||
}, [flatIndexForActiveRow]);
|
||||
|
||||
const { sensors, columnIds, handleDragEnd } = useColumnDnd({
|
||||
columns: effectiveColumns,
|
||||
onColumnOrderChange: handleColumnOrderChange,
|
||||
});
|
||||
|
||||
const hasSingleColumn = useMemo(
|
||||
() =>
|
||||
effectiveColumns.filter((c) => !c.pin && c.enableRemove !== false).length <=
|
||||
1,
|
||||
[effectiveColumns],
|
||||
);
|
||||
|
||||
const canRemoveColumn = !hasSingleColumn;
|
||||
|
||||
const flatHeaders = useMemo(
|
||||
() => table.getFlatHeaders().filter((header) => !header.isPlaceholder),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[tanstackColumns, columnPinning, effectiveVisibility],
|
||||
);
|
||||
|
||||
const columnsById = useMemo(
|
||||
() => new Map(effectiveColumns.map((c) => [c.id, c] as const)),
|
||||
[effectiveColumns],
|
||||
);
|
||||
|
||||
const visibleColumnsCount = table.getVisibleFlatColumns().length;
|
||||
|
||||
const columnOrderKey = useMemo(() => columnIds.join(','), [columnIds]);
|
||||
const columnVisibilityKey = useMemo(
|
||||
() =>
|
||||
table
|
||||
.getVisibleFlatColumns()
|
||||
.map((c) => c.id)
|
||||
.join(','),
|
||||
// we want to explicitly have table out of this deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[effectiveVisibility, columnIds],
|
||||
);
|
||||
|
||||
const virtuosoContext = useMemo<TableRowContext<TData>>(
|
||||
() => ({
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
renderExpandedRow,
|
||||
getRowKeyData,
|
||||
colCount: visibleColumnsCount,
|
||||
isDarkMode,
|
||||
plainTextCellLineClamp,
|
||||
hasSingleColumn,
|
||||
columnOrderKey,
|
||||
columnVisibilityKey,
|
||||
}),
|
||||
[
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
renderExpandedRow,
|
||||
getRowKeyData,
|
||||
visibleColumnsCount,
|
||||
isDarkMode,
|
||||
plainTextCellLineClamp,
|
||||
hasSingleColumn,
|
||||
columnOrderKey,
|
||||
columnVisibilityKey,
|
||||
],
|
||||
);
|
||||
|
||||
const tableHeader = useCallback(() => {
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
onDragEnd={handleDragEnd}
|
||||
autoScroll={COLUMN_DND_AUTO_SCROLL}
|
||||
>
|
||||
<SortableContext items={columnIds} strategy={horizontalListSortingStrategy}>
|
||||
<tr>
|
||||
{flatHeaders.map((header, index) => {
|
||||
const column = columnsById.get(header.id);
|
||||
if (!column) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<TanStackHeaderRow
|
||||
key={header.id}
|
||||
column={column}
|
||||
header={header}
|
||||
isDarkMode={isDarkMode}
|
||||
hasSingleColumn={hasSingleColumn}
|
||||
onRemoveColumn={handleRemoveColumn}
|
||||
canRemoveColumn={canRemoveColumn}
|
||||
orderBy={orderBy}
|
||||
onSort={setOrderBy}
|
||||
isLastColumn={index === flatHeaders.length - 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}, [
|
||||
sensors,
|
||||
handleDragEnd,
|
||||
columnIds,
|
||||
flatHeaders,
|
||||
columnsById,
|
||||
isDarkMode,
|
||||
hasSingleColumn,
|
||||
handleRemoveColumn,
|
||||
canRemoveColumn,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
]);
|
||||
|
||||
const handleEndReached = useCallback(
|
||||
(index: number): void => {
|
||||
onEndReached?.(index);
|
||||
},
|
||||
[onEndReached],
|
||||
);
|
||||
|
||||
const isInfiniteScrollMode = Boolean(onEndReached);
|
||||
const showInfiniteScrollLoader = isInfiniteScrollMode && isLoading;
|
||||
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
(): TanStackTableHandle =>
|
||||
new Proxy(
|
||||
{
|
||||
goToPage: (p: number): void => {
|
||||
setPage(p);
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: 0,
|
||||
align: 'start',
|
||||
});
|
||||
},
|
||||
} as TanStackTableHandle,
|
||||
{
|
||||
get(target, prop): unknown {
|
||||
if (prop in target) {
|
||||
return Reflect.get(target, prop);
|
||||
}
|
||||
const v = (virtuosoRef.current as unknown) as Record<string, unknown>;
|
||||
const value = v?.[prop as string];
|
||||
if (typeof value === 'function') {
|
||||
return (value as (...a: unknown[]) => unknown).bind(virtuosoRef.current);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
),
|
||||
[setPage],
|
||||
);
|
||||
|
||||
const showPagination = Boolean(pagination && !onEndReached);
|
||||
|
||||
const { className: tableScrollerClassName, ...restTableScrollerProps } =
|
||||
tableScrollerProps ?? {};
|
||||
|
||||
const cellTypographyClass = useMemo((): string | undefined => {
|
||||
if (cellTypographySize === 'small') {
|
||||
return viewStyles.cellTypographySmall;
|
||||
}
|
||||
if (cellTypographySize === 'medium') {
|
||||
return viewStyles.cellTypographyMedium;
|
||||
}
|
||||
if (cellTypographySize === 'large') {
|
||||
return viewStyles.cellTypographyLarge;
|
||||
}
|
||||
return undefined;
|
||||
}, [cellTypographySize]);
|
||||
|
||||
const virtuosoClassName = useMemo(
|
||||
() =>
|
||||
cx(
|
||||
viewStyles.tanstackTableVirtuosoScroll,
|
||||
cellTypographyClass,
|
||||
tableScrollerClassName,
|
||||
),
|
||||
[cellTypographyClass, tableScrollerClassName],
|
||||
);
|
||||
|
||||
const virtuosoTableStyle = useMemo(
|
||||
() =>
|
||||
({
|
||||
'--tanstack-plain-body-line-clamp': plainTextCellLineClamp,
|
||||
} as CSSProperties),
|
||||
[plainTextCellLineClamp],
|
||||
);
|
||||
|
||||
type VirtuosoTableComponentProps = ComponentProps<
|
||||
NonNullable<TableComponents<FlatItem<TData>, TableRowContext<TData>>['Table']>
|
||||
>;
|
||||
|
||||
// Use refs in virtuosoComponents to keep the component reference stable during resize
|
||||
// This prevents Virtuoso from re-rendering all rows when columns are resized
|
||||
const virtuosoComponents = useMemo(
|
||||
() => ({
|
||||
Table: ({ style, children }: VirtuosoTableComponentProps): JSX.Element => (
|
||||
<table className={tableStyles.tanStackTable} style={style}>
|
||||
<VirtuosoTableColGroup
|
||||
columns={columnsRef.current}
|
||||
table={tableRef.current}
|
||||
/>
|
||||
{children}
|
||||
</table>
|
||||
),
|
||||
TableRow: TanStackCustomTableRow,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx(viewStyles.tanstackTableViewWrapper, className)}>
|
||||
<TanStackTableStateProvider>
|
||||
<TableLoadingSync
|
||||
isLoading={isLoading}
|
||||
isInfiniteScrollMode={isInfiniteScrollMode}
|
||||
/>
|
||||
<ColumnVisibilitySync visibility={effectiveVisibility} />
|
||||
<TooltipProvider>
|
||||
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
|
||||
className={virtuosoClassName}
|
||||
ref={virtuosoRef}
|
||||
{...restTableScrollerProps}
|
||||
data={flatItems}
|
||||
totalCount={flatItems.length}
|
||||
context={virtuosoContext}
|
||||
increaseViewportBy={INCREASE_VIEWPORT_BY}
|
||||
initialTopMostItemIndex={
|
||||
flatIndexForActiveRow >= 0 ? flatIndexForActiveRow : 0
|
||||
}
|
||||
fixedHeaderContent={tableHeader}
|
||||
style={virtuosoTableStyle}
|
||||
components={virtuosoComponents}
|
||||
endReached={onEndReached ? handleEndReached : undefined}
|
||||
data-testid={testId}
|
||||
/>
|
||||
{showInfiniteScrollLoader && (
|
||||
<div
|
||||
className={viewStyles.tanstackLoadingOverlay}
|
||||
data-testid="tanstack-infinite-loader"
|
||||
>
|
||||
<Spin indicator={<LoadingOutlined spin />} tip="Loading more..." />
|
||||
</div>
|
||||
)}
|
||||
{showPagination && pagination && (
|
||||
<div className={viewStyles.paginationContainer}>
|
||||
{prefixPaginationContent}
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={limit}
|
||||
total={effectiveTotalCount}
|
||||
onPageChange={(p): void => {
|
||||
setPage(p);
|
||||
}}
|
||||
/>
|
||||
<div className={viewStyles.paginationPageSize}>
|
||||
<ComboboxSimple
|
||||
value={limit?.toString()}
|
||||
defaultValue="10"
|
||||
onChange={(value): void => setLimit(+value)}
|
||||
items={paginationPageSizeItems}
|
||||
/>
|
||||
</div>
|
||||
{suffixPaginationContent}
|
||||
</div>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</TanStackTableStateProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TanStackTableForward = forwardRef(TanStackTableInner) as <TData>(
|
||||
props: TanStackTableProps<TData> & {
|
||||
ref?: React.Ref<TanStackTableHandle>;
|
||||
},
|
||||
) => JSX.Element;
|
||||
|
||||
export const TanStackTableBase = memo(
|
||||
TanStackTableForward,
|
||||
) as typeof TanStackTableForward;
|
||||
@@ -1,21 +0,0 @@
|
||||
.headerSkeleton {
|
||||
width: 60% !important;
|
||||
min-width: 50px !important;
|
||||
height: 16px !important;
|
||||
|
||||
:global(.ant-skeleton-input) {
|
||||
min-width: 50px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cellSkeleton {
|
||||
width: 80% !important;
|
||||
min-width: 40px !important;
|
||||
height: 14px !important;
|
||||
|
||||
:global(.ant-skeleton-input) {
|
||||
min-width: 40px !important;
|
||||
height: 14px !important;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ColumnSizingState } from '@tanstack/react-table';
|
||||
import { Skeleton } from 'antd';
|
||||
|
||||
import { TableColumnDef } from './types';
|
||||
import { getColumnWidthStyle } from './utils';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
import styles from './TanStackTableSkeleton.module.scss';
|
||||
|
||||
type TanStackTableSkeletonProps<TData> = {
|
||||
columns: TableColumnDef<TData>[];
|
||||
rowCount: number;
|
||||
isDarkMode: boolean;
|
||||
columnSizing?: ColumnSizingState;
|
||||
};
|
||||
|
||||
export function TanStackTableSkeleton<TData>({
|
||||
columns,
|
||||
rowCount,
|
||||
isDarkMode,
|
||||
columnSizing,
|
||||
}: TanStackTableSkeletonProps<TData>): JSX.Element {
|
||||
const rows = useMemo(() => Array.from({ length: rowCount }, (_, i) => i), [
|
||||
rowCount,
|
||||
]);
|
||||
|
||||
return (
|
||||
<table className={tableStyles.tanStackTable}>
|
||||
<colgroup>
|
||||
{columns.map((column, index) => (
|
||||
<col
|
||||
key={column.id}
|
||||
style={getColumnWidthStyle(
|
||||
column,
|
||||
columnSizing?.[column.id],
|
||||
index === columns.length - 1,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.id}
|
||||
className={tableStyles.tableHeaderCell}
|
||||
data-dark-mode={isDarkMode}
|
||||
>
|
||||
{typeof column.header === 'function' ? (
|
||||
<Skeleton.Input active size="small" className={styles.headerSkeleton} />
|
||||
) : (
|
||||
column.header
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((rowIndex) => (
|
||||
<tr key={rowIndex} className={tableStyles.tableRow}>
|
||||
{columns.map((column) => (
|
||||
<td key={column.id} className={tableStyles.tableCell}>
|
||||
<Skeleton.Input active size="small" className={styles.cellSkeleton} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
/* eslint-disable no-restricted-imports */
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
/* eslint-enable no-restricted-imports */
|
||||
import { VisibilityState } from '@tanstack/react-table';
|
||||
import { createStore, StoreApi, useStore } from 'zustand';
|
||||
|
||||
const CLEAR_HOVER_DELAY_MS = 100;
|
||||
|
||||
type TanStackTableState = {
|
||||
hoveredRowId: string | null;
|
||||
clearTimeoutId: ReturnType<typeof setTimeout> | null;
|
||||
setHoveredRowId: (id: string | null) => void;
|
||||
scheduleClearHover: (rowId: string) => void;
|
||||
isLoading: boolean;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
isInfiniteScrollMode: boolean;
|
||||
setIsInfiniteScrollMode: (enabled: boolean) => void;
|
||||
columnVisibility: VisibilityState;
|
||||
setColumnVisibility: (visibility: VisibilityState) => void;
|
||||
};
|
||||
|
||||
const createTableStateStore = (): StoreApi<TanStackTableState> =>
|
||||
createStore<TanStackTableState>((set, get) => ({
|
||||
hoveredRowId: null,
|
||||
clearTimeoutId: null,
|
||||
setHoveredRowId: (id: string | null): void => {
|
||||
const { clearTimeoutId } = get();
|
||||
if (clearTimeoutId) {
|
||||
clearTimeout(clearTimeoutId);
|
||||
set({ clearTimeoutId: null });
|
||||
}
|
||||
set({ hoveredRowId: id });
|
||||
},
|
||||
scheduleClearHover: (rowId: string): void => {
|
||||
const { clearTimeoutId } = get();
|
||||
if (clearTimeoutId) {
|
||||
clearTimeout(clearTimeoutId);
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
const current = get().hoveredRowId;
|
||||
if (current === rowId) {
|
||||
set({ hoveredRowId: null, clearTimeoutId: null });
|
||||
}
|
||||
}, CLEAR_HOVER_DELAY_MS);
|
||||
set({ clearTimeoutId: timeoutId });
|
||||
},
|
||||
isLoading: false,
|
||||
setIsLoading: (loading: boolean): void => {
|
||||
set({ isLoading: loading });
|
||||
},
|
||||
isInfiniteScrollMode: false,
|
||||
setIsInfiniteScrollMode: (enabled: boolean): void => {
|
||||
set({ isInfiniteScrollMode: enabled });
|
||||
},
|
||||
columnVisibility: {},
|
||||
setColumnVisibility: (visibility: VisibilityState): void => {
|
||||
set({ columnVisibility: visibility });
|
||||
},
|
||||
}));
|
||||
|
||||
type TableStateStore = StoreApi<TanStackTableState>;
|
||||
|
||||
const TanStackTableStateContext = createContext<TableStateStore | null>(null);
|
||||
|
||||
export function TanStackTableStateProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
const storeRef = useRef<TableStateStore | null>(null);
|
||||
if (!storeRef.current) {
|
||||
storeRef.current = createTableStateStore();
|
||||
}
|
||||
return (
|
||||
<TanStackTableStateContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</TanStackTableStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultStore = createTableStateStore();
|
||||
|
||||
export const useIsRowHovered = (rowId: string): boolean => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
const isHovered = useStore(
|
||||
store ?? defaultStore,
|
||||
(s) => s.hoveredRowId === rowId,
|
||||
);
|
||||
return store ? isHovered : false;
|
||||
};
|
||||
|
||||
export const useSetRowHovered = (rowId: string): (() => void) => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
return useCallback(() => {
|
||||
if (store) {
|
||||
const current = store.getState().hoveredRowId;
|
||||
if (current !== rowId) {
|
||||
store.getState().setHoveredRowId(rowId);
|
||||
}
|
||||
}
|
||||
}, [store, rowId]);
|
||||
};
|
||||
|
||||
export const useClearRowHovered = (rowId: string): (() => void) => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
return useCallback(() => {
|
||||
if (store) {
|
||||
store.getState().scheduleClearHover(rowId);
|
||||
}
|
||||
}, [store, rowId]);
|
||||
};
|
||||
|
||||
export const useIsTableLoading = (): boolean => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
return useStore(store ?? defaultStore, (s) => s.isLoading);
|
||||
};
|
||||
|
||||
export const useSetTableLoading = (): ((loading: boolean) => void) => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
return useCallback(
|
||||
(loading: boolean) => {
|
||||
if (store) {
|
||||
store.getState().setIsLoading(loading);
|
||||
}
|
||||
},
|
||||
[store],
|
||||
);
|
||||
};
|
||||
|
||||
export function TableLoadingSync({
|
||||
isLoading,
|
||||
isInfiniteScrollMode,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
isInfiniteScrollMode: boolean;
|
||||
}): null {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
|
||||
// Sync on mount and when props change
|
||||
useEffect(() => {
|
||||
if (store) {
|
||||
store.getState().setIsLoading(isLoading);
|
||||
store.getState().setIsInfiniteScrollMode(isInfiniteScrollMode);
|
||||
}
|
||||
}, [isLoading, isInfiniteScrollMode, store]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const useShouldShowCellSkeleton = (): boolean => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
return useStore(
|
||||
store ?? defaultStore,
|
||||
(s) => s.isLoading && !s.isInfiniteScrollMode,
|
||||
);
|
||||
};
|
||||
|
||||
export const useColumnVisibility = (): VisibilityState => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
return useStore(store ?? defaultStore, (s) => s.columnVisibility);
|
||||
};
|
||||
|
||||
export const useIsColumnVisible = (columnId: string): boolean => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
return useStore(
|
||||
store ?? defaultStore,
|
||||
(s) => s.columnVisibility[columnId] !== false,
|
||||
);
|
||||
};
|
||||
|
||||
export const useSetColumnVisibility = (): ((
|
||||
visibility: VisibilityState,
|
||||
) => void) => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
return useCallback(
|
||||
(visibility: VisibilityState) => {
|
||||
if (store) {
|
||||
store.getState().setColumnVisibility(visibility);
|
||||
}
|
||||
},
|
||||
[store],
|
||||
);
|
||||
};
|
||||
|
||||
export function ColumnVisibilitySync({
|
||||
visibility,
|
||||
}: {
|
||||
visibility: VisibilityState;
|
||||
}): null {
|
||||
const setVisibility = useSetColumnVisibility();
|
||||
|
||||
useEffect(() => {
|
||||
setVisibility(visibility);
|
||||
}, [visibility, setVisibility]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default TanStackTableStateContext;
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
|
||||
type BaseProps = Omit<
|
||||
HTMLAttributes<HTMLSpanElement>,
|
||||
'children' | 'dangerouslySetInnerHTML'
|
||||
> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type WithChildren = BaseProps & {
|
||||
children: ReactNode;
|
||||
dangerouslySetInnerHTML?: never;
|
||||
};
|
||||
|
||||
type WithDangerousHtml = BaseProps & {
|
||||
children?: never;
|
||||
dangerouslySetInnerHTML: { __html: string };
|
||||
};
|
||||
|
||||
export type TanStackTableTextProps = WithChildren | WithDangerousHtml;
|
||||
|
||||
function TanStackTableText({
|
||||
children,
|
||||
className,
|
||||
dangerouslySetInnerHTML,
|
||||
...rest
|
||||
}: TanStackTableTextProps): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={cx(tableStyles.tableCellText, className)}
|
||||
dangerouslySetInnerHTML={dangerouslySetInnerHTML}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default TanStackTableText;
|
||||
@@ -1,152 +0,0 @@
|
||||
.tanstackTableViewWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tanstackFixedCol {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
max-width: 32px;
|
||||
}
|
||||
|
||||
.tanstackFillerCol {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tanstackActionsCol {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
max-width: 0;
|
||||
}
|
||||
|
||||
.tanstackLoadMoreContainer {
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 0 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tanstackTableVirtuoso {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tanstackTableFootLoaderCell {
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.tanstackTableVirtuosoScroll {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--bg-slate-300) transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-300);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-slate-200);
|
||||
}
|
||||
|
||||
&.cellTypographySmall {
|
||||
--tanstack-plain-cell-font-size: 11px;
|
||||
--tanstack-plain-cell-line-height: 16px;
|
||||
|
||||
:global(table tr td),
|
||||
:global(table thead th) {
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
&.cellTypographyMedium {
|
||||
--tanstack-plain-cell-font-size: 13px;
|
||||
--tanstack-plain-cell-line-height: 20px;
|
||||
|
||||
:global(table tr td),
|
||||
:global(table thead th) {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
&.cellTypographyLarge {
|
||||
--tanstack-plain-cell-font-size: 14px;
|
||||
--tanstack-plain-cell-line-height: 24px;
|
||||
|
||||
:global(table tr td),
|
||||
:global(table thead th) {
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.paginationContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.paginationPageSize {
|
||||
width: 80px;
|
||||
--combobox-trigger-height: 2rem;
|
||||
}
|
||||
|
||||
.tanstackLoadingOverlay {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 2rem;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--l1-background);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
:global(.lightMode) .tanstackTableVirtuosoScroll {
|
||||
scrollbar-color: var(--bg-vanilla-300) transparent;
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
|
||||
import type { TableColumnDef } from './types';
|
||||
import { getColumnWidthStyle } from './utils';
|
||||
|
||||
export function VirtuosoTableColGroup<TData>({
|
||||
columns,
|
||||
table,
|
||||
}: {
|
||||
columns: TableColumnDef<TData>[];
|
||||
table: Table<TData>;
|
||||
}): JSX.Element {
|
||||
const visibleTanstackColumns = table.getVisibleFlatColumns();
|
||||
const columnDefsById = new Map(columns.map((c) => [c.id, c]));
|
||||
const columnSizing = table.getState().columnSizing;
|
||||
|
||||
return (
|
||||
<colgroup>
|
||||
{visibleTanstackColumns.map((tanstackCol, index) => {
|
||||
const colDef = columnDefsById.get(tanstackCol.id);
|
||||
if (!colDef) {
|
||||
return <col key={tanstackCol.id} />;
|
||||
}
|
||||
const persistedWidth = columnSizing[tanstackCol.id];
|
||||
const isLastColumn = index === visibleTanstackColumns.length - 1;
|
||||
return (
|
||||
<col
|
||||
key={tanstackCol.id}
|
||||
style={getColumnWidthStyle(colDef, persistedWidth, isLastColumn)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</colgroup>
|
||||
);
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
jest.mock('../TanStackTable.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
tableRow: 'tableRow',
|
||||
tableRowActive: 'tableRowActive',
|
||||
tableRowExpansion: 'tableRowExpansion',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../TanStackRow', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<td data-testid="mocked-row-cells">mocked cells</td>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockSetRowHovered = jest.fn();
|
||||
const mockClearRowHovered = jest.fn();
|
||||
|
||||
jest.mock('../TanStackTableStateContext', () => ({
|
||||
useSetRowHovered: (_rowId: string): (() => void) => mockSetRowHovered,
|
||||
useClearRowHovered: (_rowId: string): (() => void) => mockClearRowHovered,
|
||||
}));
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import TanStackCustomTableRow from '../TanStackCustomTableRow';
|
||||
import type { FlatItem, TableRowContext } from '../types';
|
||||
|
||||
const makeItem = (id: string): FlatItem<{ id: string }> => ({
|
||||
kind: 'row',
|
||||
row: { original: { id }, id } as never,
|
||||
});
|
||||
|
||||
const virtuosoAttrs = {
|
||||
'data-index': 0,
|
||||
'data-item-index': 0,
|
||||
'data-known-size': 40,
|
||||
} as const;
|
||||
|
||||
const baseContext: TableRowContext<{ id: string }> = {
|
||||
colCount: 1,
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: 'col1',
|
||||
columnVisibilityKey: 'col1',
|
||||
};
|
||||
|
||||
describe('TanStackCustomTableRow', () => {
|
||||
beforeEach(() => {
|
||||
mockSetRowHovered.mockClear();
|
||||
mockClearRowHovered.mockClear();
|
||||
});
|
||||
|
||||
it('renders cells via TanStackRowCells', async () => {
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={baseContext}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(await screen.findByTestId('mocked-row-cells')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies active class when isRowActive returns true', () => {
|
||||
const ctx: TableRowContext<{ id: string }> = {
|
||||
...baseContext,
|
||||
isRowActive: (row) => row.id === '1',
|
||||
};
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={ctx}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(container.querySelector('tr')).toHaveClass('tableRowActive');
|
||||
});
|
||||
|
||||
it('does not apply active class when isRowActive returns false', () => {
|
||||
const ctx: TableRowContext<{ id: string }> = {
|
||||
...baseContext,
|
||||
isRowActive: (row) => row.id === 'other',
|
||||
};
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={ctx}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(container.querySelector('tr')).not.toHaveClass('tableRowActive');
|
||||
});
|
||||
|
||||
it('renders expansion row with expansion class', () => {
|
||||
const item: FlatItem<{ id: string }> = {
|
||||
kind: 'expansion',
|
||||
row: { original: { id: '1' }, id: '1' } as never,
|
||||
};
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={item}
|
||||
context={baseContext}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(container.querySelector('tr')).toHaveClass('tableRowExpansion');
|
||||
});
|
||||
|
||||
describe('hover state management', () => {
|
||||
it('calls setRowHovered on mouse enter', () => {
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={baseContext}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
const row = container.querySelector('tr')!;
|
||||
fireEvent.mouseEnter(row);
|
||||
expect(mockSetRowHovered).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls clearRowHovered on mouse leave', () => {
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={baseContext}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
const row = container.querySelector('tr')!;
|
||||
fireEvent.mouseLeave(row);
|
||||
expect(mockClearRowHovered).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('virtuoso integration', () => {
|
||||
it('forwards data-index attribute to tr element', () => {
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={baseContext}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
const row = container.querySelector('tr')!;
|
||||
expect(row).toHaveAttribute('data-index', '0');
|
||||
});
|
||||
|
||||
it('forwards data-item-index attribute to tr element', () => {
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={baseContext}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
const row = container.querySelector('tr')!;
|
||||
expect(row).toHaveAttribute('data-item-index', '0');
|
||||
});
|
||||
|
||||
it('forwards data-known-size attribute to tr element', () => {
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={baseContext}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
const row = container.querySelector('tr')!;
|
||||
expect(row).toHaveAttribute('data-known-size', '40');
|
||||
});
|
||||
});
|
||||
|
||||
describe('row interaction', () => {
|
||||
it('applies custom style from getRowStyle in context', () => {
|
||||
const ctx: TableRowContext<{ id: string }> = {
|
||||
...baseContext,
|
||||
getRowStyle: () => ({ backgroundColor: 'red' }),
|
||||
};
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={ctx}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
const row = container.querySelector('tr')!;
|
||||
expect(row).toHaveStyle({ backgroundColor: 'red' });
|
||||
});
|
||||
|
||||
it('applies custom className from getRowClassName in context', () => {
|
||||
const ctx: TableRowContext<{ id: string }> = {
|
||||
...baseContext,
|
||||
getRowClassName: () => 'custom-row-class',
|
||||
};
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={ctx}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
const row = container.querySelector('tr')!;
|
||||
expect(row).toHaveClass('custom-row-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,368 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import TanStackHeaderRow from '../TanStackHeaderRow';
|
||||
import type { TableColumnDef } from '../types';
|
||||
|
||||
jest.mock('@dnd-kit/sortable', () => ({
|
||||
useSortable: (): any => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: jest.fn(),
|
||||
setActivatorNodeRef: jest.fn(),
|
||||
transform: null,
|
||||
transition: null,
|
||||
isDragging: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const col = (
|
||||
id: string,
|
||||
overrides?: Partial<TableColumnDef<unknown>>,
|
||||
): TableColumnDef<unknown> => ({
|
||||
id,
|
||||
header: id,
|
||||
cell: (): null => null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const header = {
|
||||
id: 'col',
|
||||
column: {
|
||||
getCanResize: () => true,
|
||||
getIsResizing: () => false,
|
||||
columnDef: { header: 'col' },
|
||||
},
|
||||
getResizeHandler: () => jest.fn(),
|
||||
getContext: () => ({}),
|
||||
} as never;
|
||||
|
||||
describe('TanStackHeaderRow', () => {
|
||||
it('renders column title', () => {
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={col('timestamp', { header: 'timestamp' })}
|
||||
header={header}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
expect(screen.getByTitle('Timestamp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows grip icon when enableMove is not false and pin is not set', () => {
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={col('body')}
|
||||
header={header}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /drag body/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT show grip icon when pin is set', () => {
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={col('indicator', { pin: 'left' })}
|
||||
header={header}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /drag/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows remove button when enableRemove and canRemoveColumn are true', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRemoveColumn = jest.fn();
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={col('name', { enableRemove: true })}
|
||||
header={header}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
canRemoveColumn
|
||||
onRemoveColumn={onRemoveColumn}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /column actions/i }));
|
||||
await user.click(await screen.findByText(/remove column/i));
|
||||
expect(onRemoveColumn).toHaveBeenCalledWith('name');
|
||||
});
|
||||
|
||||
it('does NOT show remove button when enableRemove is absent', () => {
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={col('name')}
|
||||
header={header}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
canRemoveColumn
|
||||
onRemoveColumn={jest.fn()}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /column actions/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('sorting', () => {
|
||||
const sortableCol = col('sortable', { enableSort: true, header: 'Sortable' });
|
||||
const sortableHeader = {
|
||||
id: 'sortable',
|
||||
column: {
|
||||
id: 'sortable',
|
||||
getCanResize: (): boolean => true,
|
||||
getIsResizing: (): boolean => false,
|
||||
columnDef: { header: 'Sortable', enableSort: true },
|
||||
},
|
||||
getResizeHandler: (): jest.Mock => jest.fn(),
|
||||
getContext: (): Record<string, unknown> => ({}),
|
||||
} as never;
|
||||
|
||||
it('calls onSort with asc when clicking unsorted column', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSort = jest.fn();
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={sortableCol}
|
||||
header={sortableHeader}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
onSort={onSort}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
// Sort button uses the column header as title
|
||||
const sortButton = screen.getByTitle('Sortable');
|
||||
await user.click(sortButton);
|
||||
expect(onSort).toHaveBeenCalledWith({
|
||||
columnName: 'sortable',
|
||||
order: 'asc',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSort with desc when clicking asc-sorted column', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSort = jest.fn();
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={sortableCol}
|
||||
header={sortableHeader}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
onSort={onSort}
|
||||
orderBy={{ columnName: 'sortable', order: 'asc' }}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
const sortButton = screen.getByTitle('Sortable');
|
||||
await user.click(sortButton);
|
||||
expect(onSort).toHaveBeenCalledWith({
|
||||
columnName: 'sortable',
|
||||
order: 'desc',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSort with null when clicking desc-sorted column', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSort = jest.fn();
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={sortableCol}
|
||||
header={sortableHeader}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
onSort={onSort}
|
||||
orderBy={{ columnName: 'sortable', order: 'desc' }}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
const sortButton = screen.getByTitle('Sortable');
|
||||
await user.click(sortButton);
|
||||
expect(onSort).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('shows ascending indicator when orderBy matches column with asc', () => {
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={sortableCol}
|
||||
header={sortableHeader}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
onSort={jest.fn()}
|
||||
orderBy={{ columnName: 'sortable', order: 'asc' }}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
const sortButton = screen.getByTitle('Sortable');
|
||||
expect(sortButton).toHaveAttribute('aria-sort', 'ascending');
|
||||
});
|
||||
|
||||
it('shows descending indicator when orderBy matches column with desc', () => {
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={sortableCol}
|
||||
header={sortableHeader}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
onSort={jest.fn()}
|
||||
orderBy={{ columnName: 'sortable', order: 'desc' }}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
const sortButton = screen.getByTitle('Sortable');
|
||||
expect(sortButton).toHaveAttribute('aria-sort', 'descending');
|
||||
});
|
||||
|
||||
it('does not show sort button when enableSort is false', () => {
|
||||
const nonSortableCol = col('nonsort', {
|
||||
enableSort: false,
|
||||
header: 'Nonsort',
|
||||
});
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={nonSortableCol}
|
||||
header={header}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
// When enableSort is false, the header text is rendered as a span, not a button
|
||||
// The title 'Nonsort' exists on the span, not on a button
|
||||
const titleElement = screen.getByTitle('Nonsort');
|
||||
expect(titleElement.tagName.toLowerCase()).not.toBe('button');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resizing', () => {
|
||||
it('shows resize handle when enableResize is not false', () => {
|
||||
const resizableCol = col('resizable', { enableResize: true });
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={resizableCol}
|
||||
header={header}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
// Resize handle has title "Drag to resize column"
|
||||
expect(screen.getByTitle('Drag to resize column')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides resize handle when enableResize is false', () => {
|
||||
const nonResizableCol = col('noresize', { enableResize: false });
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={nonResizableCol}
|
||||
header={header}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
expect(screen.queryByTitle('Drag to resize column')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('column movement', () => {
|
||||
it('does not show grip when enableMove is false', () => {
|
||||
const noMoveCol = col('nomove', { enableMove: false });
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={noMoveCol}
|
||||
header={header}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /drag/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,288 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import TanStackRowCells from '../TanStackRow';
|
||||
import type { TableRowContext } from '../types';
|
||||
|
||||
const flexRenderMock = jest.fn((def: unknown) =>
|
||||
typeof def === 'function' ? def({}) : def,
|
||||
);
|
||||
jest.mock('@tanstack/react-table', () => ({
|
||||
flexRender: (def: unknown, _ctx?: unknown): unknown => flexRenderMock(def),
|
||||
}));
|
||||
|
||||
type Row = { id: string };
|
||||
|
||||
function buildMockRow(
|
||||
cells: { id: string }[],
|
||||
rowData: Row = { id: 'r1' },
|
||||
): Parameters<typeof TanStackRowCells>[0]['row'] {
|
||||
return {
|
||||
original: rowData,
|
||||
getVisibleCells: () =>
|
||||
cells.map((c, i) => ({
|
||||
id: `cell-${i}`,
|
||||
column: {
|
||||
id: c.id,
|
||||
columnDef: { cell: (): string => `content-${c.id}` },
|
||||
},
|
||||
getContext: (): Record<string, unknown> => ({}),
|
||||
getValue: (): string => `content-${c.id}`,
|
||||
})),
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe('TanStackRowCells', () => {
|
||||
beforeEach(() => flexRenderMock.mockClear());
|
||||
|
||||
it('renders a cell per visible column', () => {
|
||||
const row = buildMockRow([{ id: 'col-a' }, { id: 'col-b' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={undefined}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(screen.getAllByRole('cell')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('calls onRowClick when a cell is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
// onRowClick receives (rowData, itemKey) - itemKey is empty when getRowKeyData not provided
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
});
|
||||
|
||||
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const onRowDeactivate = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowDeactivate,
|
||||
isRowActive: () => true,
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowDeactivate).toHaveBeenCalled();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not render renderRowActions before hover', () => {
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
renderRowActions: () => <button type="button">action</button>,
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
// Row actions are not rendered until hover (useIsRowHovered returns false by default)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'action' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders expansion cell with renderExpandedRow content', async () => {
|
||||
const row = {
|
||||
original: { id: 'r1' },
|
||||
getVisibleCells: () => [],
|
||||
} as never;
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 3,
|
||||
renderExpandedRow: (r) => <div>expanded-{r.id}</div>,
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="expansion"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(await screen.findByText('expanded-r1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('new tab click', () => {
|
||||
it('calls onRowClickNewTab on ctrl+click', () => {
|
||||
const onRowClick = jest.fn();
|
||||
const onRowClickNewTab = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
fireEvent.click(screen.getAllByRole('cell')[0], { ctrlKey: true });
|
||||
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onRowClickNewTab on meta+click (cmd)', () => {
|
||||
const onRowClick = jest.fn();
|
||||
const onRowClickNewTab = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
fireEvent.click(screen.getAllByRole('cell')[0], { metaKey: true });
|
||||
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onRowClick when modifier key is pressed', () => {
|
||||
const onRowClick = jest.fn();
|
||||
const onRowClickNewTab = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
fireEvent.click(screen.getAllByRole('cell')[0], { ctrlKey: true });
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,440 +0,0 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
|
||||
|
||||
import { renderTanStackTable } from './testUtils';
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('../TanStackTable.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
tanStackTable: 'tanStackTable',
|
||||
tableRow: 'tableRow',
|
||||
tableRowActive: 'tableRowActive',
|
||||
tableRowExpansion: 'tableRowExpansion',
|
||||
tableCell: 'tableCell',
|
||||
tableCellExpansion: 'tableCellExpansion',
|
||||
tableHeaderCell: 'tableHeaderCell',
|
||||
tableCellText: 'tableCellText',
|
||||
tableViewRowActions: 'tableViewRowActions',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('TanStackTableView Integration', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders all data rows', async () => {
|
||||
renderTanStackTable({});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders column headers', async () => {
|
||||
renderTanStackTable({});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ID')).toBeInTheDocument();
|
||||
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders empty state when data is empty and not loading', async () => {
|
||||
renderTanStackTable({
|
||||
props: { data: [], isLoading: false },
|
||||
});
|
||||
// Table should still render but with no data rows
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Item 1')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders table structure when loading with no previous data', async () => {
|
||||
renderTanStackTable({
|
||||
props: { data: [], isLoading: true },
|
||||
});
|
||||
// Table should render with skeleton rows
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading states', () => {
|
||||
it('keeps table mounted when loading with no data', () => {
|
||||
renderTanStackTable({
|
||||
props: { data: [], isLoading: true },
|
||||
});
|
||||
// Table should still be in the DOM for skeleton rows
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading spinner for infinite scroll when loading', () => {
|
||||
renderTanStackTable({
|
||||
props: { isLoading: true, onEndReached: jest.fn() },
|
||||
});
|
||||
expect(screen.getByTestId('tanstack-infinite-loader')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show loading spinner for infinite scroll when not loading', () => {
|
||||
renderTanStackTable({
|
||||
props: { isLoading: false, onEndReached: jest.fn() },
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId('tanstack-infinite-loader'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show loading spinner when not in infinite scroll mode', () => {
|
||||
renderTanStackTable({
|
||||
props: { isLoading: true },
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId('tanstack-infinite-loader'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
it('renders pagination when pagination prop is provided', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
|
||||
},
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Look for pagination navigation or page number text
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates page when clicking page number', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
|
||||
enableQueryParams: true,
|
||||
},
|
||||
onUrlUpdate,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find page 2 button/link within pagination navigation
|
||||
const nav = screen.getByRole('navigation');
|
||||
const page2 = Array.from(nav.querySelectorAll('button')).find(
|
||||
(btn) => btn.textContent?.trim() === '2',
|
||||
);
|
||||
if (!page2) {
|
||||
throw new Error('Page 2 button not found in pagination');
|
||||
}
|
||||
await user.click(page2);
|
||||
|
||||
await waitFor(() => {
|
||||
const lastPage = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('page'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastPage).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render pagination in infinite scroll mode', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: { total: 100 },
|
||||
onEndReached: jest.fn(), // This enables infinite scroll mode
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Pagination should not be visible in infinite scroll mode
|
||||
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders prefixPaginationContent before pagination', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: { total: 100 },
|
||||
prefixPaginationContent: <span data-testid="prefix-content">Prefix</span>,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('prefix-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders suffixPaginationContent after pagination', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: { total: 100 },
|
||||
suffixPaginationContent: <span data-testid="suffix-content">Suffix</span>,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('suffix-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sorting', () => {
|
||||
it('updates orderBy URL param when clicking sortable header', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
renderTanStackTable({
|
||||
props: { enableQueryParams: true },
|
||||
onUrlUpdate,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the sortable column header's sort button (ID column has enableSort: true)
|
||||
const sortButton = screen.getByTitle('ID');
|
||||
await user.click(sortButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const lastOrderBy = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('order_by'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastOrderBy).toBeDefined();
|
||||
const parsed = JSON.parse(lastOrderBy!);
|
||||
expect(parsed.columnName).toBe('id');
|
||||
expect(parsed.order).toBe('asc');
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles sort order on subsequent clicks', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
renderTanStackTable({
|
||||
props: { enableQueryParams: true },
|
||||
onUrlUpdate,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const sortButton = screen.getByTitle('ID');
|
||||
|
||||
// First click - asc
|
||||
await user.click(sortButton);
|
||||
// Second click - desc
|
||||
await user.click(sortButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const lastOrderBy = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('order_by'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
if (lastOrderBy) {
|
||||
const parsed = JSON.parse(lastOrderBy);
|
||||
expect(parsed.order).toBe('desc');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('row selection', () => {
|
||||
it('calls onRowClick with row data and itemKey', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
onRowClick,
|
||||
getRowKey: (row) => row.id,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Item 1'));
|
||||
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||
'1',
|
||||
);
|
||||
});
|
||||
|
||||
it('applies active class when isRowActive returns true', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
isRowActive: (row) => row.id === '1',
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the row containing Item 1 and check for active class
|
||||
const cell = screen.getByText('Item 1');
|
||||
const row = cell.closest('tr');
|
||||
expect(row).toHaveClass('tableRowActive');
|
||||
});
|
||||
|
||||
it('calls onRowDeactivate when clicking active row', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const onRowDeactivate = jest.fn();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
onRowClick,
|
||||
onRowDeactivate,
|
||||
isRowActive: (row) => row.id === '1',
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Item 1'));
|
||||
|
||||
expect(onRowDeactivate).toHaveBeenCalled();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens in new tab on ctrl+click', async () => {
|
||||
const onRowClick = jest.fn();
|
||||
const onRowClickNewTab = jest.fn();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
getRowKey: (row) => row.id,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Item 1'), { ctrlKey: true });
|
||||
|
||||
expect(onRowClickNewTab).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1' }),
|
||||
'1',
|
||||
);
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens in new tab on meta+click', async () => {
|
||||
const onRowClick = jest.fn();
|
||||
const onRowClickNewTab = jest.fn();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
getRowKey: (row) => row.id,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Item 1'), { metaKey: true });
|
||||
|
||||
expect(onRowClickNewTab).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1' }),
|
||||
'1',
|
||||
);
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('row expansion', () => {
|
||||
it('renders expanded content below the row when expanded', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
renderExpandedRow: (row) => (
|
||||
<div data-testid="expanded-content">Expanded: {row.name}</div>
|
||||
),
|
||||
getRowCanExpand: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click expand button (if available in the row)
|
||||
// The expansion is controlled by TanStack Table's expanded state
|
||||
// For now, just verify the renderExpandedRow prop is wired correctly
|
||||
// by checking the table renders without errors
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('infinite scroll', () => {
|
||||
it('calls onEndReached when provided', async () => {
|
||||
const onEndReached = jest.fn();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
onEndReached,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Virtuoso will call onEndReached based on scroll position
|
||||
// In mock context, we verify the prop is wired correctly
|
||||
expect(onEndReached).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows loading spinner at bottom when loading in infinite scroll mode', () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
isLoading: true,
|
||||
onEndReached: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('tanstack-infinite-loader')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides pagination in infinite scroll mode', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: { total: 100 },
|
||||
onEndReached: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// When onEndReached is provided, pagination should not render
|
||||
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { TooltipProvider } from '@signozhq/ui';
|
||||
import { render, RenderResult } from '@testing-library/react';
|
||||
import { NuqsTestingAdapter, OnUrlUpdateFunction } from 'nuqs/adapters/testing';
|
||||
|
||||
import TanStackTable from '../index';
|
||||
import type { TableColumnDef, TanStackTableProps } from '../types';
|
||||
|
||||
// NOTE: Test files importing this utility must add this mock at the top of their file:
|
||||
// jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
|
||||
|
||||
// Default test data types
|
||||
export type TestRow = { id: string; name: string; value: number };
|
||||
|
||||
export const defaultColumns: TableColumnDef<TestRow>[] = [
|
||||
{
|
||||
id: 'id',
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
enableSort: true,
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
{
|
||||
id: 'value',
|
||||
header: 'Value',
|
||||
accessorKey: 'value',
|
||||
enableSort: true,
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultData: TestRow[] = [
|
||||
{ id: '1', name: 'Item 1', value: 100 },
|
||||
{ id: '2', name: 'Item 2', value: 200 },
|
||||
{ id: '3', name: 'Item 3', value: 300 },
|
||||
];
|
||||
|
||||
export type RenderTanStackTableOptions<T> = {
|
||||
props?: Partial<TanStackTableProps<T>>;
|
||||
queryParams?: Record<string, string>;
|
||||
onUrlUpdate?: OnUrlUpdateFunction;
|
||||
};
|
||||
|
||||
export function renderTanStackTable<T = TestRow>(
|
||||
options: RenderTanStackTableOptions<T> = {},
|
||||
): RenderResult {
|
||||
const { props = {}, queryParams, onUrlUpdate } = options;
|
||||
|
||||
const mergedProps = {
|
||||
data: (defaultData as unknown) as T[],
|
||||
columns: (defaultColumns as unknown) as TableColumnDef<T>[],
|
||||
...props,
|
||||
} as TanStackTableProps<T>;
|
||||
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={queryParams} onUrlUpdate={onUrlUpdate}>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 500, itemHeight: 50 }}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<TanStackTable<T> {...mergedProps} />
|
||||
</TooltipProvider>
|
||||
</VirtuosoMockContext.Provider>
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to wrap any component with test providers (for unit tests)
|
||||
export function renderWithProviders(
|
||||
ui: ReactNode,
|
||||
options: {
|
||||
queryParams?: Record<string, string>;
|
||||
onUrlUpdate?: OnUrlUpdateFunction;
|
||||
} = {},
|
||||
): RenderResult {
|
||||
const { queryParams, onUrlUpdate } = options;
|
||||
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={queryParams} onUrlUpdate={onUrlUpdate}>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 500, itemHeight: 50 }}
|
||||
>
|
||||
<TooltipProvider>{ui}</TooltipProvider>
|
||||
</VirtuosoMockContext.Provider>
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { TableColumnDef } from '../types';
|
||||
import { useColumnState } from '../useColumnState';
|
||||
import { useColumnStore } from '../useColumnStore';
|
||||
|
||||
const TEST_KEY = 'test-state';
|
||||
|
||||
type TestRow = { id: string; name: string };
|
||||
|
||||
const col = (
|
||||
id: string,
|
||||
overrides: Partial<TableColumnDef<TestRow>> = {},
|
||||
): TableColumnDef<TestRow> => ({
|
||||
id,
|
||||
header: id,
|
||||
cell: (): null => null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('useColumnState', () => {
|
||||
beforeEach(() => {
|
||||
useColumnStore.setState({ tables: {} });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('initializes store from column defaults on mount', () => {
|
||||
const columns = [
|
||||
col('a', { defaultVisibility: true }),
|
||||
col('b', { defaultVisibility: false }),
|
||||
col('c'),
|
||||
];
|
||||
|
||||
renderHook(() => useColumnState({ storageKey: TEST_KEY, columns }));
|
||||
|
||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(state.hiddenColumnIds).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('does not initialize without storageKey', () => {
|
||||
const columns = [col('a', { defaultVisibility: false })];
|
||||
|
||||
renderHook(() => useColumnState({ columns }));
|
||||
|
||||
expect(useColumnStore.getState().tables[TEST_KEY]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('columnVisibility', () => {
|
||||
it('returns visibility state from hidden columns', () => {
|
||||
const columns = [col('a'), col('b'), col('c')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
expect(result.current.columnVisibility).toEqual({ b: false });
|
||||
});
|
||||
|
||||
it('applies visibilityBehavior for grouped state', () => {
|
||||
const columns = [
|
||||
col('ungrouped', { visibilityBehavior: 'hidden-on-expand' }),
|
||||
col('grouped', { visibilityBehavior: 'hidden-on-collapse' }),
|
||||
col('always'),
|
||||
];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
});
|
||||
|
||||
// Not grouped
|
||||
const { result: notGrouped } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: false }),
|
||||
);
|
||||
expect(notGrouped.current.columnVisibility).toEqual({ grouped: false });
|
||||
|
||||
// Grouped
|
||||
const { result: grouped } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: true }),
|
||||
);
|
||||
expect(grouped.current.columnVisibility).toEqual({ ungrouped: false });
|
||||
});
|
||||
|
||||
it('combines store hidden + visibilityBehavior', () => {
|
||||
const columns = [
|
||||
col('a', { visibilityBehavior: 'hidden-on-expand' }),
|
||||
col('b'),
|
||||
];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: true }),
|
||||
);
|
||||
|
||||
expect(result.current.columnVisibility).toEqual({ a: false, b: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortedColumns', () => {
|
||||
it('returns columns in original order when no order set', () => {
|
||||
const columns = [col('a'), col('b'), col('c')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns columns sorted by stored order', () => {
|
||||
const columns = [col('a'), col('b'), col('c')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps pinned columns at the start', () => {
|
||||
const columns = [col('a'), col('pinned', { pin: 'left' }), col('b')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
useColumnStore.getState().setColumnOrder(TEST_KEY, ['b', 'a']);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
|
||||
'pinned',
|
||||
'b',
|
||||
'a',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions', () => {
|
||||
it('hideColumn hides a column', () => {
|
||||
const columns = [col('a'), col('b')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.hideColumn('a');
|
||||
});
|
||||
|
||||
expect(result.current.columnVisibility).toEqual({ a: false });
|
||||
});
|
||||
|
||||
it('showColumn shows a column', () => {
|
||||
const columns = [col('a', { defaultVisibility: false })];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
expect(result.current.columnVisibility).toEqual({ a: false });
|
||||
|
||||
act(() => {
|
||||
result.current.showColumn('a');
|
||||
});
|
||||
|
||||
expect(result.current.columnVisibility).toEqual({});
|
||||
});
|
||||
|
||||
it('setColumnSizing updates sizing', () => {
|
||||
const columns = [col('a')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setColumnSizing({ a: 200 });
|
||||
});
|
||||
|
||||
expect(result.current.columnSizing).toEqual({ a: 200 });
|
||||
});
|
||||
|
||||
it('setColumnOrder updates order from column array', () => {
|
||||
const columns = [col('a'), col('b'), col('c')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setColumnOrder([col('c'), col('a'), col('b')]);
|
||||
});
|
||||
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,296 +0,0 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
useColumnOrder,
|
||||
useColumnSizing,
|
||||
useColumnStore,
|
||||
useHiddenColumnIds,
|
||||
} from '../useColumnStore';
|
||||
|
||||
const TEST_KEY = 'test-table';
|
||||
|
||||
describe('useColumnStore', () => {
|
||||
beforeEach(() => {
|
||||
useColumnStore.getState().tables = {};
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('initializeFromDefaults', () => {
|
||||
it('initializes hidden columns from defaultVisibility: false', () => {
|
||||
const columns = [
|
||||
{ id: 'a', defaultVisibility: true },
|
||||
{ id: 'b', defaultVisibility: false },
|
||||
{ id: 'c' }, // defaults to visible
|
||||
];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns as any);
|
||||
});
|
||||
|
||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(state.hiddenColumnIds).toEqual(['b']);
|
||||
expect(state.columnOrder).toEqual([]);
|
||||
expect(state.columnSizing).toEqual({});
|
||||
});
|
||||
|
||||
it('does not reinitialize if already exists', () => {
|
||||
act(() => {
|
||||
useColumnStore
|
||||
.getState()
|
||||
.initializeFromDefaults(TEST_KEY, [
|
||||
{ id: 'a', defaultVisibility: false },
|
||||
] as any);
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'x');
|
||||
useColumnStore
|
||||
.getState()
|
||||
.initializeFromDefaults(TEST_KEY, [
|
||||
{ id: 'b', defaultVisibility: false },
|
||||
] as any);
|
||||
});
|
||||
|
||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(state.hiddenColumnIds).toContain('a');
|
||||
expect(state.hiddenColumnIds).toContain('x');
|
||||
expect(state.hiddenColumnIds).not.toContain('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hideColumn / showColumn / toggleColumn', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
||||
});
|
||||
});
|
||||
|
||||
it('hideColumn adds to hiddenColumnIds', () => {
|
||||
act(() => {
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
||||
});
|
||||
expect(useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds).toContain(
|
||||
'col1',
|
||||
);
|
||||
});
|
||||
|
||||
it('hideColumn is idempotent', () => {
|
||||
act(() => {
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
||||
});
|
||||
expect(
|
||||
useColumnStore
|
||||
.getState()
|
||||
.tables[TEST_KEY].hiddenColumnIds.filter((id) => id === 'col1'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('showColumn removes from hiddenColumnIds', () => {
|
||||
act(() => {
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
||||
useColumnStore.getState().showColumn(TEST_KEY, 'col1');
|
||||
});
|
||||
expect(
|
||||
useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds,
|
||||
).not.toContain('col1');
|
||||
});
|
||||
|
||||
it('toggleColumn toggles visibility', () => {
|
||||
act(() => {
|
||||
useColumnStore.getState().toggleColumn(TEST_KEY, 'col1');
|
||||
});
|
||||
expect(useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds).toContain(
|
||||
'col1',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().toggleColumn(TEST_KEY, 'col1');
|
||||
});
|
||||
expect(
|
||||
useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds,
|
||||
).not.toContain('col1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setColumnSizing', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates column sizing', () => {
|
||||
act(() => {
|
||||
useColumnStore
|
||||
.getState()
|
||||
.setColumnSizing(TEST_KEY, { col1: 200, col2: 300 });
|
||||
});
|
||||
expect(useColumnStore.getState().tables[TEST_KEY].columnSizing).toEqual({
|
||||
col1: 200,
|
||||
col2: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setColumnOrder', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates column order', () => {
|
||||
act(() => {
|
||||
useColumnStore
|
||||
.getState()
|
||||
.setColumnOrder(TEST_KEY, ['col2', 'col1', 'col3']);
|
||||
});
|
||||
expect(useColumnStore.getState().tables[TEST_KEY].columnOrder).toEqual([
|
||||
'col2',
|
||||
'col1',
|
||||
'col3',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetToDefaults', () => {
|
||||
it('resets to column defaults', () => {
|
||||
const columns = [
|
||||
{ id: 'a', defaultVisibility: false },
|
||||
{ id: 'b', defaultVisibility: true },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns as any);
|
||||
useColumnStore.getState().showColumn(TEST_KEY, 'a');
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
|
||||
useColumnStore.getState().setColumnOrder(TEST_KEY, ['b', 'a']);
|
||||
useColumnStore.getState().setColumnSizing(TEST_KEY, { a: 100 });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().resetToDefaults(TEST_KEY, columns as any);
|
||||
});
|
||||
|
||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(state.hiddenColumnIds).toEqual(['a']);
|
||||
expect(state.columnOrder).toEqual([]);
|
||||
expect(state.columnSizing).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupStaleHiddenColumns', () => {
|
||||
it('removes hidden column IDs that are not in validColumnIds', () => {
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col2');
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col3');
|
||||
});
|
||||
|
||||
// Only col1 and col3 are valid now
|
||||
act(() => {
|
||||
useColumnStore
|
||||
.getState()
|
||||
.cleanupStaleHiddenColumns(TEST_KEY, new Set(['col1', 'col3']));
|
||||
});
|
||||
|
||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(state.hiddenColumnIds).toEqual(['col1', 'col3']);
|
||||
expect(state.hiddenColumnIds).not.toContain('col2');
|
||||
});
|
||||
|
||||
it('does nothing when all hidden columns are valid', () => {
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col2');
|
||||
});
|
||||
|
||||
const stateBefore = useColumnStore.getState().tables[TEST_KEY];
|
||||
const hiddenBefore = [...stateBefore.hiddenColumnIds];
|
||||
|
||||
act(() => {
|
||||
useColumnStore
|
||||
.getState()
|
||||
.cleanupStaleHiddenColumns(TEST_KEY, new Set(['col1', 'col2', 'col3']));
|
||||
});
|
||||
|
||||
const stateAfter = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(stateAfter.hiddenColumnIds).toEqual(hiddenBefore);
|
||||
});
|
||||
|
||||
it('does nothing for unknown storage key', () => {
|
||||
act(() => {
|
||||
useColumnStore
|
||||
.getState()
|
||||
.cleanupStaleHiddenColumns('unknown-key', new Set(['col1']));
|
||||
});
|
||||
|
||||
// Should not throw or create state
|
||||
expect(useColumnStore.getState().tables['unknown-key']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selector hooks', () => {
|
||||
it('useHiddenColumnIds returns hidden columns', () => {
|
||||
act(() => {
|
||||
useColumnStore
|
||||
.getState()
|
||||
.initializeFromDefaults(TEST_KEY, [
|
||||
{ id: 'a', defaultVisibility: false },
|
||||
] as any);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useHiddenColumnIds(TEST_KEY));
|
||||
expect(result.current).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('useHiddenColumnIds returns a stable snapshot for persisted state', () => {
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/test-table',
|
||||
JSON.stringify({
|
||||
hiddenColumnIds: ['persisted'],
|
||||
columnOrder: [],
|
||||
columnSizing: {},
|
||||
}),
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(() => useHiddenColumnIds(TEST_KEY));
|
||||
const firstSnapshot = result.current;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current).toBe(firstSnapshot);
|
||||
});
|
||||
|
||||
it('useColumnSizing returns sizing', () => {
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
||||
useColumnStore.getState().setColumnSizing(TEST_KEY, { col1: 150 });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useColumnSizing(TEST_KEY));
|
||||
expect(result.current).toEqual({ col1: 150 });
|
||||
});
|
||||
|
||||
it('useColumnOrder returns order', () => {
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
||||
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'b', 'a']);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useColumnOrder(TEST_KEY));
|
||||
expect(result.current).toEqual(['c', 'b', 'a']);
|
||||
});
|
||||
|
||||
it('returns empty defaults for unknown storageKey', () => {
|
||||
const { result: hidden } = renderHook(() => useHiddenColumnIds('unknown'));
|
||||
const { result: sizing } = renderHook(() => useColumnSizing('unknown'));
|
||||
const { result: order } = renderHook(() => useColumnOrder('unknown'));
|
||||
|
||||
expect(hidden.current).toEqual([]);
|
||||
expect(sizing.current).toEqual({});
|
||||
expect(order.current).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,239 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import {
|
||||
NuqsTestingAdapter,
|
||||
OnUrlUpdateFunction,
|
||||
UrlUpdateEvent,
|
||||
} from 'nuqs/adapters/testing';
|
||||
|
||||
import { useTableParams } from '../useTableParams';
|
||||
|
||||
function createNuqsWrapper(
|
||||
queryParams?: Record<string, string>,
|
||||
onUrlUpdate?: OnUrlUpdateFunction,
|
||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
return function NuqsWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<NuqsTestingAdapter
|
||||
searchParams={queryParams}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
hasMemory
|
||||
>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe('useTableParams (local mode — enableQueryParams not set)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns default page=1 and limit=50', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(() => useTableParams(), { wrapper });
|
||||
expect(result.current.page).toBe(1);
|
||||
expect(result.current.limit).toBe(50);
|
||||
expect(result.current.orderBy).toBeNull();
|
||||
});
|
||||
|
||||
it('respects custom defaults', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(
|
||||
() => useTableParams(undefined, { page: 2, limit: 25 }),
|
||||
{ wrapper },
|
||||
);
|
||||
expect(result.current.page).toBe(2);
|
||||
expect(result.current.limit).toBe(25);
|
||||
});
|
||||
|
||||
it('setPage updates page', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(() => useTableParams(), { wrapper });
|
||||
act(() => {
|
||||
result.current.setPage(3);
|
||||
});
|
||||
expect(result.current.page).toBe(3);
|
||||
});
|
||||
|
||||
it('setLimit updates limit', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(() => useTableParams(), { wrapper });
|
||||
act(() => {
|
||||
result.current.setLimit(100);
|
||||
});
|
||||
expect(result.current.limit).toBe(100);
|
||||
});
|
||||
|
||||
it('setOrderBy updates orderBy', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(() => useTableParams(), { wrapper });
|
||||
act(() => {
|
||||
result.current.setOrderBy({ columnName: 'cpu', order: 'desc' });
|
||||
});
|
||||
expect(result.current.orderBy).toEqual({ columnName: 'cpu', order: 'desc' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTableParams (URL mode — enableQueryParams set)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses nuqs state when enableQueryParams=true', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
expect(result.current.page).toBe(1);
|
||||
act(() => {
|
||||
result.current.setPage(5);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(result.current.page).toBe(5);
|
||||
});
|
||||
|
||||
it('uses prefixed keys when enableQueryParams is a string', () => {
|
||||
const wrapper = createNuqsWrapper({ pods_page: '2' });
|
||||
const { result } = renderHook(() => useTableParams('pods', { page: 2 }), {
|
||||
wrapper,
|
||||
});
|
||||
expect(result.current.page).toBe(2);
|
||||
act(() => {
|
||||
result.current.setPage(4);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(result.current.page).toBe(4);
|
||||
});
|
||||
|
||||
it('local state is ignored when enableQueryParams is set', () => {
|
||||
const localWrapper = createNuqsWrapper();
|
||||
const urlWrapper = createNuqsWrapper();
|
||||
const { result: local } = renderHook(() => useTableParams(), {
|
||||
wrapper: localWrapper,
|
||||
});
|
||||
const { result: url } = renderHook(() => useTableParams(true), {
|
||||
wrapper: urlWrapper,
|
||||
});
|
||||
act(() => {
|
||||
local.current.setPage(99);
|
||||
});
|
||||
// URL mode hook in a separate wrapper should still have its own state
|
||||
expect(url.current.page).toBe(1);
|
||||
});
|
||||
|
||||
it('reads initial page from URL params', () => {
|
||||
const wrapper = createNuqsWrapper({ page: '3' });
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
expect(result.current.page).toBe(3);
|
||||
});
|
||||
|
||||
it('reads initial orderBy from URL params', () => {
|
||||
const orderBy = JSON.stringify({ columnName: 'name', order: 'desc' });
|
||||
const wrapper = createNuqsWrapper({ order_by: orderBy });
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
expect(result.current.orderBy).toEqual({ columnName: 'name', order: 'desc' });
|
||||
});
|
||||
|
||||
it('updates URL when setPage is called', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.setPage(5);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
const lastPage = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('page'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastPage).toBe('5');
|
||||
});
|
||||
|
||||
it('updates URL when setOrderBy is called', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.setOrderBy({ columnName: 'value', order: 'asc' });
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
const lastOrderBy = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('order_by'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastOrderBy).toBeDefined();
|
||||
expect(JSON.parse(lastOrderBy!)).toEqual({
|
||||
columnName: 'value',
|
||||
order: 'asc',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses custom param names from config object', () => {
|
||||
const config = {
|
||||
page: 'listPage',
|
||||
limit: 'listLimit',
|
||||
orderBy: 'listOrderBy',
|
||||
expanded: 'listExpanded',
|
||||
};
|
||||
const wrapper = createNuqsWrapper({ listPage: '3' });
|
||||
const { result } = renderHook(() => useTableParams(config, { page: 3 }), {
|
||||
wrapper,
|
||||
});
|
||||
expect(result.current.page).toBe(3);
|
||||
});
|
||||
|
||||
it('manages expanded state for row expansion', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.setExpanded({ 'row-1': true });
|
||||
});
|
||||
|
||||
expect(result.current.expanded).toEqual({ 'row-1': true });
|
||||
});
|
||||
|
||||
it('toggles sort order correctly: null → asc → desc → null', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
|
||||
// Initial state
|
||||
expect(result.current.orderBy).toBeNull();
|
||||
|
||||
// First click: null → asc
|
||||
act(() => {
|
||||
result.current.setOrderBy({ columnName: 'id', order: 'asc' });
|
||||
});
|
||||
expect(result.current.orderBy).toEqual({ columnName: 'id', order: 'asc' });
|
||||
|
||||
// Second click: asc → desc
|
||||
act(() => {
|
||||
result.current.setOrderBy({ columnName: 'id', order: 'desc' });
|
||||
});
|
||||
expect(result.current.orderBy).toEqual({ columnName: 'id', order: 'desc' });
|
||||
|
||||
// Third click: desc → null
|
||||
act(() => {
|
||||
result.current.setOrderBy(null);
|
||||
});
|
||||
expect(result.current.orderBy).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
import { TanStackTableBase } from './TanStackTable';
|
||||
import TanStackTableText from './TanStackTableText';
|
||||
|
||||
export * from './TanStackTableStateContext';
|
||||
export * from './types';
|
||||
export * from './useColumnState';
|
||||
export * from './useColumnStore';
|
||||
export * from './useTableParams';
|
||||
|
||||
/**
|
||||
* Virtualized data table built on TanStack Table and `react-virtuoso`: resizable and pinnable columns,
|
||||
* optional drag-to-reorder headers, expandable rows, and pagination or infinite scroll.
|
||||
*
|
||||
* @example Minimal usage
|
||||
* ```tsx
|
||||
* import TanStackTable from 'components/TanStackTableView';
|
||||
* import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
*
|
||||
* type Row = { id: string; name: string };
|
||||
*
|
||||
* const columns: TableColumnDef<Row>[] = [
|
||||
* {
|
||||
* id: 'name',
|
||||
* header: 'Name',
|
||||
* accessorKey: 'name',
|
||||
* cell: ({ value }) => <TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>,
|
||||
* },
|
||||
* ];
|
||||
*
|
||||
* function Example(): JSX.Element {
|
||||
* return <TanStackTable<Row> data={rows} columns={columns} />;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example Column definitions — `accessorFn`, custom header, pinned column, sortable
|
||||
* ```tsx
|
||||
* const columns: TableColumnDef<Row>[] = [
|
||||
* {
|
||||
* id: 'id',
|
||||
* header: 'ID',
|
||||
* accessorKey: 'id',
|
||||
* pin: 'left',
|
||||
* width: { min: 80, default: 120 },
|
||||
* enableSort: true,
|
||||
* cell: ({ value }) => <TanStackTable.Text>{String(value)}</TanStackTable.Text>,
|
||||
* },
|
||||
* {
|
||||
* id: 'computed',
|
||||
* header: () => <span>Computed</span>,
|
||||
* accessorFn: (row) => row.first + row.last,
|
||||
* enableMove: false,
|
||||
* enableRemove: false,
|
||||
* cell: ({ value }) => <TanStackTable.Text>{String(value)}</TanStackTable.Text>,
|
||||
* },
|
||||
* ];
|
||||
* ```
|
||||
*
|
||||
* @example Column state persistence with store (recommended)
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={data}
|
||||
* columns={columns}
|
||||
* columnStorageKey="my-table-columns"
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Pagination with query params. Use `enableQueryParams` object to customize param names.
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={pageRows}
|
||||
* columns={columns}
|
||||
* pagination={{ total: totalCount, defaultPage: 1, defaultLimit: 20 }}
|
||||
* enableQueryParams={{
|
||||
* page: 'listPage',
|
||||
* limit: 'listPageSize',
|
||||
* orderBy: 'orderBy',
|
||||
* expanded: 'listExpanded',
|
||||
* }}
|
||||
* prefixPaginationContent={<span>Custom prefix</span>}
|
||||
* suffixPaginationContent={<span>Custom suffix</span>}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Infinite scroll — use `onEndReached` (pagination UI is hidden when set).
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={accumulatedRows}
|
||||
* columns={columns}
|
||||
* onEndReached={(lastIndex) => fetchMore(lastIndex)}
|
||||
* isLoading={isFetching}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Loading state and typography for plain string/number cells
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={data}
|
||||
* columns={columns}
|
||||
* isLoading={isFetching}
|
||||
* skeletonRowCount={15}
|
||||
* cellTypographySize="small"
|
||||
* plainTextCellLineClamp={2}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Row styling, selection, and actions. `onRowClick` receives `(row, itemKey)`.
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={data}
|
||||
* columns={columns}
|
||||
* getRowKey={(row) => row.id}
|
||||
* getItemKey={(row) => row.id}
|
||||
* isRowActive={(row) => row.id === selectedId}
|
||||
* activeRowIndex={selectedIndex}
|
||||
* onRowClick={(row, itemKey) => setSelectedId(itemKey)}
|
||||
* onRowClickNewTab={(row, itemKey) => openInNewTab(itemKey)}
|
||||
* onRowDeactivate={() => setSelectedId(undefined)}
|
||||
* getRowClassName={(row) => (row.severity === 'error' ? 'row-error' : '')}
|
||||
* getRowStyle={(row) => (row.dimmed ? { opacity: 0.5 } : {})}
|
||||
* renderRowActions={(row) => <Button size="small">Open</Button>}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Expandable rows. `renderExpandedRow` receives `(row, rowKey, groupMeta?)`.
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={data}
|
||||
* columns={columns}
|
||||
* getRowKey={(row) => row.id}
|
||||
* renderExpandedRow={(row, rowKey, groupMeta) => (
|
||||
* <pre>{JSON.stringify({ rowKey, groupMeta, raw: row.raw }, null, 2)}</pre>
|
||||
* )}
|
||||
* getRowCanExpand={(row) => Boolean(row.raw)}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Grouped rows — use `groupBy` + `getGroupKey` for group-aware key generation.
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={data}
|
||||
* columns={columns}
|
||||
* getRowKey={(row) => row.id}
|
||||
* groupBy={[{ key: 'namespace' }, { key: 'cluster' }]}
|
||||
* getGroupKey={(row) => row.meta ?? {}}
|
||||
* renderExpandedRow={(row, rowKey, groupMeta) => (
|
||||
* <ExpandedDetails groupMeta={groupMeta} />
|
||||
* )}
|
||||
* getRowCanExpand={() => true}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Imperative handle — `goToPage` plus Virtuoso methods (e.g. `scrollToIndex`)
|
||||
* ```tsx
|
||||
* import type { TanStackTableHandle } from 'components/TanStackTableView';
|
||||
*
|
||||
* const ref = useRef<TanStackTableHandle>(null);
|
||||
*
|
||||
* <TanStackTable ref={ref} data={data} columns={columns} pagination={{ total, defaultLimit: 20 }} />;
|
||||
*
|
||||
* ref.current?.goToPage(2);
|
||||
* ref.current?.scrollToIndex({ index: 0, align: 'start' });
|
||||
* ```
|
||||
*
|
||||
* @example Scroll container props and testing
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={data}
|
||||
* columns={columns}
|
||||
* className="my-table-wrapper"
|
||||
* testId="logs-table"
|
||||
* tableScrollerProps={{ className: 'my-table-scroll', 'data-testid': 'logs-scroller' }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const TanStackTable = Object.assign(TanStackTableBase, {
|
||||
Text: TanStackTableText,
|
||||
});
|
||||
|
||||
export default TanStackTable;
|
||||
@@ -1,191 +0,0 @@
|
||||
import {
|
||||
CSSProperties,
|
||||
Dispatch,
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import type { TableVirtuosoHandle } from 'react-virtuoso';
|
||||
import type {
|
||||
ColumnSizingState,
|
||||
Row as TanStackRowType,
|
||||
} from '@tanstack/react-table';
|
||||
|
||||
export type SortState = { columnName: string; order: 'asc' | 'desc' };
|
||||
|
||||
/** Sets `--tanstack-plain-cell-*` on the scroll root via CSS module classes (no data attributes). */
|
||||
export type CellTypographySize = 'small' | 'medium' | 'large';
|
||||
|
||||
export type TableCellContext<TData, TValue> = {
|
||||
row: TData;
|
||||
value: TValue;
|
||||
isActive: boolean;
|
||||
rowIndex: number;
|
||||
isExpanded: boolean;
|
||||
canExpand: boolean;
|
||||
toggleExpanded: () => void;
|
||||
/** Business/selection key for the row */
|
||||
itemKey: string;
|
||||
/** Group metadata when row is part of a grouped view */
|
||||
groupMeta?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type RowKeyData = {
|
||||
/** Final unique key (with duplicate suffix if needed) */
|
||||
finalKey: string;
|
||||
/** Business/selection key */
|
||||
itemKey: string;
|
||||
/** Group metadata */
|
||||
groupMeta?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type TableColumnDef<
|
||||
TData,
|
||||
TKey extends keyof TData = any,
|
||||
TValue = TData[TKey]
|
||||
> = {
|
||||
id: string;
|
||||
header: string | (() => ReactNode);
|
||||
cell: (context: TableCellContext<TData, TValue>) => ReactNode;
|
||||
accessorKey?: TKey;
|
||||
accessorFn?: (row: TData) => TValue;
|
||||
pin?: 'left' | 'right';
|
||||
enableMove?: boolean;
|
||||
enableResize?: boolean;
|
||||
enableRemove?: boolean;
|
||||
enableSort?: boolean;
|
||||
/** Default visibility when no persisted state exists. Default: true */
|
||||
defaultVisibility?: boolean;
|
||||
/** Whether user can hide this column. Default: true */
|
||||
canBeHidden?: boolean;
|
||||
/**
|
||||
* Visibility behavior for grouped views:
|
||||
* - 'hidden-on-expand': Hide when rows are expanded (grouped view)
|
||||
* - 'hidden-on-collapse': Hide when rows are collapsed (ungrouped view)
|
||||
* - 'always-visible': Always show regardless of grouping
|
||||
* Default: 'always-visible'
|
||||
*/
|
||||
visibilityBehavior?:
|
||||
| 'hidden-on-expand'
|
||||
| 'hidden-on-collapse'
|
||||
| 'always-visible';
|
||||
width?: {
|
||||
fixed?: number | string;
|
||||
min?: number | string;
|
||||
default?: number | string;
|
||||
max?: number | string;
|
||||
};
|
||||
};
|
||||
|
||||
export type FlatItem<TData> =
|
||||
| { kind: 'row'; row: TanStackRowType<TData> }
|
||||
| { kind: 'expansion'; row: TanStackRowType<TData> };
|
||||
|
||||
export type TableRowContext<TData> = {
|
||||
getRowStyle?: (row: TData) => CSSProperties;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: string) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: string) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
rowKey: string,
|
||||
groupMeta?: Record<string, string>,
|
||||
) => ReactNode;
|
||||
/** Get key data for a row by index */
|
||||
getRowKeyData?: (index: number) => RowKeyData | undefined;
|
||||
colCount: number;
|
||||
isDarkMode?: boolean;
|
||||
/** When set, primitive cell output (string/number/boolean) is wrapped with typography + line-clamp (see `plainTextCellLineClamp` on the table). */
|
||||
plainTextCellLineClamp?: number;
|
||||
/** Whether there's only one non-pinned column that can be removed */
|
||||
hasSingleColumn: boolean;
|
||||
/** Column order key for memo invalidation on reorder */
|
||||
columnOrderKey: string;
|
||||
/** Column visibility key for memo invalidation on visibility change */
|
||||
columnVisibilityKey: string;
|
||||
};
|
||||
|
||||
export type PaginationProps = {
|
||||
total: number;
|
||||
defaultPage?: number;
|
||||
defaultLimit?: number;
|
||||
};
|
||||
|
||||
export type TanstackTableQueryParamsConfig = {
|
||||
page: string;
|
||||
limit: string;
|
||||
orderBy: string;
|
||||
expanded: string;
|
||||
};
|
||||
|
||||
export type TanStackTableProps<TData> = {
|
||||
data: TData[];
|
||||
columns: TableColumnDef<TData>[];
|
||||
/** Storage key for column state persistence (visibility, sizing, ordering). When set, enables unified column management. */
|
||||
columnStorageKey?: string;
|
||||
columnSizing?: ColumnSizingState;
|
||||
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
|
||||
onColumnOrderChange?: (cols: TableColumnDef<TData>[]) => void;
|
||||
/** Called when a column is removed via the header menu. Use this to sync with external column preferences. */
|
||||
onColumnRemove?: (columnId: string) => void;
|
||||
isLoading?: boolean;
|
||||
/** Number of skeleton rows to show when loading with no data. Default: 10 */
|
||||
skeletonRowCount?: number;
|
||||
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig;
|
||||
pagination?: PaginationProps;
|
||||
onEndReached?: (index: number) => void;
|
||||
/** Function to get the unique key for a row (before duplicate handling).
|
||||
* When set, enables automatic duplicate key detection and group-aware key composition. */
|
||||
getRowKey?: (row: TData) => string;
|
||||
/** Function to get the business/selection key. Defaults to getRowKey result. */
|
||||
getItemKey?: (row: TData) => string;
|
||||
/** When set, enables group-aware key generation (prefixes rowKey with group values). */
|
||||
groupBy?: Array<{ key: string }>;
|
||||
/** Extract group metadata from a row. Required when groupBy is set. */
|
||||
getGroupKey?: (row: TData) => Record<string, string>;
|
||||
getRowStyle?: (row: TData) => CSSProperties;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: string) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: string) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
activeRowIndex?: number;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
rowKey: string,
|
||||
groupMeta?: Record<string, string>,
|
||||
) => ReactNode;
|
||||
getRowCanExpand?: (row: TData) => boolean;
|
||||
/**
|
||||
* Primitive cell values use `--tanstack-plain-cell-*` from the scroll container when `cellTypographySize` is set.
|
||||
*/
|
||||
plainTextCellLineClamp?: number;
|
||||
/** Optional CSS-module typography tier for the scroll root (`--tanstack-plain-cell-font-size` / line-height + header `th`). */
|
||||
cellTypographySize?: CellTypographySize;
|
||||
/** Spread onto the Virtuoso scroll container. `data` is omitted — reserved by Virtuoso. */
|
||||
tableScrollerProps?: Omit<HTMLAttributes<HTMLDivElement>, 'data'>;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
/** Content rendered before the pagination controls */
|
||||
prefixPaginationContent?: ReactNode;
|
||||
/** Content rendered after the pagination controls */
|
||||
suffixPaginationContent?: ReactNode;
|
||||
};
|
||||
|
||||
export type TanStackTableHandle = TableVirtuosoHandle & {
|
||||
goToPage: (page: number) => void;
|
||||
};
|
||||
|
||||
export type TableColumnsState<TData> = {
|
||||
columns: TableColumnDef<TData>[];
|
||||
columnSizing: ColumnSizingState;
|
||||
onColumnSizingChange: Dispatch<SetStateAction<ColumnSizingState>>;
|
||||
onColumnOrderChange: (cols: TableColumnDef<TData>[]) => void;
|
||||
onRemoveColumn: (id: string) => void;
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
|
||||
import { TableColumnDef } from './types';
|
||||
|
||||
export interface UseColumnDndOptions<TData> {
|
||||
columns: TableColumnDef<TData>[];
|
||||
onColumnOrderChange: (columns: TableColumnDef<TData>[]) => void;
|
||||
}
|
||||
|
||||
export interface UseColumnDndResult {
|
||||
sensors: ReturnType<typeof useSensors>;
|
||||
columnIds: string[];
|
||||
handleDragEnd: (event: DragEndEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up drag-and-drop for column reordering.
|
||||
*/
|
||||
export function useColumnDnd<TData>({
|
||||
columns,
|
||||
onColumnOrderChange,
|
||||
}: UseColumnDndOptions<TData>): UseColumnDndResult {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
);
|
||||
|
||||
const columnIds = useMemo(() => columns.map((c) => c.id), [columns]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent): void => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
const activeCol = columns.find((c) => c.id === String(active.id));
|
||||
const overCol = columns.find((c) => c.id === String(over.id));
|
||||
if (
|
||||
!activeCol ||
|
||||
!overCol ||
|
||||
activeCol.pin != null ||
|
||||
overCol.pin != null ||
|
||||
activeCol.enableMove === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const oldIndex = columns.findIndex((c) => c.id === String(active.id));
|
||||
const newIndex = columns.findIndex((c) => c.id === String(over.id));
|
||||
if (oldIndex === -1 || newIndex === -1) {
|
||||
return;
|
||||
}
|
||||
onColumnOrderChange(arrayMove(columns, oldIndex, newIndex));
|
||||
},
|
||||
[columns, onColumnOrderChange],
|
||||
);
|
||||
|
||||
return { sensors, columnIds, handleDragEnd };
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { SetStateAction } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import type { ColumnSizingState } from '@tanstack/react-table';
|
||||
|
||||
import { TableColumnDef } from './types';
|
||||
|
||||
export interface UseColumnHandlersOptions<TData> {
|
||||
/** Storage key for persisting column state (enables store mode) */
|
||||
columnStorageKey?: string;
|
||||
effectiveSizing: ColumnSizingState;
|
||||
storeSetSizing: (sizing: ColumnSizingState) => void;
|
||||
storeSetOrder: (columns: TableColumnDef<TData>[]) => void;
|
||||
hideColumn: (columnId: string) => void;
|
||||
onColumnSizingChange?: (sizing: ColumnSizingState) => void;
|
||||
onColumnOrderChange?: (columns: TableColumnDef<TData>[]) => void;
|
||||
onColumnRemove?: (columnId: string) => void;
|
||||
}
|
||||
|
||||
export interface UseColumnHandlersResult<TData> {
|
||||
handleColumnSizingChange: (updater: SetStateAction<ColumnSizingState>) => void;
|
||||
handleColumnOrderChange: (columns: TableColumnDef<TData>[]) => void;
|
||||
handleRemoveColumn: (columnId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handlers for column state changes that delegate to either
|
||||
* the store (when columnStorageKey is provided) or prop callbacks.
|
||||
*/
|
||||
export function useColumnHandlers<TData>({
|
||||
columnStorageKey,
|
||||
effectiveSizing,
|
||||
storeSetSizing,
|
||||
storeSetOrder,
|
||||
hideColumn,
|
||||
onColumnSizingChange,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
}: UseColumnHandlersOptions<TData>): UseColumnHandlersResult<TData> {
|
||||
const handleColumnSizingChange = useCallback(
|
||||
(updater: SetStateAction<ColumnSizingState>) => {
|
||||
const next =
|
||||
typeof updater === 'function' ? updater(effectiveSizing) : updater;
|
||||
if (columnStorageKey) {
|
||||
storeSetSizing(next);
|
||||
}
|
||||
onColumnSizingChange?.(next);
|
||||
},
|
||||
[columnStorageKey, effectiveSizing, storeSetSizing, onColumnSizingChange],
|
||||
);
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(cols: TableColumnDef<TData>[]) => {
|
||||
if (columnStorageKey) {
|
||||
storeSetOrder(cols);
|
||||
}
|
||||
onColumnOrderChange?.(cols);
|
||||
},
|
||||
[columnStorageKey, storeSetOrder, onColumnOrderChange],
|
||||
);
|
||||
|
||||
const handleRemoveColumn = useCallback(
|
||||
(columnId: string) => {
|
||||
if (columnStorageKey) {
|
||||
hideColumn(columnId);
|
||||
}
|
||||
onColumnRemove?.(columnId);
|
||||
},
|
||||
[columnStorageKey, hideColumn, onColumnRemove],
|
||||
);
|
||||
|
||||
return {
|
||||
handleColumnSizingChange,
|
||||
handleColumnOrderChange,
|
||||
handleRemoveColumn,
|
||||
};
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ColumnSizingState, VisibilityState } from '@tanstack/react-table';
|
||||
|
||||
import { TableColumnDef } from './types';
|
||||
import {
|
||||
cleanupStaleHiddenColumns as storeCleanupStaleHiddenColumns,
|
||||
hideColumn as storeHideColumn,
|
||||
initializeFromDefaults as storeInitializeFromDefaults,
|
||||
resetToDefaults as storeResetToDefaults,
|
||||
setColumnOrder as storeSetColumnOrder,
|
||||
setColumnSizing as storeSetColumnSizing,
|
||||
showColumn as storeShowColumn,
|
||||
toggleColumn as storeToggleColumn,
|
||||
useColumnOrder as useStoreOrder,
|
||||
useColumnSizing as useStoreSizing,
|
||||
useHiddenColumnIds,
|
||||
} from './useColumnStore';
|
||||
|
||||
type UseColumnStateOptions<TData> = {
|
||||
storageKey?: string;
|
||||
columns: TableColumnDef<TData>[];
|
||||
isGrouped?: boolean;
|
||||
};
|
||||
|
||||
type UseColumnStateResult<TData> = {
|
||||
columnVisibility: VisibilityState;
|
||||
columnSizing: ColumnSizingState;
|
||||
/** Columns sorted by persisted order (pinned first) */
|
||||
sortedColumns: TableColumnDef<TData>[];
|
||||
hiddenColumnIds: string[];
|
||||
hideColumn: (columnId: string) => void;
|
||||
showColumn: (columnId: string) => void;
|
||||
toggleColumn: (columnId: string) => void;
|
||||
setColumnSizing: (sizing: ColumnSizingState) => void;
|
||||
setColumnOrder: (columns: TableColumnDef<TData>[]) => void;
|
||||
resetToDefaults: () => void;
|
||||
};
|
||||
|
||||
export function useColumnState<TData>({
|
||||
storageKey,
|
||||
columns,
|
||||
isGrouped = false,
|
||||
}: UseColumnStateOptions<TData>): UseColumnStateResult<TData> {
|
||||
useEffect(() => {
|
||||
if (storageKey) {
|
||||
storeInitializeFromDefaults(storageKey, columns);
|
||||
}
|
||||
// Only run on mount, not when columns change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [storageKey]);
|
||||
|
||||
const rawHiddenColumnIds = useHiddenColumnIds(storageKey ?? '');
|
||||
|
||||
useEffect(
|
||||
function cleanupHiddenColumnIdsNoLongerInDefinitions(): void {
|
||||
if (!storageKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validColumnIds = new Set(columns.map((c) => c.id));
|
||||
storeCleanupStaleHiddenColumns(storageKey, validColumnIds);
|
||||
},
|
||||
[storageKey, columns],
|
||||
);
|
||||
|
||||
const columnSizing = useStoreSizing(storageKey ?? '');
|
||||
const prevColumnIdsRef = useRef<Set<string> | null>(null);
|
||||
|
||||
useEffect(
|
||||
function autoShowNewlyAddedColumns(): void {
|
||||
if (!storageKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIds = new Set(columns.map((c) => c.id));
|
||||
|
||||
// Skip first render - just record the initial columns
|
||||
if (prevColumnIdsRef.current === null) {
|
||||
prevColumnIdsRef.current = currentIds;
|
||||
return;
|
||||
}
|
||||
|
||||
const prevIds = prevColumnIdsRef.current;
|
||||
|
||||
// Find columns that are new (in current but not in previous)
|
||||
for (const id of currentIds) {
|
||||
if (!prevIds.has(id) && rawHiddenColumnIds.includes(id)) {
|
||||
// Column was just added and is hidden - show it
|
||||
storeShowColumn(storageKey, id);
|
||||
}
|
||||
}
|
||||
|
||||
prevColumnIdsRef.current = currentIds;
|
||||
},
|
||||
[storageKey, columns, rawHiddenColumnIds],
|
||||
);
|
||||
|
||||
const columnOrder = useStoreOrder(storageKey ?? '');
|
||||
const columnMap = useMemo(() => new Map(columns.map((c) => [c.id, c])), [
|
||||
columns,
|
||||
]);
|
||||
|
||||
const hiddenColumnIds = useMemo(
|
||||
() =>
|
||||
rawHiddenColumnIds.filter((id) => {
|
||||
const col = columnMap.get(id);
|
||||
return col && col.canBeHidden !== false;
|
||||
}),
|
||||
[rawHiddenColumnIds, columnMap],
|
||||
);
|
||||
|
||||
const columnVisibility = useMemo((): VisibilityState => {
|
||||
const visibility: VisibilityState = {};
|
||||
|
||||
for (const id of hiddenColumnIds) {
|
||||
visibility[id] = false;
|
||||
}
|
||||
|
||||
for (const column of columns) {
|
||||
if (column.visibilityBehavior === 'hidden-on-expand' && isGrouped) {
|
||||
visibility[column.id] = false;
|
||||
}
|
||||
if (column.visibilityBehavior === 'hidden-on-collapse' && !isGrouped) {
|
||||
visibility[column.id] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return visibility;
|
||||
}, [hiddenColumnIds, columns, isGrouped]);
|
||||
|
||||
const sortedColumns = useMemo((): TableColumnDef<TData>[] => {
|
||||
if (columnOrder.length === 0) {
|
||||
return columns;
|
||||
}
|
||||
|
||||
const orderMap = new Map(columnOrder.map((id, i) => [id, i]));
|
||||
const pinned = columns.filter((c) => c.pin != null);
|
||||
const rest = columns.filter((c) => c.pin == null);
|
||||
const sortedRest = [...rest].sort((a, b) => {
|
||||
const ai = orderMap.get(a.id) ?? Infinity;
|
||||
const bi = orderMap.get(b.id) ?? Infinity;
|
||||
return ai - bi;
|
||||
});
|
||||
|
||||
return [...pinned, ...sortedRest];
|
||||
}, [columns, columnOrder]);
|
||||
|
||||
const hideColumn = useCallback(
|
||||
(columnId: string) => {
|
||||
if (!storageKey) {
|
||||
return;
|
||||
}
|
||||
// Prevent hiding columns with canBeHidden: false
|
||||
const col = columnMap.get(columnId);
|
||||
if (col && col.canBeHidden === false) {
|
||||
return;
|
||||
}
|
||||
storeHideColumn(storageKey, columnId);
|
||||
},
|
||||
[storageKey, columnMap],
|
||||
);
|
||||
|
||||
const showColumn = useCallback(
|
||||
(columnId: string) => {
|
||||
if (storageKey) {
|
||||
storeShowColumn(storageKey, columnId);
|
||||
}
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const toggleColumn = useCallback(
|
||||
(columnId: string) => {
|
||||
if (!storageKey) {
|
||||
return;
|
||||
}
|
||||
const col = columnMap.get(columnId);
|
||||
const isCurrentlyHidden = hiddenColumnIds.includes(columnId);
|
||||
if (col && col.canBeHidden === false && !isCurrentlyHidden) {
|
||||
return;
|
||||
}
|
||||
storeToggleColumn(storageKey, columnId);
|
||||
},
|
||||
[storageKey, columnMap, hiddenColumnIds],
|
||||
);
|
||||
|
||||
const setColumnSizing = useCallback(
|
||||
(sizing: ColumnSizingState) => {
|
||||
if (storageKey) {
|
||||
storeSetColumnSizing(storageKey, sizing);
|
||||
}
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const setColumnOrder = useCallback(
|
||||
(cols: TableColumnDef<TData>[]) => {
|
||||
if (storageKey) {
|
||||
storeSetColumnOrder(
|
||||
storageKey,
|
||||
cols.map((c) => c.id),
|
||||
);
|
||||
}
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const resetToDefaults = useCallback(() => {
|
||||
if (storageKey) {
|
||||
storeResetToDefaults(storageKey, columns);
|
||||
}
|
||||
}, [storageKey, columns]);
|
||||
|
||||
return {
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
sortedColumns,
|
||||
hiddenColumnIds,
|
||||
hideColumn,
|
||||
showColumn,
|
||||
toggleColumn,
|
||||
setColumnSizing,
|
||||
setColumnOrder,
|
||||
resetToDefaults,
|
||||
};
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
import { ColumnSizingState } from '@tanstack/react-table';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { TableColumnDef } from './types';
|
||||
|
||||
const STORAGE_PREFIX = '@signoz/table-columns/';
|
||||
|
||||
const persistedTableCache = new Map<
|
||||
string,
|
||||
{ raw: string; parsed: ColumnState }
|
||||
>();
|
||||
|
||||
type ColumnState = {
|
||||
hiddenColumnIds: string[];
|
||||
columnOrder: string[];
|
||||
columnSizing: ColumnSizingState;
|
||||
};
|
||||
|
||||
const EMPTY_STATE: ColumnState = {
|
||||
hiddenColumnIds: [],
|
||||
columnOrder: [],
|
||||
columnSizing: {},
|
||||
};
|
||||
|
||||
type ColumnStoreState = {
|
||||
tables: Record<string, ColumnState>;
|
||||
hideColumn: (storageKey: string, columnId: string) => void;
|
||||
showColumn: (storageKey: string, columnId: string) => void;
|
||||
toggleColumn: (storageKey: string, columnId: string) => void;
|
||||
setColumnSizing: (storageKey: string, sizing: ColumnSizingState) => void;
|
||||
setColumnOrder: (storageKey: string, order: string[]) => void;
|
||||
initializeFromDefaults: <TData>(
|
||||
storageKey: string,
|
||||
columns: TableColumnDef<TData>[],
|
||||
) => void;
|
||||
resetToDefaults: <TData>(
|
||||
storageKey: string,
|
||||
columns: TableColumnDef<TData>[],
|
||||
) => void;
|
||||
cleanupStaleHiddenColumns: (
|
||||
storageKey: string,
|
||||
validColumnIds: Set<string>,
|
||||
) => void;
|
||||
};
|
||||
|
||||
const getDefaultHiddenIds = <TData>(
|
||||
columns: TableColumnDef<TData>[],
|
||||
): string[] =>
|
||||
columns.filter((c) => c.defaultVisibility === false).map((c) => c.id);
|
||||
|
||||
const getStorageKeyForTable = (tableKey: string): string =>
|
||||
`${STORAGE_PREFIX}${tableKey}`;
|
||||
|
||||
const loadTableFromStorage = (tableKey: string): ColumnState | null => {
|
||||
try {
|
||||
const raw = localStorage.getItem(getStorageKeyForTable(tableKey));
|
||||
if (!raw) {
|
||||
persistedTableCache.delete(tableKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = persistedTableCache.get(tableKey);
|
||||
if (cached && cached.raw === raw) {
|
||||
return cached.parsed;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as ColumnState;
|
||||
persistedTableCache.set(tableKey, { raw, parsed });
|
||||
return parsed;
|
||||
} catch {
|
||||
persistedTableCache.delete(tableKey);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const saveTableToStorage = (tableKey: string, state: ColumnState): void => {
|
||||
try {
|
||||
const raw = JSON.stringify(state);
|
||||
localStorage.setItem(getStorageKeyForTable(tableKey), raw);
|
||||
persistedTableCache.set(tableKey, { raw, parsed: state });
|
||||
} catch {
|
||||
// Ignore storage errors (e.g., private browsing quota exceeded)
|
||||
}
|
||||
};
|
||||
|
||||
export const useColumnStore = create<ColumnStoreState>()((set, get) => {
|
||||
return {
|
||||
tables: {},
|
||||
hideColumn: (storageKey, columnId): void => {
|
||||
const state = get();
|
||||
let table = state.tables[storageKey];
|
||||
|
||||
// Lazy load from storage if not in memory
|
||||
if (!table) {
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
if (persisted) {
|
||||
table = persisted;
|
||||
set({ tables: { ...state.tables, [storageKey]: table } });
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (table.hiddenColumnIds.includes(columnId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTable = {
|
||||
...table,
|
||||
hiddenColumnIds: [...table.hiddenColumnIds, columnId],
|
||||
};
|
||||
set({ tables: { ...get().tables, [storageKey]: nextTable } });
|
||||
saveTableToStorage(storageKey, nextTable);
|
||||
},
|
||||
showColumn: (storageKey, columnId): void => {
|
||||
const state = get();
|
||||
let table = state.tables[storageKey];
|
||||
|
||||
if (!table) {
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
if (persisted) {
|
||||
table = persisted;
|
||||
set({ tables: { ...state.tables, [storageKey]: table } });
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!table.hiddenColumnIds.includes(columnId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTable = {
|
||||
...table,
|
||||
hiddenColumnIds: table.hiddenColumnIds.filter((id) => id !== columnId),
|
||||
};
|
||||
set({ tables: { ...get().tables, [storageKey]: nextTable } });
|
||||
saveTableToStorage(storageKey, nextTable);
|
||||
},
|
||||
toggleColumn: (storageKey, columnId): void => {
|
||||
const state = get();
|
||||
let table = state.tables[storageKey];
|
||||
|
||||
if (!table) {
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
if (persisted) {
|
||||
table = persisted;
|
||||
set({ tables: { ...state.tables, [storageKey]: table } });
|
||||
}
|
||||
}
|
||||
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isHidden = table.hiddenColumnIds.includes(columnId);
|
||||
if (isHidden) {
|
||||
get().showColumn(storageKey, columnId);
|
||||
} else {
|
||||
get().hideColumn(storageKey, columnId);
|
||||
}
|
||||
},
|
||||
setColumnSizing: (storageKey, sizing): void => {
|
||||
const state = get();
|
||||
let table = state.tables[storageKey];
|
||||
|
||||
if (!table) {
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
table = persisted ?? { ...EMPTY_STATE };
|
||||
}
|
||||
|
||||
const nextTable = {
|
||||
...table,
|
||||
columnSizing: sizing,
|
||||
};
|
||||
set({ tables: { ...get().tables, [storageKey]: nextTable } });
|
||||
saveTableToStorage(storageKey, nextTable);
|
||||
},
|
||||
setColumnOrder: (storageKey, order): void => {
|
||||
const state = get();
|
||||
let table = state.tables[storageKey];
|
||||
|
||||
if (!table) {
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
table = persisted ?? { ...EMPTY_STATE };
|
||||
}
|
||||
|
||||
const nextTable = {
|
||||
...table,
|
||||
columnOrder: order,
|
||||
};
|
||||
set({ tables: { ...get().tables, [storageKey]: nextTable } });
|
||||
saveTableToStorage(storageKey, nextTable);
|
||||
},
|
||||
initializeFromDefaults: (storageKey, columns): void => {
|
||||
const state = get();
|
||||
|
||||
if (state.tables[storageKey]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
if (persisted) {
|
||||
set({ tables: { ...state.tables, [storageKey]: persisted } });
|
||||
return;
|
||||
}
|
||||
|
||||
const newTable: ColumnState = {
|
||||
hiddenColumnIds: getDefaultHiddenIds(columns),
|
||||
columnOrder: [],
|
||||
columnSizing: {},
|
||||
};
|
||||
set({ tables: { ...state.tables, [storageKey]: newTable } });
|
||||
saveTableToStorage(storageKey, newTable);
|
||||
},
|
||||
|
||||
resetToDefaults: (storageKey, columns): void => {
|
||||
const newTable: ColumnState = {
|
||||
hiddenColumnIds: getDefaultHiddenIds(columns),
|
||||
columnOrder: [],
|
||||
columnSizing: {},
|
||||
};
|
||||
set({ tables: { ...get().tables, [storageKey]: newTable } });
|
||||
saveTableToStorage(storageKey, newTable);
|
||||
},
|
||||
|
||||
cleanupStaleHiddenColumns: (storageKey, validColumnIds): void => {
|
||||
const state = get();
|
||||
let table = state.tables[storageKey];
|
||||
|
||||
if (!table) {
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
if (!persisted) {
|
||||
return;
|
||||
}
|
||||
table = persisted;
|
||||
}
|
||||
|
||||
const filteredHiddenIds = table.hiddenColumnIds.filter((id) =>
|
||||
validColumnIds.has(id),
|
||||
);
|
||||
|
||||
// Only update if something changed
|
||||
if (filteredHiddenIds.length === table.hiddenColumnIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTable = {
|
||||
...table,
|
||||
hiddenColumnIds: filteredHiddenIds,
|
||||
};
|
||||
set({ tables: { ...get().tables, [storageKey]: nextTable } });
|
||||
saveTableToStorage(storageKey, nextTable);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Stable empty references to avoid `Object.is` false-negatives when a key
|
||||
// does not exist yet (returning a new `[]` / `{}` on every selector call
|
||||
// would trigger React's useSyncExternalStore tearing detection).
|
||||
const EMPTY_ARRAY: string[] = [];
|
||||
const EMPTY_SIZING: ColumnSizingState = {};
|
||||
|
||||
export const useHiddenColumnIds = (storageKey: string): string[] =>
|
||||
useColumnStore((s) => {
|
||||
const table = s.tables[storageKey];
|
||||
if (table) {
|
||||
return table.hiddenColumnIds;
|
||||
}
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
return persisted?.hiddenColumnIds ?? EMPTY_ARRAY;
|
||||
});
|
||||
|
||||
export const useColumnSizing = (storageKey: string): ColumnSizingState =>
|
||||
useColumnStore((s) => {
|
||||
const table = s.tables[storageKey];
|
||||
if (table) {
|
||||
return table.columnSizing;
|
||||
}
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
return persisted?.columnSizing ?? EMPTY_SIZING;
|
||||
});
|
||||
|
||||
export const useColumnOrder = (storageKey: string): string[] =>
|
||||
useColumnStore((s) => {
|
||||
const table = s.tables[storageKey];
|
||||
if (table) {
|
||||
return table.columnOrder;
|
||||
}
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
return persisted?.columnOrder ?? EMPTY_ARRAY;
|
||||
});
|
||||
|
||||
export const initializeFromDefaults = <TData>(
|
||||
storageKey: string,
|
||||
columns: TableColumnDef<TData>[],
|
||||
): void =>
|
||||
useColumnStore.getState().initializeFromDefaults(storageKey, columns);
|
||||
|
||||
export const hideColumn = (storageKey: string, columnId: string): void =>
|
||||
useColumnStore.getState().hideColumn(storageKey, columnId);
|
||||
|
||||
export const showColumn = (storageKey: string, columnId: string): void =>
|
||||
useColumnStore.getState().showColumn(storageKey, columnId);
|
||||
|
||||
export const toggleColumn = (storageKey: string, columnId: string): void =>
|
||||
useColumnStore.getState().toggleColumn(storageKey, columnId);
|
||||
|
||||
export const setColumnSizing = (
|
||||
storageKey: string,
|
||||
sizing: ColumnSizingState,
|
||||
): void => useColumnStore.getState().setColumnSizing(storageKey, sizing);
|
||||
|
||||
export const setColumnOrder = (storageKey: string, order: string[]): void =>
|
||||
useColumnStore.getState().setColumnOrder(storageKey, order);
|
||||
|
||||
export const resetToDefaults = <TData>(
|
||||
storageKey: string,
|
||||
columns: TableColumnDef<TData>[],
|
||||
): void => useColumnStore.getState().resetToDefaults(storageKey, columns);
|
||||
|
||||
export const cleanupStaleHiddenColumns = (
|
||||
storageKey: string,
|
||||
validColumnIds: Set<string>,
|
||||
): void =>
|
||||
useColumnStore
|
||||
.getState()
|
||||
.cleanupStaleHiddenColumns(storageKey, validColumnIds);
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
export interface UseEffectiveDataOptions {
|
||||
data: unknown[];
|
||||
isLoading: boolean;
|
||||
limit?: number;
|
||||
skeletonRowCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages effective data for the table, handling loading states gracefully.
|
||||
*/
|
||||
export function useEffectiveData<TData>({
|
||||
data,
|
||||
isLoading,
|
||||
limit,
|
||||
skeletonRowCount = 10,
|
||||
}: UseEffectiveDataOptions): TData[] {
|
||||
const prevDataRef = useRef<TData[]>(data as TData[]);
|
||||
const prevDataSizeRef = useRef(data.length || limit || skeletonRowCount);
|
||||
|
||||
// Update refs when we have real data (not loading)
|
||||
if (!isLoading && data.length > 0) {
|
||||
prevDataRef.current = data as TData[];
|
||||
prevDataSizeRef.current = data.length;
|
||||
}
|
||||
|
||||
return useMemo((): TData[] => {
|
||||
if (data.length > 0) {
|
||||
return data as TData[];
|
||||
}
|
||||
if (prevDataRef.current.length > 0) {
|
||||
return prevDataRef.current;
|
||||
}
|
||||
if (isLoading) {
|
||||
const fakeCount = prevDataSizeRef.current || limit || skeletonRowCount;
|
||||
return Array.from({ length: fakeCount }, (_, i) => ({
|
||||
id: `skeleton-${i}`,
|
||||
})) as TData[];
|
||||
}
|
||||
return data as TData[];
|
||||
}, [isLoading, data, limit, skeletonRowCount]);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ExpandedState, Row } from '@tanstack/react-table';
|
||||
|
||||
import { FlatItem } from './types';
|
||||
|
||||
export interface UseFlatItemsOptions<TData> {
|
||||
tableRows: Row<TData>[];
|
||||
/** Whether row expansion is enabled, needs to be unknown since it will be a function that can be updated/modified, boolean does not work well here */
|
||||
renderExpandedRow?: unknown;
|
||||
expanded: ExpandedState;
|
||||
/** Index of the active row (for scroll-to behavior) */
|
||||
activeRowIndex?: number;
|
||||
}
|
||||
|
||||
export interface UseFlatItemsResult<TData> {
|
||||
flatItems: FlatItem<TData>[];
|
||||
/** Index of active row in flatItems (-1 if not found) */
|
||||
flatIndexForActiveRow: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens table rows with their expansion rows into a single list.
|
||||
*
|
||||
* When a row is expanded, an expansion item is inserted immediately after it.
|
||||
* Also computes the flat index for the active row (used for scroll-to).
|
||||
*/
|
||||
export function useFlatItems<TData>({
|
||||
tableRows,
|
||||
renderExpandedRow,
|
||||
expanded,
|
||||
activeRowIndex,
|
||||
}: UseFlatItemsOptions<TData>): UseFlatItemsResult<TData> {
|
||||
const flatItems = useMemo<FlatItem<TData>[]>(() => {
|
||||
const result: FlatItem<TData>[] = [];
|
||||
for (const row of tableRows) {
|
||||
result.push({ kind: 'row', row });
|
||||
if (renderExpandedRow && row.getIsExpanded()) {
|
||||
result.push({ kind: 'expansion', row });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
// expanded needs to be here, otherwise the rows are not updated when you click to expand
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tableRows, renderExpandedRow, expanded]);
|
||||
|
||||
const flatIndexForActiveRow = useMemo(() => {
|
||||
if (activeRowIndex == null || activeRowIndex < 0) {
|
||||
return -1;
|
||||
}
|
||||
for (let i = 0; i < flatItems.length; i++) {
|
||||
const item = flatItems[i];
|
||||
if (item.kind === 'row' && item.row.index === activeRowIndex) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}, [activeRowIndex, flatItems]);
|
||||
|
||||
return { flatItems, flatIndexForActiveRow };
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
export interface RowKeyDataItem {
|
||||
/** Final unique key for the row (with dedup suffix if needed) */
|
||||
finalKey: string;
|
||||
/** Item key for tracking (may differ from finalKey) */
|
||||
itemKey: string;
|
||||
/** Group metadata when grouped */
|
||||
groupMeta: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
export interface UseRowKeyDataOptions<TData> {
|
||||
data: TData[];
|
||||
isLoading: boolean;
|
||||
getRowKey?: (item: TData) => string;
|
||||
getItemKey?: (item: TData) => string;
|
||||
groupBy?: Array<{ key: string }>;
|
||||
getGroupKey?: (item: TData) => Record<string, string>;
|
||||
}
|
||||
|
||||
export interface UseRowKeyDataResult {
|
||||
/** Array of key data for each row, undefined if getRowKey not provided or loading */
|
||||
rowKeyData: RowKeyDataItem[] | undefined;
|
||||
getRowKeyData: (index: number) => RowKeyDataItem | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes unique row keys with duplicate handling and group prefixes.
|
||||
*/
|
||||
export function useRowKeyData<TData>({
|
||||
data,
|
||||
isLoading,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
groupBy,
|
||||
getGroupKey,
|
||||
}: UseRowKeyDataOptions<TData>): UseRowKeyDataResult {
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const rowKeyData = useMemo((): RowKeyDataItem[] | undefined => {
|
||||
if (!getRowKey || isLoading) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keyCount = new Map<string, number>();
|
||||
|
||||
return data.map(
|
||||
(item, index): RowKeyDataItem => {
|
||||
const itemIdentifier = getRowKey(item);
|
||||
const itemKey = getItemKey?.(item) ?? itemIdentifier;
|
||||
const groupMeta = groupBy?.length ? getGroupKey?.(item) : undefined;
|
||||
|
||||
// Build rowKey with group prefix when grouped
|
||||
let rowKey: string;
|
||||
if (groupBy?.length && groupMeta) {
|
||||
const groupKeyStr = Object.values(groupMeta).join('-');
|
||||
if (groupKeyStr && itemIdentifier) {
|
||||
rowKey = `${groupKeyStr}-${itemIdentifier}`;
|
||||
} else {
|
||||
rowKey = groupKeyStr || itemIdentifier || String(index);
|
||||
}
|
||||
} else {
|
||||
rowKey = itemIdentifier || String(index);
|
||||
}
|
||||
|
||||
const count = keyCount.get(rowKey) || 0;
|
||||
keyCount.set(rowKey, count + 1);
|
||||
const finalKey = count > 0 ? `${rowKey}-${count}` : rowKey;
|
||||
|
||||
return { finalKey, itemKey, groupMeta };
|
||||
},
|
||||
);
|
||||
}, [data, getRowKey, getItemKey, groupBy, getGroupKey, isLoading]);
|
||||
|
||||
const getRowKeyData = useCallback((index: number) => rowKeyData?.[index], [
|
||||
rowKeyData,
|
||||
]);
|
||||
|
||||
return { rowKeyData, getRowKeyData };
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ExpandedState, Updater } from '@tanstack/react-table';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
import { SortState, TanstackTableQueryParamsConfig } from './types';
|
||||
|
||||
const NUQS_OPTIONS = { history: 'push' as const };
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_LIMIT = 50;
|
||||
|
||||
type Defaults = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
orderBy?: SortState | null;
|
||||
expanded?: ExpandedState;
|
||||
};
|
||||
|
||||
type TableParamsResult = {
|
||||
page: number;
|
||||
limit: number;
|
||||
orderBy: SortState | null;
|
||||
expanded: ExpandedState;
|
||||
setPage: (p: number) => void;
|
||||
setLimit: (l: number) => void;
|
||||
setOrderBy: (s: SortState | null) => void;
|
||||
setExpanded: (updaterOrValue: Updater<ExpandedState>) => void;
|
||||
};
|
||||
|
||||
function expandedStateToArray(state: ExpandedState): string[] {
|
||||
if (typeof state === 'boolean') {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(state)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k);
|
||||
}
|
||||
|
||||
function arrayToExpandedState(arr: string[]): ExpandedState {
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const id of arr) {
|
||||
result[id] = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function useTableParams(
|
||||
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig,
|
||||
defaults?: Defaults,
|
||||
): TableParamsResult {
|
||||
const pageQueryParam =
|
||||
typeof enableQueryParams === 'string'
|
||||
? `${enableQueryParams}_page`
|
||||
: typeof enableQueryParams === 'object'
|
||||
? enableQueryParams.page
|
||||
: 'page';
|
||||
const limitQueryParam =
|
||||
typeof enableQueryParams === 'string'
|
||||
? `${enableQueryParams}_limit`
|
||||
: typeof enableQueryParams === 'object'
|
||||
? enableQueryParams.limit
|
||||
: 'limit';
|
||||
const orderByQueryParam =
|
||||
typeof enableQueryParams === 'string'
|
||||
? `${enableQueryParams}_order_by`
|
||||
: typeof enableQueryParams === 'object'
|
||||
? enableQueryParams.orderBy
|
||||
: 'order_by';
|
||||
const expandedQueryParam =
|
||||
typeof enableQueryParams === 'string'
|
||||
? `${enableQueryParams}_expanded`
|
||||
: typeof enableQueryParams === 'object'
|
||||
? enableQueryParams.expanded
|
||||
: 'expanded';
|
||||
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
|
||||
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
|
||||
const orderByDefault = defaults?.orderBy ?? null;
|
||||
const expandedDefault = defaults?.expanded ?? {};
|
||||
const expandedDefaultArray = useMemo(
|
||||
() => expandedStateToArray(expandedDefault),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const [localPage, setLocalPage] = useState(pageDefault);
|
||||
const [localLimit, setLocalLimit] = useState(limitDefault);
|
||||
const [localOrderBy, setLocalOrderBy] = useState<SortState | null>(
|
||||
orderByDefault,
|
||||
);
|
||||
const [localExpanded, setLocalExpanded] = useState<ExpandedState>(
|
||||
expandedDefault,
|
||||
);
|
||||
|
||||
const [urlPage, setUrlPage] = useQueryState(
|
||||
pageQueryParam,
|
||||
parseAsInteger.withDefault(pageDefault).withOptions(NUQS_OPTIONS),
|
||||
);
|
||||
const [urlLimit, setUrlLimit] = useQueryState(
|
||||
limitQueryParam,
|
||||
parseAsInteger.withDefault(limitDefault).withOptions(NUQS_OPTIONS),
|
||||
);
|
||||
const [urlOrderBy, setUrlOrderBy] = useQueryState(
|
||||
orderByQueryParam,
|
||||
parseAsJsonNoValidate<SortState | null>()
|
||||
.withDefault(orderByDefault as never)
|
||||
.withOptions(NUQS_OPTIONS),
|
||||
);
|
||||
const [urlExpandedArray, setUrlExpandedArray] = useQueryState(
|
||||
expandedQueryParam,
|
||||
parseAsJsonNoValidate<string[]>()
|
||||
.withDefault(expandedDefaultArray as never)
|
||||
.withOptions(NUQS_OPTIONS),
|
||||
);
|
||||
|
||||
// Convert URL array to ExpandedState
|
||||
const urlExpanded = useMemo(
|
||||
() => arrayToExpandedState(urlExpandedArray ?? []),
|
||||
[urlExpandedArray],
|
||||
);
|
||||
|
||||
// Keep ref for updater function access
|
||||
const urlExpandedRef = useRef(urlExpanded);
|
||||
urlExpandedRef.current = urlExpanded;
|
||||
|
||||
// Wrapper to convert ExpandedState to array when setting URL state
|
||||
// Supports both direct values and updater functions (TanStack pattern)
|
||||
const setUrlExpanded = useCallback(
|
||||
(updaterOrValue: Updater<ExpandedState>): void => {
|
||||
const newState =
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(urlExpandedRef.current)
|
||||
: updaterOrValue;
|
||||
setUrlExpandedArray(expandedStateToArray(newState));
|
||||
},
|
||||
[setUrlExpandedArray],
|
||||
);
|
||||
|
||||
// Wrapper for local expanded to match TanStack's Updater pattern
|
||||
const handleSetLocalExpanded = useCallback(
|
||||
(updaterOrValue: Updater<ExpandedState>): void => {
|
||||
setLocalExpanded((prev) =>
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(prev)
|
||||
: updaterOrValue,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
|
||||
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
|
||||
const isEnabledQueryParams =
|
||||
typeof enableQueryParams === 'string' ||
|
||||
typeof enableQueryParams === 'object';
|
||||
|
||||
useEffect(() => {
|
||||
if (isEnabledQueryParams) {
|
||||
setUrlPage(pageDefault);
|
||||
} else {
|
||||
setLocalPage(pageDefault);
|
||||
}
|
||||
}, [
|
||||
isEnabledQueryParams,
|
||||
orderByDefaultMemoKey,
|
||||
orderByUrlMemoKey,
|
||||
pageDefault,
|
||||
setUrlPage,
|
||||
]);
|
||||
|
||||
if (enableQueryParams) {
|
||||
return {
|
||||
page: urlPage,
|
||||
limit: urlLimit,
|
||||
orderBy: urlOrderBy as SortState | null,
|
||||
expanded: urlExpanded,
|
||||
setPage: setUrlPage,
|
||||
setLimit: setUrlLimit,
|
||||
setOrderBy: setUrlOrderBy,
|
||||
setExpanded: setUrlExpanded,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
page: localPage,
|
||||
limit: localLimit,
|
||||
orderBy: localOrderBy,
|
||||
expanded: localExpanded,
|
||||
setPage: setLocalPage,
|
||||
setLimit: setLocalLimit,
|
||||
setOrderBy: setLocalOrderBy,
|
||||
setExpanded: handleSetLocalExpanded,
|
||||
};
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { RowKeyData, TableColumnDef } from './types';
|
||||
|
||||
export const getColumnId = <TData>(column: TableColumnDef<TData>): string =>
|
||||
column.id;
|
||||
|
||||
const DEFAULT_MIN_WIDTH = 192; // 12rem * 16px
|
||||
|
||||
export const getColumnWidthStyle = <TData>(
|
||||
column: TableColumnDef<TData>,
|
||||
/** Persisted width from user resizing (overrides defined width) */
|
||||
persistedWidth?: number,
|
||||
/** Last column always gets width: 100% and ignores other width settings */
|
||||
isLastColumn?: boolean,
|
||||
): CSSProperties => {
|
||||
// Last column always fills remaining space
|
||||
if (isLastColumn) {
|
||||
return { width: '100%' };
|
||||
}
|
||||
|
||||
const { width } = column;
|
||||
if (!width) {
|
||||
return {
|
||||
width: persistedWidth ?? DEFAULT_MIN_WIDTH,
|
||||
minWidth: DEFAULT_MIN_WIDTH,
|
||||
};
|
||||
}
|
||||
if (width.fixed != null) {
|
||||
return {
|
||||
width: width.fixed,
|
||||
minWidth: width.fixed,
|
||||
maxWidth: width.fixed,
|
||||
};
|
||||
}
|
||||
return {
|
||||
width: persistedWidth ?? width.default ?? width.min,
|
||||
minWidth: width.min ?? DEFAULT_MIN_WIDTH,
|
||||
maxWidth: width.max,
|
||||
};
|
||||
};
|
||||
|
||||
const buildAccessorFn = <TData>(
|
||||
colDef: TableColumnDef<TData>,
|
||||
): ((row: TData) => unknown) => {
|
||||
return (row: TData): unknown => {
|
||||
if (colDef.accessorFn) {
|
||||
return colDef.accessorFn(row);
|
||||
}
|
||||
if (colDef.accessorKey) {
|
||||
return (row as Record<string, unknown>)[colDef.accessorKey];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export function buildTanstackColumnDef<TData>(
|
||||
colDef: TableColumnDef<TData>,
|
||||
isRowActive?: (row: TData) => boolean,
|
||||
getRowKeyData?: (index: number) => RowKeyData | undefined,
|
||||
): ColumnDef<TData> {
|
||||
const isFixed = colDef.width?.fixed != null;
|
||||
const headerFn =
|
||||
typeof colDef.header === 'function' ? colDef.header : undefined;
|
||||
|
||||
return {
|
||||
id: colDef.id,
|
||||
header:
|
||||
typeof colDef.header === 'string'
|
||||
? colDef.header
|
||||
: (): ReactNode => headerFn?.() ?? null,
|
||||
accessorFn: buildAccessorFn(colDef),
|
||||
enableResizing: colDef.enableResize !== false && !isFixed,
|
||||
enableSorting: colDef.enableSort === true,
|
||||
cell: ({ row, getValue }): ReactNode => {
|
||||
const rowData = row.original;
|
||||
const keyData = getRowKeyData?.(row.index);
|
||||
return colDef.cell({
|
||||
row: rowData,
|
||||
value: getValue() as TData[any],
|
||||
isActive: isRowActive?.(rowData) ?? false,
|
||||
rowIndex: row.index,
|
||||
isExpanded: row.getIsExpanded(),
|
||||
canExpand: row.getCanExpand(),
|
||||
toggleExpanded: (): void => {
|
||||
row.toggleExpanded();
|
||||
},
|
||||
itemKey: keyData?.itemKey ?? '',
|
||||
groupMeta: keyData?.groupMeta,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,6 @@ export enum LOCALSTORAGE {
|
||||
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
|
||||
LOGGED_IN_USER_EMAIL = 'LOGGED_IN_USER_EMAIL',
|
||||
CHAT_SUPPORT = 'CHAT_SUPPORT',
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
export const REACT_QUERY_KEY = {
|
||||
/**
|
||||
* For any query that should support AutoRefresh and min/max time is from DateTimeSelectionV2
|
||||
* You can prefix the query with this KEY, it will allow the queries to be automatically refreshed
|
||||
* when the user clicks in the refresh button, or alert the user when the data is being refreshed.
|
||||
*/
|
||||
AUTO_REFRESH_QUERY: 'AUTO_REFRESH_QUERY',
|
||||
|
||||
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
|
||||
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
|
||||
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',
|
||||
|
||||
@@ -248,35 +248,5 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
roles: ['ADMIN', 'EDITOR'],
|
||||
perform: (): void => navigate(ROUTES.BILLING),
|
||||
},
|
||||
{
|
||||
id: 'my-settings-service-accounts',
|
||||
name: 'Go to Service Accounts',
|
||||
shortcut: [GlobalShortcutsName.NavigateToSettingsServiceAccounts],
|
||||
keywords: 'settings service accounts',
|
||||
section: 'Settings',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN'],
|
||||
perform: (): void => navigate(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
|
||||
},
|
||||
{
|
||||
id: 'my-settings-roles',
|
||||
name: 'Go to Roles',
|
||||
shortcut: [GlobalShortcutsName.NavigateToSettingsRoles],
|
||||
keywords: 'settings roles',
|
||||
section: 'Settings',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN'],
|
||||
perform: (): void => navigate(ROUTES.ROLES_SETTINGS),
|
||||
},
|
||||
{
|
||||
id: 'my-settings-members',
|
||||
name: 'Go to Members',
|
||||
shortcut: [GlobalShortcutsName.NavigateToSettingsMembers],
|
||||
keywords: 'settings members',
|
||||
section: 'Settings',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN'],
|
||||
perform: (): void => navigate(ROUTES.MEMBERS_SETTINGS),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -27,9 +27,6 @@ export const GlobalShortcuts = {
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
NavigateToSettingsNotificationChannels: 'shift+g+n',
|
||||
NavigateToSettingsServiceAccounts: 'shift+g+k',
|
||||
NavigateToSettingsRoles: 'shift+g+r',
|
||||
NavigateToSettingsMembers: 'shift+g+m',
|
||||
};
|
||||
|
||||
export const GlobalShortcutsName = {
|
||||
@@ -50,9 +47,6 @@ export const GlobalShortcutsName = {
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
NavigateToSettingsNotificationChannels: 'shift+g+n',
|
||||
NavigateToSettingsServiceAccounts: 'shift+g+k',
|
||||
NavigateToSettingsRoles: 'shift+g+r',
|
||||
NavigateToSettingsMembers: 'shift+g+m',
|
||||
NavigateToLogs: 'shift+l',
|
||||
NavigateToLogsPipelines: 'shift+l+p',
|
||||
NavigateToLogsViews: 'shift+l+v',
|
||||
@@ -80,7 +74,4 @@ export const GlobalShortcutsDescription = {
|
||||
'Navigate to Notification Channels Settings',
|
||||
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
|
||||
NavigateToLogsViews: 'Navigate to Logs Views',
|
||||
NavigateToSettingsServiceAccounts: 'Navigate to Service Accounts Settings',
|
||||
NavigateToSettingsRoles: 'Navigate to Roles Settings',
|
||||
NavigateToSettingsMembers: 'Navigate to Members Settings',
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user