mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-09 15:02:21 +00:00
Compare commits
73 Commits
feat/authz
...
cursor/use
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f226f9cb5 | ||
|
|
d40cc4e1e7 | ||
|
|
2ebd29b0cd | ||
|
|
095dd7eb9d | ||
|
|
3a34185122 | ||
|
|
6460f0cb7a | ||
|
|
289273f61e | ||
|
|
7eed7183b7 | ||
|
|
739e7d51ab | ||
|
|
039d10fa08 | ||
|
|
35f0539ca7 | ||
|
|
912ee4149f | ||
|
|
1fb7fcb7d2 | ||
|
|
5ef76c0fd0 | ||
|
|
dfca34b106 | ||
|
|
85720f0463 | ||
|
|
e3ee0b6738 | ||
|
|
7523a780fd | ||
|
|
36d4c59222 | ||
|
|
b270083bdd | ||
|
|
4c2849fc63 | ||
|
|
ace520532d | ||
|
|
749a07bcfe | ||
|
|
43954f1444 | ||
|
|
6d42be04cf | ||
|
|
3e838e8718 | ||
|
|
5387f554fc | ||
|
|
ead9968438 | ||
|
|
c917f18430 | ||
|
|
fa7b5418ab | ||
|
|
ad166fccc4 | ||
|
|
a7b8fe582a | ||
|
|
2abbeff83d | ||
|
|
abf00f174b | ||
|
|
6611432e15 | ||
|
|
5488741170 | ||
|
|
ceaf853973 | ||
|
|
6c84319e79 | ||
|
|
d59cc81d29 | ||
|
|
a2433e2050 | ||
|
|
74ed40ea82 | ||
|
|
501c94b37f | ||
|
|
c6ced93784 | ||
|
|
63849b601c | ||
|
|
cca30651a8 | ||
|
|
eb49de65f9 | ||
|
|
0004ad8934 | ||
|
|
a79193aef1 | ||
|
|
5a79efbe23 | ||
|
|
1f3f611e9a | ||
|
|
3c59f45a2f | ||
|
|
6fb92880cc | ||
|
|
089a8ee323 | ||
|
|
dab7f506f7 | ||
|
|
9587c0c1d5 | ||
|
|
f44a6aab9a | ||
|
|
7213754e71 | ||
|
|
08713cbf7d | ||
|
|
235dacf4a3 | ||
|
|
3a82085eb4 | ||
|
|
7691f3451a | ||
|
|
ded51c4379 | ||
|
|
ef8790b708 | ||
|
|
57fe84046d | ||
|
|
129d18a1b7 | ||
|
|
48ccdfcf64 | ||
|
|
8c7dc942d0 | ||
|
|
0e1bb5fd91 | ||
|
|
d7a743cea9 | ||
|
|
850dc10983 | ||
|
|
e8e85a3790 | ||
|
|
20d9d5ae38 | ||
|
|
9a046ab89d |
4
.github/workflows/commitci.yaml
vendored
4
.github/workflows/commitci.yaml
vendored
@@ -7,10 +7,14 @@ on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
merge_group:
|
||||
types:
|
||||
- checks_requested
|
||||
|
||||
jobs:
|
||||
refcheck:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
9
.github/workflows/goci.yaml
vendored
9
.github/workflows/goci.yaml
vendored
@@ -7,10 +7,14 @@ on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
merge_group:
|
||||
types:
|
||||
- checks_requested
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
uses: signoz/primus.workflows/.github/workflows/go-test.yaml@main
|
||||
@@ -21,6 +25,7 @@ jobs:
|
||||
GO_VERSION: 1.24
|
||||
fmt:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
uses: signoz/primus.workflows/.github/workflows/go-fmt.yaml@main
|
||||
@@ -30,6 +35,7 @@ jobs:
|
||||
GO_VERSION: 1.24
|
||||
lint:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
uses: signoz/primus.workflows/.github/workflows/go-lint.yaml@main
|
||||
@@ -39,6 +45,7 @@ jobs:
|
||||
GO_VERSION: 1.24
|
||||
deps:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
uses: signoz/primus.workflows/.github/workflows/go-deps.yaml@main
|
||||
@@ -48,6 +55,7 @@ jobs:
|
||||
GO_VERSION: 1.24
|
||||
build:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
@@ -79,6 +87,7 @@ jobs:
|
||||
make docker-build-enterprise
|
||||
openapi:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
25
.github/workflows/jsci.yaml
vendored
25
.github/workflows/jsci.yaml
vendored
@@ -7,26 +7,14 @@ on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
merge_group:
|
||||
types:
|
||||
- checks_requested
|
||||
|
||||
jobs:
|
||||
tsc:
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: install
|
||||
run: cd frontend && yarn install
|
||||
- name: tsc
|
||||
run: cd frontend && yarn tsc
|
||||
tsc2:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
uses: signoz/primus.workflows/.github/workflows/js-tsc.yaml@main
|
||||
@@ -36,6 +24,7 @@ jobs:
|
||||
JS_SRC: frontend
|
||||
test:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
uses: signoz/primus.workflows/.github/workflows/js-test.yaml@main
|
||||
@@ -45,6 +34,7 @@ jobs:
|
||||
JS_SRC: frontend
|
||||
fmt:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
uses: signoz/primus.workflows/.github/workflows/js-fmt.yaml@main
|
||||
@@ -54,6 +44,7 @@ jobs:
|
||||
JS_SRC: frontend
|
||||
lint:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
uses: signoz/primus.workflows/.github/workflows/js-lint.yaml@main
|
||||
@@ -63,6 +54,7 @@ jobs:
|
||||
JS_SRC: frontend
|
||||
md-languages:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
@@ -73,6 +65,7 @@ jobs:
|
||||
run: bash frontend/scripts/validate-md-languages.sh
|
||||
authz:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:18-bullseye AS build
|
||||
FROM node:22-bookworm AS build
|
||||
|
||||
WORKDIR /opt/
|
||||
COPY ./frontend/ ./
|
||||
|
||||
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.113.0
|
||||
image: signoz/signoz:v0.114.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
image: signoz/signoz-otel-collector:v0.144.2
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
@@ -241,7 +241,7 @@ services:
|
||||
replicas: 3
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
image: signoz/signoz-otel-collector:v0.144.2
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.113.0
|
||||
image: signoz/signoz:v0.114.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
@@ -139,7 +139,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
image: signoz/signoz-otel-collector:v0.144.2
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
@@ -167,7 +167,7 @@ services:
|
||||
replicas: 3
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
image: signoz/signoz-otel-collector:v0.144.2
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.113.0}
|
||||
image: signoz/signoz:${VERSION:-v0.114.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
@@ -204,7 +204,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.2}
|
||||
container_name: signoz-otel-collector
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
@@ -229,7 +229,7 @@ services:
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.2}
|
||||
container_name: signoz-telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.113.0}
|
||||
image: signoz/signoz:${VERSION:-v0.114.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.2}
|
||||
container_name: signoz-otel-collector
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
@@ -157,7 +157,7 @@ services:
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.2}
|
||||
container_name: signoz-telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -2108,6 +2108,16 @@ components:
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
TypesPostableBulkInviteRequest:
|
||||
properties:
|
||||
invites:
|
||||
items:
|
||||
$ref: '#/components/schemas/TypesPostableInvite'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- invites
|
||||
type: object
|
||||
TypesPostableForgotPassword:
|
||||
properties:
|
||||
email:
|
||||
@@ -2196,6 +2206,8 @@ components:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -3538,9 +3550,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/components/schemas/TypesPostableInvite'
|
||||
type: array
|
||||
$ref: '#/components/schemas/TypesPostableBulkInviteRequest'
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
@@ -67,6 +69,10 @@ func (p *BaseSeasonalProvider) toTSResults(ctx context.Context, resp *qbtypes.Qu
|
||||
}
|
||||
|
||||
func (p *BaseSeasonalProvider) getResults(ctx context.Context, orgID valuer.UUID, params *anomalyQueryParams) (*anomalyQueryResults, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "anomaly",
|
||||
instrumentationtypes.CodeFunctionName: "getResults",
|
||||
})
|
||||
// TODO(srikanthccv): parallelize this?
|
||||
p.logger.InfoContext(ctx, "fetching results for current period", "anomaly_current_period_query", params.CurrentPeriodQuery)
|
||||
currentPeriodResults, err := p.querier.QueryRange(ctx, orgID, ¶ms.CurrentPeriodQuery)
|
||||
|
||||
@@ -15,7 +15,9 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/roletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -105,6 +107,10 @@ func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id
|
||||
}
|
||||
|
||||
func (module *module) GetPublicWidgetQueryRange(ctx context.Context, id valuer.UUID, widgetIdx, startTime, endTime uint64) (*querybuildertypesv5.QueryRangeResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "dashboard",
|
||||
instrumentationtypes.CodeFunctionName: "GetPublicWidgetQueryRange",
|
||||
})
|
||||
dashboard, err := module.GetDashboardByPublicID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/postprocess"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -61,6 +63,10 @@ func (p *BaseSeasonalProvider) getQueryParams(req *GetAnomaliesRequest) *anomaly
|
||||
}
|
||||
|
||||
func (p *BaseSeasonalProvider) getResults(ctx context.Context, orgID valuer.UUID, params *anomalyQueryParams) (*anomalyQueryResults, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "anomaly",
|
||||
instrumentationtypes.CodeFunctionName: "getResults",
|
||||
})
|
||||
zap.L().Info("fetching results for current period", zap.Any("currentPeriodQuery", params.CurrentPeriodQuery))
|
||||
currentPeriodResults, _, err := p.querierV2.QueryRange(ctx, orgID, params.CurrentPeriodQuery)
|
||||
if err != nil {
|
||||
|
||||
@@ -170,7 +170,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
|
||||
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
|
||||
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
|
||||
|
||||
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId))
|
||||
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId), types.UserStatusActive)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
|
||||
}
|
||||
|
||||
@@ -23,25 +23,7 @@ const config: Config.InitialOptions = {
|
||||
'<rootDir>/node_modules/@signozhq/icons/dist/index.esm.js',
|
||||
'^react-syntax-highlighter/dist/esm/(.*)$':
|
||||
'<rootDir>/node_modules/react-syntax-highlighter/dist/cjs/$1',
|
||||
'^@signozhq/sonner$':
|
||||
'<rootDir>/node_modules/@signozhq/sonner/dist/sonner.js',
|
||||
'^@signozhq/button$':
|
||||
'<rootDir>/node_modules/@signozhq/button/dist/button.js',
|
||||
'^@signozhq/calendar$':
|
||||
'<rootDir>/node_modules/@signozhq/calendar/dist/calendar.js',
|
||||
'^@signozhq/badge': '<rootDir>/node_modules/@signozhq/badge/dist/badge.js',
|
||||
'^@signozhq/checkbox':
|
||||
'<rootDir>/node_modules/@signozhq/checkbox/dist/checkbox.js',
|
||||
'^@signozhq/switch': '<rootDir>/node_modules/@signozhq/switch/dist/switch.js',
|
||||
'^@signozhq/callout':
|
||||
'<rootDir>/node_modules/@signozhq/callout/dist/callout.js',
|
||||
'^@signozhq/combobox':
|
||||
'<rootDir>/node_modules/@signozhq/combobox/dist/combobox.js',
|
||||
'^@signozhq/input': '<rootDir>/node_modules/@signozhq/input/dist/input.js',
|
||||
'^@signozhq/command':
|
||||
'<rootDir>/node_modules/@signozhq/command/dist/command.js',
|
||||
'^@signozhq/radio-group':
|
||||
'<rootDir>/node_modules/@signozhq/radio-group/dist/radio-group.js',
|
||||
'^@signozhq/([^/]+)$': '<rootDir>/node_modules/@signozhq/$1/dist/$1.js',
|
||||
},
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
testMatch: ['<rootDir>/src/**/*?(*.)(test).(ts|js)?(x)'],
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
*/
|
||||
import '@testing-library/jest-dom';
|
||||
import 'jest-styled-components';
|
||||
import './src/styles.scss';
|
||||
|
||||
import { server } from './src/mocks-server/server';
|
||||
|
||||
import './src/styles.scss';
|
||||
// Establish API mocking before all tests.
|
||||
|
||||
// Mock window.matchMedia
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
"@signozhq/combobox": "0.0.2",
|
||||
"@signozhq/command": "0.0.0",
|
||||
"@signozhq/design-tokens": "2.1.1",
|
||||
"@signozhq/dialog": "^0.0.2",
|
||||
"@signozhq/drawer": "0.0.4",
|
||||
"@signozhq/icons": "0.1.0",
|
||||
"@signozhq/input": "0.0.2",
|
||||
"@signozhq/popover": "0.0.0",
|
||||
@@ -62,6 +64,7 @@
|
||||
"@signozhq/sonner": "0.1.0",
|
||||
"@signozhq/switch": "0.0.2",
|
||||
"@signozhq/table": "0.3.7",
|
||||
"@signozhq/toggle-group": "^0.0.1",
|
||||
"@signozhq/tooltip": "0.0.2",
|
||||
"@tanstack/react-table": "8.20.6",
|
||||
"@tanstack/react-virtual": "3.11.2",
|
||||
|
||||
@@ -13,5 +13,7 @@
|
||||
"pipelines": "Pipelines",
|
||||
"archives": "Archives",
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles"
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"members": "Members"
|
||||
}
|
||||
|
||||
@@ -13,5 +13,7 @@
|
||||
"pipelines": "Pipelines",
|
||||
"archives": "Archives",
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles"
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"members": "Members"
|
||||
}
|
||||
|
||||
@@ -74,5 +74,6 @@
|
||||
"METER_EXPLORER": "SigNoz | Meter Explorer",
|
||||
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
|
||||
"METER": "SigNoz | Meter",
|
||||
"ROLES_SETTINGS": "SigNoz | Roles"
|
||||
"ROLES_SETTINGS": "SigNoz | Roles",
|
||||
"MEMBERS_SETTINGS": "SigNoz | Members"
|
||||
}
|
||||
|
||||
@@ -165,11 +165,6 @@ export const MySettings = Loadable(
|
||||
() => import(/* webpackChunkName: "All MySettings" */ 'pages/Settings'),
|
||||
);
|
||||
|
||||
export const CustomDomainSettings = Loadable(
|
||||
() =>
|
||||
import(/* webpackChunkName: "Custom Domain Settings" */ 'pages/Settings'),
|
||||
);
|
||||
|
||||
export const Logs = Loadable(
|
||||
() => import(/* webpackChunkName: "Logs" */ 'pages/LogsModulePage'),
|
||||
);
|
||||
|
||||
@@ -2525,6 +2525,14 @@ export interface TypesPostableAcceptInviteDTO {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface TypesPostableBulkInviteRequestDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
invites: TypesPostableInviteDTO[] | null;
|
||||
}
|
||||
|
||||
export interface TypesPostableForgotPasswordDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2665,6 +2673,10 @@ export interface TypesUserDTO {
|
||||
* @type string
|
||||
*/
|
||||
role?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
TypesChangePasswordRequestDTO,
|
||||
TypesPostableAcceptInviteDTO,
|
||||
TypesPostableAPIKeyDTO,
|
||||
TypesPostableBulkInviteRequestDTO,
|
||||
TypesPostableForgotPasswordDTO,
|
||||
TypesPostableInviteDTO,
|
||||
TypesPostableResetPasswordDTO,
|
||||
@@ -671,14 +672,14 @@ export const useAcceptInvite = <
|
||||
* @summary Create bulk invite
|
||||
*/
|
||||
export const createBulkInvite = (
|
||||
typesPostableInviteDTO: BodyType<TypesPostableInviteDTO[]>,
|
||||
typesPostableBulkInviteRequestDTO: BodyType<TypesPostableBulkInviteRequestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/invite/bulk`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: typesPostableInviteDTO,
|
||||
data: typesPostableBulkInviteRequestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@@ -690,13 +691,13 @@ export const getCreateBulkInviteMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createBulkInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableInviteDTO[]> },
|
||||
{ data: BodyType<TypesPostableBulkInviteRequestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createBulkInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableInviteDTO[]> },
|
||||
{ data: BodyType<TypesPostableBulkInviteRequestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createBulkInvite'];
|
||||
@@ -710,7 +711,7 @@ export const getCreateBulkInviteMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createBulkInvite>>,
|
||||
{ data: BodyType<TypesPostableInviteDTO[]> }
|
||||
{ data: BodyType<TypesPostableBulkInviteRequestDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
@@ -723,7 +724,7 @@ export const getCreateBulkInviteMutationOptions = <
|
||||
export type CreateBulkInviteMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createBulkInvite>>
|
||||
>;
|
||||
export type CreateBulkInviteMutationBody = BodyType<TypesPostableInviteDTO[]>;
|
||||
export type CreateBulkInviteMutationBody = BodyType<TypesPostableBulkInviteRequestDTO>;
|
||||
export type CreateBulkInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -736,13 +737,13 @@ export const useCreateBulkInvite = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createBulkInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableInviteDTO[]> },
|
||||
{ data: BodyType<TypesPostableBulkInviteRequestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createBulkInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableInviteDTO[]> },
|
||||
{ data: BodyType<TypesPostableBulkInviteRequestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateBulkInviteMutationOptions(options);
|
||||
|
||||
@@ -94,19 +94,13 @@ export const interceptorRejected = async (
|
||||
afterLogin(response.data.accessToken, response.data.refreshToken, true);
|
||||
|
||||
try {
|
||||
const reResponse = await axios(
|
||||
`${value.config.baseURL}${value.config.url?.substring(1)}`,
|
||||
{
|
||||
method: value.config.method,
|
||||
headers: {
|
||||
...value.config.headers,
|
||||
Authorization: `Bearer ${response.data.accessToken}`,
|
||||
},
|
||||
data: {
|
||||
...JSON.parse(value.config.data || '{}'),
|
||||
},
|
||||
const reResponse = await axios({
|
||||
...value.config,
|
||||
headers: {
|
||||
...value.config.headers,
|
||||
Authorization: `Bearer ${response.data.accessToken}`,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return await Promise.resolve(reResponse);
|
||||
} catch (error) {
|
||||
|
||||
3
frontend/src/auto-import-registry.d.ts
vendored
3
frontend/src/auto-import-registry.d.ts
vendored
@@ -18,6 +18,8 @@ import '@signozhq/checkbox';
|
||||
import '@signozhq/combobox';
|
||||
import '@signozhq/command';
|
||||
import '@signozhq/design-tokens';
|
||||
import '@signozhq/dialog';
|
||||
import '@signozhq/drawer';
|
||||
import '@signozhq/icons';
|
||||
import '@signozhq/input';
|
||||
import '@signozhq/popover';
|
||||
@@ -26,4 +28,5 @@ import '@signozhq/resizable';
|
||||
import '@signozhq/sonner';
|
||||
import '@signozhq/switch';
|
||||
import '@signozhq/table';
|
||||
import '@signozhq/toggle-group';
|
||||
import '@signozhq/tooltip';
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { FilterDropdownProps } from 'antd/lib/table/interface';
|
||||
import type { FilterDropdownProps } from 'antd/lib/table/interface';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
getQueueOverview,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import { getAttributesValues } from 'api/queryBuilder/getAttributesValues';
|
||||
import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useState } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import dayjs from 'dayjs';
|
||||
import * as timeUtils from 'utils/timeUtils';
|
||||
|
||||
import CustomTimePicker from './CustomTimePicker';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useLocation: jest.fn().mockReturnValue({
|
||||
pathname: '/test-path',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('providers/Timezone', () => {
|
||||
const actual = jest.requireActual('providers/Timezone');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useTimezone: jest.fn().mockReturnValue({
|
||||
timezone: {
|
||||
value: 'UTC',
|
||||
offset: '+00:00',
|
||||
name: 'UTC',
|
||||
},
|
||||
browserTimezone: {
|
||||
value: 'UTC',
|
||||
offset: '+00:00',
|
||||
name: 'UTC',
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
interface WrapperProps {
|
||||
initialValue?: string;
|
||||
showLiveLogs?: boolean;
|
||||
onValidCustomDateChange?: () => void;
|
||||
onError?: () => void;
|
||||
onSelect?: (value: string) => void;
|
||||
onCustomDateHandler?: () => void;
|
||||
onCustomTimeStatusUpdate?: () => void;
|
||||
}
|
||||
|
||||
function Wrapper({
|
||||
initialValue = '2024-01-01 00:00:00 - 2024-01-01 01:00:00',
|
||||
showLiveLogs = false,
|
||||
onValidCustomDateChange = (): void => {},
|
||||
onError = (): void => {},
|
||||
onSelect = (): void => {},
|
||||
onCustomDateHandler = (): void => {},
|
||||
onCustomTimeStatusUpdate = (): void => {},
|
||||
}: WrapperProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedTime, setSelectedTime] = useState('custom');
|
||||
const [selectedValue, setSelectedValue] = useState(initialValue);
|
||||
|
||||
const handleSelect = (value: string): void => {
|
||||
setSelectedTime(value);
|
||||
onSelect(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomTimePicker
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
onSelect={handleSelect}
|
||||
onError={onError}
|
||||
selectedTime={selectedTime}
|
||||
selectedValue={selectedValue}
|
||||
onValidCustomDateChange={({ timeStr }): void => {
|
||||
setSelectedValue(timeStr);
|
||||
onValidCustomDateChange();
|
||||
}}
|
||||
onCustomDateHandler={(): void => {
|
||||
onCustomDateHandler();
|
||||
}}
|
||||
onCustomTimeStatusUpdate={(): void => {
|
||||
onCustomTimeStatusUpdate();
|
||||
}}
|
||||
items={[
|
||||
{ label: 'Last 5 minutes', value: '5m' },
|
||||
{ label: 'Custom', value: 'custom' },
|
||||
]}
|
||||
minTime={dayjs('2024-01-01 00:00:00').valueOf() * 1000_000}
|
||||
maxTime={dayjs('2024-01-01 01:00:00').valueOf() * 1000_000}
|
||||
showLiveLogs={showLiveLogs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('CustomTimePicker', () => {
|
||||
it('does not close or reset when clicking input while open', () => {
|
||||
render(<Wrapper />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
// Open popover
|
||||
fireEvent.focus(input);
|
||||
|
||||
// Type some text
|
||||
fireEvent.change(input, { target: { value: '5m' } });
|
||||
|
||||
// Click the input again while open
|
||||
fireEvent.mouseDown(input);
|
||||
fireEvent.click(input);
|
||||
|
||||
// Value should remain as typed
|
||||
expect((input as HTMLInputElement).value).toBe('5m');
|
||||
});
|
||||
|
||||
it('applies valid shorthand on Enter', () => {
|
||||
const onValid = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
render(<Wrapper onValidCustomDateChange={onValid} onError={onError} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: '5m' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(onValid).toHaveBeenCalledTimes(1);
|
||||
// onError(false) may be called by internal reset logic; we only assert that
|
||||
// it was never called with a truthy error state
|
||||
expect(onError).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('sets error and updates custom time status for invalid shorthand exceeding max allowed window', () => {
|
||||
const onValid = jest.fn();
|
||||
const onError = jest.fn();
|
||||
const onCustomTimeStatusUpdate = jest.fn();
|
||||
|
||||
render(
|
||||
<Wrapper
|
||||
onValidCustomDateChange={onValid}
|
||||
onError={onError}
|
||||
onCustomTimeStatusUpdate={onCustomTimeStatusUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
// large number of days to ensure it exceeds the 15 months allowed window
|
||||
fireEvent.change(input, { target: { value: '9999d' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(true);
|
||||
expect(onCustomTimeStatusUpdate).toHaveBeenCalledWith();
|
||||
expect(onValid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats close after change like pressing Enter (blur + chevron)', () => {
|
||||
const onValid = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
render(<Wrapper onValidCustomDateChange={onValid} onError={onError} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
// Open and change value so "changed since open" is true
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: '5m' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
// Click the chevron (which triggers handleClose)
|
||||
const chevron = document.querySelector(
|
||||
'.time-input-suffix-icon-badge',
|
||||
) as HTMLElement;
|
||||
|
||||
fireEvent.click(chevron);
|
||||
|
||||
// Should have applied the value (same as Enter)
|
||||
expect(onValid).toHaveBeenCalledTimes(1);
|
||||
expect(onError).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('applies epoch start/end range on Enter via onCustomDateHandler', () => {
|
||||
const onCustomDateHandler = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
render(
|
||||
<Wrapper onCustomDateHandler={onCustomDateHandler} onError={onError} />,
|
||||
);
|
||||
|
||||
const now = dayjs().valueOf();
|
||||
const later = dayjs().add(1, 'hour').valueOf();
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, {
|
||||
target: { value: `${now} - ${later}` },
|
||||
});
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(onCustomDateHandler).toHaveBeenCalledTimes(1);
|
||||
expect(onError).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('uses validateTimeRange result for generic formatted ranges (valid case)', () => {
|
||||
const validateTimeRangeSpy = jest.spyOn(timeUtils, 'validateTimeRange');
|
||||
const onCustomDateHandler = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
validateTimeRangeSpy.mockReturnValue({
|
||||
isValid: true,
|
||||
errorDetails: undefined,
|
||||
startTimeMs: dayjs('2024-01-01 00:00:00').valueOf(),
|
||||
endTimeMs: dayjs('2024-01-01 01:00:00').valueOf(),
|
||||
});
|
||||
|
||||
render(
|
||||
<Wrapper onCustomDateHandler={onCustomDateHandler} onError={onError} />,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'foo - bar' },
|
||||
});
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(validateTimeRangeSpy).toHaveBeenCalled();
|
||||
expect(onCustomDateHandler).toHaveBeenCalledTimes(1);
|
||||
expect(onError).not.toHaveBeenCalledWith(true);
|
||||
|
||||
validateTimeRangeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('uses validateTimeRange result for generic formatted ranges (invalid case)', () => {
|
||||
const validateTimeRangeSpy = jest.spyOn(timeUtils, 'validateTimeRange');
|
||||
const onValid = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
validateTimeRangeSpy.mockReturnValue({
|
||||
isValid: false,
|
||||
errorDetails: {
|
||||
message: 'Invalid range',
|
||||
code: 'INVALID_RANGE',
|
||||
description: 'Start must be before end',
|
||||
},
|
||||
startTimeMs: 0,
|
||||
endTimeMs: 0,
|
||||
});
|
||||
|
||||
render(<Wrapper onValidCustomDateChange={onValid} onError={onError} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'foo - bar' },
|
||||
});
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(validateTimeRangeSpy).toHaveBeenCalled();
|
||||
expect(onError).toHaveBeenCalledWith(true);
|
||||
expect(onValid).not.toHaveBeenCalled();
|
||||
|
||||
validateTimeRangeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('opens live mode with correct label', () => {
|
||||
render(<Wrapper showLiveLogs />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
|
||||
expect((input as HTMLInputElement).value).toBe('Live');
|
||||
});
|
||||
});
|
||||
@@ -104,6 +104,10 @@ function CustomTimePicker({
|
||||
const location = useLocation();
|
||||
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const initialInputValueOnOpenRef = useRef<string>('');
|
||||
const hasChangedSinceOpenRef = useRef<boolean>(false);
|
||||
// Tracks if the last pointer down was on the input so we don't close the popover when user clicks the input again
|
||||
const isClickFromInputRef = useRef(false);
|
||||
|
||||
const [activeView, setActiveView] = useState<ViewType>(DEFAULT_VIEW);
|
||||
|
||||
@@ -238,6 +242,21 @@ function CustomTimePicker({
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean): void => {
|
||||
// Don't close when the user clicked the input (trigger); Ant Design treats trigger as "outside" overlay
|
||||
if (!newOpen && isClickFromInputRef.current) {
|
||||
isClickFromInputRef.current = false;
|
||||
return;
|
||||
}
|
||||
isClickFromInputRef.current = false;
|
||||
|
||||
// If the popover is trying to close and the value changed since opening,
|
||||
// treat it as if the user pressed Enter (attempt to apply the value)
|
||||
if (!newOpen && hasChangedSinceOpenRef.current) {
|
||||
hasChangedSinceOpenRef.current = false;
|
||||
handleInputPressEnter();
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(newOpen);
|
||||
|
||||
if (!newOpen) {
|
||||
@@ -406,10 +425,18 @@ function CustomTimePicker({
|
||||
const handleOpen = (e?: React.SyntheticEvent): void => {
|
||||
e?.stopPropagation?.();
|
||||
|
||||
// If the popover is already open, avoid resetting the input value
|
||||
// so that any in-progress edits are preserved.
|
||||
if (open) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showLiveLogs) {
|
||||
setOpen(true);
|
||||
setSelectedTimePlaceholderValue('Live');
|
||||
setInputValue('Live');
|
||||
initialInputValueOnOpenRef.current = 'Live';
|
||||
hasChangedSinceOpenRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -424,11 +451,21 @@ function CustomTimePicker({
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.UK_DATETIME_SECONDS);
|
||||
|
||||
setInputValue(`${startTime} - ${endTime}`);
|
||||
const nextValue = `${startTime} - ${endTime}`;
|
||||
setInputValue(nextValue);
|
||||
initialInputValueOnOpenRef.current = nextValue;
|
||||
hasChangedSinceOpenRef.current = false;
|
||||
};
|
||||
|
||||
const handleClose = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
// If the value changed since opening, treat this like pressing Enter
|
||||
if (hasChangedSinceOpenRef.current) {
|
||||
hasChangedSinceOpenRef.current = false;
|
||||
handleInputPressEnter();
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
setCustomDTPickerVisible?.(false);
|
||||
|
||||
@@ -450,6 +487,9 @@ function CustomTimePicker({
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleInputBlur = (): void => {
|
||||
// Track whether the value was changed since the input was opened for editing
|
||||
hasChangedSinceOpenRef.current =
|
||||
inputValue !== initialInputValueOnOpenRef.current;
|
||||
resetErrorStatus();
|
||||
};
|
||||
|
||||
@@ -552,6 +592,12 @@ function CustomTimePicker({
|
||||
readOnly={!open || showLiveLogs}
|
||||
placeholder={selectedTimePlaceholderValue}
|
||||
value={inputValue}
|
||||
onMouseDown={(e): void => {
|
||||
// Only treat as "click from input" when the actual input element is clicked (not suffix/chevron)
|
||||
if (e.target === inputRef.current?.input) {
|
||||
isClickFromInputRef.current = true;
|
||||
}
|
||||
}}
|
||||
onFocus={handleOpen}
|
||||
onClick={handleOpen}
|
||||
onChange={handleInputChange}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
.edit-member-drawer {
|
||||
&__layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-5) var(--padding-4);
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
color: var(--l1-foreground);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 var(--padding-2);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
&--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&__email-text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__lock-icon {
|
||||
color: var(--foreground);
|
||||
flex-shrink: 0;
|
||||
margin-left: 6px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__role-select {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--l2-background) !important;
|
||||
border-color: var(--border) !important;
|
||||
border-radius: 2px;
|
||||
padding: 0 var(--padding-2) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l1-foreground);
|
||||
line-height: 32px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
margin-top: var(--margin-1);
|
||||
}
|
||||
|
||||
&__meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
[data-slot='badge'] {
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
align-items: center;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
line-height: 100%;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
&__meta-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
padding: 0 var(--padding-4);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
&__footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
&__footer-divider {
|
||||
width: 1px;
|
||||
height: 21px;
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__footer-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--label-small-400-font-size);
|
||||
font-weight: var(--label-small-400-font-weight);
|
||||
line-height: var(--label-small-400-line-height);
|
||||
letter-spacing: var(--label-small-400-letter-spacing);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
&--warning {
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delete-dialog {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__body {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l2-foreground);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
margin: 0;
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--margin-6);
|
||||
}
|
||||
}
|
||||
|
||||
.reset-link-dialog {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
|
||||
[data-slot='dialog-header'] {
|
||||
border-color: var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
[data-slot='dialog-description'] {
|
||||
width: 510px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l2-foreground);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
margin: 0;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
overflow: hidden;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&__link-text-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__link-text {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0 var(--padding-2);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l2-foreground);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
border-top: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
border-left: 1px solid var(--border);
|
||||
min-width: 64px;
|
||||
}
|
||||
}
|
||||
510
frontend/src/components/EditMemberDrawer/EditMemberDrawer.tsx
Normal file
510
frontend/src/components/EditMemberDrawer/EditMemberDrawer.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Link,
|
||||
LockKeyhole,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Select } from 'antd';
|
||||
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import cancelInvite from 'api/v1/invite/id/delete';
|
||||
import deleteUser from 'api/v1/user/id/delete';
|
||||
import update from 'api/v1/user/id/update';
|
||||
import { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { INVITE_PREFIX, MemberStatus } from 'container/MembersSettings/utils';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import './EditMemberDrawer.styles.scss';
|
||||
|
||||
export interface EditMemberDrawerProps {
|
||||
member: MemberRow | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onComplete: () => void;
|
||||
onRefetch?: () => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function EditMemberDrawer({
|
||||
member,
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
onRefetch,
|
||||
}: EditMemberDrawerProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [selectedRole, setSelectedRole] = useState<ROLES>('VIEWER');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [resetLink, setResetLink] = useState<string | null>(null);
|
||||
const [showResetLinkDialog, setShowResetLinkDialog] = useState(false);
|
||||
const [hasCopiedResetLink, setHasCopiedResetLink] = useState(false);
|
||||
|
||||
const isInvited = member?.status === MemberStatus.Invited;
|
||||
// Invited member IDs are prefixed with 'invite-'; strip it to get the real invite ID
|
||||
const inviteId =
|
||||
isInvited && member ? member.id.slice(INVITE_PREFIX.length) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (member) {
|
||||
setDisplayName(member.name ?? '');
|
||||
setSelectedRole(member.role);
|
||||
}
|
||||
}, [member]);
|
||||
|
||||
const isDirty =
|
||||
member !== null &&
|
||||
(displayName !== member.name || selectedRole !== member.role);
|
||||
|
||||
const formatTimestamp = useCallback(
|
||||
(ts: string | null | undefined): string => {
|
||||
if (!ts) {
|
||||
return '—';
|
||||
}
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
|
||||
},
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const saveInvitedMember = useCallback(async (): Promise<void> => {
|
||||
if (!member || !inviteId) {
|
||||
return;
|
||||
}
|
||||
await cancelInvite({ id: inviteId });
|
||||
try {
|
||||
await sendInvite({
|
||||
email: member.email,
|
||||
name: displayName,
|
||||
role: selectedRole,
|
||||
frontendBaseUrl: window.location.origin,
|
||||
});
|
||||
toast.success('Invite updated successfully', { richColors: true });
|
||||
onComplete();
|
||||
onClose();
|
||||
} catch {
|
||||
onRefetch?.();
|
||||
onClose();
|
||||
toast.error(
|
||||
'Failed to send the updated invite. Please re-invite this member.',
|
||||
{ richColors: true },
|
||||
);
|
||||
}
|
||||
}, [
|
||||
member,
|
||||
inviteId,
|
||||
displayName,
|
||||
selectedRole,
|
||||
onComplete,
|
||||
onClose,
|
||||
onRefetch,
|
||||
]);
|
||||
|
||||
const saveActiveMember = useCallback(async (): Promise<void> => {
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
await update({
|
||||
userId: member.id,
|
||||
displayName,
|
||||
role: selectedRole,
|
||||
});
|
||||
toast.success('Member details updated successfully', { richColors: true });
|
||||
onComplete();
|
||||
onClose();
|
||||
}, [member, displayName, selectedRole, onComplete, onClose]);
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!member || !isDirty) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (isInvited && inviteId) {
|
||||
await saveInvitedMember();
|
||||
} else {
|
||||
await saveActiveMember();
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
isInvited ? 'Failed to update invite' : 'Failed to update member details',
|
||||
{ richColors: true },
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
member,
|
||||
isDirty,
|
||||
isInvited,
|
||||
inviteId,
|
||||
saveInvitedMember,
|
||||
saveActiveMember,
|
||||
]);
|
||||
|
||||
const handleDelete = useCallback(async (): Promise<void> => {
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
if (isInvited && inviteId) {
|
||||
await cancelInvite({ id: inviteId });
|
||||
toast.success('Invitation cancelled successfully', { richColors: true });
|
||||
} else {
|
||||
await deleteUser({ userId: member.id });
|
||||
toast.success('Member deleted successfully', { richColors: true });
|
||||
}
|
||||
setShowDeleteConfirm(false);
|
||||
onComplete();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(
|
||||
isInvited ? 'Failed to cancel invitation' : 'Failed to delete member',
|
||||
{ richColors: true },
|
||||
);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [member, isInvited, inviteId, onComplete, onClose]);
|
||||
|
||||
const handleGenerateResetLink = useCallback(async (): Promise<void> => {
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
setIsGeneratingLink(true);
|
||||
try {
|
||||
const response = await getResetPasswordToken({ userId: member.id });
|
||||
if (response?.data?.token) {
|
||||
const link = `${window.location.origin}/password-reset?token=${response.data.token}`;
|
||||
setResetLink(link);
|
||||
setHasCopiedResetLink(false);
|
||||
setShowResetLinkDialog(true);
|
||||
onClose();
|
||||
} else {
|
||||
toast.error('Failed to generate password reset link', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to generate password reset link', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
} finally {
|
||||
setIsGeneratingLink(false);
|
||||
}
|
||||
}, [member, onClose]);
|
||||
|
||||
const handleCopyResetLink = useCallback(async (): Promise<void> => {
|
||||
if (!resetLink) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(resetLink);
|
||||
setHasCopiedResetLink(true);
|
||||
setTimeout(() => setHasCopiedResetLink(false), 2000);
|
||||
toast.success('Reset link copied to clipboard', { richColors: true });
|
||||
} catch {
|
||||
toast.error('Failed to copy link', {
|
||||
richColors: true,
|
||||
});
|
||||
}
|
||||
}, [resetLink]);
|
||||
|
||||
const handleCopyInviteLink = useCallback(async (): Promise<void> => {
|
||||
if (!member?.token) {
|
||||
toast.error('Invite link is not available', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const inviteLink = `${window.location.origin}${ROUTES.SIGN_UP}?token=${member.token}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
toast.success('Invite link copied to clipboard', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to copy invite link', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
}, [member]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setShowDeleteConfirm(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const joinedOnLabel = isInvited ? 'Invited On' : 'Joined On';
|
||||
|
||||
const drawerContent = (
|
||||
<div className="edit-member-drawer__layout">
|
||||
<div className="edit-member-drawer__body">
|
||||
<div className="edit-member-drawer__field">
|
||||
<label className="edit-member-drawer__label" htmlFor="member-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="member-name"
|
||||
value={displayName}
|
||||
onChange={(e): void => setDisplayName(e.target.value)}
|
||||
className="edit-member-drawer__input"
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__field">
|
||||
<label className="edit-member-drawer__label" htmlFor="member-email">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
|
||||
<span className="edit-member-drawer__email-text">
|
||||
{member?.email || '—'}
|
||||
</span>
|
||||
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__field">
|
||||
<label className="edit-member-drawer__label" htmlFor="member-role">
|
||||
Roles
|
||||
</label>
|
||||
<Select
|
||||
id="member-role"
|
||||
value={selectedRole}
|
||||
onChange={(role): void => setSelectedRole(role as ROLES)}
|
||||
className="edit-member-drawer__role-select"
|
||||
suffixIcon={<ChevronDown size={14} />}
|
||||
getPopupContainer={(triggerNode): HTMLElement =>
|
||||
(triggerNode?.closest('.edit-member-drawer') as HTMLElement) ||
|
||||
document.body
|
||||
}
|
||||
>
|
||||
<Select.Option value="ADMIN">{capitalize('ADMIN')}</Select.Option>
|
||||
<Select.Option value="EDITOR">{capitalize('EDITOR')}</Select.Option>
|
||||
<Select.Option value="VIEWER">{capitalize('VIEWER')}</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__meta">
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">Status</span>
|
||||
{member?.status === MemberStatus.Active ? (
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="amber" variant="outline">
|
||||
INVITED
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
|
||||
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
|
||||
</div>
|
||||
{!isInvited && (
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">Last Modified</span>
|
||||
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer">
|
||||
<div className="edit-member-drawer__footer-left">
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isInvited ? 'Cancel Invite' : 'Delete Member'}
|
||||
</Button>
|
||||
|
||||
<div className="edit-member-drawer__footer-divider" />
|
||||
|
||||
{isInvited ? (
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleCopyInviteLink}
|
||||
disabled={!member?.token}
|
||||
>
|
||||
<Link size={12} />
|
||||
Copy Invite Link
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink ? 'Generating...' : 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer-right">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!isDirty || isSaving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Member Details'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const deleteDialogTitle = isInvited ? 'Cancel Invitation' : 'Delete Member';
|
||||
const deleteDialogBody = isInvited ? (
|
||||
<>
|
||||
Are you sure you want to cancel the invitation for{' '}
|
||||
<strong>{member?.email}</strong>? They will no longer be able to join the
|
||||
workspace using this invite.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Are you sure you want to delete{' '}
|
||||
<strong>{member?.name || member?.email}</strong>? This will permanently
|
||||
remove their access to the workspace.
|
||||
</>
|
||||
);
|
||||
const deleteConfirmLabel = isInvited ? 'Cancel Invite' : 'Delete Member';
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
type="panel"
|
||||
showCloseButton
|
||||
showOverlay={false}
|
||||
allowOutsideClick
|
||||
header={{ title: 'Member Details' }}
|
||||
content={drawerContent}
|
||||
className="edit-member-drawer"
|
||||
/>
|
||||
|
||||
<DialogWrapper
|
||||
open={showResetLinkDialog}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
setShowResetLinkDialog(false);
|
||||
}
|
||||
}}
|
||||
title="Password Reset Link"
|
||||
showCloseButton
|
||||
width="base"
|
||||
className="reset-link-dialog"
|
||||
>
|
||||
<div className="reset-link-dialog__content">
|
||||
<p className="reset-link-dialog__description">
|
||||
This creates a one-time link the team member can use to set a new password
|
||||
for their SigNoz account.
|
||||
</p>
|
||||
<div className="reset-link-dialog__link-row">
|
||||
<div className="reset-link-dialog__link-text-wrap">
|
||||
<span className="reset-link-dialog__link-text">{resetLink}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopyResetLink}
|
||||
prefixIcon={
|
||||
hasCopiedResetLink ? <Check size={12} /> : <Copy size={12} />
|
||||
}
|
||||
className="reset-link-dialog__copy-btn"
|
||||
>
|
||||
{hasCopiedResetLink ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
|
||||
<DialogWrapper
|
||||
open={showDeleteConfirm}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
}}
|
||||
title={deleteDialogTitle}
|
||||
width="narrow"
|
||||
className="alert-dialog delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<p className="delete-dialog__body">{deleteDialogBody}</p>
|
||||
|
||||
<DialogFooter className="delete-dialog__footer">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setShowDeleteConfirm(false)}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
disabled={isDeleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isDeleting ? 'Processing...' : deleteConfirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditMemberDrawer;
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
|
||||
import cancelInvite from 'api/v1/invite/id/delete';
|
||||
import deleteUser from 'api/v1/user/id/delete';
|
||||
import update from 'api/v1/user/id/update';
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
|
||||
|
||||
jest.mock('@signozhq/drawer', () => ({
|
||||
DrawerWrapper: ({
|
||||
content,
|
||||
open,
|
||||
}: {
|
||||
content?: ReactNode;
|
||||
open: boolean;
|
||||
}): JSX.Element | null => (open ? <div>{content}</div> : null),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/dialog', () => ({
|
||||
DialogWrapper: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
DialogFooter: ({ children }: { children?: ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('api/v1/user/id/update');
|
||||
jest.mock('api/v1/user/id/delete');
|
||||
jest.mock('api/v1/invite/id/delete');
|
||||
jest.mock('api/v1/invite/create');
|
||||
jest.mock('api/v1/factor_password/getResetPasswordToken');
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockUpdate = jest.mocked(update);
|
||||
const mockDeleteUser = jest.mocked(deleteUser);
|
||||
const mockCancelInvite = jest.mocked(cancelInvite);
|
||||
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
|
||||
|
||||
const activeMember = {
|
||||
id: 'user-1',
|
||||
name: 'Alice Smith',
|
||||
email: 'alice@signoz.io',
|
||||
role: 'ADMIN' as ROLES,
|
||||
status: MemberStatus.Active,
|
||||
joinedOn: '1700000000000',
|
||||
updatedAt: '1710000000000',
|
||||
};
|
||||
|
||||
const invitedMember = {
|
||||
id: 'invite-abc123',
|
||||
name: '',
|
||||
email: 'bob@signoz.io',
|
||||
role: 'VIEWER' as ROLES,
|
||||
status: MemberStatus.Invited,
|
||||
joinedOn: '1700000000000',
|
||||
token: 'tok-xyz',
|
||||
};
|
||||
|
||||
function renderDrawer(
|
||||
props: Partial<EditMemberDrawerProps> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<EditMemberDrawer
|
||||
member={activeMember}
|
||||
open
|
||||
onClose={jest.fn()}
|
||||
onComplete={jest.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('EditMemberDrawer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUpdate.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
mockDeleteUser.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
mockCancelInvite.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
});
|
||||
|
||||
it('renders active member details and disables Save when form is not dirty', () => {
|
||||
renderDrawer();
|
||||
|
||||
expect(screen.getByDisplayValue('Alice Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByText('ACTIVE')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save member details/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables Save after editing name and calls update API on confirm', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer({ onComplete });
|
||||
|
||||
const nameInput = screen.getByDisplayValue('Alice Smith');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'Alice Updated');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
displayName: 'Alice Updated',
|
||||
}),
|
||||
);
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows delete confirm dialog and calls deleteUser for active members', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer({ onComplete });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /delete member/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/are you sure you want to delete/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /delete member/i });
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteUser).toHaveBeenCalledWith({ userId: 'user-1' });
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Cancel Invite and Copy Invite Link for invited members; hides Last Modified', () => {
|
||||
renderDrawer({ member: invitedMember });
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /cancel invite/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /copy invite link/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Invited On')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Last Modified')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls cancelInvite after confirming Cancel Invite for invited members', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer({ member: invitedMember, onComplete });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /cancel invite/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/are you sure you want to cancel the invitation/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /cancel invite/i });
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCancelInvite).toHaveBeenCalledWith({ id: 'abc123' });
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Generate Password Reset Link', () => {
|
||||
const mockWriteText = jest.fn().mockResolvedValue(undefined);
|
||||
let clipboardSpy: jest.SpyInstance | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: (): Promise<void> => Promise.resolve() },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockWriteText.mockClear();
|
||||
clipboardSpy = jest
|
||||
.spyOn(navigator.clipboard, 'writeText')
|
||||
.mockImplementation(mockWriteText);
|
||||
mockGetResetPasswordToken.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { token: 'reset-tok-abc', userId: 'user-1' },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clipboardSpy?.mockRestore();
|
||||
});
|
||||
|
||||
it('calls getResetPasswordToken and opens the reset link dialog with the generated link', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /generate password reset link/i }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: /password reset link/i,
|
||||
});
|
||||
expect(mockGetResetPasswordToken).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(dialog).toHaveTextContent('reset-tok-abc');
|
||||
});
|
||||
|
||||
it('copies the link to clipboard and shows "Copied!" on the button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /generate password reset link/i }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: /password reset link/i,
|
||||
});
|
||||
expect(dialog).toHaveTextContent('reset-tok-abc');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^copy$/i }));
|
||||
|
||||
// Verify success path: writeText called with the correct link
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Reset link copied to clipboard',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('reset-tok-abc'),
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /copied!/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,8 @@ jest.mock('react-router-dom', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('antd/es/form/Form', () => ({
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
useForm: jest.fn().mockReturnValue({
|
||||
onFinish: jest.fn(),
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UseMutateAsyncFunction } from 'react-query';
|
||||
import { FormInstance } from 'antd';
|
||||
import { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import type { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import type { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import axios from 'axios';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Tag, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import { TableColumnsType as ColumnsType, Tag, Typography } from 'antd';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/lib';
|
||||
import type { RadioChangeEvent } from 'antd/lib';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
.invite-members-modal {
|
||||
max-width: 700px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
|
||||
|
||||
[data-slot='dialog-header'] {
|
||||
padding: var(--padding-4);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--label-base-400-font-size);
|
||||
font-weight: var(--label-base-400-font-weight);
|
||||
line-height: var(--label-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--bg-base-white);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-description'] {
|
||||
padding: 0;
|
||||
|
||||
.invite-members-modal__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.invite-members-modal__table-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
|
||||
.email-header {
|
||||
flex: 0 0 240px;
|
||||
}
|
||||
|
||||
.role-header {
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.action-header {
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
|
||||
.table-header-cell {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.team-member-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
|
||||
> .email-cell {
|
||||
flex: 0 0 240px;
|
||||
}
|
||||
|
||||
> .role-cell {
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
> .action-cell {
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
&.action-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-email-input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
color: var(--l1-foreground);
|
||||
background-color: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
.team-member-role-select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selector {
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--l2-background) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
padding: 0 var(--padding-2) !important;
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--l3-foreground);
|
||||
opacity: 0.4;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
letter-spacing: -0.07px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--bg-base-white);
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector,
|
||||
&:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.remove-team-member-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: var(--destructive);
|
||||
opacity: 0.6;
|
||||
padding: 0;
|
||||
transition: background-color 0.2s, opacity 0.2s;
|
||||
box-shadow: none;
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 72, 77, 0.1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.email-error-message {
|
||||
display: block;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-18);
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.invite-team-members-error-callout {
|
||||
background: rgba(229, 72, 77, 0.1);
|
||||
border: 1px solid rgba(229, 72, 77, 0.2);
|
||||
border-radius: 4px;
|
||||
animation: horizontal-shaking 300ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes horizontal-shaking {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--padding-4);
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
border-top: 1px solid var(--secondary);
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.invite-members-modal__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.add-another-member-button {
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
border-style: dashed;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.invite-members-modal {
|
||||
[data-slot='dialog-title'] {
|
||||
color: var(--bg-base-black);
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-role-select {
|
||||
.ant-select-selector {
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-base-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { ChevronDown, CircleAlert, Plus, Trash2, X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Select } from 'antd';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { cloneDeep, debounce } from 'lodash-es';
|
||||
import APIError from 'types/api/error';
|
||||
import { ROLES } from 'types/roles';
|
||||
import { EMAIL_REGEX } from 'utils/app';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import './InviteMembersModal.styles.scss';
|
||||
|
||||
interface InviteRow {
|
||||
id: string;
|
||||
email: string;
|
||||
role: ROLES | '';
|
||||
}
|
||||
|
||||
export interface InviteMembersModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_ROW = (): InviteRow => ({ id: uuid(), email: '', role: '' });
|
||||
|
||||
const isRowTouched = (row: InviteRow): boolean =>
|
||||
row.email.trim() !== '' || Boolean(row.role && row.role.trim() !== '');
|
||||
|
||||
function InviteMembersModal({
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: InviteMembersModalProps): JSX.Element {
|
||||
const [rows, setRows] = useState<InviteRow[]>(() => [
|
||||
EMPTY_ROW(),
|
||||
EMPTY_ROW(),
|
||||
EMPTY_ROW(),
|
||||
]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
|
||||
const [hasInvalidRoles, setHasInvalidRoles] = useState<boolean>(false);
|
||||
|
||||
const resetAndClose = useCallback((): void => {
|
||||
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
|
||||
setEmailValidity({});
|
||||
setHasInvalidEmails(false);
|
||||
setHasInvalidRoles(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const getValidationErrorMessage = (): string => {
|
||||
if (hasInvalidEmails && hasInvalidRoles) {
|
||||
return 'Please enter valid emails and select roles for team members';
|
||||
}
|
||||
if (hasInvalidEmails) {
|
||||
return 'Please enter valid emails for team members';
|
||||
}
|
||||
return 'Please select roles for team members';
|
||||
};
|
||||
|
||||
const validateAllUsers = useCallback((): boolean => {
|
||||
let isValid = true;
|
||||
let hasEmailErrors = false;
|
||||
let hasRoleErrors = false;
|
||||
|
||||
const updatedEmailValidity: Record<string, boolean> = {};
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
|
||||
touchedRows.forEach((row) => {
|
||||
const emailValid = EMAIL_REGEX.test(row.email);
|
||||
const roleValid = Boolean(row.role && row.role.trim() !== '');
|
||||
|
||||
if (!emailValid || !row.email) {
|
||||
isValid = false;
|
||||
hasEmailErrors = true;
|
||||
}
|
||||
if (!roleValid) {
|
||||
isValid = false;
|
||||
hasRoleErrors = true;
|
||||
}
|
||||
|
||||
if (row.id) {
|
||||
updatedEmailValidity[row.id] = emailValid;
|
||||
}
|
||||
});
|
||||
|
||||
setEmailValidity(updatedEmailValidity);
|
||||
setHasInvalidEmails(hasEmailErrors);
|
||||
setHasInvalidRoles(hasRoleErrors);
|
||||
|
||||
return isValid;
|
||||
}, [rows]);
|
||||
|
||||
const debouncedValidateEmail = useMemo(
|
||||
() =>
|
||||
debounce((email: string, rowId: string) => {
|
||||
const isValid = EMAIL_REGEX.test(email);
|
||||
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
|
||||
}, 500),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
debouncedValidateEmail.cancel();
|
||||
}
|
||||
return (): void => {
|
||||
debouncedValidateEmail.cancel();
|
||||
};
|
||||
}, [open, debouncedValidateEmail]);
|
||||
|
||||
const updateEmail = (id: string, email: string): void => {
|
||||
const updatedRows = cloneDeep(rows);
|
||||
const rowToUpdate = updatedRows.find((r) => r.id === id);
|
||||
if (rowToUpdate) {
|
||||
rowToUpdate.email = email;
|
||||
setRows(updatedRows);
|
||||
|
||||
if (hasInvalidEmails) {
|
||||
setHasInvalidEmails(false);
|
||||
}
|
||||
if (emailValidity[id] === false) {
|
||||
setEmailValidity((prev) => ({ ...prev, [id]: true }));
|
||||
}
|
||||
|
||||
debouncedValidateEmail(email, id);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRole = (id: string, role: ROLES): void => {
|
||||
const updatedRows = cloneDeep(rows);
|
||||
const rowToUpdate = updatedRows.find((r) => r.id === id);
|
||||
if (rowToUpdate) {
|
||||
rowToUpdate.role = role;
|
||||
setRows(updatedRows);
|
||||
|
||||
if (hasInvalidRoles) {
|
||||
setHasInvalidRoles(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addRow = (): void => {
|
||||
setRows((prev) => [...prev, EMPTY_ROW()]);
|
||||
};
|
||||
|
||||
const removeRow = (id: string): void => {
|
||||
setRows((prev) => prev.filter((r) => r.id !== id));
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async (): Promise<void> => {
|
||||
if (!validateAllUsers()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
if (touchedRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (touchedRows.length === 1) {
|
||||
const row = touchedRows[0];
|
||||
await sendInvite({
|
||||
email: row.email.trim(),
|
||||
name: '',
|
||||
role: row.role as ROLES,
|
||||
frontendBaseUrl: window.location.origin,
|
||||
});
|
||||
} else {
|
||||
await inviteUsers({
|
||||
invites: touchedRows.map((row) => ({
|
||||
email: row.email.trim(),
|
||||
name: '',
|
||||
role: row.role,
|
||||
frontendBaseUrl: window.location.origin,
|
||||
})),
|
||||
});
|
||||
}
|
||||
toast.success('Invites sent successfully', { richColors: true });
|
||||
resetAndClose();
|
||||
onComplete?.();
|
||||
} catch (err) {
|
||||
const apiErr = err as APIError;
|
||||
if (apiErr?.getHttpStatusCode() === 409) {
|
||||
toast.error(
|
||||
touchedRows.length === 1
|
||||
? `${touchedRows[0].email} is already a member`
|
||||
: 'Invite for one or more users already exists',
|
||||
{ richColors: true },
|
||||
);
|
||||
} else {
|
||||
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
|
||||
toast.error(`Failed to send invites: ${errorMessage}`, {
|
||||
richColors: true,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [rows, onComplete, resetAndClose, validateAllUsers]);
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="Invite Team Members"
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
resetAndClose();
|
||||
}
|
||||
}}
|
||||
showCloseButton
|
||||
width="wide"
|
||||
className="invite-members-modal"
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<div className="invite-members-modal__content">
|
||||
<div className="invite-members-modal__table">
|
||||
<div className="invite-members-modal__table-header">
|
||||
<div className="table-header-cell email-header">Email address</div>
|
||||
<div className="table-header-cell role-header">Roles</div>
|
||||
<div className="table-header-cell action-header" />
|
||||
</div>
|
||||
<div className="invite-members-modal__container">
|
||||
{rows.map(
|
||||
(row): JSX.Element => (
|
||||
<div key={row.id} className="team-member-row">
|
||||
<div className="team-member-cell email-cell">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="john@signoz.io"
|
||||
value={row.email}
|
||||
onChange={(e): void => updateEmail(row.id, e.target.value)}
|
||||
className="team-member-email-input"
|
||||
/>
|
||||
{emailValidity[row.id] === false && row.email.trim() !== '' && (
|
||||
<span className="email-error-message">Invalid email address</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="team-member-cell role-cell">
|
||||
<Select
|
||||
value={row.role || undefined}
|
||||
onChange={(role): void => updateRole(row.id, role as ROLES)}
|
||||
className="team-member-role-select"
|
||||
placeholder="Select roles"
|
||||
suffixIcon={<ChevronDown size={14} />}
|
||||
getPopupContainer={(triggerNode): HTMLElement =>
|
||||
(triggerNode?.closest('.invite-members-modal') as HTMLElement) ||
|
||||
document.body
|
||||
}
|
||||
>
|
||||
<Select.Option value="VIEWER">Viewer</Select.Option>
|
||||
<Select.Option value="EDITOR">Editor</Select.Option>
|
||||
<Select.Option value="ADMIN">Admin</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="team-member-cell action-cell">
|
||||
{rows.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
className="remove-team-member-button"
|
||||
onClick={(): void => removeRow(row.id)}
|
||||
aria-label="Remove row"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(hasInvalidEmails || hasInvalidRoles) && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
className="invite-team-members-error-callout"
|
||||
description={getValidationErrorMessage()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="invite-members-modal__footer">
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="add-another-member-button"
|
||||
prefixIcon={<Plus size={12} color={Style.L1_FOREGROUND} />}
|
||||
onClick={addRow}
|
||||
>
|
||||
Add another
|
||||
</Button>
|
||||
|
||||
<div className="invite-members-modal__footer-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={resetAndClose}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitDisabled}
|
||||
>
|
||||
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteMembersModal;
|
||||
@@ -0,0 +1,177 @@
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import InviteMembersModal from '../InviteMembersModal';
|
||||
|
||||
jest.mock('api/v1/invite/create');
|
||||
jest.mock('api/v1/invite/bulk/create');
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockSendInvite = jest.mocked(sendInvite);
|
||||
const mockInviteUsers = jest.mocked(inviteUsers);
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: jest.fn(),
|
||||
onComplete: jest.fn(),
|
||||
};
|
||||
|
||||
describe('InviteMembersModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSendInvite.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { data: 'test', status: 'success' },
|
||||
});
|
||||
mockInviteUsers.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
});
|
||||
|
||||
it('renders 3 initial empty rows and disables the submit button', () => {
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
expect(emailInputs).toHaveLength(3);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('adds a row when "Add another" is clicked and removes a row via trash button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
expect(screen.getAllByPlaceholderText('john@signoz.io')).toHaveLength(4);
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
await user.click(removeButtons[0]);
|
||||
expect(screen.getAllByPlaceholderText('john@signoz.io')).toHaveLength(3);
|
||||
});
|
||||
|
||||
describe('validation callout messages', () => {
|
||||
it('shows combined message when email is invalid and role is missing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'not-an-email',
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Please enter valid emails and select roles for team members',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows email-only message when email is invalid but role is selected', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
await user.type(emailInputs[0], 'not-an-email');
|
||||
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText('Please enter valid emails for team members'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows role-only message when email is valid but role is missing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'valid@signoz.io',
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText('Please select roles for team members'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses sendInvite (single) when only one row is filled', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onComplete = jest.fn();
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} onComplete={onComplete} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
await user.type(emailInputs[0], 'single@signoz.io');
|
||||
|
||||
const roleSelects = screen.getAllByText('Select roles');
|
||||
await user.click(roleSelects[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendInvite).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: 'single@signoz.io', role: 'VIEWER' }),
|
||||
);
|
||||
expect(mockInviteUsers).not.toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses inviteUsers (bulk) when multiple rows are filled', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onComplete = jest.fn();
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} onComplete={onComplete} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInviteUsers).toHaveBeenCalledWith({
|
||||
invites: expect.arrayContaining([
|
||||
expect.objectContaining({ email: 'alice@signoz.io', role: 'VIEWER' }),
|
||||
expect.objectContaining({ email: 'bob@signoz.io', role: 'EDITOR' }),
|
||||
]),
|
||||
});
|
||||
expect(mockSendInvite).not.toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { useSelector } from 'react-redux';
|
||||
import { useCopyToClipboard, useLocation } from 'react-use';
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import { Button, Divider, Drawer, Radio, Tooltip, Typography } from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/lib';
|
||||
import type { RadioChangeEvent } from 'antd/lib';
|
||||
import cx from 'classnames';
|
||||
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ColumnsType, ColumnType } from 'antd/es/table';
|
||||
import {
|
||||
TableColumnsType as ColumnsType,
|
||||
TableColumnType as ColumnType,
|
||||
} from 'antd';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import { TableColumnsType as ColumnsType, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Input, InputNumber, Popover, Tooltip, Typography } from 'antd';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import cx from 'classnames';
|
||||
import { LogViewMode } from 'container/LogsTable';
|
||||
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
|
||||
216
frontend/src/components/MembersTable/MembersTable.styles.scss
Normal file
216
frontend/src/components/MembersTable/MembersTable.styles.scss
Normal file
@@ -0,0 +1,216 @@
|
||||
.members-table-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.members-table {
|
||||
.ant-table {
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ant-table-container {
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ant-table-thead {
|
||||
> tr > th,
|
||||
> tr > td {
|
||||
background: var(--background);
|
||||
font-size: var(--paragraph-small-600-font-size);
|
||||
font-weight: var(--paragraph-small-600-font-weight);
|
||||
line-height: var(--paragraph-small-600-line-height);
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
color: var(--foreground);
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
border-bottom: none !important;
|
||||
border-top: none !important;
|
||||
|
||||
&::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ant-table-column-sorters {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.ant-table-column-title {
|
||||
flex: unset;
|
||||
}
|
||||
|
||||
.ant-table-column-sorter {
|
||||
color: var(--foreground);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ant-table-column-sorter-up.active,
|
||||
.ant-table-column-sorter-down.active {
|
||||
color: var(--bg-base-white);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody {
|
||||
> tr > td {
|
||||
border-bottom: none !important;
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
background: transparent;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
> tr.members-table-row--tinted > td {
|
||||
background: rgba(171, 189, 255, 0.02);
|
||||
}
|
||||
> tr:hover > td {
|
||||
background: rgba(171, 189, 255, 0.04) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-wrapper,
|
||||
.ant-table-container,
|
||||
.ant-spin-nested-loading,
|
||||
.ant-spin-container {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.member-status-cell {
|
||||
[data-slot='badge'] {
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
align-items: center;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
line-height: 100%;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
.member-name-email-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
height: 22px;
|
||||
overflow: hidden;
|
||||
|
||||
.member-name {
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
color: var(--foreground);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-email {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l3-foreground-hover);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.member-joined-date {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-joined-dash {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
color: var(--l3-foreground-hover);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.members-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--padding-12) var(--padding-4);
|
||||
gap: var(--spacing-4);
|
||||
color: var(--foreground);
|
||||
|
||||
&__emoji {
|
||||
font-size: var(--font-size-2xl);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
line-height: var(--paragraph-base-400-font-height);
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--bg-base-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.members-table-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
|
||||
.ant-pagination-total-text {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.members-pagination-range {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.members-pagination-total {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.members-table {
|
||||
.ant-table-tbody {
|
||||
> tr.members-table-row--tinted > td {
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
}
|
||||
|
||||
> tr:hover > td {
|
||||
background: rgba(0, 0, 0, 0.03) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.members-empty-state {
|
||||
&__text {
|
||||
strong {
|
||||
color: var(--bg-base-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
238
frontend/src/components/MembersTable/MembersTable.tsx
Normal file
238
frontend/src/components/MembersTable/MembersTable.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import type React from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Pagination, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import './MembersTable.styles.scss';
|
||||
|
||||
export interface MemberRow {
|
||||
id: string;
|
||||
name?: string;
|
||||
email: string;
|
||||
role: ROLES;
|
||||
status: MemberStatus;
|
||||
joinedOn: string | null;
|
||||
updatedAt?: string | null;
|
||||
token?: string | null;
|
||||
}
|
||||
|
||||
interface MembersTableProps {
|
||||
data: MemberRow[];
|
||||
loading: boolean;
|
||||
total: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
searchQuery: string;
|
||||
onPageChange: (page: number) => void;
|
||||
onRowClick?: (member: MemberRow) => void;
|
||||
onSortChange?: (
|
||||
sorter: SorterResult<MemberRow> | SorterResult<MemberRow>[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
function NameEmailCell({
|
||||
name,
|
||||
email,
|
||||
}: {
|
||||
name?: string;
|
||||
email: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="member-name-email-cell">
|
||||
{name && (
|
||||
<span className="member-name" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
<Tooltip title={email} overlayClassName="member-tooltip">
|
||||
<span className="member-email">{email}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: MemberRow['status'] }): JSX.Element {
|
||||
if (status === MemberStatus.Active) {
|
||||
return (
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge color="amber" variant="outline">
|
||||
INVITED
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersEmptyState({
|
||||
searchQuery,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="members-empty-state">
|
||||
<span
|
||||
className="members-empty-state__emoji"
|
||||
role="img"
|
||||
aria-label="monocle face"
|
||||
>
|
||||
🧐
|
||||
</span>
|
||||
{searchQuery ? (
|
||||
<p className="members-empty-state__text">
|
||||
No results for <strong>{searchQuery}</strong>
|
||||
</p>
|
||||
) : (
|
||||
<p className="members-empty-state__text">No members found</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersTable({
|
||||
data,
|
||||
loading,
|
||||
total,
|
||||
currentPage,
|
||||
pageSize,
|
||||
searchQuery,
|
||||
onPageChange,
|
||||
onRowClick,
|
||||
onSortChange,
|
||||
}: MembersTableProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const formatJoinedOn = (date: string | null): string => {
|
||||
if (!date) {
|
||||
return '—';
|
||||
}
|
||||
const d = new Date(date);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.DASH_DATETIME);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<MemberRow> = [
|
||||
{
|
||||
title: 'Name / Email',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
sorter: (a, b): number => a.email.localeCompare(b.email),
|
||||
render: (_, record): JSX.Element => (
|
||||
<NameEmailCell name={record.name} email={record.email} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Roles',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
width: 180,
|
||||
sorter: (a, b): number => a.role.localeCompare(b.role),
|
||||
render: (role: ROLES): JSX.Element => (
|
||||
<Badge color="vanilla">{capitalize(role)}</Badge>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
className: 'member-status-cell',
|
||||
sorter: (a, b): number => a.status.localeCompare(b.status),
|
||||
render: (status: MemberRow['status']): JSX.Element => (
|
||||
<StatusBadge status={status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Joined On',
|
||||
dataIndex: 'joinedOn',
|
||||
key: 'joinedOn',
|
||||
width: 250,
|
||||
align: 'right' as const,
|
||||
sorter: (a, b): number => {
|
||||
if (!a.joinedOn && !b.joinedOn) {
|
||||
return 0;
|
||||
}
|
||||
if (!a.joinedOn) {
|
||||
return 1;
|
||||
}
|
||||
if (!b.joinedOn) {
|
||||
return -1;
|
||||
}
|
||||
return new Date(a.joinedOn).getTime() - new Date(b.joinedOn).getTime();
|
||||
},
|
||||
render: (joinedOn: string | null): JSX.Element => {
|
||||
const formatted = formatJoinedOn(joinedOn);
|
||||
const isDash = formatted === '—';
|
||||
return (
|
||||
<span className={isDash ? 'member-joined-dash' : 'member-joined-date'}>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const showPaginationTotal = (_total: number, range: number[]): JSX.Element => (
|
||||
<>
|
||||
<span className="members-pagination-range">
|
||||
{range[0]} — {range[1]}
|
||||
</span>
|
||||
<span className="members-pagination-total"> of {_total}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="members-table-wrapper">
|
||||
<Table<MemberRow>
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'members-table-row--tinted' : ''
|
||||
}
|
||||
onRow={(record): React.HTMLAttributes<HTMLElement> => ({
|
||||
onClick: (): void => onRowClick?.(record),
|
||||
style: onRowClick ? { cursor: 'pointer' } : undefined,
|
||||
})}
|
||||
onChange={(_, __, sorter): void => {
|
||||
if (onSortChange) {
|
||||
onSortChange(
|
||||
sorter as SorterResult<MemberRow> | SorterResult<MemberRow>[],
|
||||
);
|
||||
}
|
||||
}}
|
||||
showSorterTooltip={false}
|
||||
locale={{
|
||||
emptyText: <MembersEmptyState searchQuery={searchQuery} />,
|
||||
}}
|
||||
className="members-table"
|
||||
/>
|
||||
{total > pageSize && (
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showTotal={showPaginationTotal}
|
||||
showSizeChanger={false}
|
||||
onChange={onPageChange}
|
||||
className="members-table-pagination"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MembersTable;
|
||||
@@ -0,0 +1,143 @@
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import MembersTable, { MemberRow } from '../MembersTable';
|
||||
|
||||
const mockActiveMembers: MemberRow[] = [
|
||||
{
|
||||
id: 'user-1',
|
||||
name: 'Alice Smith',
|
||||
email: 'alice@signoz.io',
|
||||
role: 'ADMIN' as ROLES,
|
||||
status: MemberStatus.Active,
|
||||
joinedOn: '1700000000000',
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
name: 'Bob Jones',
|
||||
email: 'bob@signoz.io',
|
||||
role: 'VIEWER' as ROLES,
|
||||
status: MemberStatus.Active,
|
||||
joinedOn: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockInvitedMember: MemberRow = {
|
||||
id: 'invite-abc',
|
||||
name: '',
|
||||
email: 'charlie@signoz.io',
|
||||
role: 'EDITOR' as ROLES,
|
||||
status: MemberStatus.Invited,
|
||||
joinedOn: null,
|
||||
token: 'tok-123',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
loading: false,
|
||||
total: 2,
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
searchQuery: '',
|
||||
onPageChange: jest.fn(),
|
||||
onRowClick: jest.fn(),
|
||||
};
|
||||
|
||||
describe('MembersTable', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders member rows with name, email, role badge, and ACTIVE status', () => {
|
||||
render(<MembersTable {...defaultProps} data={mockActiveMembers} />);
|
||||
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('ACTIVE')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders INVITED badge for pending invite members', () => {
|
||||
render(
|
||||
<MembersTable
|
||||
{...defaultProps}
|
||||
data={[...mockActiveMembers, mockInvitedMember]}
|
||||
total={3}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('INVITED')).toBeInTheDocument();
|
||||
expect(screen.getByText('charlie@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByText('Editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRowClick with the member data when a row is clicked', async () => {
|
||||
const onRowClick = jest.fn() as jest.MockedFunction<
|
||||
(member: MemberRow) => void
|
||||
>;
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<MembersTable
|
||||
{...defaultProps}
|
||||
data={mockActiveMembers}
|
||||
onRowClick={onRowClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Alice Smith'));
|
||||
|
||||
expect(onRowClick).toHaveBeenCalledTimes(1);
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'user-1', email: 'alice@signoz.io' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "No members found" empty state when no data and no search query', () => {
|
||||
render(<MembersTable {...defaultProps} data={[]} total={0} searchQuery="" />);
|
||||
|
||||
expect(screen.getByText('No members found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "No results for X" when no data and a search query is set', () => {
|
||||
render(
|
||||
<MembersTable {...defaultProps} data={[]} total={0} searchQuery="unknown" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/No results for/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('unknown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides pagination when total does not exceed pageSize', () => {
|
||||
const { container } = render(
|
||||
<MembersTable
|
||||
{...defaultProps}
|
||||
data={mockActiveMembers}
|
||||
total={2}
|
||||
pageSize={20}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector('.members-table-pagination'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows pagination when total exceeds pageSize', () => {
|
||||
const { container } = render(
|
||||
<MembersTable
|
||||
{...defaultProps}
|
||||
data={mockActiveMembers}
|
||||
total={25}
|
||||
pageSize={20}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector('.members-table-pagination'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector('.members-pagination-total'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Tooltip } from 'antd';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import './MQCommon.styles.scss';
|
||||
|
||||
@@ -14,6 +14,7 @@ import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import HavingFilter from './HavingFilter/HavingFilter';
|
||||
import { buildDefaultLegendFromGroupBy } from './utils';
|
||||
|
||||
import './QueryAddOns.styles.scss';
|
||||
|
||||
@@ -250,12 +251,33 @@ function QueryAddOns({
|
||||
}, [panelType, isListViewPanel, query, showReduceTo]);
|
||||
|
||||
const handleOptionClick = (e: RadioChangeEvent): void => {
|
||||
if (selectedViews.find((view) => view.key === e.target.value.key)) {
|
||||
setSelectedViews(
|
||||
selectedViews.filter((view) => view.key !== e.target.value.key),
|
||||
const clickedAddOn = e.target.value as AddOn;
|
||||
const isAlreadySelected = selectedViews.some(
|
||||
(view) => view.key === clickedAddOn.key,
|
||||
);
|
||||
|
||||
if (isAlreadySelected) {
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) => view.key !== clickedAddOn.key),
|
||||
);
|
||||
} else {
|
||||
setSelectedViews([...selectedViews, e.target.value]);
|
||||
// When enabling Legend format for the first time with an empty legend
|
||||
// and existing group-by keys, prefill the legend using all group-by keys.
|
||||
// This keeps existing custom legends intact and only helps seed a sensible default.
|
||||
if (
|
||||
clickedAddOn.key === ADD_ONS_KEYS.LEGEND_FORMAT &&
|
||||
isEmpty(query?.legend) &&
|
||||
Array.isArray(query.groupBy) &&
|
||||
query.groupBy.length > 0
|
||||
) {
|
||||
const defaultLegend = buildDefaultLegendFromGroupBy(query.groupBy);
|
||||
|
||||
if (defaultLegend) {
|
||||
handleChangeQueryLegend(defaultLegend);
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedViews((prev) => [...prev, clickedAddOn]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -288,12 +310,9 @@ function QueryAddOns({
|
||||
[handleSetQueryData, index, query],
|
||||
);
|
||||
|
||||
const handleRemoveView = useCallback(
|
||||
(key: string): void => {
|
||||
setSelectedViews(selectedViews.filter((view) => view.key !== key));
|
||||
},
|
||||
[selectedViews],
|
||||
);
|
||||
const handleRemoveView = useCallback((key: string): void => {
|
||||
setSelectedViews((prev) => prev.filter((view) => view.key !== key));
|
||||
}, []);
|
||||
|
||||
const handleChangeQueryLegend = useCallback(
|
||||
(value: string) => {
|
||||
@@ -379,8 +398,8 @@ function QueryAddOns({
|
||||
<div className="input">
|
||||
<HavingFilter
|
||||
onClose={(): void => {
|
||||
setSelectedViews(
|
||||
selectedViews.filter((view) => view.key !== 'having'),
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) => view.key !== 'having'),
|
||||
);
|
||||
}}
|
||||
onChange={handleChangeHaving}
|
||||
@@ -399,7 +418,9 @@ function QueryAddOns({
|
||||
initialValue={query?.limit ?? undefined}
|
||||
placeholder="Enter limit"
|
||||
onClose={(): void => {
|
||||
setSelectedViews(selectedViews.filter((view) => view.key !== 'limit'));
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) => view.key !== 'limit'),
|
||||
);
|
||||
}}
|
||||
closeIcon={<ChevronUp size={16} />}
|
||||
/>
|
||||
@@ -482,8 +503,8 @@ function QueryAddOns({
|
||||
onChange={handleChangeQueryLegend}
|
||||
initialValue={isEmpty(query?.legend) ? undefined : query?.legend}
|
||||
onClose={(): void => {
|
||||
setSelectedViews(
|
||||
selectedViews.filter((view) => view.key !== 'legend_format'),
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) => view.key !== 'legend_format'),
|
||||
);
|
||||
}}
|
||||
closeIcon={<ChevronUp size={16} />}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const buildDefaultLegendFromGroupBy = (
|
||||
groupBy: IBuilderQuery['groupBy'],
|
||||
): string | null => {
|
||||
const segments = groupBy
|
||||
.map((item) => item?.key)
|
||||
.filter((key): key is string => Boolean(key))
|
||||
.map((key) => `${key} = {{${key}}}`);
|
||||
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return segments.join(', ');
|
||||
};
|
||||
@@ -275,4 +275,59 @@ describe('QueryAddOns', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-generates legend from all groupBy keys when enabling Legend format with empty legend', async () => {
|
||||
const user = userEvent.setup();
|
||||
const query = baseQuery({
|
||||
groupBy: [{ key: 'service.name' }, { key: 'operation' }],
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const legendTab = screen.getByTestId('query-add-on-legend_format');
|
||||
await user.click(legendTab);
|
||||
|
||||
expect(mockHandleChangeQueryData).toHaveBeenCalledWith(
|
||||
'legend',
|
||||
'service.name = {{service.name}}, operation = {{operation}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not override existing legend when enabling Legend format', async () => {
|
||||
const user = userEvent.setup();
|
||||
const query = baseQuery({
|
||||
legend: 'existing legend',
|
||||
groupBy: [{ key: 'service.name' }],
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const legendTab = screen.getByTestId('query-add-on-legend_format');
|
||||
await user.click(legendTab);
|
||||
|
||||
expect(mockHandleChangeQueryData).not.toHaveBeenCalledWith(
|
||||
'legend',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import type {
|
||||
TableColumnGroupType as ColumnGroupType,
|
||||
TableColumnsType as ColumnsType,
|
||||
TableColumnType as ColumnType,
|
||||
} from 'antd';
|
||||
import { Button, Dropdown, Flex, MenuProps, Switch } from 'antd';
|
||||
import { ColumnGroupType, ColumnType } from 'antd/es/table';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
@@ -9,13 +9,11 @@ import {
|
||||
import ReactDragListView from 'react-drag-listview';
|
||||
import { ResizeCallbackData } from 'react-resizable';
|
||||
import { Table } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import type { ColumnsType } from 'antd/lib/table';
|
||||
import cx from 'classnames';
|
||||
import { dragColumnParams } from 'hooks/useDragColumns/configs';
|
||||
import { getColumnWidth, RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { debounce, set } from 'lodash-es';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
|
||||
import ResizableHeader from './ResizableHeader';
|
||||
import { DragSpanStyle } from './styles';
|
||||
@@ -26,31 +24,26 @@ function ResizeTable({
|
||||
columns,
|
||||
onDragColumn,
|
||||
pagination,
|
||||
widgetId,
|
||||
shouldPersistColumnWidths = false,
|
||||
columnWidths,
|
||||
onColumnWidthsChange,
|
||||
...restProps
|
||||
}: ResizeTableProps): JSX.Element {
|
||||
const [columnsData, setColumns] = useState<ColumnsType>([]);
|
||||
const { setColumnWidths, selectedDashboard } = useDashboard();
|
||||
|
||||
const columnWidths = shouldPersistColumnWidths
|
||||
? (selectedDashboard?.data?.widgets?.find(
|
||||
(widget) => widget.id === widgetId,
|
||||
) as Widgets)?.columnWidths
|
||||
: undefined;
|
||||
const onColumnWidthsChangeRef = useRef(onColumnWidthsChange);
|
||||
|
||||
const updateAllColumnWidths = useRef(
|
||||
debounce((widthsConfig: Record<string, number>) => {
|
||||
if (!widgetId || !shouldPersistColumnWidths) {
|
||||
if (!onColumnWidthsChangeRef.current) {
|
||||
return;
|
||||
}
|
||||
setColumnWidths?.((prev) => ({
|
||||
...prev,
|
||||
[widgetId]: widthsConfig,
|
||||
}));
|
||||
onColumnWidthsChangeRef.current(widthsConfig);
|
||||
}, 1000),
|
||||
).current;
|
||||
|
||||
useEffect(() => {
|
||||
onColumnWidthsChangeRef.current = onColumnWidthsChange;
|
||||
}, [onColumnWidthsChange]);
|
||||
|
||||
const handleResize = useCallback(
|
||||
(index: number) => (
|
||||
e: SyntheticEvent<Element>,
|
||||
@@ -75,7 +68,7 @@ function ResizeTable({
|
||||
...col,
|
||||
...(onDragColumn && {
|
||||
title: (
|
||||
<DragSpanStyle className="dragHandler">
|
||||
<DragSpanStyle className="dragHandler" data-testid="drag-column-title">
|
||||
{col?.title?.toString() || ''}
|
||||
</DragSpanStyle>
|
||||
),
|
||||
@@ -106,31 +99,31 @@ function ResizeTable({
|
||||
}, [mergedColumns, pagination, restProps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (columns) {
|
||||
// Apply stored column widths from widget configuration
|
||||
const columnsWithStoredWidths = columns.map((col) => {
|
||||
const dataIndex = (col as RowData).dataIndex as string;
|
||||
if (dataIndex && columnWidths) {
|
||||
const width = getColumnWidth(dataIndex, columnWidths);
|
||||
if (width) {
|
||||
return {
|
||||
...col,
|
||||
width, // Apply stored width
|
||||
};
|
||||
}
|
||||
}
|
||||
return col;
|
||||
});
|
||||
|
||||
setColumns(columnsWithStoredWidths);
|
||||
}
|
||||
}, [columns, columnWidths]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldPersistColumnWidths) {
|
||||
if (!columns) {
|
||||
return;
|
||||
}
|
||||
// Collect all column widths in a single object
|
||||
|
||||
const columnsWithStoredWidths = columns.map((col) => {
|
||||
const dataIndex = (col as RowData).dataIndex as string;
|
||||
if (dataIndex && columnWidths) {
|
||||
const width = getColumnWidth(dataIndex, columnWidths);
|
||||
if (width) {
|
||||
return { ...col, width };
|
||||
}
|
||||
}
|
||||
return col;
|
||||
});
|
||||
|
||||
setColumns(columnsWithStoredWidths);
|
||||
}, [columns, columnWidths]);
|
||||
|
||||
const lastReportedWidthsRef = useRef<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!onColumnWidthsChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newColumnWidths: Record<string, number> = {};
|
||||
|
||||
mergedColumns.forEach((col) => {
|
||||
@@ -140,11 +133,20 @@ function ResizeTable({
|
||||
}
|
||||
});
|
||||
|
||||
// Only update if there are actual widths to set
|
||||
if (Object.keys(newColumnWidths).length > 0) {
|
||||
if (Object.keys(newColumnWidths).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const last = lastReportedWidthsRef.current;
|
||||
const hasChange =
|
||||
Object.keys(newColumnWidths).length !== Object.keys(last).length ||
|
||||
Object.keys(newColumnWidths).some((k) => newColumnWidths[k] !== last[k]);
|
||||
|
||||
if (hasChange) {
|
||||
lastReportedWidthsRef.current = newColumnWidths;
|
||||
updateAllColumnWidths(newColumnWidths);
|
||||
}
|
||||
}, [mergedColumns, updateAllColumnWidths, shouldPersistColumnWidths]);
|
||||
}, [mergedColumns, updateAllColumnWidths, onColumnWidthsChange]);
|
||||
|
||||
return onDragColumn ? (
|
||||
<ReactDragListView.DragColumn {...dragColumnParams} onDragEnd={onDragColumn}>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { act } from '@testing-library/react';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import ResizeTable from '../ResizeTable';
|
||||
|
||||
jest.mock('react-resizable', () => ({
|
||||
Resizable: ({
|
||||
children,
|
||||
onResize,
|
||||
width,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onResize: (
|
||||
e: React.SyntheticEvent,
|
||||
data: { size: { width: number } },
|
||||
) => void;
|
||||
width: number;
|
||||
}): JSX.Element => (
|
||||
<div>
|
||||
{children}
|
||||
<button
|
||||
data-testid="resize-trigger"
|
||||
type="button"
|
||||
onClick={(e): void => onResize(e, { size: { width: width + 50 } })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Make debounce synchronous so onColumnWidthsChange fires immediately
|
||||
jest.mock('lodash-es', () => ({
|
||||
...jest.requireActual('lodash-es'),
|
||||
debounce: (fn: (...args: any[]) => any): ((...args: any[]) => any) => fn,
|
||||
}));
|
||||
|
||||
const baseColumns = [
|
||||
{ dataIndex: 'name', title: 'Name', width: 100 },
|
||||
{ dataIndex: 'value', title: 'Value', width: 100 },
|
||||
];
|
||||
|
||||
const baseDataSource = [
|
||||
{ key: '1', name: 'Alice', value: 42 },
|
||||
{ key: '2', name: 'Bob', value: 99 },
|
||||
];
|
||||
|
||||
describe('ResizeTable', () => {
|
||||
it('renders column headers and data rows', () => {
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Value')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('overrides column widths from columnWidths prop and reports them via onColumnWidthsChange', () => {
|
||||
const onColumnWidthsChange = jest.fn();
|
||||
|
||||
act(() => {
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
columnWidths={{ name: 250, value: 180 }}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onColumnWidthsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 250, value: 180 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports original column widths via onColumnWidthsChange when columnWidths prop is not provided', () => {
|
||||
const onColumnWidthsChange = jest.fn();
|
||||
|
||||
act(() => {
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onColumnWidthsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 100, value: 100 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call onColumnWidthsChange when it is not provided', () => {
|
||||
// Should render without errors and without attempting to call an undefined callback
|
||||
expect(() => {
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('only overrides the column that has a stored width, leaving others at their original width', () => {
|
||||
const onColumnWidthsChange = jest.fn();
|
||||
|
||||
act(() => {
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
columnWidths={{ name: 250 }}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onColumnWidthsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 250, value: 100 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call onColumnWidthsChange on re-render when widths have not changed', () => {
|
||||
const onColumnWidthsChange = jest.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onColumnWidthsChange).toHaveBeenCalledTimes(1);
|
||||
onColumnWidthsChange.mockClear();
|
||||
|
||||
rerender(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onColumnWidthsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onColumnWidthsChange when no column has a defined width', () => {
|
||||
const onColumnWidthsChange = jest.fn();
|
||||
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={[
|
||||
{ dataIndex: 'name', title: 'Name' },
|
||||
{ dataIndex: 'value', title: 'Value' },
|
||||
]}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onColumnWidthsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onColumnWidthsChange with the new width after a column is resized', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onColumnWidthsChange = jest.fn();
|
||||
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
onColumnWidthsChange.mockClear();
|
||||
|
||||
// Click the first column's resize trigger — mock adds 50px to the current width (100 → 150)
|
||||
const [firstResizeTrigger] = screen.getAllByTestId('resize-trigger');
|
||||
await user.click(firstResizeTrigger);
|
||||
|
||||
expect(onColumnWidthsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 150, value: 100 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not affect other columns when only one column is resized', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onColumnWidthsChange = jest.fn();
|
||||
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
onColumnWidthsChange.mockClear();
|
||||
|
||||
// Resize only the second column (value: 100 → 150), name should stay at 100
|
||||
const resizeTriggers = screen.getAllByTestId('resize-trigger');
|
||||
await user.click(resizeTriggers[1]);
|
||||
|
||||
expect(onColumnWidthsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 100, value: 150 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps column titles in drag handler spans when onDragColumn is provided', () => {
|
||||
const onDragColumn = jest.fn();
|
||||
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
onDragColumn={onDragColumn}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dragTitles = screen.getAllByTestId('drag-column-title');
|
||||
expect(dragTitles).toHaveLength(baseColumns.length);
|
||||
expect(dragTitles[0]).toHaveTextContent('Name');
|
||||
expect(dragTitles[1]).toHaveTextContent('Value');
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,30 @@
|
||||
import { TableProps } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import { PaginationProps } from 'antd/lib';
|
||||
import { ColumnGroupType, ColumnType } from 'antd/lib/table';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { PaginationProps } from 'antd/lib';
|
||||
import type { ColumnGroupType, ColumnType } from 'antd/lib/table';
|
||||
import { LaunchChatSupportProps } from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
|
||||
import { TableDataSource } from './contants';
|
||||
|
||||
type ColumnWidths = Record<string, number>;
|
||||
|
||||
export interface ResizeTableProps extends TableProps<any> {
|
||||
onDragColumn?: (fromIndex: number, toIndex: number) => void;
|
||||
widgetId?: string;
|
||||
shouldPersistColumnWidths?: boolean;
|
||||
/**
|
||||
* Pre-resolved column widths for this table, keyed by column dataIndex.
|
||||
* Use this to apply persisted widths on mount (e.g. from widget.columnWidths).
|
||||
* Do NOT pass a value that updates reactively on every resize — that creates a
|
||||
* feedback loop. Pass only stable / persisted values.
|
||||
*/
|
||||
columnWidths?: ColumnWidths;
|
||||
/**
|
||||
* Called (debounced) whenever the user finishes resizing a column.
|
||||
* The widths object contains all current column widths keyed by dataIndex.
|
||||
* Intended for persisting widths to an external store (e.g. dashboard context
|
||||
* staging buffer). The caller owns the storage; ResizeTable does not read back
|
||||
* whatever is written here.
|
||||
*/
|
||||
onColumnWidthsChange?: (widths: ColumnWidths) => void;
|
||||
}
|
||||
export interface DynamicColumnTableProps extends TableProps<any> {
|
||||
tablesource: typeof TableDataSource[keyof typeof TableDataSource];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Radio } from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/es/radio';
|
||||
import { Radio, RadioChangeEvent } from 'antd';
|
||||
|
||||
import './SignozRadioGroup.styles.scss';
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
TabsProps,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { TextProps } from 'antd/lib/typography/Text';
|
||||
import { TitleProps } from 'antd/lib/typography/Title';
|
||||
import type { TextProps } from 'antd/lib/typography/Text';
|
||||
import type { TitleProps } from 'antd/lib/typography/Title';
|
||||
import styled, { FlattenSimpleInterpolation } from 'styled-components';
|
||||
|
||||
import { IStyledClass } from './types';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ColumnType } from 'antd/es/table';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import type {
|
||||
TableColumnsType as ColumnsType,
|
||||
TableColumnType as ColumnType,
|
||||
} from 'antd';
|
||||
|
||||
export const generatorResizeTableColumns = <T>({
|
||||
baseColumnOptions,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Radio } from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/lib';
|
||||
import type { RadioChangeEvent } from 'antd/lib';
|
||||
import { History, Table } from 'lucide-react';
|
||||
|
||||
import { ALERT_TABS } from '../constants';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Button } from 'antd';
|
||||
import { Tag } from 'antd/lib';
|
||||
import { Button, Tag } from 'antd';
|
||||
import Input from 'components/Input';
|
||||
import { Check, X } from 'lucide-react';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { WarningFilled } from '@ant-design/icons';
|
||||
import { Select, Tooltip } from 'antd';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { UniversalYAxisUnitMappings } from './constants';
|
||||
|
||||
@@ -38,7 +38,6 @@ const ROUTES = {
|
||||
SETTINGS: '/settings',
|
||||
MY_SETTINGS: '/settings/my-settings',
|
||||
ORG_SETTINGS: '/settings/org-settings',
|
||||
CUSTOM_DOMAIN_SETTINGS: '/settings/custom-domain-settings',
|
||||
API_KEYS: '/settings/api-keys',
|
||||
INGESTION_SETTINGS: '/settings/ingestion-settings',
|
||||
SOMETHING_WENT_WRONG: '/something-went-wrong',
|
||||
@@ -56,6 +55,8 @@ const ROUTES = {
|
||||
TRACE_EXPLORER: '/trace-explorer',
|
||||
BILLING: '/settings/billing',
|
||||
ROLES_SETTINGS: '/settings/roles',
|
||||
ROLE_DETAILS: '/settings/roles/:roleId',
|
||||
MEMBERS_SETTINGS: '/settings/members',
|
||||
SUPPORT: '/support',
|
||||
LOGS_SAVE_VIEWS: '/logs/saved-views',
|
||||
TRACES_SAVE_VIEWS: '/traces/saved-views',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Button,
|
||||
Col,
|
||||
Collapse,
|
||||
CollapseProps,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
@@ -20,8 +21,7 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import { CollapseProps } from 'antd/lib';
|
||||
import type { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import createAPIKeyApi from 'api/v1/pats/create';
|
||||
import deleteAPIKeyApi from 'api/v1/pats/delete';
|
||||
import updateAPIKeyApi from 'api/v1/pats/update';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Progress, Table } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import { Progress, Table, TableColumnsType as ColumnsType } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover';
|
||||
import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisOutlined } from '@ant-design/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import { Button, TableColumnsType as ColumnsType } from 'antd';
|
||||
import ClientSideQBSearch, {
|
||||
AttributeKey,
|
||||
} from 'components/ClientSideQBSearch/ClientSideQBSearch';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Button } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import type { ColumnsType } from 'antd/lib/table';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from 'antd';
|
||||
import { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import type { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import deleteChannel from 'api/channels/delete';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { ColumnType, TablePaginationConfig } from 'antd/es/table';
|
||||
import { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import type { ColumnType, TablePaginationConfig } from 'antd/es/table';
|
||||
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||
import type { ColumnsType } from 'antd/lib/table';
|
||||
import { FilterConfirmProps } from 'antd/lib/table/interface';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import getAll from 'api/errors/getAll';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FilterDropdownProps } from 'antd/es/table/interface';
|
||||
import type { FilterDropdownProps } from 'antd/es/table/interface';
|
||||
|
||||
export interface FilterDropdownExtendsProps {
|
||||
placeholder: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FilterValue, SortOrder } from 'antd/lib/table/interface';
|
||||
import type { FilterValue, SortOrder } from 'antd/lib/table/interface';
|
||||
import Timestamp from 'timestamp-nano';
|
||||
import { Order, OrderBy } from 'types/api/errors/getAll';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import { Button, Divider, Drawer, Radio, Typography } from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/lib';
|
||||
import type { RadioChangeEvent } from 'antd/lib';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Table, TablePaginationConfig, Typography } from 'antd';
|
||||
import Skeleton from 'antd/lib/skeleton';
|
||||
import { Skeleton, Table, TablePaginationConfig, Typography } from 'antd';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
dependentServicesColumns,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQueries } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Spin, Table } from 'antd';
|
||||
import { ColumnType } from 'antd/lib/table';
|
||||
import type { ColumnType } from 'antd/lib/table';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
@@ -69,9 +69,6 @@ jest.mock('antd', () => {
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
Skeleton: jest
|
||||
.fn()
|
||||
.mockImplementation(() => <div data-testid="skeleton-mock" />),
|
||||
Typography: {
|
||||
Text: jest
|
||||
.fn()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Progress, Tag, Tooltip } from 'antd';
|
||||
import { ColumnType } from 'antd/es/table';
|
||||
import { Progress, TableColumnType as ColumnType, Tag, Tooltip } from 'antd';
|
||||
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
FiltersType,
|
||||
|
||||
@@ -249,7 +249,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
clearInterval(timer);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
Row,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableColumnsType as ColumnsType,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import getUsage, { UsageResponsePayloadProps } from 'api/billing/getUsage';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateCreditCardApi from 'api/v1/checkout/create';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import Breadcrumb from 'antd/es/breadcrumb';
|
||||
import { Breadcrumb } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Blocks, LifeBuoy } from 'lucide-react';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Select, Skeleton } from 'antd';
|
||||
import { SelectProps } from 'antd/lib';
|
||||
import type { SelectProps } from 'antd/lib';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useAwsAccounts } from 'hooks/integration/aws/useAwsAccounts';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
import * as appHooks from 'providers/App/App';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Flex, Switch, Typography } from 'antd';
|
||||
import { BaseOptionType, DefaultOptionType, SelectProps } from 'antd/es/select';
|
||||
import { Button, Flex, SelectProps, Switch, Typography } from 'antd';
|
||||
import type { BaseOptionType, DefaultOptionType } from 'antd/es/select';
|
||||
import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { getYAxisCategories } from 'components/YAxisUnitSelector/utils';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, DatePicker, Select, Typography } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import { Button, DatePicker, Input, Select, Typography } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { useCreateAlertState } from 'container/CreateAlertV2/context';
|
||||
import { AdvancedOptionsState } from 'container/CreateAlertV2/context/types';
|
||||
@@ -206,7 +205,7 @@ function EvaluationCadenceDetails({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<TextArea
|
||||
<Input.TextArea
|
||||
value={evaluationCadence.rrule.rrule}
|
||||
placeholder="Enter RRule"
|
||||
onChange={(value): void =>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import { Input, Tooltip, Typography } from 'antd';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
@@ -76,7 +75,7 @@ function NotificationMessage(): JSX.Element {
|
||||
</Popover> */}
|
||||
</div>
|
||||
</div>
|
||||
<TextArea
|
||||
<Input.TextArea
|
||||
value={notificationSettings.description}
|
||||
onChange={(e): void =>
|
||||
setNotificationSettings({
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { DialogWrapper } from '@signozhq/dialog';
|
||||
import { CircleAlert, CircleCheck, LoaderCircle } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
|
||||
interface CustomDomainEditModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
customDomainSubdomain?: string;
|
||||
dnsSuffix: string;
|
||||
isLoading: boolean;
|
||||
updateDomainError: AxiosError<RenderErrorResponseDTO> | null;
|
||||
onClearError: () => void;
|
||||
onSubmit: (subdomain: string) => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function CustomDomainEditModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
customDomainSubdomain,
|
||||
dnsSuffix,
|
||||
isLoading,
|
||||
updateDomainError,
|
||||
onClearError,
|
||||
onSubmit,
|
||||
}: CustomDomainEditModalProps): JSX.Element {
|
||||
const [value, setValue] = useState(customDomainSubdomain ?? '');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setValue(customDomainSubdomain ?? '');
|
||||
}
|
||||
}, [isOpen, customDomainSubdomain]);
|
||||
|
||||
const handleClose = (): void => {
|
||||
setValidationError(null);
|
||||
onClearError();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setValue(e.target.value);
|
||||
setValidationError(null);
|
||||
onClearError();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
if (!value) {
|
||||
setValidationError('This field is required');
|
||||
return;
|
||||
}
|
||||
if (value.length < 3) {
|
||||
setValidationError('Minimum 3 characters required');
|
||||
return;
|
||||
}
|
||||
onSubmit(value);
|
||||
};
|
||||
|
||||
const is409 = updateDomainError?.status === 409;
|
||||
|
||||
const apiErrorMessage =
|
||||
(updateDomainError?.response?.data as RenderErrorResponseDTO)?.error
|
||||
?.message ?? null;
|
||||
|
||||
const errorMessage =
|
||||
validationError ??
|
||||
(is409
|
||||
? apiErrorMessage ??
|
||||
"You've already updated the custom domain once today. Please contact support."
|
||||
: apiErrorMessage);
|
||||
|
||||
const hasError = Boolean(errorMessage);
|
||||
|
||||
const statusIcon = ((): JSX.Element => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<LoaderCircle size={16} className="animate-spin edit-modal-status-icon" />
|
||||
);
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return <CircleAlert size={16} color={Color.BG_CHERRY_500} />;
|
||||
}
|
||||
|
||||
return <CircleCheck size={16} color={Color.BG_FOREST_500} />;
|
||||
})();
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
className="edit-workspace-modal"
|
||||
title="Edit Workspace Link"
|
||||
open={isOpen}
|
||||
onOpenChange={(open: boolean): void => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
width="base"
|
||||
>
|
||||
<div className="edit-workspace-modal-content">
|
||||
<p className="edit-modal-description">
|
||||
Enter your preferred subdomain to create a unique URL for your team. Need
|
||||
help?{' '}
|
||||
<a
|
||||
href="https://signoz.io/support"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="edit-modal-link"
|
||||
>
|
||||
Contact support.
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div className="edit-modal-field">
|
||||
<label
|
||||
htmlFor="workspace-url-input"
|
||||
className={`edit-modal-label${
|
||||
hasError ? ' edit-modal-label--error' : ''
|
||||
}`}
|
||||
>
|
||||
Workspace URL
|
||||
</label>
|
||||
|
||||
<div
|
||||
className={`edit-modal-input-wrapper${
|
||||
hasError ? ' edit-modal-input-wrapper--error' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="edit-modal-input-field">
|
||||
{statusIcon}
|
||||
<Input
|
||||
id="workspace-url-input"
|
||||
aria-describedby="workspace-url-helper"
|
||||
aria-invalid={hasError}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="edit-modal-input-suffix">{dnsSuffix}</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
id="workspace-url-helper"
|
||||
className={`edit-modal-helper${
|
||||
hasError ? ' edit-modal-helper--error' : ''
|
||||
}`}
|
||||
>
|
||||
{hasError
|
||||
? errorMessage
|
||||
: "To help you easily explore SigNoz, we've selected a tenant sub domain name for you."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="edit-modal-note">
|
||||
<span className="edit-modal-note-emoji">🚧</span>
|
||||
<span className="edit-modal-note-text">
|
||||
Note that your previous URL still remains accessible. Your access
|
||||
credentials for the new URL remain the same.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="edit-modal-footer">
|
||||
{is409 ? (
|
||||
<LaunchChatSupport
|
||||
attributes={{ screen: 'Custom Domain Settings' }}
|
||||
eventName="Custom Domain Settings: Facing Issues Updating Custom Domain"
|
||||
message="Hi Team, I need help with updating custom domain"
|
||||
buttonText="Contact Support"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="md"
|
||||
color="primary"
|
||||
className="edit-modal-apply-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
Apply Changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,262 +1,460 @@
|
||||
.custom-domain-settings-container {
|
||||
margin-top: 24px;
|
||||
.beacon {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 1px;
|
||||
border-radius: 50%;
|
||||
background: rgba(78, 116, 248, 0.2);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 5px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-card {
|
||||
width: 100%;
|
||||
max-width: 768px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
overflow: hidden;
|
||||
|
||||
&--loading {
|
||||
padding: var(--padding-3);
|
||||
}
|
||||
|
||||
.custom-domain-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: var(--padding-3);
|
||||
gap: var(--spacing-6);
|
||||
|
||||
.custom-domain-edit-button {
|
||||
border: 1px solid var(--l3-border);
|
||||
background: var(--l3-background);
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.custom-domain-card-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.custom-domain-card-org-name {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
letter-spacing: var(--paragraph-base-500-letter-spacing);
|
||||
}
|
||||
|
||||
.custom-domain-card-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-10);
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
.custom-domain-card-meta-timezone {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
color: var(--l1-foreground);
|
||||
font-variant-numeric: lining-nums tabular-nums slashed-zero;
|
||||
font-feature-settings: 'dlig' on, 'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: var(--paragraph-small-400-font-size);
|
||||
font-style: normal;
|
||||
font-weight: var(--paragraph-small-400-font-weight);
|
||||
line-height: var(--paragraph-small-400-line-height);
|
||||
letter-spacing: var(--paragraph-small-400-letter-spacing);
|
||||
text-transform: uppercase;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-callout {
|
||||
margin: 0 var(--margin-3) var(--margin-3);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
max-width: 742px;
|
||||
--callout-background: var(--primary);
|
||||
--callout-border-color: var(--callout-primary-border);
|
||||
--callout-icon-color: var(--primary);
|
||||
--callout-title-color: var(--callout-primary-title);
|
||||
}
|
||||
|
||||
.custom-domain-card-divider {
|
||||
height: 1px;
|
||||
background: var(--l2-border);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-domain-card-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--padding-3);
|
||||
}
|
||||
|
||||
.custom-domain-card-license {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.custom-domain-plan-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-url-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.06px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-url-dropdown {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: var(--padding-2) 0;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-header {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
padding: 0 var(--padding-3) var(--padding-2);
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
margin-bottom: var(--margin-1);
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: 5px var(--padding-3);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--l1-background-hover);
|
||||
|
||||
.workspace-url-dropdown-item-label {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-item-external {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: var(--l1-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-item-external {
|
||||
color: var(--l2-foreground);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-item-label {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
.workspace-url-dropdown-item--active & {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-item-check {
|
||||
color: var(--l1-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-workspace-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
// Description
|
||||
.edit-modal-description {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.edit-modal-link {
|
||||
color: var(--primary);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// Input field group
|
||||
.edit-modal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.edit-modal-label {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
|
||||
&--error {
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-modal-input-wrapper {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
.edit-modal-input-field {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
height: 44px;
|
||||
padding: 6px var(--padding-3);
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-right: none;
|
||||
border-radius: 2px 0 0 2px;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
padding: 0;
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-modal-input-suffix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px var(--padding-3);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-left: none;
|
||||
border-radius: 0 2px 2px 0;
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.edit-modal-helper {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-20);
|
||||
|
||||
&--error {
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-modal-status-icon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.edit-modal-note {
|
||||
display: flex;
|
||||
gap: var(--spacing-6);
|
||||
align-items: flex-start;
|
||||
padding: var(--padding-3);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.edit-modal-note-emoji {
|
||||
font-size: 16px;
|
||||
line-height: var(--line-height-20);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.edit-modal-note-text {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.edit-modal-footer {
|
||||
.facing-issue-button {
|
||||
width: 100%;
|
||||
|
||||
.periscope-btn {
|
||||
width: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
color: var(--primary-foreground);
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
height: 36px;
|
||||
|
||||
.ant-btn-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--primary) !important;
|
||||
border: none !important;
|
||||
color: var(--primary-foreground) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-modal-apply-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-domain-settings-content {
|
||||
width: calc(100% - 30px);
|
||||
max-width: 736px;
|
||||
.custom-domain-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 0 16px;
|
||||
height: 40px;
|
||||
width: min(942px, calc(100vw - 32px));
|
||||
border-radius: 4px;
|
||||
background: var(--primary-background);
|
||||
color: var(--bg-base-white);
|
||||
|
||||
.title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 28px; /* 155.556% */
|
||||
letter-spacing: -0.09px;
|
||||
}
|
||||
.custom-domain-toast-message {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
.custom-domain-toast-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.custom-domain-toast-visit-btn {
|
||||
text-decoration: none;
|
||||
background: var(--bg-robin-600);
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-card {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-400);
|
||||
.custom-domain-toast-dismiss-btn {
|
||||
color: var(--callout-primary-icon);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
|
||||
.ant-card-body {
|
||||
padding: 12px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.custom-domain-settings-content-header {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.custom-domain-settings-content-body {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
|
||||
.custom-domain-url-edit-btn {
|
||||
.periscope-btn {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--Slate-200, #2c3140);
|
||||
background: var(--Ink-200, #23262e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-urls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.custom-domain-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
line-height: 24px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.custom-domain-update-status {
|
||||
margin-top: 12px;
|
||||
|
||||
color: var(--bg-robin-400);
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(78, 116, 248, 0.1);
|
||||
background: rgba(78, 116, 248, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-modal {
|
||||
.ant-modal-content {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: var(--bg-ink-400);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: 0;
|
||||
|
||||
.ant-modal-header {
|
||||
background: none;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
padding: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-modal-close-x {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 12px 16px;
|
||||
|
||||
.custom-domain-settings-modal-body {
|
||||
margin-bottom: 48px;
|
||||
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-modal-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
.update-limit-reached-error {
|
||||
display: flex;
|
||||
padding: 20px 24px 24px 24px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
align-self: stretch;
|
||||
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 205, 86, 0.2);
|
||||
background: rgba(255, 205, 86, 0.1);
|
||||
|
||||
color: var(--bg-amber-400);
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
|
||||
.ant-alert-message::first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-modal-footer {
|
||||
padding: 16px 0;
|
||||
margin-top: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.apply-changes-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.facing-issue-button {
|
||||
width: 100%;
|
||||
|
||||
.periscope-btn {
|
||||
width: 100%;
|
||||
|
||||
border-radius: 2px;
|
||||
background: var(--bg-robin-500);
|
||||
border: none;
|
||||
|
||||
color: var(--bg-vanilla-100);
|
||||
line-height: 20px;
|
||||
|
||||
.ant-btn-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-robin-500) !important;
|
||||
border: none !important;
|
||||
color: var(--bg-vanilla-100) !important;
|
||||
line-height: 20px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.custom-domain-settings-container {
|
||||
.custom-domain-settings-content {
|
||||
.title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-card {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
.ant-card-body {
|
||||
.custom-domain-settings-content-header {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
|
||||
.custom-domain-update-status {
|
||||
color: var(--bg-robin-400);
|
||||
border: 1px solid rgba(78, 116, 248, 0.1);
|
||||
background: rgba(78, 116, 248, 0.1);
|
||||
}
|
||||
|
||||
.custom-domain-url-edit-btn {
|
||||
.periscope-btn {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-modal {
|
||||
.ant-modal-content {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ant-modal-header {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.custom-domain-settings-modal-error {
|
||||
.update-limit-reached-error {
|
||||
border: 1px solid rgba(255, 205, 86, 0.2);
|
||||
background: rgba(255, 205, 86, 0.1);
|
||||
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background: var(--primary-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,88 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Skeleton,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
FilePenLine,
|
||||
Link2,
|
||||
SolidAlertCircle,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Dropdown, Skeleton } from 'antd';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
ZeustypesHostDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetHosts, usePutHost } from 'api/generated/services/zeus';
|
||||
import { AxiosError } from 'axios';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { InfoIcon, Link2, Pencil } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import CustomDomainEditModal from './CustomDomainEditModal';
|
||||
|
||||
import './CustomDomainSettings.styles.scss';
|
||||
|
||||
interface CustomDomainSettingsProps {
|
||||
subdomain: string;
|
||||
function DomainUpdateToast({
|
||||
toastId,
|
||||
url,
|
||||
}: {
|
||||
toastId: string | number;
|
||||
url: string;
|
||||
}): JSX.Element {
|
||||
const displayUrl = url?.split('://')[1] ?? url;
|
||||
|
||||
return (
|
||||
<div className="custom-domain-toast">
|
||||
<span className="custom-domain-toast-message">
|
||||
Your workspace URL is being updated to <strong>{displayUrl}</strong>. This
|
||||
may take a few minutes.
|
||||
</span>
|
||||
<div className="custom-domain-toast-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="custom-domain-toast-visit-btn"
|
||||
suffixIcon={<ExternalLink size={12} />}
|
||||
onClick={(): void => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
>
|
||||
Visit new URL
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="custom-domain-toast-dismiss-btn"
|
||||
onClick={(): void => {
|
||||
toast.dismiss(toastId);
|
||||
}}
|
||||
aria-label="Dismiss"
|
||||
prefixIcon={<X size={14} />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomDomainSettings(): JSX.Element {
|
||||
const { org } = useAppContext();
|
||||
const { notifications } = useNotifications();
|
||||
const { org, activeLicense } = useAppContext();
|
||||
const { timezone } = useTimezone();
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isPollingEnabled, setIsPollingEnabled] = useState(false);
|
||||
const [hosts, setHosts] = useState<ZeustypesHostDTO[] | null>(null);
|
||||
|
||||
const [updateDomainError, setUpdateDomainError] = useState<AxiosError | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [, setCopyUrl] = useCopyToClipboard();
|
||||
|
||||
const [
|
||||
customDomainDetails,
|
||||
setCustomDomainDetails,
|
||||
] = useState<CustomDomainSettingsProps | null>();
|
||||
updateDomainError,
|
||||
setUpdateDomainError,
|
||||
] = useState<AxiosError<RenderErrorResponseDTO> | null>(null);
|
||||
|
||||
const [editForm] = Form.useForm();
|
||||
|
||||
const handleModalClose = (): void => {
|
||||
setIsEditModalOpen(false);
|
||||
editForm.resetFields();
|
||||
setUpdateDomainError(null);
|
||||
};
|
||||
const [customDomainSubdomain, setCustomDomainSubdomain] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
|
||||
const {
|
||||
data: hostsData,
|
||||
@@ -67,9 +96,7 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
isLoading: isLoadingUpdateCustomDomain,
|
||||
} = usePutHost<AxiosError<RenderErrorResponseDTO>>();
|
||||
|
||||
const stripProtocol = (url: string): string => {
|
||||
return url?.split('://')[1] ?? url;
|
||||
};
|
||||
const stripProtocol = (url: string): string => url?.split('://')[1] ?? url;
|
||||
|
||||
const dnsSuffix = useMemo(() => {
|
||||
const defaultHost = hosts?.find((h) => h.is_default);
|
||||
@@ -78,6 +105,11 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
: '';
|
||||
}, [hosts]);
|
||||
|
||||
const activeHost = useMemo(
|
||||
() => hosts?.find((h) => !h.is_default) ?? hosts?.find((h) => h.is_default),
|
||||
[hosts],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFetchingHosts || !hostsData) {
|
||||
return;
|
||||
@@ -85,22 +117,14 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
|
||||
if (hostsData.status === 'success') {
|
||||
setHosts(hostsData.data.hosts ?? null);
|
||||
|
||||
const activeCustomDomain = hostsData.data.hosts?.find(
|
||||
(host) => !host.is_default,
|
||||
);
|
||||
|
||||
if (activeCustomDomain) {
|
||||
setCustomDomainDetails({
|
||||
subdomain: activeCustomDomain?.name || '',
|
||||
});
|
||||
const customHost = hostsData.data.hosts?.find((h) => !h.is_default);
|
||||
if (customHost) {
|
||||
setCustomDomainSubdomain(customHost.name || '');
|
||||
}
|
||||
}
|
||||
|
||||
if (hostsData.data.state !== 'HEALTHY' && isPollingEnabled) {
|
||||
setTimeout(() => {
|
||||
refetchHosts();
|
||||
}, 3000);
|
||||
setTimeout(() => refetchHosts(), 3000);
|
||||
}
|
||||
|
||||
if (hostsData.data.state === 'HEALTHY') {
|
||||
@@ -108,206 +132,175 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
}
|
||||
}, [hostsData, refetchHosts, isPollingEnabled, isFetchingHosts]);
|
||||
|
||||
const onUpdateCustomDomainSettings = (): void => {
|
||||
editForm
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
if (values.subdomain) {
|
||||
updateSubDomain(
|
||||
{ data: { name: values.subdomain } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsPollingEnabled(true);
|
||||
refetchHosts();
|
||||
setIsEditModalOpen(false);
|
||||
},
|
||||
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
|
||||
setUpdateDomainError(error as AxiosError);
|
||||
setIsPollingEnabled(false);
|
||||
},
|
||||
},
|
||||
const handleSubmit = (subdomain: string): void => {
|
||||
updateSubDomain(
|
||||
{ data: { name: subdomain } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsPollingEnabled(true);
|
||||
refetchHosts();
|
||||
setIsEditModalOpen(false);
|
||||
setCustomDomainSubdomain(subdomain);
|
||||
const newUrl = `https://${subdomain}.${dnsSuffix}`;
|
||||
toast.custom(
|
||||
(toastId) => <DomainUpdateToast toastId={toastId} url={newUrl} />,
|
||||
{ duration: 5000, position: 'bottom-right' }, // this 5 sec is as per design
|
||||
);
|
||||
},
|
||||
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
|
||||
setUpdateDomainError(error as AxiosError<RenderErrorResponseDTO>);
|
||||
setIsPollingEnabled(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
setCustomDomainDetails({
|
||||
subdomain: values.subdomain,
|
||||
});
|
||||
const sortedHosts = useMemo(
|
||||
() =>
|
||||
[...(hosts ?? [])].sort((a, b) => {
|
||||
if (a.name === activeHost?.name) {
|
||||
return -1;
|
||||
}
|
||||
})
|
||||
.catch((errorInfo) => {
|
||||
console.error('error info', errorInfo);
|
||||
});
|
||||
};
|
||||
if (b.name === activeHost?.name) {
|
||||
return 1;
|
||||
}
|
||||
if (a.is_default && !b.is_default) {
|
||||
return 1;
|
||||
}
|
||||
if (!a.is_default && b.is_default) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
[hosts, activeHost],
|
||||
);
|
||||
|
||||
const onCopyUrlHandler = (url: string): void => {
|
||||
setCopyUrl(stripProtocol(url));
|
||||
notifications.success({
|
||||
message: 'Copied to clipboard',
|
||||
});
|
||||
};
|
||||
const planName = activeLicense?.plan?.name;
|
||||
|
||||
if (isLoadingHosts) {
|
||||
return (
|
||||
<div className="custom-domain-card custom-domain-card--loading">
|
||||
<Skeleton
|
||||
active
|
||||
title={{ width: '40%' }}
|
||||
paragraph={{ rows: 1, width: '60%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="custom-domain-settings-container">
|
||||
<div className="custom-domain-settings-content">
|
||||
<header>
|
||||
<Typography.Title className="title">
|
||||
Custom Domain Settings
|
||||
</Typography.Title>
|
||||
<Typography.Text className="subtitle">
|
||||
Personalize your workspace domain effortlessly.
|
||||
</Typography.Text>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div className="custom-domain-settings-content">
|
||||
{!isLoadingHosts && (
|
||||
<Card className="custom-domain-settings-card">
|
||||
<div className="custom-domain-settings-content-header">
|
||||
Team {org?.[0]?.displayName} Information
|
||||
<>
|
||||
<div className="custom-domain-card">
|
||||
<div className="custom-domain-card-top">
|
||||
<div className="custom-domain-card-info">
|
||||
<div className="custom-domain-card-name-row">
|
||||
<span className="beacon" />
|
||||
<span className="custom-domain-card-org-name">
|
||||
{org?.[0]?.displayName ? org?.[0]?.displayName : customDomainSubdomain}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="custom-domain-settings-content-body">
|
||||
<div className="custom-domain-urls">
|
||||
{hosts?.map((host) => (
|
||||
<div
|
||||
className="custom-domain-url"
|
||||
key={host.name}
|
||||
onClick={(): void => onCopyUrlHandler(host.url || '')}
|
||||
>
|
||||
<Link2 size={12} /> {stripProtocol(host.url || '')}
|
||||
{host.is_default && <Tag color={Color.BG_ROBIN_500}>Default</Tag>}
|
||||
<div className="custom-domain-card-meta-row">
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={(): JSX.Element => (
|
||||
<div className="workspace-url-dropdown">
|
||||
<span className="workspace-url-dropdown-header">
|
||||
All Workspace URLs
|
||||
</span>
|
||||
<div className="workspace-url-dropdown-divider" />
|
||||
{sortedHosts.map((host) => {
|
||||
const isActive = host.name === activeHost?.name;
|
||||
return (
|
||||
<a
|
||||
key={host.name}
|
||||
href={host.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`workspace-url-dropdown-item${
|
||||
isActive ? ' workspace-url-dropdown-item--active' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="workspace-url-dropdown-item-label">
|
||||
{stripProtocol(host.url ?? '')}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Check size={14} className="workspace-url-dropdown-item-check" />
|
||||
) : (
|
||||
<ExternalLink
|
||||
size={12}
|
||||
className="workspace-url-dropdown-item-external"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="custom-domain-url-edit-btn">
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className="periscope-btn"
|
||||
disabled={isLoadingHosts || isFetchingHosts || isPollingEnabled}
|
||||
type="default"
|
||||
icon={<Pencil size={10} />}
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
type="button"
|
||||
size="xs"
|
||||
className="workspace-url-trigger"
|
||||
disabled={isFetchingHosts}
|
||||
>
|
||||
Customize team’s URL
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<span className="custom-domain-card-meta-timezone">
|
||||
<Clock size={11} />
|
||||
{timezone.offset}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPollingEnabled && (
|
||||
<Alert
|
||||
className="custom-domain-update-status"
|
||||
message={`Updating your URL to ⎯ ${customDomainDetails?.subdomain}.${dnsSuffix}. This may take a few mins.`}
|
||||
type="info"
|
||||
icon={<InfoIcon size={12} />}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
className="custom-domain-edit-button"
|
||||
prefixIcon={<FilePenLine size={12} />}
|
||||
disabled={isFetchingHosts || isPollingEnabled}
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
>
|
||||
Edit workspace link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isPollingEnabled && (
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
className="custom-domain-callout"
|
||||
size="small"
|
||||
icon={<SolidAlertCircle size={13} color="primary" />}
|
||||
message={`Updating your URL to ⎯ ${customDomainSubdomain}.${dnsSuffix}. This may take a few mins.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoadingHosts && (
|
||||
<Card className="custom-domain-settings-card">
|
||||
<Skeleton
|
||||
className="custom-domain-settings-skeleton"
|
||||
active
|
||||
paragraph={{ rows: 2 }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
<div className="custom-domain-card-divider" />
|
||||
|
||||
<div className="custom-domain-card-bottom">
|
||||
<span className="beacon" />
|
||||
<span className="custom-domain-card-license">
|
||||
{planName && <code className="custom-domain-plan-badge">{planName}</code>}{' '}
|
||||
license is currently active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Update Custom Domain Modal */}
|
||||
<Modal
|
||||
className="custom-domain-settings-modal"
|
||||
title="Customize your team’s URL"
|
||||
open={isEditModalOpen}
|
||||
key="edit-custom-domain-settings-modal"
|
||||
afterClose={handleModalClose}
|
||||
// closable
|
||||
onCancel={handleModalClose}
|
||||
destroyOnClose
|
||||
footer={null}
|
||||
>
|
||||
<Form
|
||||
name="edit-custom-domain-settings-form"
|
||||
key={customDomainDetails?.subdomain}
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
initialValues={{
|
||||
subdomain: customDomainDetails?.subdomain,
|
||||
}}
|
||||
>
|
||||
{updateDomainError?.status !== 409 && (
|
||||
<>
|
||||
<div className="custom-domain-settings-modal-body">
|
||||
Enter your preferred subdomain to create a unique URL for your team.
|
||||
Need help? Contact support.
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="subdomain"
|
||||
label="Team’s URL subdomain"
|
||||
rules={[{ required: true }, { type: 'string', min: 3 }]}
|
||||
>
|
||||
<Input
|
||||
addonBefore={updateDomainError && <InfoIcon size={12} color="red" />}
|
||||
placeholder="Enter Domain"
|
||||
onChange={(): void => setUpdateDomainError(null)}
|
||||
addonAfter={dnsSuffix}
|
||||
autoFocus
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{updateDomainError && (
|
||||
<div className="custom-domain-settings-modal-error">
|
||||
{updateDomainError.status === 409 ? (
|
||||
<Alert
|
||||
message={
|
||||
(updateDomainError?.response?.data as RenderErrorResponseDTO)?.error
|
||||
?.message ||
|
||||
'You’ve already updated the custom domain once today. To make further changes, please contact our support team for assistance.'
|
||||
}
|
||||
type="warning"
|
||||
className="update-limit-reached-error"
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text type="danger">
|
||||
{
|
||||
(updateDomainError?.response?.data as RenderErrorResponseDTO)?.error
|
||||
?.message
|
||||
}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateDomainError?.status !== 409 && (
|
||||
<div className="custom-domain-settings-modal-footer">
|
||||
<Button
|
||||
className="periscope-btn primary apply-changes-btn"
|
||||
onClick={onUpdateCustomDomainSettings}
|
||||
loading={isLoadingUpdateCustomDomain}
|
||||
>
|
||||
Apply Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateDomainError?.status === 409 && (
|
||||
<div className="custom-domain-settings-modal-footer">
|
||||
<LaunchChatSupport
|
||||
attributes={{
|
||||
screen: 'Custom Domain Settings',
|
||||
}}
|
||||
eventName="Custom Domain Settings: Facing Issues Updating Custom Domain"
|
||||
message="Hi Team, I need help with updating custom domain"
|
||||
buttonText="Contact Support"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
<CustomDomainEditModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={(): void => setIsEditModalOpen(false)}
|
||||
customDomainSubdomain={customDomainSubdomain}
|
||||
dnsSuffix={dnsSuffix}
|
||||
isLoading={isLoadingUpdateCustomDomain}
|
||||
updateDomainError={updateDomainError}
|
||||
onClearError={(): void => setUpdateDomainError(null)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,21 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CustomDomainSettings from '../CustomDomainSettings';
|
||||
|
||||
jest.mock('components/LaunchChatSupport/LaunchChatSupport', () => ({
|
||||
__esModule: true,
|
||||
default: ({ buttonText }: { buttonText?: string }): JSX.Element => (
|
||||
<button type="button">{buttonText ?? 'Facing issues?'}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockToastCustom = jest.fn();
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
custom: (...args: unknown[]): unknown => mockToastCustom(...args),
|
||||
dismiss: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const ZEUS_HOSTS_ENDPOINT = '*/api/v2/zeus/hosts';
|
||||
|
||||
const mockHostsResponse: GetHosts200 = {
|
||||
@@ -28,9 +43,12 @@ const mockHostsResponse: GetHosts200 = {
|
||||
};
|
||||
|
||||
describe('CustomDomainSettings', () => {
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
mockToastCustom.mockClear();
|
||||
});
|
||||
|
||||
it('renders host URLs with protocol stripped and marks the default host', async () => {
|
||||
it('renders active host URL in the trigger button', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
@@ -39,12 +57,11 @@ describe('CustomDomainSettings', () => {
|
||||
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/accepted-starfish\.test\.cloud/i);
|
||||
// The active host is the non-default one (custom-host)
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
expect(screen.getByText('Default')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens edit modal with DNS suffix derived from the default host', async () => {
|
||||
it('opens edit modal when clicking the edit button', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
@@ -54,14 +71,14 @@ describe('CustomDomainSettings', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/accepted-starfish\.test\.cloud/i);
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /customize team['’]s url/i }),
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: /customize your team['’]s url/i }),
|
||||
screen.getByRole('dialog', { name: /edit workspace link/i }),
|
||||
).toBeInTheDocument();
|
||||
// DNS suffix is the part of the default host URL after the name prefix
|
||||
expect(screen.getByText('test.cloud')).toBeInTheDocument();
|
||||
@@ -83,12 +100,13 @@ describe('CustomDomainSettings', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/accepted-starfish\.test\.cloud/i);
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /customize team['’]s url/i }),
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText(/enter domain/i);
|
||||
// The input is inside the modal — find it by its role
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
@@ -114,15 +132,111 @@ describe('CustomDomainSettings', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/accepted-starfish\.test\.cloud/i);
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /customize team['’]s url/i }),
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
await user.type(screen.getByPlaceholderText(/enter domain/i), 'myteam');
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /contact support/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation error when subdomain is less than 3 characters', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'ab');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
expect(
|
||||
screen.getByText(/minimum 3 characters required/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all workspace URLs as links in the dropdown', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
|
||||
// Open the URL dropdown
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /custom-host\.test\.cloud/i }),
|
||||
);
|
||||
|
||||
// Both host URLs should appear as links in the dropdown
|
||||
const links = await screen.findAllByRole('link');
|
||||
const hostLinks = links.filter(
|
||||
(link) =>
|
||||
link.getAttribute('href')?.includes('test.cloud') &&
|
||||
link.getAttribute('target') === '_blank',
|
||||
);
|
||||
expect(hostLinks).toHaveLength(2);
|
||||
|
||||
// Verify the URLs
|
||||
const hrefs = hostLinks.map((link) => link.getAttribute('href'));
|
||||
expect(hrefs).toContain('https://accepted-starfish.test.cloud');
|
||||
expect(hrefs).toContain('https://custom-host.test.cloud');
|
||||
});
|
||||
|
||||
it('calls toast.custom with new URL after successful domain update', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
rest.put(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({})),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
// Verify toast.custom was called
|
||||
await waitFor(() => {
|
||||
expect(mockToastCustom).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Render the toast element to verify its content
|
||||
const toastRenderer = mockToastCustom.mock.calls[0][0] as (
|
||||
id: string,
|
||||
) => JSX.Element;
|
||||
const { container } = render(toastRenderer('test-id'));
|
||||
expect(container).toHaveTextContent(/myteam\.test\.cloud/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,7 @@ import {
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import { arrayMove, SortableContext, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button, Modal, Row, Space, Table, Typography } from 'antd';
|
||||
import { RowProps } from 'antd/lib';
|
||||
import { Button, Modal, Row, RowProps, Space, Table, Typography } from 'antd';
|
||||
import { VariablesSettingsTabHandle } from 'container/DashboardContainer/DashboardDescription/types';
|
||||
import { convertVariablesToDbFormat } from 'container/DashboardContainer/DashboardVariablesSelection/util';
|
||||
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
|
||||
|
||||
@@ -10,7 +10,15 @@ import { useBarChartStacking } from '../../hooks/useBarChartStacking';
|
||||
import { BarChartProps } from '../types';
|
||||
|
||||
export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
const { children, isStackedBarChart, config, data, ...rest } = props;
|
||||
const {
|
||||
children,
|
||||
isStackedBarChart,
|
||||
customTooltip,
|
||||
config,
|
||||
data,
|
||||
pinnedTooltipElement,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const chartData = useBarChartStacking({
|
||||
data,
|
||||
@@ -20,6 +28,9 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
if (customTooltip) {
|
||||
return customTooltip(props);
|
||||
}
|
||||
const tooltipProps: BarTooltipProps = {
|
||||
...props,
|
||||
timezone: rest.timezone,
|
||||
@@ -29,7 +40,13 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
};
|
||||
return <BarChartTooltip {...tooltipProps} />;
|
||||
},
|
||||
[rest.timezone, rest.yAxisUnit, rest.decimalPrecision, isStackedBarChart],
|
||||
[
|
||||
customTooltip,
|
||||
rest.timezone,
|
||||
rest.yAxisUnit,
|
||||
rest.decimalPrecision,
|
||||
isStackedBarChart,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -37,7 +54,8 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
{...rest}
|
||||
config={config}
|
||||
data={chartData}
|
||||
renderTooltip={renderTooltip}
|
||||
customTooltip={renderTooltip}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
>
|
||||
{children}
|
||||
</ChartWrapper>
|
||||
|
||||
@@ -30,7 +30,8 @@ export default function ChartWrapper({
|
||||
onDestroy = noop,
|
||||
children,
|
||||
layoutChildren,
|
||||
renderTooltip,
|
||||
customTooltip,
|
||||
pinnedTooltipElement,
|
||||
'data-testid': testId,
|
||||
}: ChartProps): JSX.Element {
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
@@ -53,12 +54,12 @@ export default function ChartWrapper({
|
||||
|
||||
const renderTooltipCallback = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => {
|
||||
if (renderTooltip) {
|
||||
return renderTooltip(args);
|
||||
if (customTooltip) {
|
||||
return customTooltip(args);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[renderTooltip],
|
||||
[customTooltip],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -99,6 +100,7 @@ export default function ChartWrapper({
|
||||
)}
|
||||
syncKey={syncKey}
|
||||
render={renderTooltipCallback}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
/>
|
||||
)}
|
||||
</UPlotChart>
|
||||
|
||||
@@ -11,15 +11,16 @@ import { HistogramChartProps } from '../types';
|
||||
export default function Histogram(props: HistogramChartProps): JSX.Element {
|
||||
const {
|
||||
children,
|
||||
renderTooltip: customRenderTooltip,
|
||||
customTooltip,
|
||||
isQueriesMerged,
|
||||
pinnedTooltipElement,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
if (customRenderTooltip) {
|
||||
return customRenderTooltip(props);
|
||||
if (customTooltip) {
|
||||
return customTooltip(props);
|
||||
}
|
||||
const tooltipProps: HistogramTooltipProps = {
|
||||
...props,
|
||||
@@ -29,14 +30,15 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
|
||||
};
|
||||
return <HistogramTooltip {...tooltipProps} />;
|
||||
},
|
||||
[customRenderTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
|
||||
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
showLegend={!isQueriesMerged}
|
||||
{...rest}
|
||||
renderTooltip={renderTooltip}
|
||||
customTooltip={renderTooltip}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
>
|
||||
{children}
|
||||
</ChartWrapper>
|
||||
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
import { TimeSeriesChartProps } from '../types';
|
||||
|
||||
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
const { children, renderTooltip: customRenderTooltip, ...rest } = props;
|
||||
const { children, customTooltip, pinnedTooltipElement, ...rest } = props;
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
if (customRenderTooltip) {
|
||||
return customRenderTooltip(props);
|
||||
if (customTooltip) {
|
||||
return customTooltip(props);
|
||||
}
|
||||
const tooltipProps: TimeSeriesTooltipProps = {
|
||||
...props,
|
||||
@@ -24,11 +24,15 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
};
|
||||
return <TimeSeriesTooltip {...tooltipProps} />;
|
||||
},
|
||||
[customRenderTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
|
||||
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartWrapper {...rest} renderTooltip={renderTooltip}>
|
||||
<ChartWrapper
|
||||
{...rest}
|
||||
customTooltip={renderTooltip}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
>
|
||||
{children}
|
||||
</ChartWrapper>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,10 @@ import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
TooltipClickData,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
interface BaseChartProps {
|
||||
width: number;
|
||||
@@ -13,7 +16,8 @@ interface BaseChartProps {
|
||||
canPinTooltip?: boolean;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
renderTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
interface UPlotBasedChartProps {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ColumnType } from 'antd/es/table';
|
||||
import { TableColumnType as ColumnType } from 'antd';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import CustomCheckBox from 'container/GridCardLayout/GridCard/FullView/TableRender/CustomCheckBox';
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export const prepareUPlotConfig = ({
|
||||
widget: Widgets;
|
||||
isDarkMode: boolean;
|
||||
currentQuery: Query;
|
||||
onClick: OnClickPluginOpts['onClick'];
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
onDragSelect: (startTime: number, endTime: number) => void;
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
timezone: Timezone;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { UseMutateAsyncFunction } from 'react-query';
|
||||
import { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import type { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { SaveViewWithNameProps } from 'components/ExplorerCard/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Dispatch, ReactElement, SetStateAction } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, FormInstance, Input, Select, Switch, Typography } from 'antd';
|
||||
import { Store } from 'antd/lib/form/interface';
|
||||
import type { Store } from 'antd/lib/form/interface';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
ChannelType,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Space,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import {
|
||||
getCategoryByOptionId,
|
||||
getCategorySelectOptionByName,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
.general-settings-page {
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
padding: var(--padding-8) 0 var(--padding-16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.general-settings-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.general-settings-title {
|
||||
font-size: var(--paragraph-medium-500-font-size);
|
||||
font-weight: var(--paragraph-medium-500-font-weight);
|
||||
line-height: 32px;
|
||||
letter-spacing: -0.08px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.general-settings-subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.retention-controls-container {
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.retention-controls-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--padding-4);
|
||||
height: 50px;
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.retention-controls-header-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-24);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.retention-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--padding-4);
|
||||
height: 52px;
|
||||
background: var(--l2-background);
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.retention-row-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
svg {
|
||||
color: var(--l2-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.retention-row-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.retention-input-group {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
// todo: https://github.com/SigNoz/components/issues/116
|
||||
input[type='number'] {
|
||||
display: flex;
|
||||
width: 120px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
border-radius: 2px 0 0 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 16px;
|
||||
box-shadow: none;
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
padding: 6px 6px 6px 8px;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
width: 80px;
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.retention-error-text {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--accent-amber);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.retention-modal-description {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 22px;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user