Compare commits
21 Commits
v0.80.0-52
...
remove-dea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
807aa60906 | ||
|
|
208a5603a9 | ||
|
|
940313d28b | ||
|
|
0de779a866 | ||
|
|
9815ec7d81 | ||
|
|
a7cad0f1a5 | ||
|
|
a624b4758d | ||
|
|
5cc833b73f | ||
|
|
3eee3bfec1 | ||
|
|
01b308d507 | ||
|
|
ee5684b130 | ||
|
|
dcf627a683 | ||
|
|
2f8da5957b | ||
|
|
3f6f77d0e2 | ||
|
|
5bceffbeaa | ||
|
|
49c04eb9d9 | ||
|
|
c89a8cbb0c | ||
|
|
b6bb71f650 | ||
|
|
af135aa068 | ||
|
|
4a4e4d6779 | ||
|
|
fc604915ed |
42
.github/workflows/README.md
vendored
42
.github/workflows/README.md
vendored
@@ -1,42 +0,0 @@
|
||||
# Github actions
|
||||
|
||||
## Testing the UI manually on each PR
|
||||
|
||||
First we need to make sure the UI is ready
|
||||
* Check the `Start tunnel` step in `e2e-k8s/deploy-on-k3s-cluster` job and make sure you see `your url is: https://pull-<number>-signoz.loca.lt`
|
||||
* This job will run until the PR is merged or closed to keep the local tunneling alive
|
||||
- github will cancel this job if the PR wasn't merged after 6h
|
||||
- if the job was cancel, go to the action and press `Re-run all jobs`
|
||||
|
||||
Now you can open your browser at https://pull-<number>-signoz.loca.lt and check the UI.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
To run GitHub workflow, a few environment variables needs to add in GitHub secrets
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th> Variables </th>
|
||||
<th> Description </th>
|
||||
<th> Example </th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> REPONAME </td>
|
||||
<td> Provide the DockerHub user/organisation name of the image. </td>
|
||||
<td> signoz</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> DOCKERHUB_USERNAME </td>
|
||||
<td> Docker hub username </td>
|
||||
<td> signoz</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> DOCKERHUB_TOKEN </td>
|
||||
<td> Docker hub password/token with push permission </td>
|
||||
<td> **** </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> SONAR_TOKEN </td>
|
||||
<td> <a href="https://sonarcloud.io">SonarCloud</a> token </td>
|
||||
<td> **** </td>
|
||||
</tr>
|
||||
16
.github/workflows/remove-label.yaml
vendored
16
.github/workflows/remove-label.yaml
vendored
@@ -1,16 +0,0 @@
|
||||
name: remove-label
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [synchronize]
|
||||
|
||||
jobs:
|
||||
remove:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Remove label testing-deploy from PR
|
||||
uses: buildsville/add-remove-label@v2.0.0
|
||||
with:
|
||||
label: testing-deploy
|
||||
type: remove
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -103,6 +103,13 @@ telemetrystore:
|
||||
clickhouse:
|
||||
# The DSN to use for clickhouse.
|
||||
dsn: tcp://localhost:9000
|
||||
# The query settings for clickhouse.
|
||||
settings:
|
||||
max_execution_time: 0
|
||||
max_execution_time_leaf: 0
|
||||
timeout_before_checking_execution_speed: 0
|
||||
max_bytes_to_read: 0
|
||||
max_result_rows_for_ch_query: 0
|
||||
|
||||
##################### Prometheus #####################
|
||||
prometheus:
|
||||
|
||||
@@ -13,9 +13,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/apis/fields"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
preferencecore "github.com/SigNoz/signoz/pkg/modules/preference/core"
|
||||
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
@@ -26,14 +23,12 @@ import (
|
||||
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/preferencetypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type APIHandlerOptions struct {
|
||||
DataConnector interfaces.DataConnector
|
||||
SkipConfig *basemodel.SkipConfig
|
||||
PreferSpanMetrics bool
|
||||
AppDao dao.ModelDao
|
||||
RulesManager *rules.Manager
|
||||
@@ -60,13 +55,8 @@ type APIHandler struct {
|
||||
|
||||
// NewAPIHandler returns an APIHandler
|
||||
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, error) {
|
||||
preference := preference.NewAPI(preferencecore.NewPreference(preferencecore.NewStore(signoz.SQLStore), preferencetypes.NewDefaultPreferenceMap()))
|
||||
organizationAPI := implorganization.NewAPI(implorganization.NewModule(implorganization.NewStore(signoz.SQLStore)))
|
||||
organizationModule := implorganization.NewModule(implorganization.NewStore(signoz.SQLStore))
|
||||
|
||||
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
|
||||
Reader: opts.DataConnector,
|
||||
SkipConfig: opts.SkipConfig,
|
||||
PreferSpanMetrics: opts.PreferSpanMetrics,
|
||||
AppDao: opts.AppDao,
|
||||
RuleManager: opts.RulesManager,
|
||||
@@ -76,14 +66,9 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
|
||||
LogsParsingPipelineController: opts.LogsParsingPipelineController,
|
||||
Cache: opts.Cache,
|
||||
FluxInterval: opts.FluxInterval,
|
||||
UseLogsNewSchema: opts.UseLogsNewSchema,
|
||||
UseTraceNewSchema: opts.UseTraceNewSchema,
|
||||
AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager),
|
||||
FieldsAPI: fields.NewAPI(signoz.TelemetryStore),
|
||||
Signoz: signoz,
|
||||
Preference: preference,
|
||||
OrganizationAPI: organizationAPI,
|
||||
OrganizationModule: organizationModule,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -134,7 +134,7 @@ func (ah *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, registerError := baseauth.Register(ctx, req, ah.Signoz.Alertmanager, ah.OrganizationModule)
|
||||
_, registerError := baseauth.Register(ctx, req, ah.Signoz.Alertmanager, ah.Signoz.Modules.Organization)
|
||||
if !registerError.IsNil() {
|
||||
RespondError(w, apierr, nil)
|
||||
return
|
||||
@@ -152,7 +152,7 @@ func (ah *APIHandler) getInvite(w http.ResponseWriter, r *http.Request) {
|
||||
token := mux.Vars(r)["token"]
|
||||
sourceUrl := r.URL.Query().Get("ref")
|
||||
|
||||
inviteObject, err := baseauth.GetInvite(r.Context(), token, ah.OrganizationModule)
|
||||
inviteObject, err := baseauth.GetInvite(r.Context(), token, ah.Signoz.Modules.Organization)
|
||||
if err != nil {
|
||||
RespondError(w, model.BadRequest(err), nil)
|
||||
return
|
||||
|
||||
@@ -23,12 +23,10 @@ func NewDataConnector(
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
prometheus prometheus.Prometheus,
|
||||
cluster string,
|
||||
useLogsNewSchema bool,
|
||||
useTraceNewSchema bool,
|
||||
fluxIntervalForTraceDetail time.Duration,
|
||||
cache cache.Cache,
|
||||
) *ClickhouseReader {
|
||||
chReader := basechr.NewReader(sqlDB, telemetryStore, prometheus, cluster, useLogsNewSchema, useTraceNewSchema, fluxIntervalForTraceDetail, cache)
|
||||
chReader := basechr.NewReader(sqlDB, telemetryStore, prometheus, cluster, fluxIntervalForTraceDetail, cache)
|
||||
return &ClickhouseReader{
|
||||
conn: telemetryStore.ClickhouseDB(),
|
||||
appdb: sqlDB,
|
||||
|
||||
@@ -45,33 +45,23 @@ import (
|
||||
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"
|
||||
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/telemetry"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const AppDbEngine = "sqlite"
|
||||
|
||||
type ServerOptions struct {
|
||||
Config signoz.Config
|
||||
SigNoz *signoz.SigNoz
|
||||
PromConfigPath string
|
||||
SkipTopLvlOpsPath string
|
||||
HTTPHostPort string
|
||||
PrivateHostPort string
|
||||
// alert specific params
|
||||
DisableRules bool
|
||||
RuleRepoURL string
|
||||
Config signoz.Config
|
||||
SigNoz *signoz.SigNoz
|
||||
HTTPHostPort string
|
||||
PrivateHostPort string
|
||||
PreferSpanMetrics bool
|
||||
CacheConfigPath string
|
||||
FluxInterval string
|
||||
FluxIntervalForTraceDetail string
|
||||
Cluster string
|
||||
GatewayUrl string
|
||||
UseLogsNewSchema bool
|
||||
UseTraceNewSchema bool
|
||||
Jwt *authtypes.JWT
|
||||
}
|
||||
|
||||
@@ -140,20 +130,10 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
|
||||
serverOptions.SigNoz.TelemetryStore,
|
||||
serverOptions.SigNoz.Prometheus,
|
||||
serverOptions.Cluster,
|
||||
serverOptions.UseLogsNewSchema,
|
||||
serverOptions.UseTraceNewSchema,
|
||||
fluxIntervalForTraceDetail,
|
||||
serverOptions.SigNoz.Cache,
|
||||
)
|
||||
|
||||
skipConfig := &basemodel.SkipConfig{}
|
||||
if serverOptions.SkipTopLvlOpsPath != "" {
|
||||
// read skip config
|
||||
skipConfig, err = basemodel.ReadSkipConfig(serverOptions.SkipTopLvlOpsPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var c cache.Cache
|
||||
if serverOptions.CacheConfigPath != "" {
|
||||
cacheOpts, err := cache.LoadFromYAMLCacheConfigFile(serverOptions.CacheConfigPath)
|
||||
@@ -164,13 +144,9 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
|
||||
}
|
||||
|
||||
rm, err := makeRulesManager(
|
||||
serverOptions.RuleRepoURL,
|
||||
serverOptions.SigNoz.SQLStore.SQLxDB(),
|
||||
reader,
|
||||
c,
|
||||
serverOptions.DisableRules,
|
||||
serverOptions.UseLogsNewSchema,
|
||||
serverOptions.UseTraceNewSchema,
|
||||
serverOptions.SigNoz.Alertmanager,
|
||||
serverOptions.SigNoz.SQLStore,
|
||||
serverOptions.SigNoz.TelemetryStore,
|
||||
@@ -238,7 +214,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
|
||||
|
||||
apiOpts := api.APIHandlerOptions{
|
||||
DataConnector: reader,
|
||||
SkipConfig: skipConfig,
|
||||
PreferSpanMetrics: serverOptions.PreferSpanMetrics,
|
||||
AppDao: modelDao,
|
||||
RulesManager: rm,
|
||||
@@ -252,8 +227,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
|
||||
FluxInterval: fluxInterval,
|
||||
Gateway: gatewayProxy,
|
||||
GatewayUrl: serverOptions.GatewayUrl,
|
||||
UseLogsNewSchema: serverOptions.UseLogsNewSchema,
|
||||
UseTraceNewSchema: serverOptions.UseTraceNewSchema,
|
||||
JWT: serverOptions.Jwt,
|
||||
}
|
||||
|
||||
@@ -263,8 +236,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
// logger: logger,
|
||||
// tracer: tracer,
|
||||
ruleManager: rm,
|
||||
serverOptions: serverOptions,
|
||||
unavailableChannel: make(chan healthcheck.Status),
|
||||
@@ -356,7 +327,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.RegisterMessagingQueuesRoutes(r, am)
|
||||
apiHandler.RegisterThirdPartyApiRoutes(r, am)
|
||||
apiHandler.MetricExplorerRoutes(r, am)
|
||||
apiHandler.RegisterTraceFunnelsRoutes(r, am)
|
||||
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
@@ -412,13 +382,7 @@ func (s *Server) initListeners() error {
|
||||
|
||||
// Start listening on http and private http port concurrently
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
|
||||
// initiate rule manager first
|
||||
if !s.serverOptions.DisableRules {
|
||||
s.ruleManager.Start(ctx)
|
||||
} else {
|
||||
zap.L().Info("msg: Rules disabled as rules.disable is set to TRUE")
|
||||
}
|
||||
s.ruleManager.Start(ctx)
|
||||
|
||||
err := s.initListeners()
|
||||
if err != nil {
|
||||
@@ -509,13 +473,9 @@ func (s *Server) Stop() error {
|
||||
}
|
||||
|
||||
func makeRulesManager(
|
||||
ruleRepoURL string,
|
||||
db *sqlx.DB,
|
||||
ch baseint.Reader,
|
||||
cache cache.Cache,
|
||||
disableRules bool,
|
||||
useLogsNewSchema bool,
|
||||
useTraceNewSchema bool,
|
||||
alertmanager alertmanager.Alertmanager,
|
||||
sqlstore sqlstore.SQLStore,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
@@ -525,17 +485,13 @@ func makeRulesManager(
|
||||
managerOpts := &baserules.ManagerOptions{
|
||||
TelemetryStore: telemetryStore,
|
||||
Prometheus: prometheus,
|
||||
RepoURL: ruleRepoURL,
|
||||
DBConn: db,
|
||||
Context: context.Background(),
|
||||
Logger: zap.L(),
|
||||
DisableRules: disableRules,
|
||||
Reader: ch,
|
||||
Cache: cache,
|
||||
EvalDelay: baseconst.GetEvalDelay(),
|
||||
PrepareTaskFunc: rules.PrepareTaskFunc,
|
||||
UseLogsNewSchema: useLogsNewSchema,
|
||||
UseTraceNewSchema: useTraceNewSchema,
|
||||
PrepareTestRuleFunc: rules.TestNotification,
|
||||
Alertmanager: alertmanager,
|
||||
SQLStore: sqlstore,
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
// Deprecated: Please use the logger from pkg/instrumentation.
|
||||
func initZapLog() *zap.Logger {
|
||||
config := zap.NewProductionConfig()
|
||||
config.EncoderConfig.TimeKey = "timestamp"
|
||||
@@ -50,21 +51,31 @@ func main() {
|
||||
var gatewayUrl string
|
||||
var useLicensesV3 bool
|
||||
|
||||
// Deprecated
|
||||
flag.BoolVar(&useLogsNewSchema, "use-logs-new-schema", false, "use logs_v2 schema for logs")
|
||||
// Deprecated
|
||||
flag.BoolVar(&useTraceNewSchema, "use-trace-new-schema", false, "use new schema for traces")
|
||||
// Deprecated
|
||||
flag.StringVar(&promConfigPath, "config", "./config/prometheus.yml", "(prometheus config to read metrics)")
|
||||
// Deprecated
|
||||
flag.StringVar(&skipTopLvlOpsPath, "skip-top-level-ops", "", "(config file to skip top level operations)")
|
||||
// Deprecated
|
||||
flag.BoolVar(&disableRules, "rules.disable", false, "(disable rule evaluation)")
|
||||
flag.BoolVar(&preferSpanMetrics, "prefer-span-metrics", false, "(prefer span metrics for service level metrics)")
|
||||
// Deprecated
|
||||
flag.IntVar(&maxIdleConns, "max-idle-conns", 50, "(number of connections to maintain in the pool.)")
|
||||
// Deprecated
|
||||
flag.IntVar(&maxOpenConns, "max-open-conns", 100, "(max connections for use at any time.)")
|
||||
// Deprecated
|
||||
flag.DurationVar(&dialTimeout, "dial-timeout", 5*time.Second, "(the maximum time to establish a connection.)")
|
||||
// Deprecated
|
||||
flag.StringVar(&ruleRepoURL, "rules.repo-url", baseconst.AlertHelpPage, "(host address used to build rule link in alert messages)")
|
||||
flag.StringVar(&cacheConfigPath, "experimental.cache-config", "", "(cache config to use)")
|
||||
flag.StringVar(&fluxInterval, "flux-interval", "5m", "(the interval to exclude data from being cached to avoid incorrect cache for data in motion)")
|
||||
flag.StringVar(&fluxIntervalForTraceDetail, "flux-interval-trace-detail", "2m", "(the interval to exclude data from being cached to avoid incorrect cache for trace data in motion)")
|
||||
flag.StringVar(&cluster, "cluster", "cluster", "(cluster name - defaults to 'cluster')")
|
||||
flag.StringVar(&gatewayUrl, "gateway-url", "", "(url to the gateway)")
|
||||
// Deprecated
|
||||
flag.BoolVar(&useLicensesV3, "use-licenses-v3", false, "use licenses_v3 schema for licenses")
|
||||
flag.Parse()
|
||||
|
||||
@@ -121,19 +132,13 @@ func main() {
|
||||
Config: config,
|
||||
SigNoz: signoz,
|
||||
HTTPHostPort: baseconst.HTTPHostPort,
|
||||
PromConfigPath: promConfigPath,
|
||||
SkipTopLvlOpsPath: skipTopLvlOpsPath,
|
||||
PreferSpanMetrics: preferSpanMetrics,
|
||||
PrivateHostPort: baseconst.PrivateHostPort,
|
||||
DisableRules: disableRules,
|
||||
RuleRepoURL: ruleRepoURL,
|
||||
CacheConfigPath: cacheConfigPath,
|
||||
FluxInterval: fluxInterval,
|
||||
FluxIntervalForTraceDetail: fluxIntervalForTraceDetail,
|
||||
Cluster: cluster,
|
||||
GatewayUrl: gatewayUrl,
|
||||
UseLogsNewSchema: useLogsNewSchema,
|
||||
UseTraceNewSchema: useTraceNewSchema,
|
||||
Jwt: jwt,
|
||||
}
|
||||
|
||||
|
||||
@@ -25,8 +25,6 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
ruleId,
|
||||
opts.Rule,
|
||||
opts.Reader,
|
||||
opts.UseLogsNewSchema,
|
||||
opts.UseTraceNewSchema,
|
||||
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
)
|
||||
@@ -123,8 +121,6 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
|
||||
alertname,
|
||||
parsedRule,
|
||||
opts.Reader,
|
||||
opts.UseLogsNewSchema,
|
||||
opts.UseTraceNewSchema,
|
||||
baserules.WithSendAlways(),
|
||||
baserules.WithSendUnmatched(),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
|
||||
@@ -194,7 +194,7 @@ func (dialect *dialect) RenameColumn(ctx context.Context, bun bun.IDB, table str
|
||||
}
|
||||
|
||||
if !oldColumnExists {
|
||||
return false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("old column: %s doesn't exist", oldColumnName))
|
||||
return false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "old column: %s doesn't exist", oldColumnName)
|
||||
}
|
||||
|
||||
_, err = bun.
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/jmoiron/sqlx"
|
||||
@@ -87,3 +89,20 @@ func (provider *provider) BunDBCtx(ctx context.Context) bun.IDB {
|
||||
func (provider *provider) RunInTxCtx(ctx context.Context, opts *sql.TxOptions, cb func(ctx context.Context) error) error {
|
||||
return provider.bundb.RunInTxCtx(ctx, opts, cb)
|
||||
}
|
||||
|
||||
func (provider *provider) WrapNotFoundErrf(err error, code errors.Code, format string, args ...any) error {
|
||||
if err == sql.ErrNoRows {
|
||||
return errors.Wrapf(err, errors.TypeNotFound, code, format, args...)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (provider *provider) WrapAlreadyExistsErrf(err error, code errors.Code, format string, args ...any) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return errors.Wrapf(err, errors.TypeAlreadyExists, code, format, args...)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"ansi-to-html": "0.7.2",
|
||||
"antd": "5.11.0",
|
||||
"antd-table-saveas-excel": "2.2.1",
|
||||
"axios": "1.7.7",
|
||||
"axios": "1.8.2",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^29.6.4",
|
||||
"babel-loader": "9.1.3",
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 5.9 KiB |
@@ -1 +0,0 @@
|
||||
<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><g stroke="#C0C1C3" stroke-width="1.167" stroke-linecap="round" stroke-linejoin="round"><path d="m12.192 3.18-1.167 2.33-.583 1.165M7.31 12.74a.583.583 0 0 1-.835-.24L1.808 3.179"/><path d="M7 1.167c2.9 0 5.25.783 5.25 1.75 0 .966-2.35 1.75-5.25 1.75s-5.25-.784-5.25-1.75c0-.967 2.35-1.75 5.25-1.75ZM8.75 10.5h3.5M10.5 12.25v-3.5"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 418 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)" stroke-linecap="round" stroke-linejoin="round"><path d="M8 14.666A6.667 6.667 0 1 0 8 1.333a6.667 6.667 0 0 0 0 13.333Z" fill="#C0C1C3" stroke="#C0C1C3" stroke-width="2"/><path d="M8 11.333v-4H6.333M8 4.667h.007" stroke="#121317" stroke-width="1.333"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 439 B |
@@ -47,10 +47,9 @@ export const TracesFunnels = Loadable(
|
||||
import(/* webpackChunkName: "Traces Funnels" */ 'pages/TracesModulePage'),
|
||||
);
|
||||
export const TracesFunnelDetails = Loadable(
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesModulePage'
|
||||
/* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesFunnelDetails'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -65,10 +64,6 @@ export const TraceDetail = Loadable(
|
||||
),
|
||||
);
|
||||
|
||||
export const UsageExplorerPage = Loadable(
|
||||
() => import(/* webpackChunkName: "UsageExplorerPage" */ 'modules/Usage'),
|
||||
);
|
||||
|
||||
export const SignupPage = Loadable(
|
||||
() => import(/* webpackChunkName: "SignupPage" */ 'pages/SignUp'),
|
||||
);
|
||||
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
TracesFunnels,
|
||||
TracesSaveViews,
|
||||
UnAuthorized,
|
||||
UsageExplorerPage,
|
||||
WorkspaceAccessRestricted,
|
||||
WorkspaceBlocked,
|
||||
WorkspaceSuspended,
|
||||
@@ -155,13 +154,6 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
key: 'SETTINGS',
|
||||
},
|
||||
{
|
||||
path: ROUTES.USAGE_EXPLORER,
|
||||
exact: true,
|
||||
component: UsageExplorerPage,
|
||||
isPrivate: true,
|
||||
key: 'USAGE_EXPLORER',
|
||||
},
|
||||
{
|
||||
path: ROUTES.ALL_DASHBOARD,
|
||||
exact: true,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/logs/getLogs';
|
||||
|
||||
const GetLogs = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
try {
|
||||
const data = await axios.get(`/logs`, {
|
||||
params: props,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: data.data.results,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default GetLogs;
|
||||
@@ -1,19 +0,0 @@
|
||||
import apiV1 from 'api/apiV1';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
|
||||
// 10 min in ms
|
||||
const TIMEOUT_IN_MS = 10 * 60 * 1000;
|
||||
|
||||
export const LiveTail = (queryParams: string): EventSourcePolyfill =>
|
||||
new EventSourcePolyfill(
|
||||
`${ENVIRONMENT.baseURL}${apiV1}logs/tail?${queryParams}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${getLocalStorageKey(LOCALSTORAGE.AUTH_TOKEN)}`,
|
||||
},
|
||||
heartbeatTimeout: TIMEOUT_IN_MS,
|
||||
},
|
||||
);
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
CreateFunnelPayload,
|
||||
CreateFunnelResponse,
|
||||
FunnelData,
|
||||
FunnelStepData,
|
||||
} from 'types/api/traceFunnels';
|
||||
|
||||
const FUNNELS_BASE_PATH = '/trace-funnels';
|
||||
@@ -55,10 +54,10 @@ export const getFunnelsList = async ({
|
||||
};
|
||||
|
||||
export const getFunnelById = async (
|
||||
funnelId?: string,
|
||||
funnelId: string,
|
||||
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
|
||||
const response: AxiosResponse = await axios.get(
|
||||
`${FUNNELS_BASE_PATH}/${funnelId}`,
|
||||
`${FUNNELS_BASE_PATH}/get/${funnelId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -98,7 +97,7 @@ export const deleteFunnel = async (
|
||||
payload: DeleteFunnelPayload,
|
||||
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
|
||||
const response: AxiosResponse = await axios.delete(
|
||||
`${FUNNELS_BASE_PATH}/${payload.id}`,
|
||||
`${FUNNELS_BASE_PATH}/delete/${payload.id}`,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -108,267 +107,3 @@ export const deleteFunnel = async (
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
export interface UpdateFunnelStepsPayload {
|
||||
funnel_id: string;
|
||||
steps: FunnelStepData[];
|
||||
updated_timestamp: number;
|
||||
}
|
||||
|
||||
export const updateFunnelSteps = async (
|
||||
payload: UpdateFunnelStepsPayload,
|
||||
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
|
||||
const response: AxiosResponse = await axios.put(
|
||||
`${FUNNELS_BASE_PATH}/steps/update`,
|
||||
payload,
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Funnel steps updated successfully',
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
export interface ValidateFunnelPayload {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
}
|
||||
|
||||
export interface ValidateFunnelResponse {
|
||||
status: string;
|
||||
data: Array<{
|
||||
timestamp: string;
|
||||
data: {
|
||||
trace_id: string;
|
||||
};
|
||||
}> | null;
|
||||
}
|
||||
|
||||
export const validateFunnelSteps = async (
|
||||
funnelId: string,
|
||||
payload: ValidateFunnelPayload,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SuccessResponse<ValidateFunnelResponse> | ErrorResponse> => {
|
||||
const response = await axios.post(
|
||||
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/validate`,
|
||||
payload,
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
export interface UpdateFunnelStepDetailsPayload {
|
||||
funnel_id: string;
|
||||
steps: Array<{
|
||||
step_name: string;
|
||||
description: string;
|
||||
}>;
|
||||
updated_timestamp: number;
|
||||
}
|
||||
|
||||
export const updateFunnelStepDetails = async ({
|
||||
stepOrder,
|
||||
payload,
|
||||
}: {
|
||||
stepOrder: number;
|
||||
payload: UpdateFunnelStepDetailsPayload;
|
||||
}): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
|
||||
const response: AxiosResponse = await axios.put(
|
||||
`${FUNNELS_BASE_PATH}/steps/${stepOrder}/update`,
|
||||
payload,
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Funnel step details updated successfully',
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
interface UpdateFunnelDescriptionPayload {
|
||||
funnel_id: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const saveFunnelDescription = async (
|
||||
payload: UpdateFunnelDescriptionPayload,
|
||||
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
|
||||
const response: AxiosResponse = await axios.post(
|
||||
`${FUNNELS_BASE_PATH}/save`,
|
||||
payload,
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Funnel description updated successfully',
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
export interface FunnelOverviewPayload {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
step_start?: number;
|
||||
step_end?: number;
|
||||
}
|
||||
|
||||
export interface FunnelOverviewResponse {
|
||||
status: string;
|
||||
data: Array<{
|
||||
timestamp: string;
|
||||
data: {
|
||||
avg_duration: number;
|
||||
avg_rate: number;
|
||||
conversion_rate: number | null;
|
||||
errors: number;
|
||||
p99_latency: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export const getFunnelOverview = async (
|
||||
funnelId: string,
|
||||
payload: FunnelOverviewPayload,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SuccessResponse<FunnelOverviewResponse> | ErrorResponse> => {
|
||||
const response = await axios.post(
|
||||
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/overview`,
|
||||
payload,
|
||||
{
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
export interface SlowTracesPayload {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
step_a_order: number;
|
||||
step_b_order: number;
|
||||
}
|
||||
|
||||
export interface SlowTraceData {
|
||||
status: string;
|
||||
data: Array<{
|
||||
timestamp: string;
|
||||
data: {
|
||||
duration_ms: string;
|
||||
span_count: number;
|
||||
trace_id: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export const getFunnelSlowTraces = async (
|
||||
funnelId: string,
|
||||
payload: SlowTracesPayload,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SuccessResponse<SlowTraceData> | ErrorResponse> => {
|
||||
const response = await axios.post(
|
||||
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/slow-traces`,
|
||||
payload,
|
||||
{
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
export interface ErrorTracesPayload {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
step_a_order: number;
|
||||
step_b_order: number;
|
||||
}
|
||||
|
||||
export interface ErrorTraceData {
|
||||
status: string;
|
||||
data: Array<{
|
||||
timestamp: string;
|
||||
data: {
|
||||
duration_ms: string;
|
||||
span_count: number;
|
||||
trace_id: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export const getFunnelErrorTraces = async (
|
||||
funnelId: string,
|
||||
payload: ErrorTracesPayload,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SuccessResponse<ErrorTraceData> | ErrorResponse> => {
|
||||
const response: AxiosResponse = await axios.post(
|
||||
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/error-traces`,
|
||||
payload,
|
||||
{
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
export interface FunnelStepsPayload {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
}
|
||||
|
||||
export interface FunnelStepGraphMetrics {
|
||||
[key: `total_s${number}_spans`]: number;
|
||||
[key: `total_s${number}_errored_spans`]: number;
|
||||
}
|
||||
|
||||
export interface FunnelStepsResponse {
|
||||
status: string;
|
||||
data: Array<{
|
||||
timestamp: string;
|
||||
data: FunnelStepGraphMetrics;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const getFunnelSteps = async (
|
||||
funnelId: string,
|
||||
payload: FunnelStepsPayload,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SuccessResponse<FunnelStepsResponse> | ErrorResponse> => {
|
||||
const response = await axios.post(
|
||||
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/steps`,
|
||||
payload,
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,27 +9,18 @@ import { useCeleryFilterOptions } from 'components/CeleryTask/useCeleryFilterOpt
|
||||
import { SelectMaxTagPlaceholder } from 'components/MessagingQueues/MQCommon/MQCommon';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useCallback } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
export interface SelectOptionConfig {
|
||||
interface SelectOptionConfig {
|
||||
placeholder: string;
|
||||
queryParam: QueryParams;
|
||||
filterType: string | string[];
|
||||
shouldSetQueryParams?: boolean;
|
||||
onChange?: (value: string | string[]) => void;
|
||||
values?: string | string[];
|
||||
isMultiple?: boolean;
|
||||
}
|
||||
|
||||
export function FilterSelect({
|
||||
function FilterSelect({
|
||||
placeholder,
|
||||
queryParam,
|
||||
filterType,
|
||||
values,
|
||||
shouldSetQueryParams,
|
||||
onChange,
|
||||
isMultiple,
|
||||
}: SelectOptionConfig): JSX.Element {
|
||||
const { handleSearch, isFetching, options } = useCeleryFilterOptions(
|
||||
filterType,
|
||||
@@ -39,46 +30,12 @@ export function FilterSelect({
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
|
||||
// Use externally provided `values` if `shouldSetQueryParams` is false, otherwise get from URL params.
|
||||
const selectValue =
|
||||
!shouldSetQueryParams && !!values?.length
|
||||
? values
|
||||
: getValuesFromQueryParams(queryParam, urlQuery) || [];
|
||||
|
||||
const handleSelectChange = useCallback(
|
||||
(value: string | string[]): void => {
|
||||
handleSearch('');
|
||||
if (shouldSetQueryParams) {
|
||||
setQueryParamsFromOptions(
|
||||
value as string[],
|
||||
urlQuery,
|
||||
history,
|
||||
location,
|
||||
queryParam,
|
||||
);
|
||||
}
|
||||
onChange?.(value);
|
||||
},
|
||||
[
|
||||
handleSearch,
|
||||
shouldSetQueryParams,
|
||||
urlQuery,
|
||||
history,
|
||||
location,
|
||||
queryParam,
|
||||
onChange,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
key={filterType.toString()}
|
||||
placeholder={placeholder}
|
||||
showSearch
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...(isMultiple ? { mode: 'multiple' } : {})}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...(isMultiple ? { mode: 'multiple' } : {})}
|
||||
mode="multiple"
|
||||
options={options}
|
||||
loading={isFetching}
|
||||
className="config-select-option"
|
||||
@@ -86,7 +43,7 @@ export function FilterSelect({
|
||||
maxTagCount={4}
|
||||
allowClear
|
||||
maxTagPlaceholder={SelectMaxTagPlaceholder}
|
||||
value={selectValue}
|
||||
value={getValuesFromQueryParams(queryParam, urlQuery) || []}
|
||||
notFoundContent={
|
||||
isFetching ? (
|
||||
<span>
|
||||
@@ -96,25 +53,14 @@ export function FilterSelect({
|
||||
<span>No {placeholder} found</span>
|
||||
)
|
||||
}
|
||||
onChange={handleSelectChange}
|
||||
onChange={(value): void => {
|
||||
handleSearch('');
|
||||
setQueryParamsFromOptions(value, urlQuery, history, location, queryParam);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
FilterSelect.defaultProps = {
|
||||
shouldSetQueryParams: true,
|
||||
onChange: (): void => {},
|
||||
values: [],
|
||||
isMultiple: true,
|
||||
};
|
||||
|
||||
FilterSelect.defaultProps = {
|
||||
shouldSetQueryParams: true,
|
||||
onChange: (): void => {},
|
||||
values: [],
|
||||
isMultiple: true,
|
||||
};
|
||||
|
||||
function CeleryOverviewConfigOptions(): JSX.Element {
|
||||
const selectConfigs: SelectOptionConfig[] = [
|
||||
{
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
.change-percentage-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 50px;
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
&__label {
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
line-height: normal;
|
||||
}
|
||||
&--positive {
|
||||
.change-percentage-pill {
|
||||
&__icon {
|
||||
color: var(--bg-forest-500);
|
||||
}
|
||||
&__label {
|
||||
color: var(--bg-forest-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
&--negative {
|
||||
background: rgba(229, 72, 77, 0.1);
|
||||
|
||||
.change-percentage-pill {
|
||||
&__icon {
|
||||
color: var(--bg-cherry-500);
|
||||
}
|
||||
&__label {
|
||||
color: var(--bg-cherry-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import './ChangePercentagePill.styles.scss';
|
||||
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import cx from 'classnames';
|
||||
import { ArrowDown, ArrowUp } from 'lucide-react';
|
||||
|
||||
interface ChangePercentagePillProps {
|
||||
percentage: number;
|
||||
direction: number;
|
||||
}
|
||||
function ChangePercentagePill({
|
||||
percentage,
|
||||
direction,
|
||||
}: ChangePercentagePillProps): JSX.Element | null {
|
||||
if (direction === 0 || percentage === 0) {
|
||||
return null;
|
||||
}
|
||||
const isPositive = direction > 0;
|
||||
return (
|
||||
<div
|
||||
className={cx('change-percentage-pill', {
|
||||
'change-percentage-pill--positive': isPositive,
|
||||
'change-percentage-pill--negative': !isPositive,
|
||||
})}
|
||||
>
|
||||
<div className="change-percentage-pill__icon">
|
||||
{isPositive ? (
|
||||
<ArrowUp size={12} color={Color.BG_FOREST_500} />
|
||||
) : (
|
||||
<ArrowDown size={12} color={Color.BG_CHERRY_500} />
|
||||
)}
|
||||
</div>
|
||||
<div className="change-percentage-pill__label">{percentage}%</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangePercentagePill;
|
||||
13
frontend/src/components/NewSelect/CustomMultiSelect.scss
Normal file
13
frontend/src/components/NewSelect/CustomMultiSelect.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
.custom-multiselect-dropdown {
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: #e8e8e8;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.all-option {
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
1765
frontend/src/components/NewSelect/CustomMultiSelect.tsx
Normal file
1765
frontend/src/components/NewSelect/CustomMultiSelect.tsx
Normal file
File diff suppressed because it is too large
Load Diff
606
frontend/src/components/NewSelect/CustomSelect.tsx
Normal file
606
frontend/src/components/NewSelect/CustomSelect.tsx
Normal file
@@ -0,0 +1,606 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
/* eslint-disable react/function-component-definition */
|
||||
import './styles.scss';
|
||||
|
||||
import {
|
||||
CloseOutlined,
|
||||
DownOutlined,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Select } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { capitalize, isEmpty } from 'lodash-es';
|
||||
import { ArrowDown, ArrowUp } from 'lucide-react';
|
||||
import type { BaseSelectRef } from 'rc-select';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { CustomSelectProps, OptionData } from './types';
|
||||
import {
|
||||
filterOptionsBySearch,
|
||||
prioritizeOrAddOptionForSingleSelect,
|
||||
SPACEKEY,
|
||||
} from './utils';
|
||||
|
||||
/**
|
||||
* CustomSelect Component
|
||||
*
|
||||
*/
|
||||
const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
placeholder = 'Search...',
|
||||
className,
|
||||
loading = false,
|
||||
onSearch,
|
||||
options = [],
|
||||
value,
|
||||
onChange,
|
||||
defaultActiveFirstOption = true,
|
||||
noDataMessage,
|
||||
onClear,
|
||||
getPopupContainer,
|
||||
dropdownRender,
|
||||
highlightSearch = true,
|
||||
placement = 'bottomLeft',
|
||||
popupMatchSelectWidth = true,
|
||||
popupClassName,
|
||||
errorMessage,
|
||||
allowClear = false,
|
||||
onRetry,
|
||||
...rest
|
||||
}) => {
|
||||
// ===== State & Refs =====
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(-1);
|
||||
|
||||
// Refs for element access and scroll behavior
|
||||
const selectRef = useRef<BaseSelectRef>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
|
||||
// ===== Option Filtering & Processing Utilities =====
|
||||
|
||||
/**
|
||||
* Checks if a label exists in the provided options
|
||||
*/
|
||||
const isLabelPresent = useCallback(
|
||||
(options: OptionData[], label: string): boolean =>
|
||||
options.some((option) => {
|
||||
const lowerLabel = label.toLowerCase();
|
||||
|
||||
// Check in nested options if they exist
|
||||
if ('options' in option && Array.isArray(option.options)) {
|
||||
return option.options.some(
|
||||
(subOption) => subOption.label.toLowerCase() === lowerLabel,
|
||||
);
|
||||
}
|
||||
|
||||
// Check top-level option
|
||||
return option.label.toLowerCase() === lowerLabel;
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Separates section and non-section options
|
||||
*/
|
||||
const splitOptions = useCallback((options: OptionData[]): {
|
||||
sectionOptions: OptionData[];
|
||||
nonSectionOptions: OptionData[];
|
||||
} => {
|
||||
const sectionOptions: OptionData[] = [];
|
||||
const nonSectionOptions: OptionData[] = [];
|
||||
|
||||
options.forEach((option) => {
|
||||
if ('options' in option && Array.isArray(option.options)) {
|
||||
sectionOptions.push(option);
|
||||
} else {
|
||||
nonSectionOptions.push(option);
|
||||
}
|
||||
});
|
||||
|
||||
return { sectionOptions, nonSectionOptions };
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Apply search filtering to options
|
||||
*/
|
||||
const filteredOptions = useMemo(
|
||||
(): OptionData[] => filterOptionsBySearch(options, searchText),
|
||||
[options, searchText],
|
||||
);
|
||||
|
||||
// ===== UI & Rendering Functions =====
|
||||
|
||||
/**
|
||||
* Highlights matched text in search results
|
||||
*/
|
||||
const highlightMatchedText = useCallback(
|
||||
(text: string, searchQuery: string): React.ReactNode => {
|
||||
if (!searchQuery || !highlightSearch) return text;
|
||||
|
||||
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
// Create a deterministic but unique key
|
||||
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
||||
|
||||
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||
<span key={uniqueKey} className="highlight-text">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
},
|
||||
[highlightSearch],
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders an individual option with proper keyboard navigation support
|
||||
*/
|
||||
const renderOptionItem = useCallback(
|
||||
(
|
||||
option: OptionData,
|
||||
isSelected: boolean,
|
||||
index?: number,
|
||||
): React.ReactElement => {
|
||||
const handleSelection = (): void => {
|
||||
if (onChange) {
|
||||
onChange(option.value, option);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = index === activeOptionIndex;
|
||||
const optionId = `option-${index}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
handleSelection();
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === SPACEKEY) {
|
||||
e.preventDefault();
|
||||
handleSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<div className="option-content">
|
||||
<div>{highlightMatchedText(String(option.label || ''), searchText)}</div>
|
||||
{option.type === 'custom' && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[highlightMatchedText, searchText, onChange, activeOptionIndex],
|
||||
);
|
||||
|
||||
/**
|
||||
* Helper function to render option with index tracking
|
||||
*/
|
||||
const renderOptionWithIndex = useCallback(
|
||||
(option: OptionData, isSelected: boolean, idx: number) =>
|
||||
renderOptionItem(option, isSelected, idx),
|
||||
[renderOptionItem],
|
||||
);
|
||||
|
||||
/**
|
||||
* Custom clear button renderer
|
||||
*/
|
||||
const clearIcon = useCallback(
|
||||
() => (
|
||||
<CloseOutlined
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
if (onChange) onChange(undefined, []);
|
||||
if (onClear) onClear();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[onChange, onClear],
|
||||
);
|
||||
|
||||
// ===== Event Handlers =====
|
||||
|
||||
/**
|
||||
* Handles search input changes
|
||||
*/
|
||||
const handleSearch = useCallback(
|
||||
(value: string): void => {
|
||||
const trimmedValue = value.trim();
|
||||
setSearchText(trimmedValue);
|
||||
|
||||
if (onSearch) onSearch(trimmedValue);
|
||||
},
|
||||
[onSearch],
|
||||
);
|
||||
|
||||
/**
|
||||
* Prevents event propagation for dropdown clicks
|
||||
*/
|
||||
const handleDropdownClick = useCallback((e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Comprehensive keyboard navigation handler
|
||||
*/
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent): void => {
|
||||
// Handle keyboard navigation when dropdown is open
|
||||
if (isOpen) {
|
||||
// Get flattened list of all selectable options
|
||||
const getFlatOptions = (): OptionData[] => {
|
||||
if (!filteredOptions) return [];
|
||||
|
||||
const flatList: OptionData[] = [];
|
||||
|
||||
// Process options
|
||||
const { sectionOptions, nonSectionOptions } = splitOptions(
|
||||
isEmpty(value)
|
||||
? filteredOptions
|
||||
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value),
|
||||
);
|
||||
|
||||
// Add custom option if needed
|
||||
if (!isEmpty(searchText) && !isLabelPresent(filteredOptions, searchText)) {
|
||||
flatList.push({
|
||||
label: searchText,
|
||||
value: searchText,
|
||||
type: 'custom',
|
||||
});
|
||||
}
|
||||
|
||||
// Add all options to flat list
|
||||
flatList.push(...nonSectionOptions);
|
||||
sectionOptions.forEach((section) => {
|
||||
if (section.options) {
|
||||
flatList.push(...section.options);
|
||||
}
|
||||
});
|
||||
|
||||
return flatList;
|
||||
};
|
||||
|
||||
const options = getFlatOptions();
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : options.length - 1,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'Tab':
|
||||
// Tab navigation with Shift key support
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : options.length - 1,
|
||||
);
|
||||
} else {
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (activeOptionIndex >= 0 && activeOptionIndex < options.length) {
|
||||
// Select the focused option
|
||||
const selectedOption = options[activeOptionIndex];
|
||||
if (onChange) {
|
||||
onChange(selectedOption.value, selectedOption);
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
}
|
||||
} else if (!isEmpty(searchText)) {
|
||||
// Add custom value when no option is focused
|
||||
const customOption = {
|
||||
label: searchText,
|
||||
value: searchText,
|
||||
type: 'custom',
|
||||
};
|
||||
if (onChange) {
|
||||
onChange(customOption.value, customOption);
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
break;
|
||||
|
||||
case ' ': // Space key
|
||||
if (activeOptionIndex >= 0 && activeOptionIndex < options.length) {
|
||||
e.preventDefault();
|
||||
const selectedOption = options[activeOptionIndex];
|
||||
if (onChange) {
|
||||
onChange(selectedOption.value, selectedOption);
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else if (e.key === 'ArrowDown' || e.key === 'Tab') {
|
||||
// Open dropdown when Down or Tab is pressed while closed
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
setActiveOptionIndex(0);
|
||||
}
|
||||
},
|
||||
[
|
||||
isOpen,
|
||||
activeOptionIndex,
|
||||
filteredOptions,
|
||||
searchText,
|
||||
onChange,
|
||||
splitOptions,
|
||||
value,
|
||||
isLabelPresent,
|
||||
],
|
||||
);
|
||||
|
||||
// ===== Dropdown Rendering =====
|
||||
|
||||
/**
|
||||
* Renders the custom dropdown with sections and keyboard navigation
|
||||
*/
|
||||
const customDropdownRender = useCallback((): React.ReactElement => {
|
||||
// Process options based on current value
|
||||
let processedOptions = isEmpty(value)
|
||||
? filteredOptions
|
||||
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value);
|
||||
|
||||
if (!isEmpty(searchText)) {
|
||||
processedOptions = filterOptionsBySearch(processedOptions, searchText);
|
||||
}
|
||||
|
||||
const { sectionOptions, nonSectionOptions } = splitOptions(processedOptions);
|
||||
|
||||
// Check if we need to add a custom option based on search text
|
||||
const isSearchTextNotPresent =
|
||||
!isEmpty(searchText) && !isLabelPresent(processedOptions, searchText);
|
||||
|
||||
let optionIndex = 0;
|
||||
|
||||
// Add custom option if needed
|
||||
if (isSearchTextNotPresent) {
|
||||
nonSectionOptions.unshift({
|
||||
label: searchText,
|
||||
value: searchText,
|
||||
type: 'custom',
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to map options with index tracking
|
||||
const mapOptions = (options: OptionData[]): React.ReactNode =>
|
||||
options.map((option) => {
|
||||
const result = renderOptionWithIndex(
|
||||
option,
|
||||
option.value === value,
|
||||
optionIndex,
|
||||
);
|
||||
optionIndex += 1;
|
||||
return result;
|
||||
});
|
||||
|
||||
const customMenu = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="custom-select-dropdown"
|
||||
onClick={handleDropdownClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-activedescendant={
|
||||
activeOptionIndex >= 0 ? `option-${activeOptionIndex}` : undefined
|
||||
}
|
||||
>
|
||||
{/* Non-section options */}
|
||||
<div className="no-section-options">
|
||||
{nonSectionOptions.length > 0 && mapOptions(nonSectionOptions)}
|
||||
</div>
|
||||
|
||||
{/* Section options */}
|
||||
{sectionOptions.length > 0 &&
|
||||
sectionOptions.map((section) =>
|
||||
!isEmpty(section.options) ? (
|
||||
<div className="select-group" key={section.label}>
|
||||
<div className="group-label" role="heading" aria-level={2}>
|
||||
{section.label}
|
||||
</div>
|
||||
<div role="group" aria-label={`${section.label} options`}>
|
||||
{section.options && mapOptions(section.options)}
|
||||
</div>
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
|
||||
{/* Navigation help footer */}
|
||||
<div className="navigation-footer" role="note">
|
||||
{!loading && !errorMessage && !noDataMessage && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
<ArrowUp size={8} className="icons" />
|
||||
<span className="keyboard-text">to navigate</span>
|
||||
</section>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="navigation-loading">
|
||||
<div className="navigation-icons">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="navigation-text">We are updating the values...</div>
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && !loading && (
|
||||
<div className="navigation-error">
|
||||
<div className="navigation-text">
|
||||
{errorMessage || SOMETHING_WENT_WRONG}
|
||||
</div>
|
||||
<div className="navigation-icons">
|
||||
<ReloadOutlined
|
||||
twoToneColor={Color.BG_CHERRY_400}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
if (onRetry) onRetry();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noDataMessage && !loading && (
|
||||
<div className="navigation-text">{noDataMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return dropdownRender ? dropdownRender(customMenu) : customMenu;
|
||||
}, [
|
||||
value,
|
||||
filteredOptions,
|
||||
searchText,
|
||||
splitOptions,
|
||||
isLabelPresent,
|
||||
handleDropdownClick,
|
||||
handleKeyDown,
|
||||
activeOptionIndex,
|
||||
loading,
|
||||
errorMessage,
|
||||
noDataMessage,
|
||||
dropdownRender,
|
||||
renderOptionWithIndex,
|
||||
onRetry,
|
||||
]);
|
||||
|
||||
// ===== Side Effects =====
|
||||
|
||||
// Clear search text when dropdown closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSearchText('');
|
||||
setActiveOptionIndex(-1);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-scroll to active option for keyboard navigation
|
||||
useEffect(() => {
|
||||
if (
|
||||
isOpen &&
|
||||
activeOptionIndex >= 0 &&
|
||||
optionRefs.current[activeOptionIndex]
|
||||
) {
|
||||
optionRefs.current[activeOptionIndex]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
}, [isOpen, activeOptionIndex]);
|
||||
|
||||
// ===== Final Processing =====
|
||||
|
||||
// Apply highlight to matched text in options
|
||||
const optionsWithHighlight = useMemo(
|
||||
() =>
|
||||
options
|
||||
?.filter((option) =>
|
||||
String(option.label || '')
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()),
|
||||
)
|
||||
?.map((option) => ({
|
||||
...option,
|
||||
label: highlightMatchedText(String(option.label || ''), searchText),
|
||||
})),
|
||||
[options, searchText, highlightMatchedText],
|
||||
);
|
||||
|
||||
// ===== Component Rendering =====
|
||||
return (
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-select', className)}
|
||||
placeholder={placeholder}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={setIsOpen}
|
||||
open={isOpen}
|
||||
options={optionsWithHighlight}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={popupMatchSelectWidth}
|
||||
allowClear={allowClear ? { clearIcon } : false}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
|
||||
listHeight={300}
|
||||
placement={placement}
|
||||
optionFilterProp="label"
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomSelect;
|
||||
@@ -0,0 +1,263 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import CustomMultiSelect from '../CustomMultiSelect';
|
||||
|
||||
// Mock scrollIntoView which isn't available in JSDOM
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
// Mock options data
|
||||
const mockOptions = [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
{ label: 'Option 2', value: 'option2' },
|
||||
{ label: 'Option 3', value: 'option3' },
|
||||
];
|
||||
|
||||
const mockGroupedOptions = [
|
||||
{
|
||||
label: 'Group 1',
|
||||
options: [
|
||||
{ label: 'Group 1 - Option 1', value: 'g1-option1' },
|
||||
{ label: 'Group 1 - Option 2', value: 'g1-option2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Group 2',
|
||||
options: [
|
||||
{ label: 'Group 2 - Option 1', value: 'g2-option1' },
|
||||
{ label: 'Group 2 - Option 2', value: 'g2-option2' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('CustomMultiSelect Component', () => {
|
||||
it('renders with placeholder', () => {
|
||||
const handleChange = jest.fn();
|
||||
render(
|
||||
<CustomMultiSelect
|
||||
placeholder="Select multiple options"
|
||||
options={mockOptions}
|
||||
onChange={handleChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check placeholder exists
|
||||
const placeholderElement = screen.getByText('Select multiple options');
|
||||
expect(placeholderElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens dropdown when clicked', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<CustomMultiSelect options={mockOptions} onChange={handleChange} />);
|
||||
|
||||
// Click to open the dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument(); // The ALL option
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('selects multiple options', async () => {
|
||||
const handleChange = jest.fn();
|
||||
|
||||
// Start with option1 already selected
|
||||
render(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
onChange={handleChange}
|
||||
value={['option1']}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Option 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on Option 3
|
||||
const option3 = screen.getByText('Option 3');
|
||||
fireEvent.click(option3);
|
||||
|
||||
// Verify onChange was called with the right values
|
||||
expect(handleChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects ALL options when ALL is clicked', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
onChange={handleChange}
|
||||
enableAllSelection
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on ALL option
|
||||
const allOption = screen.getByText('ALL');
|
||||
fireEvent.click(allOption);
|
||||
|
||||
// Verify onChange was called with all option values
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
['option1', 'option2', 'option3'],
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ value: 'option1' }),
|
||||
expect.objectContaining({ value: 'option2' }),
|
||||
expect.objectContaining({ value: 'option3' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('displays selected options as tags', async () => {
|
||||
render(
|
||||
<CustomMultiSelect options={mockOptions} value={['option1', 'option2']} />,
|
||||
);
|
||||
|
||||
// Check that option values are shown as tags (not labels)
|
||||
expect(screen.getByText('option1')).toBeInTheDocument();
|
||||
expect(screen.getByText('option2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes a tag when clicked', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
value={['option1', 'option2']}
|
||||
onChange={handleChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find close button on Option 1 tag and click it
|
||||
const closeButtons = document.querySelectorAll(
|
||||
'.ant-select-selection-item-remove',
|
||||
);
|
||||
fireEvent.click(closeButtons[0]);
|
||||
|
||||
// Verify onChange was called with remaining option
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
['option2'],
|
||||
expect.arrayContaining([expect.objectContaining({ value: 'option2' })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters options when searching', async () => {
|
||||
render(<CustomMultiSelect options={mockOptions} />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Type into search box - get input directly
|
||||
const inputElement = selectElement.querySelector('input');
|
||||
if (inputElement) {
|
||||
fireEvent.change(inputElement, { target: { value: '2' } });
|
||||
}
|
||||
|
||||
// Wait for the dropdown filtering to happen
|
||||
await waitFor(() => {
|
||||
// Check that the dropdown is present
|
||||
const dropdownElement = document.querySelector(
|
||||
'.custom-multiselect-dropdown',
|
||||
);
|
||||
expect(dropdownElement).toBeInTheDocument();
|
||||
|
||||
// Verify Option 2 is visible in the dropdown
|
||||
const options = document.querySelectorAll('.option-label-text');
|
||||
let foundOption2 = false;
|
||||
|
||||
options.forEach((option) => {
|
||||
const text = option.textContent || '';
|
||||
if (text.includes('Option 2')) foundOption2 = true;
|
||||
});
|
||||
|
||||
expect(foundOption2).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders grouped options correctly', async () => {
|
||||
render(<CustomMultiSelect options={mockGroupedOptions} />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check group headers and options
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Group 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 1 - Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 1 - Option 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 2 - Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 2 - Option 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
render(<CustomMultiSelect options={mockOptions} loading />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check loading text is displayed
|
||||
expect(screen.getByText('We are updating the values...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error message', () => {
|
||||
render(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
errorMessage="Test error message"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check error message is displayed
|
||||
expect(screen.getByText('Test error message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no data message', () => {
|
||||
render(<CustomMultiSelect options={[]} noDataMessage="No data available" />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check no data message is displayed
|
||||
expect(screen.getByText('No data available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "ALL" tag when all options are selected', () => {
|
||||
render(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
value={['option1', 'option2', 'option3']}
|
||||
maxTagCount={2}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When all options are selected, component shows ALL tag instead
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
206
frontend/src/components/NewSelect/__test__/CustomSelect.test.tsx
Normal file
206
frontend/src/components/NewSelect/__test__/CustomSelect.test.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import CustomSelect from '../CustomSelect';
|
||||
|
||||
// Mock scrollIntoView which isn't available in JSDOM
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
// Mock options data
|
||||
const mockOptions = [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
{ label: 'Option 2', value: 'option2' },
|
||||
{ label: 'Option 3', value: 'option3' },
|
||||
];
|
||||
|
||||
const mockGroupedOptions = [
|
||||
{
|
||||
label: 'Group 1',
|
||||
options: [
|
||||
{ label: 'Group 1 - Option 1', value: 'g1-option1' },
|
||||
{ label: 'Group 1 - Option 2', value: 'g1-option2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Group 2',
|
||||
options: [
|
||||
{ label: 'Group 2 - Option 1', value: 'g2-option1' },
|
||||
{ label: 'Group 2 - Option 2', value: 'g2-option2' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('CustomSelect Component', () => {
|
||||
it('renders with placeholder and options', () => {
|
||||
const handleChange = jest.fn();
|
||||
render(
|
||||
<CustomSelect
|
||||
placeholder="Test placeholder"
|
||||
options={mockOptions}
|
||||
onChange={handleChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check placeholder exists in the DOM (not using getByPlaceholderText)
|
||||
const placeholderElement = screen.getByText('Test placeholder');
|
||||
expect(placeholderElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens dropdown when clicked', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<CustomSelect options={mockOptions} onChange={handleChange} />);
|
||||
|
||||
// Click to open the dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onChange when option is selected', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<CustomSelect options={mockOptions} onChange={handleChange} />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Click on an option
|
||||
await waitFor(() => {
|
||||
const option = screen.getByText('Option 2');
|
||||
fireEvent.click(option);
|
||||
});
|
||||
|
||||
// Check onChange was called with correct value
|
||||
expect(handleChange).toHaveBeenCalledWith('option2', expect.anything());
|
||||
});
|
||||
|
||||
it('filters options when searching', async () => {
|
||||
render(<CustomSelect options={mockOptions} />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Type into search box
|
||||
fireEvent.change(selectElement, { target: { value: '2' } });
|
||||
|
||||
// Dropdown should only show Option 2
|
||||
await waitFor(() => {
|
||||
// Check that the dropdown is present
|
||||
const dropdownElement = document.querySelector('.custom-select-dropdown');
|
||||
expect(dropdownElement).toBeInTheDocument();
|
||||
|
||||
// Use a simple approach to verify filtering
|
||||
const allOptionsInDropdown = document.querySelectorAll('.option-item');
|
||||
let foundOption2 = false;
|
||||
|
||||
allOptionsInDropdown.forEach((option) => {
|
||||
if (option.textContent?.includes('Option 2')) {
|
||||
foundOption2 = true;
|
||||
}
|
||||
|
||||
// Should not show Options 1 or 3
|
||||
expect(option.textContent).not.toContain('Option 1');
|
||||
expect(option.textContent).not.toContain('Option 3');
|
||||
});
|
||||
|
||||
expect(foundOption2).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders grouped options correctly', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<CustomSelect options={mockGroupedOptions} onChange={handleChange} />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check group headers and options
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Group 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 1 - Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 1 - Option 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 2 - Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 2 - Option 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
render(<CustomSelect options={mockOptions} loading />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check loading text is displayed
|
||||
expect(screen.getByText('We are updating the values...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error message', () => {
|
||||
render(
|
||||
<CustomSelect options={mockOptions} errorMessage="Test error message" />,
|
||||
);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check error message is displayed
|
||||
expect(screen.getByText('Test error message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no data message', () => {
|
||||
render(<CustomSelect options={[]} noDataMessage="No data available" />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check no data message is displayed
|
||||
expect(screen.getByText('No data available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports keyboard navigation', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<CustomSelect options={mockOptions} onChange={handleChange} />);
|
||||
|
||||
// Open dropdown using keyboard
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.focus(selectElement);
|
||||
|
||||
// Press down arrow to open dropdown
|
||||
fireEvent.keyDown(selectElement, { key: 'ArrowDown' });
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles selection via keyboard', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<CustomSelect options={mockOptions} onChange={handleChange} />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Wait for dropdown to appear then press Enter
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
|
||||
// Press Enter to select first option
|
||||
fireEvent.keyDown(screen.getByText('Option 1'), { key: 'Enter' });
|
||||
});
|
||||
|
||||
// Check onChange was called
|
||||
expect(handleChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
4
frontend/src/components/NewSelect/index.ts
Normal file
4
frontend/src/components/NewSelect/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import CustomMultiSelect from './CustomMultiSelect';
|
||||
import CustomSelect from './CustomSelect';
|
||||
|
||||
export { CustomMultiSelect, CustomSelect };
|
||||
838
frontend/src/components/NewSelect/styles.scss
Normal file
838
frontend/src/components/NewSelect/styles.scss
Normal file
@@ -0,0 +1,838 @@
|
||||
// Main container styles
|
||||
|
||||
// make const of #2c3044
|
||||
$custom-border-color: #2c3044;
|
||||
|
||||
.custom-select {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
&.ant-select-focused {
|
||||
.ant-select-selector {
|
||||
border-color: var(--bg-robin-500);
|
||||
box-shadow: 0 0 0 2px rgba(78, 116, 248, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: rgba(192, 193, 195, 0.45);
|
||||
}
|
||||
|
||||
// Base styles are for dark mode
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-400);
|
||||
border-color: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.ant-select-clear {
|
||||
background-color: var(--bg-ink-400);
|
||||
color: rgba(192, 193, 195, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep chip styles ONLY in the multi-select
|
||||
.custom-multiselect {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
.ant-select-selector {
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
background-color: var(--bg-ink-400);
|
||||
border-color: var(--bg-slate-400);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $custom-border-color;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-select-focused {
|
||||
.ant-select-selector {
|
||||
border-color: var(--bg-robin-500);
|
||||
box-shadow: 0 0 0 2px rgba(78, 116, 248, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: rgba(192, 193, 195, 0.45);
|
||||
}
|
||||
|
||||
// Customize tags in multiselect (dark mode by default)
|
||||
.ant-select-selection-item {
|
||||
background-color: var(--bg-slate-400);
|
||||
border-radius: 4px;
|
||||
border: 1px solid $custom-border-color;
|
||||
margin-right: 4px;
|
||||
transition: all 0.2s;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
// Style for active tag (keyboard navigation)
|
||||
&-active {
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
background-color: rgba(78, 116, 248, 0.15) !important;
|
||||
outline: 2px solid rgba(78, 116, 248, 0.2);
|
||||
}
|
||||
|
||||
// Style for selected tags (via keyboard or mouse selection)
|
||||
&-selected {
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
background-color: rgba(78, 116, 248, 0.15) !important;
|
||||
box-shadow: 0 0 0 2px rgba(78, 116, 248, 0.2);
|
||||
}
|
||||
|
||||
.ant-select-selection-item-content {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.ant-select-selection-item-remove {
|
||||
color: rgba(192, 193, 195, 0.7);
|
||||
&:hover {
|
||||
color: rgba(192, 193, 195, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Class applied when in selection mode
|
||||
&.has-selection {
|
||||
.ant-select-selection-item-selected {
|
||||
cursor: move; // Indicate draggable
|
||||
}
|
||||
|
||||
// Change cursor for selection
|
||||
.ant-select-selector {
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown styles
|
||||
.custom-select-dropdown-container,
|
||||
.custom-multiselect-dropdown-container {
|
||||
z-index: 1050 !important;
|
||||
padding: 0;
|
||||
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.5), 0 6px 16px 0 rgba(0, 0, 0, 0.4),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.3);
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.ant-select-item {
|
||||
padding: 8px 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
// Make keyboard navigation visible
|
||||
&-option-active {
|
||||
background-color: var(--bg-slate-400) !important;
|
||||
}
|
||||
|
||||
&-option-selected {
|
||||
background-color: rgba(78, 116, 248, 0.15) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-select-dropdown-container,
|
||||
.custom-multiselect-dropdown-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
resize: horizontal;
|
||||
min-width: 300px !important;
|
||||
|
||||
.empty-message {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: rgba(192, 193, 195, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom dropdown styles for single select
|
||||
.custom-select-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
width: 100%;
|
||||
background-color: var(--bg-ink-400);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $custom-border-color;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.no-section-options {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.select-group {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
.group-label {
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--bg-vanilla-400);
|
||||
background-color: var(--bg-slate-400);
|
||||
border-bottom: 1px solid $custom-border-color;
|
||||
border-top: 1px solid $custom-border-color;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.option-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: rgba(78, 116, 248, 0.15);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(78, 116, 248, 0.15);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
.option-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.option-label-text {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background-color: $custom-border-color;
|
||||
color: var(--bg-vanilla-400);
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--bg-slate-400);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background-color: var(--bg-ink-400);
|
||||
z-index: 1;
|
||||
|
||||
.navigation-icons {
|
||||
display: flex;
|
||||
margin-right: 8px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.navigation-text {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.navigation-error {
|
||||
.navigation-text,
|
||||
.navigation-icons {
|
||||
color: var(--bg-cherry-500) !important;
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.navigation-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.navigation-text,
|
||||
.navigation-icons {
|
||||
color: var(--bg-robin-600) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.navigate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 12px;
|
||||
gap: 6px;
|
||||
|
||||
.icons {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2.286px;
|
||||
border-top: 1.143px solid var(--bg-ink-200);
|
||||
border-right: 1.143px solid var(--bg-ink-200);
|
||||
border-bottom: 2.286px solid var(--bg-ink-200);
|
||||
border-left: 1.143px solid var(--bg-ink-200);
|
||||
background: var(--Ink-400, var(--bg-ink-400));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom dropdown styles for multi-select
|
||||
.custom-multiselect-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
width: 100%;
|
||||
background-color: var(--bg-ink-400);
|
||||
|
||||
.select-all-option,
|
||||
.custom-value-option {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid $custom-border-color;
|
||||
margin-bottom: 8px;
|
||||
background-color: var(--bg-slate-400);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.selected-values-section {
|
||||
padding: 0 0 8px 0;
|
||||
border-bottom: 1px solid $custom-border-color;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.selected-option {
|
||||
padding: 4px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.select-group {
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
.group-label {
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--bg-vanilla-400);
|
||||
background-color: var(--bg-slate-400);
|
||||
border-bottom: 1px solid $custom-border-color;
|
||||
border-top: 1px solid $custom-border-color;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.option-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
&.active {
|
||||
background-color: rgba(78, 116, 248, 0.15);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: rgba(78, 116, 248, 0.15);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.all-option {
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid $custom-border-color;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.option-checkbox {
|
||||
width: 100%;
|
||||
|
||||
> span:not(.ant-checkbox) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.option-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.option-label-text {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background-color: $custom-border-color;
|
||||
color: var(--bg-vanilla-400);
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.only-btn {
|
||||
display: none;
|
||||
}
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.only-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
.toggle-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.option-content:hover {
|
||||
.only-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
}
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.option-checkbox:hover {
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
}
|
||||
.option-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: rgba(192, 193, 195, 0.45);
|
||||
}
|
||||
|
||||
.status-message {
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
color: rgba(192, 193, 195, 0.65);
|
||||
border-top: 1px dashed $custom-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom styles for highlight text
|
||||
.highlight-text {
|
||||
background-color: rgba(78, 116, 248, 0.2);
|
||||
padding: 0 1px;
|
||||
border-radius: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// Custom option styles for keyboard navigation
|
||||
.custom-option {
|
||||
&.focused,
|
||||
&.ant-select-item-option-active {
|
||||
background-color: var(--bg-slate-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Improve the sticky headers appearance
|
||||
.custom-select-dropdown-container {
|
||||
.group-label,
|
||||
.ant-select-item-group {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background-color: var(--bg-slate-400);
|
||||
border-bottom: 1px solid $custom-border-color;
|
||||
padding: 4px 12px;
|
||||
margin: 0;
|
||||
width: 100%; // Ensure the header spans full width
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); // Add subtle shadow for separation
|
||||
}
|
||||
|
||||
// Ensure proper spacing between sections
|
||||
.select-group {
|
||||
margin-bottom: 8px;
|
||||
position: relative; // Create a positioning context
|
||||
}
|
||||
}
|
||||
|
||||
// Custom scrollbar styling (shared between components)
|
||||
@mixin custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(192, 193, 195, 0.3) rgba(29, 33, 45, 0.6);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: rgba(29, 33, 45, 0.6);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(192, 193, 195, 0.3);
|
||||
border-radius: 10px;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(192, 193, 195, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subtle nested scrollbar styling
|
||||
@mixin nested-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(192, 193, 195, 0.2) rgba(29, 33, 45, 0.6);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: rgba(29, 33, 45, 0.6);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(192, 193, 195, 0.2);
|
||||
border-radius: 10px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(192, 193, 195, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to main dropdown containers
|
||||
.custom-select-dropdown,
|
||||
.custom-multiselect-dropdown {
|
||||
@include custom-scrollbar;
|
||||
|
||||
// Main content area
|
||||
.options-container {
|
||||
@include custom-scrollbar;
|
||||
padding-right: 2px; // Add slight padding to prevent content touching scrollbar
|
||||
}
|
||||
|
||||
// Non-sectioned options
|
||||
.no-section-options {
|
||||
@include nested-scrollbar;
|
||||
margin-right: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to dropdown container wrappers
|
||||
.custom-select-dropdown-container,
|
||||
.custom-multiselect-dropdown-container {
|
||||
@include custom-scrollbar;
|
||||
|
||||
// Add subtle shadow inside to indicate scrollable area
|
||||
&.has-overflow {
|
||||
box-shadow: inset 0 -10px 10px -10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// Light Mode Overrides
|
||||
.lightMode {
|
||||
.custom-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border-color: #e9e9e9;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.ant-select-clear {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
&.ant-select-focused {
|
||||
.ant-select-selector {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-multiselect {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border-color: #e9e9e9;
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #e8e8e8;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
.ant-select-selection-item-content {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.ant-select-selection-item-remove {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
&:hover {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
&-active {
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
|
||||
&-selected {
|
||||
border-color: #1890ff !important;
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-select-dropdown-container,
|
||||
.custom-multiselect-dropdown-container {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border: 1px solid #f0f0f0;
|
||||
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.empty-message {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.ant-select-item {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
&-option-active {
|
||||
background-color: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
&-option-selected {
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-select-dropdown,
|
||||
.custom-multiselect-dropdown {
|
||||
border: 1px solid #f0f0f0;
|
||||
background-color: var(--bg-vanilla-100);
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.select-group {
|
||||
.group-label {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
background-color: #fafafa;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.option-item {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border-color: #91d5ff;
|
||||
}
|
||||
|
||||
.option-content {
|
||||
.option-badge {
|
||||
background-color: #f0f0f0;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-footer {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background-color: var(--bg-vanilla-100);
|
||||
|
||||
.navigation-icons {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.navigation-text {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.navigate {
|
||||
.icons {
|
||||
border-top: 1.143px solid var(--bg-ink-200);
|
||||
border-right: 1.143px solid var(--bg-ink-200);
|
||||
border-bottom: 2.286px solid var(--bg-ink-200);
|
||||
border-left: 1.143px solid var(--bg-ink-200);
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-multiselect-dropdown {
|
||||
.select-all-option,
|
||||
.custom-value-option {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.selected-values-section {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
border-top: 1px dashed #f0f0f0;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
&.all-option {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.highlight-text {
|
||||
background-color: rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
.custom-option {
|
||||
&.focused,
|
||||
&.ant-select-item-option-active {
|
||||
background-color: #f5f5f5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-select-dropdown-container {
|
||||
.group-label,
|
||||
.ant-select-item-group {
|
||||
background-color: #f5f0f0;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
// Light mode scrollbar overrides
|
||||
.custom-select-dropdown,
|
||||
.custom-multiselect-dropdown,
|
||||
.custom-select-dropdown-container,
|
||||
.custom-multiselect-dropdown-container {
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) rgba(0, 0, 0, 0.05);
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
frontend/src/components/NewSelect/types.ts
Normal file
60
frontend/src/components/NewSelect/types.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { SelectProps } from 'antd';
|
||||
|
||||
export interface OptionData {
|
||||
label: string;
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
options?: OptionData[];
|
||||
type?: 'defined' | 'custom' | 'regex';
|
||||
}
|
||||
|
||||
export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
loading?: boolean;
|
||||
onSearch?: (value: string) => void;
|
||||
options?: OptionData[];
|
||||
defaultActiveFirstOption?: boolean;
|
||||
noDataMessage?: string;
|
||||
onClear?: () => void;
|
||||
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
|
||||
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
|
||||
highlightSearch?: boolean;
|
||||
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
||||
popupMatchSelectWidth?: boolean;
|
||||
errorMessage?: string;
|
||||
allowClear?: SelectProps['allowClear'];
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export interface CustomTagProps {
|
||||
label: React.ReactNode;
|
||||
value: string;
|
||||
closable: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface CustomMultiSelectProps
|
||||
extends Omit<SelectProps<string[] | string>, 'options'> {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
loading?: boolean;
|
||||
onSearch?: (value: string) => void;
|
||||
options?: OptionData[];
|
||||
defaultActiveFirstOption?: boolean;
|
||||
dropdownMatchSelectWidth?: boolean | number;
|
||||
noDataMessage?: string;
|
||||
onClear?: () => void;
|
||||
enableAllSelection?: boolean;
|
||||
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
|
||||
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
|
||||
highlightSearch?: boolean;
|
||||
errorMessage?: string;
|
||||
popupClassName?: string;
|
||||
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
||||
maxTagCount?: number;
|
||||
allowClear?: SelectProps['allowClear'];
|
||||
onRetry?: () => void;
|
||||
}
|
||||
135
frontend/src/components/NewSelect/utils.ts
Normal file
135
frontend/src/components/NewSelect/utils.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { OptionData } from './types';
|
||||
|
||||
export const SPACEKEY = ' ';
|
||||
|
||||
export const prioritizeOrAddOptionForSingleSelect = (
|
||||
options: OptionData[],
|
||||
value: string,
|
||||
label?: string,
|
||||
): OptionData[] => {
|
||||
let foundOption: OptionData | null = null;
|
||||
|
||||
// Separate the found option and the rest
|
||||
const filteredOptions = options
|
||||
.map((option) => {
|
||||
if ('options' in option && Array.isArray(option.options)) {
|
||||
// Filter out the value from nested options
|
||||
const remainingSubOptions = option.options.filter(
|
||||
(subOption) => subOption.value !== value,
|
||||
);
|
||||
const extractedOption = option.options.find(
|
||||
(subOption) => subOption.value === value,
|
||||
);
|
||||
|
||||
if (extractedOption) foundOption = extractedOption;
|
||||
|
||||
// Keep the group if it still has remaining options
|
||||
return remainingSubOptions.length > 0
|
||||
? { ...option, options: remainingSubOptions }
|
||||
: null;
|
||||
}
|
||||
|
||||
// Check top-level options
|
||||
if (option.value === value) {
|
||||
foundOption = option;
|
||||
return null; // Remove it from the list
|
||||
}
|
||||
|
||||
return option;
|
||||
})
|
||||
.filter(Boolean) as OptionData[]; // Remove null values
|
||||
|
||||
// If not found, create a new option
|
||||
if (!foundOption) {
|
||||
foundOption = { value, label: label ?? value };
|
||||
}
|
||||
|
||||
// Add the found/new option at the top
|
||||
return [foundOption, ...filteredOptions];
|
||||
};
|
||||
|
||||
export const prioritizeOrAddOptionForMultiSelect = (
|
||||
options: OptionData[],
|
||||
values: string[], // Only supports multiple values (string[])
|
||||
labels?: Record<string, string>,
|
||||
): OptionData[] => {
|
||||
const foundOptions: OptionData[] = [];
|
||||
|
||||
// Separate the found options and the rest
|
||||
const filteredOptions = options
|
||||
.map((option) => {
|
||||
if ('options' in option && Array.isArray(option.options)) {
|
||||
// Filter out selected values from nested options
|
||||
const remainingSubOptions = option.options.filter(
|
||||
(subOption) => subOption.value && !values.includes(subOption.value),
|
||||
);
|
||||
const extractedOptions = option.options.filter(
|
||||
(subOption) => subOption.value && values.includes(subOption.value),
|
||||
);
|
||||
|
||||
if (extractedOptions.length > 0) {
|
||||
foundOptions.push(...extractedOptions);
|
||||
}
|
||||
|
||||
// Keep the group if it still has remaining options
|
||||
return remainingSubOptions.length > 0
|
||||
? { ...option, options: remainingSubOptions }
|
||||
: null;
|
||||
}
|
||||
|
||||
// Check top-level options
|
||||
if (option.value && values.includes(option.value)) {
|
||||
foundOptions.push(option);
|
||||
return null; // Remove it from the list
|
||||
}
|
||||
|
||||
return option;
|
||||
})
|
||||
.filter(Boolean) as OptionData[]; // Remove null values
|
||||
|
||||
// Find missing values that were not present in the original options and create new ones
|
||||
const missingValues = values.filter(
|
||||
(value) => !foundOptions.some((opt) => opt.value === value),
|
||||
);
|
||||
|
||||
const newOptions = missingValues.map((value) => ({
|
||||
value,
|
||||
label: labels?.[value] ?? value, // Use provided label or default to value
|
||||
}));
|
||||
|
||||
// Add found & new options to the top
|
||||
return [...newOptions, ...foundOptions, ...filteredOptions];
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters options based on search text
|
||||
*/
|
||||
export const filterOptionsBySearch = (
|
||||
options: OptionData[],
|
||||
searchText: string,
|
||||
): OptionData[] => {
|
||||
if (!searchText.trim()) return options;
|
||||
|
||||
const lowerSearchText = searchText.toLowerCase();
|
||||
|
||||
return options
|
||||
.map((option) => {
|
||||
if ('options' in option && Array.isArray(option.options)) {
|
||||
// Filter nested options
|
||||
const filteredSubOptions = option.options.filter((subOption) =>
|
||||
subOption.label.toLowerCase().includes(lowerSearchText),
|
||||
);
|
||||
|
||||
return filteredSubOptions.length > 0
|
||||
? { ...option, options: filteredSubOptions }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// Filter top-level options
|
||||
return option.label.toLowerCase().includes(lowerSearchText)
|
||||
? option
|
||||
: undefined;
|
||||
})
|
||||
.filter(Boolean) as OptionData[];
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
.signoz-radio-group.ant-radio-group {
|
||||
color: var(--text-vanilla-400);
|
||||
|
||||
.view-title {
|
||||
display: flex;
|
||||
gap: var(--margin-2);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-xs);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
&:hover {
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
&::before {
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.selected_view {
|
||||
&,
|
||||
&:hover {
|
||||
background: var(--bg-slate-300);
|
||||
color: var(--text-vanilla-100);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
&::before {
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light mode styles
|
||||
.lightMode {
|
||||
.signoz-radio-group {
|
||||
.tab {
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.selected_view {
|
||||
background: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
color: var(--text-ink-400);
|
||||
}
|
||||
|
||||
.selected_view::before {
|
||||
background: var(--bg-vanilla-300);
|
||||
border-left: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import './SignozRadioGroup.styles.scss';
|
||||
|
||||
import { Radio } from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/es/radio';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SignozRadioGroupProps {
|
||||
value: string;
|
||||
options: Option[];
|
||||
onChange: (e: RadioChangeEvent) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function SignozRadioGroup({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
className = '',
|
||||
}: SignozRadioGroupProps): JSX.Element {
|
||||
return (
|
||||
<Radio.Group
|
||||
value={value}
|
||||
buttonStyle="solid"
|
||||
className={`signoz-radio-group ${className}`}
|
||||
onChange={onChange}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Radio.Button
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={value === option.value ? 'selected_view tab' : 'tab'}
|
||||
>
|
||||
{option.label}
|
||||
</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
);
|
||||
}
|
||||
|
||||
SignozRadioGroup.defaultProps = {
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default SignozRadioGroup;
|
||||
@@ -8,5 +8,5 @@ export enum FeatureKeys {
|
||||
PREMIUM_SUPPORT = 'PREMIUM_SUPPORT',
|
||||
ANOMALY_DETECTION = 'ANOMALY_DETECTION',
|
||||
ONBOARDING_V3 = 'ONBOARDING_V3',
|
||||
THIRD_PARTY_API = 'THIRD_PARTY_API',
|
||||
TRACE_FUNNELS = 'TRACE_FUNNELS',
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const REACT_QUERY_KEY = {
|
||||
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
|
||||
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
|
||||
|
||||
// Traces Funnels Query Keys
|
||||
// API Monitoring Query Keys
|
||||
GET_DOMAINS_LIST: 'GET_DOMAINS_LIST',
|
||||
GET_ENDPOINTS_LIST_BY_DOMAIN: 'GET_ENDPOINTS_LIST_BY_DOMAIN',
|
||||
GET_NESTED_ENDPOINTS_LIST: 'GET_NESTED_ENDPOINTS_LIST',
|
||||
@@ -68,11 +68,4 @@ export const REACT_QUERY_KEY = {
|
||||
'GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA',
|
||||
GET_FUNNELS_LIST: 'GET_FUNNELS_LIST',
|
||||
GET_FUNNEL_DETAILS: 'GET_FUNNEL_DETAILS',
|
||||
UPDATE_FUNNEL_STEPS: 'UPDATE_FUNNEL_STEPS',
|
||||
VALIDATE_FUNNEL_STEPS: 'VALIDATE_FUNNEL_STEPS',
|
||||
UPDATE_FUNNEL_STEP_DETAILS: 'UPDATE_FUNNEL_STEP_DETAILS',
|
||||
GET_FUNNEL_OVERVIEW: 'GET_FUNNEL_OVERVIEW',
|
||||
GET_FUNNEL_SLOW_TRACES: 'GET_FUNNEL_SLOW_TRACES',
|
||||
GET_FUNNEL_ERROR_TRACES: 'GET_FUNNEL_ERROR_TRACES',
|
||||
GET_FUNNEL_STEPS_GRAPH_DATA: 'GET_FUNNEL_STEPS_GRAPH_DATA',
|
||||
} as const;
|
||||
|
||||
@@ -17,7 +17,6 @@ const ROUTES = {
|
||||
'/get-started/infrastructure-monitoring',
|
||||
GET_STARTED_AWS_MONITORING: '/get-started/aws-monitoring',
|
||||
GET_STARTED_AZURE_MONITORING: '/get-started/azure-monitoring',
|
||||
USAGE_EXPLORER: '/usage-explorer',
|
||||
APPLICATION: '/services',
|
||||
ALL_DASHBOARD: '/dashboard',
|
||||
DASHBOARD: '/dashboard/:dashboardId',
|
||||
|
||||
@@ -42,7 +42,7 @@ import { Helmet } from 'react-helmet-async';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueries } from 'react-query';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Dispatch } from 'redux';
|
||||
import AppActions from 'types/actions';
|
||||
import {
|
||||
@@ -362,9 +362,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
routeKey === 'INFRASTRUCTURE_MONITORING_HOSTS' ||
|
||||
routeKey === 'INFRASTRUCTURE_MONITORING_KUBERNETES';
|
||||
const isTracesFunnels = (): boolean => routeKey === 'TRACES_FUNNELS';
|
||||
const isTracesFunnelDetails = (): boolean =>
|
||||
!!matchPath(pathname, ROUTES.TRACES_FUNNELS_DETAIL);
|
||||
|
||||
const isPathMatch = (regex: RegExp): boolean => regex.test(pathname);
|
||||
|
||||
const isDashboardView = (): boolean =>
|
||||
@@ -671,11 +668,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
? 0
|
||||
: '0 1rem',
|
||||
|
||||
...(isTraceDetailsView() ||
|
||||
isTracesFunnels() ||
|
||||
isTracesFunnelDetails()
|
||||
? { margin: 0 }
|
||||
: {}),
|
||||
...(isTraceDetailsView() || isTracesFunnels() ? { margin: 0 } : {}),
|
||||
}}
|
||||
>
|
||||
{isToDisplayLayout && !renderFullScreen && <TopNav />}
|
||||
|
||||
@@ -133,231 +133,3 @@ const ServicesListTable = memo(
|
||||
),
|
||||
);
|
||||
ServicesListTable.displayName = 'ServicesListTable';
|
||||
|
||||
function ServiceMetrics({
|
||||
onUpdateChecklistDoneItem,
|
||||
loadingUserPreferences,
|
||||
}: {
|
||||
onUpdateChecklistDoneItem: (itemKey: string) => void;
|
||||
loadingUserPreferences: boolean;
|
||||
}): JSX.Element {
|
||||
const { selectedTime: globalSelectedInterval } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
const { user, activeLicenseV3 } = useAppContext();
|
||||
|
||||
const [timeRange, setTimeRange] = useState(() => {
|
||||
const now = new Date().getTime();
|
||||
return {
|
||||
startTime: now - homeInterval,
|
||||
endTime: now,
|
||||
selectedInterval: homeInterval,
|
||||
};
|
||||
});
|
||||
|
||||
const { queries } = useResourceAttribute();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const selectedTags = useMemo(
|
||||
() => (convertRawQueriesToTraceSelectedTags(queries) as Tags[]) || [],
|
||||
[queries],
|
||||
);
|
||||
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const queryKey: QueryKey = useMemo(
|
||||
() => [
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
selectedTags,
|
||||
globalSelectedInterval,
|
||||
],
|
||||
[
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
selectedTags,
|
||||
globalSelectedInterval,
|
||||
],
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading: isLoadingTopLevelOperations,
|
||||
isError: isErrorTopLevelOperations,
|
||||
} = useGetTopLevelOperations(queryKey, {
|
||||
start: timeRange.startTime * 1e6,
|
||||
end: timeRange.endTime * 1e6,
|
||||
});
|
||||
|
||||
const handleTimeIntervalChange = useCallback((value: number): void => {
|
||||
const timeInterval = TIME_PICKER_OPTIONS.find(
|
||||
(option) => option.value === value,
|
||||
);
|
||||
|
||||
logEvent('Homepage: Services time interval updated', {
|
||||
updatedTimeInterval: timeInterval?.label,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
setTimeRange({
|
||||
startTime: now.getTime() - value,
|
||||
endTime: now.getTime(),
|
||||
selectedInterval: value,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const topLevelOperations = useMemo(() => Object.entries(data || {}), [data]);
|
||||
|
||||
const queryRangeRequestData = useMemo(
|
||||
() =>
|
||||
getQueryRangeRequestData({
|
||||
topLevelOperations,
|
||||
minTime: timeRange.startTime * 1e6,
|
||||
maxTime: timeRange.endTime * 1e6,
|
||||
globalSelectedInterval,
|
||||
}),
|
||||
[
|
||||
globalSelectedInterval,
|
||||
timeRange.endTime,
|
||||
timeRange.startTime,
|
||||
topLevelOperations,
|
||||
],
|
||||
);
|
||||
|
||||
const dataQueries = useGetQueriesRange(
|
||||
queryRangeRequestData,
|
||||
ENTITY_VERSION_V4,
|
||||
{
|
||||
queryKey: useMemo(
|
||||
() => [
|
||||
`GetMetricsQueryRange-home-${globalSelectedInterval}`,
|
||||
timeRange.endTime,
|
||||
timeRange.startTime,
|
||||
globalSelectedInterval,
|
||||
],
|
||||
[globalSelectedInterval, timeRange.endTime, timeRange.startTime],
|
||||
),
|
||||
keepPreviousData: true,
|
||||
enabled: true,
|
||||
refetchOnMount: false,
|
||||
onError: () => {
|
||||
setIsError(true);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const isLoading = useMemo(() => dataQueries.some((query) => query.isLoading), [
|
||||
dataQueries,
|
||||
]);
|
||||
|
||||
const services: ServicesList[] = useMemo(
|
||||
() =>
|
||||
getServiceListFromQuery({
|
||||
queries: dataQueries,
|
||||
topLevelOperations,
|
||||
isLoading,
|
||||
}),
|
||||
[dataQueries, topLevelOperations, isLoading],
|
||||
);
|
||||
|
||||
const sortedServices = useMemo(
|
||||
() =>
|
||||
services?.sort((a, b) => {
|
||||
const aUpdateAt = new Date(a.p99).getTime();
|
||||
const bUpdateAt = new Date(b.p99).getTime();
|
||||
return bUpdateAt - aUpdateAt;
|
||||
}) || [],
|
||||
[services],
|
||||
);
|
||||
|
||||
const servicesExist = sortedServices.length > 0;
|
||||
const top5Services = useMemo(() => sortedServices.slice(0, 5), [
|
||||
sortedServices,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadingUserPreferences && servicesExist) {
|
||||
onUpdateChecklistDoneItem('SETUP_SERVICES');
|
||||
}
|
||||
}, [onUpdateChecklistDoneItem, loadingUserPreferences, servicesExist]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(record: ServicesList) => {
|
||||
logEvent('Homepage: Service clicked', {
|
||||
serviceName: record.serviceName,
|
||||
});
|
||||
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`);
|
||||
},
|
||||
[safeNavigate],
|
||||
);
|
||||
|
||||
if (isLoadingTopLevelOperations || isLoading) {
|
||||
return (
|
||||
<Card className="services-list-card home-data-card loading-card">
|
||||
<Card.Content>
|
||||
<Skeleton active />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isErrorTopLevelOperations || isError) {
|
||||
return (
|
||||
<Card className="services-list-card home-data-card error-card">
|
||||
<Card.Content>
|
||||
<Skeleton active />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="services-list-card home-data-card">
|
||||
{servicesExist && (
|
||||
<Card.Header>
|
||||
<div className="services-header home-data-card-header">
|
||||
{' '}
|
||||
Services
|
||||
<div className="services-header-actions">
|
||||
<Select
|
||||
value={timeRange.selectedInterval}
|
||||
onChange={handleTimeIntervalChange}
|
||||
options={TIME_PICKER_OPTIONS}
|
||||
className="services-header-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
)}
|
||||
<Card.Content>
|
||||
{servicesExist ? (
|
||||
<ServicesListTable services={top5Services} onRowClick={handleRowClick} />
|
||||
) : (
|
||||
<EmptyState user={user} activeLicenseV3={activeLicenseV3} />
|
||||
)}
|
||||
</Card.Content>
|
||||
|
||||
{servicesExist && (
|
||||
<Card.Footer>
|
||||
<div className="services-footer home-data-card-footer">
|
||||
<Link to="/services">
|
||||
<Button
|
||||
type="link"
|
||||
className="periscope-btn link learn-more-link"
|
||||
onClick={(): void => {
|
||||
logEvent('Homepage: All Services clicked', {});
|
||||
}}
|
||||
>
|
||||
All Services <ArrowRight size={12} />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card.Footer>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ServiceMetrics);
|
||||
|
||||
@@ -21,17 +21,10 @@ function Services({
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="home-services-container">
|
||||
{isSpanMetricEnabled ? (
|
||||
<ServiceMetrics
|
||||
<ServiceTraces
|
||||
onUpdateChecklistDoneItem={onUpdateChecklistDoneItem}
|
||||
loadingUserPreferences={loadingUserPreferences}
|
||||
/>
|
||||
) : (
|
||||
<ServiceTraces
|
||||
onUpdateChecklistDoneItem={onUpdateChecklistDoneItem}
|
||||
loadingUserPreferences={loadingUserPreferences}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -481,7 +481,6 @@ export const apDexMetricsQueryBuilderQueries = ({
|
||||
export const operationPerSec = ({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
topLevelOperations,
|
||||
}: OperationPerSecProps): QueryBuilderData => {
|
||||
const autocompleteData: BaseAutocompleteData[] = [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import getTopLevelOperations, {
|
||||
ServiceDataProps,
|
||||
} from 'api/metrics/getTopLevelOperations';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -110,21 +107,6 @@ function Application(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: topLevelOperations,
|
||||
error: topLevelOperationsError,
|
||||
isLoading: topLevelOperationsIsLoading,
|
||||
isError: topLevelOperationsIsError,
|
||||
} = useQuery<ServiceDataProps>({
|
||||
queryKey: [servicename, minTime, maxTime],
|
||||
queryFn: (): Promise<ServiceDataProps> =>
|
||||
getTopLevelOperations({
|
||||
service: servicename || '',
|
||||
start: minTime,
|
||||
end: maxTime,
|
||||
}),
|
||||
});
|
||||
|
||||
const selectedTraceTags: string = JSON.stringify(
|
||||
convertRawQueriesToTraceSelectedTags(queries) || [],
|
||||
);
|
||||
@@ -137,14 +119,6 @@ function Application(): JSX.Element {
|
||||
[queries],
|
||||
);
|
||||
|
||||
const topLevelOperationsRoute = useMemo(
|
||||
() =>
|
||||
topLevelOperations
|
||||
? defaultTo(topLevelOperations[servicename || ''], [])
|
||||
: [],
|
||||
[servicename, topLevelOperations],
|
||||
);
|
||||
|
||||
const operationPerSecWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'dayjs/locale/en';
|
||||
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Flex, Form, Input, Typography } from 'antd';
|
||||
import { Button, Flex, Form, Input, Tooltip, Typography } from 'antd';
|
||||
import getAll from 'api/alerts/getAll';
|
||||
import { useDeleteDowntimeSchedule } from 'api/plannedDowntime/deleteDowntimeSchedule';
|
||||
import {
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
import dayjs from 'dayjs';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import React, { ChangeEvent, useEffect, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { PlannedDowntimeDeleteModal } from './PlannedDowntimeDeleteModal';
|
||||
import { PlannedDowntimeForm } from './PlannedDowntimeForm';
|
||||
@@ -33,6 +35,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
});
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const { user } = useAppContext();
|
||||
|
||||
const [initialValues, setInitialValues] = useState<
|
||||
Partial<DowntimeSchedules & { editMode: boolean }>
|
||||
@@ -108,18 +111,27 @@ export function PlannedDowntime(): JSX.Element {
|
||||
value={searchValue}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={(): void => {
|
||||
setInitialValues({ ...defautlInitialValues, editMode: false });
|
||||
setIsOpen(true);
|
||||
setEditMode(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
<Tooltip
|
||||
title={
|
||||
user?.role === USER_ROLES.VIEWER
|
||||
? 'You need edit permissions to create a planned downtime'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
New downtime
|
||||
</Button>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={(): void => {
|
||||
setInitialValues({ ...defautlInitialValues, editMode: false });
|
||||
setIsOpen(true);
|
||||
setEditMode(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
disabled={user?.role === USER_ROLES.VIEWER}
|
||||
>
|
||||
New downtime
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<br />
|
||||
<PlannedDowntimeList
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { PlannedDowntime } from '../PlannedDowntime';
|
||||
|
||||
describe('PlannedDowntime Component', () => {
|
||||
it('renders the PlannedDowntime component properly', () => {
|
||||
render(<PlannedDowntime />, {}, 'ADMIN');
|
||||
|
||||
// Check if title is rendered
|
||||
expect(screen.getByText('Planned Downtime')).toBeInTheDocument();
|
||||
|
||||
// Check if subtitle is rendered
|
||||
expect(
|
||||
screen.getByText('Create and manage planned downtimes.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check if search input is rendered
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search for a planned downtime...'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check if "New downtime" button is enabled for ADMIN
|
||||
const newDowntimeButton = screen.getByRole('button', {
|
||||
name: /new downtime/i,
|
||||
});
|
||||
expect(newDowntimeButton).toBeInTheDocument();
|
||||
expect(newDowntimeButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables the "New downtime" button for users with VIEWER role', () => {
|
||||
render(<PlannedDowntime />, {}, USER_ROLES.VIEWER);
|
||||
|
||||
// Check if "New downtime" button is disabled for VIEWER
|
||||
const newDowntimeButton = screen.getByRole('button', {
|
||||
name: /new downtime/i,
|
||||
});
|
||||
expect(newDowntimeButton).toBeInTheDocument();
|
||||
expect(newDowntimeButton).toBeDisabled();
|
||||
|
||||
expect(newDowntimeButton).toHaveAttribute('disabled');
|
||||
});
|
||||
});
|
||||
@@ -458,7 +458,6 @@ export const Query = memo(function Query({
|
||||
query={query}
|
||||
onChange={handleChangeTagFilters}
|
||||
whereClauseConfig={filterConfigs?.filters}
|
||||
hideSpanScopeSelector={query.dataSource !== DataSource.TRACES}
|
||||
/>
|
||||
) : (
|
||||
<QueryBuilderSearch
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
.query-builder-search {
|
||||
.content {
|
||||
.suggested-filters {
|
||||
color: var(--bg-slate-50);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
padding: 12px 0px 8px 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-builder-search-v2 {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
@@ -91,10 +91,6 @@ interface QueryBuilderSearchV2Props {
|
||||
className?: string;
|
||||
suffixIcon?: React.ReactNode;
|
||||
hardcodedAttributeKeys?: BaseAutocompleteData[];
|
||||
hasPopupContainer?: boolean;
|
||||
hideSpanScopeSelector?: boolean;
|
||||
rootClassName?: string;
|
||||
maxTagCount?: number | 'responsive';
|
||||
operatorConfigKey?: OperatorConfigKeys;
|
||||
}
|
||||
|
||||
@@ -129,11 +125,7 @@ function QueryBuilderSearchV2(
|
||||
suffixIcon,
|
||||
whereClauseConfig,
|
||||
hardcodedAttributeKeys,
|
||||
hasPopupContainer,
|
||||
rootClassName,
|
||||
maxTagCount,
|
||||
operatorConfigKey,
|
||||
hideSpanScopeSelector,
|
||||
} = props;
|
||||
|
||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||
@@ -944,14 +936,16 @@ function QueryBuilderSearchV2(
|
||||
);
|
||||
};
|
||||
|
||||
const isTracesDataSource = useMemo(
|
||||
() => query.dataSource === DataSource.TRACES,
|
||||
[query.dataSource],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="query-builder-search-v2">
|
||||
<Select
|
||||
ref={selectRef}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...(hasPopupContainer ? { getPopupContainer: popupContainer } : {})}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...(maxTagCount ? { maxTagCount } : {})}
|
||||
getPopupContainer={popupContainer}
|
||||
key={queryTags.join('.')}
|
||||
virtual={false}
|
||||
showSearch
|
||||
@@ -983,7 +977,7 @@ function QueryBuilderSearchV2(
|
||||
: '',
|
||||
className,
|
||||
)}
|
||||
rootClassName={cx('query-builder-search', rootClassName)}
|
||||
rootClassName="query-builder-search"
|
||||
disabled={isMetricsDataSource && !query.aggregateAttribute.key}
|
||||
style={selectStyle}
|
||||
onSearch={handleSearch}
|
||||
@@ -1031,7 +1025,7 @@ function QueryBuilderSearchV2(
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
{!hideSpanScopeSelector && <SpanScopeSelector queryName={query.queryName} />}
|
||||
{isTracesDataSource && <SpanScopeSelector queryName={query.queryName} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1041,12 +1035,8 @@ QueryBuilderSearchV2.defaultProps = {
|
||||
className: '',
|
||||
suffixIcon: null,
|
||||
whereClauseConfig: {},
|
||||
hasPopupContainer: true,
|
||||
rootClassName: '',
|
||||
hardcodedAttributeKeys: undefined,
|
||||
maxTagCount: undefined,
|
||||
operatorConfigKey: undefined,
|
||||
hideSpanScopeSelector: true,
|
||||
};
|
||||
|
||||
export default QueryBuilderSearchV2;
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
// Modal base styles
|
||||
.add-span-to-funnel-modal-container {
|
||||
.ant-modal {
|
||||
&-content,
|
||||
&-header {
|
||||
background: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
&-header {
|
||||
border-bottom: none;
|
||||
|
||||
.ant-modal-title {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
&-body {
|
||||
padding: 14px 16px !important;
|
||||
}
|
||||
}
|
||||
&--details {
|
||||
.ant-modal-content {
|
||||
height: 710px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main modal styles
|
||||
.add-span-to-funnel-modal {
|
||||
// Common button styles
|
||||
%button-base {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: Inter;
|
||||
}
|
||||
|
||||
// Details view styles
|
||||
&--details {
|
||||
.traces-funnel-details {
|
||||
height: unset;
|
||||
|
||||
&__steps-config {
|
||||
width: unset;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.funnel-step-wrapper {
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.steps-content {
|
||||
height: 500px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search section
|
||||
&__search {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
align-items: center;
|
||||
|
||||
&-input {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-ink-300);
|
||||
|
||||
.ant-input-prefix {
|
||||
height: 18px;
|
||||
margin-inline-end: 6px;
|
||||
svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
&,
|
||||
input {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
font-weight: 400;
|
||||
background: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create button
|
||||
&__create-button {
|
||||
@extend %button-base;
|
||||
width: 153px;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-slate-500);
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
.funnel-item {
|
||||
padding: 8px 16px 12px;
|
||||
&,
|
||||
&:first-child {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
line-height: 20px;
|
||||
}
|
||||
&__details {
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
// List section
|
||||
&__list {
|
||||
max-height: 400px;
|
||||
overflow-y: scroll;
|
||||
.funnels-empty {
|
||||
&__content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.funnels-list {
|
||||
gap: 8px;
|
||||
|
||||
.funnel-item {
|
||||
padding: 8px 16px 12px;
|
||||
|
||||
&__details {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
// Back button
|
||||
&__back-button {
|
||||
@extend %button-base;
|
||||
gap: 6px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
// Details section
|
||||
&__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
.funnel-configuration__steps {
|
||||
padding: 0;
|
||||
|
||||
.funnel-step {
|
||||
&__content .filters__service-and-span .ant-select {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
&__footer .error {
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
.inter-step-config {
|
||||
width: calc(100% - 104px);
|
||||
}
|
||||
}
|
||||
.funnel-item__actions-popover {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light mode styles
|
||||
.lightMode {
|
||||
.add-span-to-funnel-modal-container {
|
||||
.ant-modal {
|
||||
&-content,
|
||||
&-header {
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
&-title {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-span-to-funnel-modal {
|
||||
&__search-input {
|
||||
background: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-500);
|
||||
|
||||
input {
|
||||
color: var(--bg-ink-500);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__create-button {
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
&__back-button {
|
||||
color: var(--bg-ink-500);
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
&__details h3 {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import './AddSpanToFunnelModal.styles.scss';
|
||||
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Spin } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import SignozModal from 'components/SignozModal/SignozModal';
|
||||
import {
|
||||
useFunnelDetails,
|
||||
useFunnelsList,
|
||||
} from 'hooks/TracesFunnels/useFunnels';
|
||||
import { ArrowLeft, Plus, Search } from 'lucide-react';
|
||||
import FunnelConfiguration from 'pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelConfiguration';
|
||||
import { TracesFunnelsContentRenderer } from 'pages/TracesFunnels';
|
||||
import CreateFunnel from 'pages/TracesFunnels/components/CreateFunnel/CreateFunnel';
|
||||
import { FunnelListItem } from 'pages/TracesFunnels/components/FunnelsList/FunnelsList';
|
||||
import { FunnelProvider } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { ChangeEvent, useMemo, useState } from 'react';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
enum ModalView {
|
||||
LIST = 'list',
|
||||
DETAILS = 'details',
|
||||
}
|
||||
|
||||
function FunnelDetailsView({
|
||||
funnel,
|
||||
span,
|
||||
}: {
|
||||
funnel: FunnelData;
|
||||
span: Span;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="add-span-to-funnel-modal__details">
|
||||
<FunnelListItem
|
||||
funnel={funnel}
|
||||
shouldRedirectToTracesListOnDeleteSuccess={false}
|
||||
/>
|
||||
<FunnelConfiguration funnel={funnel} isTraceDetailsPage span={span} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
interface AddSpanToFunnelModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
span: Span;
|
||||
}
|
||||
|
||||
function AddSpanToFunnelModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
span,
|
||||
}: AddSpanToFunnelModalProps): JSX.Element {
|
||||
const [activeView, setActiveView] = useState<ModalView>(ModalView.LIST);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [selectedFunnelId, setSelectedFunnelId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);
|
||||
|
||||
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setSearchQuery(e.target.value);
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, isFetching } = useFunnelsList({
|
||||
searchQuery: '',
|
||||
});
|
||||
|
||||
const filteredData = useMemo(
|
||||
() =>
|
||||
data?.payload
|
||||
?.filter((funnel) =>
|
||||
funnel.funnel_name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.creation_timestamp).getTime() -
|
||||
new Date(a.creation_timestamp).getTime(),
|
||||
),
|
||||
[data?.payload, searchQuery],
|
||||
);
|
||||
|
||||
const {
|
||||
data: funnelDetails,
|
||||
isLoading: isFunnelDetailsLoading,
|
||||
isFetching: isFunnelDetailsFetching,
|
||||
} = useFunnelDetails({
|
||||
funnelId: selectedFunnelId,
|
||||
});
|
||||
|
||||
const handleFunnelClick = (funnel: FunnelData): void => {
|
||||
setSelectedFunnelId(funnel.id);
|
||||
setActiveView(ModalView.DETAILS);
|
||||
};
|
||||
|
||||
const handleBack = (): void => {
|
||||
setActiveView(ModalView.LIST);
|
||||
setSelectedFunnelId(undefined);
|
||||
};
|
||||
|
||||
const handleCreateNewClick = (): void => {
|
||||
setIsCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const renderListView = (): JSX.Element => (
|
||||
<div className="add-span-to-funnel-modal">
|
||||
{!!filteredData?.length && (
|
||||
<div className="add-span-to-funnel-modal__search">
|
||||
<Input
|
||||
className="add-span-to-funnel-modal__search-input"
|
||||
placeholder="Search by name, description, or tags..."
|
||||
prefix={<Search size={12} />}
|
||||
value={searchQuery}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="add-span-to-funnel-modal__list">
|
||||
<OverlayScrollbar>
|
||||
<TracesFunnelsContentRenderer
|
||||
isError={isError}
|
||||
isLoading={isLoading || isFetching}
|
||||
data={filteredData || []}
|
||||
onCreateFunnel={handleCreateNewClick}
|
||||
onFunnelClick={(funnel: FunnelData): void => handleFunnelClick(funnel)}
|
||||
shouldRedirectToTracesListOnDeleteSuccess={false}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
<CreateFunnel
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={(funnelId): void => {
|
||||
if (funnelId) {
|
||||
setSelectedFunnelId(funnelId);
|
||||
setActiveView(ModalView.DETAILS);
|
||||
}
|
||||
setIsCreateModalOpen(false);
|
||||
}}
|
||||
redirectToDetails={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDetailsView = ({ span }: { span: Span }): JSX.Element => (
|
||||
<div className="add-span-to-funnel-modal add-span-to-funnel-modal--details">
|
||||
<Button
|
||||
type="text"
|
||||
className="add-span-to-funnel-modal__back-button"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
All funnels
|
||||
</Button>
|
||||
<Spin
|
||||
style={{ height: 400 }}
|
||||
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
|
||||
indicator={<LoadingOutlined spin />}
|
||||
>
|
||||
<div className="traces-funnel-details">
|
||||
<div className="traces-funnel-details__steps-config">
|
||||
{selectedFunnelId && funnelDetails?.payload && (
|
||||
<FunnelProvider funnelId={selectedFunnelId}>
|
||||
<FunnelDetailsView funnel={funnelDetails.payload} span={span} />
|
||||
</FunnelProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SignozModal
|
||||
open={isOpen}
|
||||
onCancel={onClose}
|
||||
width={570}
|
||||
title="Add span to funnel"
|
||||
className={cx('add-span-to-funnel-modal-container', {
|
||||
'add-span-to-funnel-modal-container--details':
|
||||
activeView === ModalView.DETAILS,
|
||||
})}
|
||||
okText="Save Funnel"
|
||||
footer={
|
||||
activeView === ModalView.LIST && !!filteredData?.length ? (
|
||||
<Button
|
||||
type="default"
|
||||
className="add-span-to-funnel-modal__create-button"
|
||||
onClick={handleCreateNewClick}
|
||||
icon={<Plus size={14} />}
|
||||
>
|
||||
Create new funnel
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{activeView === ModalView.LIST
|
||||
? renderListView()
|
||||
: renderDetailsView({ span })}
|
||||
</SignozModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddSpanToFunnelModal;
|
||||
@@ -95,10 +95,6 @@
|
||||
border-radius: 4px;
|
||||
background: rgba(171, 189, 255, 0.06) !important;
|
||||
|
||||
.div-td .span-overview .second-row .add-funnel-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.span-overview {
|
||||
background: unset !important;
|
||||
|
||||
@@ -235,24 +231,6 @@
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
.add-funnel-button {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: opacity 0.1s ease-in-out;
|
||||
|
||||
&__separator {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
&__button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import cx from 'classnames';
|
||||
import { TableV3 } from 'components/TableV3/TableV3';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import AddSpanToFunnelModal from 'container/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal';
|
||||
import SpanLineActionButtons from 'container/TraceWaterfall/SpanLineActionButtons';
|
||||
import { IInterestedSpan } from 'container/TraceWaterfall/TraceWaterfall';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
@@ -62,7 +61,6 @@ function SpanOverview({
|
||||
isSpanCollapsed,
|
||||
handleCollapseUncollapse,
|
||||
setSelectedSpan,
|
||||
handleAddSpanToFunnel,
|
||||
selectedSpan,
|
||||
}: {
|
||||
span: Span;
|
||||
@@ -70,8 +68,6 @@ function SpanOverview({
|
||||
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
|
||||
selectedSpan: Span | undefined;
|
||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||
|
||||
handleAddSpanToFunnel: (span: Span) => void;
|
||||
}): JSX.Element {
|
||||
const isRootSpan = span.level === 0;
|
||||
|
||||
@@ -149,28 +145,6 @@ function SpanOverview({
|
||||
<Typography.Text className="service-name">
|
||||
{span.serviceName}
|
||||
</Typography.Text>
|
||||
{!!span.serviceName && !!span.name && (
|
||||
<div className="add-funnel-button">
|
||||
<span className="add-funnel-button__separator">·</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="add-funnel-button__button"
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleAddSpanToFunnel(span);
|
||||
}}
|
||||
icon={
|
||||
<img
|
||||
className="add-funnel-button__icon"
|
||||
src="/Icons/funnel-add.svg"
|
||||
alt="funnel-icon"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,15 +235,12 @@ function getWaterfallColumns({
|
||||
traceMetadata,
|
||||
selectedSpan,
|
||||
setSelectedSpan,
|
||||
handleAddSpanToFunnel,
|
||||
}: {
|
||||
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
|
||||
uncollapsedNodes: string[];
|
||||
traceMetadata: ITraceMetadata;
|
||||
selectedSpan: Span | undefined;
|
||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||
|
||||
handleAddSpanToFunnel: (span: Span) => void;
|
||||
}): ColumnDef<Span, any>[] {
|
||||
const waterfallColumns: ColumnDef<Span, any>[] = [
|
||||
columnDefHelper.display({
|
||||
@@ -282,7 +253,6 @@ function getWaterfallColumns({
|
||||
isSpanCollapsed={!uncollapsedNodes.includes(props.row.original.spanId)}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
handleAddSpanToFunnel={handleAddSpanToFunnel}
|
||||
/>
|
||||
),
|
||||
size: 450,
|
||||
@@ -349,17 +319,6 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] = useState(
|
||||
false,
|
||||
);
|
||||
const [selectedSpanToAddToFunnel, setSelectedSpanToAddToFunnel] = useState<
|
||||
Span | undefined
|
||||
>(undefined);
|
||||
const handleAddSpanToFunnel = useCallback((span: Span): void => {
|
||||
setIsAddSpanToFunnelModalOpen(true);
|
||||
setSelectedSpanToAddToFunnel(span);
|
||||
}, []);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getWaterfallColumns({
|
||||
@@ -368,7 +327,6 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
traceMetadata,
|
||||
selectedSpan,
|
||||
setSelectedSpan,
|
||||
handleAddSpanToFunnel,
|
||||
}),
|
||||
[
|
||||
handleCollapseUncollapse,
|
||||
@@ -376,7 +334,6 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
traceMetadata,
|
||||
selectedSpan,
|
||||
setSelectedSpan,
|
||||
handleAddSpanToFunnel,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -448,13 +405,6 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
virtualiserRef={virtualizerRef}
|
||||
setColumnWidths={setTraceFlamegraphStatsWidth}
|
||||
/>
|
||||
{selectedSpanToAddToFunnel && (
|
||||
<AddSpanToFunnelModal
|
||||
span={selectedSpanToAddToFunnel}
|
||||
isOpen={isAddSpanToFunnelModalOpen}
|
||||
onClose={(): void => setIsAddSpanToFunnelModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { FunnelData, FunnelStepData } from 'types/api/traceFunnels';
|
||||
|
||||
import { useUpdateFunnelSteps } from './useFunnels';
|
||||
|
||||
interface UseFunnelConfiguration {
|
||||
isPopoverOpen: boolean;
|
||||
setIsPopoverOpen: (isPopoverOpen: boolean) => void;
|
||||
steps: FunnelStepData[];
|
||||
}
|
||||
|
||||
// Add this helper function
|
||||
const normalizeSteps = (steps: FunnelStepData[]): FunnelStepData[] => {
|
||||
if (steps.some((step) => !step.filters)) return steps;
|
||||
|
||||
return steps.map((step) => ({
|
||||
...step,
|
||||
filters: {
|
||||
...step.filters,
|
||||
items: step.filters.items.map((item) => ({
|
||||
id: '',
|
||||
key: item.key,
|
||||
value: item.value,
|
||||
op: item.op,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function useFunnelConfiguration({
|
||||
funnel,
|
||||
}: {
|
||||
funnel: FunnelData;
|
||||
}): UseFunnelConfiguration {
|
||||
const { notifications } = useNotifications();
|
||||
const {
|
||||
steps,
|
||||
initialSteps,
|
||||
setHasIncompleteStepFields,
|
||||
setHasAllEmptyStepFields,
|
||||
handleRestoreSteps,
|
||||
} = useFunnelContext();
|
||||
|
||||
// State management
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
const debouncedSteps = useDebounce(steps, 200);
|
||||
|
||||
const [lastValidatedSteps, setLastValidatedSteps] = useState<FunnelStepData[]>(
|
||||
initialSteps,
|
||||
);
|
||||
|
||||
// Mutation hooks
|
||||
const updateStepsMutation = useUpdateFunnelSteps(funnel.id, notifications);
|
||||
|
||||
// Derived state
|
||||
const lastSavedStepsStateRef = useRef<FunnelStepData[]>(steps);
|
||||
|
||||
const hasStepsChanged = useCallback(() => {
|
||||
const normalizedLastSavedSteps = normalizeSteps(
|
||||
lastSavedStepsStateRef.current,
|
||||
);
|
||||
const normalizedDebouncedSteps = normalizeSteps(debouncedSteps);
|
||||
return !isEqual(normalizedDebouncedSteps, normalizedLastSavedSteps);
|
||||
}, [debouncedSteps]);
|
||||
|
||||
const hasStepServiceOrSpanNameChanged = useCallback(
|
||||
(prevSteps: FunnelStepData[], nextSteps: FunnelStepData[]): boolean => {
|
||||
if (prevSteps.length !== nextSteps.length) return true;
|
||||
return prevSteps.some((step, index) => {
|
||||
const nextStep = nextSteps[index];
|
||||
return (
|
||||
step.service_name !== nextStep.service_name ||
|
||||
step.span_name !== nextStep.span_name
|
||||
);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Mutation payload preparation
|
||||
const getUpdatePayload = useCallback(
|
||||
() => ({
|
||||
funnel_id: funnel.id,
|
||||
steps: debouncedSteps,
|
||||
updated_timestamp: Date.now(),
|
||||
}),
|
||||
[funnel.id, debouncedSteps],
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedTime } = useFunnelContext();
|
||||
|
||||
const validateStepsQueryKey = useMemo(
|
||||
() => [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnel.id, selectedTime],
|
||||
[funnel.id, selectedTime],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (hasStepsChanged()) {
|
||||
updateStepsMutation.mutate(getUpdatePayload(), {
|
||||
onSuccess: (data) => {
|
||||
const updatedFunnelSteps = data?.payload?.steps;
|
||||
|
||||
if (!updatedFunnelSteps) return;
|
||||
|
||||
queryClient.setQueryData(
|
||||
[REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnel.id],
|
||||
(oldData: any) => ({
|
||||
...oldData,
|
||||
payload: {
|
||||
...oldData.payload,
|
||||
steps: updatedFunnelSteps,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
lastSavedStepsStateRef.current = updatedFunnelSteps;
|
||||
|
||||
const hasIncompleteStepFields = updatedFunnelSteps.some(
|
||||
(step) => step.service_name === '' || step.span_name === '',
|
||||
);
|
||||
|
||||
const hasAllEmptyStepsData = updatedFunnelSteps.every(
|
||||
(step) => step.service_name === '' && step.span_name === '',
|
||||
);
|
||||
|
||||
setHasIncompleteStepFields(hasIncompleteStepFields);
|
||||
setHasAllEmptyStepFields(hasAllEmptyStepsData);
|
||||
|
||||
// Only validate if service_name or span_name changed
|
||||
if (
|
||||
!hasIncompleteStepFields &&
|
||||
hasStepServiceOrSpanNameChanged(lastValidatedSteps, debouncedSteps)
|
||||
) {
|
||||
queryClient.refetchQueries(validateStepsQueryKey);
|
||||
setLastValidatedSteps(debouncedSteps);
|
||||
}
|
||||
},
|
||||
|
||||
onError: () => {
|
||||
handleRestoreSteps(lastSavedStepsStateRef.current);
|
||||
queryClient.setQueryData(
|
||||
[REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnel.id],
|
||||
(oldData: any) => ({
|
||||
...oldData,
|
||||
payload: {
|
||||
...oldData.payload,
|
||||
steps: lastSavedStepsStateRef.current,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
debouncedSteps,
|
||||
getUpdatePayload,
|
||||
hasStepServiceOrSpanNameChanged,
|
||||
hasStepsChanged,
|
||||
lastValidatedSteps,
|
||||
queryClient,
|
||||
validateStepsQueryKey,
|
||||
]);
|
||||
|
||||
return {
|
||||
isPopoverOpen,
|
||||
setIsPopoverOpen,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { FunnelStepGraphMetrics } from 'api/traceFunnels';
|
||||
import { Chart, ChartConfiguration } from 'chart.js';
|
||||
import ChangePercentagePill from 'components/ChangePercentagePill/ChangePercentagePill';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
const CHART_CONFIG: Partial<ChartConfiguration> = {
|
||||
type: 'bar',
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
family: "'Geist Mono', monospace",
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(192, 193, 195, 0.04)',
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
family: "'Geist Mono', monospace",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface UseFunnelGraphProps {
|
||||
data: FunnelStepGraphMetrics | undefined;
|
||||
}
|
||||
|
||||
interface UseFunnelGraph {
|
||||
successSteps: number[];
|
||||
errorSteps: number[];
|
||||
totalSteps: number;
|
||||
canvasRef: React.RefObject<HTMLCanvasElement>;
|
||||
renderLegendItem: (
|
||||
step: number,
|
||||
successSpans: number,
|
||||
errorSpans: number,
|
||||
prevTotalSpans: number,
|
||||
) => JSX.Element;
|
||||
}
|
||||
|
||||
function useFunnelGraph({ data }: UseFunnelGraphProps): UseFunnelGraph {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const chartRef = useRef<Chart | null>(null);
|
||||
|
||||
const getPercentageChange = useCallback(
|
||||
(current: number, previous: number): number => {
|
||||
if (previous === 0) return 0;
|
||||
return Math.abs(Math.round(((current - previous) / previous) * 100));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
interface StepGraphData {
|
||||
successSteps: number[];
|
||||
errorSteps: number[];
|
||||
totalSteps: number;
|
||||
}
|
||||
const getStepGraphData = useCallback((): StepGraphData => {
|
||||
const successSteps: number[] = [];
|
||||
const errorSteps: number[] = [];
|
||||
let stepCount = 1;
|
||||
|
||||
if (!data) return { successSteps, errorSteps, totalSteps: 0 };
|
||||
|
||||
while (
|
||||
data[`total_s${stepCount}_spans`] !== undefined &&
|
||||
data[`total_s${stepCount}_errored_spans`] !== undefined
|
||||
) {
|
||||
const totalSpans = data[`total_s${stepCount}_spans`];
|
||||
const erroredSpans = data[`total_s${stepCount}_errored_spans`];
|
||||
const successSpans = totalSpans - erroredSpans;
|
||||
|
||||
successSteps.push(successSpans);
|
||||
errorSteps.push(erroredSpans);
|
||||
stepCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
successSteps,
|
||||
errorSteps,
|
||||
totalSteps: stepCount - 1,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy();
|
||||
}
|
||||
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const { successSteps, errorSteps, totalSteps } = getStepGraphData();
|
||||
|
||||
chartRef.current = new Chart(ctx, {
|
||||
...CHART_CONFIG,
|
||||
data: {
|
||||
labels: Array.from({ length: totalSteps }, (_, i) => String(i + 1)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Success spans',
|
||||
data: successSteps,
|
||||
backgroundColor: Color.BG_ROBIN_500,
|
||||
stack: 'Stack 0',
|
||||
borderRadius: 2,
|
||||
borderSkipped: false,
|
||||
},
|
||||
{
|
||||
label: 'Error spans',
|
||||
data: errorSteps,
|
||||
backgroundColor: Color.BG_CHERRY_500,
|
||||
stack: 'Stack 0',
|
||||
borderRadius: 2,
|
||||
borderSkipped: false,
|
||||
borderWidth: {
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
},
|
||||
borderColor: 'rgba(0, 0, 0, 0)',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: CHART_CONFIG.options,
|
||||
} as ChartConfiguration);
|
||||
}, [data, getStepGraphData]);
|
||||
|
||||
// Log the widths when they change
|
||||
|
||||
const renderLegendItem = useCallback(
|
||||
(
|
||||
step: number,
|
||||
successSpans: number,
|
||||
errorSpans: number,
|
||||
prevTotalSpans: number,
|
||||
): JSX.Element => {
|
||||
const totalSpans = successSpans + errorSpans;
|
||||
|
||||
return (
|
||||
<div key={step} className="funnel-graph__legend-column">
|
||||
<div className="legend-item">
|
||||
<div className="legend-item__left">
|
||||
<span className="legend-item__dot legend-item--total" />
|
||||
<span className="legend-item__label">Total spans</span>
|
||||
</div>
|
||||
<div className="legend-item__right">
|
||||
<span className="legend-item__value">{totalSpans}</span>
|
||||
{step > 1 && (
|
||||
<ChangePercentagePill
|
||||
direction={totalSpans < prevTotalSpans ? -1 : 1}
|
||||
percentage={getPercentageChange(totalSpans, prevTotalSpans)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="legend-item">
|
||||
<div className="legend-item__left">
|
||||
<span className="legend-item__dot legend-item--error" />
|
||||
<span className="legend-item__label">Error spans</span>
|
||||
</div>
|
||||
<div className="legend-item__right">
|
||||
<span className="legend-item__value">{errorSpans}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[getPercentageChange],
|
||||
);
|
||||
|
||||
const { successSteps, errorSteps, totalSteps } = getStepGraphData();
|
||||
|
||||
return {
|
||||
successSteps,
|
||||
errorSteps,
|
||||
totalSteps,
|
||||
canvasRef,
|
||||
renderLegendItem,
|
||||
};
|
||||
}
|
||||
|
||||
export default useFunnelGraph;
|
||||
@@ -1,69 +0,0 @@
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { MetricItem } from 'pages/TracesFunnelDetails/components/FunnelResults/FunnelMetricsTable';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useFunnelOverview } from './useFunnels';
|
||||
|
||||
interface FunnelMetricsParams {
|
||||
funnelId: string;
|
||||
stepStart?: number;
|
||||
stepEnd?: number;
|
||||
}
|
||||
|
||||
export function useFunnelMetrics({
|
||||
funnelId,
|
||||
stepStart,
|
||||
stepEnd,
|
||||
}: FunnelMetricsParams): {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
metricsData: MetricItem[];
|
||||
conversionRate: number;
|
||||
} {
|
||||
const { startTime, endTime } = useFunnelContext();
|
||||
const payload = {
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
...(stepStart !== undefined && { step_start: stepStart }),
|
||||
...(stepEnd !== undefined && { step_end: stepEnd }),
|
||||
};
|
||||
|
||||
const {
|
||||
data: overviewData,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
} = useFunnelOverview(funnelId, payload);
|
||||
|
||||
const metricsData = useMemo(() => {
|
||||
const sourceData = overviewData?.payload?.data?.[0]?.data;
|
||||
if (!sourceData) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'Avg. Rate',
|
||||
value: `${Number(sourceData.avg_rate.toFixed(2))} req/s`,
|
||||
},
|
||||
{ title: 'Errors', value: sourceData.errors },
|
||||
{
|
||||
title: 'Avg. Duration',
|
||||
value: getYAxisFormattedValue(sourceData.avg_duration.toString(), 'ns'),
|
||||
},
|
||||
{
|
||||
title: 'P99 Latency',
|
||||
value: getYAxisFormattedValue(sourceData.p99_latency.toString(), 'ns'),
|
||||
},
|
||||
];
|
||||
}, [overviewData]);
|
||||
|
||||
const conversionRate =
|
||||
overviewData?.payload?.data?.[0]?.data?.conversion_rate ?? 0;
|
||||
|
||||
return {
|
||||
isLoading: isLoading || isFetching,
|
||||
isError,
|
||||
metricsData,
|
||||
conversionRate,
|
||||
};
|
||||
}
|
||||
@@ -1,31 +1,11 @@
|
||||
import { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import {
|
||||
createFunnel,
|
||||
deleteFunnel,
|
||||
ErrorTraceData,
|
||||
ErrorTracesPayload,
|
||||
FunnelOverviewPayload,
|
||||
FunnelOverviewResponse,
|
||||
FunnelStepsResponse,
|
||||
getFunnelById,
|
||||
getFunnelErrorTraces,
|
||||
getFunnelOverview,
|
||||
getFunnelsList,
|
||||
getFunnelSlowTraces,
|
||||
getFunnelSteps,
|
||||
renameFunnel,
|
||||
saveFunnelDescription,
|
||||
SlowTraceData,
|
||||
SlowTracesPayload,
|
||||
updateFunnelStepDetails,
|
||||
UpdateFunnelStepDetailsPayload,
|
||||
updateFunnelSteps,
|
||||
UpdateFunnelStepsPayload,
|
||||
ValidateFunnelResponse,
|
||||
validateFunnelSteps,
|
||||
} from 'api/traceFunnels';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import {
|
||||
useMutation,
|
||||
UseMutationResult,
|
||||
@@ -40,20 +20,19 @@ import {
|
||||
} from 'types/api/traceFunnels';
|
||||
|
||||
export const useFunnelsList = ({
|
||||
searchQuery = '',
|
||||
searchQuery,
|
||||
}: {
|
||||
searchQuery?: string;
|
||||
searchQuery: string;
|
||||
}): UseQueryResult<SuccessResponse<FunnelData[]> | ErrorResponse, unknown> =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNELS_LIST, searchQuery],
|
||||
queryFn: () => getFunnelsList({ search: searchQuery }),
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
export const useFunnelDetails = ({
|
||||
funnelId,
|
||||
}: {
|
||||
funnelId?: string;
|
||||
funnelId: string;
|
||||
}): UseQueryResult<SuccessResponse<FunnelData> | ErrorResponse, unknown> =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnelId],
|
||||
@@ -96,148 +75,3 @@ export const useDeleteFunnel = (): UseMutationResult<
|
||||
useMutation({
|
||||
mutationFn: deleteFunnel,
|
||||
});
|
||||
|
||||
export const useUpdateFunnelSteps = (
|
||||
funnelId: string,
|
||||
notification: NotificationInstance,
|
||||
): UseMutationResult<
|
||||
SuccessResponse<FunnelData> | ErrorResponse,
|
||||
Error,
|
||||
UpdateFunnelStepsPayload
|
||||
> =>
|
||||
useMutation({
|
||||
mutationFn: updateFunnelSteps,
|
||||
mutationKey: [REACT_QUERY_KEY.UPDATE_FUNNEL_STEPS, funnelId],
|
||||
|
||||
onError: (error) => {
|
||||
notification.error({
|
||||
message: 'Failed to update funnel steps',
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const useValidateFunnelSteps = ({
|
||||
funnelId,
|
||||
selectedTime,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
funnelId: string;
|
||||
selectedTime: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}): UseQueryResult<
|
||||
SuccessResponse<ValidateFunnelResponse> | ErrorResponse,
|
||||
Error
|
||||
> =>
|
||||
useQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
validateFunnelSteps(
|
||||
funnelId,
|
||||
{ start_time: startTime, end_time: endTime },
|
||||
signal,
|
||||
),
|
||||
queryKey: [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnelId, selectedTime],
|
||||
enabled: !!funnelId && !!selectedTime && !!startTime && !!endTime,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
export const useUpdateFunnelStepDetails = ({
|
||||
stepOrder,
|
||||
}: {
|
||||
stepOrder: number;
|
||||
}): UseMutationResult<
|
||||
SuccessResponse<FunnelData> | ErrorResponse,
|
||||
Error,
|
||||
UpdateFunnelStepDetailsPayload
|
||||
> =>
|
||||
useMutation({
|
||||
mutationFn: (payload) => updateFunnelStepDetails({ payload, stepOrder }),
|
||||
mutationKey: [REACT_QUERY_KEY.UPDATE_FUNNEL_STEP_DETAILS, stepOrder],
|
||||
});
|
||||
|
||||
interface SaveFunnelDescriptionPayload {
|
||||
funnel_id: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const useSaveFunnelDescription = (): UseMutationResult<
|
||||
SuccessResponse<FunnelData> | ErrorResponse,
|
||||
Error,
|
||||
SaveFunnelDescriptionPayload
|
||||
> =>
|
||||
useMutation({
|
||||
mutationFn: saveFunnelDescription,
|
||||
});
|
||||
|
||||
export const useFunnelOverview = (
|
||||
funnelId: string,
|
||||
payload: FunnelOverviewPayload,
|
||||
): UseQueryResult<
|
||||
SuccessResponse<FunnelOverviewResponse> | ErrorResponse,
|
||||
Error
|
||||
> => {
|
||||
const { selectedTime, validTracesCount } = useFunnelContext();
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) => getFunnelOverview(funnelId, payload, signal),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_FUNNEL_OVERVIEW,
|
||||
funnelId,
|
||||
selectedTime,
|
||||
payload.step_start ?? '',
|
||||
payload.step_end ?? '',
|
||||
],
|
||||
enabled: !!funnelId && validTracesCount > 0,
|
||||
});
|
||||
};
|
||||
|
||||
export const useFunnelSlowTraces = (
|
||||
funnelId: string,
|
||||
payload: SlowTracesPayload,
|
||||
): UseQueryResult<SuccessResponse<SlowTraceData> | ErrorResponse, Error> => {
|
||||
const { selectedTime, validTracesCount } = useFunnelContext();
|
||||
return useQuery<SuccessResponse<SlowTraceData> | ErrorResponse, Error>({
|
||||
queryFn: ({ signal }) => getFunnelSlowTraces(funnelId, payload, signal),
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES, funnelId, selectedTime],
|
||||
enabled: !!funnelId && validTracesCount > 0,
|
||||
});
|
||||
};
|
||||
|
||||
export const useFunnelErrorTraces = (
|
||||
funnelId: string,
|
||||
payload: ErrorTracesPayload,
|
||||
): UseQueryResult<SuccessResponse<ErrorTraceData> | ErrorResponse, Error> => {
|
||||
const { selectedTime, validTracesCount } = useFunnelContext();
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) => getFunnelErrorTraces(funnelId, payload, signal),
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES, funnelId, selectedTime],
|
||||
enabled: !!funnelId && validTracesCount > 0,
|
||||
});
|
||||
};
|
||||
|
||||
export function useFunnelStepsGraphData(
|
||||
funnelId: string,
|
||||
): UseQueryResult<SuccessResponse<FunnelStepsResponse> | ErrorResponse, Error> {
|
||||
const {
|
||||
startTime,
|
||||
endTime,
|
||||
selectedTime,
|
||||
validTracesCount,
|
||||
} = useFunnelContext();
|
||||
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
getFunnelSteps(
|
||||
funnelId,
|
||||
{ start_time: startTime, end_time: endTime },
|
||||
signal,
|
||||
),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_FUNNEL_STEPS_GRAPH_DATA,
|
||||
funnelId,
|
||||
selectedTime,
|
||||
],
|
||||
enabled: !!funnelId && validTracesCount > 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,38 +1,31 @@
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { ChangeEvent, useCallback, useState } from 'react';
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
|
||||
const useHandleTraceFunnelsSearch = (): {
|
||||
searchQuery: string;
|
||||
handleSearch: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
} => {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
const [searchQuery, setSearchQuery] = useState<string>(
|
||||
urlQuery.get('search') || '',
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedUpdateUrl = useCallback(
|
||||
debounce((value: string) => {
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue) {
|
||||
urlQuery.set('search', trimmedValue);
|
||||
} else {
|
||||
urlQuery.delete('search');
|
||||
}
|
||||
|
||||
safeNavigate({ search: urlQuery.toString() });
|
||||
}, 300),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const { value } = e.target;
|
||||
setSearchQuery(value);
|
||||
debouncedUpdateUrl(value);
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue) {
|
||||
urlQuery.set('search', trimmedValue);
|
||||
} else {
|
||||
urlQuery.delete('search');
|
||||
}
|
||||
|
||||
safeNavigate({ search: urlQuery.toString() });
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
/* eslint-disable */
|
||||
//@ts-nocheck
|
||||
|
||||
import { Select, Space, Typography } from 'antd';
|
||||
import Graph from 'components/Graph';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { connect, useSelector } from 'react-redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { GetService, getUsageData, UsageDataItem } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalTime } from 'types/actions/globalTime';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import MetricReducer from 'types/reducer/metrics';
|
||||
import { isOnboardingSkipped } from 'utils/app';
|
||||
|
||||
import { Card } from './styles';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface UsageExplorerProps {
|
||||
usageData: UsageDataItem[];
|
||||
getUsageData: (
|
||||
minTime: number,
|
||||
maxTime: number,
|
||||
selectedInterval: number,
|
||||
selectedService: string,
|
||||
) => void;
|
||||
getServicesList: ({
|
||||
selectedTimeInterval,
|
||||
}: {
|
||||
selectedTimeInterval: GlobalReducer['selectedTime'];
|
||||
}) => void;
|
||||
globalTime: GlobalTime;
|
||||
servicesList: servicesListItem[];
|
||||
totalCount: number;
|
||||
}
|
||||
const timeDaysOptions = [
|
||||
{ value: 30, label: 'Last 30 Days' },
|
||||
{ value: 7, label: 'Last week' },
|
||||
{ value: 1, label: 'Last day' },
|
||||
];
|
||||
|
||||
const interval = [
|
||||
{
|
||||
value: 604800,
|
||||
chartDivideMultiplier: 1,
|
||||
label: 'Weekly',
|
||||
applicableOn: [timeDaysOptions[0]],
|
||||
},
|
||||
{
|
||||
value: 86400,
|
||||
chartDivideMultiplier: 30,
|
||||
label: 'Daily',
|
||||
applicableOn: [timeDaysOptions[0], timeDaysOptions[1]],
|
||||
},
|
||||
{
|
||||
value: 3600,
|
||||
chartDivideMultiplier: 10,
|
||||
label: 'Hours',
|
||||
applicableOn: [timeDaysOptions[2], timeDaysOptions[1]],
|
||||
},
|
||||
];
|
||||
|
||||
function _UsageExplorer(props: UsageExplorerProps): JSX.Element {
|
||||
const [selectedTime, setSelectedTime] = useState(timeDaysOptions[1]);
|
||||
const [selectedInterval, setSelectedInterval] = useState(interval[2]);
|
||||
const [selectedService, setSelectedService] = useState<string>('');
|
||||
const { selectedTime: globalSelectedTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const {
|
||||
getServicesList,
|
||||
getUsageData,
|
||||
globalTime,
|
||||
totalCount,
|
||||
usageData,
|
||||
} = props;
|
||||
const { services } = useSelector<AppState, MetricReducer>(
|
||||
(state) => state.metrics,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTime && selectedInterval) {
|
||||
const maxTime = new Date().getTime() * 1000000;
|
||||
const minTime = maxTime - selectedTime.value * 24 * 3600000 * 1000000;
|
||||
|
||||
getUsageData(minTime, maxTime, selectedInterval.value, selectedService);
|
||||
}
|
||||
}, [selectedTime, selectedInterval, selectedService, getUsageData]);
|
||||
|
||||
useEffect(() => {
|
||||
getServicesList({
|
||||
selectedTimeInterval: globalSelectedTime,
|
||||
});
|
||||
}, [globalTime, getServicesList, globalSelectedTime]);
|
||||
|
||||
const data = {
|
||||
labels: usageData.map((s) => new Date(s.timestamp / 1000000)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Span Count',
|
||||
data: usageData.map((s) => s.count),
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.2)',
|
||||
borderColor: 'rgba(255, 99, 132, 1)',
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space style={{ marginTop: 40, marginLeft: 20 }}>
|
||||
<Space>
|
||||
<Select
|
||||
onSelect={(value): void => {
|
||||
setSelectedTime(
|
||||
timeDaysOptions.filter((item) => item.value == parseInt(value))[0],
|
||||
);
|
||||
}}
|
||||
value={selectedTime.label}
|
||||
>
|
||||
{timeDaysOptions.map(({ value, label }) => (
|
||||
<Option key={value} value={value}>
|
||||
{label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Space>
|
||||
<Space>
|
||||
<Select
|
||||
onSelect={(value): void => {
|
||||
setSelectedInterval(
|
||||
interval.filter((item) => item.value === parseInt(value))[0],
|
||||
);
|
||||
}}
|
||||
value={selectedInterval.label}
|
||||
>
|
||||
{interval
|
||||
.filter((interval) => interval.applicableOn.includes(selectedTime))
|
||||
.map((item) => (
|
||||
<Option key={item.label} value={item.value}>
|
||||
{item.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Select
|
||||
onSelect={(value): void => {
|
||||
setSelectedService(value);
|
||||
}}
|
||||
value={selectedService || 'All Services'}
|
||||
>
|
||||
<Option value="">All Services</Option>
|
||||
{services?.map((service) => (
|
||||
<Option key={service.serviceName} value={service.serviceName}>
|
||||
{service.serviceName}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Space>
|
||||
|
||||
{isOnboardingSkipped() && totalCount === 0 ? (
|
||||
<Space
|
||||
style={{
|
||||
width: '100%',
|
||||
margin: '40px 0',
|
||||
marginLeft: 20,
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography>
|
||||
No spans found. Please add instrumentation (follow this
|
||||
<a
|
||||
href="https://signoz.io/docs/instrumentation/overview"
|
||||
target="_blank"
|
||||
style={{ marginLeft: 3 }}
|
||||
rel="noreferrer"
|
||||
>
|
||||
guide
|
||||
</a>
|
||||
)
|
||||
</Typography>
|
||||
</Space>
|
||||
) : (
|
||||
<Space style={{ display: 'block', marginLeft: 20, width: 200 }}>
|
||||
<Typography>{`Total count is ${totalCount}`}</Typography>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Card>
|
||||
<Graph name="usage" data={data} type="bar" />
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const mapStateToProps = (
|
||||
state: AppState,
|
||||
): {
|
||||
totalCount: number;
|
||||
globalTime: GlobalTime;
|
||||
usageData: UsageDataItem[];
|
||||
} => {
|
||||
let totalCount = 0;
|
||||
for (const item of state.usageDate) {
|
||||
totalCount += item.count;
|
||||
}
|
||||
return {
|
||||
totalCount,
|
||||
usageData: state.usageDate,
|
||||
globalTime: state.globalTime,
|
||||
};
|
||||
};
|
||||
|
||||
export const UsageExplorer = withRouter(
|
||||
connect(mapStateToProps, {
|
||||
getUsageData,
|
||||
getServicesList: GetService,
|
||||
})(_UsageExplorer),
|
||||
);
|
||||
@@ -1,7 +0,0 @@
|
||||
import { UsageExplorer } from './UsageExplorer';
|
||||
|
||||
function UsageExplorerContainer(): JSX.Element {
|
||||
return <UsageExplorer />;
|
||||
}
|
||||
|
||||
export default UsageExplorerContainer;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Card as CardComponent } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Card = styled(CardComponent)`
|
||||
&&& {
|
||||
width: 90%;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
height: 70vh;
|
||||
}
|
||||
`;
|
||||
@@ -1,7 +1,4 @@
|
||||
.traces-module-container {
|
||||
.funnel-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.trace-module {
|
||||
.ant-tabs-tab {
|
||||
.tab-item {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import './TraceDetailV2.styles.scss';
|
||||
|
||||
import { Button, Tabs } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { Compass, Cone, TowerControl, Undo } from 'lucide-react';
|
||||
import { Compass, TowerControl, Undo } from 'lucide-react';
|
||||
import TraceDetail from 'pages/TraceDetail';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
@@ -34,10 +33,6 @@ function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
|
||||
if (activeKey === 'trace-details') {
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
}
|
||||
if (activeKey === 'funnels') {
|
||||
logEvent('Trace Funnels: visited from trace details page', {});
|
||||
history.push(ROUTES.TRACES_FUNNELS);
|
||||
}
|
||||
}}
|
||||
tabBarExtraContent={
|
||||
<Button
|
||||
@@ -56,7 +51,6 @@ function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
|
||||
|
||||
export default function TraceDetailsPage(): JSX.Element {
|
||||
const [showOldTraceDetails, setShowOldTraceDetails] = useState<boolean>(false);
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: (
|
||||
@@ -67,15 +61,6 @@ export default function TraceDetailsPage(): JSX.Element {
|
||||
key: 'trace-details',
|
||||
children: <TraceDetailsV2 />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="tab-item">
|
||||
<Cone className="funnel-icon" size={16} /> Funnels
|
||||
</div>
|
||||
),
|
||||
key: 'funnels',
|
||||
children: <div />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="tab-item">
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
.traces-funnel-details {
|
||||
display: flex;
|
||||
// 45px -> height of the tab bar
|
||||
height: calc(100vh - 45px);
|
||||
|
||||
&__steps-config {
|
||||
flex-shrink: 0;
|
||||
width: 600px;
|
||||
border-right: 1px solid var(--bg-slate-400);
|
||||
position: relative;
|
||||
}
|
||||
&__steps-results {
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.traces-funnel-details {
|
||||
&__steps-config {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,13 @@
|
||||
import './TracesFunnelDetails.styles.scss';
|
||||
|
||||
import { Typography } from 'antd';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { NotFoundContainer } from 'container/GridCardLayout/GridCard/FullView/styles';
|
||||
import { useFunnelDetails } from 'hooks/TracesFunnels/useFunnels';
|
||||
import { FunnelProvider } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import FunnelConfiguration from './components/FunnelConfiguration/FunnelConfiguration';
|
||||
import FunnelResults from './components/FunnelResults/FunnelResults';
|
||||
|
||||
function TracesFunnelDetails(): JSX.Element {
|
||||
const { funnelId } = useParams<{ funnelId: string }>();
|
||||
const { data, isLoading, isError } = useFunnelDetails({ funnelId });
|
||||
|
||||
if (isLoading || !data?.payload) {
|
||||
return <Spinner size="large" tip="Loading..." />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<NotFoundContainer>
|
||||
<Typography>Error loading funnel details</Typography>
|
||||
</NotFoundContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const { data } = useFunnelDetails({ funnelId });
|
||||
return (
|
||||
<FunnelProvider funnelId={funnelId}>
|
||||
<div className="traces-funnel-details">
|
||||
<div className="traces-funnel-details__steps-config">
|
||||
<FunnelConfiguration funnel={data.payload} />
|
||||
</div>
|
||||
<div className="traces-funnel-details__steps-results">
|
||||
<FunnelResults />
|
||||
</div>
|
||||
</div>
|
||||
</FunnelProvider>
|
||||
<div style={{ color: 'var(--bg-vanilla-400)' }}>
|
||||
TracesFunnelDetails, {JSON.stringify(data)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
.funnel-step-modal {
|
||||
.ant-modal-content {
|
||||
background: var(--bg-ink-400);
|
||||
.ant-modal-header {
|
||||
background: var(--bg-ink-400);
|
||||
.ant-modal-title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
.ant-modal-body {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__ok-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-robin-500);
|
||||
border: none;
|
||||
|
||||
&[disabled] {
|
||||
background: var(--bg-slate-400);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__cancel-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.funnel-step-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
background: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.ant-input-textarea {
|
||||
.ant-input {
|
||||
background: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light mode styles
|
||||
.lightMode {
|
||||
.funnel-step-modal {
|
||||
.ant-modal-content {
|
||||
.ant-modal-header {
|
||||
background: var(--bg-vanilla-100);
|
||||
.ant-modal-title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__cancel-btn {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.funnel-step-modal-content {
|
||||
&__label {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
&__input {
|
||||
background: var(--bg-vanilla-400);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
|
||||
&.ant-input-textarea {
|
||||
.ant-input {
|
||||
background: var(--bg-vanilla-400);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import './AddFunnelDescriptionModal.styles.scss';
|
||||
|
||||
import { Input } from 'antd';
|
||||
import SignozModal from 'components/SignozModal/SignozModal';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useSaveFunnelDescription } from 'hooks/TracesFunnels/useFunnels';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
interface AddFunnelDescriptionProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
funnelId: string;
|
||||
}
|
||||
|
||||
function AddFunnelDescriptionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
funnelId,
|
||||
}: AddFunnelDescriptionProps): JSX.Element {
|
||||
const [description, setDescription] = useState<string>('');
|
||||
const { notifications } = useNotifications();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
mutate: saveFunnelDescription,
|
||||
isLoading,
|
||||
} = useSaveFunnelDescription();
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setDescription('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSave = (): void => {
|
||||
saveFunnelDescription(
|
||||
{
|
||||
funnel_id: funnelId,
|
||||
description,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries([
|
||||
REACT_QUERY_KEY.GET_FUNNEL_DETAILS,
|
||||
funnelId,
|
||||
]);
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: 'Funnel description saved successfully',
|
||||
});
|
||||
handleCancel();
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.error({
|
||||
message: 'Failed to save funnel description',
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SignozModal
|
||||
open={isOpen}
|
||||
title="Add funnel description"
|
||||
width={384}
|
||||
onCancel={handleCancel}
|
||||
rootClassName="funnel-step-modal funnel-modal signoz-modal"
|
||||
cancelText="Cancel"
|
||||
okText="Save changes"
|
||||
okButtonProps={{
|
||||
icon: <Check size={14} />,
|
||||
type: 'primary',
|
||||
className: 'funnel-step-modal__ok-btn',
|
||||
onClick: handleSave,
|
||||
loading: isLoading,
|
||||
}}
|
||||
cancelButtonProps={{
|
||||
icon: <X size={14} />,
|
||||
type: 'text',
|
||||
className: 'funnel-step-modal__cancel-btn',
|
||||
onClick: handleCancel,
|
||||
disabled: isLoading,
|
||||
}}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="funnel-step-modal-content">
|
||||
<div className="funnel-step-modal-content__field">
|
||||
<span className="funnel-step-modal-content__label">Description</span>
|
||||
<Input.TextArea
|
||||
className="funnel-step-modal-content__input"
|
||||
placeholder="(Optional) Eg. checkout dropoff funnel"
|
||||
value={description}
|
||||
onChange={(e): void => setDescription(e.target.value)}
|
||||
autoSize={{ minRows: 3, maxRows: 5 }}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SignozModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddFunnelDescriptionModal;
|
||||
@@ -1,138 +0,0 @@
|
||||
.funnel-step-modal {
|
||||
.ant-modal-content {
|
||||
background: var(--bg-ink-400);
|
||||
.ant-modal-header {
|
||||
background: var(--bg-ink-400);
|
||||
.ant-modal-title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
.ant-modal-body {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__ok-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-robin-500);
|
||||
border: none;
|
||||
|
||||
&[disabled] {
|
||||
background: var(--bg-slate-400);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__cancel-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.funnel-step-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
background: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.ant-input-textarea {
|
||||
.ant-input {
|
||||
background: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light mode styles
|
||||
.lightMode {
|
||||
.funnel-step-modal {
|
||||
.ant-modal-content {
|
||||
.ant-modal-header {
|
||||
.ant-modal-title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__ok-btn {
|
||||
background: var(--bg-robin-500) !important;
|
||||
}
|
||||
|
||||
&__cancel-btn {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.funnel-step-modal-content {
|
||||
&__label {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
&__input {
|
||||
background: var(--bg-vanilla-400);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
|
||||
&.ant-input-textarea {
|
||||
.ant-input {
|
||||
background: var(--bg-vanilla-400);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import './AddFunnelStepDetailsModal.styles.scss';
|
||||
|
||||
import { Input } from 'antd';
|
||||
import SignozModal from 'components/SignozModal/SignozModal';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useUpdateFunnelStepDetails } from 'hooks/TracesFunnels/useFunnels';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
interface AddFunnelStepDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
stepOrder: number;
|
||||
}
|
||||
|
||||
function AddFunnelStepDetailsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
stepOrder,
|
||||
}: AddFunnelStepDetailsModalProps): JSX.Element {
|
||||
const { funnelId } = useFunnelContext();
|
||||
const [stepName, setStepName] = useState<string>('');
|
||||
const [description, setDescription] = useState<string>('');
|
||||
const { notifications } = useNotifications();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
mutate: updateFunnelStepDetails,
|
||||
isLoading,
|
||||
} = useUpdateFunnelStepDetails({ stepOrder });
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setStepName('');
|
||||
setDescription('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSave = (): void => {
|
||||
updateFunnelStepDetails(
|
||||
{
|
||||
funnel_id: funnelId,
|
||||
steps: [
|
||||
{
|
||||
step_name: stepName,
|
||||
description,
|
||||
},
|
||||
],
|
||||
updated_timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries([
|
||||
REACT_QUERY_KEY.GET_FUNNEL_DETAILS,
|
||||
funnelId,
|
||||
]);
|
||||
console.log('funnelId', funnelId);
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: 'Funnel step details updated successfully',
|
||||
});
|
||||
handleCancel();
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.error({
|
||||
message: 'Failed to update funnel step details',
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SignozModal
|
||||
open={isOpen}
|
||||
title="Add funnel step details"
|
||||
width={384}
|
||||
onCancel={handleCancel}
|
||||
rootClassName="funnel-step-modal funnel-modal signoz-modal"
|
||||
cancelText="Cancel"
|
||||
okText="Save changes"
|
||||
okButtonProps={{
|
||||
icon: <Check size={14} />,
|
||||
type: 'primary',
|
||||
className: 'funnel-step-modal__ok-btn',
|
||||
onClick: handleSave,
|
||||
disabled: !stepName.trim(),
|
||||
loading: isLoading,
|
||||
}}
|
||||
cancelButtonProps={{
|
||||
icon: <X size={14} />,
|
||||
type: 'text',
|
||||
className: 'funnel-step-modal__cancel-btn',
|
||||
onClick: handleCancel,
|
||||
disabled: isLoading,
|
||||
}}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="funnel-step-modal-content">
|
||||
<div className="funnel-step-modal-content__field">
|
||||
<span className="funnel-step-modal-content__label">Step name</span>
|
||||
<Input
|
||||
className="funnel-step-modal-content__input"
|
||||
placeholder="Eg. checkout-dropoff-funnel-step1"
|
||||
value={stepName}
|
||||
onChange={(e): void => setStepName(e.target.value)}
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="funnel-step-modal-content__field">
|
||||
<span className="funnel-step-modal-content__label">Description</span>
|
||||
<Input.TextArea
|
||||
className="funnel-step-modal-content__input"
|
||||
placeholder="Eg. checkout dropoff funnel"
|
||||
value={description}
|
||||
onChange={(e): void => setDescription(e.target.value)}
|
||||
autoSize={{ minRows: 3, maxRows: 5 }}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SignozModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddFunnelStepDetailsModal;
|
||||
@@ -1,134 +0,0 @@
|
||||
.funnel-step-modal {
|
||||
.ant-modal-content {
|
||||
background: var(--bg-ink-400);
|
||||
.ant-modal-header {
|
||||
background: var(--bg-ink-400);
|
||||
.ant-modal-title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
.ant-modal-body {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__ok-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-robin-500);
|
||||
border: none;
|
||||
|
||||
&[disabled] {
|
||||
background: var(--bg-slate-400);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__cancel-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.funnel-step-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
background: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.ant-input-textarea {
|
||||
.ant-input {
|
||||
background: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light mode styles
|
||||
.lightMode {
|
||||
.funnel-step-modal {
|
||||
.ant-modal-content {
|
||||
.ant-modal-header {
|
||||
.ant-modal-title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__cancel-btn {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.funnel-step-modal-content {
|
||||
&__label {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
&__input {
|
||||
background: var(--bg-vanilla-400);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
|
||||
&.ant-input-textarea {
|
||||
.ant-input {
|
||||
background: var(--bg-vanilla-400);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import './DeleteFunnelStep.styles.scss';
|
||||
|
||||
import SignozModal from 'components/SignozModal/SignozModal';
|
||||
import { Trash2, X } from 'lucide-react';
|
||||
|
||||
interface DeleteFunnelStepProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onStepRemove: () => void;
|
||||
}
|
||||
|
||||
function DeleteFunnelStep({
|
||||
isOpen,
|
||||
onClose,
|
||||
onStepRemove,
|
||||
}: DeleteFunnelStepProps): JSX.Element {
|
||||
const handleStepRemoval = (): void => {
|
||||
onStepRemove();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<SignozModal
|
||||
open={isOpen}
|
||||
title="Delete this step"
|
||||
width={390}
|
||||
onCancel={onClose}
|
||||
rootClassName="funnel-modal delete-funnel-modal"
|
||||
cancelText="Cancel"
|
||||
okText="Delete Funnel"
|
||||
okButtonProps={{
|
||||
icon: <Trash2 size={14} />,
|
||||
type: 'primary',
|
||||
className: 'funnel-modal__ok-btn',
|
||||
onClick: handleStepRemoval,
|
||||
}}
|
||||
cancelButtonProps={{
|
||||
icon: <X size={14} />,
|
||||
type: 'text',
|
||||
className: 'funnel-modal__cancel-btn',
|
||||
onClick: onClose,
|
||||
}}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="delete-funnel-modal-content">
|
||||
Deleting this step would stop further analytics using this step of the
|
||||
funnel.
|
||||
</div>
|
||||
</SignozModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteFunnelStep;
|
||||
@@ -1,38 +0,0 @@
|
||||
.funnel-breadcrumb {
|
||||
height: 20px;
|
||||
&__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
li:first-of-type {
|
||||
.funnel-breadcrumb__title {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
.ant-breadcrumb-separator {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
& > ol {
|
||||
gap: 6px;
|
||||
}
|
||||
&__title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.funnel-breadcrumb__title,
|
||||
.ant-breadcrumb-separator {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
li:first-of-type {
|
||||
.funnel-breadcrumb__title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import './FunnelBreadcrumb.styles.scss';
|
||||
|
||||
import { Breadcrumb } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface FunnelBreadcrumbProps {
|
||||
funnelName: string;
|
||||
}
|
||||
|
||||
function FunnelBreadcrumb({ funnelName }: FunnelBreadcrumbProps): JSX.Element {
|
||||
const breadcrumbItems = [
|
||||
{
|
||||
title: (
|
||||
<Link to={ROUTES.TRACES_FUNNELS}>
|
||||
<span className="funnel-breadcrumb__link">
|
||||
<span className="funnel-breadcrumb__title">All funnels</span>
|
||||
</span>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <div className="funnel-breadcrumb__title">{funnelName}</div>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb className="funnel-breadcrumb" items={breadcrumbItems} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelBreadcrumb;
|
||||
@@ -1,42 +0,0 @@
|
||||
.funnel-configuration {
|
||||
&__steps-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
|
||||
&__description {
|
||||
padding: 16px 16px 0 16px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
.funnel-item__action-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
&__steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.funnel-configuration {
|
||||
&__header {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import './FunnelConfiguration.styles.scss';
|
||||
|
||||
import useFunnelConfiguration from 'hooks/TracesFunnels/useFunnelConfiguration';
|
||||
import FunnelItemPopover from 'pages/TracesFunnels/components/FunnelsList/FunnelItemPopover';
|
||||
import { memo } from 'react';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
import FunnelBreadcrumb from './FunnelBreadcrumb';
|
||||
import StepsContent from './StepsContent';
|
||||
import StepsFooter from './StepsFooter';
|
||||
import StepsHeader from './StepsHeader';
|
||||
|
||||
interface FunnelConfigurationProps {
|
||||
funnel: FunnelData;
|
||||
isTraceDetailsPage?: boolean;
|
||||
span?: Span;
|
||||
}
|
||||
|
||||
function FunnelConfiguration({
|
||||
funnel,
|
||||
isTraceDetailsPage,
|
||||
span,
|
||||
}: FunnelConfigurationProps): JSX.Element {
|
||||
const { isPopoverOpen, setIsPopoverOpen, steps } = useFunnelConfiguration({
|
||||
funnel,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="funnel-configuration">
|
||||
{!isTraceDetailsPage && (
|
||||
<>
|
||||
<div className="funnel-configuration__header">
|
||||
<FunnelBreadcrumb funnelName={funnel.funnel_name} />
|
||||
<FunnelItemPopover
|
||||
isPopoverOpen={isPopoverOpen}
|
||||
setIsPopoverOpen={setIsPopoverOpen}
|
||||
funnel={funnel}
|
||||
/>
|
||||
</div>
|
||||
<div className="funnel-configuration__description">
|
||||
{funnel?.description}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="funnel-configuration__steps-wrapper">
|
||||
<div className="funnel-configuration__steps">
|
||||
{!isTraceDetailsPage && <StepsHeader />}
|
||||
<StepsContent isTraceDetailsPage={isTraceDetailsPage} span={span} />
|
||||
</div>
|
||||
{!isTraceDetailsPage && (
|
||||
<StepsFooter
|
||||
funnelId={funnel.id}
|
||||
stepsCount={steps.length}
|
||||
funnelDescription={funnel?.description || ''}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FunnelConfiguration.defaultProps = {
|
||||
isTraceDetailsPage: false,
|
||||
span: undefined,
|
||||
};
|
||||
|
||||
export default memo(FunnelConfiguration);
|
||||
@@ -1,208 +0,0 @@
|
||||
.traces-funnel-where-filter {
|
||||
.keyboard-shortcuts {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.funnel-step {
|
||||
background: var(--bg-ink-400);
|
||||
color: var(--bg-vanilla-400);
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
border-radius: 6px;
|
||||
.step-popover {
|
||||
opacity: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 4px;
|
||||
background: var(--bg-ink-100);
|
||||
border-radius: 2px;
|
||||
position: absolute;
|
||||
right: -11px;
|
||||
top: -11px;
|
||||
}
|
||||
&:hover .step-popover {
|
||||
opacity: 1;
|
||||
}
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
.funnel-step-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
&__title {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
&__description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&__content {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding: 16px;
|
||||
padding-left: 6px;
|
||||
.ant-form-item {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.drag-icon {
|
||||
cursor: grab;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
.ant-select-selector {
|
||||
background: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
.ant-select-selection-placeholder {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
&__service-and-span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
.ant-select {
|
||||
width: 239px;
|
||||
}
|
||||
}
|
||||
&__where-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.label {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.query-builder-search-v2 {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ant-steps.ant-steps-vertical > .ant-steps-item .ant-steps-item-description {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
}
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10.5px 12px 10.5px 16px;
|
||||
gap: 20px;
|
||||
border-right: 1px solid var(--bg-slate-500);
|
||||
width: 50%;
|
||||
}
|
||||
.error__label,
|
||||
.latency-pointer__label {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
.latency-pointer {
|
||||
padding: 10.5px 16px 10.5px 12px;
|
||||
width: 55%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.ant-space {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
&-item {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
&:last-child {
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.funnel-step {
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--bg-ink-400);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
|
||||
.step-popover {
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
&__header {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
.funnel-step-details {
|
||||
&__title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
&__description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
.filters {
|
||||
.ant-select-selector {
|
||||
background: var(--bg-vanilla-100);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
&__service-and-span {
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
&__where-filter {
|
||||
.label {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
&,
|
||||
.error {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
.error__label,
|
||||
.latency-pointer__label {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
.latency-pointer {
|
||||
.ant-space-item {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import './FunnelStep.styles.scss';
|
||||
|
||||
import { Dropdown, Form, Space, Switch } from 'antd';
|
||||
import { MenuProps } from 'antd/lib';
|
||||
import { FilterSelect } from 'components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import { ChevronDown, GripVertical, HardHat } from 'lucide-react';
|
||||
import { LatencyPointers } from 'pages/TracesFunnelDetails/constants';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { FunnelStepData } from 'types/api/traceFunnels';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import FunnelStepPopover from './FunnelStepPopover';
|
||||
|
||||
interface FunnelStepProps {
|
||||
stepData: FunnelStepData;
|
||||
index: number;
|
||||
stepsCount: number;
|
||||
}
|
||||
|
||||
function FunnelStep({
|
||||
stepData,
|
||||
index,
|
||||
stepsCount,
|
||||
}: FunnelStepProps): JSX.Element {
|
||||
const {
|
||||
handleStepChange: onStepChange,
|
||||
handleStepRemoval: onStepRemove,
|
||||
} = useFunnelContext();
|
||||
const [form] = Form.useForm();
|
||||
const currentQuery = initialQueriesMap[DataSource.TRACES];
|
||||
|
||||
const latencyPointerItems: MenuProps['items'] = LatencyPointers.map(
|
||||
(option) => ({
|
||||
key: option.value,
|
||||
label: option.key,
|
||||
style:
|
||||
option.value === stepData.latency_pointer
|
||||
? { backgroundColor: 'var(--bg-slate-100)' }
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const updatedCurrentQuery = useMemo(
|
||||
() => ({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
dataSource: DataSource.TRACES,
|
||||
filters: stepData.filters ?? {
|
||||
op: 'AND',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
[stepData.filters, currentQuery],
|
||||
);
|
||||
|
||||
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
|
||||
|
||||
return (
|
||||
<div className="funnel-step">
|
||||
<Form form={form}>
|
||||
<div className="funnel-step__header">
|
||||
<div className="funnel-step-details">
|
||||
{!!stepData.title && (
|
||||
<div className="funnel-step-details__title">{stepData.title}</div>
|
||||
)}
|
||||
{!!stepData.description && (
|
||||
<div className="funnel-step-details__description">
|
||||
{stepData.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="funnel-step-actions">
|
||||
<FunnelStepPopover
|
||||
isPopoverOpen={isPopoverOpen}
|
||||
setIsPopoverOpen={setIsPopoverOpen}
|
||||
stepOrder={stepData.step_order}
|
||||
onStepRemove={(): void => onStepRemove(index)}
|
||||
stepsCount={stepsCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="funnel-step__content">
|
||||
<div className="drag-icon">
|
||||
<GripVertical size={14} color="var(--bg-slate-200)" />
|
||||
</div>
|
||||
<div className="filters">
|
||||
<div className="filters__service-and-span">
|
||||
<div className="service">
|
||||
<Form.Item name={['steps', stepData.id, 'service_name']}>
|
||||
<FilterSelect
|
||||
placeholder="Select Service"
|
||||
queryParam={QueryParams.service}
|
||||
filterType="serviceName"
|
||||
shouldSetQueryParams={false}
|
||||
values={stepData.service_name}
|
||||
isMultiple={false}
|
||||
onChange={(v): void => {
|
||||
onStepChange(index, { service_name: (v ?? '') as string });
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="span">
|
||||
<Form.Item name={['steps', stepData.id, 'span_name']}>
|
||||
<FilterSelect
|
||||
placeholder="Select Span name"
|
||||
queryParam={QueryParams.spanName}
|
||||
filterType="name"
|
||||
shouldSetQueryParams={false}
|
||||
values={stepData.span_name}
|
||||
isMultiple={false}
|
||||
onChange={(v): void =>
|
||||
onStepChange(index, { span_name: (v ?? '') as string })
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
<div className="filters__where-filter">
|
||||
<div className="label">Where</div>
|
||||
<Form.Item name={['steps', stepData.id, 'filters']}>
|
||||
<QueryBuilderSearchV2
|
||||
query={query}
|
||||
onChange={(query): void => onStepChange(index, { filters: query })}
|
||||
hasPopupContainer={false}
|
||||
placeholder="Search for filters..."
|
||||
suffixIcon={<HardHat size={12} color="var(--bg-vanilla-400)" />}
|
||||
rootClassName="traces-funnel-where-filter"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="funnel-step__footer">
|
||||
<div className="error">
|
||||
<Switch
|
||||
className="error__switch"
|
||||
size="small"
|
||||
checked={stepData.has_errors}
|
||||
onChange={(): void =>
|
||||
onStepChange(index, { has_errors: !stepData.has_errors })
|
||||
}
|
||||
/>
|
||||
<div className="error__label">Errors</div>
|
||||
</div>
|
||||
<div className="latency-pointer">
|
||||
<div className="latency-pointer__label">Latency pointer</div>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: latencyPointerItems,
|
||||
onClick: ({ key }): void =>
|
||||
onStepChange(index, {
|
||||
latency_pointer: key as FunnelStepData['latency_pointer'],
|
||||
}),
|
||||
}}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Space>
|
||||
{
|
||||
LatencyPointers.find(
|
||||
(option) => option.value === stepData.latency_pointer,
|
||||
)?.key
|
||||
}
|
||||
<ChevronDown size={14} color="var(--bg-vanilla-400)" />
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelStep;
|
||||
@@ -1,129 +0,0 @@
|
||||
import { Button, Popover, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { Ellipsis, PencilLine, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import AddFunnelStepDetailsModal from './AddFunnelStepDetailsModal';
|
||||
import DeleteFunnelStep from './DeleteFunnelStep';
|
||||
|
||||
interface FunnelStepPopoverProps {
|
||||
isPopoverOpen: boolean;
|
||||
setIsPopoverOpen: (isOpen: boolean) => void;
|
||||
className?: string;
|
||||
stepOrder: number;
|
||||
stepsCount: number;
|
||||
onStepRemove: () => void;
|
||||
}
|
||||
|
||||
interface FunnelStepActionsProps {
|
||||
setIsPopoverOpen: (isOpen: boolean) => void;
|
||||
setIsAddDetailsModalOpen: (isOpen: boolean) => void;
|
||||
setIsDeleteModalOpen: (isOpen: boolean) => void;
|
||||
stepsCount: number;
|
||||
}
|
||||
|
||||
function FunnelStepActions({
|
||||
setIsPopoverOpen,
|
||||
setIsAddDetailsModalOpen,
|
||||
setIsDeleteModalOpen,
|
||||
stepsCount,
|
||||
}: FunnelStepActionsProps): JSX.Element {
|
||||
return (
|
||||
<div className="funnel-item__actions">
|
||||
<Button
|
||||
type="text"
|
||||
className="funnel-item__action-btn"
|
||||
icon={<PencilLine size={14} />}
|
||||
onClick={(): void => {
|
||||
setIsPopoverOpen(false);
|
||||
setIsAddDetailsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Add details
|
||||
</Button>
|
||||
|
||||
<Tooltip title={stepsCount <= 2 ? 'Minimum 2 steps required' : 'Delete'}>
|
||||
<Button
|
||||
type="text"
|
||||
className="funnel-item__action-btn funnel-item__action-btn--delete"
|
||||
icon={<Trash2 size={14} />}
|
||||
disabled={stepsCount <= 2}
|
||||
onClick={(): void => {
|
||||
if (stepsCount > 2) {
|
||||
setIsPopoverOpen(false);
|
||||
setIsDeleteModalOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FunnelStepPopover({
|
||||
isPopoverOpen,
|
||||
setIsPopoverOpen,
|
||||
stepOrder,
|
||||
className,
|
||||
onStepRemove,
|
||||
stepsCount,
|
||||
}: FunnelStepPopoverProps): JSX.Element {
|
||||
const [isAddDetailsModalOpen, setIsAddDetailsModalOpen] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false);
|
||||
|
||||
const preventDefault = (e: React.MouseEvent | React.KeyboardEvent): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
||||
<div onClick={preventDefault} role="button" tabIndex={0}>
|
||||
<Popover
|
||||
trigger="click"
|
||||
rootClassName="funnel-item__actions"
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={setIsPopoverOpen}
|
||||
content={
|
||||
<FunnelStepActions
|
||||
setIsDeleteModalOpen={setIsDeleteModalOpen}
|
||||
setIsPopoverOpen={setIsPopoverOpen}
|
||||
setIsAddDetailsModalOpen={setIsAddDetailsModalOpen}
|
||||
stepsCount={stepsCount}
|
||||
/>
|
||||
}
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
>
|
||||
<Ellipsis
|
||||
className={cx('funnel-item__action-icon', className, {
|
||||
'funnel-item__action-icon--active': isPopoverOpen,
|
||||
})}
|
||||
size={14}
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
<DeleteFunnelStep
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={(): void => setIsDeleteModalOpen(false)}
|
||||
onStepRemove={onStepRemove}
|
||||
/>
|
||||
|
||||
<AddFunnelStepDetailsModal
|
||||
isOpen={isAddDetailsModalOpen}
|
||||
onClose={(): void => setIsAddDetailsModalOpen(false)}
|
||||
stepOrder={stepOrder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FunnelStepPopover.defaultProps = {
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default FunnelStepPopover;
|
||||
@@ -1,57 +0,0 @@
|
||||
.inter-step-config {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
.ant-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
bottom: 16px;
|
||||
transform: translateY(-50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--bg-slate-400);
|
||||
border-radius: 50%;
|
||||
z-index: 1;
|
||||
}
|
||||
&__label {
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
&__divider {
|
||||
width: 100%;
|
||||
.ant-divider {
|
||||
margin: 0;
|
||||
border-color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
&__latency-options {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.inter-step-config {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
color: var(--bg-ink-400);
|
||||
&::before {
|
||||
background-color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&__divider {
|
||||
.ant-divider {
|
||||
border-color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import './InterStepConfig.styles.scss';
|
||||
|
||||
import { Divider } from 'antd';
|
||||
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { FunnelStepData, LatencyOptions } from 'types/api/traceFunnels';
|
||||
|
||||
function InterStepConfig({
|
||||
index,
|
||||
step,
|
||||
}: {
|
||||
index: number;
|
||||
step: FunnelStepData;
|
||||
}): JSX.Element {
|
||||
const { handleStepChange: onStepChange } = useFunnelContext();
|
||||
const options = Object.entries(LatencyOptions).map(([key, value]) => ({
|
||||
label: key,
|
||||
value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="inter-step-config">
|
||||
<div className="inter-step-config__label">Latency type</div>
|
||||
<div className="inter-step-config__divider">
|
||||
<Divider dashed />
|
||||
</div>
|
||||
<div className="inter-step-config__latency-options">
|
||||
<SignozRadioGroup
|
||||
value={step.latency_type}
|
||||
options={options}
|
||||
onChange={(e): void =>
|
||||
onStepChange(index, {
|
||||
...step,
|
||||
latency_type: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InterStepConfig;
|
||||
@@ -1,156 +0,0 @@
|
||||
.steps-content {
|
||||
height: calc(
|
||||
100vh - 253px
|
||||
); // 64px (footer) + 12 (steps gap) + 32 (steps header) + 16 (steps padding) + 50 (breadcrumb) + 34 (description) + 45 (steps footer) = 219px
|
||||
overflow-y: auto;
|
||||
.ant-btn {
|
||||
box-shadow: none;
|
||||
&-icon {
|
||||
margin-inline-end: 0 !important;
|
||||
}
|
||||
}
|
||||
&__description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
.funnel-step-wrapper {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
&__replace-button {
|
||||
display: flex;
|
||||
height: 28px;
|
||||
padding: 5px 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
&:disabled {
|
||||
background-color: rgba(209, 209, 209, 0.074);
|
||||
color: #5f5f5f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__add-btn {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-ink-200);
|
||||
background: var(--bg-ink-200);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
padding: 6px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ant-steps-item.steps-content__add-step {
|
||||
.ant-steps-item-icon {
|
||||
margin-left: 4px;
|
||||
margin-right: 20px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
.ant-steps-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-steps-item-process .ant-steps-item-icon,
|
||||
.ant-steps-item-icon {
|
||||
// margin-left: 6px;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background-color: var(--bg-slate-400) !important;
|
||||
|
||||
& > .ant-steps-icon {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-steps.ant-steps-vertical
|
||||
> .ant-steps-item
|
||||
> .ant-steps-item-container
|
||||
> .ant-steps-item-tail {
|
||||
inset-inline-start: 9px;
|
||||
}
|
||||
.ant-steps-item-tail {
|
||||
padding: 20px 0 0 !important;
|
||||
|
||||
&::after {
|
||||
background-color: var(--bg-slate-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.latency-step-marker {
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--bg-ink-400);
|
||||
border-radius: 50%;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light mode styles
|
||||
.lightMode {
|
||||
.funnel-step-wrapper__replace-button {
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
border: none;
|
||||
}
|
||||
.steps-content {
|
||||
&__add-btn {
|
||||
background: var(--bg-vanilla-300);
|
||||
border: none;
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-steps-item-icon {
|
||||
background-color: var(--bg-vanilla-400) !important;
|
||||
|
||||
.ant-steps-icon {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-steps-item-tail::after {
|
||||
background-color: var(--bg-vanilla-400) !important;
|
||||
}
|
||||
|
||||
.inter-step-config::before {
|
||||
background-color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.latency-step-marker::before {
|
||||
background-color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import './StepsContent.styles.scss';
|
||||
|
||||
import { Button, Steps } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { PlusIcon, Undo2 } from 'lucide-react';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import FunnelStep from './FunnelStep';
|
||||
import InterStepConfig from './InterStepConfig';
|
||||
|
||||
const { Step } = Steps;
|
||||
|
||||
function StepsContent({
|
||||
isTraceDetailsPage,
|
||||
span,
|
||||
}: {
|
||||
isTraceDetailsPage?: boolean;
|
||||
span?: Span;
|
||||
}): JSX.Element {
|
||||
const { steps, handleAddStep, handleReplaceStep } = useFunnelContext();
|
||||
|
||||
const handleAddForNewStep = useCallback(() => {
|
||||
if (!span) return;
|
||||
|
||||
const stepWasAdded = handleAddStep();
|
||||
if (stepWasAdded) {
|
||||
handleReplaceStep(steps.length, span.serviceName, span.name);
|
||||
}
|
||||
logEvent(
|
||||
'Trace Funnels: span added for a new step from trace details page',
|
||||
{},
|
||||
);
|
||||
}, [span, handleAddStep, handleReplaceStep, steps.length]);
|
||||
|
||||
return (
|
||||
<div className="steps-content">
|
||||
<OverlayScrollbar>
|
||||
<Steps direction="vertical">
|
||||
{steps.map((step, index) => (
|
||||
<Step
|
||||
key={`step-${index + 1}`}
|
||||
description={
|
||||
<div className="steps-content__description">
|
||||
<div className="funnel-step-wrapper">
|
||||
<FunnelStep stepData={step} index={index} stepsCount={steps.length} />
|
||||
{isTraceDetailsPage && span && (
|
||||
<Button
|
||||
type="default"
|
||||
className="funnel-step-wrapper__replace-button"
|
||||
icon={<Undo2 size={12} />}
|
||||
disabled={
|
||||
step.service_name === span.serviceName &&
|
||||
step.span_name === span.name
|
||||
}
|
||||
onClick={(): void =>
|
||||
handleReplaceStep(index, span.serviceName, span.name)
|
||||
}
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* Display InterStepConfig only between steps */}
|
||||
{index < steps.length - 1 && (
|
||||
<InterStepConfig index={index} step={step} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{/* For now we are only supporting 3 steps */}
|
||||
{steps.length < 3 && (
|
||||
<Step
|
||||
className="steps-content__add-step"
|
||||
description={
|
||||
!isTraceDetailsPage ? (
|
||||
<Button
|
||||
type="default"
|
||||
className="steps-content__add-btn"
|
||||
onClick={handleAddStep}
|
||||
icon={<PlusIcon size={14} />}
|
||||
>
|
||||
Add Funnel Step
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="default"
|
||||
className="steps-content__add-btn"
|
||||
onClick={handleAddForNewStep}
|
||||
icon={<PlusIcon size={14} />}
|
||||
>
|
||||
Add for new Step
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Steps>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
StepsContent.defaultProps = {
|
||||
isTraceDetailsPage: false,
|
||||
span: undefined,
|
||||
};
|
||||
|
||||
export default memo(StepsContent);
|
||||
@@ -1,74 +0,0 @@
|
||||
.steps-footer {
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
background: var(--bg-ink-500);
|
||||
padding: 16px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
&__valid-traces {
|
||||
&--none {
|
||||
color: var(--text-amber-500);
|
||||
}
|
||||
}
|
||||
&__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__button {
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0 !important;
|
||||
}
|
||||
&--save {
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
&--run {
|
||||
background-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.steps-footer {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-200);
|
||||
|
||||
&__left {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
&__valid-traces {
|
||||
&--none {
|
||||
color: var(--text-amber-600);
|
||||
}
|
||||
}
|
||||
|
||||
&__button {
|
||||
&--save {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
&--run {
|
||||
background-color: var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import './StepsFooter.styles.scss';
|
||||
|
||||
import { SyncOutlined } from '@ant-design/icons';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { Check, Cone, Play } from 'lucide-react';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useState } from 'react';
|
||||
|
||||
import AddFunnelDescriptionModal from './AddFunnelDescriptionModal';
|
||||
|
||||
interface StepsFooterProps {
|
||||
stepsCount: number;
|
||||
funnelId: string;
|
||||
funnelDescription: string;
|
||||
}
|
||||
|
||||
function ValidTracesCount(): JSX.Element {
|
||||
const {
|
||||
hasAllEmptyStepFields,
|
||||
isValidateStepsLoading,
|
||||
hasIncompleteStepFields,
|
||||
validTracesCount,
|
||||
} = useFunnelContext();
|
||||
if (isValidateStepsLoading) {
|
||||
return <Skeleton.Button size="small" />;
|
||||
}
|
||||
|
||||
if (hasAllEmptyStepFields) {
|
||||
return (
|
||||
<span className="steps-footer__valid-traces">No service / span names</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasIncompleteStepFields) {
|
||||
return (
|
||||
<span className="steps-footer__valid-traces">
|
||||
Missing service / span names
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cx('steps-footer__valid-traces', {
|
||||
'steps-footer__valid-traces--none': validTracesCount === 0,
|
||||
})}
|
||||
>
|
||||
{validTracesCount} valid traces
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StepsFooter({
|
||||
stepsCount,
|
||||
funnelId,
|
||||
funnelDescription,
|
||||
}: StepsFooterProps): JSX.Element {
|
||||
const { validTracesCount, handleRunFunnel } = useFunnelContext();
|
||||
const [isDescriptionModalOpen, setIsDescriptionModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="steps-footer">
|
||||
<div className="steps-footer__left">
|
||||
<Cone className="funnel-icon" size={14} />
|
||||
<span>{stepsCount} steps</span>
|
||||
<span>·</span>
|
||||
<ValidTracesCount />
|
||||
</div>
|
||||
<div className="steps-footer__right">
|
||||
{funnelDescription ? (
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={validTracesCount === 0}
|
||||
onClick={handleRunFunnel}
|
||||
icon={<SyncOutlined />}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="default"
|
||||
className="steps-footer__button steps-footer__button--save"
|
||||
icon={<Check size={16} />}
|
||||
onClick={(): void => setIsDescriptionModalOpen(true)}
|
||||
>
|
||||
Save funnel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={validTracesCount === 0}
|
||||
onClick={handleRunFunnel}
|
||||
type="primary"
|
||||
className="steps-footer__button steps-footer__button--run"
|
||||
icon={<Play size={16} />}
|
||||
>
|
||||
Run funnel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<AddFunnelDescriptionModal
|
||||
isOpen={isDescriptionModalOpen}
|
||||
onClose={(): void => setIsDescriptionModalOpen(false)}
|
||||
funnelId={funnelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StepsFooter;
|
||||
@@ -1,51 +0,0 @@
|
||||
.steps-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
&__label {
|
||||
color: var(--bg-slate-50);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
&__divider {
|
||||
width: 100%;
|
||||
.ant-divider {
|
||||
margin: 0;
|
||||
border-color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
&__time-range {
|
||||
min-width: 192px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
.timeSelection-input {
|
||||
.ant-input-prefix > svg {
|
||||
height: 12px;
|
||||
}
|
||||
&,
|
||||
input {
|
||||
background: var(--bg-ink-300);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.steps-header {
|
||||
&__label {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
.timeSelection-input {
|
||||
&,
|
||||
input {
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import './StepsHeader.styles.scss';
|
||||
|
||||
import { Divider } from 'antd';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
|
||||
function StepsHeader(): JSX.Element {
|
||||
return (
|
||||
<div className="steps-header">
|
||||
<div className="steps-header__label">FUNNEL STEPS</div>
|
||||
<div className="steps-header__divider">
|
||||
<Divider dashed />
|
||||
</div>
|
||||
<div className="steps-header__time-range">
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={false}
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StepsHeader;
|
||||
@@ -1,42 +0,0 @@
|
||||
.funnel-results--empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.empty-funnel-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
&__title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__learn-more {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.empty-funnel-results {
|
||||
&__title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
&__description {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import './EmptyFunnelResults.styles.scss';
|
||||
|
||||
import LearnMore from 'components/LearnMore/LearnMore';
|
||||
|
||||
function EmptyFunnelResults({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="funnel-results funnel-results--empty">
|
||||
<div className="empty-funnel-results">
|
||||
<div className="empty-funnel-results__icon">
|
||||
<img src="/Icons/empty-funnel-icon.svg" alt="Empty funnel results" />
|
||||
</div>
|
||||
<div className="empty-funnel-results__title">{title}</div>
|
||||
<div className="empty-funnel-results__description">{description}</div>
|
||||
<div className="empty-funnel-results__learn-more">
|
||||
<LearnMore />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EmptyFunnelResults.defaultProps = {
|
||||
title: 'No spans selected yet.',
|
||||
description: 'Add spans to the funnel steps to start seeing analytics here.',
|
||||
};
|
||||
|
||||
export default EmptyFunnelResults;
|
||||
@@ -1,117 +0,0 @@
|
||||
.funnel-graph {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
height: 459px;
|
||||
background: var(--bg-ink-500);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 13px;
|
||||
|
||||
&--2-columns {
|
||||
.funnel-graph {
|
||||
&__legend-column {
|
||||
width: 45%;
|
||||
}
|
||||
&__legends {
|
||||
padding-left: 10%;
|
||||
padding-right: 5%;
|
||||
}
|
||||
}
|
||||
}
|
||||
&--3-columns {
|
||||
.funnel-graph {
|
||||
&__legend-column {
|
||||
width: 30%;
|
||||
}
|
||||
&__legends {
|
||||
padding-left: 6%;
|
||||
padding-right: 2%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__chart-container {
|
||||
width: 100%;
|
||||
height: 370px;
|
||||
}
|
||||
|
||||
&__legends {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-left: 7%;
|
||||
padding-right: 2%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__legend-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12px;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&--total {
|
||||
background-color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
&--error {
|
||||
background-color: var(--bg-cherry-500);
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.funnel-graph {
|
||||
background: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
&__legend-column {
|
||||
.legend-item {
|
||||
&__label,
|
||||
&__value {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import './FunnelGraph.styles.scss';
|
||||
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Empty, Spin } from 'antd';
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Legend,
|
||||
LinearScale,
|
||||
Title,
|
||||
} from 'chart.js';
|
||||
import cx from 'classnames';
|
||||
import Spinner from 'components/Spinner';
|
||||
import useFunnelGraph from 'hooks/TracesFunnels/useFunnelGraph';
|
||||
import { useFunnelStepsGraphData } from 'hooks/TracesFunnels/useFunnels';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
// Register required components
|
||||
Chart.register(
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Legend,
|
||||
Title,
|
||||
);
|
||||
|
||||
function FunnelGraph(): JSX.Element {
|
||||
const { funnelId } = useFunnelContext();
|
||||
const {
|
||||
data: stepsData,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
} = useFunnelStepsGraphData(funnelId);
|
||||
|
||||
const data = useMemo(() => stepsData?.payload?.data?.[0]?.data, [
|
||||
stepsData?.payload?.data,
|
||||
]);
|
||||
|
||||
const {
|
||||
successSteps,
|
||||
errorSteps,
|
||||
totalSteps,
|
||||
canvasRef,
|
||||
renderLegendItem,
|
||||
} = useFunnelGraph({ data });
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="funnel-graph">
|
||||
<Spinner size="default" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="funnel-graph">
|
||||
<Empty description="No data" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="funnel-graph">
|
||||
<Empty description="Error fetching data. If the problem persists, please contact support." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Spin spinning={isFetching} indicator={<LoadingOutlined spin />}>
|
||||
<div className={cx('funnel-graph', `funnel-graph--${totalSteps}-columns`)}>
|
||||
<div className="funnel-graph__chart-container">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
<div className="funnel-graph__legends">
|
||||
{Array.from({ length: totalSteps }, (_, index) => {
|
||||
const prevTotalSpans =
|
||||
index > 0
|
||||
? successSteps[index - 1] + errorSteps[index - 1]
|
||||
: successSteps[0] + errorSteps[0];
|
||||
return renderLegendItem(
|
||||
index + 1,
|
||||
successSteps[index],
|
||||
errorSteps[index],
|
||||
prevTotalSpans,
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelGraph;
|
||||
@@ -1,122 +0,0 @@
|
||||
.funnel-metrics {
|
||||
background: var(--bg-ink-500);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
&--loading-state,
|
||||
&--empty-state {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&-label {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 22px; /* 157.143% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&-value {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 22px; /* 157.143% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 26px;
|
||||
padding: 14px 16px;
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
&-title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 14px;
|
||||
line-height: 22px; /* 157.143% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&-value,
|
||||
&-unit {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 14px;
|
||||
line-height: 22px; /* 157.143% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.funnel-metrics {
|
||||
background: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&__header {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: var(--text-ink-300);
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
&-label {
|
||||
color: var(--text-ink-300);
|
||||
}
|
||||
|
||||
&-value {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&-title {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
|
||||
&-value,
|
||||
&-unit {
|
||||
color: var(--text-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import './FunnelMetricsTable.styles.scss';
|
||||
|
||||
import { Empty } from 'antd';
|
||||
import Spinner from 'components/Spinner';
|
||||
|
||||
export interface MetricItem {
|
||||
title: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
interface FunnelMetricsTableProps {
|
||||
title: string;
|
||||
subtitle?: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
data: MetricItem[];
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
emptyState?: JSX.Element;
|
||||
}
|
||||
|
||||
function FunnelMetricsContentRenderer({
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
emptyState,
|
||||
}: {
|
||||
data: MetricItem[];
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
emptyState?: JSX.Element;
|
||||
}): JSX.Element {
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="funnel-metrics--loading-state">
|
||||
<Spinner size="small" height="100%" />
|
||||
</div>
|
||||
);
|
||||
if (data.length === 0 && emptyState) {
|
||||
return emptyState;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Empty description="Error fetching data. If the problem persists, please contact support." />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="funnel-metrics__grid">
|
||||
{data.map((metric) => (
|
||||
<div key={metric.title} className="funnel-metrics__item">
|
||||
<div className="funnel-metrics__item-title">{metric.title}</div>
|
||||
<div className="funnel-metrics__item-value">{metric.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
FunnelMetricsContentRenderer.defaultProps = {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
emptyState: <Empty className="funnel-metrics--empty-state" />,
|
||||
};
|
||||
|
||||
function FunnelMetricsTable({
|
||||
title,
|
||||
subtitle,
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
emptyState,
|
||||
}: FunnelMetricsTableProps): JSX.Element {
|
||||
return (
|
||||
<div className="funnel-metrics">
|
||||
<div className="funnel-metrics__header">
|
||||
<div className="funnel-metrics__title">{title}</div>
|
||||
{subtitle && (
|
||||
<div className="funnel-metrics__subtitle">
|
||||
<span className="funnel-metrics__subtitle-label">{subtitle.label}</span>
|
||||
<span className="funnel-metrics__subtitle-separator">⎯</span>
|
||||
<span className="funnel-metrics__subtitle-value">{subtitle.value}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FunnelMetricsContentRenderer
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
emptyState={emptyState}
|
||||
isError={isError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FunnelMetricsTable.defaultProps = {
|
||||
subtitle: undefined,
|
||||
isLoading: false,
|
||||
emptyState: <Empty className="funnel-metrics--empty-state" />,
|
||||
isError: false,
|
||||
};
|
||||
|
||||
export default FunnelMetricsTable;
|
||||
@@ -1,6 +0,0 @@
|
||||
.funnel-results {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import './FunnelResults.styles.scss';
|
||||
|
||||
import Spinner from 'components/Spinner';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
|
||||
import EmptyFunnelResults from './EmptyFunnelResults';
|
||||
import FunnelGraph from './FunnelGraph';
|
||||
import OverallMetrics from './OverallMetrics';
|
||||
import StepsTransitionResults from './StepsTransitionResults';
|
||||
|
||||
function FunnelResults(): JSX.Element {
|
||||
const {
|
||||
validTracesCount,
|
||||
isValidateStepsLoading,
|
||||
hasIncompleteStepFields,
|
||||
hasAllEmptyStepFields,
|
||||
} = useFunnelContext();
|
||||
|
||||
if (isValidateStepsLoading) {
|
||||
return <Spinner size="large" />;
|
||||
}
|
||||
|
||||
if (hasAllEmptyStepFields) return <EmptyFunnelResults />;
|
||||
|
||||
if (hasIncompleteStepFields)
|
||||
return (
|
||||
<EmptyFunnelResults
|
||||
title="Missing service / span names"
|
||||
description="Fill in the service and span names for all the steps"
|
||||
/>
|
||||
);
|
||||
|
||||
if (validTracesCount === 0) {
|
||||
return (
|
||||
<EmptyFunnelResults
|
||||
title="There are no traces that match the funnel steps."
|
||||
description="Check the service / span names in the funnel steps and try again to start seeing analytics here"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="funnel-results">
|
||||
<OverallMetrics />
|
||||
<FunnelGraph />
|
||||
<StepsTransitionResults />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelResults;
|
||||
@@ -1,148 +0,0 @@
|
||||
.funnel-table {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(171, 189, 255, 0.01) 0%,
|
||||
rgba(171, 189, 255, 0.01) 100%
|
||||
),
|
||||
#0b0c0e;
|
||||
|
||||
&__header {
|
||||
padding: 12px 14px 12px;
|
||||
padding-bottom: 24px;
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
background: var(--bg-ink-400);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
.ant-table-thead > tr > th {
|
||||
padding: 2px 12px;
|
||||
border-bottom: none;
|
||||
color: var(--text-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
.ant-table-cell:first-child {
|
||||
border-radius: 0px 4px 0px 0px !important;
|
||||
}
|
||||
|
||||
&::before {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-cell {
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--bg-vanilla-100);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.ant-table-cell:first-child {
|
||||
text-align: justify;
|
||||
background: rgba(171, 189, 255, 0.04);
|
||||
}
|
||||
|
||||
.ant-table-cell:nth-child(2) {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.ant-table-cell:nth-child(n + 3) {
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ant-table-thead
|
||||
> tr
|
||||
> th:not(:last-child):not(.ant-table-selection-column):not(.ant-table-row-expand-icon-cell):not([colspan])::before {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.ant-empty-normal {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.table-row-light {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.table-row-dark {
|
||||
background: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.trace-id-cell {
|
||||
color: var(--bg-robin-400);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.funnel-table {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
.ant-table {
|
||||
.ant-table-thead > tr > th {
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--text-ink-300);
|
||||
}
|
||||
|
||||
.ant-table-cell {
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.table-row-light {
|
||||
background: none;
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
.table-row-dark {
|
||||
background: none;
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--text-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import './FunnelTable.styles.scss';
|
||||
|
||||
import { Empty, Table, Tooltip } from 'antd';
|
||||
import { ColumnProps } from 'antd/es/table';
|
||||
|
||||
interface FunnelTableProps {
|
||||
loading?: boolean;
|
||||
data?: any[];
|
||||
columns: Array<ColumnProps<any>>;
|
||||
title: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
function FunnelTable({
|
||||
loading = false,
|
||||
data = [],
|
||||
columns = [],
|
||||
title,
|
||||
tooltip,
|
||||
}: FunnelTableProps): JSX.Element {
|
||||
return (
|
||||
<div className="funnel-table">
|
||||
<div className="funnel-table__header">
|
||||
<div className="funnel-table__title">{title}</div>
|
||||
<div className="funnel-table__actions">
|
||||
<Tooltip title={tooltip ?? null}>
|
||||
<img src="/Icons/solid-info-circle.svg" alt="info" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: loading ? null : <Empty />,
|
||||
}}
|
||||
scroll={{ x: true }}
|
||||
tableLayout="fixed"
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FunnelTable.defaultProps = {
|
||||
loading: false,
|
||||
data: [],
|
||||
tooltip: '',
|
||||
};
|
||||
|
||||
export default FunnelTable;
|
||||
@@ -1,74 +0,0 @@
|
||||
import { ErrorTraceData, SlowTraceData } from 'api/traceFunnels';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useMemo } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
import FunnelTable from './FunnelTable';
|
||||
import { topTracesTableColumns } from './utils';
|
||||
|
||||
interface FunnelTopTracesTableProps {
|
||||
funnelId: string;
|
||||
stepAOrder: number;
|
||||
stepBOrder: number;
|
||||
title: string;
|
||||
tooltip: string;
|
||||
useQueryHook: (
|
||||
funnelId: string,
|
||||
payload: {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
step_a_order: number;
|
||||
step_b_order: number;
|
||||
},
|
||||
) => UseQueryResult<
|
||||
SuccessResponse<SlowTraceData | ErrorTraceData> | ErrorResponse,
|
||||
Error
|
||||
>;
|
||||
}
|
||||
|
||||
function FunnelTopTracesTable({
|
||||
funnelId,
|
||||
stepAOrder,
|
||||
stepBOrder,
|
||||
title,
|
||||
tooltip,
|
||||
useQueryHook,
|
||||
}: FunnelTopTracesTableProps): JSX.Element {
|
||||
const { startTime, endTime } = useFunnelContext();
|
||||
const payload = useMemo(
|
||||
() => ({
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
step_a_order: stepAOrder,
|
||||
step_b_order: stepBOrder,
|
||||
}),
|
||||
[startTime, endTime, stepAOrder, stepBOrder],
|
||||
);
|
||||
|
||||
const { data: response, isLoading, isFetching } = useQueryHook(
|
||||
funnelId,
|
||||
payload,
|
||||
);
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (!response?.payload?.data) return [];
|
||||
return response.payload.data.map((item) => ({
|
||||
trace_id: item.data.trace_id,
|
||||
duration_ms: item.data.duration_ms,
|
||||
span_count: item.data.span_count,
|
||||
}));
|
||||
}, [response]);
|
||||
|
||||
return (
|
||||
<FunnelTable
|
||||
title={title}
|
||||
tooltip={tooltip}
|
||||
columns={topTracesTableColumns}
|
||||
data={data}
|
||||
loading={isLoading || isFetching}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelTopTracesTable;
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useFunnelMetrics } from 'hooks/TracesFunnels/useFunnelMetrics';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import FunnelMetricsTable from './FunnelMetricsTable';
|
||||
|
||||
function OverallMetrics(): JSX.Element {
|
||||
const { funnelId } = useParams<{ funnelId: string }>();
|
||||
const { isLoading, metricsData, conversionRate, isError } = useFunnelMetrics({
|
||||
funnelId: funnelId || '',
|
||||
});
|
||||
|
||||
return (
|
||||
<FunnelMetricsTable
|
||||
title="Overall Funnel Metrics"
|
||||
subtitle={{
|
||||
label: 'Conversion rate',
|
||||
value: `${conversionRate.toFixed(2)}%`,
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
data={metricsData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverallMetrics;
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useFunnelMetrics } from 'hooks/TracesFunnels/useFunnelMetrics';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import FunnelMetricsTable from './FunnelMetricsTable';
|
||||
import { StepTransition } from './StepsTransitionResults';
|
||||
|
||||
interface StepsTransitionMetricsProps {
|
||||
selectedTransition: string;
|
||||
transitions: StepTransition[];
|
||||
startStep?: number;
|
||||
endStep?: number;
|
||||
}
|
||||
|
||||
function StepsTransitionMetrics({
|
||||
selectedTransition,
|
||||
transitions,
|
||||
startStep,
|
||||
endStep,
|
||||
}: StepsTransitionMetricsProps): JSX.Element {
|
||||
const { funnelId } = useParams<{ funnelId: string }>();
|
||||
const currentTransition = transitions.find(
|
||||
(transition) => transition.value === selectedTransition,
|
||||
);
|
||||
|
||||
const { isLoading, metricsData, conversionRate } = useFunnelMetrics({
|
||||
funnelId: funnelId || '',
|
||||
stepStart: startStep,
|
||||
stepEnd: endStep,
|
||||
});
|
||||
|
||||
if (!currentTransition) {
|
||||
return <div>No transition selected</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<FunnelMetricsTable
|
||||
title={currentTransition.label}
|
||||
subtitle={{
|
||||
label: 'Conversion rate',
|
||||
value: `${conversionRate.toFixed(2)}%`,
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
data={metricsData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
StepsTransitionMetrics.defaultProps = {
|
||||
startStep: undefined,
|
||||
endStep: undefined,
|
||||
};
|
||||
|
||||
export default StepsTransitionMetrics;
|
||||
@@ -1,38 +0,0 @@
|
||||
.steps-transition-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
&__steps-selector {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.steps-transition-results {
|
||||
&__steps-selector {
|
||||
.views-tabs {
|
||||
.tab {
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.selected_view {
|
||||
background: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
color: var(--text-ink-400);
|
||||
}
|
||||
|
||||
.selected_view::before {
|
||||
background: var(--bg-vanilla-300);
|
||||
border-left: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import './StepsTransitionResults.styles.scss';
|
||||
|
||||
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import StepsTransitionMetrics from './StepsTransitionMetrics';
|
||||
import TopSlowestTraces from './TopSlowestTraces';
|
||||
import TopTracesWithErrors from './TopTracesWithErrors';
|
||||
|
||||
export interface StepTransition {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function generateStepTransitions(stepsCount: number): StepTransition[] {
|
||||
return Array.from({ length: stepsCount - 1 }, (_, index) => ({
|
||||
value: `${index + 1}_to_${index + 2}`,
|
||||
label: `Step ${index + 1} -> Step ${index + 2}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function StepsTransitionResults(): JSX.Element {
|
||||
const { steps, funnelId } = useFunnelContext();
|
||||
const stepTransitions = generateStepTransitions(steps.length);
|
||||
const [selectedTransition, setSelectedTransition] = useState<string>(
|
||||
stepTransitions[0]?.value || '',
|
||||
);
|
||||
|
||||
const [stepAOrder, stepBOrder] = useMemo(() => {
|
||||
const [a, b] = selectedTransition.split('_to_');
|
||||
return [parseInt(a, 10), parseInt(b, 10)];
|
||||
}, [selectedTransition]);
|
||||
|
||||
return (
|
||||
<div className="steps-transition-results">
|
||||
<div className="steps-transition-results__steps-selector">
|
||||
<SignozRadioGroup
|
||||
value={selectedTransition}
|
||||
options={stepTransitions}
|
||||
onChange={(e): void => setSelectedTransition(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="steps-transition-results__results">
|
||||
<StepsTransitionMetrics
|
||||
selectedTransition={selectedTransition}
|
||||
transitions={stepTransitions}
|
||||
startStep={stepAOrder}
|
||||
endStep={stepBOrder}
|
||||
/>
|
||||
<TopSlowestTraces
|
||||
funnelId={funnelId}
|
||||
stepAOrder={stepAOrder}
|
||||
stepBOrder={stepBOrder}
|
||||
/>
|
||||
<TopTracesWithErrors
|
||||
funnelId={funnelId}
|
||||
stepAOrder={stepAOrder}
|
||||
stepBOrder={stepBOrder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StepsTransitionResults;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useFunnelSlowTraces } from 'hooks/TracesFunnels/useFunnels';
|
||||
|
||||
import FunnelTopTracesTable from './FunnelTopTracesTable';
|
||||
|
||||
interface TopSlowestTracesProps {
|
||||
funnelId: string;
|
||||
stepAOrder: number;
|
||||
stepBOrder: number;
|
||||
}
|
||||
|
||||
function TopSlowestTraces(props: TopSlowestTracesProps): JSX.Element {
|
||||
return (
|
||||
<FunnelTopTracesTable
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
title="Slowest 5 traces"
|
||||
tooltip="A list of the slowest traces in the funnel"
|
||||
useQueryHook={useFunnelSlowTraces}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopSlowestTraces;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user