diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00de556e..a1a8b464 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,11 +36,21 @@ jobs: -e MINIO_ROOT_USER=mosaic_ci -e MINIO_ROOT_PASSWORD=mosaic_ci_secret \ minio/minio:RELEASE.2025-07-23T15-54-02Z server /data for i in $(seq 1 30); do - curl -fsS http://localhost:9000/minio/health/live && break; sleep 2 + curl -fsS http://localhost:9000/minio/health/live && break + sleep 2 done - docker run --rm --network host \ + # The loop above exits on its last iteration whether or not MinIO ever + # answered, so failure has to be its own statement: otherwise the + # bucket step is the first thing to notice, and it reports a client + # error rather than an unreachable server. + curl -fsS http://localhost:9000/minio/health/live >/dev/null || { + echo "MinIO did not become ready"; docker logs mosaic-ci-minio | tail -50; exit 1; + } + # `mc` is the image's entrypoint, so an `sh -c` argument is read as an + # mc subcommand and fails with "`sh` is not a recognized command". + docker run --rm --network host --entrypoint sh \ minio/mc:RELEASE.2025-07-21T05-28-08Z \ - sh -c "mc alias set ci http://localhost:9000 mosaic_ci mosaic_ci_secret && mc mb --ignore-existing ci/mosaic-ci-assets" + -c "mc alias set ci http://localhost:9000 mosaic_ci mosaic_ci_secret && mc mb --ignore-existing ci/mosaic-ci-assets" - uses: actions/setup-go@v5 with: go-version-file: apps/api/go.mod @@ -112,6 +122,15 @@ jobs: - name: Install working-directory: apps/dashboard run: npm ci + # apps/dashboard/scripts/*.mjs reach protocol/browser/index.js by relative + # path rather than as a declared dependency, so its runtime deps resolve + # from protocol/node_modules — which installing the dashboard alone never + # creates. The relay test is the step that notices, and only in a clean + # checkout: any working copy that has built the protocol package has the + # directory already and passes. + - name: Install protocol runtime dependencies + working-directory: protocol + run: npm ci --omit=dev - name: Check (format, lint, typecheck, tests, relay, build) working-directory: apps/dashboard run: npm run check @@ -133,15 +152,36 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: stable + # `dart format` picks its style from the package's language version, which + # it reads from .dart_tool/package_config.json. On a fresh checkout that + # file does not exist yet, so it falls back to the newest language version + # and applies the tall style introduced in Dart 3.7 — rewriting 86 of 90 + # files against a package that declares `sdk: ">=3.4.0"`. Resolving first + # pins the language version to 3.4 and the formatter to the style the + # sources are actually written in. One `pub get` here also resolves the + # example package, which the format step covers. + - name: Resolve packages + working-directory: sdk/flutter + run: | + # Every package with a pubspec, not just the root: `flutter pub get` + # here resolves the root and the example, but packages/* are separate + # and analyze covers them, so unresolved they report as missing URIs. + for pubspec in $(find . -name pubspec.yaml -not -path "*/build/*" | sort); do + (cd "$(dirname "$pubspec")" && flutter pub get) + done - name: Format working-directory: sdk/flutter run: dart format --output=none --set-exit-if-changed lib test example/lib - name: Analyze working-directory: sdk/flutter run: flutter analyze + # Goldens are rasterised output, so they encode the host's font rendering + # and the Flutter version that produced them. The baselines are macOS and + # this runner is Linux on a channel that moves, so comparing here fails on + # the environment rather than on the renderer. See sdk/flutter/dart_test.yaml. - name: Tests working-directory: sdk/flutter - run: flutter test + run: flutter test --exclude-tags golden android: runs-on: ubuntu-latest diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..569d7c1f --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,28 @@ +# Gitleaks configuration. +# +# The default rules are kept in full. This narrows exactly one of them. +# +# `generic-api-key` fires on any assignment whose value clears a Shannon +# entropy of 3.5, which readable Go test fixtures do as soon as they get +# descriptive: `redelivery-v1-destination` scores 3.65, while its siblings +# `redelivery-stale-digest` (3.41) and `redelivery-1` (3.02) pass. Renaming is +# not a fix — a longer, clearer name scores *higher* — so without this the +# repository would have to name its test fixtures against an entropy budget. +# +# Scope is by value, not by path. An earlier attempt allowlisted `_test.go` +# paths as well, which turned out to widen rather than narrow: allowlist +# conditions are OR'd, so the path alone exempted the file and real secrets +# planted in a test went unreported. Matching only the secret, anchored, means +# a value has to *be* a fixture identifier end to end to be exempt; a random +# credential in the same file still fails the scan. +# +# Verified both directions — see the commit that introduced this file. +[extend] +useDefault = true + +[[allowlists]] +description = "Readable fixture identifiers, matched only by the generic entropy rule" +targetRules = ["generic-api-key"] +regexes = [ + '''(?i)^(idempotency|correlation|redelivery|fixture|expected|request)[-_][a-z0-9-]+$''', +] diff --git a/apps/api/internal/platform/billingmigrationpostgres/cutover_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/cutover_integration_test.go index fdc4e6f9..ab2ac35e 100644 --- a/apps/api/internal/platform/billingmigrationpostgres/cutover_integration_test.go +++ b/apps/api/internal/platform/billingmigrationpostgres/cutover_integration_test.go @@ -339,12 +339,61 @@ func TestCutoverPreparationRequiresAuthoritativeEvidenceAndDistinctProductionApp if _, err := service.PromoteReady(ctx, billingmigration.Actor{ID: "owner_one"}, billingmigration.PromoteReadyInput{ProjectID: "project_one", ProgramID: "program_ready", ExpectedStateVersion: 6}); !errors.Is(err, billingmigration.ErrConflict) { t.Fatalf("unsupported in-window version bypassed by outside-window flag: %v", err) } - if err := goose.DownContext(ctx, db, "."); err == nil || !strings.Contains(err.Error(), "immutable cohort or rollback proposal evidence exists") { +} + +func TestExecutionPrerequisiteRollbackGuardRetainsCohortEvidence(t *testing.T) { + databaseURL := os.Getenv("DATABASE_TEST_URL") + if databaseURL == "" { + t.Skip("DATABASE_TEST_URL is required for PostgreSQL integration tests") + } + configuration, err := pgx.ParseConfig(databaseURL) + if err != nil { + t.Fatal(err) + } + db := stdlib.OpenDB(*configuration) + t.Cleanup(func() { _ = db.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + if _, err = db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil { + t.Fatal(err) + } + goose.SetBaseFS(migrations.Files) + if err = goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + if err = goose.UpContext(ctx, db, "."); err != nil { + t.Fatal(err) + } + seedMigrationTenant(t, ctx, db) + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + // A frozen cohort set is 00054 evidence and nothing later, so the program + // is seeded by hand rather than via seedReadyProgram: that helper also + // writes final-delta jobs and prepared pointers, and 00055 refuses to roll + // back over those — 54 would never be reached and its guard never + // exercised. + for _, statement := range []string{ + `INSERT INTO billing_migration_credentials(id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_by_actor_id,created_at) VALUES('credential_ready','project_one','revenuecat','rc','active',1,'AES-256-GCM','key',decode(repeat('01',12),'hex'),decode(repeat('02',16),'hex'),decode(repeat('03',32),'hex'),'owner_one',$1)`, + `INSERT INTO billing_migration_programs(id,project_id,environment_id,source_adapter,source_adapter_version,credential_id,state,state_version,authority_epoch_before,stabilization_days,rollback_window_days,scope_digest,policy_digest,idempotency_key,request_digest,created_by_actor_id,created_at,updated_at) VALUES('program_ready','project_one','environment_one','revenuecat','v2','credential_ready','shadowing',4,0,7,7,decode(repeat('61',32),'hex'),decode(repeat('62',32),'hex'),'ready-program',decode(repeat('63',32),'hex'),'owner_one',$1,$1)`, + `INSERT INTO billing_migration_final_deltas(id,program_id,project_id,state_version,manifest_digest,mapping_digest,evidence_digest,final_watermark_digest,source_watermark,provider_watermark,shadow_watermark,delta_digest,completed_at) VALUES('delta_guard','program_ready','project_one',4,decode(repeat('65',32),'hex'),decode(repeat('66',32),'hex'),decode(repeat('67',32),'hex'),decode(repeat('96',32),'hex'),$1,$1,$1,decode(repeat('97',32),'hex'),$1)`, + `INSERT INTO billing_migration_final_delta_cohort_sets(id,final_delta_id,program_id,project_id,customer_count,cohort_digest,frozen_at) VALUES('cohort_guard','delta_guard','program_ready','project_one',1,decode(repeat('99',32),'hex'),$1)`, + } { + if _, err = db.ExecContext(ctx, statement, now); err != nil { + t.Fatal(err) + } + } + if err = goose.DownToContext(ctx, db, ".", 54); err != nil { + t.Fatalf("down to 54: %v", err) + } + if err = goose.DownToContext(ctx, db, ".", 53); err == nil || !strings.Contains(err.Error(), "immutable cohort or rollback proposal evidence exists") { t.Fatalf("migration 54 evidence guard error=%v", err) } - version, err := goose.GetDBVersionContext(ctx, db) - if err != nil || version != 54 { - t.Fatalf("guarded rollback version=%d err=%v", version, err) + version, versionErr := goose.GetDBVersionContext(ctx, db) + if versionErr != nil || version != 54 { + t.Fatalf("guarded rollback version=%d err=%v", version, versionErr) + } + var cohorts int + if err = db.QueryRowContext(ctx, `SELECT count(*) FROM billing_migration_final_delta_cohort_sets`).Scan(&cohorts); err != nil || cohorts != 1 { + t.Fatalf("guard lost cohorts count=%d err=%v", cohorts, err) } } diff --git a/apps/api/internal/platform/billingmigrationpostgres/evidence.go b/apps/api/internal/platform/billingmigrationpostgres/evidence.go index da191874..35f7e1dd 100644 --- a/apps/api/internal/platform/billingmigrationpostgres/evidence.go +++ b/apps/api/internal/platform/billingmigrationpostgres/evidence.go @@ -189,8 +189,8 @@ func (r *Repository) CreateImportBatch(ctx context.Context, expectedStateVersion command, err := tx.Exec(ctx, `INSERT INTO billing_migration_import_batches( id,program_id,project_id,manifest_id,mapping_set_id,idempotency_key,request_digest, expected_program_state_version,status,record_count,validated_count,quarantined_count, - cursor_before,cursor_after,attempt_count,created_at,updated_at) - VALUES($1,$2,$3,$4,$5,$6,$7,$8,'pending',$9,0,0,$10,'',0,$11,$11) + cursor_before,cursor_after,attempt_count,created_at,updated_at,due_at,max_attempts) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,'pending',$9,0,0,$10,'',0,$11,$11,$11,8) ON CONFLICT (program_id,idempotency_key) DO NOTHING`, batch.BatchID, batch.ProgramID, write.ProjectID, write.ManifestID, write.MappingSetID, batch.IdempotencyKey, write.RequestDigest, expectedStateVersion, batch.RecordCount, @@ -328,8 +328,8 @@ func (r *Repository) QueueRun(ctx context.Context, expectedStateVersion int64, w return false, err } command, err := tx.Exec(ctx, `INSERT INTO billing_migration_run_jobs(id,program_id,project_id,run_kind,idempotency_key, - request_digest,expected_program_state_version,manifest_digest,mapping_digest,policy_digest,status,created_at,updated_at) - SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'pending',$11,$11 + request_digest,expected_program_state_version,manifest_digest,mapping_digest,policy_digest,status,created_at,updated_at,due_at,max_attempts) + SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'pending',$11,$11,$11,8 WHERE EXISTS(SELECT 1 FROM billing_migration_source_manifests WHERE program_id=$2 AND project_id=$3 AND manifest_digest=$8) AND EXISTS(SELECT 1 FROM billing_migration_mapping_sets WHERE program_id=$2 AND project_id=$3 AND mapping_digest=$9 AND status='frozen') AND EXISTS(SELECT 1 FROM billing_migration_programs WHERE id=$2 AND project_id=$3 AND policy_digest=$10) diff --git a/apps/api/internal/platform/billingmigrationpostgres/repository_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/repository_integration_test.go index a7cf93ea..5c9db3e2 100644 --- a/apps/api/internal/platform/billingmigrationpostgres/repository_integration_test.go +++ b/apps/api/internal/platform/billingmigrationpostgres/repository_integration_test.go @@ -358,16 +358,16 @@ func TestProgramTransactionEnforcesTenantScopeAndEncryptedCredential(t *testing. if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_source_access_exceptions(id,case_id,program_id,project_id,application_id,platform,reason,affected_customer_count,rollback_treatment,identity_ambiguity_count,proposer_actor_id,approver_actor_id,approved_at,expires_at,exception_digest) VALUES('exception_same_actor','case_distinct_exception',$1,'project_one','app_one','ios','reviewed',1,'restore source',0,'owner_one','owner_one',now(),now()+interval '1 hour',decode(repeat('93',32),'hex'))`, created.Program.ProgramID); err == nil { t.Fatal("nonproduction source-access exception accepted identical proposer and approver") } - if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest) VALUES('completion_bad',$1,'project_one',4,now(),now(),now()+interval '1 day',true,now(),false,now()+interval '30 days',decode(repeat('94',32),'hex'))`, created.Program.ProgramID); err == nil { + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest,authority_digest,stability_evidence_digest,completion_policy_digest) VALUES('completion_bad',$1,'project_one',4,now(),now(),now()+interval '1 day',true,now(),false,now()+interval '30 days',decode(repeat('94',32),'hex'),decode(repeat('a7',32),'hex'),decode(repeat('b7',32),'hex'),decode(repeat('c7',32),'hex'))`, created.Program.ProgramID); err == nil { t.Fatal("completion accepted completion/credential removal before rollback window ended") } - if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest) VALUES('completion_bad_stabilization',$1,'project_one',4,now(),now()+interval '1 second',now(),true,now(),false,now()+interval '30 days',decode(repeat('99',32),'hex'))`, created.Program.ProgramID); err == nil { + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest,authority_digest,stability_evidence_digest,completion_policy_digest) VALUES('completion_bad_stabilization',$1,'project_one',4,now(),now()+interval '1 second',now(),true,now(),false,now()+interval '30 days',decode(repeat('99',32),'hex'),decode(repeat('a7',32),'hex'),decode(repeat('b7',32),'hex'),decode(repeat('c7',32),'hex'))`, created.Program.ProgramID); err == nil { t.Fatal("completion accepted completed_at before stabilization ended") } - if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest) VALUES('completion_bad_delete',$1,'project_one',4,now(),now(),now(),true,now(),false,now()+interval '31 days',decode(repeat('95',32),'hex'))`, created.Program.ProgramID); err == nil { + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest,authority_digest,stability_evidence_digest,completion_policy_digest) VALUES('completion_bad_delete',$1,'project_one',4,now(),now(),now(),true,now(),false,now()+interval '31 days',decode(repeat('95',32),'hex'),decode(repeat('a7',32),'hex'),decode(repeat('b7',32),'hex'),decode(repeat('c7',32),'hex'))`, created.Program.ProgramID); err == nil { t.Fatal("completion accepted a non-deterministic source-object deletion time") } - if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest) VALUES('completion_valid',$1,'project_one',4,now(),now()-interval '1 day',now(),true,now(),false,now()+interval '30 days',decode(repeat('96',32),'hex'))`, created.Program.ProgramID); err != nil { + if _, err := pool.Exec(ctx, `INSERT INTO billing_migration_completion_reports(id,program_id,project_id,state_version,completed_at,stabilization_ended_at,rollback_window_ended_at,credential_removed,credential_removed_at,legal_hold,source_objects_delete_at,completion_digest,authority_digest,stability_evidence_digest,completion_policy_digest) VALUES('completion_valid',$1,'project_one',4,now(),now()-interval '1 day',now(),true,now(),false,now()+interval '30 days',decode(repeat('96',32),'hex'),decode(repeat('a7',32),'hex'),decode(repeat('b7',32),'hex'),decode(repeat('c7',32),'hex'))`, created.Program.ProgramID); err != nil { t.Fatalf("valid completion timing rejected: %v", err) } } diff --git a/apps/api/internal/platform/billingmigrationpostgres/source_pull_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/source_pull_integration_test.go index b2f2f3d4..26837999 100644 --- a/apps/api/internal/platform/billingmigrationpostgres/source_pull_integration_test.go +++ b/apps/api/internal/platform/billingmigrationpostgres/source_pull_integration_test.go @@ -177,6 +177,48 @@ func TestSourcePullLeaseAndFrozenApplicationBinding(t *testing.T) { if err = pool.QueryRow(ctx, `SELECT count(*) FROM billing_migration_capability_assessments WHERE program_id='program_source'`).Scan(&capabilityCount); err != nil || capabilityCount != 2 { t.Fatalf("capability replay count=%d err=%v", capabilityCount, err) } +} + +func TestSourcePullRollbackGuardRetainsDurableEvidence(t *testing.T) { + databaseURL := os.Getenv("DATABASE_TEST_URL") + if databaseURL == "" { + t.Skip("DATABASE_TEST_URL is required for PostgreSQL integration tests") + } + configuration, err := pgx.ParseConfig(databaseURL) + if err != nil { + t.Fatal(err) + } + db := stdlib.OpenDB(*configuration) + t.Cleanup(func() { _ = db.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + if _, err = db.ExecContext(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil { + t.Fatal(err) + } + goose.SetBaseFS(migrations.Files) + if err = goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + if err = goose.UpContext(ctx, db, "."); err != nil { + t.Fatal(err) + } + seedMigrationTenant(t, ctx, db) + now := time.Now().UTC().Truncate(time.Microsecond) + for _, statement := range []string{ + `INSERT INTO billing_migration_credentials(id,project_id,provider,external_project_id,status,envelope_version,algorithm,key_id,nonce,ciphertext,fingerprint,created_by_actor_id,created_at) VALUES('credential_source','project_one','revenuecat','rc_project','active',1,'AES-256-GCM','credential_key',decode(repeat('01',12),'hex'),decode(repeat('02',32),'hex'),decode(repeat('03',32),'hex'),'owner_one',$1)`, + `INSERT INTO billing_migration_programs(id,project_id,environment_id,source_adapter,source_adapter_version,credential_id,state,state_version,authority_epoch_before,stabilization_days,rollback_window_days,scope_digest,policy_digest,idempotency_key,request_digest,created_by_actor_id,created_at,updated_at) VALUES('program_source','project_one','environment_one','revenuecat','` + billingmigration.AdapterVersion + `','credential_source','mapping',1,0,7,7,decode(repeat('11',32),'hex'),decode(repeat('12',32),'hex'),'program-source',decode(repeat('13',32),'hex'),'owner_one',$1,$1)`, + `INSERT INTO billing_migration_mapping_sets(id,program_id,project_id,version,status,mapping_digest,expected_program_state_version,created_by_actor_id,created_at,frozen_at) VALUES('mapping_pull','program_source','project_one',1,'frozen',decode(repeat('51',32),'hex'),1,'owner_one',$1,$1)`, + } { + if _, err = db.ExecContext(ctx, statement, now); err != nil { + t.Fatal(err) + } + } + // A completed checkpoint is 00058 evidence and nothing later: it writes a + // source-pull job but no import batch *record*. That distinction is what + // makes 58 reachable at all — 00059 guards the record table too, so a + // fixture that imports records can never roll back far enough to observe + // 00058's own guard. + seedCompletedSourcePullCheckpoint(t, ctx, db, "guard", "cursor", "watermark", bytesOf(0x53), now) if err = goose.DownToContext(ctx, db, ".", 58); err != nil { t.Fatalf("down to 58: %v", err) } diff --git a/apps/api/internal/platform/billingmigrationpostgres/stabilization.go b/apps/api/internal/platform/billingmigrationpostgres/stabilization.go index 967d8359..e0d26af5 100644 --- a/apps/api/internal/platform/billingmigrationpostgres/stabilization.go +++ b/apps/api/internal/platform/billingmigrationpostgres/stabilization.go @@ -212,11 +212,11 @@ func (r *Repository) RecordStabilization(ctx context.Context, c billingmigration var accessKnown bool err = tx.QueryRow(ctx, `SELECT (SELECT count(*) FROM billing_migration_program_scopes ps LEFT JOIN billing_migration_authority_scopes a ON a.project_id=ps.project_id AND a.environment_id=ps.environment_id AND a.application_id=ps.application_id AND a.platform=ps.platform WHERE ps.program_id=$1 AND ps.project_id=$2 AND (a.current_authority IS DISTINCT FROM 'mosaic' OR a.current_epoch IS DISTINCT FROM $4 OR a.active_program_id IS DISTINCT FROM $1)), - COALESCE((SELECT sum(error_count) FROM billing_migration_access_api_signal_windows WHERE program_id=$1 AND project_id=$2 AND window_ended_at>=GREATEST($3,$7-($8*interval '1 second'))),$5+1), - EXISTS(SELECT 1 FROM billing_migration_access_api_signal_windows WHERE program_id=$1 AND project_id=$2 AND request_count>0 AND window_ended_at>=GREATEST($3,$7-($8*interval '1 second'))), + COALESCE((SELECT sum(error_count) FROM billing_migration_access_api_signal_windows WHERE program_id=$1 AND project_id=$2 AND window_ended_at>=GREATEST($3,$7::timestamptz-($8*interval '1 second'))),$5+1), + EXISTS(SELECT 1 FROM billing_migration_access_api_signal_windows WHERE program_id=$1 AND project_id=$2 AND request_count>0 AND window_ended_at>=GREATEST($3,$7::timestamptz-($8*interval '1 second'))), COALESCE((SELECT sum(traffic_count) FROM billing_migration_v2_sync_observations WHERE program_id=$1 AND project_id=$2 AND observed_at>=$3 AND sync_result<>'accepted'),0), (SELECT count(*) FROM billing_migration_divergences d LEFT JOIN billing_migration_divergence_resolutions x ON x.divergence_id=d.id WHERE d.program_id=$1 AND d.project_id=$2 AND x.id IS NULL), - (SELECT count(*) FROM billing_migration_import_batch_records ir JOIN billing_migration_source_records sr ON sr.id=ir.source_record_id AND sr.program_id=ir.program_id AND sr.project_id=ir.project_id WHERE ir.program_id=$1 AND ir.project_id=$2 AND sr.current_access AND NOT EXISTS(SELECT 1 FROM billing_migration_validation_bindings vb WHERE vb.program_id=ir.program_id AND vb.project_id=ir.project_id AND vb.environment_id=ir.environment_id AND vb.expected_application_id=ir.application_id AND vb.provider=ir.provider AND vb.reference_kind=ir.reference_kind AND vb.reference_digest=CASE WHEN ir.provider='app_store' THEN sha256(convert_to('mosaic-billing-apple-transaction-v1'||chr(0)||'unclassified'||chr(0)||ir.provider_reference,'UTF8')) ELSE sha256(convert_to('mosaic-billing-google-order-v1'||chr(0)||ir.provider_reference,'UTF8')) END AND vb.expected_store_environment=ir.expected_store_environment AND (ir.expected_store_product_identifier IS NULL OR (vb.expected_store_product_identifier=ir.expected_store_product_identifier AND vb.expected_mosaic_product_id=ir.mosaic_product_id)) AND vb.status IN ('validated','quarantined')))+ + (SELECT count(*) FROM billing_migration_import_batch_records ir JOIN billing_migration_source_records sr ON sr.id=ir.source_record_id AND sr.program_id=ir.program_id AND sr.project_id=ir.project_id WHERE ir.program_id=$1 AND ir.project_id=$2 AND sr.current_access AND NOT EXISTS(SELECT 1 FROM billing_migration_validation_bindings vb WHERE vb.program_id=ir.program_id AND vb.project_id=ir.project_id AND vb.environment_id=ir.environment_id AND vb.expected_application_id=ir.application_id AND vb.provider=ir.provider AND vb.reference_kind=ir.reference_kind AND vb.reference_digest=CASE WHEN ir.provider='app_store' THEN sha256(convert_to('mosaic-billing-apple-transaction-v1','UTF8')||'\x00'::bytea||convert_to('unclassified','UTF8')||'\x00'::bytea||convert_to(ir.provider_reference,'UTF8')) ELSE sha256(convert_to('mosaic-billing-google-order-v1','UTF8')||'\x00'::bytea||convert_to(ir.provider_reference,'UTF8')) END AND vb.expected_store_environment=ir.expected_store_environment AND (ir.expected_store_product_identifier IS NULL OR (vb.expected_store_product_identifier=ir.expected_store_product_identifier AND vb.expected_mosaic_product_id=ir.mosaic_product_id)) AND vb.status IN ('validated','quarantined')))+ (SELECT count(*) FROM billing_migration_import_batches WHERE program_id=$1 AND project_id=$2 AND status IN ('pending','running')), COALESCE((SELECT source_watermark FROM billing_migration_final_deltas WHERE program_id=$1 AND project_id=$2 ORDER BY completed_at DESC,id DESC LIMIT 1),NULL), (SELECT count(*) FROM webhook_deliveries d JOIN webhook_events e ON e.id=d.webhook_event_id AND e.project_id=d.project_id JOIN billing_migration_authority_scopes a ON a.id=e.authority_scope_id AND a.project_id=e.project_id JOIN webhook_destinations destination ON destination.id=d.webhook_destination_id AND destination.project_id=d.project_id WHERE d.project_id=$2 AND d.environment_id=$6 AND a.active_program_id=$1 AND e.contract_version=2 AND e.event_type IN ('authority.cutover.completed','authority.rollback.completed','authority.stabilization.completed') AND destination.status='active' AND d.created_at>=$3 AND d.status IN ('failed','exhausted')), @@ -254,6 +254,12 @@ func (r *Repository) RecordStabilization(ctx context.Context, c billingmigration Breaches []string Source, Webhook, At time.Time }{c.Input.ProgramID, version, c.Input.ExpectedAuthorityEpoch, policyRaw, m, breaches, sourceAt.UTC(), webhookAt.UTC(), now.UTC()}) + if breaches == nil { + // A healthy observation breaches nothing, and a nil slice encodes as + // NULL against a text[] NOT NULL column — so the healthy path, the + // one that matters most, could never be persisted. + breaches = []string{} + } o := billingmigration.StabilizationObservation{ID: stabilizationID("mso", evidence), ProgramID: c.Input.ProgramID, ProjectID: c.Input.ProjectID, PolicyID: p.ID, PolicyDigest: p.PolicyDigest, EvidenceDigest: billingmigration.FormatDigest(evidence), StateVersion: version, AuthorityEpoch: c.Input.ExpectedAuthorityEpoch, Metrics: m, SourceWatermark: sourceAt.UTC(), WebhookLastSuccessAt: webhookAt.UTC(), ObservedAt: now.UTC(), BreachCodes: breaches, Healthy: len(breaches) == 0} _, err = tx.Exec(ctx, `INSERT INTO billing_migration_stabilization_observations(id,program_id,project_id,policy_id,state_version,authority_epoch,authority_mismatches,access_api_errors,sdk_sync_failures,divergences,validation_backlog,source_delta_lag_seconds,webhook_failures,webhook_age_seconds,quarantined_records,support_cases,old_app_versions,unhealthy_workers,source_watermark,webhook_last_success_at,breach_codes,healthy,evidence_digest,observed_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)`, o.ID, o.ProgramID, o.ProjectID, o.PolicyID, o.StateVersion, o.AuthorityEpoch, m.AuthorityMismatches, m.AccessAPIErrors, m.SDKSyncFailures, m.Divergences, m.ValidationBacklog, m.SourceDeltaLagSeconds, m.WebhookFailures, m.WebhookAgeSeconds, m.QuarantinedRecords, m.SupportCases, m.OldAppVersions, m.UnhealthyWorkers, o.SourceWatermark, o.WebhookLastSuccessAt, o.BreachCodes, o.Healthy, evidence, o.ObservedAt) if err != nil { diff --git a/apps/api/internal/platform/billingmigrationpostgres/stabilization_integration_test.go b/apps/api/internal/platform/billingmigrationpostgres/stabilization_integration_test.go index 4c0ad585..7ffe745d 100644 --- a/apps/api/internal/platform/billingmigrationpostgres/stabilization_integration_test.go +++ b/apps/api/internal/platform/billingmigrationpostgres/stabilization_integration_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/jackc/pgx/v5" "github.com/pressly/goose/v3" "github.com/Mujhtech/mosaic/apps/api/internal/billingmigration" @@ -18,7 +19,7 @@ func TestStabilizationAndRollbackReadinessUsePersistedEvidence(t *testing.T) { ctx, pool, db := executionDatabase(t) now := time.Now().UTC().Truncate(time.Microsecond) seedReadyProgram(t, ctx, db, now) - _, err := pool.Exec(ctx, `UPDATE billing_migration_programs SET state='stabilizing',state_version=7,updated_at=$1 WHERE id='program_ready'; UPDATE billing_migration_authority_scopes SET current_authority='mosaic',current_epoch=1,authority_digest=decode(repeat('a1',32),'hex'),updated_at=$1 WHERE active_program_id='program_ready'; INSERT INTO billing_migration_capability_assessments(id,program_id,project_id,state_version,provider_api_version,capabilities,assessment_digest,assessed_at) VALUES('capability_rollback','program_ready','project_one',7,'v2',ARRAY['read_customers','read_subscriptions','read_aliases','incremental_delta'],decode(repeat('af',32),'hex'),$1-interval '1 minute'); INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) VALUES('stable_ios','program_ready','project_one','app_one','ios','2.10.0+ios.1','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],1,1,'accepted',decode(repeat('a2',32),'hex'),$1),('stable_android_incomplete','program_ready','project_one','app_two','android','2.10.0+android.1','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','mosaic_authoritative_targeting'],1,1,'accepted',decode(repeat('a3',32),'hex'),$1)`, now) + _, err := pool.Exec(ctx, `UPDATE billing_migration_programs SET state='stabilizing',state_version=7,updated_at=$1 WHERE id='program_ready'; UPDATE billing_migration_authority_scopes SET current_authority='mosaic',current_epoch=1,authority_digest=decode(repeat('a1',32),'hex'),updated_at=$1 WHERE active_program_id='program_ready'; INSERT INTO billing_migration_capability_assessments(id,program_id,project_id,state_version,provider_api_version,capabilities,assessment_digest,assessed_at) VALUES('capability_rollback','program_ready','project_one',7,'v2',ARRAY['read_customers','read_subscriptions','read_aliases','incremental_delta'],decode(repeat('af',32),'hex'),$1::timestamptz-interval '1 minute'); INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) VALUES('stable_ios','program_ready','project_one','app_one','ios','2.10.0+ios.1','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],1,1,'accepted',decode(repeat('a2',32),'hex'),$1),('stable_android_incomplete','program_ready','project_one','app_two','android','2.10.0+android.1','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','mosaic_authoritative_targeting'],1,1,'accepted',decode(repeat('a3',32),'hex'),$1)`, pgx.QueryExecModeSimpleProtocol, now) if err != nil { t.Fatal(err) } @@ -75,15 +76,15 @@ func TestStabilizationAndRollbackReadinessUsePersistedEvidence(t *testing.T) { INSERT INTO billing_migration_source_records(id,program_id,project_id,manifest_id,source_kind,source_identifier,source_revision,record_digest,current_access,normalization_schema_version,evidence_kind,observed_at,created_at) VALUES('record_unvalidated','program_ready','project_one','manifest_ready','subscription','subscription-unvalidated','1',decode(repeat('de',32),'hex'),true,'v1','trusted_source_export',$1,$1); INSERT INTO billing_migration_import_batch_records(import_batch_id,program_id,project_id,source_record_id,ordinal,provider,environment_id,application_id,provider_reference,reference_kind,expected_store_environment) VALUES('batch_stable','program_ready','project_one','record_ready',0,'app_store','environment_one','app_one','transaction-stable','app_store_transaction_id','production'); INSERT INTO billing_migration_import_batch_records(import_batch_id,program_id,project_id,source_record_id,ordinal,provider,environment_id,application_id,provider_reference,reference_kind,expected_store_environment) VALUES('batch_stable','program_ready','project_one','record_unvalidated',1,'app_store','environment_one','app_one','transaction-unvalidated','app_store_transaction_id','production'); - INSERT INTO billing_raw_inputs(id,project_id,organization_id,environment_id,environment_mode,application_id,provider,source,source_authority,idempotency_key,content_digest,transaction_reference_digest,body_state,authentication_result,store_environment,ingestion_status,correlation_id,received_at,expires_at) VALUES('raw_stable','project_one','org_one','environment_one','production','app_one','app_store','migration_known_reference','store_reconciliation',decode(repeat('da',32),'hex'),decode(repeat('db',32),'hex'),sha256(convert_to('mosaic-billing-apple-transaction-v1'||chr(0)||'unclassified'||chr(0)||'transaction-stable','UTF8')),'not_retained','verified_transport','unclassified','accepted','stable',$1,$1+interval '1 hour'); + INSERT INTO billing_raw_inputs(id,project_id,organization_id,environment_id,environment_mode,application_id,provider,source,source_authority,idempotency_key,content_digest,transaction_reference_digest,body_state,authentication_result,store_environment,ingestion_status,correlation_id,received_at,expires_at) VALUES('raw_stable','project_one','org_one','environment_one','production','app_one','app_store','migration_known_reference','store_reconciliation',decode(repeat('da',32),'hex'),decode(repeat('db',32),'hex'),sha256(convert_to('mosaic-billing-apple-transaction-v1','UTF8')||'\x00'::bytea||convert_to('unclassified','UTF8')||'\x00'::bytea||convert_to('transaction-stable','UTF8')),'not_retained','verified_transport','unclassified','accepted','stable',$1,$1::timestamptz+interval '1 hour'); INSERT INTO billing_validation_attempts(id,project_id,environment_id,raw_input_id,attempt_number,validator_version,started_at,completed_at,outcome,retryable,store_environment,latency_ms,correlation_id) VALUES('attempt_stable','project_one','environment_one','raw_stable',1,2,$1,$1,'validated',false,'production',1,'stable'); - INSERT INTO billing_migration_validation_bindings(id,program_id,project_id,environment_id,raw_input_id,provider,reference_kind,reference_digest,expected_application_id,expected_store_product_identifier,expected_store_environment,expected_mosaic_product_id,status,validation_attempt_id,evidence_digest,provider_watermark,accepted_at,completed_at) VALUES('binding_stable','program_ready','project_one','environment_one','raw_stable','app_store','app_store_transaction_id',sha256(convert_to('mosaic-billing-apple-transaction-v1'||chr(0)||'unclassified'||chr(0)||'transaction-stable','UTF8')),'app_one','store.product','production','customer_two','validated','attempt_stable',decode(repeat('dd',32),'hex'),$1,$1,$1); + INSERT INTO billing_migration_validation_bindings(id,program_id,project_id,environment_id,raw_input_id,provider,reference_kind,reference_digest,expected_application_id,expected_store_product_identifier,expected_store_environment,expected_mosaic_product_id,status,validation_attempt_id,evidence_digest,provider_watermark,accepted_at,completed_at) VALUES('binding_stable','program_ready','project_one','environment_one','raw_stable','app_store','app_store_transaction_id',sha256(convert_to('mosaic-billing-apple-transaction-v1','UTF8')||'\x00'::bytea||convert_to('unclassified','UTF8')||'\x00'::bytea||convert_to('transaction-stable','UTF8')),'app_one','store.product','production','customer_two','validated','attempt_stable',decode(repeat('dd',32),'hex'),$1,$1,$1); INSERT INTO billing_migration_source_pull_jobs(id,program_id,project_id,intent,idempotency_key,request_digest,expected_program_state_version,starting_cursor,starting_watermark,starting_watermark_digest,mapping_set_id,status,result_source_object_id,result_manifest_id,result_import_batch_id,resume_cursor,final_watermark,evidence_digest,record_count,current_access_count,import_record_count,due_at,lease_generation,attempt_count,max_attempts,created_by_actor_id,started_at,completed_at,created_at,updated_at) VALUES('pull_completed_predecessor','program_ready','project_one','snapshot','completed-predecessor',decode(repeat('c7',32),'hex'),7,'','',NULL,'mapping_ready','completed','object_predecessor','manifest_predecessor','batch_predecessor','cursor','watermark',decode(repeat('c9',32),'hex'),0,0,0,$1,1,1,8,'owner_one',$1,$1,$1,$1); INSERT INTO billing_migration_source_pull_jobs(id,program_id,project_id,intent,idempotency_key,request_digest,expected_program_state_version,starting_cursor,starting_watermark,starting_watermark_digest,predecessor_pull_job_id,mapping_set_id,status,result_source_object_id,result_manifest_id,result_import_batch_id,result_final_delta_job_id,resume_cursor,final_watermark,evidence_digest,record_count,current_access_count,import_record_count,due_at,lease_generation,attempt_count,max_attempts,created_by_actor_id,started_at,completed_at,created_at,updated_at) VALUES('pull_final_stable','program_ready','project_one','final_delta','final-stable',decode(repeat('c8',32),'hex'),7,'cursor','watermark',decode(repeat('c9',32),'hex'),'pull_completed_predecessor','mapping_ready','completed','object_stable','manifest_ready','batch_stable','delta_job_ready','','final-watermark',decode(repeat('ca',32),'hex'),2,2,1,$1,1,1,8,'owner_one',$1,$1,$1,$1); INSERT INTO webhook_destinations(id,project_id,environment_id,url,status,event_types,description,contract_version,last_successful_test_at,created_at,updated_at) VALUES('stable_webhook','project_one','environment_one','https://stable.example.test','active',ARRAY['authority.cutover.completed'],'stable',2,$1,$1,$1),('stable_webhook_missing','project_one','environment_one','https://missing.example.test','active',ARRAY['authority.cutover.completed'],'missing coverage',2,$1,$1,$1); INSERT INTO webhook_events(id,project_id,environment_id,event_type,billing_customer_id,snapshot_version,payload,occurred_at,created_at,contract_version,authority_scope_id,authority_epoch,authority_kind,transition_state,correlation_id,snapshot_authority_digest) VALUES('stable_event','project_one','environment_one','authority.cutover.completed','customer_one',0,'{}',$1,$1,2,'authority_ready_ios',1,'mosaic','stabilizing','stable-correlation',decode(repeat('a1',32),'hex')); INSERT INTO webhook_events(id,project_id,environment_id,event_type,billing_customer_id,snapshot_version,payload,occurred_at,created_at,contract_version,authority_scope_id,authority_epoch,authority_kind,transition_state,correlation_id,snapshot_authority_digest) VALUES('stable_event_android','project_one','environment_one','authority.cutover.completed','customer_one',0,'{}',$1,$1,2,'authority_ready_android',1,'mosaic','stabilizing','stable-correlation-android',decode(repeat('a1',32),'hex')); - INSERT INTO webhook_deliveries(id,project_id,environment_id,webhook_event_id,webhook_destination_id,status,attempt_count,max_attempts,created_at,updated_at,completed_at) VALUES('stable_delivery','project_one','environment_one','stable_event','stable_webhook','succeeded',1,8,$1,$1,$1),('stable_delivery_android','project_one','environment_one','stable_event_android','stable_webhook','succeeded',1,8,$1,$1,$1)`, now) + INSERT INTO webhook_deliveries(id,project_id,environment_id,webhook_event_id,webhook_destination_id,status,attempt_count,max_attempts,created_at,updated_at,completed_at) VALUES('stable_delivery','project_one','environment_one','stable_event','stable_webhook','succeeded',1,8,$1,$1,$1),('stable_delivery_android','project_one','environment_one','stable_event_android','stable_webhook','succeeded',1,8,$1,$1,$1)`, pgx.QueryExecModeSimpleProtocol, now) if err != nil { t.Fatal(err) } @@ -108,11 +109,11 @@ func TestStabilizationAndRollbackReadinessUsePersistedEvidence(t *testing.T) { if _, err = pool.Exec(ctx, `INSERT INTO billing_migration_rollback_readiness_checkpoints(id,program_id,project_id,assessment_id,assessment_ready,state_version,authority_epoch,authority_digest,policy_digest,evidence_digest,readiness_digest,checkpoint_digest,created_by_actor_id,created_at) VALUES('forged_not_ready','program_ready','project_one',$1,true,7,1,decode(repeat('f1',32),'hex'),decode(repeat('f2',32),'hex'),decode(repeat('f3',32),'hex'),decode(repeat('f4',32),'hex'),decode(repeat('f5',32),'hex'),'owner_one',$2)`, negativeAssessment.ID, now); err == nil { t.Fatal("database accepted checkpoint referencing a not-ready assessment") } - _, err = pool.Exec(ctx, `INSERT INTO billing_raw_inputs(id,project_id,organization_id,environment_id,environment_mode,application_id,provider,source,source_authority,idempotency_key,content_digest,transaction_reference_digest,body_state,authentication_result,store_environment,ingestion_status,correlation_id,received_at,expires_at) VALUES('raw_unvalidated','project_one','org_one','environment_one','production','app_one','app_store','migration_known_reference','store_reconciliation',decode(repeat('e1',32),'hex'),decode(repeat('e2',32),'hex'),sha256(convert_to('mosaic-billing-apple-transaction-v1'||chr(0)||'unclassified'||chr(0)||'transaction-unvalidated','UTF8')),'not_retained','verified_transport','unclassified','accepted','unvalidated',$1,$1+interval '1 hour'); + _, err = pool.Exec(ctx, `INSERT INTO billing_raw_inputs(id,project_id,organization_id,environment_id,environment_mode,application_id,provider,source,source_authority,idempotency_key,content_digest,transaction_reference_digest,body_state,authentication_result,store_environment,ingestion_status,correlation_id,received_at,expires_at) VALUES('raw_unvalidated','project_one','org_one','environment_one','production','app_one','app_store','migration_known_reference','store_reconciliation',decode(repeat('e1',32),'hex'),decode(repeat('e2',32),'hex'),sha256(convert_to('mosaic-billing-apple-transaction-v1','UTF8')||'\x00'::bytea||convert_to('unclassified','UTF8')||'\x00'::bytea||convert_to('transaction-unvalidated','UTF8')),'not_retained','verified_transport','unclassified','accepted','unvalidated',$1,$1::timestamptz+interval '1 hour'); INSERT INTO billing_validation_attempts(id,project_id,environment_id,raw_input_id,attempt_number,validator_version,started_at,completed_at,outcome,retryable,store_environment,latency_ms,correlation_id) VALUES('attempt_unvalidated','project_one','environment_one','raw_unvalidated',1,2,$1,$1,'validated',false,'production',1,'unvalidated'); - INSERT INTO billing_migration_validation_bindings(id,program_id,project_id,environment_id,raw_input_id,provider,reference_kind,reference_digest,expected_application_id,expected_store_product_identifier,expected_store_environment,expected_mosaic_product_id,status,validation_attempt_id,evidence_digest,provider_watermark,accepted_at,completed_at) VALUES('binding_unvalidated','program_ready','project_one','environment_one','raw_unvalidated','app_store','app_store_transaction_id',sha256(convert_to('mosaic-billing-apple-transaction-v1'||chr(0)||'unclassified'||chr(0)||'transaction-unvalidated','UTF8')),'app_one','store.product','production','customer_two','validated','attempt_unvalidated',decode(repeat('e4',32),'hex'),$1,$1,$1); + INSERT INTO billing_migration_validation_bindings(id,program_id,project_id,environment_id,raw_input_id,provider,reference_kind,reference_digest,expected_application_id,expected_store_product_identifier,expected_store_environment,expected_mosaic_product_id,status,validation_attempt_id,evidence_digest,provider_watermark,accepted_at,completed_at) VALUES('binding_unvalidated','program_ready','project_one','environment_one','raw_unvalidated','app_store','app_store_transaction_id',sha256(convert_to('mosaic-billing-apple-transaction-v1','UTF8')||'\x00'::bytea||convert_to('unclassified','UTF8')||'\x00'::bytea||convert_to('transaction-unvalidated','UTF8')),'app_one','store.product','production','customer_two','validated','attempt_unvalidated',decode(repeat('e4',32),'hex'),$1,$1,$1); INSERT INTO billing_migration_v2_sync_observations(id,program_id,project_id,application_id,platform,app_version,sdk_version,supported_contract_versions,authority_capabilities,traffic_count,authority_epoch,sync_result,observation_digest,observed_at) VALUES('stable_android_complete','program_ready','project_one','app_two','android','2.10.0+android.1','2.1.0',ARRAY['2'],ARRAY['authority_epoch','authority_scope','urgent_authority_sync','mosaic_authoritative_targeting'],1,1,'accepted',decode(repeat('e5',32),'hex'),$1); - INSERT INTO webhook_deliveries(id,project_id,environment_id,webhook_event_id,webhook_destination_id,status,attempt_count,max_attempts,created_at,updated_at,completed_at) VALUES('stable_delivery_missing_ios','project_one','environment_one','stable_event','stable_webhook_missing','succeeded',1,8,$1,$1,$1),('stable_delivery_missing_android','project_one','environment_one','stable_event_android','stable_webhook_missing','succeeded',1,8,$1,$1,$1)`, now) + INSERT INTO webhook_deliveries(id,project_id,environment_id,webhook_event_id,webhook_destination_id,status,attempt_count,max_attempts,created_at,updated_at,completed_at) VALUES('stable_delivery_missing_ios','project_one','environment_one','stable_event','stable_webhook_missing','succeeded',1,8,$1,$1,$1),('stable_delivery_missing_android','project_one','environment_one','stable_event_android','stable_webhook_missing','succeeded',1,8,$1,$1,$1)`, pgx.QueryExecModeSimpleProtocol, now) if err != nil { t.Fatal(err) } diff --git a/apps/dashboard/.prettierignore b/apps/dashboard/.prettierignore deleted file mode 100644 index 212789e5..00000000 --- a/apps/dashboard/.prettierignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules -dist -.output -coverage -src/routeTree.gen.ts -src/generated/ -package-lock.json diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index d43f9ef5..375d0634 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -274,9 +274,8 @@ npm run preview:relay # start only the loopback preview relay npm run generate:api # regenerate the REST client from docs/backend/openapi.yaml npm run build # create the production client and server bundles npm run start # serve the production build -npm run format # format local files -npm run format:check # verify formatting -npm run lint # run ESLint +npm run lint # lint and check formatting with Ultracite (Biome) +npm run lint:fix # apply Ultracite formatting and safe lint fixes npm run typecheck # run TypeScript without emitting files npm run test # run Vitest and relay integration tests once npm run test:relay # run relay protocol/routing integration tests diff --git a/apps/dashboard/biome.jsonc b/apps/dashboard/biome.jsonc new file mode 100644 index 00000000..cbb52016 --- /dev/null +++ b/apps/dashboard/biome.jsonc @@ -0,0 +1,91 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "extends": [ + "ultracite/biome/core", + "ultracite/biome/react", + "ultracite/biome/tanstack", + "ultracite/biome/vitest" + ], + "javascript": { + // Vite `define` constants, typed in src/build-info.d.ts. + "globals": [ + "__MOSAIC_BUILD_TIME__", + "__MOSAIC_COMMIT__", + "__MOSAIC_VERSION__" + ] + }, + "linter": { + "rules": { + "performance": { + // `delete` removes an optional field from a protocol document; the + // suggested `= undefined` leaves the key in place, which changes + // Object.keys, `in`, and deep-equality against a fixture. The two are + // not interchangeable here. + "noDelete": "off", + + // Most of what remains is a handler inside a .map that closes over the + // row it renders, where useCallback is not available and the fix is a + // child component per list. The handlers that could be lifted have + // been: see the useCallback pass across the feature components. + "noJsxPropsBind": "off" + }, + "complexity": { + // The five worst offenders were decomposed: the canvas preview + // renderer, the document validator, the editor keydown handler, the + // preview socket message handler and the relay frame handler, together + // 102/97/93/92/89 down to clean/24/31/38/43. What is left tops out at + // 82 and is mostly React render callbacks, where splitting means + // extracting components rather than moving logic. 90 holds that line + // and still fails anything worse than today's worst. + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { "maxAllowedComplexity": 90 } + } + }, + "suspicious": { + // Type-aware, and its inference disagrees with tsc. It reads + // `data?.proposals ?? []` as a redundant coalesce where `data` is + // `LifecycleData | undefined`; removing it, as the rule asks, spreads + // undefined and fails to compile (TS2488). It also resolves + // `Exclude<...>` to never, so reachable switch cases read as dead, and + // misses a `let` reassigned inside a closure. A rule that is wrong + // about types is worse than no rule, because acting on it breaks code. + "noUnnecessaryConditions": "off" + } + } + }, + "assist": { + "actions": { + "source": { + // Object key order is observable here (locale precedence via + // Object.keys, protocol document field order), so leave it alone. + "useSortedKeys": "off" + } + } + }, + "overrides": [ + { + "includes": ["**/*.test.ts", "**/*.test.tsx", "**/*.test.mjs"], + "linter": { + "rules": { + "performance": { + // Testing Library name matchers are regex literals by design. The + // rule guards against recompiling in a hot path, which a matcher + // evaluated once per assertion is not. + "useTopLevelRegex": "off" + } + } + } + } + ], + "files": { + "includes": [ + "!dist", + "!.output", + "!coverage", + "!src/generated", + "!src/routeTree.gen.ts", + "!package-lock.json" + ] + } +} diff --git a/apps/dashboard/eslint.config.mjs b/apps/dashboard/eslint.config.mjs deleted file mode 100644 index 39ea4ddf..00000000 --- a/apps/dashboard/eslint.config.mjs +++ /dev/null @@ -1,58 +0,0 @@ -import eslint from "@eslint/js" -import pluginQuery from "@tanstack/eslint-plugin-query" -import reactHooks from "eslint-plugin-react-hooks" -import reactRefresh from "eslint-plugin-react-refresh" -import globals from "globals" -import tseslint from "typescript-eslint" - -export default tseslint.config( - { - ignores: ["dist/**", ".output/**", "coverage/**", "src/generated/**", "src/routeTree.gen.ts"], - }, - eslint.configs.recommended, - ...tseslint.configs.recommended, - ...pluginQuery.configs["flat/recommended"], - reactHooks.configs.flat.recommended, - { - files: ["scripts/**/*.mjs"], - languageOptions: { - ecmaVersion: "latest", - globals: globals.node, - sourceType: "module", - }, - }, - { - files: ["**/*.{ts,tsx}"], - languageOptions: { - ecmaVersion: "latest", - globals: { - ...globals.browser, - ...globals.node, - }, - parserOptions: { - ecmaFeatures: { jsx: true }, - }, - sourceType: "module", - }, - plugins: { - "react-refresh": reactRefresh, - }, - rules: { - ...reactRefresh.configs.vite.rules, - }, - }, - { - files: ["src/routes/**/*.tsx"], - rules: { - // File-route modules must export TanStack's generated Route constant. - "react-refresh/only-export-components": "off", - }, - }, - { - files: ["src/features/paywall-editor/stores/editor-store-context.tsx"], - rules: { - // This feature context intentionally colocates its provider and typed hooks. - "react-refresh/only-export-components": "off", - }, - }, -) diff --git a/apps/dashboard/package-lock.json b/apps/dashboard/package-lock.json index e2c93708..37da35d3 100644 --- a/apps/dashboard/package-lock.json +++ b/apps/dashboard/package-lock.json @@ -30,10 +30,9 @@ "ws": "^8.21.1" }, "devDependencies": { - "@eslint/js": "^10.0.1", + "@biomejs/biome": "^2.5.6", "@hey-api/openapi-ts": "0.99.0", "@tailwindcss/vite": "^4.3.3", - "@tanstack/eslint-plugin-query": "^5.101.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -42,16 +41,10 @@ "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^6.0.3", - "eslint": "^10.7.0", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.3", - "globals": "^17.7.0", "jsdom": "^29.1.1", - "prettier": "^3.9.5", - "prettier-plugin-tailwindcss": "^0.8.1", "tailwindcss": "^4.3.3", "typescript": "^6.0.3", - "typescript-eslint": "^8.64.0", + "ultracite": "^7.9.4", "vite": "^8.1.5", "vitest": "^4.1.10" }, @@ -464,6 +457,169 @@ } } }, + "node_modules/@biomejs/biome": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.6.tgz", + "integrity": "sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.6", + "@biomejs/cli-darwin-x64": "2.5.6", + "@biomejs/cli-linux-arm64": "2.5.6", + "@biomejs/cli-linux-arm64-musl": "2.5.6", + "@biomejs/cli-linux-x64": "2.5.6", + "@biomejs/cli-linux-x64-musl": "2.5.6", + "@biomejs/cli-win32-arm64": "2.5.6", + "@biomejs/cli-win32-x64": "2.5.6" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.6.tgz", + "integrity": "sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.6.tgz", + "integrity": "sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.6.tgz", + "integrity": "sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.6.tgz", + "integrity": "sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.6.tgz", + "integrity": "sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -477,6 +633,36 @@ "specificity": "bin/cli.js" } }, + "node_modules/@clack/core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz", + "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz", + "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.3", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", @@ -676,6 +862,7 @@ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -686,6 +873,7 @@ "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", @@ -701,6 +889,7 @@ "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@eslint/core": "^1.2.1" }, @@ -714,6 +903,7 @@ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -721,33 +911,13 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, "node_modules/@eslint/object-schema": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } @@ -758,6 +928,7 @@ "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" @@ -975,6 +1146,7 @@ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@humanfs/types": "^0.15.0" }, @@ -988,6 +1160,7 @@ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", @@ -1003,6 +1176,7 @@ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=18.18.0" } @@ -1013,6 +1187,7 @@ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=12.22" }, @@ -1027,6 +1202,7 @@ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=18.18" }, @@ -1754,29 +1930,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tanstack/eslint-plugin-query": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.101.2.tgz", - "integrity": "sha512-cPE99s3XZwlObfn8lCezT4j4JLj2CVzpIEywx0H4hzfPsX/o9QhdwaOwcDXxrQAqx2ds7TbvTinxhB8B/ywb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/utils": "^8.58.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": "^5.4.0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@tanstack/form-core": { "version": "1.33.2", "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.33.2.tgz", @@ -2520,7 +2673,8 @@ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/estree": { "version": "1.0.9", @@ -2576,70 +2730,6 @@ "@types/node": "*" } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", - "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.64.0", - "@typescript-eslint/type-utils": "8.64.0", - "@typescript-eslint/utils": "8.64.0", - "@typescript-eslint/visitor-keys": "8.64.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.64.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", - "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.64.0", - "@typescript-eslint/types": "8.64.0", - "@typescript-eslint/typescript-estree": "8.64.0", - "@typescript-eslint/visitor-keys": "8.64.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, "node_modules/@typescript-eslint/project-service": { "version": "8.64.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", @@ -2697,31 +2787,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz", - "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.64.0", - "@typescript-eslint/typescript-estree": "8.64.0", - "@typescript-eslint/utils": "8.64.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, "node_modules/@typescript-eslint/types": { "version": "8.64.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", @@ -3019,6 +3084,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3032,6 +3098,7 @@ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -3300,6 +3367,13 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "dev": true, + "license": "MIT" + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -3557,7 +3631,18 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, "node_modules/default-browser": { "version": "5.5.0", @@ -3719,6 +3804,7 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -3732,6 +3818,7 @@ "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -3785,42 +3872,13 @@ } } }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", - "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", - "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": "^9 || ^10" - } - }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", @@ -3853,6 +3911,7 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3870,6 +3929,7 @@ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, @@ -3882,7 +3942,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/espree": { "version": "11.2.0", @@ -3890,6 +3951,7 @@ "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", @@ -3908,6 +3970,7 @@ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, @@ -3921,6 +3984,7 @@ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -3934,6 +3998,7 @@ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -3947,6 +4012,7 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -3967,6 +4033,7 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3998,15 +4065,34 @@ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, "license": "MIT" }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", @@ -4023,6 +4109,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4052,6 +4148,7 @@ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "flat-cache": "^4.0.0" }, @@ -4065,6 +4162,7 @@ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -4082,6 +4180,7 @@ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -4095,7 +4194,8 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/fsevents": { "version": "2.3.3", @@ -4144,12 +4244,31 @@ "giget": "dist/cli.mjs" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -4157,19 +4276,6 @@ "node": ">=10.13.0" } }, - "node_modules/globals": { - "version": "17.7.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", - "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4202,23 +4308,6 @@ } } }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -4238,6 +4327,7 @@ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4" } @@ -4248,6 +4338,7 @@ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.8.19" } @@ -4284,6 +4375,7 @@ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4294,6 +4386,7 @@ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -4477,7 +4570,8 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json-schema-traverse": { "version": "1.0.0", @@ -4490,7 +4584,8 @@ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json5": { "version": "2.2.3", @@ -4504,12 +4599,20 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -4520,6 +4623,7 @@ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -4783,6 +4887,7 @@ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -4854,6 +4959,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4884,7 +4999,8 @@ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/node-releases": { "version": "2.0.51", @@ -4895,6 +5011,24 @@ "node": ">=18" } }, + "node_modules/nypm": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.9.tgz", + "integrity": "sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.2.4" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -4943,6 +5077,7 @@ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -4961,6 +5096,7 @@ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -4977,6 +5113,7 @@ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -5006,6 +5143,7 @@ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -5020,6 +5158,33 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -5111,6 +5276,7 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8.0" } @@ -5130,85 +5296,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/prettier-plugin-tailwindcss": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.8.1.tgz", - "integrity": "sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.19" - }, - "peerDependencies": { - "@ianvs/prettier-plugin-sort-imports": "*", - "@prettier/plugin-hermes": "*", - "@prettier/plugin-oxc": "*", - "@prettier/plugin-pug": "*", - "@shopify/prettier-plugin-liquid": "*", - "@trivago/prettier-plugin-sort-imports": "*", - "@zackad/prettier-plugin-twig": "*", - "prettier": "^3.0", - "prettier-plugin-astro": "*", - "prettier-plugin-css-order": "*", - "prettier-plugin-jsdoc": "*", - "prettier-plugin-marko": "*", - "prettier-plugin-multiline-arrays": "*", - "prettier-plugin-organize-attributes": "*", - "prettier-plugin-organize-imports": "*", - "prettier-plugin-sort-imports": "*", - "prettier-plugin-svelte": "*" - }, - "peerDependenciesMeta": { - "@ianvs/prettier-plugin-sort-imports": { - "optional": true - }, - "@prettier/plugin-hermes": { - "optional": true - }, - "@prettier/plugin-oxc": { - "optional": true - }, - "@prettier/plugin-pug": { - "optional": true - }, - "@shopify/prettier-plugin-liquid": { - "optional": true - }, - "@trivago/prettier-plugin-sort-imports": { - "optional": true - }, - "@zackad/prettier-plugin-twig": { - "optional": true - }, - "prettier-plugin-astro": { - "optional": true - }, - "prettier-plugin-css-order": { - "optional": true - }, - "prettier-plugin-jsdoc": { - "optional": true - }, - "prettier-plugin-marko": { - "optional": true - }, - "prettier-plugin-multiline-arrays": { - "optional": true - }, - "prettier-plugin-organize-attributes": { - "optional": true - }, - "prettier-plugin-organize-imports": { - "optional": true - }, - "prettier-plugin-sort-imports": { - "optional": true - }, - "prettier-plugin-svelte": { - "optional": true - } - } - }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -5467,6 +5554,13 @@ "dev": true, "license": "ISC" }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -5688,6 +5782,7 @@ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -5709,36 +5804,46 @@ "node": ">=14.17" } }, - "node_modules/typescript-eslint": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz", - "integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==", + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/ultracite": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/ultracite/-/ultracite-7.9.4.tgz", + "integrity": "sha512-efAEbQJoii3wH0aZ+jwbcGXQYBwC1E6TvC4//EIV3ei84RDGHDo9fSB6h/TBTNk/+/psQVGm56trVvA+2VwRbg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.64.0", - "@typescript-eslint/parser": "8.64.0", - "@typescript-eslint/typescript-estree": "8.64.0", - "@typescript-eslint/utils": "8.64.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@clack/prompts": "^1.5.1", + "@typescript-eslint/utils": "^8.62.1", + "commander": "^15.0.0", + "cross-spawn": "^7.0.6", + "deepmerge": "^4.3.1", + "glob": "^13.0.6", + "jsonc-parser": "^3.3.1", + "nypm": "^0.6.6", + "yaml": "^2.9.0", + "zod": "^4.4.3" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "bin": { + "ultracite": "dist/index.js" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "oxfmt": ">=0.1.0", + "oxlint": "^1.0.0" + }, + "peerDependenciesMeta": { + "oxfmt": { + "optional": true + }, + "oxlint": { + "optional": true + } } }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", - "license": "MIT" - }, "node_modules/undici": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", @@ -5846,6 +5951,7 @@ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "punycode": "^2.1.0" } @@ -6139,6 +6245,7 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6219,12 +6326,29 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6241,19 +6365,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 507ef793..de9787eb 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -24,14 +24,13 @@ "preview:relay": "node scripts/local-preview-relay.mjs", "build": "vite build", "start": "srvx --prod -s ../client dist/server/server.js", - "format": "prettier --write .", - "format:check": "prettier --check .", - "lint": "eslint . --max-warnings=0", + "lint": "ultracite check", + "lint:fix": "ultracite fix", "typecheck": "tsc --noEmit", "test": "vitest run", "test:relay": "node --test scripts/local-preview-relay.test.mjs", "test:watch": "vitest", - "check": "npm run format:check && npm run lint && npm run typecheck && npm run test && npm run test:relay && npm run build" + "check": "npm run lint && npm run typecheck && npm run test && npm run test:relay && npm run build" }, "dependencies": { "@base-ui/react": "^1.6.0", @@ -56,10 +55,9 @@ "ws": "^8.21.1" }, "devDependencies": { - "@eslint/js": "^10.0.1", + "@biomejs/biome": "^2.5.6", "@hey-api/openapi-ts": "0.99.0", "@tailwindcss/vite": "^4.3.3", - "@tanstack/eslint-plugin-query": "^5.101.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -68,16 +66,10 @@ "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^6.0.3", - "eslint": "^10.7.0", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.3", - "globals": "^17.7.0", "jsdom": "^29.1.1", - "prettier": "^3.9.5", - "prettier-plugin-tailwindcss": "^0.8.1", "tailwindcss": "^4.3.3", "typescript": "^6.0.3", - "typescript-eslint": "^8.64.0", + "ultracite": "^7.9.4", "vite": "^8.1.5", "vitest": "^4.1.10" } diff --git a/apps/dashboard/prettier.config.mjs b/apps/dashboard/prettier.config.mjs deleted file mode 100644 index de123508..00000000 --- a/apps/dashboard/prettier.config.mjs +++ /dev/null @@ -1,7 +0,0 @@ -/** @type {import("prettier").Config} */ -export default { - plugins: ["prettier-plugin-tailwindcss"], - printWidth: 100, - semi: false, - trailingComma: "all", -} diff --git a/apps/dashboard/scripts/dev-studio.mjs b/apps/dashboard/scripts/dev-studio.mjs index 59070d60..99bc255a 100644 --- a/apps/dashboard/scripts/dev-studio.mjs +++ b/apps/dashboard/scripts/dev-studio.mjs @@ -1,27 +1,33 @@ -import { spawn } from "node:child_process" +import { spawn } from "node:child_process"; -const npm = process.platform === "win32" ? "npm.cmd" : "npm" +const npm = process.platform === "win32" ? "npm.cmd" : "npm"; const processes = [ spawn(npm, ["run", "preview:relay"], { stdio: "inherit" }), spawn(npm, ["run", "dev"], { stdio: "inherit" }), -] +]; -let stopping = false +let stopping = false; function stop(exitCode = 0) { - if (stopping) return - stopping = true + if (stopping) { + return; + } + stopping = true; for (const child of processes) { - if (!child.killed) child.kill("SIGTERM") + if (!child.killed) { + child.kill("SIGTERM"); + } } - process.exitCode = exitCode + process.exitCode = exitCode; } for (const child of processes) { child.on("exit", (code, signal) => { - if (!stopping) stop(signal ? 1 : (code ?? 1)) - }) - child.on("error", () => stop(1)) + if (!stopping) { + stop(signal ? 1 : (code ?? 1)); + } + }); + child.on("error", () => stop(1)); } -process.once("SIGINT", () => stop(0)) -process.once("SIGTERM", () => stop(0)) +process.once("SIGINT", () => stop(0)); +process.once("SIGTERM", () => stop(0)); diff --git a/apps/dashboard/scripts/live-preview-demo.mjs b/apps/dashboard/scripts/live-preview-demo.mjs index b9754306..c67aebbf 100644 --- a/apps/dashboard/scripts/live-preview-demo.mjs +++ b/apps/dashboard/scripts/live-preview-demo.mjs @@ -1,138 +1,167 @@ -import { readFile } from "node:fs/promises" +import { readFile } from "node:fs/promises"; -import WebSocket from "ws" +import WebSocket from "ws"; import { localPreviewWebSocketProtocols, validatePreviewMessage, -} from "../../../protocol/browser/index.js" - -const endpoint = process.env.MOSAIC_PREVIEW_ENDPOINT ?? "ws://127.0.0.1:4317/preview" -const sessionId = process.env.MOSAIC_PREVIEW_SESSION_ID ?? "session_local_01" -const revisionSequence = Number.parseInt(process.env.MOSAIC_DEMO_SEQUENCE ?? "2", 10) +} from "../../../protocol/browser/index.js"; + +const endpoint = + process.env.MOSAIC_PREVIEW_ENDPOINT ?? "ws://127.0.0.1:4317/preview"; +const sessionId = process.env.MOSAIC_PREVIEW_SESSION_ID ?? "session_local_01"; +const revisionSequence = Number.parseInt( + process.env.MOSAIC_DEMO_SEQUENCE ?? "2", + 10 +); if (!Number.isSafeInteger(revisionSequence) || revisionSequence < 2) { - throw new Error("MOSAIC_DEMO_SEQUENCE must be an integer of at least 2.") + throw new Error("MOSAIC_DEMO_SEQUENCE must be an integer of at least 2."); } -const revisionSuffix = String(revisionSequence).padStart(6, "0") -const revisionId = `revision_phase2_live_demo_${revisionSuffix}` -const holdMilliseconds = Number.parseInt(process.env.MOSAIC_DEMO_HOLD_MS ?? "0", 10) +const revisionSuffix = String(revisionSequence).padStart(6, "0"); +const revisionId = `revision_phase2_live_demo_${revisionSuffix}`; +const holdMilliseconds = Number.parseInt( + process.env.MOSAIC_DEMO_HOLD_MS ?? "0", + 10 +); if (!Number.isSafeInteger(holdMilliseconds) || holdMilliseconds < 0) { - throw new Error("MOSAIC_DEMO_HOLD_MS must be a non-negative integer.") + throw new Error("MOSAIC_DEMO_HOLD_MS must be a non-negative integer."); } -const expectedRenderers = new Set(["mosaic.flutter", "mosaic.ios", "mosaic.android"]) -const headline = "Ship one native paywall in under a minute" -const deadline = Date.now() + 20_000 +const expectedRenderers = new Set([ + "mosaic.flutter", + "mosaic.ios", + "mosaic.android", +]); +const headline = "Ship one native paywall in under a minute"; +const deadline = Date.now() + 20_000; const fixtures = JSON.parse( await readFile( new URL( "../../../protocol/fixtures/local-preview/v0.2/session-flow.messages.json", - import.meta.url, + import.meta.url ), - "utf8", - ), -) + "utf8" + ) +); -const clients = new Map() -const capabilities = new Set() -const acknowledgements = new Set() -const diagnostics = [] +const clients = new Map(); +const capabilities = new Set(); +const acknowledgements = new Set(); +const diagnostics = []; -const studioUrl = new URL(endpoint) -studioUrl.searchParams.set("role", "studio") -studioUrl.searchParams.set("sessionId", sessionId) -const socket = new WebSocket(studioUrl, localPreviewWebSocketProtocols["0.2"]) +const studioUrl = new URL(endpoint); +studioUrl.searchParams.set("role", "studio"); +studioUrl.searchParams.set("sessionId", sessionId); +const socket = new WebSocket(studioUrl, localPreviewWebSocketProtocols["0.2"]); socket.on("message", (frame) => { - const message = JSON.parse(frame.toString()) + const message = JSON.parse(frame.toString()); if (!validatePreviewMessage(message).ok) { - diagnostics.push({ type: "invalidMessage", messageId: message.messageId }) - return + diagnostics.push({ type: "invalidMessage", messageId: message.messageId }); + return; } if (message.type === "previewClientConnected") { - clients.set(message.payload.client.clientId, message.payload.client) + clients.set(message.payload.client.clientId, message.payload.client); } else if (message.type === "capabilityReport") { - capabilities.add(message.payload.clientId) + capabilities.add(message.payload.clientId); } else if ( message.type === "draftAccepted" && message.payload.editableDocumentId === "document_phase2_live_demo" && message.payload.revision.revisionId === revisionId && message.payload.revision.sequence === revisionSequence ) { - acknowledgements.add(message.payload.clientId) + acknowledgements.add(message.payload.clientId); } else if ( - ["draftRejected", "validationError", "renderWarning", "renderFailure"].includes(message.type) + [ + "draftRejected", + "validationError", + "renderWarning", + "renderFailure", + ].includes(message.type) ) { diagnostics.push({ type: message.type, clientId: message.payload.clientId, - codes: (message.payload.warnings ?? message.payload.errors ?? []).map((entry) => entry.code), - }) + codes: (message.payload.warnings ?? message.payload.errors ?? []).map( + (entry) => entry.code + ), + }); } -}) +}); await new Promise((resolve, reject) => { - socket.once("open", resolve) - socket.once("error", reject) -}) + socket.once("open", resolve); + socket.once("error", reject); +}); await waitUntil(() => { const readyRenderers = new Set( [...clients.entries()] .filter(([clientId]) => capabilities.has(clientId)) - .map(([, client]) => client.renderer.id), - ) - return [...expectedRenderers].every((renderer) => readyRenderers.has(renderer)) -}) + .map(([, client]) => client.renderer.id) + ); + return [...expectedRenderers].every((renderer) => + readyRenderers.has(renderer) + ); +}); const commerce = structuredClone( - fixtures.find((message) => message.type === "mockCommerceStateChanged"), -) -commerce.messageId = `msg_phase2_live_demo_commerce_${revisionSuffix}` -commerce.sessionId = sessionId -commerce.sentAt = new Date().toISOString() -commerce.payload.editableDocumentId = "document_phase2_live_demo" + fixtures.find((message) => message.type === "mockCommerceStateChanged") +); +commerce.messageId = `msg_phase2_live_demo_commerce_${revisionSuffix}`; +commerce.sessionId = sessionId; +commerce.sentAt = new Date().toISOString(); +commerce.payload.editableDocumentId = "document_phase2_live_demo"; commerce.payload.stateRevision = { revisionId: `revision_phase2_live_demo_commerce_${String(revisionSequence - 1).padStart(6, "0")}`, sequence: revisionSequence - 1, -} - -const draft = structuredClone(fixtures.find((message) => message.type === "draftUpdated")) -draft.messageId = `msg_phase2_live_demo_draft_${revisionSuffix}` -draft.sessionId = sessionId -draft.sentAt = new Date().toISOString() -draft.payload.editableDocumentId = "document_phase2_live_demo" +}; + +const draft = structuredClone( + fixtures.find((message) => message.type === "draftUpdated") +); +draft.messageId = `msg_phase2_live_demo_draft_${revisionSuffix}`; +draft.sessionId = sessionId; +draft.sentAt = new Date().toISOString(); +draft.payload.editableDocumentId = "document_phase2_live_demo"; draft.payload.revision = { revisionId, sequence: revisionSequence, -} -draft.payload.document.revision = revisionSequence -draft.payload.document.localization.locales.en.strings["paywall.headline"] = headline -const children = draft.payload.document.layout.content.children -const headlineNode = children.find((node) => node.id === "headline") -headlineNode.value.default = headline -const productSelector = children.find((node) => node.id === "plans") +}; +draft.payload.document.revision = revisionSequence; +draft.payload.document.localization.locales.en.strings["paywall.headline"] = + headline; +const { children } = draft.payload.document.layout.content; +const headlineNode = children.find((node) => node.id === "headline"); +headlineNode.value.default = headline; +const productSelector = children.find((node) => node.id === "plans"); if (!productSelector.productReferenceIds.includes("yearly-plan")) { - throw new Error("The demo document does not bind the yearly product.") + throw new Error("The demo document does not bind the yearly product."); } -productSelector.initiallySelectedProductReferenceId = "yearly-plan" -const featureIndex = children.findIndex((node) => node.id === "features") -const subtitleIndex = children.findIndex((node) => node.id === "subtitle") -const [features] = children.splice(featureIndex, 1) -children.splice(subtitleIndex, 0, features) +productSelector.initiallySelectedProductReferenceId = "yearly-plan"; +const featureIndex = children.findIndex((node) => node.id === "features"); +const subtitleIndex = children.findIndex((node) => node.id === "subtitle"); +const [features] = children.splice(featureIndex, 1); +children.splice(subtitleIndex, 0, features); for (const message of [commerce, draft]) { - const result = validatePreviewMessage(message) - if (!result.ok) throw new Error(`Demo message ${message.type} is not protocol-valid.`) - socket.send(JSON.stringify(message)) + const result = validatePreviewMessage(message); + if (!result.ok) { + throw new Error(`Demo message ${message.type} is not protocol-valid.`); + } + socket.send(JSON.stringify(message)); } await waitUntil(() => { const acknowledgedRenderers = new Set( - [...acknowledgements].map((clientId) => clients.get(clientId)?.renderer.id).filter(Boolean), - ) - return [...expectedRenderers].every((renderer) => acknowledgedRenderers.has(renderer)) -}) + [...acknowledgements] + .map((clientId) => clients.get(clientId)?.renderer.id) + .filter(Boolean) + ); + return [...expectedRenderers].every((renderer) => + acknowledgedRenderers.has(renderer) + ); +}); const result = { sessionId, @@ -150,14 +179,14 @@ const result = { })) .sort((left, right) => left.renderer.localeCompare(right.renderer)), diagnostics, -} +}; -console.log(JSON.stringify(result, null, 2)) +console.log(JSON.stringify(result, null, 2)); -const holdDeadline = Date.now() + holdMilliseconds -let heartbeatSequence = 0 +const holdDeadline = Date.now() + holdMilliseconds; +let heartbeatSequence = 0; while (Date.now() < holdDeadline) { - heartbeatSequence += 1 + heartbeatSequence += 1; for (const client of result.clients) { const heartbeat = { previewProtocolVersion: "0.2", @@ -165,14 +194,23 @@ while (Date.now() < holdDeadline) { sessionId, sentAt: new Date().toISOString(), type: "previewHeartbeat", - payload: { clientId: client.clientId, kind: "ping", sequence: heartbeatSequence }, + payload: { + clientId: client.clientId, + kind: "ping", + sequence: heartbeatSequence, + }, + }; + if (!validatePreviewMessage(heartbeat).ok) { + throw new Error("Demo heartbeat is not valid."); } - if (!validatePreviewMessage(heartbeat).ok) throw new Error("Demo heartbeat is not valid.") - socket.send(JSON.stringify(heartbeat)) + socket.send(JSON.stringify(heartbeat)); } - await new Promise((resolve) => setTimeout(resolve, Math.min(2_000, holdDeadline - Date.now()))) + // biome-ignore lint/performance/noAwaitInLoops: paces the demo; the next frame is meant to follow the delay + await new Promise((resolve) => + setTimeout(resolve, Math.min(2000, holdDeadline - Date.now())) + ); } -socket.close() +socket.close(); async function waitUntil(predicate) { while (!predicate()) { @@ -180,9 +218,10 @@ async function waitUntil(predicate) { throw new Error( `Timed out waiting for Flutter, SwiftUI, and Compose in ${sessionId}. ` + `Connected: ${[...clients.values()].map((client) => client.renderer.id).join(", ") || "none"}. ` + - `Acknowledged: ${[...acknowledgements].join(", ") || "none"}.`, - ) + `Acknowledged: ${[...acknowledgements].join(", ") || "none"}.` + ); } - await new Promise((resolve) => setTimeout(resolve, 25)) + // biome-ignore lint/performance/noAwaitInLoops: paces the demo; the next frame is meant to follow the delay + await new Promise((resolve) => setTimeout(resolve, 25)); } } diff --git a/apps/dashboard/scripts/local-preview-relay.mjs b/apps/dashboard/scripts/local-preview-relay.mjs index f55c7e73..2e20919e 100644 --- a/apps/dashboard/scripts/local-preview-relay.mjs +++ b/apps/dashboard/scripts/local-preview-relay.mjs @@ -1,27 +1,32 @@ -import { pathToFileURL } from "node:url" +import { pathToFileURL } from "node:url"; -import { WebSocket, WebSocketServer } from "ws" +import { WebSocket, WebSocketServer } from "ws"; import { decideLocalPreviewDraftDelivery, localPreviewVersionPreference, localPreviewWebSocketProtocols, validatePreviewMessage, -} from "../../../protocol/browser/index.js" +} from "../../../protocol/browser/index.js"; export const PREVIEW_SUBPROTOCOLS = Object.freeze( - localPreviewVersionPreference.map((version) => localPreviewWebSocketProtocols[version]), -) -export const PREVIEW_SUBPROTOCOL = localPreviewWebSocketProtocols["0.2"] -const HEARTBEAT_TIMEOUT_MS = 15_000 + localPreviewVersionPreference.map( + (version) => localPreviewWebSocketProtocols[version] + ) +); +export const PREVIEW_SUBPROTOCOL = localPreviewWebSocketProtocols["0.2"]; +const HEARTBEAT_TIMEOUT_MS = 15_000; const STUDIO_MESSAGE_TYPES = new Set([ "draftUpdated", "mockCommerceStateChanged", "previewHeartbeat", -]) +]); const VERSION_BY_SUBPROTOCOL = new Map( - Object.entries(localPreviewWebSocketProtocols).map(([version, protocol]) => [protocol, version]), -) + Object.entries(localPreviewWebSocketProtocols).map(([version, protocol]) => [ + protocol, + version, + ]) +); const CLIENT_MESSAGE_TYPES = new Set([ "previewClientDisconnected", "draftAccepted", @@ -30,21 +35,34 @@ const CLIENT_MESSAGE_TYPES = new Set([ "renderWarning", "renderFailure", "previewHeartbeat", -]) +]); function isLoopbackOrigin(origin) { - if (!origin) return true + if (!origin) { + return true; + } try { - const hostname = new URL(origin).hostname - return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]" + const { hostname } = new URL(origin); + return ( + hostname === "127.0.0.1" || + hostname === "localhost" || + hostname === "[::1]" + ); } catch { - return false + return false; } } function messageForVersion(message, targetVersion) { - if (message.previewProtocolVersion === targetVersion) return message - if (message.type === "draftUpdated" || message.type === "mockCommerceStateChanged") return null + if (message.previewProtocolVersion === targetVersion) { + return message; + } + if ( + message.type === "draftUpdated" || + message.type === "mockCommerceStateChanged" + ) { + return null; + } const translated = { ...message, previewProtocolVersion: targetVersion, @@ -52,25 +70,29 @@ function messageForVersion(message, targetVersion) { message.type === "capabilityReport" ? { ...message.payload, - previewCapabilities: message.payload.previewCapabilities.map((capability) => ({ - ...capability, - version: targetVersion, - })), + previewCapabilities: message.payload.previewCapabilities.map( + (capability) => ({ + ...capability, + version: targetVersion, + }) + ), } : message.payload, - } - return validatePreviewMessage(translated).ok ? translated : null + }; + return validatePreviewMessage(translated).ok ? translated : null; } function sendCanonical(socket, meta, message) { - const translated = messageForVersion(message, meta.protocolVersion) - if (!translated) return false - socket.send(JSON.stringify(translated)) - return true + const translated = messageForVersion(message, meta.protocolVersion); + if (!translated) { + return false; + } + socket.send(JSON.stringify(translated)); + return true; } function incompatibleDraftDecision(message, peerMeta) { - const document = message.payload.document + const { document } = message.payload; if (document.schemaVersion === "0.2") { return decideLocalPreviewDraftDelivery({ capabilityReport: peerMeta.capabilityReport, @@ -80,9 +102,11 @@ function incompatibleDraftDecision(message, peerMeta) { selectedVersion: peerMeta.protocolVersion, selectedWebSocketSubprotocol: peerMeta.protocol, }, - }) + }); + } + if (document.schemaVersion === peerMeta.protocolVersion) { + return { delivery: "send" }; } - if (document.schemaVersion === peerMeta.protocolVersion) return { delivery: "send" } return { delivery: "withhold", diagnostic: { @@ -91,17 +115,24 @@ function incompatibleDraftDecision(message, peerMeta) { fallback: "keepLastAcceptedDraft", recovery: { action: "updatePreviewClient", - message: "Update the preview client to a version that supports this paywall format.", + message: + "Update the preview client to a version that supports this paywall format.", }, }, - } + }; } function rejectionReason(code) { - if (code === "preview.incompatibleSchemaVersion") return "unsupportedSchemaVersion" - if (code === "preview.unsupportedCapability") return "unsupportedCapability" - if (code === "preview.documentTooLarge") return "documentTooLarge" - return "validationFailed" + if (code === "preview.incompatibleSchemaVersion") { + return "unsupportedSchemaVersion"; + } + if (code === "preview.unsupportedCapability") { + return "unsupportedCapability"; + } + if (code === "preview.documentTooLarge") { + return "documentTooLarge"; + } + return "validationFailed"; } function draftRejection(message, clientId, diagnostic, protocolVersion) { @@ -120,12 +151,15 @@ function draftRejection(message, clientId, diagnostic, protocolVersion) { { code: diagnostic.code, message: diagnostic.message, - location: { documentPath: "/schemaVersion", property: "schemaVersion" }, + location: { + documentPath: "/schemaVersion", + property: "schemaVersion", + }, recovery: diagnostic.recovery, }, ], }, - } + }; } function relayMessage( @@ -135,11 +169,11 @@ function relayMessage( sender, message, targetRole, - targetClientId = null, + targetClientId = null ) { - const senderMeta = metadata.get(sender) + const senderMeta = metadata.get(sender); for (const peer of server.clients) { - const peerMeta = metadata.get(peer) + const peerMeta = metadata.get(peer); if ( peer !== sender && peer.readyState === WebSocket.OPEN && @@ -149,7 +183,7 @@ function relayMessage( (targetRole !== "client" || peerMeta.phase === "ready") ) { if (targetRole === "client" && message.type === "draftUpdated") { - const decision = incompatibleDraftDecision(message, peerMeta) + const decision = incompatibleDraftDecision(message, peerMeta); if (decision.delivery === "withhold") { sendCanonical( sender, @@ -158,10 +192,10 @@ function relayMessage( message, peerMeta.clientId, decision.diagnostic, - senderMeta.protocolVersion, - ), - ) - continue + senderMeta.protocolVersion + ) + ); + continue; } } if ( @@ -169,9 +203,9 @@ function relayMessage( message.type === "mockCommerceStateChanged" && message.previewProtocolVersion !== peerMeta.protocolVersion ) { - continue + continue; } - sendCanonical(peer, peerMeta, message) + sendCanonical(peer, peerMeta, message); } } } @@ -184,71 +218,98 @@ function relayEnvelope(protocolVersion, sessionId, type, payload) { sentAt: new Date().toISOString(), type, payload, - } + }; } -export function createPreviewRelay({ host = "127.0.0.1", port = 4317, path = "/preview" } = {}) { +export function createPreviewRelay({ + host = "127.0.0.1", + port = 4317, + path = "/preview", +} = {}) { if (host !== "127.0.0.1" && host !== "::1" && host !== "localhost") { - throw new Error("The Phase 2 preview relay may only bind to a loopback interface.") + throw new Error( + "The Phase 2 preview relay may only bind to a loopback interface." + ); } - const metadata = new WeakMap() - const cachedClients = new Map() + const metadata = new WeakMap(); + const cachedClients = new Map(); const server = new WebSocketServer({ host, port, path, maxPayload: 2_097_152, handleProtocols(protocols) { - return PREVIEW_SUBPROTOCOLS.find((protocol) => protocols.has(protocol)) ?? false + return ( + PREVIEW_SUBPROTOCOLS.find((protocol) => protocols.has(protocol)) ?? + false + ); }, verifyClient(info, done) { if (!isLoopbackOrigin(info.origin)) { - done(false, 403, "Loopback origins only") - return + done(false, 403, "Loopback origins only"); + return; } - const requestedProtocols = String(info.req.headers["sec-websocket-protocol"] ?? "") + const requestedProtocols = String( + info.req.headers["sec-websocket-protocol"] ?? "" + ) .split(",") - .map((value) => value.trim()) - if (!PREVIEW_SUBPROTOCOLS.some((protocol) => requestedProtocols.includes(protocol))) { - done(false, 426, "Required WebSocket subprotocol missing") - return + .map((value) => value.trim()); + if ( + !PREVIEW_SUBPROTOCOLS.some((protocol) => + requestedProtocols.includes(protocol) + ) + ) { + done(false, 426, "Required WebSocket subprotocol missing"); + return; } - done(true) + done(true); }, - }) + }); const ready = new Promise((resolve, reject) => { - server.once("listening", resolve) - server.once("error", reject) - }) + server.once("listening", resolve); + server.once("error", reject); + }); function sessionCache(sessionId) { - let cache = cachedClients.get(sessionId) + let cache = cachedClients.get(sessionId); if (!cache) { - cache = new Map() - cachedClients.set(sessionId, cache) + cache = new Map(); + cachedClients.set(sessionId, cache); } - return cache + return cache; } function replayClients(socket, sessionId) { - const cache = cachedClients.get(sessionId) - if (!cache) return - const meta = metadata.get(socket) + const cache = cachedClients.get(sessionId); + if (!cache) { + return; + } + const meta = metadata.get(socket); for (const client of cache.values()) { - if (client.connected) sendCanonical(socket, meta, client.connected) - if (client.capability) sendCanonical(socket, meta, client.capability) + if (client.connected) { + sendCanonical(socket, meta, client.connected); + } + if (client.capability) { + sendCanonical(socket, meta, client.capability); + } } } function disconnectClient(socket, reason) { - const meta = metadata.get(socket) - if (!meta?.clientId || !meta.sessionId || meta.disconnected) return - meta.disconnected = true - const cache = cachedClients.get(meta.sessionId) - if (cache?.get(meta.clientId)?.socket !== socket) return - cache?.delete(meta.clientId) - if (cache?.size === 0) cachedClients.delete(meta.sessionId) + const meta = metadata.get(socket); + if (!(meta?.clientId && meta.sessionId) || meta.disconnected) { + return; + } + meta.disconnected = true; + const cache = cachedClients.get(meta.sessionId); + if (cache?.get(meta.clientId)?.socket !== socket) { + return; + } + cache?.delete(meta.clientId); + if (cache?.size === 0) { + cachedClients.delete(meta.sessionId); + } const message = relayEnvelope( meta.protocolVersion, meta.sessionId, @@ -256,21 +317,22 @@ export function createPreviewRelay({ host = "127.0.0.1", port = 4317, path = "/p { clientId: meta.clientId, reason, - }, - ) - relayMessage(server, metadata, meta.sessionId, socket, message, "studio") + } + ); + relayMessage(server, metadata, meta.sessionId, socket, message, "studio"); } server.on("connection", (socket, request) => { - const requestUrl = new URL(request.url ?? path, `http://${host}`) - const requestedRole = requestUrl.searchParams.get("role") - const isStudio = requestedRole === "studio" - const querySession = requestUrl.searchParams.get("sessionId") - const protocol = socket.protocol - const protocolVersion = VERSION_BY_SUBPROTOCOL.get(protocol) + const requestUrl = new URL(request.url ?? path, `http://${host}`); + const requestedRole = requestUrl.searchParams.get("role"); + const isStudio = requestedRole === "studio"; + const querySession = requestUrl.searchParams.get("sessionId"); + const { protocol } = socket; + const protocolVersion = VERSION_BY_SUBPROTOCOL.get(protocol); const meta = { role: isStudio ? "studio" : "client", - sessionId: isStudio && querySession?.startsWith("session_") ? querySession : null, + sessionId: + isStudio && querySession?.startsWith("session_") ? querySession : null, clientId: null, phase: isStudio ? "ready" : "awaitingConnected", lastActivityAt: Date.now(), @@ -278,122 +340,188 @@ export function createPreviewRelay({ host = "127.0.0.1", port = 4317, path = "/p protocol, protocolVersion, capabilityReport: null, - } - metadata.set(socket, meta) + }; + metadata.set(socket, meta); if ( !protocolVersion || - (requestedRole && requestedRole !== "studio" && requestedRole !== "client") || + (requestedRole && + requestedRole !== "studio" && + requestedRole !== "client") || (isStudio && !meta.sessionId) ) { - socket.close(1008, "A Studio connection requires a valid role and sessionId") - return + socket.close( + 1008, + "A Studio connection requires a valid role and sessionId" + ); + return; + } + if (meta.role === "studio" && meta.sessionId) { + replayClients(socket, meta.sessionId); } - if (meta.role === "studio" && meta.sessionId) replayClients(socket, meta.sessionId) socket.on("message", (data, isBinary) => { - if (isBinary) { - socket.close(1003, "JSON text messages only") - return - } - - let message - try { - message = JSON.parse(data.toString()) - } catch { - socket.close(1007, "Invalid JSON") - return - } - if (!validatePreviewMessage(message).ok) { - socket.close(1008, "Invalid preview message") - return - } - if (message.previewProtocolVersion !== meta.protocolVersion) { - socket.close(1008, "Message version does not match the negotiated subprotocol") - return - } - - if (meta.sessionId && meta.sessionId !== message.sessionId) { - socket.close(1008, "Session cannot change") - return - } - - if (meta.role === "studio") { - if (!STUDIO_MESSAGE_TYPES.has(message.type)) { - socket.close(1008, "Message direction is not allowed for Studio") - return + /** Frame-level checks every message has to pass. */ + const rejectInvalidFrame = () => { + if (!validatePreviewMessage(message).ok) { + socket.close(1008, "Invalid preview message"); + return true; } - } else if (meta.phase === "awaitingConnected") { - if (message.type !== "previewClientConnected") { - socket.close(1008, "The first client message must identify the preview client") - return + if (message.previewProtocolVersion !== meta.protocolVersion) { + socket.close( + 1008, + "Message version does not match the negotiated subprotocol" + ); + return true; } - } else if (meta.phase === "awaitingCapability") { - if (message.type !== "capabilityReport") { - socket.close(1008, "The capability report must follow client identity") - return + if (meta.sessionId && meta.sessionId !== message.sessionId) { + socket.close(1008, "Session cannot change"); + return true; } - } else if (meta.phase !== "ready" || !CLIENT_MESSAGE_TYPES.has(message.type)) { - socket.close(1008, "Message direction is not allowed for a preview client") - return - } - - if ( - meta.role === "client" && - meta.phase === "ready" && - cachedClients.get(message.sessionId)?.get(meta.clientId)?.socket !== socket - ) { - socket.close(1008, "This preview client connection has been replaced") - return - } + return false; + }; - meta.sessionId = message.sessionId - - if (message.type === "previewClientConnected") { - meta.clientId = message.payload.client.clientId - meta.phase = "awaitingCapability" - meta.disconnected = false - meta.connected = message - const cache = sessionCache(message.sessionId) - if (!cache.has(meta.clientId)) { - cache.set(meta.clientId, { connected: message, capability: null, socket }) - } - } else if (message.type === "capabilityReport") { - if (message.payload.clientId !== meta.clientId) { - socket.close(1008, "Capability identity does not match the connected client") - return - } - meta.phase = "ready" - meta.capabilityReport = message.payload - const cache = sessionCache(message.sessionId) - const previousSocket = cache.get(meta.clientId)?.socket - cache.set(meta.clientId, { - connected: meta.connected, - capability: message, - socket, - }) - if (previousSocket && previousSocket !== socket) { - queueMicrotask(() => previousSocket.close(1000, "Replaced by reconnect")) + /** What a studio-role socket is allowed to send. */ + const rejectStudioFrame = () => { + if (meta.role === "studio") { + if (!STUDIO_MESSAGE_TYPES.has(message.type)) { + socket.close(1008, "Message direction is not allowed for Studio"); + return true; + } + } else if (meta.phase === "awaitingConnected") { + if (message.type !== "previewClientConnected") { + socket.close( + 1008, + "The first client message must identify the preview client" + ); + return true; + } + } else if (meta.phase === "awaitingCapability") { + if (message.type !== "capabilityReport") { + socket.close( + 1008, + "The capability report must follow client identity" + ); + return true; + } + } else if ( + meta.phase !== "ready" || + !CLIENT_MESSAGE_TYPES.has(message.type) + ) { + socket.close( + 1008, + "Message direction is not allowed for a preview client" + ); + return true; } - } else if (message.type === "previewClientDisconnected") { - if (message.payload.clientId !== meta.clientId) { - socket.close(1008, "Disconnect identity does not match the connected client") - return + if ( + meta.role === "client" && + meta.phase === "ready" && + cachedClients.get(message.sessionId)?.get(meta.clientId)?.socket !== + socket + ) { + socket.close( + 1008, + "This preview client connection has been replaced" + ); + return true; } - if (sessionCache(message.sessionId).get(meta.clientId)?.socket !== socket) { - meta.disconnected = true - meta.phase = "disconnected" - queueMicrotask(() => socket.close(1000, "Preview client connection replaced")) - return + return false; + }; + + /** The connect frame carries the renderer's identity and capabilities. */ + const handleClientConnected = () => { + if (message.type === "previewClientConnected") { + meta.clientId = message.payload.client.clientId; + meta.phase = "awaitingCapability"; + meta.disconnected = false; + meta.connected = message; + const cache = sessionCache(message.sessionId); + if (!cache.has(meta.clientId)) { + cache.set(meta.clientId, { + connected: message, + capability: null, + socket, + }); + } + } else if (message.type === "capabilityReport") { + if (message.payload.clientId !== meta.clientId) { + socket.close( + 1008, + "Capability identity does not match the connected client" + ); + return true; + } + meta.phase = "ready"; + meta.capabilityReport = message.payload; + const cache = sessionCache(message.sessionId); + const previousSocket = cache.get(meta.clientId)?.socket; + cache.set(meta.clientId, { + connected: meta.connected, + capability: message, + socket, + }); + if (previousSocket && previousSocket !== socket) { + queueMicrotask(() => + previousSocket.close(1000, "Replaced by reconnect") + ); + } + } else if (message.type === "previewClientDisconnected") { + if (message.payload.clientId !== meta.clientId) { + socket.close( + 1008, + "Disconnect identity does not match the connected client" + ); + return true; + } + if ( + sessionCache(message.sessionId).get(meta.clientId)?.socket !== + socket + ) { + meta.disconnected = true; + meta.phase = "disconnected"; + queueMicrotask(() => + socket.close(1000, "Preview client connection replaced") + ); + return true; + } + meta.disconnected = true; + meta.phase = "disconnected"; + sessionCache(message.sessionId).delete(meta.clientId); + } else if ( + meta.role === "client" && + message.payload.clientId !== meta.clientId + ) { + socket.close( + 1008, + "Message identity does not match the connected client" + ); + return true; } - meta.disconnected = true - meta.phase = "disconnected" - sessionCache(message.sessionId).delete(meta.clientId) - } else if (meta.role === "client" && message.payload.clientId !== meta.clientId) { - socket.close(1008, "Message identity does not match the connected client") - return - } + return false; + }; - meta.lastActivityAt = Date.now() + if (isBinary) { + socket.close(1003, "JSON text messages only"); + return; + } + let message; + try { + message = JSON.parse(data.toString()); + } catch { + socket.close(1007, "Invalid JSON"); + return; + } + if (rejectInvalidFrame()) { + return; + } + if (rejectStudioFrame()) { + return; + } + meta.sessionId = message.sessionId; + if (handleClientConnected()) { + return; + } + meta.lastActivityAt = Date.now(); relayMessage( server, metadata, @@ -403,63 +531,68 @@ export function createPreviewRelay({ host = "127.0.0.1", port = 4317, path = "/p meta.role === "studio" ? "client" : "studio", meta.role === "studio" && message.type === "previewHeartbeat" ? message.payload.clientId - : null, - ) + : null + ); if (message.type === "previewClientDisconnected") { - queueMicrotask(() => socket.close(1000, "Preview client disconnected")) + queueMicrotask(() => socket.close(1000, "Preview client disconnected")); } - }) + }); - socket.on("close", () => disconnectClient(socket, "closed")) - }) + socket.on("close", () => disconnectClient(socket, "closed")); + }); const heartbeatTimer = setInterval(() => { - const now = Date.now() + const now = Date.now(); for (const socket of server.clients) { - const meta = metadata.get(socket) + const meta = metadata.get(socket); if ( meta?.role === "client" && meta.clientId && now - meta.lastActivityAt > HEARTBEAT_TIMEOUT_MS ) { - disconnectClient(socket, "timeout") - socket.terminate() + disconnectClient(socket, "timeout"); + socket.terminate(); } } - }, 1_000) - heartbeatTimer.unref() + }, 1000); + heartbeatTimer.unref(); return { server, ready, address() { - const address = server.address() - if (!address || typeof address === "string") return null - const hostname = address.address === "::1" ? "[::1]" : address.address - return `ws://${hostname}:${address.port}${path}` + const address = server.address(); + if (!address || typeof address === "string") { + return null; + } + const hostname = address.address === "::1" ? "[::1]" : address.address; + return `ws://${hostname}:${address.port}${path}`; }, close() { - clearInterval(heartbeatTimer) - for (const client of server.clients) client.terminate() + clearInterval(heartbeatTimer); + for (const client of server.clients) { + client.terminate(); + } return new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())) - }) + server.close((error) => (error ? reject(error) : resolve())); + }); }, - } + }; } -const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +const isMain = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; if (isMain) { - const relay = createPreviewRelay() - await relay.ready - console.log(`Mosaic local preview relay listening at ${relay.address()}`) - console.log(`WebSocket subprotocols: ${PREVIEW_SUBPROTOCOLS.join(", ")}`) + const relay = createPreviewRelay(); + await relay.ready; + console.log(`Mosaic local preview relay listening at ${relay.address()}`); + console.log(`WebSocket subprotocols: ${PREVIEW_SUBPROTOCOLS.join(", ")}`); async function shutdown() { - await relay.close() - process.exit(0) + await relay.close(); + process.exit(0); } - process.once("SIGINT", shutdown) - process.once("SIGTERM", shutdown) + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); } diff --git a/apps/dashboard/scripts/local-preview-relay.test.mjs b/apps/dashboard/scripts/local-preview-relay.test.mjs index 9d20a5b9..91c77f4a 100644 --- a/apps/dashboard/scripts/local-preview-relay.test.mjs +++ b/apps/dashboard/scripts/local-preview-relay.test.mjs @@ -1,11 +1,15 @@ -import assert from "node:assert/strict" -import { once } from "node:events" -import { readFile } from "node:fs/promises" -import test from "node:test" +// biome-ignore-all lint/suspicious/noMisplacedAssertion: both assertions sit in connect/waitForMessages helpers that the tests call; the rule cannot see through the call to the test() that owns them +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; -import WebSocket from "ws" +import WebSocket from "ws"; -import { createPreviewRelay, PREVIEW_SUBPROTOCOL } from "./local-preview-relay.mjs" +import { + createPreviewRelay, + PREVIEW_SUBPROTOCOL, +} from "./local-preview-relay.mjs"; function heartbeat(sessionId, sequence = 1, clientId = "client_relay_test") { return { @@ -15,309 +19,370 @@ function heartbeat(sessionId, sequence = 1, clientId = "client_relay_test") { sentAt: new Date().toISOString(), type: "previewHeartbeat", payload: { clientId, kind: "ping", sequence }, - } + }; } -async function connect(url, sessionId, receivedMessages, protocols = PREVIEW_SUBPROTOCOL) { - const target = sessionId ? `${url}?role=studio&sessionId=${encodeURIComponent(sessionId)}` : url - const socket = new WebSocket(target, protocols) +async function connect( + url, + sessionId, + receivedMessages, + protocols = PREVIEW_SUBPROTOCOL +) { + const target = sessionId + ? `${url}?role=studio&sessionId=${encodeURIComponent(sessionId)}` + : url; + const socket = new WebSocket(target, protocols); if (receivedMessages) { - socket.on("message", (payload) => receivedMessages.push(JSON.parse(payload.toString()))) + socket.on("message", (payload) => + receivedMessages.push(JSON.parse(payload.toString())) + ); } - await once(socket, "open") + await once(socket, "open"); assert.equal( socket.protocol, Array.isArray(protocols) && protocols.includes(PREVIEW_SUBPROTOCOL) ? PREVIEW_SUBPROTOCOL - : protocols, - ) - return socket + : protocols + ); + return socket; } async function waitForMessages(messages, count) { - const deadline = Date.now() + 2_000 + const deadline = Date.now() + 2000; while (messages.length < count && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 10)) + // biome-ignore lint/performance/noAwaitInLoops: polls until the relay settles, which is inherently sequential + await new Promise((resolve) => setTimeout(resolve, 10)); } - assert.equal(messages.length, count) + assert.equal(messages.length, count); } test("negotiates Local Preview 0.2 and relays a canonical Protocol 0.2 draft", async () => { - const relay = createPreviewRelay({ port: 0 }) - await relay.ready - const url = relay.address() - assert.ok(url) + const relay = createPreviewRelay({ port: 0 }); + await relay.ready; + const url = relay.address(); + assert.ok(url); const fixture = JSON.parse( await readFile( new URL( "../../../protocol/fixtures/local-preview/v0.2/session-flow.messages.json", - import.meta.url, + import.meta.url ), - "utf8", - ), - ) - const connected = fixture.find((message) => message.type === "previewClientConnected") - const capability = fixture.find((message) => message.type === "capabilityReport") - const draft = fixture.find((message) => message.type === "draftUpdated") - assert.ok(connected) - assert.ok(capability) - assert.ok(draft) - - const studioMessages = [] - const clientMessages = [] - const studio = await connect(url, connected.sessionId, studioMessages, PREVIEW_SUBPROTOCOL) - const nativeClient = await connect(url, null, clientMessages, [PREVIEW_SUBPROTOCOL]) - nativeClient.send(JSON.stringify(connected)) - nativeClient.send(JSON.stringify(capability)) - await waitForMessages(studioMessages, 2) - assert.ok(studioMessages.every((message) => message.previewProtocolVersion === "0.2")) - - studio.send(JSON.stringify(draft)) - await waitForMessages(clientMessages, 1) - assert.equal(clientMessages[0].type, "draftUpdated") - assert.equal(clientMessages[0].payload.document.schemaVersion, "0.2") - - studio.close() - nativeClient.close() - await relay.close() -}) + "utf8" + ) + ); + const connected = fixture.find( + (message) => message.type === "previewClientConnected" + ); + const capability = fixture.find( + (message) => message.type === "capabilityReport" + ); + const draft = fixture.find((message) => message.type === "draftUpdated"); + assert.ok(connected); + assert.ok(capability); + assert.ok(draft); + + const studioMessages = []; + const clientMessages = []; + const studio = await connect( + url, + connected.sessionId, + studioMessages, + PREVIEW_SUBPROTOCOL + ); + const nativeClient = await connect(url, null, clientMessages, [ + PREVIEW_SUBPROTOCOL, + ]); + nativeClient.send(JSON.stringify(connected)); + nativeClient.send(JSON.stringify(capability)); + await waitForMessages(studioMessages, 2); + assert.ok( + studioMessages.every((message) => message.previewProtocolVersion === "0.2") + ); + + studio.send(JSON.stringify(draft)); + await waitForMessages(clientMessages, 1); + assert.equal(clientMessages[0].type, "draftUpdated"); + assert.equal(clientMessages[0].payload.document.schemaVersion, "0.2"); + + studio.close(); + nativeClient.close(); + await relay.close(); +}); test("relays canonical messages only within the same local preview session", async () => { - const relay = createPreviewRelay({ port: 0 }) - await relay.ready - const url = relay.address() - assert.ok(url) + const relay = createPreviewRelay({ port: 0 }); + await relay.ready; + const url = relay.address(); + assert.ok(url); const fixture = JSON.parse( await readFile( new URL( "../../../protocol/fixtures/local-preview/v0.2/session-flow.messages.json", - import.meta.url, + import.meta.url ), - "utf8", - ), - ) - const connected = fixture.find((message) => message.type === "previewClientConnected") - const capability = fixture.find((message) => message.type === "capabilityReport") - assert.ok(connected) - assert.ok(capability) - const studioMessages = [] - const studio = await connect(url, connected.sessionId, studioMessages) - const nativeClient = await connect(url) - const otherStudio = await connect(url, "session_other_01") - const otherMessages = [] - otherStudio.on("message", (payload) => otherMessages.push(payload.toString())) - - nativeClient.send(JSON.stringify(connected)) - nativeClient.send(JSON.stringify(capability)) - await waitForMessages(studioMessages, 2) + "utf8" + ) + ); + const connected = fixture.find( + (message) => message.type === "previewClientConnected" + ); + const capability = fixture.find( + (message) => message.type === "capabilityReport" + ); + assert.ok(connected); + assert.ok(capability); + const studioMessages = []; + const studio = await connect(url, connected.sessionId, studioMessages); + const nativeClient = await connect(url); + const otherStudio = await connect(url, "session_other_01"); + const otherMessages = []; + otherStudio.on("message", (payload) => + otherMessages.push(payload.toString()) + ); + + nativeClient.send(JSON.stringify(connected)); + nativeClient.send(JSON.stringify(capability)); + await waitForMessages(studioMessages, 2); nativeClient.send( - JSON.stringify(heartbeat(connected.sessionId, 1, connected.payload.client.clientId)), - ) - await waitForMessages(studioMessages, 3) - assert.equal(studioMessages[2].sessionId, connected.sessionId) - await new Promise((resolve) => setTimeout(resolve, 20)) - assert.equal(otherMessages.length, 0) - - studio.close() - nativeClient.close() - otherStudio.close() - await relay.close() -}) + JSON.stringify( + heartbeat(connected.sessionId, 1, connected.payload.client.clientId) + ) + ); + await waitForMessages(studioMessages, 3); + assert.equal(studioMessages[2].sessionId, connected.sessionId); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(otherMessages.length, 0); + + studio.close(); + nativeClient.close(); + otherStudio.close(); + await relay.close(); +}); test("replays cached identity then capabilities when Studio joins after a native client", async () => { - const relay = createPreviewRelay({ port: 0 }) - await relay.ready - const url = relay.address() - assert.ok(url) + const relay = createPreviewRelay({ port: 0 }); + await relay.ready; + const url = relay.address(); + assert.ok(url); const fixture = JSON.parse( await readFile( new URL( "../../../protocol/fixtures/local-preview/v0.2/session-flow.messages.json", - import.meta.url, + import.meta.url ), - "utf8", - ), - ) - const connected = fixture.find((message) => message.type === "previewClientConnected") - const capability = fixture.find((message) => message.type === "capabilityReport") - assert.ok(connected) - assert.ok(capability) - - const nativeClient = await connect(url) - nativeClient.send(JSON.stringify(connected)) - nativeClient.send(JSON.stringify(capability)) - await new Promise((resolve) => setTimeout(resolve, 20)) - - const received = [] - const studio = await connect(url, connected.sessionId, received) - await waitForMessages(received, 2) + "utf8" + ) + ); + const connected = fixture.find( + (message) => message.type === "previewClientConnected" + ); + const capability = fixture.find( + (message) => message.type === "capabilityReport" + ); + assert.ok(connected); + assert.ok(capability); + + const nativeClient = await connect(url); + nativeClient.send(JSON.stringify(connected)); + nativeClient.send(JSON.stringify(capability)); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const received = []; + const studio = await connect(url, connected.sessionId, received); + await waitForMessages(received, 2); assert.deepEqual( received.map((message) => message.type), - ["previewClientConnected", "capabilityReport"], - ) + ["previewClientConnected", "capabilityReport"] + ); - studio.close() - nativeClient.close() - await relay.close() -}) + studio.close(); + nativeClient.close(); + await relay.close(); +}); test("notifies Studio when a connected native client closes", async () => { - const relay = createPreviewRelay({ port: 0 }) - await relay.ready - const url = relay.address() - assert.ok(url) + const relay = createPreviewRelay({ port: 0 }); + await relay.ready; + const url = relay.address(); + assert.ok(url); const fixture = JSON.parse( await readFile( new URL( "../../../protocol/fixtures/local-preview/v0.2/session-flow.messages.json", - import.meta.url, + import.meta.url ), - "utf8", - ), - ) - const connected = fixture.find((message) => message.type === "previewClientConnected") - assert.ok(connected) - - const studio = await connect(url, connected.sessionId) - const nativeClient = await connect(url) - nativeClient.send(JSON.stringify(connected)) - const [connectedPayload] = await once(studio, "message") - assert.equal(JSON.parse(connectedPayload.toString()).type, "previewClientConnected") - - nativeClient.close() - const [disconnectedPayload] = await once(studio, "message") - const disconnected = JSON.parse(disconnectedPayload.toString()) - assert.equal(disconnected.type, "previewClientDisconnected") - assert.equal(disconnected.payload.clientId, connected.payload.client.clientId) - assert.equal(disconnected.payload.reason, "closed") - - studio.close() - await relay.close() -}) + "utf8" + ) + ); + const connected = fixture.find( + (message) => message.type === "previewClientConnected" + ); + assert.ok(connected); + + const studio = await connect(url, connected.sessionId); + const nativeClient = await connect(url); + nativeClient.send(JSON.stringify(connected)); + const [connectedPayload] = await once(studio, "message"); + assert.equal( + JSON.parse(connectedPayload.toString()).type, + "previewClientConnected" + ); + + nativeClient.close(); + const [disconnectedPayload] = await once(studio, "message"); + const disconnected = JSON.parse(disconnectedPayload.toString()); + assert.equal(disconnected.type, "previewClientDisconnected"); + assert.equal( + disconnected.payload.clientId, + connected.payload.client.clientId + ); + assert.equal(disconnected.payload.reason, "closed"); + + studio.close(); + await relay.close(); +}); test("rejects messages with unknown fields and requires the exact subprotocol", async () => { - const relay = createPreviewRelay({ port: 0 }) - await relay.ready - const url = relay.address() - assert.ok(url) + const relay = createPreviewRelay({ port: 0 }); + await relay.ready; + const url = relay.address(); + assert.ok(url); - const invalid = await connect(url) - invalid.send(JSON.stringify({ ...heartbeat("session_local_01"), unexpected: true })) - const [code] = await once(invalid, "close") - assert.equal(code, 1008) + const invalid = await connect(url); + invalid.send( + JSON.stringify({ ...heartbeat("session_local_01"), unexpected: true }) + ); + const [code] = await once(invalid, "close"); + assert.equal(code, 1008); - const noProtocol = new WebSocket(url) - const [error] = await once(noProtocol, "error") - assert.ok(error) + const noProtocol = new WebSocket(url); + const [error] = await once(noProtocol, "error"); + assert.ok(error); - const retiredProtocol = new WebSocket(url, "mosaic.local-preview.v0.1") - const [retiredError] = await once(retiredProtocol, "error") - assert.ok(retiredError) + const retiredProtocol = new WebSocket(url, "mosaic.local-preview.v0.1"); + const [retiredError] = await once(retiredProtocol, "error"); + assert.ok(retiredError); - await relay.close() -}) + await relay.close(); +}); test("enforces client handshake order, message direction, and connected identity", async () => { - const relay = createPreviewRelay({ port: 0 }) - await relay.ready - const url = relay.address() - assert.ok(url) + const relay = createPreviewRelay({ port: 0 }); + await relay.ready; + const url = relay.address(); + assert.ok(url); const fixture = JSON.parse( await readFile( new URL( "../../../protocol/fixtures/local-preview/v0.2/session-flow.messages.json", - import.meta.url, + import.meta.url ), - "utf8", - ), - ) - const connected = fixture.find((message) => message.type === "previewClientConnected") - const capability = fixture.find((message) => message.type === "capabilityReport") - const accepted = fixture.find((message) => message.type === "draftAccepted") - const draft = fixture.find((message) => message.type === "draftUpdated") - assert.ok(connected) - assert.ok(capability) - assert.ok(accepted) - assert.ok(draft) - - const outOfOrderClient = await connect(url) - outOfOrderClient.send(JSON.stringify(capability)) - const [outOfOrderCode] = await once(outOfOrderClient, "close") - assert.equal(outOfOrderCode, 1008) - - const studio = await connect(url, connected.sessionId) - studio.send(JSON.stringify(accepted)) - const [studioCode] = await once(studio, "close") - assert.equal(studioCode, 1008) - - const mismatchedClient = await connect(url) - mismatchedClient.send(JSON.stringify(connected)) + "utf8" + ) + ); + const connected = fixture.find( + (message) => message.type === "previewClientConnected" + ); + const capability = fixture.find( + (message) => message.type === "capabilityReport" + ); + const accepted = fixture.find((message) => message.type === "draftAccepted"); + const draft = fixture.find((message) => message.type === "draftUpdated"); + assert.ok(connected); + assert.ok(capability); + assert.ok(accepted); + assert.ok(draft); + + const outOfOrderClient = await connect(url); + outOfOrderClient.send(JSON.stringify(capability)); + const [outOfOrderCode] = await once(outOfOrderClient, "close"); + assert.equal(outOfOrderCode, 1008); + + const studio = await connect(url, connected.sessionId); + studio.send(JSON.stringify(accepted)); + const [studioCode] = await once(studio, "close"); + assert.equal(studioCode, 1008); + + const mismatchedClient = await connect(url); + mismatchedClient.send(JSON.stringify(connected)); mismatchedClient.send( JSON.stringify({ ...capability, messageId: "msg_identity_mismatch", payload: { ...capability.payload, clientId: "client_different_identity" }, - }), - ) - const [mismatchCode] = await once(mismatchedClient, "close") - assert.equal(mismatchCode, 1008) + }) + ); + const [mismatchCode] = await once(mismatchedClient, "close"); + assert.equal(mismatchCode, 1008); - const wrongDirectionClient = await connect(url) - wrongDirectionClient.send(JSON.stringify(connected)) - wrongDirectionClient.send(JSON.stringify(capability)) - wrongDirectionClient.send(JSON.stringify(draft)) - const [directionCode] = await once(wrongDirectionClient, "close") - assert.equal(directionCode, 1008) + const wrongDirectionClient = await connect(url); + wrongDirectionClient.send(JSON.stringify(connected)); + wrongDirectionClient.send(JSON.stringify(capability)); + wrongDirectionClient.send(JSON.stringify(draft)); + const [directionCode] = await once(wrongDirectionClient, "close"); + assert.equal(directionCode, 1008); - await relay.close() -}) + await relay.close(); +}); test("keeps the replacement socket active when a stable client identity reconnects", async () => { - const relay = createPreviewRelay({ port: 0 }) - await relay.ready - const url = relay.address() - assert.ok(url) + const relay = createPreviewRelay({ port: 0 }); + await relay.ready; + const url = relay.address(); + assert.ok(url); const fixture = JSON.parse( await readFile( new URL( "../../../protocol/fixtures/local-preview/v0.2/session-flow.messages.json", - import.meta.url, + import.meta.url ), - "utf8", - ), - ) - const connected = fixture.find((message) => message.type === "previewClientConnected") - const capability = fixture.find((message) => message.type === "capabilityReport") - assert.ok(connected) - assert.ok(capability) - - const studioMessages = [] - const studio = await connect(url, connected.sessionId, studioMessages) - const original = await connect(url) - original.send(JSON.stringify(connected)) - original.send(JSON.stringify(capability)) - await waitForMessages(studioMessages, 2) - - const replacement = await connect(url) - const originalClosed = once(original, "close") - replacement.send(JSON.stringify({ ...connected, messageId: "msg_replacement_connected" })) - replacement.send(JSON.stringify({ ...capability, messageId: "msg_replacement_capability" })) - await waitForMessages(studioMessages, 4) - await originalClosed - await new Promise((resolve) => setTimeout(resolve, 20)) + "utf8" + ) + ); + const connected = fixture.find( + (message) => message.type === "previewClientConnected" + ); + const capability = fixture.find( + (message) => message.type === "capabilityReport" + ); + assert.ok(connected); + assert.ok(capability); + + const studioMessages = []; + const studio = await connect(url, connected.sessionId, studioMessages); + const original = await connect(url); + original.send(JSON.stringify(connected)); + original.send(JSON.stringify(capability)); + await waitForMessages(studioMessages, 2); + + const replacement = await connect(url); + const originalClosed = once(original, "close"); + replacement.send( + JSON.stringify({ ...connected, messageId: "msg_replacement_connected" }) + ); + replacement.send( + JSON.stringify({ ...capability, messageId: "msg_replacement_capability" }) + ); + await waitForMessages(studioMessages, 4); + await originalClosed; + await new Promise((resolve) => setTimeout(resolve, 20)); assert.equal( - studioMessages.filter((message) => message.type === "previewClientDisconnected").length, - 0, - ) + studioMessages.filter( + (message) => message.type === "previewClientDisconnected" + ).length, + 0 + ); replacement.send( - JSON.stringify(heartbeat(connected.sessionId, 9, connected.payload.client.clientId)), - ) - await waitForMessages(studioMessages, 5) - assert.equal(studioMessages[4].type, "previewHeartbeat") - - studio.close() - replacement.close() - await relay.close() -}) + JSON.stringify( + heartbeat(connected.sessionId, 9, connected.payload.client.clientId) + ) + ); + await waitForMessages(studioMessages, 5); + assert.equal(studioMessages[4].type, "previewHeartbeat"); + + studio.close(); + replacement.close(); + await relay.close(); +}); diff --git a/apps/dashboard/scripts/studio-browser-demo.mjs b/apps/dashboard/scripts/studio-browser-demo.mjs index 6717c5a5..1116223c 100644 --- a/apps/dashboard/scripts/studio-browser-demo.mjs +++ b/apps/dashboard/scripts/studio-browser-demo.mjs @@ -1,163 +1,195 @@ -import { mkdtemp, readFile, readdir, writeFile } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join } from "node:path" - -import WebSocket from "ws" - -import { validatePaywallDocument } from "../../../protocol/browser/index.js" - -const debuggingEndpoint = process.env.MOSAIC_CHROME_DEBUG_URL ?? "http://127.0.0.1:9223" -const startedAt = Date.now() -const finalHeadline = "Edit once, preview natively everywhere" -const artifactsDirectory = await mkdtemp(join(tmpdir(), "mosaic-phase2c-browser-demo-")) -const validationScreenshot = join(artifactsDirectory, "studio-validation-error.png") -const finalScreenshot = join(artifactsDirectory, "studio-final.png") - -const targets = await fetch(`${debuggingEndpoint}/json/list`).then((response) => response.json()) -const target = targets.find((entry) => entry.type === "page" && entry.url.includes("/studio")) +import { mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import WebSocket from "ws"; + +import { validatePaywallDocument } from "../../../protocol/browser/index.js"; + +const debuggingEndpoint = + process.env.MOSAIC_CHROME_DEBUG_URL ?? "http://127.0.0.1:9223"; +const startedAt = Date.now(); +const finalHeadline = "Edit once, preview natively everywhere"; +const artifactsDirectory = await mkdtemp( + join(tmpdir(), "mosaic-phase2c-browser-demo-") +); +const validationScreenshot = join( + artifactsDirectory, + "studio-validation-error.png" +); +const finalScreenshot = join(artifactsDirectory, "studio-final.png"); + +const targets = await fetch(`${debuggingEndpoint}/json/list`).then((response) => + response.json() +); +const target = targets.find( + (entry) => entry.type === "page" && entry.url.includes("/studio") +); if (!target?.webSocketDebuggerUrl) { - throw new Error(`No /studio Chrome target is available at ${debuggingEndpoint}.`) + throw new Error( + `No /studio Chrome target is available at ${debuggingEndpoint}.` + ); } -const cdp = await connectCdp(target.webSocketDebuggerUrl) -await cdp.send("Page.enable") -await cdp.send("Runtime.enable") +const cdp = await connectCdp(target.webSocketDebuggerUrl); +await cdp.send("Page.enable"); +await cdp.send("Runtime.enable"); await cdp.send("Page.setDownloadBehavior", { behavior: "allow", downloadPath: artifactsDirectory, -}) +}); -await cdp.evaluate("localStorage.clear()") -await cdp.send("Page.reload", { ignoreCache: true }) +await cdp.evaluate("localStorage.clear()"); +await cdp.send("Page.reload", { ignoreCache: true }); await waitFor("template selection", async () => - (await bodyText()).includes("Start from intent, not JSON"), -) + (await bodyText()).includes("Start from intent, not JSON") +); await waitFor("Studio hydration", async () => cdp.evaluate(`(() => { const button = [...document.querySelectorAll("button")].find((candidate) => candidate.textContent?.includes("Focused offer") ) return Boolean(button && Object.keys(button).some((key) => key.startsWith("__reactProps"))) - })()`), -) + })()`) +); -await clickButtonContaining("Focused offer") -await waitFor("editor shell", async () => (await bodyText()).includes("Paywall order")) +await clickButtonContaining("Focused offer"); +await waitFor("editor shell", async () => + (await bodyText()).includes("Paywall order") +); -await clickCanvasComponent("headline") +await clickCanvasComponent("headline"); await waitFor("headline inspector", async () => - Boolean(await elementValue("#property-headline-value")), -) -await setElementValue("#property-headline-value", finalHeadline) -await moveSelectedLayerDown() + Boolean(await elementValue("#property-headline-value")) +); +await setElementValue("#property-headline-value", finalHeadline); +await moveSelectedLayerDown(); -await clickCanvasComponent("monthly-card") +await clickCanvasComponent("monthly-card"); await waitFor( "monthly Product Card inspector", - async () => (await elementChecked("#property-monthly-card-initialProductCardId")) !== null, -) -await clickElement("#property-monthly-card-initialProductCardId") + async () => + (await elementChecked("#property-monthly-card-initialProductCardId")) !== + null +); +await clickElement("#property-monthly-card-initialProductCardId"); await waitFor("monthly Product Card selection", async () => - Boolean(await elementChecked("#property-monthly-card-initialProductCardId")), -) -await clickCanvasComponent("yearly-card") + Boolean(await elementChecked("#property-monthly-card-initialProductCardId")) +); +await clickCanvasComponent("yearly-card"); await waitFor( "yearly Product Card inspector", - async () => (await elementChecked("#property-yearly-card-initialProductCardId")) === false, -) -await clickElement("#property-yearly-card-initialProductCardId") + async () => + (await elementChecked("#property-yearly-card-initialProductCardId")) === + false +); +await clickElement("#property-yearly-card-initialProductCardId"); await waitFor("yearly Product Card selection", async () => - Boolean(await elementChecked("#property-yearly-card-initialProductCardId")), -) -await setElementValue("#mock-outcome", "purchaseSuccess") + Boolean(await elementChecked("#property-yearly-card-initialProductCardId")) +); +await setElementValue("#mock-outcome", "purchaseSuccess"); -await clickButtonContaining("Layers") +await clickButtonContaining("Layers"); await waitFor("Layers panel", async () => - (await bodyText()).includes("Drag anywhere on a layer row to reorder."), -) -await clickButtonByLabel("Add screen or sheet") -await clickRoleContaining("menuitem", "Add sheet") -await waitFor("Sheet destination", async () => (await bodyText()).includes("Sheet · screen-2")) - -await clickCanvasComponent("headline") + (await bodyText()).includes("Drag anywhere on a layer row to reorder.") +); +await clickButtonByLabel("Add screen or sheet"); +await clickRoleContaining("menuitem", "Add sheet"); +await waitFor("Sheet destination", async () => + (await bodyText()).includes("Sheet · screen-2") +); + +await clickCanvasComponent("headline"); await waitFor( "headline re-selection", - async () => (await elementValue("#property-headline-value")) !== null, -) -await setElementValue("#property-headline-value", "") + async () => (await elementValue("#property-headline-value")) !== null +); +await setElementValue("#property-headline-value", ""); await waitFor("local validation error", async () => - (await bodyText()).includes("Visible text cannot be empty."), -) -await scrollTo("#validation-title") -await captureScreenshot(validationScreenshot) + (await bodyText()).includes("Visible text cannot be empty.") +); +await scrollTo("#validation-title"); +await captureScreenshot(validationScreenshot); -await setElementValue("#property-headline-value", finalHeadline) +await setElementValue("#property-headline-value", finalHeadline); await waitFor("validation recovery", async () => (await bodyText()).includes( - "This paywall is valid and ready to send to native previews or export.", - ), -) + "This paywall is valid and ready to send to native previews or export." + ) +); await waitFor( "three native acknowledgements", async () => (await exactTextCount("Updated")) >= 3, - 25_000, -) + 25_000 +); -const finalBody = await bodyText() +const finalBody = await bodyText(); for (const clientLabel of [ "Flutter example preview", "Mosaic iOS local preview", "Android example preview", ]) { if (!finalBody.includes(clientLabel)) { - throw new Error(`Studio did not display the connected client: ${clientLabel}.`) + throw new Error( + `Studio did not display the connected client: ${clientLabel}.` + ); } } -await clickButtonContaining("Export") -const exportedFile = await waitForExport() -const exported = JSON.parse(await readFile(exportedFile, "utf8")) -const validation = validatePaywallDocument(exported) -if (!validation.ok) throw new Error("The browser-exported document is not Protocol 0.2 valid.") +await clickButtonContaining("Export"); +const exportedFile = await waitForExport(); +const exported = JSON.parse(await readFile(exportedFile, "utf8")); +const validation = validatePaywallDocument(exported); +if (!validation.ok) { + throw new Error("The browser-exported document is not Protocol 0.2 valid."); +} -const initialScreen = exported.screens.find((screen) => screen.id === exported.initialScreenId) -if (!initialScreen || initialScreen.presentation?.type !== "screen") { - throw new Error("The browser export does not preserve its initial Screen.") +const initialScreen = exported.screens.find( + (screen) => screen.id === exported.initialScreenId +); +if (initialScreen?.presentation?.type !== "screen") { + throw new Error("The browser export does not preserve its initial Screen."); } -const children = initialScreen.layout.content.children -const headlineIndex = children.findIndex((node) => node.id === "headline") -const subtitleIndex = children.findIndex((node) => node.id === "subtitle") -const headlineNode = children.find((node) => node.id === "headline") -const productSelector = children.find((node) => node.id === "plans") +const { children } = initialScreen.layout.content; +const headlineIndex = children.findIndex((node) => node.id === "headline"); +const subtitleIndex = children.findIndex((node) => node.id === "subtitle"); +const headlineNode = children.find((node) => node.id === "headline"); +const productSelector = children.find((node) => node.id === "plans"); const selectedProductCard = productSelector?.cards?.find( - (card) => card.id === productSelector.initialProductCardId, -) -const sheet = exported.screens.find((screen) => screen.id === "screen-2") + (card) => card.id === productSelector.initialProductCardId +); +const sheet = exported.screens.find((screen) => screen.id === "screen-2"); const sheetNavigation = children.find( (node) => node.type === "button" && node.action?.type === "navigateTo" && - node.action.screenId === sheet?.id, -) + node.action.screenId === sheet?.id +); if (headlineNode?.value?.default !== finalHeadline) { - throw new Error("The exported headline does not match the browser edit.") + throw new Error("The exported headline does not match the browser edit."); } if (subtitleIndex < 0 || headlineIndex !== subtitleIndex + 1) { - throw new Error("The exported document does not preserve the browser reorder.") + throw new Error( + "The exported document does not preserve the browser reorder." + ); } if ( productSelector?.type !== "productSelector" || productSelector.initialProductCardId !== "yearly-card" || selectedProductCard?.productReferenceId !== "yearly-plan" ) { - throw new Error("The exported Product Selector does not select the authored yearly Product Card.") + throw new Error( + "The exported Product Selector does not select the authored yearly Product Card." + ); } if (sheet?.presentation?.type !== "sheet" || !sheetNavigation) { - throw new Error("The exported document does not preserve the new Sheet and its navigation edge.") + throw new Error( + "The exported document does not preserve the new Sheet and its navigation edge." + ); } -await scrollTo("#native-preview-title") -await captureScreenshot(finalScreenshot) +await scrollTo("#native-preview-title"); +await captureScreenshot(finalScreenshot); const result = { template: "Focused offer", @@ -182,32 +214,32 @@ const result = { validationError: validationScreenshot, finalStudio: finalScreenshot, }, - elapsedSeconds: Number(((Date.now() - startedAt) / 1_000).toFixed(2)), -} + elapsedSeconds: Number(((Date.now() - startedAt) / 1000).toFixed(2)), +}; -console.log(JSON.stringify(result, null, 2)) -cdp.close() +console.log(JSON.stringify(result, null, 2)); +cdp.close(); -async function bodyText() { - return cdp.evaluate("document.body?.innerText ?? ''") +function bodyText() { + return cdp.evaluate("document.body?.innerText ?? ''"); } -async function elementValue(selector) { +function elementValue(selector) { return cdp.evaluate( - `(() => { const element = document.querySelector(${JSON.stringify(selector)}); return element ? element.value : null })()`, - ) + `(() => { const element = document.querySelector(${JSON.stringify(selector)}); return element ? element.value : null })()` + ); } -async function elementChecked(selector) { +function elementChecked(selector) { return cdp.evaluate( - `(() => { const element = document.querySelector(${JSON.stringify(selector)}); return element instanceof HTMLInputElement ? element.checked : null })()`, - ) + `(() => { const element = document.querySelector(${JSON.stringify(selector)}); return element instanceof HTMLInputElement ? element.checked : null })()` + ); } -async function exactTextCount(text) { +function exactTextCount(text) { return cdp.evaluate( - `([...document.querySelectorAll("body *")].filter((element) => element.children.length === 0 && element.textContent?.trim() === ${JSON.stringify(text)})).length`, - ) + `([...document.querySelectorAll("body *")].filter((element) => element.children.length === 0 && element.textContent?.trim() === ${JSON.stringify(text)})).length` + ); } async function clickButtonContaining(text) { @@ -218,8 +250,10 @@ async function clickButtonContaining(text) { if (!button) return false button.click() return true - })()`) - if (!clicked) throw new Error(`Could not find a button containing: ${text}.`) + })()`); + if (!clicked) { + throw new Error(`Could not find a button containing: ${text}.`); + } } async function clickButtonByLabel(label) { @@ -230,12 +264,14 @@ async function clickButtonByLabel(label) { if (!button) return false button.click() return true - })()`) - if (!clicked) throw new Error(`Could not find a button labelled: ${label}.`) + })()`); + if (!clicked) { + throw new Error(`Could not find a button labelled: ${label}.`); + } } async function clickRoleContaining(role, text) { - const selector = `[role="${role}"]` + const selector = `[role="${role}"]`; const clicked = await cdp.evaluate(`(() => { const element = [...document.querySelectorAll(${JSON.stringify(selector)})].find((candidate) => candidate.textContent?.includes(${JSON.stringify(text)}) @@ -243,8 +279,10 @@ async function clickRoleContaining(role, text) { if (!element) return false element.click() return true - })()`) - if (!clicked) throw new Error(`Could not find ${role} containing: ${text}.`) + })()`); + if (!clicked) { + throw new Error(`Could not find ${role} containing: ${text}.`); + } } async function clickElement(selector) { @@ -253,19 +291,23 @@ async function clickElement(selector) { if (!(element instanceof HTMLElement)) return false element.click() return true - })()`) - if (!clicked) throw new Error(`Could not click element: ${selector}.`) + })()`); + if (!clicked) { + throw new Error(`Could not click element: ${selector}.`); + } } async function clickCanvasComponent(componentId) { - const selector = `[data-component-id="${componentId}"][data-preview-node-type]` + const selector = `[data-component-id="${componentId}"][data-preview-node-type]`; const clicked = await cdp.evaluate(`(() => { const component = document.querySelector(${JSON.stringify(selector)}) if (!component) return false component.click() return true - })()`) - if (!clicked) throw new Error(`Could not select canvas component: ${componentId}.`) + })()`); + if (!clicked) { + throw new Error(`Could not select canvas component: ${componentId}.`); + } } async function moveSelectedLayerDown() { @@ -279,8 +321,10 @@ async function moveSelectedLayerDown() { key: "ArrowDown", })) return true - })()`) - if (!moved) throw new Error("Could not move the selected layer down.") + })()`); + if (!moved) { + throw new Error("Could not move the selected layer down."); + } } async function setElementValue(selector, value) { @@ -299,16 +343,18 @@ async function setElementValue(selector, value) { element.dispatchEvent(new Event("change", { bubbles: true })) element.blur() return true - })()`) - if (!changed) throw new Error(`Could not set element value: ${selector}.`) - await new Promise((resolve) => setTimeout(resolve, 100)) + })()`); + if (!changed) { + throw new Error(`Could not set element value: ${selector}.`); + } + await new Promise((resolve) => setTimeout(resolve, 100)); } async function scrollTo(selector) { await cdp.evaluate( - `document.querySelector(${JSON.stringify(selector)})?.scrollIntoView({ block: "start" })`, - ) - await new Promise((resolve) => setTimeout(resolve, 150)) + `document.querySelector(${JSON.stringify(selector)})?.scrollIntoView({ block: "start" })` + ); + await new Promise((resolve) => setTimeout(resolve, 150)); } async function captureScreenshot(path) { @@ -316,59 +362,73 @@ async function captureScreenshot(path) { format: "png", fromSurface: true, captureBeyondViewport: false, - }) - await writeFile(path, screenshot.data, "base64") + }); + await writeFile(path, screenshot.data, "base64"); } async function waitForExport() { - const exportDeadline = Date.now() + 10_000 + const exportDeadline = Date.now() + 10_000; while (Date.now() < exportDeadline) { + // biome-ignore lint/performance/noAwaitInLoops: polls the page until the condition holds, which is inherently sequential const files = (await readdir(artifactsDirectory)).filter((file) => - file.endsWith(".mosaic.json"), - ) - if (files.length === 1) return join(artifactsDirectory, files[0]) - await new Promise((resolve) => setTimeout(resolve, 50)) + file.endsWith(".mosaic.json") + ); + if (files.length === 1) { + return join(artifactsDirectory, files[0]); + } + await new Promise((resolve) => setTimeout(resolve, 50)); } - throw new Error("Studio did not download one exported Mosaic document.") + throw new Error("Studio did not download one exported Mosaic document."); } async function waitFor(label, predicate, timeout = 15_000) { - const waitDeadline = Date.now() + timeout + const waitDeadline = Date.now() + timeout; while (Date.now() < waitDeadline) { try { - if (await predicate()) return + // biome-ignore lint/performance/noAwaitInLoops: polls the page until the condition holds, which is inherently sequential + if (await predicate()) { + return; + } } catch { // Navigation can briefly invalidate the execution context. } - await new Promise((resolve) => setTimeout(resolve, 50)) + await new Promise((resolve) => setTimeout(resolve, 50)); } - throw new Error(`Timed out waiting for ${label}.`) + throw new Error(`Timed out waiting for ${label}.`); } async function connectCdp(url) { - const socket = new WebSocket(url) + const socket = new WebSocket(url); await new Promise((resolve, reject) => { - socket.once("open", resolve) - socket.once("error", reject) - }) - let sequence = 0 - const pending = new Map() + socket.once("open", resolve); + socket.once("error", reject); + }); + let sequence = 0; + const pending = new Map(); socket.on("message", (source) => { - const message = JSON.parse(source.toString()) - if (!message.id) return - const request = pending.get(message.id) - if (!request) return - pending.delete(message.id) - if (message.error) request.reject(new Error(message.error.message)) - else request.resolve(message.result) - }) + const message = JSON.parse(source.toString()); + if (!message.id) { + return; + } + const request = pending.get(message.id); + if (!request) { + return; + } + pending.delete(message.id); + if (message.error) { + request.reject(new Error(message.error.message)); + } else { + request.resolve(message.result); + } + }); function send(method, params = {}) { - const id = ++sequence + sequence += 1; + const id = sequence; return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }) - socket.send(JSON.stringify({ id, method, params })) - }) + pending.set(id, { resolve, reject }); + socket.send(JSON.stringify({ id, method, params })); + }); } return { @@ -378,14 +438,16 @@ async function connectCdp(url) { expression, awaitPromise: true, returnByValue: true, - }) + }); if (response.exceptionDetails) { - throw new Error(response.exceptionDetails.text ?? "Chrome evaluation failed.") + throw new Error( + response.exceptionDetails.text ?? "Chrome evaluation failed." + ); } - return response.result.value + return response.result.value; }, close() { - socket.close() + socket.close(); }, - } + }; } diff --git a/apps/dashboard/src/build-info.d.ts b/apps/dashboard/src/build-info.d.ts index 2ebc5de2..8a0d2826 100644 --- a/apps/dashboard/src/build-info.d.ts +++ b/apps/dashboard/src/build-info.d.ts @@ -3,6 +3,6 @@ * running bundle self-identifying, which is what a deployment diagnosis needs * when several dashboard versions may be in circulation. */ -declare const __MOSAIC_VERSION__: string -declare const __MOSAIC_COMMIT__: string -declare const __MOSAIC_BUILD_TIME__: string +declare const __MOSAIC_VERSION__: string; +declare const __MOSAIC_COMMIT__: string; +declare const __MOSAIC_BUILD_TIME__: string; diff --git a/apps/dashboard/src/components/feedback/app-error-boundary.tsx b/apps/dashboard/src/components/feedback/app-error-boundary.tsx index 8e2687d6..343885ea 100644 --- a/apps/dashboard/src/components/feedback/app-error-boundary.tsx +++ b/apps/dashboard/src/components/feedback/app-error-boundary.tsx @@ -1,10 +1,11 @@ -import * as React from "react" +import type { ReactNode } from "react"; +import { Component } from "react"; -import { ErrorState } from "@/components/feedback/error-state" -import { describeApiError } from "@/lib/api/errors" +import { ErrorState } from "@/components/feedback/error-state"; +import { describeApiError } from "@/lib/api/errors"; interface AppErrorBoundaryState { - error: unknown + error: unknown; } /** @@ -16,40 +17,43 @@ interface AppErrorBoundaryState { * anywhere: the operator gets recovery actions and, when available, the * correlation identifier to quote to whoever runs the API. */ -export class AppErrorBoundary extends React.Component< - { children: React.ReactNode }, +// biome-ignore lint/style/useReactFunctionComponents: React implements error boundaries only via componentDidCatch on a class +export class AppErrorBoundary extends Component< + { children: ReactNode }, AppErrorBoundaryState > { - state: AppErrorBoundaryState = { error: null } + state: AppErrorBoundaryState = { error: null }; static getDerivedStateFromError(error: unknown): AppErrorBoundaryState { - return { error } + return { error }; } render() { - if (!this.state.error) return this.props.children + if (!this.state.error) { + return this.props.children; + } - const described = describeApiError(this.state.error) + const described = describeApiError(this.state.error); return (
{ - this.setState({ error: null }) + this.setState({ error: null }); }} retryLabel="Try rendering again" title="The dashboard could not be displayed" /> {described.correlationId ? ( -

+

Request ID:{" "} - + {described.correlationId}

) : null}
- ) + ); } } diff --git a/apps/dashboard/src/components/feedback/confirm-dialog.tsx b/apps/dashboard/src/components/feedback/confirm-dialog.tsx new file mode 100644 index 00000000..3a287d8d --- /dev/null +++ b/apps/dashboard/src/components/feedback/confirm-dialog.tsx @@ -0,0 +1,53 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +/** + * Confirmation for an action that replaces work in place. + * + * `window.confirm` blocks the main thread, cannot be styled or translated, and + * is suppressible by the browser, which would silently turn a destructive + * action into an unconfirmed one. This keeps the same shape — ask once, act on + * accept — inside the app's own dialog. + */ +export function ConfirmDialog({ + confirmLabel, + description, + onConfirm, + onOpenChange, + open, + title, +}: { + confirmLabel: string; + description: string; + onConfirm: () => void; + onOpenChange: (open: boolean) => void; + open: boolean; + title: string; +}) { + return ( + + + + {title} + {description} + + + }> + Cancel + + + + + + ); +} diff --git a/apps/dashboard/src/components/feedback/connectivity-banner.tsx b/apps/dashboard/src/components/feedback/connectivity-banner.tsx index 5295b70a..bb4c002d 100644 --- a/apps/dashboard/src/components/feedback/connectivity-banner.tsx +++ b/apps/dashboard/src/components/feedback/connectivity-banner.tsx @@ -1,21 +1,21 @@ -import { WifiSlashIcon } from "@phosphor-icons/react/dist/ssr/WifiSlash" +import { WifiSlashIcon } from "@phosphor-icons/react/dist/ssr/WifiSlash"; -import { useConnectivityStatus } from "@/hooks/use-connectivity-status" +import { useConnectivityStatus } from "@/hooks/use-connectivity-status"; /** * Always-mounted status region. It stays in the tree so assistive technology * announces the transition rather than a region appearing from nowhere. */ export function ConnectivityBanner() { - const { isDegraded, isOffline } = useConnectivityStatus() - const visible = isDegraded || isOffline + const { isDegraded, isOffline } = useConnectivityStatus(); + const visible = isDegraded || isOffline; return (
) : null}
- ) + ); } diff --git a/apps/dashboard/src/components/feedback/empty-state.tsx b/apps/dashboard/src/components/feedback/empty-state.tsx index defa9b1c..d2b63d95 100644 --- a/apps/dashboard/src/components/feedback/empty-state.tsx +++ b/apps/dashboard/src/components/feedback/empty-state.tsx @@ -1,33 +1,40 @@ -import type { ReactNode } from "react" +import type { ReactNode } from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; interface EmptyStateProps { - action?: ReactNode - className?: string - description: string - title: string + action?: ReactNode; + className?: string; + description: string; + title: string; } -export function EmptyState({ action, className, description, title }: EmptyStateProps) { +export function EmptyState({ + action, + className, + description, + title, +}: EmptyStateProps) { return (
- ) + ); } diff --git a/apps/dashboard/src/components/feedback/error-state.tsx b/apps/dashboard/src/components/feedback/error-state.tsx index 3be8fbb3..0d4dad28 100644 --- a/apps/dashboard/src/components/feedback/error-state.tsx +++ b/apps/dashboard/src/components/feedback/error-state.tsx @@ -1,12 +1,12 @@ -import { Button } from "@/components/ui/button" -import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; interface ErrorStateProps { - className?: string - description?: string - onRetry?: () => void - retryLabel?: string - title?: string + className?: string; + description?: string; + onRetry?: () => void; + retryLabel?: string; + title?: string; } export function ErrorState({ @@ -19,18 +19,25 @@ export function ErrorState({ return (
-

{title}

-

{description}

+

{title}

+

+ {description} +

{onRetry ? ( - ) : null}
- ) + ); } diff --git a/apps/dashboard/src/components/feedback/feedback-states.test.tsx b/apps/dashboard/src/components/feedback/feedback-states.test.tsx index 8fc47d0a..a7212e57 100644 --- a/apps/dashboard/src/components/feedback/feedback-states.test.tsx +++ b/apps/dashboard/src/components/feedback/feedback-states.test.tsx @@ -1,29 +1,34 @@ -import { fireEvent, render, screen } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; -import { EmptyState } from "@/components/feedback/empty-state" -import { ErrorState } from "@/components/feedback/error-state" -import { LoadingState } from "@/components/feedback/loading-state" -import { Button } from "@/components/ui/button" +import { EmptyState } from "@/components/feedback/empty-state"; +import { ErrorState } from "@/components/feedback/error-state"; +import { LoadingState } from "@/components/feedback/loading-state"; +import { Button } from "@/components/ui/button"; describe("feedback states", () => { it("announces loading progress without exposing the decorative spinner", () => { - render() - - const status = screen.getByRole("status") - expect(status).toHaveAttribute("aria-busy", "true") - expect(status).toHaveTextContent("Loading workspace") - expect(status).toHaveTextContent("Fetching the workspace.") - }) + render( + + ); + + const status = screen.getByRole("status"); + expect(status).toHaveAttribute("aria-busy", "true"); + expect(status).toHaveTextContent("Loading workspace"); + expect(status).toHaveTextContent("Fetching the workspace."); + }); it("exposes an actionable error alert", () => { - const retry = vi.fn() - render() + const retry = vi.fn(); + render(); - expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong") - fireEvent.click(screen.getByRole("button", { name: "Try again" })) - expect(retry).toHaveBeenCalledOnce() - }) + expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong"); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(retry).toHaveBeenCalledOnce(); + }); it("announces an empty result and renders an optional action", () => { render( @@ -31,18 +36,18 @@ describe("feedback states", () => { action={} description="Nothing matches the current view." title="No results" - />, - ) + /> + ); - expect(screen.getByRole("status")).toHaveTextContent("No results") - expect(screen.getByRole("button", { name: "Create one" })).toBeEnabled() - }) + expect(screen.getByRole("status")).toHaveTextContent("No results"); + expect(screen.getByRole("button", { name: "Create one" })).toBeEnabled(); + }); it("keeps the shadcn button wrapper on an accessible Base UI button", () => { - render() + render(); - const button = screen.getByRole("button", { name: "Unavailable" }) - expect(button).toBeDisabled() - expect(button).toHaveAttribute("data-slot", "button") - }) -}) + const button = screen.getByRole("button", { name: "Unavailable" }); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("data-slot", "button"); + }); +}); diff --git a/apps/dashboard/src/components/feedback/live-announcer.tsx b/apps/dashboard/src/components/feedback/live-announcer.tsx index 1a25f0cf..7181333e 100644 --- a/apps/dashboard/src/components/feedback/live-announcer.tsx +++ b/apps/dashboard/src/components/feedback/live-announcer.tsx @@ -10,8 +10,8 @@ export function LiveAnnouncer({ assertive = false, message, }: { - assertive?: boolean - message?: string + assertive?: boolean; + message?: string; }) { return (
{message ?? ""}
- ) + ); } diff --git a/apps/dashboard/src/components/feedback/loading-state.tsx b/apps/dashboard/src/components/feedback/loading-state.tsx index a1096313..b513cae1 100644 --- a/apps/dashboard/src/components/feedback/loading-state.tsx +++ b/apps/dashboard/src/components/feedback/loading-state.tsx @@ -1,32 +1,40 @@ -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; interface LoadingStateProps { - className?: string - description?: string - title?: string + className?: string; + description?: string; + title?: string; } -export function LoadingState({ className, description, title = "Loading" }: LoadingStateProps) { +export function LoadingState({ + className, + description, + title = "Loading", +}: LoadingStateProps) { return (
- ) + ); } diff --git a/apps/dashboard/src/components/feedback/one-time-secret.test.tsx b/apps/dashboard/src/components/feedback/one-time-secret.test.tsx index f07d8a58..bae23555 100644 --- a/apps/dashboard/src/components/feedback/one-time-secret.test.tsx +++ b/apps/dashboard/src/components/feedback/one-time-secret.test.tsx @@ -1,35 +1,38 @@ -import { fireEvent, render, screen } from "@testing-library/react" -import { useState } from "react" -import { describe, expect, it, vi } from "vitest" +import { fireEvent, render, screen } from "@testing-library/react"; +import { useCallback, useState } from "react"; +import { describe, expect, it, vi } from "vitest"; -import { OneTimeSecret } from "@/components/feedback/one-time-secret" +import { OneTimeSecret } from "@/components/feedback/one-time-secret"; function SecretHarness({ writeToClipboard, }: { - writeToClipboard: (value: string) => Promise + writeToClipboard: (value: string) => Promise; }) { - const [visible, setVisible] = useState(true) + const handleDismiss = useCallback(() => setVisible(false), []); + const [visible, setVisible] = useState(true); return visible ? ( setVisible(false)} + onDismiss={handleDismiss} secret="mosaic_test_secret" writeToClipboard={writeToClipboard} /> - ) : null + ) : null; } describe("one-time API-key secret", () => { it("copies the raw secret and permanently removes it from the current view on dismissal", async () => { - const writeToClipboard = vi.fn(async () => undefined) - render() + const writeToClipboard = vi.fn(async () => undefined); + render(); - fireEvent.click(screen.getByRole("button", { name: "Copy secret" })) - expect(writeToClipboard).toHaveBeenCalledWith("mosaic_test_secret") - expect(await screen.findByRole("button", { name: "Copied" })).toBeVisible() + fireEvent.click(screen.getByRole("button", { name: "Copy secret" })); + expect(writeToClipboard).toHaveBeenCalledWith("mosaic_test_secret"); + expect(await screen.findByRole("button", { name: "Copied" })).toBeVisible(); - fireEvent.click(screen.getByRole("button", { name: "Dismiss one-time secret" })) - expect(screen.queryByText("mosaic_test_secret")).not.toBeInTheDocument() - }) -}) + fireEvent.click( + screen.getByRole("button", { name: "Dismiss one-time secret" }) + ); + expect(screen.queryByText("mosaic_test_secret")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/dashboard/src/components/feedback/one-time-secret.tsx b/apps/dashboard/src/components/feedback/one-time-secret.tsx index 0aff07a5..03d6e54c 100644 --- a/apps/dashboard/src/components/feedback/one-time-secret.tsx +++ b/apps/dashboard/src/components/feedback/one-time-secret.tsx @@ -1,19 +1,19 @@ -import { CopyIcon } from "@phosphor-icons/react/dist/ssr/Copy" -import { XIcon } from "@phosphor-icons/react/dist/ssr/X" -import { useId, useState } from "react" +import { CopyIcon } from "@phosphor-icons/react/dist/ssr/Copy"; +import { XIcon } from "@phosphor-icons/react/dist/ssr/X"; +import { useCallback, useId, useState } from "react"; -import { Button } from "@/components/ui/button" +import { Button } from "@/components/ui/button"; interface OneTimeSecretProps { /** Label for the copy control. Name the thing being copied, not "value". */ - copyLabel?: string - description?: string - dismissLabel?: string - eyebrow?: string - onDismiss: () => void - secret: string - title?: string - writeToClipboard?: (value: string) => Promise + copyLabel?: string; + description?: string; + dismissLabel?: string; + eyebrow?: string; + onDismiss: () => void; + secret: string; + title?: string; + writeToClipboard?: (value: string) => Promise; } /** @@ -35,50 +35,63 @@ export function OneTimeSecret({ title = "Copy this key now", writeToClipboard = (value) => navigator.clipboard.writeText(value), }: OneTimeSecretProps) { - const [copied, setCopied] = useState(false) - const [copyFailed, setCopyFailed] = useState(false) - const titleId = useId() + const [copied, setCopied] = useState(false); + const copyButtonLabel = copied ? "Copied" : copyLabel; + const [copyFailed, setCopyFailed] = useState(false); + const titleId = useId(); - async function copySecret() { + const copySecret = useCallback(async () => { try { - await writeToClipboard(secret) - setCopied(true) - setCopyFailed(false) + await writeToClipboard(secret); + setCopied(true); + setCopyFailed(false); } catch { - setCopyFailed(true) + setCopyFailed(true); } - } + }, [secret, writeToClipboard]); + const handleClick = useCallback(() => { + copySecret(); + }, [copySecret]); return (
-

{eyebrow}

-

+

+ {eyebrow} +

+

{title}

-

{description}

+

{description}

-
{/* Selectable as the manual fallback: clipboard access is unavailable outside secure contexts and in some browsers. */} - + {secret} - -

- {copyFailed ? "Copying failed. Select the value above to copy it manually." : ""} +

+ {copyFailed + ? "Copying failed. Select the value above to copy it manually." + : ""}

- ) + ); } diff --git a/apps/dashboard/src/components/feedback/root-error-component.tsx b/apps/dashboard/src/components/feedback/root-error-component.tsx index e23bb60e..29063061 100644 --- a/apps/dashboard/src/components/feedback/root-error-component.tsx +++ b/apps/dashboard/src/components/feedback/root-error-component.tsx @@ -1,7 +1,7 @@ -import type { ErrorComponentProps } from "@tanstack/react-router" +import type { ErrorComponentProps } from "@tanstack/react-router"; -import { RouteErrorState } from "@/components/feedback/route-feedback" -import { RootDocument } from "@/components/layout/root-document" +import { RouteErrorState } from "@/components/feedback/route-feedback"; +import { RootDocument } from "@/components/layout/root-document"; export function RootErrorComponent(props: ErrorComponentProps) { return ( @@ -10,5 +10,5 @@ export function RootErrorComponent(props: ErrorComponentProps) { - ) + ); } diff --git a/apps/dashboard/src/components/feedback/route-feedback.tsx b/apps/dashboard/src/components/feedback/route-feedback.tsx index 3b3601d1..03539272 100644 --- a/apps/dashboard/src/components/feedback/route-feedback.tsx +++ b/apps/dashboard/src/components/feedback/route-feedback.tsx @@ -1,35 +1,35 @@ -import { Link, type ErrorComponentProps } from "@tanstack/react-router" +import { type ErrorComponentProps, Link } from "@tanstack/react-router"; +import { useCallback } from "react"; -import { EmptyState } from "@/components/feedback/empty-state" -import { ErrorState } from "@/components/feedback/error-state" -import { LoadingState } from "@/components/feedback/loading-state" -import { Button } from "@/components/ui/button" -import { buttonVariants } from "@/components/ui/button-variants" -import { describeApiError } from "@/lib/api/errors" +import { EmptyState } from "@/components/feedback/empty-state"; +import { ErrorState } from "@/components/feedback/error-state"; +import { LoadingState } from "@/components/feedback/loading-state"; +import { Button } from "@/components/ui/button"; +import { buttonVariants } from "@/components/ui/button-variants"; +import { describeApiError } from "@/lib/api/errors"; function RouteRecoveryActions() { + const handleClick = useCallback(() => { + if (typeof window !== "undefined") { + window.history.back(); + } + }, []); return (
Go to workspace -
- ) + ); } export function RouteErrorState({ error, reset }: ErrorComponentProps) { // Stack traces and raw server messages are never rendered; operators get the // correlation ID instead (client error reporting is off by design). - const described = describeApiError(error) + const described = describeApiError(error); return (
@@ -42,16 +42,16 @@ export function RouteErrorState({ error, reset }: ErrorComponentProps) { onRetry={reset} /> {described.correlationId ? ( -

+

Request ID:{" "} - + {described.correlationId}

) : null}
- ) + ); } export function RouteNotFoundState() { @@ -63,7 +63,7 @@ export function RouteNotFoundState() { /> - ) + ); } export function RoutePendingState() { @@ -72,5 +72,5 @@ export function RoutePendingState() { className="mx-auto my-10 max-w-3xl" description="Preparing your Mosaic workspace." /> - ) + ); } diff --git a/apps/dashboard/src/components/layout/root-document.tsx b/apps/dashboard/src/components/layout/root-document.tsx index 5f1837e8..7bed9aad 100644 --- a/apps/dashboard/src/components/layout/root-document.tsx +++ b/apps/dashboard/src/components/layout/root-document.tsx @@ -1,7 +1,7 @@ -import { HeadContent, Scripts } from "@tanstack/react-router" -import type { ReactNode } from "react" +import { HeadContent, Scripts } from "@tanstack/react-router"; +import type { ReactNode } from "react"; -import { runtimeConfigScript } from "@/config/environment" +import { runtimeConfigScript } from "@/config/environment"; export function RootDocument({ children }: { children: ReactNode }) { return ( @@ -15,6 +15,7 @@ export function RootDocument({ children }: { children: ReactNode }) { API host. The value is resolved identically on the server and the client, so hydration stays stable. */} ", reference: "x".repeat(500), resolutionState: "entitled", storeEnvironment: "PRODUCTION", - }) + }); - expect(filters.provider).toBeUndefined() - expect(filters.storeEnvironment).toBeUndefined() - expect(filters.resolutionState).toBeUndefined() - expect(filters.productId).toBeUndefined() - expect(filters.reference).toBeUndefined() - expect(filters.cursor).toBeUndefined() - expect(filters.from).toBeUndefined() - expect(filters.limit).toBe(DEFAULT_TRANSACTION_PAGE_SIZE) - }) + expect(filters.provider).toBeUndefined(); + expect(filters.storeEnvironment).toBeUndefined(); + expect(filters.resolutionState).toBeUndefined(); + expect(filters.productId).toBeUndefined(); + expect(filters.reference).toBeUndefined(); + expect(filters.cursor).toBeUndefined(); + expect(filters.from).toBeUndefined(); + expect(filters.limit).toBe(DEFAULT_TRANSACTION_PAGE_SIZE); + }); it("drops an inverted date range rather than returning an always-empty ledger", () => { const inverted = parseTransactionFilters({ from: "2026-07-01T00:00:00Z", to: "2026-06-01T00:00:00Z", - }) - expect(inverted.from).toBeUndefined() - expect(inverted.to).toBeUndefined() + }); + expect(inverted.from).toBeUndefined(); + expect(inverted.to).toBeUndefined(); const ordered = parseTransactionFilters({ from: "2026-06-01T00:00:00Z", to: "2026-07-01T00:00:00Z", - }) - expect(ordered.from).toBe("2026-06-01T00:00:00Z") - expect(ordered.to).toBe("2026-07-01T00:00:00Z") - }) + }); + expect(ordered.from).toBe("2026-06-01T00:00:00Z"); + expect(ordered.to).toBe("2026-07-01T00:00:00Z"); + }); it("keeps sandbox and production facts separate when filtering the loaded page", () => { const facts = [ { id: "fact_sandbox", storeEnvironment: "sandbox" }, { id: "fact_production", storeEnvironment: "production" }, - ] as TransactionFact[] + ] as TransactionFact[]; - const filters = parseTransactionFilters({ storeEnvironment: "production" }) - const visible = applyClientTransactionFilters(facts, filters) + const filters = parseTransactionFilters({ storeEnvironment: "production" }); + const visible = applyClientTransactionFilters(facts, filters); - expect(visible.map((fact) => fact.id)).toEqual(["fact_production"]) - }) -}) + expect(visible.map((fact) => fact.id)).toEqual(["fact_production"]); + }); +}); diff --git a/apps/dashboard/src/features/billing-ledger/types/transaction-filters.ts b/apps/dashboard/src/features/billing-ledger/types/transaction-filters.ts index 472581fb..d2d5ec0b 100644 --- a/apps/dashboard/src/features/billing-ledger/types/transaction-filters.ts +++ b/apps/dashboard/src/features/billing-ledger/types/transaction-filters.ts @@ -1,12 +1,14 @@ -import type { TransactionFact } from "@/generated/api" import { - billingProviders, - resolutionStates, - storeEnvironments, type BillingProvider, + billingProviders, type ResolutionState, + resolutionStates, type StoreEnvironment, -} from "@/features/billing-ledger/types/billing-vocabulary" + storeEnvironments, +} from "@/features/billing-ledger/types/billing-vocabulary"; +import type { TransactionFact } from "@/generated/api"; + +const CURSOR_PATTERN = /^[\x20-\x7E]{1,512}$/; /** * Transaction ledger filters, carried in the URL so a view is shareable and @@ -26,58 +28,65 @@ import { * would replace a recoverable page with a route error. */ export interface TransactionFilters { - applicationId?: string - cursor?: string + applicationId?: string; + cursor?: string; /** Inclusive lower bound on the store-reported `occurredAt`. */ - from?: string - limit: number - productId?: string - provider?: BillingProvider - reference?: string - resolutionState?: ResolutionState - storeEnvironment?: StoreEnvironment + from?: string; + limit: number; + productId?: string; + provider?: BillingProvider; + reference?: string; + resolutionState?: ResolutionState; + storeEnvironment?: StoreEnvironment; /** Inclusive upper bound on the store-reported `occurredAt`. */ - to?: string + to?: string; } -export const TRANSACTION_PAGE_SIZES = [25, 50, 100] as const -export const DEFAULT_TRANSACTION_PAGE_SIZE = 50 +export const TRANSACTION_PAGE_SIZES = [25, 50, 100] as const; +export const DEFAULT_TRANSACTION_PAGE_SIZE = 50; /** Matches the `safeProviderCode` bound the ingestion contract applies. */ -const SAFE_REFERENCE = /^[\x20-\x7E]{1,128}$/ -const IDENTIFIER = /^[A-Za-z0-9_-]{1,64}$/ +const SAFE_REFERENCE = /^[\x20-\x7E]{1,128}$/; +const IDENTIFIER = /^[A-Za-z0-9_-]{1,64}$/; function safeString(value: unknown, pattern: RegExp) { - return typeof value === "string" && pattern.test(value) ? value : undefined + return typeof value === "string" && pattern.test(value) ? value : undefined; } function safeTimestamp(value: unknown) { - if (typeof value !== "string" || value.length === 0 || value.length > 40) return undefined - const parsed = Date.parse(value) - return Number.isNaN(parsed) ? undefined : value + if (typeof value !== "string" || value.length === 0 || value.length > 40) { + return; + } + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? undefined : value; } function member(value: unknown, allowed: readonly T[]) { return typeof value === "string" && allowed.some((item) => item === value) ? (value as T) - : undefined + : undefined; } export function defaultTransactionFilters(): TransactionFilters { - return { limit: DEFAULT_TRANSACTION_PAGE_SIZE } + return { limit: DEFAULT_TRANSACTION_PAGE_SIZE }; } -export function parseTransactionFilters(search: Record): TransactionFilters { - const from = safeTimestamp(search.from) - const to = safeTimestamp(search.to) +export function parseTransactionFilters( + search: Record +): TransactionFilters { + const from = safeTimestamp(search.from); + const to = safeTimestamp(search.to); // An inverted range would silently return nothing and read as data loss. // Dropping both bounds returns the documented unfiltered default instead. - const orderedRange = from && to && Date.parse(from) > Date.parse(to) ? {} : { from, to } - const limit = TRANSACTION_PAGE_SIZES.find((size) => size === Number(search.limit)) + const orderedRange = + from && to && Date.parse(from) > Date.parse(to) ? {} : { from, to }; + const limit = TRANSACTION_PAGE_SIZES.find( + (size) => size === Number(search.limit) + ); return { applicationId: safeString(search.applicationId, IDENTIFIER), - cursor: safeString(search.cursor, /^[\x20-\x7E]{1,512}$/), + cursor: safeString(search.cursor, CURSOR_PATTERN), ...orderedRange, limit: limit ?? DEFAULT_TRANSACTION_PAGE_SIZE, productId: safeString(search.productId, IDENTIFIER), @@ -87,13 +96,20 @@ export function parseTransactionFilters(search: Record): Transa // Deliberately independent of the Mosaic Environment, which is a path // segment and is never read from the search string. storeEnvironment: member(search.storeEnvironment, storeEnvironments), - } + }; } /** Drops empty values so a cleared filter leaves the URL rather than sitting in it. */ -export function serializeTransactionFilters(filters: TransactionFilters): TransactionFilters { - const entries = Object.entries(filters).filter(([, value]) => value !== undefined && value !== "") - return { ...(Object.fromEntries(entries) as TransactionFilters), limit: filters.limit } +export function serializeTransactionFilters( + filters: TransactionFilters +): TransactionFilters { + const entries = Object.entries(filters).filter( + ([, value]) => value !== undefined && value !== "" + ); + return { + ...(Object.fromEntries(entries) as TransactionFilters), + limit: filters.limit, + }; } /** @@ -108,7 +124,7 @@ export function transactionFactsQuery(filters: TransactionFilters) { limit: filters.limit, ...(filters.provider ? { provider: filters.provider } : {}), ...(filters.to ? { to: filters.to } : {}), - } + }; } export const CLIENT_APPLIED_FILTER_KEYS = [ @@ -117,10 +133,11 @@ export const CLIENT_APPLIED_FILTER_KEYS = [ "reference", "resolutionState", "storeEnvironment", -] as const satisfies readonly (keyof TransactionFilters)[] +] as const satisfies readonly (keyof TransactionFilters)[]; export function clientAppliedFilterCount(filters: TransactionFilters) { - return CLIENT_APPLIED_FILTER_KEYS.filter((key) => filters[key] !== undefined).length + return CLIENT_APPLIED_FILTER_KEYS.filter((key) => filters[key] !== undefined) + .length; } export function hasActiveTransactionFilters(filters: TransactionFilters) { @@ -129,19 +146,33 @@ export function hasActiveTransactionFilters(filters: TransactionFilters) { filters.provider !== undefined || filters.from !== undefined || filters.to !== undefined - ) + ); } export function applyClientTransactionFilters( items: readonly TransactionFact[], - filters: TransactionFilters, + filters: TransactionFilters ): TransactionFact[] { - const reference = filters.reference?.toLowerCase() + const reference = filters.reference?.toLowerCase(); return items.filter((item) => { - if (filters.applicationId && item.applicationId !== filters.applicationId) return false - if (filters.productId && item.mosaicProductId !== filters.productId) return false - if (filters.storeEnvironment && item.storeEnvironment !== filters.storeEnvironment) return false - if (filters.resolutionState && item.resolutionState !== filters.resolutionState) return false + if (filters.applicationId && item.applicationId !== filters.applicationId) { + return false; + } + if (filters.productId && item.mosaicProductId !== filters.productId) { + return false; + } + if ( + filters.storeEnvironment && + item.storeEnvironment !== filters.storeEnvironment + ) { + return false; + } + if ( + filters.resolutionState && + item.resolutionState !== filters.resolutionState + ) { + return false; + } if (reference) { const haystack = [ item.providerTransactionId, @@ -150,9 +181,11 @@ export function applyClientTransactionFilters( ] .filter((value): value is string => typeof value === "string") .join(" ") - .toLowerCase() - if (!haystack.includes(reference)) return false + .toLowerCase(); + if (!haystack.includes(reference)) { + return false; + } } - return true - }) + return true; + }); } diff --git a/apps/dashboard/src/features/billing-migrations/components/create-migration-program-form.test.tsx b/apps/dashboard/src/features/billing-migrations/components/create-migration-program-form.test.tsx index 903cebba..0dbae46b 100644 --- a/apps/dashboard/src/features/billing-migrations/components/create-migration-program-form.test.tsx +++ b/apps/dashboard/src/features/billing-migrations/components/create-migration-program-form.test.tsx @@ -1,9 +1,13 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; -import { CreateMigrationProgramForm } from "@/features/billing-migrations/components/create-migration-program-form" -import { chooseSelectOption } from "@/test/select" -import type { Application, BillingMigrationProgram, Environment } from "@/generated/api" +import { CreateMigrationProgramForm } from "@/features/billing-migrations/components/create-migration-program-form"; +import type { + Application, + BillingMigrationProgram, + Environment, +} from "@/generated/api"; +import { chooseSelectOption } from "@/test/select"; const application: Application = { createdAt: "2026-07-29T00:00:00Z", @@ -13,7 +17,7 @@ const application: Application = { platform: "ios", projectId: "project_1", updatedAt: "2026-07-29T00:00:00Z", -} +}; const environment: Environment = { createdAt: "2026-07-29T00:00:00Z", id: "env_1", @@ -22,7 +26,7 @@ const environment: Environment = { name: "Production", projectId: "project_1", updatedAt: "2026-07-29T00:00:00Z", -} +}; const program: BillingMigrationProgram = { authorityEpochBefore: 0, programId: "program_1", @@ -32,33 +36,40 @@ const program: BillingMigrationProgram = { environmentId: environment.id, projectId: "project_1", }, - source: { adapter: "revenuecat", adapterVersion: "v2", credentialReference: "credential_1" }, + source: { + adapter: "revenuecat", + adapterVersion: "v2", + credentialReference: "credential_1", + }, stabilizationDays: 7, state: "mapping", stateVersion: 1, -} +}; async function fillRequiredFields() { - await chooseSelectOption(screen.getByLabelText("Environment"), environment.name) + await chooseSelectOption( + screen.getByLabelText("Environment"), + environment.name + ); fireEvent.change(screen.getByLabelText("RevenueCat Project ID"), { target: { value: "rc_project" }, - }) + }); fireEvent.change(screen.getByLabelText("RevenueCat migration API key"), { target: { value: "rc_secret" }, - }) - fireEvent.click(screen.getByLabelText("Mosaic iOS · ios")) + }); + fireEvent.click(screen.getByLabelText("Mosaic iOS · ios")); } describe("CreateMigrationProgramForm", () => { it("clears the secret after success and suppresses a double submission with one command key", async () => { - let resolve!: (value: BillingMigrationProgram) => void - const commandKeys: string[] = [] + let resolve!: (value: BillingMigrationProgram) => void; + const commandKeys: string[] = []; const onCreate = vi.fn((command: { idempotencyKey: string }) => { - commandKeys.push(command.idempotencyKey) + commandKeys.push(command.idempotencyKey); return new Promise((done) => { - resolve = done - }) - }) + resolve = done; + }); + }); render( { onCreate={onCreate} onCreated={vi.fn()} resetMutation={vi.fn()} - />, - ) - await fillRequiredFields() - fireEvent.click(screen.getByRole("button", { name: "Create and check source" })) - fireEvent.click(screen.getByRole("button", { name: "Create and check source" })) - await waitFor(() => expect(onCreate).toHaveBeenCalledOnce()) - expect(commandKeys).toHaveLength(1) - expect(commandKeys[0]).toBeTruthy() - resolve(program) + /> + ); + await fillRequiredFields(); + fireEvent.click( + screen.getByRole("button", { name: "Create and check source" }) + ); + fireEvent.click( + screen.getByRole("button", { name: "Create and check source" }) + ); + await waitFor(() => expect(onCreate).toHaveBeenCalledOnce()); + expect(commandKeys).toHaveLength(1); + expect(commandKeys[0]).toBeTruthy(); + resolve(program); await waitFor(() => - expect(screen.getByLabelText("RevenueCat migration API key")).toHaveValue(""), - ) - }) + expect(screen.getByLabelText("RevenueCat migration API key")).toHaveValue( + "" + ) + ); + }); it("clears the secret after failure and renders Mosaic-owned recovery copy", async () => { render( @@ -90,12 +107,20 @@ describe("CreateMigrationProgramForm", () => { onCreate={() => Promise.reject(new Error("raw provider stack"))} onCreated={vi.fn()} resetMutation={vi.fn()} - />, - ) - await fillRequiredFields() - fireEvent.click(screen.getByRole("button", { name: "Create and check source" })) - expect(await screen.findByRole("alert")).toHaveTextContent("Mosaic could not check this source") - expect(screen.getByRole("alert")).not.toHaveTextContent("raw provider stack") - expect(screen.getByLabelText("RevenueCat migration API key")).toHaveValue("") - }) -}) + /> + ); + await fillRequiredFields(); + fireEvent.click( + screen.getByRole("button", { name: "Create and check source" }) + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Mosaic could not check this source" + ); + expect(screen.getByRole("alert")).not.toHaveTextContent( + "raw provider stack" + ); + expect(screen.getByLabelText("RevenueCat migration API key")).toHaveValue( + "" + ); + }); +}); diff --git a/apps/dashboard/src/features/billing-migrations/components/create-migration-program-form.tsx b/apps/dashboard/src/features/billing-migrations/components/create-migration-program-form.tsx index e1312477..60f06bf6 100644 --- a/apps/dashboard/src/features/billing-migrations/components/create-migration-program-form.tsx +++ b/apps/dashboard/src/features/billing-migrations/components/create-migration-program-form.tsx @@ -1,35 +1,40 @@ -import { useForm } from "@tanstack/react-form" -import { useRef, useState } from "react" +import { useForm } from "@tanstack/react-form"; +import { useRef, useState } from "react"; -import { Button } from "@/components/ui/button" -import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field" -import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button"; +import { + Field, + FieldDescription, + FieldError, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/ui/select" -import { createMigrationCommandKey } from "@/features/billing-migrations/mutations/migration-mutations" -import { migrationErrorCopy } from "@/features/billing-migrations/types/migration-operations" +} from "@/components/ui/select"; +import { createMigrationCommandKey } from "@/features/billing-migrations/mutations/migration-mutations"; +import { migrationErrorCopy } from "@/features/billing-migrations/types/migration-operations"; import type { Application, BillingMigrationProgram, CreateBillingMigrationProgramRequestWritable, Environment, -} from "@/generated/api" +} from "@/generated/api"; interface Props { - applications: readonly Application[] - environments: readonly Environment[] - isPending: boolean + applications: readonly Application[]; + environments: readonly Environment[]; + isPending: boolean; onCreate: (command: { - body: CreateBillingMigrationProgramRequestWritable - idempotencyKey: string - }) => Promise - onCreated: (program: BillingMigrationProgram) => void - resetMutation: () => void + body: CreateBillingMigrationProgramRequestWritable; + idempotencyKey: string; + }) => Promise; + onCreated: (program: BillingMigrationProgram) => void; + resetMutation: () => void; } export function CreateMigrationProgramForm({ @@ -43,9 +48,9 @@ export function CreateMigrationProgramForm({ const environmentOptions = [ { label: "Choose an Environment", value: "" }, ...environments.map((item) => ({ label: item.name, value: item.id })), - ] - const submitting = useRef(false) - const [submitError, setSubmitError] = useState(null) + ]; + const submitting = useRef(false); + const [submitError, setSubmitError] = useState(null); const form = useForm({ defaultValues: { applicationIds: [] as string[], @@ -56,16 +61,22 @@ export function CreateMigrationProgramForm({ stabilizationDays: 7, }, onSubmit: async ({ value }) => { - if (submitting.current) return - submitting.current = true - setSubmitError(null) + if (submitting.current) { + return; + } + submitting.current = true; + setSubmitError(null); try { const program = await onCreate({ body: { applications: value.applicationIds.map((applicationId) => { - const application = applications.find((item) => item.id === applicationId) - if (!application) throw new Error("invalid_scope") - return { applicationId, platform: application.platform } + const application = applications.find( + (item) => item.id === applicationId + ); + if (!application) { + throw new Error("invalid_scope"); + } + return { applicationId, platform: application.platform }; }), environmentId: value.environmentId, revenueCatApiKey: value.revenueCatApiKey, @@ -74,30 +85,33 @@ export function CreateMigrationProgramForm({ stabilizationDays: value.stabilizationDays, }, idempotencyKey: createMigrationCommandKey(), - }) - form.reset() - onCreated(program) + }); + form.reset(); + onCreated(program); } catch (error) { - setSubmitError(migrationErrorCopy(error, "create")) + setSubmitError(migrationErrorCopy(error, "create")); } finally { - form.setFieldValue("revenueCatApiKey", "") - resetMutation() - submitting.current = false + form.setFieldValue("revenueCatApiKey", ""); + resetMutation(); + submitting.current = false; } }, - }) + }); return (
{ - event.preventDefault() - void form.handleSubmit() + event.preventDefault(); + form.handleSubmit(); }} > (value ? undefined : "Choose an Environment.") }} + validators={{ + onSubmit: ({ value }) => + value ? undefined : "Choose an Environment.", + }} > {(field) => ( 0}> @@ -118,37 +132,50 @@ export function CreateMigrationProgramForm({ ))} - ({ message }))} /> + ({ message }))} + /> )} (value.trim() ? undefined : "Enter the RevenueCat Project ID."), + onSubmit: ({ value }) => + value.trim() ? undefined : "Enter the RevenueCat Project ID.", }} > {(field) => ( 0}> - RevenueCat Project ID + + RevenueCat Project ID + field.handleChange(event.target.value)} value={field.state.value} /> - ({ message }))} /> + ({ message }))} + /> )} (value ? undefined : "Enter a least-privilege migration key."), + onSubmit: ({ value }) => + value ? undefined : "Enter a least-privilege migration key.", }} > {(field) => ( - 0}> - RevenueCat migration API key + 0} + > + + RevenueCat migration API key + - Cleared after every submission and never read back. - ({ message }))} /> + + Cleared after every submission and never read back. + + ({ message }))} + /> )} @@ -165,26 +196,36 @@ export function CreateMigrationProgramForm({ name="applicationIds" validators={{ onSubmit: ({ value }) => - value.length ? undefined : "Select at least one Application/platform scope.", + value.length + ? undefined + : "Select at least one Application/platform scope.", }} > {(field) => ( - 0}> + 0} + > Application/platform scope - Choose every Application and platform explicitly. Mosaic does not infer or wildcard - this scope. + Choose every Application and platform explicitly. Mosaic does not + infer or wildcard this scope.
{applications.map((application) => ( -
- ({ message }))} /> + ({ message }))} + />
)} @@ -208,5 +251,5 @@ export function CreateMigrationProgramForm({ - ) + ); } diff --git a/apps/dashboard/src/features/billing-migrations/components/freeze-mapping-action.test.tsx b/apps/dashboard/src/features/billing-migrations/components/freeze-mapping-action.test.tsx index e2dda041..94ca2486 100644 --- a/apps/dashboard/src/features/billing-migrations/components/freeze-mapping-action.test.tsx +++ b/apps/dashboard/src/features/billing-migrations/components/freeze-mapping-action.test.tsx @@ -1,10 +1,14 @@ -import { fireEvent, render, screen } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; -import { FreezeMappingAction } from "@/features/billing-migrations/components/freeze-mapping-action" -import type { BillingMigrationMappingSet } from "@/generated/api" +import { FreezeMappingAction } from "@/features/billing-migrations/components/freeze-mapping-action"; +import type { BillingMigrationMappingSet } from "@/generated/api"; -function mapping(id: string, version: number, digest: string): BillingMigrationMappingSet { +function mapping( + id: string, + version: number, + digest: string +): BillingMigrationMappingSet { return { entries: [], mappingDigest: digest, @@ -13,32 +17,36 @@ function mapping(id: string, version: number, digest: string): BillingMigrationM stateVersion: 1, status: "draft", version, - } + }; } describe("FreezeMappingAction", () => { it("binds confirmation to the exact selected mapping ID, version, and digest", () => { - const onFreeze = vi.fn().mockResolvedValue(undefined) + const onFreeze = vi.fn().mockResolvedValue(undefined); render( - <> - {[mapping("mapping_1", 1, "sha256:one"), mapping("mapping_2", 2, "sha256:two")].map( - (item) => ( - - ), - )} - , - ) - expect(screen.getByRole("button", { name: "Freeze version 1" })).toBeDisabled() - fireEvent.click(screen.getByLabelText("Confirm freeze version 2")) - const button = screen.getByRole("button", { name: "Freeze version 2" }) - expect(button).toHaveAttribute("data-confirmation", "mapping_2:2:sha256:two") - fireEvent.click(button) - expect(onFreeze).toHaveBeenCalledOnce() - expect(onFreeze).toHaveBeenCalledWith("mapping_2") - }) -}) + [ + mapping("mapping_1", 1, "sha256:one"), + mapping("mapping_2", 2, "sha256:two"), + ].map((item) => ( + + )) + ); + expect( + screen.getByRole("button", { name: "Freeze version 1" }) + ).toBeDisabled(); + fireEvent.click(screen.getByLabelText("Confirm freeze version 2")); + const button = screen.getByRole("button", { name: "Freeze version 2" }); + expect(button).toHaveAttribute( + "data-confirmation", + "mapping_2:2:sha256:two" + ); + fireEvent.click(button); + expect(onFreeze).toHaveBeenCalledOnce(); + expect(onFreeze).toHaveBeenCalledWith("mapping_2"); + }); +}); diff --git a/apps/dashboard/src/features/billing-migrations/components/freeze-mapping-action.tsx b/apps/dashboard/src/features/billing-migrations/components/freeze-mapping-action.tsx index 47c83f26..cf95d168 100644 --- a/apps/dashboard/src/features/billing-migrations/components/freeze-mapping-action.tsx +++ b/apps/dashboard/src/features/billing-migrations/components/freeze-mapping-action.tsx @@ -1,26 +1,26 @@ -import { useState } from "react" +import { useState } from "react"; -import { Button } from "@/components/ui/button" -import type { BillingMigrationMappingSet } from "@/generated/api" +import { Button } from "@/components/ui/button"; +import type { BillingMigrationMappingSet } from "@/generated/api"; export function FreezeMappingAction({ isPending, mapping, onFreeze, }: { - isPending: boolean - mapping: BillingMigrationMappingSet - onFreeze: (mappingSetId: string) => Promise + isPending: boolean; + mapping: BillingMigrationMappingSet; + onFreeze: (mappingSetId: string) => Promise; }) { - const [confirmed, setConfirmed] = useState(false) - const confirmation = `${mapping.mappingSetId}:${mapping.version}:${mapping.mappingDigest}` + const [confirmed, setConfirmed] = useState(false); + const confirmation = `${mapping.mappingSetId}:${mapping.version}:${mapping.mappingDigest}`; return (
-

Freeze version {mapping.version}

-

+

Freeze version {mapping.version}

+

Impact: mapping set {mapping.mappingSetId} with digest{" "} - {mapping.mappingDigest} becomes immutable and the Program - advances to Import. + {mapping.mappingDigest} becomes + immutable and the Program advances to Import.

- ) + ); } diff --git a/apps/dashboard/src/features/billing-migrations/components/guided-mapping-form.test.tsx b/apps/dashboard/src/features/billing-migrations/components/guided-mapping-form.test.tsx index 0a234bdf..5fe2aaa7 100644 --- a/apps/dashboard/src/features/billing-migrations/components/guided-mapping-form.test.tsx +++ b/apps/dashboard/src/features/billing-migrations/components/guided-mapping-form.test.tsx @@ -1,25 +1,39 @@ -import { fireEvent, render, screen } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; -import { GuidedMappingForm } from "@/features/billing-migrations/components/guided-mapping-form" +import { GuidedMappingForm } from "@/features/billing-migrations/components/guided-mapping-form"; describe("GuidedMappingForm", () => { it("validates structured rows and presents an exact pre-submit review", () => { - const onCreate = vi.fn().mockResolvedValue(undefined) - render() - expect(screen.getByRole("button", { name: "Review mapping set" })).toBeDisabled() - expect(screen.getByText("Enter the exact source identifier.")).toBeInTheDocument() - fireEvent.change(screen.getByRole("textbox", { name: /Source identifier/ }), { - target: { value: "source.product.pro" }, - }) - fireEvent.change(screen.getByRole("textbox", { name: /Mosaic target ID/ }), { - target: { value: "product_pro" }, - }) - fireEvent.click(screen.getByRole("button", { name: "Review mapping set" })) - expect(screen.getByRole("heading", { name: "Review before creating" })).toBeInTheDocument() - expect(screen.getByText(/source\.product\.pro/)).toBeInTheDocument() - expect(screen.getByText(/product_pro/)).toBeInTheDocument() - fireEvent.click(screen.getByRole("button", { name: "Create reviewed draft" })) + const onCreate = vi.fn().mockResolvedValue(undefined); + render(); + expect( + screen.getByRole("button", { name: "Review mapping set" }) + ).toBeDisabled(); + expect( + screen.getByText("Enter the exact source identifier.") + ).toBeInTheDocument(); + fireEvent.change( + screen.getByRole("textbox", { name: /Source identifier/ }), + { + target: { value: "source.product.pro" }, + } + ); + fireEvent.change( + screen.getByRole("textbox", { name: /Mosaic target ID/ }), + { + target: { value: "product_pro" }, + } + ); + fireEvent.click(screen.getByRole("button", { name: "Review mapping set" })); + expect( + screen.getByRole("heading", { name: "Review before creating" }) + ).toBeInTheDocument(); + expect(screen.getByText(/source\.product\.pro/)).toBeInTheDocument(); + expect(screen.getByText(/product_pro/)).toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: "Create reviewed draft" }) + ); expect(onCreate).toHaveBeenCalledWith([ { matchKind: "exact", @@ -27,6 +41,6 @@ describe("GuidedMappingForm", () => { sourceKind: "product", targetId: "product_pro", }, - ]) - }) -}) + ]); + }); +}); diff --git a/apps/dashboard/src/features/billing-migrations/components/guided-mapping-form.tsx b/apps/dashboard/src/features/billing-migrations/components/guided-mapping-form.tsx index d6613929..986a969f 100644 --- a/apps/dashboard/src/features/billing-migrations/components/guided-mapping-form.tsx +++ b/apps/dashboard/src/features/billing-migrations/components/guided-mapping-form.tsx @@ -1,219 +1,314 @@ -import { useMemo, useState } from "react" +import { useCallback, useMemo, useState } from "react"; -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/ui/select" -import type { BillingMigrationMappingEntry } from "@/generated/api" +} from "@/components/ui/select"; +import type { BillingMigrationMappingEntry } from "@/generated/api"; -type MappingEntry = BillingMigrationMappingEntry +type MappingEntry = BillingMigrationMappingEntry; const sourceKinds: MappingEntry["sourceKind"][] = [ "customer_id", "original_customer_id", "audited_alias", "product", "entitlement", -] +]; const sourceKindOptions = sourceKinds.map((kind) => ({ label: kind.replaceAll("_", " "), value: kind, -})) +})); const MATCH_KIND_OPTIONS = [ { label: "Exact", value: "exact" }, { label: "Audited alias", value: "audited_alias" }, -] -const emptyRow = (): MappingEntry => ({ - matchKind: "exact", - sourceIdentifier: "", - sourceKind: "product", - targetId: "", -}) +]; + +/** + * Rows carry their own identity because they are added and removed freely. Keyed + * by position instead, React would reuse a removed row's DOM — and its focus and + * caret — for whichever row slid into its place. + */ +interface MappingRow { + entry: MappingEntry; + id: string; +} + +const emptyRow = (): MappingRow => ({ + entry: { + matchKind: "exact", + sourceIdentifier: "", + sourceKind: "product", + targetId: "", + }, + id: crypto.randomUUID(), +}); + +interface RowError { + source: string; + target: string; +} interface Props { - isPending: boolean - onCreate: (entries: MappingEntry[]) => Promise + isPending: boolean; + onCreate: (entries: MappingEntry[]) => Promise; +} + +function MappingRowFields({ + canRemove, + entry, + error, + id, + isPending, + onChange, + onRemove, + position, +}: { + canRemove: boolean; + entry: MappingEntry; + error?: RowError; + id: string; + isPending: boolean; + onChange: (id: string, patch: Partial) => void; + onRemove: (id: string) => void; + position: number; +}) { + const handleSourceKind = useCallback( + (value: string) => + onChange(id, { sourceKind: value as MappingEntry["sourceKind"] }), + [id, onChange] + ); + const handleSourceIdentifier = useCallback( + (event: React.ChangeEvent) => + onChange(id, { sourceIdentifier: event.target.value }), + [id, onChange] + ); + const handleMatchKind = useCallback( + (value: string) => + onChange(id, { matchKind: value as MappingEntry["matchKind"] }), + [id, onChange] + ); + const handleTargetId = useCallback( + (event: React.ChangeEvent) => + onChange(id, { targetId: event.target.value }), + [id, onChange] + ); + const handleRemove = useCallback(() => onRemove(id), [id, onRemove]); + const sourceInputId = `mapping-${id}-source`; + const targetInputId = `mapping-${id}-target`; + + return ( +
+ Mapping {position} +
+
+ Source kind + +
+ +
+ Match + +
+ +
+ {canRemove ? ( + + ) : null} +
+ ); } export function GuidedMappingForm({ isPending, onCreate }: Props) { - const [entries, setEntries] = useState([emptyRow()]) - const [reviewing, setReviewing] = useState(false) + const [rows, setRows] = useState([emptyRow()]); + const [reviewing, setReviewing] = useState(false); const errors = useMemo( () => - entries.map((entry) => ({ - source: entry.sourceIdentifier.trim() ? "" : "Enter the exact source identifier.", + rows.map(({ entry }) => ({ + source: entry.sourceIdentifier.trim() + ? "" + : "Enter the exact source identifier.", target: entry.targetId.trim() ? "" : "Enter the Mosaic target ID.", })), - [entries], - ) - const valid = errors.every((entry) => !entry.source && !entry.target) + [rows] + ); + const valid = errors.every((entry) => !(entry.source || entry.target)); - function update(index: number, patch: Partial) { - setReviewing(false) - setEntries((current) => - current.map((entry, entryIndex) => (entryIndex === index ? { ...entry, ...patch } : entry)), - ) - } + const update = useCallback((id: string, patch: Partial) => { + setReviewing(false); + setRows((current) => + current.map((row) => + row.id === id ? { ...row, entry: { ...row.entry, ...patch } } : row + ) + ); + }, []); + + const remove = useCallback((id: string) => { + setReviewing(false); + setRows((current) => current.filter((row) => row.id !== id)); + }, []); + + const add = useCallback(() => { + setReviewing(false); + setRows((current) => [...current, emptyRow()]); + }, []); + + const review = useCallback(() => setReviewing(true), []); + + const create = useCallback( + () => + onCreate( + rows.map(({ entry }) => ({ + ...entry, + sourceIdentifier: entry.sourceIdentifier.trim(), + targetId: entry.targetId.trim(), + })) + ), + [onCreate, rows] + ); return (
-

- Enter source identifiers from normalized, assessed source evidence only. Source-row browsing - and Mosaic target pickers are deferred until verified manifest ingestion and list APIs are - available; do not infer or transform identifiers here. +

+ Enter source identifiers from normalized, assessed source evidence only. + Source-row browsing and Mosaic target pickers are deferred until + verified manifest ingestion and list APIs are available; do not infer or + transform identifiers here.

- {entries.map((entry, index) => ( -
- Mapping {index + 1} -
-
- Source kind - -
- -
- Match - -
- -
- {entries.length > 1 ? ( - - ) : null} -
+ {rows.map((row, index) => ( + 1} + entry={row.entry} + error={errors[index]} + id={row.id} + isPending={isPending} + key={row.id} + onChange={update} + onRemove={remove} + position={index + 1} + /> ))}
-
{reviewing ? ( -
+

Review before creating

-

- This draft contains {entries.length} explicit mapping(s). Review every source and target - before creating an immutable version later. +

+ This draft contains {rows.length} explicit mapping(s). Review every + source and target before creating an immutable version later.

    - {entries.map((entry, index) => ( -
  • - {entry.sourceKind.replaceAll("_", " ")} {entry.sourceIdentifier} →{" "} - {entry.targetId} ({entry.matchKind.replaceAll("_", " ")}) + {rows.map(({ entry, id }) => ( +
  • + {entry.sourceKind.replaceAll("_", " ")}{" "} + {entry.sourceIdentifier} →{" "} + {entry.targetId} ( + {entry.matchKind.replaceAll("_", " ")})
  • ))}
) : null}
- ) + ); } diff --git a/apps/dashboard/src/features/billing-migrations/components/migration-command-safety.test.tsx b/apps/dashboard/src/features/billing-migrations/components/migration-command-safety.test.tsx index c1040f41..a1a0845c 100644 --- a/apps/dashboard/src/features/billing-migrations/components/migration-command-safety.test.tsx +++ b/apps/dashboard/src/features/billing-migrations/components/migration-command-safety.test.tsx @@ -1,35 +1,37 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react" -import { describe, expect, it, vi } from "vitest" +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useCallback } from "react"; +import { describe, expect, it, vi } from "vitest"; -import { MigrationImpactReviewAction } from "@/features/billing-migrations/components/migration-impact-review-action" -import { useMigrationCommand } from "@/features/billing-migrations/hooks/use-migration-command" -import { ApiError } from "@/lib/api/errors" +import { MigrationImpactReviewAction } from "@/features/billing-migrations/components/migration-impact-review-action"; +import { useMigrationCommand } from "@/features/billing-migrations/hooks/use-migration-command"; +import { ApiError } from "@/lib/api/errors"; function StaleHarness({ onStale }: { onStale: () => Promise }) { - const command = useMigrationCommand(onStale) + const command = useMigrationCommand(onStale); + const handleClick = useCallback( + () => + command.run( + () => + Promise.reject( + new ApiError("raw conflict", { + code: "migration_state_conflict", + correlationId: "request_1", + retryable: false, + status: 409, + }) + ), + "freeze" + ), + [command] + ); return ( <> - {command.error ?

{command.error}

: null} - ) + ); } describe("migration command components", () => { @@ -44,18 +46,20 @@ describe("migration command components", () => { onConfirm={vi.fn()} pendingLabel="Queueing…" title="Review import impact" - />, - ) - const button = screen.getByRole("button", { name: "Queue import" }) - expect(button).toBeDisabled() - expect(button).toHaveAccessibleDescription("Freeze a reviewed mapping set before importing.") + /> + ); + const button = screen.getByRole("button", { name: "Queue import" }); + expect(button).toBeDisabled(); + expect(button).toHaveAccessibleDescription( + "Freeze a reviewed mapping set before importing." + ); expect( - screen.getByText(/does not change billing authority or customer access/), - ).toBeInTheDocument() - }) + screen.getByText(/does not change billing authority or customer access/) + ).toBeInTheDocument(); + }); it("requires confirmation for the exact reviewed command inputs", () => { - const onConfirm = vi.fn() + const onConfirm = vi.fn(); const { rerender } = render( { onConfirm={onConfirm} pendingLabel="Queueing…" title="Review import impact" - />, - ) + /> + ); - const button = screen.getByRole("button", { name: "Queue import" }) - expect(button).toBeDisabled() - expect(button).toHaveAttribute("data-impact-binding", "import:4:manifest_1:mapping_1:100") - fireEvent.click(screen.getByRole("checkbox", { name: "Confirm Queue import" })) - fireEvent.click(button) - expect(onConfirm).toHaveBeenCalledOnce() + const button = screen.getByRole("button", { name: "Queue import" }); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute( + "data-impact-binding", + "import:4:manifest_1:mapping_1:100" + ); + fireEvent.click( + screen.getByRole("checkbox", { name: "Confirm Queue import" }) + ); + fireEvent.click(button); + expect(onConfirm).toHaveBeenCalledOnce(); rerender( { onConfirm={onConfirm} pendingLabel="Queueing…" title="Review import impact" - />, - ) - expect(screen.getByRole("button", { name: "Queue import" })).toBeDisabled() - expect(screen.getByRole("checkbox", { name: "Confirm Queue import" })).not.toBeChecked() - }) + /> + ); + expect(screen.getByRole("button", { name: "Queue import" })).toBeDisabled(); + expect( + screen.getByRole("checkbox", { name: "Confirm Queue import" }) + ).not.toBeChecked(); + }); it("refetches stale state and shows safe review-before-retry copy", async () => { - const onStale = vi.fn().mockResolvedValue(undefined) - render() - fireEvent.click(screen.getByRole("button", { name: "Freeze" })) - await waitFor(() => expect(onStale).toHaveBeenCalledOnce()) - expect(screen.getByRole("alert")).toHaveTextContent("Mosaic refreshed the latest state") - expect(screen.getByRole("alert")).not.toHaveTextContent("raw conflict") - }) -}) + const onStale = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.click(screen.getByRole("button", { name: "Freeze" })); + await waitFor(() => expect(onStale).toHaveBeenCalledOnce()); + expect(screen.getByRole("alert")).toHaveTextContent( + "Mosaic refreshed the latest state" + ); + expect(screen.getByRole("alert")).not.toHaveTextContent("raw conflict"); + }); +}); diff --git a/apps/dashboard/src/features/billing-migrations/components/migration-impact-review-action.tsx b/apps/dashboard/src/features/billing-migrations/components/migration-impact-review-action.tsx index ac7513c6..073b3d6f 100644 --- a/apps/dashboard/src/features/billing-migrations/components/migration-impact-review-action.tsx +++ b/apps/dashboard/src/features/billing-migrations/components/migration-impact-review-action.tsx @@ -1,24 +1,24 @@ -import { useState } from "react" +import { useState } from "react"; -import { Button } from "@/components/ui/button" +import { Button } from "@/components/ui/button"; interface ReviewFact { - label: string - value: string + label: string; + value: string; } interface Props { - actionLabel: string - binding: string - disabledReason: string | null - facts: readonly ReviewFact[] - impactSummary?: string - confirmationCopy?: string - isPending: boolean - onConfirm: () => void - pendingLabel: string - title: string - variant?: "default" | "outline" + actionLabel: string; + binding: string; + confirmationCopy?: string; + disabledReason: string | null; + facts: readonly ReviewFact[]; + impactSummary?: string; + isPending: boolean; + onConfirm: () => void; + pendingLabel: string; + title: string; + variant?: "default" | "outline"; } export function MigrationImpactReviewAction({ @@ -34,18 +34,21 @@ export function MigrationImpactReviewAction({ title, variant = "default", }: Props) { - const [confirmedBinding, setConfirmedBinding] = useState(null) - const confirmed = confirmedBinding === binding - const explanationId = `${binding.replaceAll(/[^a-zA-Z0-9_-]/g, "-")}-explanation` + const [confirmedBinding, setConfirmedBinding] = useState(null); + const confirmed = confirmedBinding === binding; + const explanationId = `${binding.replaceAll(/[^a-zA-Z0-9_-]/g, "-")}-explanation`; + const buttonLabel = isPending ? pendingLabel : actionLabel; return (
-

{title}

-

Impact: {impactSummary}

+

{title}

+

+ Impact: {impactSummary} +

{facts.map((fact) => (
{fact.label}
-
{fact.value}
+
{fact.value}
))}
@@ -54,7 +57,9 @@ export function MigrationImpactReviewAction({ aria-label={`Confirm ${actionLabel}`} checked={confirmed} disabled={Boolean(disabledReason) || isPending} - onChange={(event) => setConfirmedBinding(event.currentTarget.checked ? binding : null)} + onChange={(event) => + setConfirmedBinding(event.currentTarget.checked ? binding : null) + } type="checkbox" /> {confirmationCopy} @@ -68,13 +73,13 @@ export function MigrationImpactReviewAction({ type="button" variant={variant} > - {isPending ? pendingLabel : actionLabel} + {buttonLabel} {disabledReason ? ( -

+

{disabledReason}

) : null}
- ) + ); } diff --git a/apps/dashboard/src/features/billing-migrations/components/migration-journey-cockpit.test.tsx b/apps/dashboard/src/features/billing-migrations/components/migration-journey-cockpit.test.tsx index ae89fd0d..9c95d5d2 100644 --- a/apps/dashboard/src/features/billing-migrations/components/migration-journey-cockpit.test.tsx +++ b/apps/dashboard/src/features/billing-migrations/components/migration-journey-cockpit.test.tsx @@ -1,10 +1,12 @@ -import { render, screen } from "@testing-library/react" -import { describe, expect, it } from "vitest" +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; -import { MigrationJourneyCockpit } from "@/features/billing-migrations/components/migration-journey-cockpit" -import type { BillingMigrationProgram } from "@/generated/api" +import { MigrationJourneyCockpit } from "@/features/billing-migrations/components/migration-journey-cockpit"; +import type { BillingMigrationProgram } from "@/generated/api"; -function terminalProgram(state: "failed" | "cancelled"): BillingMigrationProgram { +function terminalProgram( + state: "failed" | "cancelled" +): BillingMigrationProgram { return { authorityEpochBefore: 0, programId: `program_${state}`, @@ -22,7 +24,7 @@ function terminalProgram(state: "failed" | "cancelled"): BillingMigrationProgram stabilizationDays: 7, state, stateVersion: 4, - } + }; } describe("MigrationJourneyCockpit terminal recovery", () => { @@ -31,24 +33,26 @@ describe("MigrationJourneyCockpit terminal recovery", () => { , - ) + /> + ); expect( - screen.getByRole("heading", { name: "Migration stopped after a failure" }), - ).toBeInTheDocument() - expect(screen.getByText(/evidence remain available/)).toBeInTheDocument() - expect(screen.queryByText("Connect source")).not.toBeInTheDocument() - }) + screen.getByRole("heading", { name: "Migration stopped after a failure" }) + ).toBeInTheDocument(); + expect(screen.getByText(/evidence remain available/)).toBeInTheDocument(); + expect(screen.queryByText("Connect source")).not.toBeInTheDocument(); + }); it("shows cancelled recovery without implying the Program can resume", () => { render( , - ) - expect(screen.getByRole("heading", { name: "Migration cancelled" })).toBeInTheDocument() - expect(screen.getByText(/read-only and cannot resume/)).toBeInTheDocument() - expect(screen.queryByText("Connect source")).not.toBeInTheDocument() - }) -}) + /> + ); + expect( + screen.getByRole("heading", { name: "Migration cancelled" }) + ).toBeInTheDocument(); + expect(screen.getByText(/read-only and cannot resume/)).toBeInTheDocument(); + expect(screen.queryByText("Connect source")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/dashboard/src/features/billing-migrations/components/migration-journey-cockpit.tsx b/apps/dashboard/src/features/billing-migrations/components/migration-journey-cockpit.tsx index c0acc53a..1d7ac31c 100644 --- a/apps/dashboard/src/features/billing-migrations/components/migration-journey-cockpit.tsx +++ b/apps/dashboard/src/features/billing-migrations/components/migration-journey-cockpit.tsx @@ -1,4 +1,4 @@ -import type { BillingMigrationProgram } from "@/generated/api" +import type { BillingMigrationProgram } from "@/generated/api"; const steps = [ { key: "connect", label: "Connect source", tab: "overview" }, @@ -7,50 +7,67 @@ const steps = [ { key: "compare", label: "Compare", tab: "compare" }, { key: "readiness", label: "Readiness", tab: "readiness" }, { key: "lifecycle", label: "Cutover & operations", tab: "lifecycle" }, -] as const +] as const; function activeStep(state: BillingMigrationProgram["state"]) { - if (state === "mapping") return 1 - if (state === "importing") return 2 - if (state === "dry_run") return 3 - if (state === "shadowing") return 4 - if (["ready", "cutover_pending", "stabilizing", "completed", "rolled_back"].includes(state)) - return 5 - return 0 + if (state === "mapping") { + return 1; + } + if (state === "importing") { + return 2; + } + if (state === "dry_run") { + return 3; + } + if (state === "shadowing") { + return 4; + } + if ( + [ + "ready", + "cutover_pending", + "stabilizing", + "completed", + "rolled_back", + ].includes(state) + ) { + return 5; + } + return 0; } export function MigrationJourneyCockpit({ baseHref, program, }: { - baseHref: string - program: BillingMigrationProgram + baseHref: string; + program: BillingMigrationProgram; }) { if (program.state === "failed" || program.state === "cancelled") { - const failed = program.state === "failed" + const failed = program.state === "failed"; return (

Migration {failed ? "stopped after a failure" : "cancelled"}

-

+

{failed ? "The Program did not advance. Its source, mappings, and evidence remain available for inspection. Review the evidence and ask an Organization owner to decide whether to create a new Program." : "This Program is read-only and cannot resume. Review its retained evidence, then ask an Organization owner to create a new Program when migration work should restart."}

Review retained evidence
- ) + ); } - const active = activeStep(program.state) + const active = activeStep(program.state); const nextCopy = [ "Confirm the connected source and explicit Application/platform scope.", "Create and freeze an exact mapping set.", @@ -58,15 +75,18 @@ export function MigrationJourneyCockpit({ "Run an isolated dry run, then compare the shadow view.", "Create an evidence-derived readiness assessment.", "Use two-person authority controls, stabilization evidence, rollback, and completion operations.", - ][active] + ][active]; return ( -
+
- ) + ); } diff --git a/apps/dashboard/src/features/billing-migrations/components/migration-lifecycle-operations.tsx b/apps/dashboard/src/features/billing-migrations/components/migration-lifecycle-operations.tsx index df3a4931..3a65c22b 100644 --- a/apps/dashboard/src/features/billing-migrations/components/migration-lifecycle-operations.tsx +++ b/apps/dashboard/src/features/billing-migrations/components/migration-lifecycle-operations.tsx @@ -1,26 +1,26 @@ -import { useMutation, type QueryClient } from "@tanstack/react-query" -import { useState } from "react" +import { type QueryClient, useMutation } from "@tanstack/react-query"; +import { useCallback, useId, useState } from "react"; -import { Input } from "@/components/ui/input" +import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/ui/select" -import { StatusPill } from "@/features/billing-ledger/components/billing-chrome" -import { MigrationImpactReviewAction } from "@/features/billing-migrations/components/migration-impact-review-action" +} from "@/components/ui/select"; +import { StatusPill } from "@/features/billing-ledger/components/billing-chrome"; +import { MigrationImpactReviewAction } from "@/features/billing-migrations/components/migration-impact-review-action"; import { createMigrationCommandKey, migrationLifecycleMutationOptions, -} from "@/features/billing-migrations/mutations/migration-mutations" +} from "@/features/billing-migrations/mutations/migration-mutations"; import { canRunMigrationCommand, - migrationCompletionBlockers, type MigrationCommandCapability, type MigrationProgramView, -} from "@/features/billing-migrations/types/migration-operations" + migrationCompletionBlockers, +} from "@/features/billing-migrations/types/migration-operations"; import type { BillingMigrationApproval, BillingMigrationAuthorityExecution, @@ -39,7 +39,7 @@ import type { BillingMigrationRollbackReadinessAssessment, BillingMigrationStabilizationObservation, BillingMigrationWebhookRedelivery, -} from "@/generated/api" +} from "@/generated/api"; type LifecycleRecord = | BillingMigrationApproval @@ -55,25 +55,25 @@ type LifecycleRecord = | BillingMigrationRepairPreview | BillingMigrationRollbackReadinessAssessment | BillingMigrationStabilizationObservation - | BillingMigrationWebhookRedelivery -type RepairKind = BillingMigrationRepairPreviewRequest["repairKind"] + | BillingMigrationWebhookRedelivery; +type RepairKind = BillingMigrationRepairPreviewRequest["repairKind"]; interface LifecycleData { - approvals: BillingMigrationApproval[] - cases: BillingMigrationCase[] - checkpoints: BillingMigrationCheckpoint[] - completion: BillingMigrationCompletionPrerequisites | undefined - executions: BillingMigrationAuthorityExecution[] - holdProposals: BillingMigrationLegalHoldProposal[] - holds: BillingMigrationLegalHold[] - observations: BillingMigrationStabilizationObservation[] - proposals: BillingMigrationProposal[] - removals: BillingMigrationCredentialRemoval[] - repairExecutions: BillingMigrationRepairExecution[] - repairPreviews: BillingMigrationRepairPreview[] - reports: BillingMigrationCompletionReport[] - rollbackAssessments: BillingMigrationRollbackReadinessAssessment[] - webhookRedeliveries: BillingMigrationWebhookRedelivery[] + approvals: BillingMigrationApproval[]; + cases: BillingMigrationCase[]; + checkpoints: BillingMigrationCheckpoint[]; + completion: BillingMigrationCompletionPrerequisites | undefined; + executions: BillingMigrationAuthorityExecution[]; + holdProposals: BillingMigrationLegalHoldProposal[]; + holds: BillingMigrationLegalHold[]; + observations: BillingMigrationStabilizationObservation[]; + proposals: BillingMigrationProposal[]; + removals: BillingMigrationCredentialRemoval[]; + repairExecutions: BillingMigrationRepairExecution[]; + repairPreviews: BillingMigrationRepairPreview[]; + reports: BillingMigrationCompletionReport[]; + rollbackAssessments: BillingMigrationRollbackReadinessAssessment[]; + webhookRedeliveries: BillingMigrationWebhookRedelivery[]; } type CommandName = | "propose_cutover" @@ -90,7 +90,7 @@ type CommandName = | "remove_credential" | "propose_legal_hold" | "approve_legal_hold" - | "complete" + | "complete"; const commandCapability: Record = { approve_cutover: "approve-cutover", @@ -108,7 +108,7 @@ const commandCapability: Record = { propose_rollback: "execute-rollback", redeliver_webhook: "execute-repair", remove_credential: "remove-credential", -} +}; const digestNames = [ "scope", @@ -119,11 +119,11 @@ const digestNames = [ "readiness", "finalWatermark", "applicationVersion", -] as const +] as const; const emptyDigests = Object.fromEntries( - digestNames.map((name) => [name, ""]), -) as BillingMigrationDigestSet + digestNames.map((name) => [name, ""]) +) as BillingMigrationDigestSet; const impact: Record = { approve_cutover: @@ -132,8 +132,10 @@ const impact: Record = { "Applies an approved retention hold command and changes deletion eligibility.", approve_rollback: "Attests to another human's rollback proposal; execution creates a newer source-authority epoch.", - checkpoint: "Freezes the approved cohort and rollback baseline; stale evidence invalidates it.", - complete: "Closes stabilization only when the server confirms every completion prerequisite.", + checkpoint: + "Freezes the approved cohort and rollback baseline; stale evidence invalidates it.", + complete: + "Closes stabilization only when the server confirms every completion prerequisite.", execute_cutover: "Atomically changes access authority from the source to Mosaic and increments the authority epoch.", execute_repair: @@ -154,7 +156,7 @@ const impact: Record = { "Redelivers one stored event to one stored destination without accepting payloads or secrets.", remove_credential: "Irreversibly destroys migration credential material and may make rollback unhealthy.", -} +}; function repairKind(value: string): RepairKind | undefined { const values: RepairKind[] = [ @@ -163,115 +165,151 @@ function repairKind(value: string): RepairKind | undefined { "attach_proven_alias", "replace_mapping_set", "retry_quarantined_record", - ] - return values.find((candidate) => candidate === value) + ]; + return values.find((candidate) => candidate === value); } function requireRepairKind(value: string): RepairKind { - const parsed = repairKind(value) - if (!parsed) throw new Error("Select a supported repair kind.") - return parsed + const parsed = repairKind(value); + if (!parsed) { + throw new Error("Select a supported repair kind."); + } + return parsed; } function timelineRecord(record: LifecycleRecord) { - if ("approvalId" in record) - return { id: record.approvalId, title: `${record.command} approval`, time: record.approvedAt } - if ("classification" in record) + if ("approvalId" in record) { + return { + id: record.approvalId, + title: `${record.command} approval`, + time: record.approvedAt, + }; + } + if ("classification" in record) { return { id: record.caseId, reason: record.reason, status: record.status, title: `${record.classification} reconciliation case`, time: record.updatedAt, - } - if ("checkpointId" in record) - return { id: record.checkpointId, title: "migration checkpoint", time: record.createdAt } - if ("executionId" in record && "command" in record) + }; + } + if ("checkpointId" in record) { + return { + id: record.checkpointId, + title: "migration checkpoint", + time: record.createdAt, + }; + } + if ("executionId" in record && "command" in record) { return { id: record.executionId, status: record.state, title: `${record.command} authority execution`, time: record.executedAt, - } - if ("holdId" in record) + }; + } + if ("holdId" in record) { return { id: record.holdId, reason: record.reason, title: `${record.command} legal hold`, time: record.commandedAt, - } - if ("observationId" in record && "metrics" in record) + }; + } + if ("observationId" in record && "metrics" in record) { return { id: record.observationId, status: record.healthy ? "healthy" : "breached", title: "stabilization observation", time: record.observedAt, - } - if ("removalId" in record) + }; + } + if ("removalId" in record) { return { id: record.removalId, reason: record.reason, title: "credential removal", time: record.removedAt, - } - if ("previewId" in record && "repairKind" in record) + }; + } + if ("previewId" in record && "repairKind" in record) { return { id: record.previewId, reason: record.reason, title: `${record.repairKind.replaceAll("_", " ")} preview`, time: record.createdAt, - } - if ("executionId" in record) + }; + } + if ("executionId" in record) { return { id: record.executionId, - status: record.executionStatus === "completed" ? record.result : "pending", + status: + record.executionStatus === "completed" ? record.result : "pending", title: "repair execution", - time: record.executionStatus === "completed" ? record.executedAt : record.reservedAt, - } - if ("reportId" in record) - return { id: record.reportId, title: "migration completion", time: record.completedAt } - if ("assessmentId" in record) + time: + record.executionStatus === "completed" + ? record.executedAt + : record.reservedAt, + }; + } + if ("reportId" in record) { + return { + id: record.reportId, + title: "migration completion", + time: record.completedAt, + }; + } + if ("assessmentId" in record) { return { id: record.assessmentId, status: record.ready ? "ready" : "blocked", title: "rollback readiness assessment", time: record.assessedAt, - } - if ("redeliveryId" in record) + }; + } + if ("redeliveryId" in record) { return { id: record.redeliveryId, reason: record.reason, title: "webhook redelivery", time: record.createdAt, - } - if ("externalComplianceReference" in record) + }; + } + if ("externalComplianceReference" in record) { return { id: record.proposalId, reason: record.reason, status: record.status, title: `${record.command} legal hold proposal`, time: record.proposedAt, - } + }; + } return { id: record.proposalId, reason: record.reason, status: record.status, title: `${record.command} proposal`, time: record.proposedAt, - } + }; } function Timeline({ records }: { records: LifecycleRecord[] }) { - if (records.length === 0) return

No records yet.

+ if (records.length === 0) { + return

No records yet.

; + } return (
    {records.map((record) => { - const item = timelineRecord(record) + const item = timelineRecord(record); return ( -
  • +
  • {item.title}

    -

    +

    {item.id} · {new Date(item.time).toLocaleString()}

    {item.reason ?

    {item.reason}

    : null} @@ -279,16 +317,18 @@ function Timeline({ records }: { records: LifecycleRecord[] }) {
  • - ) + ); })}
- ) + ); } export function MigrationLifecycleOperations({ @@ -299,85 +339,105 @@ export function MigrationLifecycleOperations({ programId, queryClient, }: { - data: LifecycleData | undefined - detail: MigrationProgramView - organizationRole?: string - projectId: string - programId: string - queryClient: QueryClient + data: LifecycleData | undefined; + detail: MigrationProgramView; + organizationRole?: string; + projectId: string; + programId: string; + queryClient: QueryClient; }) { - const program = detail.program - const mutation = useMutation(migrationLifecycleMutationOptions(projectId, programId, queryClient)) - const [name, setName] = useState("propose_cutover") - const [reason, setReason] = useState("") - const [referenceId, setReferenceId] = useState("") - const [secondaryId, setSecondaryId] = useState("") - const [expectedDigest, setExpectedDigest] = useState("") - const [authorityDigest, setAuthorityDigest] = useState("") - const [prerequisiteDigest, setPrerequisiteDigest] = useState("") - const [approvalDigest, setApprovalDigest] = useState("") - const [caseDigest, setCaseDigest] = useState("") - const [scopeKind, setScopeKind] = useState("case") - const [digests, setDigests] = useState(emptyDigests) - const [acknowledged, setAcknowledged] = useState(false) - const [expiresAt, setExpiresAt] = useState("") + const fieldIds = useId(); + const { program } = detail; + const mutation = useMutation( + migrationLifecycleMutationOptions(projectId, programId, queryClient) + ); + const [name, setName] = useState("propose_cutover"); + const [reason, setReason] = useState(""); + const [referenceId, setReferenceId] = useState(""); + const [secondaryId, setSecondaryId] = useState(""); + const [expectedDigest, setExpectedDigest] = useState(""); + const [authorityDigest, setAuthorityDigest] = useState(""); + const [prerequisiteDigest, setPrerequisiteDigest] = useState(""); + const [approvalDigest, setApprovalDigest] = useState(""); + const [caseDigest, setCaseDigest] = useState(""); + const [scopeKind, setScopeKind] = useState("case"); + const [digests, setDigests] = + useState(emptyDigests); + const [acknowledged, setAcknowledged] = useState(false); + const [expiresAt, setExpiresAt] = useState(""); const commandOptions = Object.keys(commandCapability).map((item) => ({ label: item.replaceAll("_", " "), value: item, - })) - const capability = commandCapability[name] - const completionBlockers = migrationCompletionBlockers(data?.completion) - const granted = canRunMigrationCommand(detail, capability) - const allDigestsPresent = digestNames.every((digestName) => digests[digestName].trim()) + })); + const capability = commandCapability[name]; + const completionBlockers = migrationCompletionBlockers(data?.completion); + const granted = canRunMigrationCommand(detail, capability); + const allDigestsPresent = digestNames.every((digestName) => + digests[digestName].trim() + ); const boundInputsPresent = (() => { switch (name) { case "propose_cutover": - return allDigestsPresent + return allDigestsPresent; case "approve_cutover": case "approve_rollback": - return Boolean(referenceId) + return Boolean(referenceId); case "checkpoint": - return Boolean(referenceId && expectedDigest && approvalDigest && allDigestsPresent) + return Boolean( + referenceId && expectedDigest && approvalDigest && allDigestsPresent + ); case "execute_cutover": - return Boolean(referenceId && secondaryId && approvalDigest && allDigestsPresent) + return Boolean( + referenceId && secondaryId && approvalDigest && allDigestsPresent + ); case "observe_stabilization": - return Boolean(digests.policy) + return Boolean(digests.policy); case "propose_rollback": - return Boolean(referenceId && expectedDigest && authorityDigest && prerequisiteDigest) + return Boolean( + referenceId && expectedDigest && authorityDigest && prerequisiteDigest + ); case "execute_rollback": return Boolean( referenceId && - secondaryId && - expectedDigest && - authorityDigest && - prerequisiteDigest && - approvalDigest, - ) + secondaryId && + expectedDigest && + authorityDigest && + prerequisiteDigest && + approvalDigest + ); case "preview_repair": return Boolean( referenceId && - secondaryId && - expectedDigest && - caseDigest && - digests.policy && - digests.scope, - ) + secondaryId && + expectedDigest && + caseDigest && + digests.policy && + digests.scope + ); case "execute_repair": return Boolean( - referenceId && expectedDigest && caseDigest && digests.policy && digests.scope, - ) + referenceId && + expectedDigest && + caseDigest && + digests.policy && + digests.scope + ); case "redeliver_webhook": - return Boolean(referenceId && secondaryId && expectedDigest) + return Boolean(referenceId && secondaryId && expectedDigest); case "remove_credential": - return true + return true; case "propose_legal_hold": - return Boolean(referenceId) + return Boolean(referenceId); case "approve_legal_hold": - return Boolean(referenceId && expectedDigest) + return Boolean(referenceId && expectedDigest); case "complete": - return Boolean(authorityDigest && digests.policy && expectedDigest) + return Boolean(authorityDigest && digests.policy && expectedDigest); + default: { + const unhandled: never = name; + throw new Error(`Unhandled name: ${JSON.stringify(unhandled)}`); + } } - })() + })(); const needsReason = ![ "approve_cutover", "approve_rollback", @@ -386,40 +446,57 @@ export function MigrationLifecycleOperations({ "approve_legal_hold", "complete", "observe_stabilization", - ].includes(name) - const disabledReason = !granted - ? `The server has not granted ${capability}. Organization role ${organizationRole ?? "unknown"} is explanatory only.` - : needsReason && reason.trim().length < 8 - ? "Enter a specific operational reason of at least 8 characters." - : !boundInputsPresent - ? "Enter every reference and expected digest required to bind this command to reviewed server state." - : name === "complete" && completionBlockers.length > 0 - ? `Completion is blocked: ${completionBlockers.join(", ")}` - : [ + ].includes(name); + const disabledReason = (() => { + if (granted) { + return (() => { + if (needsReason && reason.trim().length < 8) { + return "Enter a specific operational reason of at least 8 characters."; + } + if (boundInputsPresent) { + return (() => { + if (name === "complete" && completionBlockers.length > 0) { + return `Completion is blocked: ${completionBlockers.join(", ")}`; + } + if ( + [ "propose_cutover", "propose_rollback", "preview_repair", "propose_legal_hold", - ].includes(name) && !expiresAt - ? "Enter the explicit approval or preview expiry time." - : name === "remove_credential" && !acknowledged - ? "Acknowledge that credential removal is irreversible and can prevent rollback." - : null + ].includes(name) && + !expiresAt + ) { + return "Enter the explicit approval or preview expiry time."; + } + if (name === "remove_credential" && !acknowledged) { + return "Acknowledge that credential removal is irreversible and can prevent rollback."; + } + return null; + })(); + } + return "Enter every reference and expected digest required to bind this command to reviewed server state."; + })(); + } + return `The server has not granted ${capability}. Organization role ${organizationRole ?? "unknown"} is explanatory only.`; + })(); - async function submit() { - const expectedStateVersion = program.stateVersion - const expiration = expiresAt ? new Date(expiresAt).toISOString() : "" + const submit = useCallback(async () => { + const expectedStateVersion = program.stateVersion; + const expiration = expiresAt ? new Date(expiresAt).toISOString() : ""; const scope = { applications: program.scope.applications, environmentId: program.scope.environmentId, - } + }; const approvalCommand = { body: { expectedStateVersion }, kind: "approve_proposal" as const, proposalId: referenceId, - } + }; const selectedRepairKind = - name === "preview_repair" ? requireRepairKind(secondaryId) : "provider_revalidate" + name === "preview_repair" + ? requireRepairKind(secondaryId) + : "provider_revalidate"; const command = (() => { switch (name) { case "propose_cutover": @@ -431,10 +508,10 @@ export function MigrationLifecycleOperations({ expiresAt: expiration, reason: reason.trim(), }, - } + }; case "approve_cutover": case "approve_rollback": - return approvalCommand + return approvalCommand; case "checkpoint": return { kind: "create_checkpoint" as const, @@ -445,7 +522,7 @@ export function MigrationLifecycleOperations({ expectedDigests: digests, expectedStateVersion, }, - } + }; case "execute_cutover": return { kind: name, @@ -459,16 +536,17 @@ export function MigrationLifecycleOperations({ reason: reason.trim(), scope, }, - } + }; case "observe_stabilization": return { kind: name, body: { - expectedAuthorityEpoch: program.authorityEpochAfter ?? program.authorityEpochBefore, + expectedAuthorityEpoch: + program.authorityEpochAfter ?? program.authorityEpochBefore, expectedPolicyDigest: digests.policy, expectedStateVersion, }, - } + }; case "propose_rollback": return { kind: name, @@ -481,7 +559,7 @@ export function MigrationLifecycleOperations({ expiresAt: expiration, reason: reason.trim(), }, - } + }; case "execute_rollback": return { kind: name, @@ -490,14 +568,15 @@ export function MigrationLifecycleOperations({ checkpointId: referenceId, expectedApprovalDigest: approvalDigest, expectedAuthorityDigest: authorityDigest, - expectedAuthorityEpoch: program.authorityEpochAfter ?? program.authorityEpochBefore, + expectedAuthorityEpoch: + program.authorityEpochAfter ?? program.authorityEpochBefore, expectedCheckpointDigest: expectedDigest, expectedRollbackPrerequisitesDigest: prerequisiteDigest, expectedStateVersion, reason: reason.trim(), scope, }, - } + }; case "preview_repair": return { kind: name, @@ -516,7 +595,7 @@ export function MigrationLifecycleOperations({ .map((item) => item.trim()) .filter(Boolean), }, - } + }; case "execute_repair": return { kind: name, @@ -528,7 +607,7 @@ export function MigrationLifecycleOperations({ expectedStateVersion, previewId: referenceId, }, - } + }; case "redeliver_webhook": return { kind: name, @@ -539,7 +618,7 @@ export function MigrationLifecycleOperations({ expectedStateVersion, reason: reason.trim(), }, - } + }; case "remove_credential": return { kind: name, @@ -548,23 +627,26 @@ export function MigrationLifecycleOperations({ irreversibleAcknowledged: true as const, reason: reason.trim(), }, - } + }; case "propose_legal_hold": return { kind: name, body: { - command: secondaryId === "release" ? ("release" as const) : ("set" as const), + command: + secondaryId === "release" + ? ("release" as const) + : ("set" as const), expiresAt: expiration, externalComplianceReference: referenceId, reason: reason.trim(), }, - } + }; case "approve_legal_hold": return { kind: name, proposalId: referenceId, body: { expectedProposalDigest: expectedDigest }, - } + }; case "complete": return { kind: name, @@ -574,33 +656,67 @@ export function MigrationLifecycleOperations({ expectedStabilityEvidenceDigest: expectedDigest, expectedStateVersion, }, - } + }; + default: { + const unhandled: never = name; + throw new Error(`Unhandled name: ${JSON.stringify(unhandled)}`); + } } - })() - await mutation.mutateAsync({ command, idempotencyKey: createMigrationCommandKey() }) - setReason("") - setAcknowledged(false) - } + })(); + await mutation.mutateAsync({ + command, + idempotencyKey: createMigrationCommandKey(), + }); + setReason(""); + setAcknowledged(false); + }, [ + approvalDigest, + authorityDigest, + caseDigest, + digests, + expectedDigest, + expiresAt, + mutation, + name, + prerequisiteDigest, + program, + reason, + referenceId, + scopeKind, + secondaryId, + ]); + const handleConfirm = useCallback(() => { + submit(); + }, [submit]); const facts = [ - { label: "Program state", value: `${program.state} · version ${program.stateVersion}` }, + { + label: "Program state", + value: `${program.state} · version ${program.stateVersion}`, + }, { label: "Authority epoch", - value: String(program.authorityEpochAfter ?? program.authorityEpochBefore), + value: String( + program.authorityEpochAfter ?? program.authorityEpochBefore + ), }, { label: "Primary reference", value: referenceId || "Not supplied" }, - { label: "Expected digest", value: expectedDigest || "See bound digest fields" }, + { + label: "Expected digest", + value: expectedDigest || "See bound digest fields", + }, { label: "Authority consequence", value: impact[name] }, - ] + ]; return (

Lifecycle command builder

-

- Commands are compare-and-swap operations. A 409 refreshes Program and operational state - and requires a new review. Production proposal and approval must be performed by distinct - humans; a proposer cannot approve their own command. +

+ Commands are compare-and-swap operations. A 409 refreshes Program and + operational state and requires a new review. Production proposal and + approval must be performed by distinct humans; a proposer cannot + approve their own command.

@@ -622,80 +738,125 @@ export function MigrationLifecycleOperations({
-
-

Proposals, approvals, and checkpoints

-

- Approval records remain separate from proposals so two-person production control is - visible. +

+ Proposals, approvals, and checkpoints +

+

+ Approval records remain separate from proposals so two-person + production control is visible.

Authority executions

-

+

Cutover and rollback always create monotonic authority epochs.

-

Stabilization and rollback readiness

-

- The rollback window is {program.rollbackWindowDays} days. Completion stays blocked until - the window and server-derived health policy are satisfied. +

+ Stabilization and rollback readiness +

+

+ The rollback window is {program.rollbackWindowDays} days. Completion + stays blocked until the window and server-derived health policy are + satisfied.

@@ -772,9 +941,14 @@ export function MigrationLifecycleOperations({
-

Approved repair previews and executions

+

+ Approved repair previews and executions +

@@ -783,7 +957,7 @@ export function MigrationLifecycleOperations({

Credential removal and legal hold

-

+

Only redacted metadata is returned. Secrets are never redisplayed.

-

Completion reports and audit history

+

+ Completion reports and audit history +

{data?.completion ? (
{completionBlockers.length ? ( <>

Completion blocked

-
    +
      {completionBlockers.map((blocker) => (
    • {blocker}
    • ))} @@ -814,7 +990,7 @@ export function MigrationLifecycleOperations({ )}
) : ( -

+

Completion inspection is unavailable or has not produced a report.

)} @@ -822,5 +998,5 @@ export function MigrationLifecycleOperations({
- ) + ); } diff --git a/apps/dashboard/src/features/billing-migrations/components/migration-program-detail-page.tsx b/apps/dashboard/src/features/billing-migrations/components/migration-program-detail-page.tsx index 38405e34..f6338aab 100644 --- a/apps/dashboard/src/features/billing-migrations/components/migration-program-detail-page.tsx +++ b/apps/dashboard/src/features/billing-migrations/components/migration-program-detail-page.tsx @@ -1,57 +1,66 @@ -import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query" -import { useCallback, useMemo, useState } from "react" +import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useId, useMemo, useState } from "react"; -import { Input } from "@/components/ui/input" +import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/ui/select" -import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" -import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" -import { StatusPill } from "@/features/billing-ledger/components/billing-chrome" -import { FreezeMappingAction } from "@/features/billing-migrations/components/freeze-mapping-action" -import { GuidedMappingForm } from "@/features/billing-migrations/components/guided-mapping-form" -import { MigrationImpactReviewAction } from "@/features/billing-migrations/components/migration-impact-review-action" -import { MigrationJourneyCockpit } from "@/features/billing-migrations/components/migration-journey-cockpit" -import { MigrationLifecycleOperations } from "@/features/billing-migrations/components/migration-lifecycle-operations" -import { useMigrationCommand } from "@/features/billing-migrations/hooks/use-migration-command" +} from "@/components/ui/select"; +import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary"; +import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state"; +import { StatusPill } from "@/features/billing-ledger/components/billing-chrome"; +import { FreezeMappingAction } from "@/features/billing-migrations/components/freeze-mapping-action"; +import { GuidedMappingForm } from "@/features/billing-migrations/components/guided-mapping-form"; +import { MigrationImpactReviewAction } from "@/features/billing-migrations/components/migration-impact-review-action"; +import { MigrationJourneyCockpit } from "@/features/billing-migrations/components/migration-journey-cockpit"; +import { MigrationLifecycleOperations } from "@/features/billing-migrations/components/migration-lifecycle-operations"; +import { useMigrationCommand } from "@/features/billing-migrations/hooks/use-migration-command"; import { assessMigrationReadinessMutationOptions, - createMigrationCommandKey, createMigrationBatchMutationOptions, + createMigrationCommandKey, createMigrationMappingMutationOptions, freezeMigrationMappingMutationOptions, queueMigrationRunMutationOptions, -} from "@/features/billing-migrations/mutations/migration-mutations" +} from "@/features/billing-migrations/mutations/migration-mutations"; import { - migrationBatchQueryOptions, migrationBatchesQueryOptions, + migrationBatchQueryOptions, migrationDivergencesQueryOptions, - migrationManifestsQueryOptions, - migrationMappingsQueryOptions, migrationKeys, migrationLifecycleQueryOptions, + migrationManifestsQueryOptions, + migrationMappingsQueryOptions, migrationProgramQueryOptions, migrationReadinessQueryOptions, migrationRunQueryOptions, -} from "@/features/billing-migrations/queries/migration-queries" +} from "@/features/billing-migrations/queries/migration-queries"; import { canRunMigrationCommand, dryRunAuthorityNotice, laterLifecycleNotice, migrationCommandJourney, -} from "@/features/billing-migrations/types/migration-operations" -import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" -import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" -import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" -import { useOrganizationAccess } from "@/hooks/use-organization-access" -import { ApiError } from "@/lib/api/errors" +} from "@/features/billing-migrations/types/migration-operations"; +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery"; +import { + WorkflowPanel, + WorkspacePage, +} from "@/features/orgs/components/workspace-page"; +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope"; +import { useOrganizationAccess } from "@/hooks/use-organization-access"; +import { ApiError } from "@/lib/api/errors"; type DetailTab = - "overview" | "evidence" | "mappings" | "imports" | "compare" | "readiness" | "lifecycle" + | "overview" + | "evidence" + | "mappings" + | "imports" + | "compare" + | "readiness" + | "lifecycle"; const tabs: DetailTab[] = [ "overview", "evidence", @@ -60,29 +69,29 @@ const tabs: DetailTab[] = [ "compare", "readiness", "lifecycle", -] +]; const CLASSIFICATION_OPTIONS = [ { label: "All", value: "all" }, { label: "Critical", value: "critical" }, { label: "Blocking", value: "blocking" }, { label: "Warning", value: "warning" }, { label: "Informational", value: "informational" }, -] +]; interface Props { - batchId?: string - classification: string + batchId?: string; + classification: string; onSearchChange: (next: { - batchId?: string - classification?: string - runJobId?: string - tab?: DetailTab - }) => void - organizationId: string - programId: string - projectId: string - runJobId?: string - tab: DetailTab + batchId?: string; + classification?: string; + runJobId?: string; + tab?: DetailTab; + }) => void; + organizationId: string; + programId: string; + projectId: string; + runJobId?: string; + tab: DetailTab; } export function MigrationProgramDetailPage({ @@ -95,110 +104,201 @@ export function MigrationProgramDetailPage({ runJobId = "", tab, }: Props) { - const queryClient = useQueryClient() - const access = useOrganizationAccess(organizationId) - const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) - const [importCount, setImportCount] = useState(1000) - const [cursorBefore, setCursorBefore] = useState("") + const fieldIds = useId(); + const queryClient = useQueryClient(); + const access = useOrganizationAccess(organizationId); + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope( + organizationId, + projectId + ); + const [importCount, setImportCount] = useState(1000); + const [cursorBefore, setCursorBefore] = useState(""); - const [program, manifests, mappings, batches, divergences, readiness, batch, run, lifecycle] = - useQueries({ - queries: [ - { ...migrationProgramQueryOptions(projectId, programId), enabled: scopeReady }, - { ...migrationManifestsQueryOptions(projectId, programId), enabled: scopeReady }, - { ...migrationMappingsQueryOptions(projectId, programId), enabled: scopeReady }, - { ...migrationBatchesQueryOptions(projectId, programId), enabled: scopeReady }, - { - ...migrationDivergencesQueryOptions(projectId, programId, classification), - enabled: scopeReady, - }, - { ...migrationReadinessQueryOptions(projectId, programId), enabled: scopeReady }, - { - ...migrationBatchQueryOptions(projectId, programId, batchId), - enabled: scopeReady && batchId.length > 0, - }, - { - ...migrationRunQueryOptions(projectId, programId, runJobId), - enabled: scopeReady && runJobId.length > 0, - }, - { ...migrationLifecycleQueryOptions(projectId, programId), enabled: scopeReady }, - ], - }) + const [ + program, + manifests, + mappings, + batches, + divergences, + readiness, + batch, + run, + lifecycle, + ] = useQueries({ + queries: [ + { + ...migrationProgramQueryOptions(projectId, programId), + enabled: scopeReady, + }, + { + ...migrationManifestsQueryOptions(projectId, programId), + enabled: scopeReady, + }, + { + ...migrationMappingsQueryOptions(projectId, programId), + enabled: scopeReady, + }, + { + ...migrationBatchesQueryOptions(projectId, programId), + enabled: scopeReady, + }, + { + ...migrationDivergencesQueryOptions( + projectId, + programId, + classification + ), + enabled: scopeReady, + }, + { + ...migrationReadinessQueryOptions(projectId, programId), + enabled: scopeReady, + }, + { + ...migrationBatchQueryOptions(projectId, programId, batchId), + enabled: scopeReady && batchId.length > 0, + }, + { + ...migrationRunQueryOptions(projectId, programId, runJobId), + enabled: scopeReady && runJobId.length > 0, + }, + { + ...migrationLifecycleQueryOptions(projectId, programId), + enabled: scopeReady, + }, + ], + }); const refetchProgram = useCallback( - () => queryClient.refetchQueries({ queryKey: migrationKeys.program(projectId, programId) }), - [programId, projectId, queryClient], - ) - const command = useMigrationCommand(refetchProgram) + () => + queryClient.refetchQueries({ + queryKey: migrationKeys.program(projectId, programId), + }), + [programId, projectId, queryClient] + ); + const command = useMigrationCommand(refetchProgram); const createMapping = useMutation( - createMigrationMappingMutationOptions(projectId, programId, queryClient), - ) + createMigrationMappingMutationOptions(projectId, programId, queryClient) + ); const freezeMapping = useMutation( - freezeMigrationMappingMutationOptions(projectId, programId, queryClient), - ) + freezeMigrationMappingMutationOptions(projectId, programId, queryClient) + ); const createBatch = useMutation( - createMigrationBatchMutationOptions(projectId, programId, queryClient), - ) + createMigrationBatchMutationOptions(projectId, programId, queryClient) + ); const dryRun = useMutation( - queueMigrationRunMutationOptions(projectId, programId, "dry_run", queryClient), - ) + queueMigrationRunMutationOptions( + projectId, + programId, + "dry_run", + queryClient + ) + ); const shadowRun = useMutation( - queueMigrationRunMutationOptions(projectId, programId, "shadow", queryClient), - ) + queueMigrationRunMutationOptions( + projectId, + programId, + "shadow", + queryClient + ) + ); const assess = useMutation( - assessMigrationReadinessMutationOptions(projectId, programId, queryClient), - ) + assessMigrationReadinessMutationOptions(projectId, programId, queryClient) + ); - const detail = program.data - const current = detail?.program - const latestManifest = manifests.data?.at(0) - const latestMapping = mappings.data?.at(0) + const detail = program.data; + const current = detail?.program; + const latestManifest = manifests.data?.at(0); + const latestMapping = mappings.data?.at(0); const divergenceCounts = useMemo( () => (divergences.data ?? []).reduce( - (counts, item) => ({ ...counts, [item.classification]: counts[item.classification] + 1 }), - { blocking: 0, critical: 0, informational: 0, warning: 0 }, + (counts, item) => { + counts[item.classification] += 1; + return counts; + }, + { blocking: 0, critical: 0, informational: 0, warning: 0 } ), - [divergences.data], - ) - const journey = current && detail ? migrationCommandJourney(current.state, detail) : null + [divergences.data] + ); + const journey = + current && detail ? migrationCommandJourney(current.state, detail) : null; const anyCommandPending = createMapping.isPending || freezeMapping.isPending || createBatch.isPending || dryRun.isPending || shadowRun.isPending || - assess.isPending - const importDisabledReason = !canRunMigrationCommand(detail, "run-import") - ? "The server has not granted the run-import command. Organization role is not command authority." - : !current || current.state !== "importing" - ? "Freeze a reviewed mapping set before importing." - : !latestManifest - ? "A source manifest is required before importing." - : !latestMapping || latestMapping.status !== "frozen" - ? "Choose and freeze a mapping set before importing." - : importCount < 0 || importCount > 1000 - ? "Record count must be between 0 and 1,000." - : null - const dryRunDisabledReason = !canRunMigrationCommand(detail, "run-import") - ? "The server has not granted the run-import command. Organization role is not command authority." - : !current || !journey?.canQueueDryRun - ? "Complete a bounded import before starting the dry run." - : !latestManifest || !latestMapping - ? "A source manifest and mapping set are required for comparison." - : null - const shadowDisabledReason = !canRunMigrationCommand(detail, "run-import") - ? "The server has not granted the run-import command. Organization role is not command authority." - : !current || !journey?.canQueueShadow - ? "Complete the dry run before starting shadow comparison." - : !latestManifest || !latestMapping - ? "A source manifest and mapping set are required for comparison." - : null - const readinessDisabledReason = !canRunMigrationCommand(detail, "assess-readiness") - ? "The server has not granted the assess-readiness command. Organization role is explanatory only." - : !current || !journey?.canAssess - ? "Complete shadow comparison before assessing readiness." - : null + assess.isPending; + const importDisabledReason = (() => { + if (canRunMigrationCommand(detail, "run-import")) { + return (() => { + if (current?.state === "importing") { + return (() => { + if (latestManifest) { + return (() => { + if (latestMapping?.status === "frozen") { + return (() => { + if (importCount < 0 || importCount > 1000) { + return "Record count must be between 0 and 1,000."; + } + return null; + })(); + } + return "Choose and freeze a mapping set before importing."; + })(); + } + return "A source manifest is required before importing."; + })(); + } + return "Freeze a reviewed mapping set before importing."; + })(); + } + return "The server has not granted the run-import command. Organization role is not command authority."; + })(); + const dryRunDisabledReason = (() => { + if (canRunMigrationCommand(detail, "run-import")) { + return (() => { + if (current && journey?.canQueueDryRun) { + return (() => { + if (latestManifest && latestMapping) { + return null; + } + return "A source manifest and mapping set are required for comparison."; + })(); + } + return "Complete a bounded import before starting the dry run."; + })(); + } + return "The server has not granted the run-import command. Organization role is not command authority."; + })(); + const shadowDisabledReason = (() => { + if (canRunMigrationCommand(detail, "run-import")) { + return (() => { + if (current && journey?.canQueueShadow) { + return (() => { + if (latestManifest && latestMapping) { + return null; + } + return "A source manifest and mapping set are required for comparison."; + })(); + } + return "Complete the dry run before starting shadow comparison."; + })(); + } + return "The server has not granted the run-import command. Organization role is not command authority."; + })(); + const readinessDisabledReason = (() => { + if (canRunMigrationCommand(detail, "assess-readiness")) { + return (() => { + if (current && journey?.canAssess) { + return null; + } + return "Complete shadow comparison before assessing readiness."; + })(); + } + return "The server has not granted the assess-readiness command. Organization role is explanatory only."; + })(); const commonError = project.error ?? access.error ?? @@ -207,8 +307,9 @@ export function MigrationProgramDetailPage({ mappings.error ?? batches.error ?? divergences.error ?? - lifecycle.error - const readinessMissing = readiness.error instanceof ApiError && readiness.error.status === 404 + lifecycle.error; + const readinessMissing = + readiness.error instanceof ApiError && readiness.error.status === 404; const state = resolveHostedQueryState({ error: commonError, isEmpty: false, @@ -222,23 +323,28 @@ export function MigrationProgramDetailPage({ batches.isPending || divergences.isPending || lifecycle.isPending)), - loadingDescription: "Loading migration scope and operational evidence in parallel.", + loadingDescription: + "Loading migration scope and operational evidence in parallel.", onRetry: () => { - void program.refetch() - void manifests.refetch() - void mappings.refetch() - void batches.refetch() - void divergences.refetch() - void lifecycle.refetch() + program.refetch(); + manifests.refetch(); + mappings.refetch(); + batches.refetch(); + divergences.refetch(); + lifecycle.refetch(); }, - permissionDescription: "Project membership is required to view this Migration Program.", + permissionDescription: + "Project membership is required to view this Migration Program.", scope: { organizationId, projectId }, - }) + }); - const programBase = `/orgs/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/migrations/${encodeURIComponent(programId)}` - const scopeRows = useMemo(() => current?.scope.applications ?? [], [current?.scope.applications]) + const programBase = `/orgs/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/billing/migrations/${encodeURIComponent(programId)}`; + const scopeRows = useMemo( + () => current?.scope.applications ?? [], + [current?.scope.applications] + ); - if (scopeMismatch) + if (scopeMismatch) { return ( - ) + ); + } return ( - {current ? : null} -
- ) + ); } diff --git a/apps/dashboard/src/features/catalog/components/product-readiness-panel.tsx b/apps/dashboard/src/features/catalog/components/product-readiness-panel.tsx index a724f2e3..b7958abc 100644 --- a/apps/dashboard/src/features/catalog/components/product-readiness-panel.tsx +++ b/apps/dashboard/src/features/catalog/components/product-readiness-panel.tsx @@ -1,13 +1,13 @@ -import { WarningCircleIcon } from "@phosphor-icons/react/dist/ssr/WarningCircle" +import { WarningCircleIcon } from "@phosphor-icons/react/dist/ssr/WarningCircle"; import { - readinessStateLabel, type ProductReadinessView, -} from "@/features/catalog/types/connected-product-view" + readinessStateLabel, +} from "@/features/catalog/types/connected-product-view"; import { providerRecoveryDescriptor, providerRecoveryHref, -} from "@/features/catalog/types/provider-recovery" +} from "@/features/catalog/types/provider-recovery"; export function ProductReadinessPanel({ accessHref, @@ -17,36 +17,48 @@ export function ProductReadinessPanel({ readiness, scopeLabel, }: { - accessHref: string - applicationsHref: string - manageProvidersHref: string - mappingHref?: string - readiness: ProductReadinessView - scopeLabel?: string + accessHref: string; + applicationsHref: string; + manageProvidersHref: string; + mappingHref?: string; + readiness: ProductReadinessView; + scopeLabel?: string; }) { - const blockers = readiness.issues.filter((issue) => issue.severity === "blocker") - const warnings = readiness.issues.filter((issue) => issue.severity === "warning") + const blockers = readiness.issues.filter( + (issue) => issue.severity === "blocker" + ); + const warnings = readiness.issues.filter( + (issue) => issue.severity === "warning" + ); const healthy = - ["configured", "connected", "verifiedInTest"].includes(readiness.state as string) && - blockers.length === 0 + ["configured", "connected", "verifiedInTest"].includes( + readiness.state as string + ) && blockers.length === 0; return ( -
+
-

+

Purchase readiness

-

- Authoritative for one explicit Mosaic Environment, Application, and platform. +

+ Authoritative for one explicit Mosaic Environment, Application, + and platform.

{readinessStateLabel(readiness.state)} @@ -57,19 +69,23 @@ export function ProductReadinessPanel({
Scope
-
+
{scopeLabel ?? `${readiness.environmentId} · ${readiness.applicationId} · ${readiness.platform.toUpperCase()}`}
Evaluated
-
{readiness.evaluatedAt}
+
+ {readiness.evaluatedAt} +
{readiness.issues.length === 0 ? ( -

No scoped provider readiness issues were reported.

+

+ No scoped provider readiness issues were reported. +

) : (
{blockers.length > 0 ? ( @@ -98,7 +114,7 @@ export function ProductReadinessPanel({ )}
- ) + ); } function IssueList({ @@ -110,25 +126,25 @@ function IssueList({ title, tone, }: { - issues: ProductReadinessView["issues"] - accessHref: string - applicationsHref: string - manageProvidersHref: string - mappingHref: string - title: string - tone: "danger" | "neutral" + issues: ProductReadinessView["issues"]; + accessHref: string; + applicationsHref: string; + manageProvidersHref: string; + mappingHref: string; + title: string; + tone: "danger" | "neutral"; }) { return (
-

+

{readinessIssueLabel(issue.code, issue.recoveryAction)}

- ) + ); } function readinessIssueLabel(code: string, action: string) { @@ -168,30 +184,30 @@ function readinessIssueLabel(code: string, action: string) { action === "archiveDuplicateMappings" || action === "grantEntitlement" ) { - return providerRecoveryDescriptor(action).message + return providerRecoveryDescriptor(action).message; } switch (code) { case "mappingMissing": - return "This Product is not mapped to the active purchase provider." + return "This Product is not mapped to the active purchase provider."; case "metadataStale": - return "Connected-provider catalog metadata is stale." + return "Connected-provider catalog metadata is stale."; case "entitlementGrantMissing": - return "This Product does not grant an Access definition." + return "This Product does not grant an Access definition."; case "connectionUnavailable": - return "The active purchase provider is unavailable." + return "The active purchase provider is unavailable."; case "commerce.mapping.basePlanMissing": - return "This Google Play subscription needs an exact base plan." + return "This Google Play subscription needs an exact base plan."; case "commerce.mapping.offerMissing": - return "The selected Google Play offer is missing." + return "The selected Google Play offer is missing."; case "commerce.mapping.offerIneligible": - return "The selected Google Play offer is not eligible for this test context." + return "The selected Google Play offer is not eligible for this test context."; case "commerce.provider.platformMismatch": - return "The active provider is not compatible with this Application platform." + return "The active provider is not compatible with this Application platform."; case "commerce.observation.missing": - return "This mapping is configured but has not been observed by an accepted test client." + return "This mapping is configured but has not been observed by an accepted test client."; case "commerce.observation.stale": - return "The latest test-client observation is stale." + return "The latest test-client observation is stale."; default: - return "Purchase setup needs attention before publishing." + return "Purchase setup needs attention before publishing."; } } diff --git a/apps/dashboard/src/features/catalog/components/products-page.tsx b/apps/dashboard/src/features/catalog/components/products-page.tsx index 5651e13c..62e7a0d0 100644 --- a/apps/dashboard/src/features/catalog/components/products-page.tsx +++ b/apps/dashboard/src/features/catalog/components/products-page.tsx @@ -1,10 +1,10 @@ -import { useState } from "react" -import { useForm } from "@tanstack/react-form" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { Link } from "@tanstack/react-router" +import { useForm } from "@tanstack/react-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { useId, useState } from "react"; -import { Button } from "@/components/ui/button" -import { buttonVariants } from "@/components/ui/button-variants" +import { Button } from "@/components/ui/button"; +import { buttonVariants } from "@/components/ui/button-variants"; import { Dialog, DialogClose, @@ -14,29 +14,36 @@ import { DialogHeader, DialogTitle, DialogTrigger, -} from "@/components/ui/dialog" -import { Field, FieldDescription, FieldLabel } from "@/components/ui/field" -import { Input } from "@/components/ui/input" +} from "@/components/ui/dialog"; +import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/ui/select" -import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" -import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" -import { createProductMutationOptions } from "@/features/catalog/mutations/catalog-mutations" -import { productsQueryOptions, type ProductFilters } from "@/features/catalog/queries/catalog-query" -import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" -import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" -import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" -import { describeReturnDestination } from "@/lib/routing/workspace-hrefs" +} from "@/components/ui/select"; +import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary"; +import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state"; +import { createProductMutationOptions } from "@/features/catalog/mutations/catalog-mutations"; +import { + type ProductFilters, + productsQueryOptions, +} from "@/features/catalog/queries/catalog-query"; +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery"; +import { + WorkflowPanel, + WorkspacePage, +} from "@/features/orgs/components/workspace-page"; +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope"; +import { describeReturnDestination } from "@/lib/routing/workspace-hrefs"; +import { workspaceScopeParams } from "@/lib/routing/workspace-params"; const PRODUCT_TYPE_OPTIONS = [ { label: "Subscription", value: "subscription" }, { label: "One-time non-consumable", value: "one_time_non_consumable" }, -] +]; const STATUS_FILTER_OPTIONS = [ { label: "All statuses", value: "" }, @@ -44,25 +51,25 @@ const STATUS_FILTER_OPTIONS = [ { label: "Connected", value: "connected" }, { label: "Attention required", value: "attention_required" }, { label: "Archived", value: "archived" }, -] +]; const TYPE_FILTER_OPTIONS = [ { label: "All types", value: "" }, { label: "Subscription", value: "subscription" }, { label: "One-time", value: "one_time_non_consumable" }, -] +]; interface ProductsPageProps { - filters: ProductFilters - onFiltersChange: (filters: ProductFilters) => void - organizationId: string - projectId: string + filters: ProductFilters; + onFiltersChange: (filters: ProductFilters) => void; + organizationId: string; + projectId: string; /** * Where a recovery round trip came from. Mosaic Billing sends operators here * from a quarantine record to map a store Product, and the way back has to * survive the trip or the repair loop cannot be walked. */ - returnTo?: string + returnTo?: string; } export function ProductsPage({ @@ -72,12 +79,21 @@ export function ProductsPage({ projectId, returnTo, }: ProductsPageProps) { - const queryClient = useQueryClient() - const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) - const products = useQuery({ ...productsQueryOptions(projectId, filters), enabled: scopeReady }) - const mutation = useMutation(createProductMutationOptions(projectId, queryClient)) - const items = products.data?.items ?? [] - const [createOpen, setCreateOpen] = useState(false) + const fieldIds = useId(); + const queryClient = useQueryClient(); + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope( + organizationId, + projectId + ); + const products = useQuery({ + ...productsQueryOptions(projectId, filters), + enabled: scopeReady, + }); + const mutation = useMutation( + createProductMutationOptions(projectId, queryClient) + ); + const items = products.data?.items ?? []; + const [createOpen, setCreateOpen] = useState(false); const form = useForm({ defaultValues: { description: "", @@ -91,28 +107,32 @@ export function ProductsPage({ internalName: value.internalName.trim(), key: value.key.trim(), type: value.type, - }) - form.reset() - setCreateOpen(false) + }); + form.reset(); + setCreateOpen(false); }, - }) + }); const state = resolveHostedQueryState({ emptyDescription: filters.search || filters.status || filters.type ? "Adjust the project-wide filters or create a Product." : "Create a Monthly, Yearly, or one-time Product with mock metadata.", emptyTitle: - filters.search || filters.status || filters.type ? "No Products match" : "No Products yet", + filters.search || filters.status || filters.type + ? "No Products match" + : "No Products yet", error: project.error ?? products.error, isEmpty: scopeReady && products.isSuccess && items.length === 0, isPending: project.isPending || (scopeReady && products.isPending), loadingDescription: "Loading project Products.", - onRetry: () => void products.refetch(), + onRetry: () => { + products.refetch(); + }, permissionDescription: "Project membership is required to view Products; owner or admin is required to change them.", scope: { organizationId, projectId }, - }) - const canManageProducts = state.kind === "empty" || state.kind === "ready" + }); + const canManageProducts = state.kind === "empty" || state.kind === "ready"; if (scopeMismatch) { return ( @@ -126,34 +146,36 @@ export function ProductsPage({ projectId={projectId} /> - ) + ); } const createDialog = ( { - setCreateOpen(open) + setCreateOpen(open); if (!open) { - form.reset() - mutation.reset() + form.reset(); + mutation.reset(); } }} open={createOpen} > - }>Create Product + }> + Create Product +
{ - event.preventDefault() - event.stopPropagation() - void form.handleSubmit() + event.preventDefault(); + event.stopPropagation(); + form.handleSubmit(); }} > Create Product - Create a provider-neutral Product manually or import synchronized provider metadata - from Purchase setup. + Create a provider-neutral Product manually or import synchronized + provider metadata from Purchase setup.
@@ -176,7 +198,9 @@ export function ProductsPage({ Key field.handleChange(event.target.value.toLowerCase())} + onChange={(event) => + field.handleChange(event.target.value.toLowerCase()) + } placeholder="monthly" value={field.state.value} /> @@ -190,7 +214,9 @@ export function ProductsPage({ field.handleChange(event.target.value)} value={field.state.value} /> - Optional Mosaic-owned metadata. + + Optional Mosaic-owned metadata. + )} @@ -228,7 +258,9 @@ export function ProductsPage({ ) : null}
- }>Cancel + }> + Cancel + @@ -236,7 +268,7 @@ export function ProductsPage({
- ) + ); return ( {returnTo ? ( -
+ {describeReturnDestination(returnTo)} ) : null} @@ -257,31 +292,38 @@ export function ProductsPage({
prev} + params={(prev) => ({ + ...prev, + ...workspaceScopeParams(prev), + })} to="/orgs/$organizationId/projects/$projectId/env/$environmentKey/catalog/providers" > Review Purchase setup

- Provider catalog IDs stay behind mappings; Paywalls continue referencing stable Mosaic - Product IDs. + Provider catalog IDs stay behind mappings; Paywalls continue + referencing stable Mosaic Product IDs.

-
- ) + ); } function syncStateLabel(syncState: ProviderMappingView["syncState"]) { switch (syncState) { case "never_synced": - return "Never synchronized" + return "Never synchronized"; case "current": - return "Current" + return "Current"; case "stale": - return "Stale" + return "Stale"; case "failed": - return "Synchronization failed" + return "Synchronization failed"; + default: { + const unhandled: never = syncState; + throw new Error(`Unhandled syncState: ${JSON.stringify(unhandled)}`); + } } } function mappingStateLabel(mapping: ProviderMappingView) { if (mapping.provider === "app_store" || mapping.provider === "google_play") { - if (!mapping.latestObservation) return "Configured" + if (!mapping.latestObservation) { + return "Configured"; + } if ( mapping.latestObservation.expiresAt && Date.parse(mapping.latestObservation.expiresAt) <= Date.now() ) { - return "Observation stale" + return "Observation stale"; } - return `Observed · ${mapping.latestObservation.result}` + return `Observed · ${mapping.latestObservation.result}`; } - return syncStateLabel(mapping.syncState) + return syncStateLabel(mapping.syncState); } function replacementIsValid( mapping: ProviderMappingView, productType: "one_time_non_consumable" | "subscription" | undefined, replacement: ReplaceProviderMappingRequest, - offerSelection: "none" | "specific", + offerSelection: "none" | "specific" ) { if (mapping.provider !== "app_store" && mapping.provider !== "google_play") { - return Boolean(replacement.providerProductIdentifier.trim()) + return Boolean(replacement.providerProductIdentifier.trim()); + } + if (!productType) { + return false; } - if (!productType) return false if ( mapping.provider === "google_play" && productType === "subscription" && offerSelection === "specific" && !replacement.providerOfferIdentifier?.trim() ) { - return false + return false; } return ( Object.keys( @@ -543,33 +745,37 @@ function replacementIsValid( : {}), } : {}), - }), + }) ).length === 0 - ) + ); } function MappingField({ label, value }: { label: string; value: string }) { return (
{label}
-
{value}
+
{value}
- ) + ); } function observationContext( - context: NonNullable["storeContext"], + context: NonNullable["storeContext"] ) { switch (context) { case "storekitConfiguration": - return "StoreKit Configuration" + return "StoreKit Configuration"; case "appleSandbox": - return "Apple Sandbox" + return "Apple Sandbox"; case "googlePlayTest": - return "Google Play test" + return "Google Play test"; case "production": - return "Production" + return "Production"; case "unknown": - return "Unknown" + return "Unknown"; + default: { + const unhandled: never = context; + throw new Error(`Unhandled context: ${JSON.stringify(unhandled)}`); + } } } diff --git a/apps/dashboard/src/features/catalog/mutations/catalog-impact.test.ts b/apps/dashboard/src/features/catalog/mutations/catalog-impact.test.ts index 5bc1abc4..5b5e519b 100644 --- a/apps/dashboard/src/features/catalog/mutations/catalog-impact.test.ts +++ b/apps/dashboard/src/features/catalog/mutations/catalog-impact.test.ts @@ -1,29 +1,33 @@ -import { QueryClient } from "@tanstack/react-query" -import { describe, expect, it } from "vitest" +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; import { invalidateCatalogImpact, replacementImpactProductIds, -} from "@/features/catalog/mutations/catalog-impact" -import { catalogKeys } from "@/features/catalog/queries/catalog-query" +} from "@/features/catalog/mutations/catalog-impact"; +import { catalogKeys } from "@/features/catalog/queries/catalog-query"; describe("Catalog impact invalidation", () => { it("invalidates project lists plus affected detail, usage, readiness, and relationship caches", async () => { - const queryClient = new QueryClient() + const queryClient = new QueryClient(); const affectedKeys = [ catalogKeys.products("project_one"), catalogKeys.planProducts("plan_one"), catalogKeys.product("product_one"), catalogKeys.productUsage("product_one"), - catalogKeys.productReadiness("product_one", "environment_one", "application_one"), + catalogKeys.productReadiness( + "product_one", + "environment_one", + "application_one" + ), catalogKeys.productEntitlements("product_one"), catalogKeys.providerMappings("product_one"), catalogKeys.entitlement("entitlement_one"), - ] - const unrelatedKey = catalogKeys.product("product_other") + ]; + const unrelatedKey = catalogKeys.product("product_other"); for (const key of [...affectedKeys, unrelatedKey]) { - queryClient.setQueryData(key, { value: key.join(":") }) + queryClient.setQueryData(key, { value: key.join(":") }); } await invalidateCatalogImpact(queryClient, { @@ -31,34 +35,42 @@ describe("Catalog impact invalidation", () => { planIds: ["plan_one"], productIds: ["product_one"], projectId: "project_one", - }) + }); for (const key of affectedKeys) { - expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true); } - expect(queryClient.getQueryState(unrelatedKey)?.isInvalidated).toBe(false) - }) + expect(queryClient.getQueryState(unrelatedKey)?.isInvalidated).toBe(false); + }); it("invalidates the source plus previous and next replacement targets", async () => { - const queryClient = new QueryClient() - const impactedProductIds = replacementImpactProductIds("source", "old_target", "new_target") + const queryClient = new QueryClient(); + const impactedProductIds = replacementImpactProductIds( + "source", + "old_target", + "new_target" + ); for (const productId of [...impactedProductIds, "unrelated"]) { - queryClient.setQueryData(catalogKeys.productUsage(productId), { productId }) + queryClient.setQueryData(catalogKeys.productUsage(productId), { + productId, + }); } await invalidateCatalogImpact(queryClient, { productIds: impactedProductIds, projectId: "project_one", - }) + }); for (const productId of impactedProductIds) { - expect(queryClient.getQueryState(catalogKeys.productUsage(productId))?.isInvalidated).toBe( - true, - ) + expect( + queryClient.getQueryState(catalogKeys.productUsage(productId)) + ?.isInvalidated + ).toBe(true); } - expect(queryClient.getQueryState(catalogKeys.productUsage("unrelated"))?.isInvalidated).toBe( - false, - ) - }) -}) + expect( + queryClient.getQueryState(catalogKeys.productUsage("unrelated")) + ?.isInvalidated + ).toBe(false); + }); +}); diff --git a/apps/dashboard/src/features/catalog/mutations/catalog-impact.ts b/apps/dashboard/src/features/catalog/mutations/catalog-impact.ts index 16488d62..ee79e309 100644 --- a/apps/dashboard/src/features/catalog/mutations/catalog-impact.ts +++ b/apps/dashboard/src/features/catalog/mutations/catalog-impact.ts @@ -1,12 +1,12 @@ -import type { QueryClient, QueryKey } from "@tanstack/react-query" +import type { QueryClient, QueryKey } from "@tanstack/react-query"; -import { catalogKeys } from "@/features/catalog/queries/catalog-query" +import { catalogKeys } from "@/features/catalog/queries/catalog-query"; interface CatalogImpact { - entitlementIds?: readonly string[] - planIds?: readonly string[] - productIds?: readonly string[] - projectId: string + entitlementIds?: readonly string[]; + planIds?: readonly string[]; + productIds?: readonly string[]; + projectId: string; } export function catalogImpactKeys({ @@ -19,22 +19,33 @@ export function catalogImpactKeys({ catalogKeys.all(projectId), ...planIds.map((planId) => catalogKeys.plan(planId)), ...productIds.map((productId) => catalogKeys.product(productId)), - ...entitlementIds.map((entitlementId) => catalogKeys.entitlement(entitlementId)), - ] + ...entitlementIds.map((entitlementId) => + catalogKeys.entitlement(entitlementId) + ), + ]; } -export async function invalidateCatalogImpact(queryClient: QueryClient, impact: CatalogImpact) { +export async function invalidateCatalogImpact( + queryClient: QueryClient, + impact: CatalogImpact +) { await Promise.all( - catalogImpactKeys(impact).map((queryKey) => queryClient.invalidateQueries({ queryKey })), - ) + catalogImpactKeys(impact).map((queryKey) => + queryClient.invalidateQueries({ queryKey }) + ) + ); } export function replacementImpactProductIds( sourceProductId: string, previousReplacementProductId: string | undefined, - nextReplacementProductId: string, + nextReplacementProductId: string ) { return [ - ...new Set([sourceProductId, previousReplacementProductId, nextReplacementProductId]), - ].filter((productId): productId is string => Boolean(productId)) + ...new Set([ + sourceProductId, + previousReplacementProductId, + nextReplacementProductId, + ]), + ].filter((productId): productId is string => Boolean(productId)); } diff --git a/apps/dashboard/src/features/catalog/mutations/catalog-mutations.ts b/apps/dashboard/src/features/catalog/mutations/catalog-mutations.ts index 81fed95b..6ab796b9 100644 --- a/apps/dashboard/src/features/catalog/mutations/catalog-mutations.ts +++ b/apps/dashboard/src/features/catalog/mutations/catalog-mutations.ts @@ -1,29 +1,31 @@ -import { mutationOptions, type QueryClient } from "@tanstack/react-query" - +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { + invalidateCatalogImpact, + replacementImpactProductIds, +} from "@/features/catalog/mutations/catalog-impact"; import { addPlanProduct, - archiveProviderMapping, archiveProduct, + archiveProviderMapping, + type CreateCatalogResourceRequest, + type CreateProductRequest, + type CreateProviderMappingDraftRequest, createEntitlement, createPlan, createProduct, createProviderMappingDraft, + type ReplaceProviderMappingRequest, removePlanProduct, replaceProviderMapping, restoreProduct, setProductReplacement, - type CreateCatalogResourceRequest, - type CreateProductRequest, - type CreateProviderMappingDraftRequest, - type ReplaceProviderMappingRequest, -} from "@/generated/api" -import { - invalidateCatalogImpact, - replacementImpactProductIds, -} from "@/features/catalog/mutations/catalog-impact" -import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" +} from "@/generated/api"; +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client"; -export function createPlanMutationOptions(projectId: string, queryClient: QueryClient) { +export function createPlanMutationOptions( + projectId: string, + queryClient: QueryClient +) { return mutationOptions({ mutationFn: async (body: CreateCatalogResourceRequest) => { const result = await createPlan({ @@ -31,17 +33,17 @@ export function createPlanMutationOptions(projectId: string, queryClient: QueryC client: generatedDashboardClient, path: { projectId }, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, onSuccess: async () => invalidateCatalogImpact(queryClient, { projectId }), - }) + }); } export function archiveProviderMappingMutationOptions( productId: string, projectId: string, - queryClient: QueryClient, + queryClient: QueryClient ) { return mutationOptions({ mutationFn: async (mappingId: string) => { @@ -49,44 +51,50 @@ export function archiveProviderMappingMutationOptions( client: generatedDashboardClient, path: { mappingId }, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, onSuccess: async () => - invalidateCatalogImpact(queryClient, { productIds: [productId], projectId }), - }) + invalidateCatalogImpact(queryClient, { + productIds: [productId], + projectId, + }), + }); } export function replaceProviderMappingMutationOptions( productId: string, projectId: string, - queryClient: QueryClient, + queryClient: QueryClient ) { return mutationOptions({ mutationFn: async ({ body, mappingId, }: { - body: ReplaceProviderMappingRequest - mappingId: string + body: ReplaceProviderMappingRequest; + mappingId: string; }) => { const result = await replaceProviderMapping({ body, client: generatedDashboardClient, path: { mappingId }, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, onSuccess: async () => - invalidateCatalogImpact(queryClient, { productIds: [productId], projectId }), - }) + invalidateCatalogImpact(queryClient, { + productIds: [productId], + projectId, + }), + }); } export function createProviderMappingDraftMutationOptions( productId: string, projectId: string, - queryClient: QueryClient, + queryClient: QueryClient ) { return mutationOptions({ mutationFn: async (body: CreateProviderMappingDraftRequest) => { @@ -95,15 +103,21 @@ export function createProviderMappingDraftMutationOptions( client: generatedDashboardClient, path: { productId }, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, onSuccess: async () => - invalidateCatalogImpact(queryClient, { productIds: [productId], projectId }), - }) + invalidateCatalogImpact(queryClient, { + productIds: [productId], + projectId, + }), + }); } -export function createProductMutationOptions(projectId: string, queryClient: QueryClient) { +export function createProductMutationOptions( + projectId: string, + queryClient: QueryClient +) { return mutationOptions({ mutationFn: async (body: CreateProductRequest) => { const result = await createProduct({ @@ -111,14 +125,17 @@ export function createProductMutationOptions(projectId: string, queryClient: Que client: generatedDashboardClient, path: { projectId }, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, onSuccess: async () => invalidateCatalogImpact(queryClient, { projectId }), - }) + }); } -export function createEntitlementMutationOptions(projectId: string, queryClient: QueryClient) { +export function createEntitlementMutationOptions( + projectId: string, + queryClient: QueryClient +) { return mutationOptions({ mutationFn: async (body: CreateCatalogResourceRequest) => { const result = await createEntitlement({ @@ -126,17 +143,17 @@ export function createEntitlementMutationOptions(projectId: string, queryClient: client: generatedDashboardClient, path: { projectId }, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, onSuccess: async () => invalidateCatalogImpact(queryClient, { projectId }), - }) + }); } export function addPlanProductMutationOptions( planId: string, projectId: string, - queryClient: QueryClient, + queryClient: QueryClient ) { return mutationOptions({ mutationFn: async (productId: string) => { @@ -145,8 +162,8 @@ export function addPlanProductMutationOptions( client: generatedDashboardClient, path: { planId }, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, onSuccess: async (_, productId) => invalidateCatalogImpact(queryClient, { @@ -154,13 +171,13 @@ export function addPlanProductMutationOptions( productIds: [productId], projectId, }), - }) + }); } export function removePlanProductMutationOptions( planId: string, projectId: string, - queryClient: QueryClient, + queryClient: QueryClient ) { return mutationOptions({ mutationFn: async (productId: string) => { @@ -168,8 +185,8 @@ export function removePlanProductMutationOptions( client: generatedDashboardClient, path: { planId, productId }, throwOnError: true, - }) - return productId + }); + return productId; }, onSuccess: async (productId) => invalidateCatalogImpact(queryClient, { @@ -177,7 +194,7 @@ export function removePlanProductMutationOptions( productIds: [productId], projectId, }), - }) + }); } /* @@ -198,30 +215,35 @@ export function removePlanProductMutationOptions( export function productLifecycleMutationOptions( queryClient: QueryClient, - action: "archive" | "restore", + action: "archive" | "restore" ) { return mutationOptions({ mutationFn: async (productId: string) => { - const request = action === "archive" ? archiveProduct : restoreProduct + const request = action === "archive" ? archiveProduct : restoreProduct; const result = await request({ client: generatedDashboardClient, path: { productId }, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, onSuccess: async (product) => invalidateCatalogImpact(queryClient, { productIds: [ product.id, - ...(product.replacementProductId ? [product.replacementProductId] : []), + ...(product.replacementProductId + ? [product.replacementProductId] + : []), ], projectId: product.projectId, }), - }) + }); } -export function setProductReplacementMutationOptions(productId: string, queryClient: QueryClient) { +export function setProductReplacementMutationOptions( + productId: string, + queryClient: QueryClient +) { return mutationOptions({ mutationFn: async ({ replacementProductId }: ReplacementSelection) => { const result = await setProductReplacement({ @@ -229,22 +251,22 @@ export function setProductReplacementMutationOptions(productId: string, queryCli client: generatedDashboardClient, path: { productId }, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, onSuccess: async (product, selection) => invalidateCatalogImpact(queryClient, { productIds: replacementImpactProductIds( productId, selection.previousReplacementProductId, - selection.replacementProductId, + selection.replacementProductId ), projectId: product.projectId, }), - }) + }); } interface ReplacementSelection { - previousReplacementProductId?: string - replacementProductId: string + previousReplacementProductId?: string; + replacementProductId: string; } diff --git a/apps/dashboard/src/features/catalog/queries/catalog-query.ts b/apps/dashboard/src/features/catalog/queries/catalog-query.ts index 771a4020..df26e2fa 100644 --- a/apps/dashboard/src/features/catalog/queries/catalog-query.ts +++ b/apps/dashboard/src/features/catalog/queries/catalog-query.ts @@ -1,4 +1,4 @@ -import { queryOptions } from "@tanstack/react-query" +import { queryOptions } from "@tanstack/react-query"; import { getEntitlement, @@ -17,22 +17,37 @@ import { listProviderMappings, type ProductStatus, type ProductType, -} from "@/generated/api" -import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" +} from "@/generated/api"; +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client"; export const catalogKeys = { all: (projectId: string) => ["catalog", projectId] as const, - entitlement: (entitlementId: string) => ["catalog", "entitlement", entitlementId] as const, - entitlements: (projectId: string) => ["catalog", projectId, "entitlements"] as const, + entitlement: (entitlementId: string) => + ["catalog", "entitlement", entitlementId] as const, + entitlements: (projectId: string) => + ["catalog", projectId, "entitlements"] as const, plan: (planId: string) => ["catalog", "plan", planId] as const, - planProducts: (planId: string) => ["catalog", "plan", planId, "products"] as const, + planProducts: (planId: string) => + ["catalog", "plan", planId, "products"] as const, plans: (projectId: string) => ["catalog", projectId, "plans"] as const, product: (productId: string) => ["catalog", "product", productId] as const, productEntitlements: (productId: string) => ["catalog", "product", productId, "entitlements"] as const, - productReadiness: (productId: string, environmentId: string, applicationId: string) => - ["catalog", "product", productId, "readiness", environmentId, applicationId] as const, - productUsage: (productId: string) => ["catalog", "product", productId, "usage"] as const, + productReadiness: ( + productId: string, + environmentId: string, + applicationId: string + ) => + [ + "catalog", + "product", + productId, + "readiness", + environmentId, + applicationId, + ] as const, + productUsage: (productId: string) => + ["catalog", "product", productId, "usage"] as const, products: (projectId: string, filters: ProductFilters = {}) => ["catalog", projectId, "products", filters] as const, providerMappings: (productId: string) => @@ -43,7 +58,7 @@ export const catalogKeys = { ["catalog", "provider-mapping", mappingId, "observations"] as const, providerUsage: (mappingId: string) => ["catalog", "provider-mapping", mappingId, "usage"] as const, -} +}; export function providerMappingMetadataQueryOptions(mappingId: string) { return queryOptions({ @@ -54,10 +69,10 @@ export function providerMappingMetadataQueryOptions(mappingId: string) { path: { mappingId }, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } export function providerMappingObservationsQueryOptions(mappingId: string) { @@ -69,10 +84,10 @@ export function providerMappingObservationsQueryOptions(mappingId: string) { path: { mappingId }, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } export function providerMappingUsageQueryOptions(mappingId: string) { @@ -84,16 +99,16 @@ export function providerMappingUsageQueryOptions(mappingId: string) { path: { mappingId }, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } export interface ProductFilters { - search?: string - status?: ProductStatus - type?: ProductType + search?: string; + status?: ProductStatus; + type?: ProductType; } export function plansQueryOptions(projectId: string) { @@ -105,10 +120,10 @@ export function plansQueryOptions(projectId: string) { path: { projectId }, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } export function planQueryOptions(planId: string) { @@ -120,10 +135,10 @@ export function planQueryOptions(planId: string) { path: { planId }, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } export function planProductsQueryOptions(planId: string) { @@ -135,13 +150,16 @@ export function planProductsQueryOptions(planId: string) { path: { planId }, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } -export function productsQueryOptions(projectId: string, filters: ProductFilters = {}) { +export function productsQueryOptions( + projectId: string, + filters: ProductFilters = {} +) { return queryOptions({ queryKey: catalogKeys.products(projectId, filters), queryFn: async ({ signal }) => { @@ -151,10 +169,10 @@ export function productsQueryOptions(projectId: string, filters: ProductFilters query: filters, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } export function productQueryOptions(productId: string) { @@ -166,10 +184,10 @@ export function productQueryOptions(productId: string) { path: { productId }, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } export function productUsageQueryOptions(productId: string) { @@ -181,19 +199,23 @@ export function productUsageQueryOptions(productId: string) { path: { productId }, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } export function productReadinessQueryOptions( productId: string, environmentId: string, - applicationId: string, + applicationId: string ) { return queryOptions({ - queryKey: catalogKeys.productReadiness(productId, environmentId, applicationId), + queryKey: catalogKeys.productReadiness( + productId, + environmentId, + applicationId + ), queryFn: async ({ signal }) => { const result = await getProductReadiness({ client: generatedDashboardClient, @@ -201,10 +223,10 @@ export function productReadinessQueryOptions( query: { applicationId, environmentId }, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } export function providerMappingsQueryOptions(productId: string) { @@ -216,10 +238,10 @@ export function providerMappingsQueryOptions(productId: string) { path: { productId }, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } export function entitlementsQueryOptions(projectId: string) { @@ -231,10 +253,10 @@ export function entitlementsQueryOptions(projectId: string) { path: { projectId }, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } export function entitlementQueryOptions(entitlementId: string) { @@ -246,10 +268,10 @@ export function entitlementQueryOptions(entitlementId: string) { path: { entitlementId }, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } export function productEntitlementsQueryOptions(productId: string) { @@ -261,8 +283,8 @@ export function productEntitlementsQueryOptions(productId: string) { path: { productId }, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } diff --git a/apps/dashboard/src/features/catalog/types/connected-product-view.ts b/apps/dashboard/src/features/catalog/types/connected-product-view.ts index b2bc23a8..1ab77a14 100644 --- a/apps/dashboard/src/features/catalog/types/connected-product-view.ts +++ b/apps/dashboard/src/features/catalog/types/connected-product-view.ts @@ -6,107 +6,120 @@ import type { ProviderProductMapping, ProviderProductMetadataSnapshot, ProviderReadiness, -} from "@/generated/api" +} from "@/generated/api"; -export type ProductReadinessState = ProviderReadiness["state"] +export type ProductReadinessState = ProviderReadiness["state"]; export interface ProductReadinessIssueView { - code: string - recoveryAction: string - resourceId: string - resourceType: string - severity: "blocker" | "warning" + code: string; + recoveryAction: string; + resourceId: string; + resourceType: string; + severity: "blocker" | "warning"; } export interface ProductReadinessView { - applicationId: string - connectionId?: string - environmentId: string - evaluatedAt: string - issues: readonly ProductReadinessIssueView[] - platform: ProviderReadiness["platform"] - state: ProductReadinessState + applicationId: string; + connectionId?: string; + environmentId: string; + evaluatedAt: string; + issues: readonly ProductReadinessIssueView[]; + platform: ProviderReadiness["platform"]; + state: ProductReadinessState; } export interface ProviderMappingView { - applicationLabel: string - availability: ProviderProductMapping["availability"] - connectionLabel: string - connectionLastSuccessfulSyncAt?: string - environmentLabel: string - expectedStoreProductId?: string - id: string - lastErrorCode?: ProviderProductMapping["lastErrorCode"] - platformLabel: string - provider: ProviderProductMapping["provider"] - providerLabel: string - providerOfferingIdentifier?: string - providerPackageIdentifier?: string - providerBasePlanIdentifier?: string - providerOfferIdentifier?: string - providerProductIdentifier: string - providerDisplayName?: string - providerProductState?: string - providerProductType?: string - snapshotId?: string - snapshotSource?: string - snapshotExpiresAt?: string - snapshotObservedAt?: string - snapshotStaleAt?: string - snapshotSyncedAt?: string - latestObservation?: ProviderMappingObservation - status: ProviderProductMapping["status"] - syncState: ProviderProductMapping["syncState"] + applicationLabel: string; + availability: ProviderProductMapping["availability"]; + connectionLabel: string; + connectionLastSuccessfulSyncAt?: string; + environmentLabel: string; + expectedStoreProductId?: string; + id: string; + lastErrorCode?: ProviderProductMapping["lastErrorCode"]; + latestObservation?: ProviderMappingObservation; + platformLabel: string; + provider: ProviderProductMapping["provider"]; + providerBasePlanIdentifier?: string; + providerDisplayName?: string; + providerLabel: string; + providerOfferIdentifier?: string; + providerOfferingIdentifier?: string; + providerPackageIdentifier?: string; + providerProductIdentifier: string; + providerProductState?: string; + providerProductType?: string; + snapshotExpiresAt?: string; + snapshotId?: string; + snapshotObservedAt?: string; + snapshotSource?: string; + snapshotStaleAt?: string; + snapshotSyncedAt?: string; + status: ProviderProductMapping["status"]; + syncState: ProviderProductMapping["syncState"]; } function providerLabel(provider: ProviderProductMapping["provider"]) { switch (provider) { case "revenuecat": - return "RevenueCat" + return "RevenueCat"; case "custom": - return "Custom provider" + return "Custom provider"; case "app_store": - return "StoreKit" + return "StoreKit"; case "google_play": - return "Google Play Billing" + return "Google Play Billing"; + default: { + const unhandled: never = provider; + throw new Error(`Unhandled provider: ${JSON.stringify(unhandled)}`); + } } } export function readinessStateLabel(state: ProductReadinessState) { switch (state as string) { case "archived": - return "Archived" + return "Archived"; case "attentionRequired": - return "Attention required" + return "Attention required"; case "configured": - return "Configured" + return "Configured"; case "verifiedInTest": - return "Verified in test" + return "Verified in test"; case "connected": - return "Connected" + return "Connected"; case "draft": - return "Draft" + return "Draft"; case "mockOnly": - return "Mock only" + return "Mock only"; case "unavailable": - return "Unavailable" + return "Unavailable"; + default: + return state; } - return state } -export function productReadinessView(readiness: ProviderReadiness): ProductReadinessView { +export function productReadinessView( + readiness: ProviderReadiness +): ProductReadinessView { return { applicationId: readiness.applicationId, connectionId: readiness.connectionId, environmentId: readiness.environmentId, evaluatedAt: readiness.evaluatedAt, issues: [ - ...readiness.blockers.map((issue) => ({ ...issue, severity: "blocker" as const })), - ...readiness.warnings.map((issue) => ({ ...issue, severity: "warning" as const })), + ...readiness.blockers.map((issue) => ({ + ...issue, + severity: "blocker" as const, + })), + ...readiness.warnings.map((issue) => ({ + ...issue, + severity: "warning" as const, + })), ], platform: readiness.platform, state: readiness.state, - } + }; } export function providerMappingView( @@ -115,21 +128,29 @@ export function providerMappingView( environments: readonly Environment[], connections: readonly ProviderConnection[], snapshot?: ProviderProductMetadataSnapshot, - observations: readonly ProviderMappingObservation[] = [], + observations: readonly ProviderMappingObservation[] = [] ): ProviderMappingView { - const application = applications.find((item) => item.id === mapping.applicationId) - const environment = environments.find((item) => item.id === mapping.environmentId) - const connection = connections.find((item) => item.id === mapping.connectionId) - const metadata = snapshot?.metadata + const application = applications.find( + (item) => item.id === mapping.applicationId + ); + const environment = environments.find( + (item) => item.id === mapping.environmentId + ); + const connection = connections.find( + (item) => item.id === mapping.connectionId + ); + const metadata = snapshot?.metadata; const metadataString = (key: string) => - typeof metadata?.[key] === "string" ? metadata[key] : undefined + typeof metadata?.[key] === "string" ? metadata[key] : undefined; return { applicationLabel: application?.name ?? mapping.applicationId, availability: mapping.availability, - connectionLabel: connection?.name ?? mapping.connectionId ?? "No connection assigned", + connectionLabel: + connection?.name ?? mapping.connectionId ?? "No connection assigned", connectionLastSuccessfulSyncAt: connection?.lastSuccessfulSyncAt, - environmentLabel: environment?.name ?? mapping.environmentId ?? "No Environment assigned", + environmentLabel: + environment?.name ?? mapping.environmentId ?? "No Environment assigned", expectedStoreProductId: mapping.expectedStoreProductId, id: mapping.id, lastErrorCode: mapping.lastErrorCode, @@ -151,9 +172,9 @@ export function providerMappingView( snapshotStaleAt: snapshot?.staleAt, snapshotSyncedAt: snapshot?.syncedAt, latestObservation: [...observations].sort((left, right) => - right.observedAt.localeCompare(left.observedAt), + right.observedAt.localeCompare(left.observedAt) )[0], status: mapping.status, syncState: mapping.syncState, - } + }; } diff --git a/apps/dashboard/src/features/catalog/types/native-provider-mapping.ts b/apps/dashboard/src/features/catalog/types/native-provider-mapping.ts index c7453831..5fcab227 100644 --- a/apps/dashboard/src/features/catalog/types/native-provider-mapping.ts +++ b/apps/dashboard/src/features/catalog/types/native-provider-mapping.ts @@ -1,44 +1,51 @@ -import type { ProductType } from "@/generated/api" +import type { ProductType } from "@/generated/api"; -export type NativeProviderKind = "app_store" | "google_play" +export type NativeProviderKind = "app_store" | "google_play"; export interface NativeProviderMappingInput { - applicationId: string - environmentId: string - googleBasePlanId?: string - googleOfferId?: string - productType: ProductType - provider: NativeProviderKind - providerProductIdentifier: string + applicationId: string; + environmentId: string; + googleBasePlanId?: string; + googleOfferId?: string; + productType: ProductType; + provider: NativeProviderKind; + providerProductIdentifier: string; } -export type GoogleOfferSelection = "none" | "specific" +export type GoogleOfferSelection = "none" | "specific"; export function nativeProviderLabel(provider: NativeProviderKind) { - return provider === "app_store" ? "StoreKit" : "Google Play Billing" + return provider === "app_store" ? "StoreKit" : "Google Play Billing"; } -export function nativeProviderForPlatform(platform: "android" | "ios"): NativeProviderKind { - return platform === "ios" ? "app_store" : "google_play" +export function nativeProviderForPlatform( + platform: "android" | "ios" +): NativeProviderKind { + return platform === "ios" ? "app_store" : "google_play"; } export function nativeProviderMatchesPlatform( provider: NativeProviderKind, - platform: "android" | "ios", + platform: "android" | "ios" ) { - return nativeProviderForPlatform(platform) === provider + return nativeProviderForPlatform(platform) === provider; } -export function validateNativeProviderMapping(input: NativeProviderMappingInput) { +export function validateNativeProviderMapping( + input: NativeProviderMappingInput +) { const errors: Partial< - Record<"googleBasePlanId" | "googleOfferId" | "providerProductIdentifier", string> - > = {} + Record< + "googleBasePlanId" | "googleOfferId" | "providerProductIdentifier", + string + > + > = {}; if (!input.providerProductIdentifier.trim()) { errors.providerProductIdentifier = input.provider === "app_store" ? "Enter the exact StoreKit Product ID." - : "Enter the exact Google Play Product ID." + : "Enter the exact Google Play Product ID."; } if ( @@ -46,17 +53,22 @@ export function validateNativeProviderMapping(input: NativeProviderMappingInput) input.productType === "subscription" && !input.googleBasePlanId?.trim() ) { - errors.googleBasePlanId = "Enter the exact base plan ID for this subscription." + errors.googleBasePlanId = + "Enter the exact base plan ID for this subscription."; } - if (input.provider === "google_play" && input.productType === "one_time_non_consumable") { + if ( + input.provider === "google_play" && + input.productType === "one_time_non_consumable" + ) { if (input.googleBasePlanId?.trim()) { - errors.googleBasePlanId = "One-time Products cannot use a base plan." + errors.googleBasePlanId = "One-time Products cannot use a base plan."; } if (input.googleOfferId?.trim()) { - errors.googleOfferId = "One-time Products cannot use a subscription offer." + errors.googleOfferId = + "One-time Products cannot use a subscription offer."; } } - return errors + return errors; } diff --git a/apps/dashboard/src/features/catalog/types/product-lifecycle.test.ts b/apps/dashboard/src/features/catalog/types/product-lifecycle.test.ts index 185f8db1..0f790527 100644 --- a/apps/dashboard/src/features/catalog/types/product-lifecycle.test.ts +++ b/apps/dashboard/src/features/catalog/types/product-lifecycle.test.ts @@ -1,10 +1,10 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it } from "vitest"; import { canConfirmProductArchive, countProductUsage, replacementCandidates, -} from "@/features/catalog/types/product-lifecycle" +} from "@/features/catalog/types/product-lifecycle"; describe("Product lifecycle safeguards", () => { it("requires a replacement before confirming archive for an in-use Product", () => { @@ -13,12 +13,12 @@ describe("Product lifecycle safeguards", () => { historicalReferences: [], plans: [{ id: "plan_one" }], providerMappings: [], - }) + }); - expect(usageCount).toBe(2) - expect(canConfirmProductArchive(usageCount, false)).toBe(false) - expect(canConfirmProductArchive(usageCount, true)).toBe(true) - }) + expect(usageCount).toBe(2); + expect(canConfirmProductArchive(usageCount, false)).toBe(false); + expect(canConfirmProductArchive(usageCount, true)).toBe(true); + }); it("excludes the current, archived, and incompatible Product types from replacement choices", () => { expect( @@ -26,12 +26,18 @@ describe("Product lifecycle safeguards", () => { [ { id: "current", status: "draft", type: "subscription" }, { id: "archived", status: "archived", type: "subscription" }, - { id: "incompatible", status: "connected", type: "one_time_non_consumable" }, + { + id: "incompatible", + status: "connected", + type: "one_time_non_consumable", + }, { id: "replacement", status: "connected", type: "subscription" }, ], "current", - "subscription", - ), - ).toEqual([{ id: "replacement", status: "connected", type: "subscription" }]) - }) -}) + "subscription" + ) + ).toEqual([ + { id: "replacement", status: "connected", type: "subscription" }, + ]); + }); +}); diff --git a/apps/dashboard/src/features/catalog/types/product-lifecycle.ts b/apps/dashboard/src/features/catalog/types/product-lifecycle.ts index 194ccf8f..a4269a73 100644 --- a/apps/dashboard/src/features/catalog/types/product-lifecycle.ts +++ b/apps/dashboard/src/features/catalog/types/product-lifecycle.ts @@ -1,40 +1,45 @@ interface ProductUsageSummary { - entitlements: readonly unknown[] - historicalReferences: readonly unknown[] - plans: readonly unknown[] - providerMappings: readonly unknown[] + entitlements: readonly unknown[]; + historicalReferences: readonly unknown[]; + plans: readonly unknown[]; + providerMappings: readonly unknown[]; } interface ReplacementCandidate { - id: string - status: string - type?: string + id: string; + status: string; + type?: string; } export function countProductUsage(usage?: ProductUsageSummary) { - if (!usage) return 0 + if (!usage) { + return 0; + } return ( usage.plans.length + usage.entitlements.length + usage.providerMappings.length + usage.historicalReferences.length - ) + ); } -export function canConfirmProductArchive(usageCount: number, replacementSelected: boolean) { - return usageCount === 0 || replacementSelected +export function canConfirmProductArchive( + usageCount: number, + replacementSelected: boolean +) { + return usageCount === 0 || replacementSelected; } export function replacementCandidates( products: readonly T[], currentProductId: string, - currentProductType?: string, + currentProductType?: string ) { return products.filter( (product) => product.id !== currentProductId && product.status !== "archived" && - (currentProductType === undefined || product.type === currentProductType), - ) + (currentProductType === undefined || product.type === currentProductType) + ); } diff --git a/apps/dashboard/src/features/catalog/types/provider-recovery.test.ts b/apps/dashboard/src/features/catalog/types/provider-recovery.test.ts index cebd50f0..3866b4cd 100644 --- a/apps/dashboard/src/features/catalog/types/provider-recovery.test.ts +++ b/apps/dashboard/src/features/catalog/types/provider-recovery.test.ts @@ -1,26 +1,26 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it } from "vitest"; import { PROVIDER_RECOVERY_ACTIONS, providerRecoveryDescriptor, providerRecoveryHref, -} from "@/features/catalog/types/provider-recovery" +} from "@/features/catalog/types/provider-recovery"; describe("provider recovery actions", () => { it.each(PROVIDER_RECOVERY_ACTIONS)( "maps %s to typed copy and one exact Product recovery destination", (action) => { - const descriptor = providerRecoveryDescriptor(action) + const descriptor = providerRecoveryDescriptor(action); const href = providerRecoveryHref(action, { access: "#access-grants-title", applications: "/apps", lifecycle: "#lifecycle-title", mapping: "#provider-mappings-title", providers: "/catalog/providers?environmentId=env_01", - }) + }); - expect(descriptor.label).not.toMatch(/^[a-z]+[A-Z]/) - expect(descriptor.message.length).toBeGreaterThan(20) + expect(descriptor.label).not.toMatch(/^[a-z]+[A-Z]/); + expect(descriptor.message.length).toBeGreaterThan(20); expect(href).toBe( { access: "#access-grants-title", @@ -28,18 +28,20 @@ describe("provider recovery actions", () => { lifecycle: "#lifecycle-title", mapping: "#provider-mappings-title", providers: "/catalog/providers?environmentId=env_01", - }[descriptor.destination], - ) - }, - ) + }[descriptor.destination] + ); + } + ); it("keeps native observation recovery distinct from server synchronization", () => { expect(providerRecoveryDescriptor("runNativeProviderTest").message).toMatch( - /test-client observation/i, - ) - expect(providerRecoveryDescriptor("rerunNativeProviderTest").message).toMatch( - /test-client observation/i, - ) - expect(providerRecoveryDescriptor("runNativeProviderTest").message).not.toMatch(/synchroniz/i) - }) -}) + /test-client observation/i + ); + expect( + providerRecoveryDescriptor("rerunNativeProviderTest").message + ).toMatch(/test-client observation/i); + expect( + providerRecoveryDescriptor("runNativeProviderTest").message + ).not.toMatch(/synchroniz/i); + }); +}); diff --git a/apps/dashboard/src/features/catalog/types/provider-recovery.ts b/apps/dashboard/src/features/catalog/types/provider-recovery.ts index c46731a9..7a4d1219 100644 --- a/apps/dashboard/src/features/catalog/types/provider-recovery.ts +++ b/apps/dashboard/src/features/catalog/types/provider-recovery.ts @@ -28,16 +28,20 @@ export const PROVIDER_RECOVERY_ACTIONS = [ "selectOffer", "resolveMapping", "reviewObservation", -] as const +] as const; -export type ProviderRecoveryAction = (typeof PROVIDER_RECOVERY_ACTIONS)[number] +export type ProviderRecoveryAction = (typeof PROVIDER_RECOVERY_ACTIONS)[number]; export type ProviderRecoveryDestination = - "access" | "applications" | "lifecycle" | "mapping" | "providers" + | "access" + | "applications" + | "lifecycle" + | "mapping" + | "providers"; export interface ProviderRecoveryDescriptor { - destination: ProviderRecoveryDestination - label: string - message: string + destination: ProviderRecoveryDestination; + label: string; + message: string; } const RECOVERY: Record = { @@ -59,7 +63,8 @@ const RECOVERY: Record = { assignProvider: { destination: "providers", label: "Select active provider", - message: "Select the purchase provider for this Environment and Application.", + message: + "Select the purchase provider for this Environment and Application.", }, assignProviderConnection: { destination: "providers", @@ -69,27 +74,32 @@ const RECOVERY: Record = { selectCompatibleProvider: { destination: "providers", label: "Select compatible provider", - message: "Choose the built-in or connected provider for this Application platform.", + message: + "Choose the built-in or connected provider for this Application platform.", }, createApplication: { destination: "applications", label: "Register Application", - message: "Register the iOS or Android Application required by this publishing scope.", + message: + "Register the iOS or Android Application required by this publishing scope.", }, createNativeProviderMapping: { destination: "mapping", label: "Add native store mapping", - message: "Add the exact native store Product identifier for this Application.", + message: + "Add the exact native store Product identifier for this Application.", }, addGoogleBasePlan: { destination: "mapping", label: "Add Google base plan", - message: "Add the exact Google Play base plan and explicitly choose no offer or one offer.", + message: + "Add the exact Google Play base plan and explicitly choose no offer or one offer.", }, runNativeProviderTest: { destination: "mapping", label: "Run native store test", - message: "This mapping is configured but has no accepted test-client observation.", + message: + "This mapping is configured but has no accepted test-client observation.", }, rerunNativeProviderTest: { destination: "mapping", @@ -100,7 +110,8 @@ const RECOVERY: Record = { archiveDuplicateMappings: { destination: "mapping", label: "Review duplicate mappings", - message: "Archive duplicate active mappings so this scope resolves to exactly one mapping.", + message: + "Archive duplicate active mappings so this scope resolves to exactly one mapping.", }, syncProviderMetadata: { destination: "mapping", @@ -115,7 +126,8 @@ const RECOVERY: Record = { updateConnectionScopes: { destination: "providers", label: "Update connection scopes", - message: "Add this Environment and Application to the Provider Connection scope.", + message: + "Add this Environment and Application to the Provider Connection scope.", }, assignProductionConnection: { destination: "providers", @@ -125,17 +137,20 @@ const RECOVERY: Record = { reviewProductionConnectionUse: { destination: "providers", label: "Review production connection", - message: "Review and explicitly acknowledge production connection use in this Environment.", + message: + "Review and explicitly acknowledge production connection use in this Environment.", }, testOrReconnectProvider: { destination: "providers", label: "Test or reconnect provider", - message: "Test the Provider Connection and reconnect it if its credentials are unavailable.", + message: + "Test the Provider Connection and reconnect it if its credentials are unavailable.", }, createOrSyncProviderMapping: { destination: "mapping", label: "Create or refresh mapping", - message: "Create the connected-provider mapping or refresh its catalog metadata.", + message: + "Create the connected-provider mapping or refresh its catalog metadata.", }, reviewProviderProduct: { destination: "mapping", @@ -150,7 +165,8 @@ const RECOVERY: Record = { replaceProviderEntitlementMapping: { destination: "mapping", label: "Replace provider Access mapping", - message: "Replace the incompatible provider Access mapping while preserving history.", + message: + "Replace the incompatible provider Access mapping while preserving history.", }, addEntitlementGrant: { destination: "access", @@ -165,7 +181,8 @@ const RECOVERY: Record = { selectActiveProvider: { destination: "providers", label: "Select active provider", - message: "Select the purchase provider for this Environment and Application.", + message: + "Select the purchase provider for this Environment and Application.", }, selectBasePlan: { destination: "mapping", @@ -187,24 +204,30 @@ const RECOVERY: Record = { label: "Review test evidence", message: "Review the latest scoped test-client observation.", }, -} +}; -export function isProviderRecoveryAction(value: string): value is ProviderRecoveryAction { - return value in RECOVERY +export function isProviderRecoveryAction( + value: string +): value is ProviderRecoveryAction { + return value in RECOVERY; } -export function providerRecoveryDescriptor(action: string): ProviderRecoveryDescriptor { - if (isProviderRecoveryAction(action)) return RECOVERY[action] +export function providerRecoveryDescriptor( + action: string +): ProviderRecoveryDescriptor { + if (isProviderRecoveryAction(action)) { + return RECOVERY[action]; + } return { destination: "providers", label: "Review Purchase setup", message: "Purchase setup needs attention before publishing.", - } + }; } export function providerRecoveryHref( action: string, - destinations: Record, + destinations: Record ) { - return destinations[providerRecoveryDescriptor(action).destination] + return destinations[providerRecoveryDescriptor(action).destination]; } diff --git a/apps/dashboard/src/features/diagnostics/components/diagnostics-page.tsx b/apps/dashboard/src/features/diagnostics/components/diagnostics-page.tsx index 833e47ba..dc3d9262 100644 --- a/apps/dashboard/src/features/diagnostics/components/diagnostics-page.tsx +++ b/apps/dashboard/src/features/diagnostics/components/diagnostics-page.tsx @@ -1,7 +1,7 @@ -import { Link } from "@tanstack/react-router" +import { Link } from "@tanstack/react-router"; -import { buttonVariants } from "@/components/ui/button-variants" -import { DiagnosticsPanel } from "@/features/diagnostics/components/diagnostics-panel" +import { buttonVariants } from "@/components/ui/button-variants"; +import { DiagnosticsPanel } from "@/features/diagnostics/components/diagnostics-panel"; /** * Diagnostics answers "which dashboard is this, what is it configured to talk @@ -13,21 +13,24 @@ export function DiagnosticsPage() { return (
-

+

Mosaic dashboard

-

Diagnostics

-

- Build identity, runtime configuration, and connectivity for this browser session. No - Organization or Project data is read on this page, so it stays available while you are - signed out. +

Diagnostics

+

+ Build identity, runtime configuration, and connectivity for this + browser session. No Organization or Project data is read on this page, + so it stays available while you are signed out.

- ) + ); } diff --git a/apps/dashboard/src/features/diagnostics/components/diagnostics-panel.test.tsx b/apps/dashboard/src/features/diagnostics/components/diagnostics-panel.test.tsx index d2fc5ca5..35a14b0d 100644 --- a/apps/dashboard/src/features/diagnostics/components/diagnostics-panel.test.tsx +++ b/apps/dashboard/src/features/diagnostics/components/diagnostics-panel.test.tsx @@ -1,10 +1,10 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { render, screen, waitFor } from "@testing-library/react" -import { existsSync } from "node:fs" -import { resolve } from "node:path" -import { describe, expect, it, vi } from "vitest" +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; -import { ApiError } from "@/lib/api/errors" +import { ApiError } from "@/lib/api/errors"; /** * Diagnostics used to sit behind the hosted authentication guard, so the one @@ -15,15 +15,17 @@ import { ApiError } from "@/lib/api/errors" * The generated SDK calls are stubbed rather than the network, because the * client captures `globalThis.fetch` when it is constructed. */ -const getSession = vi.hoisted(() => vi.fn()) +const getSession = vi.hoisted(() => vi.fn()); vi.mock("@/generated/api/sdk.gen", async (importOriginal) => ({ ...(await importOriginal()), getSession, -})) +})); -const { DiagnosticsPanel } = await import("@/features/diagnostics/components/diagnostics-panel") -const { Route } = await import("@/routes/diagnostics") +const { DiagnosticsPanel } = await import( + "@/features/diagnostics/components/diagnostics-panel" +); +const { Route } = await import("@/routes/diagnostics"); describe("diagnostics availability", () => { it("reports build identity and configuration while signed out", async () => { @@ -33,33 +35,37 @@ describe("diagnostics availability", () => { correlationId: "request_test", retryable: false, status: 401, - }), - ) + }) + ); render( - , - ) + + ); - await waitFor(() => expect(screen.getByText("Not signed in")).toBeVisible()) - expect(screen.getByText("0.0.0-test")).toBeVisible() - expect(screen.getByText("Dashboard version")).toBeVisible() - expect(screen.getByText("API base URL")).toBeVisible() + await waitFor(() => + expect(screen.getByText("Not signed in")).toBeVisible() + ); + expect(screen.getByText("0.0.0-test")).toBeVisible(); + expect(screen.getByText("Dashboard version")).toBeVisible(); + expect(screen.getByText("API base URL")).toBeVisible(); // A 401 is the expected state here, not a failure to report. - expect(screen.queryByRole("alert")).not.toBeInTheDocument() - }) + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); it("is not mounted under the hosted authentication guard", () => { // File-based routing means the directory decides the guard: the redirect // lives on the `_hosted` layout route, so a diagnostics route file placed // under `routes/_hosted/` would silently become unreachable while signed // out again. - const routes = resolve(process.cwd(), "src/routes") - expect(existsSync(resolve(routes, "diagnostics.tsx"))).toBe(true) - expect(existsSync(resolve(routes, "_hosted/diagnostics.tsx"))).toBe(false) - expect(Route.options.beforeLoad).toBeUndefined() - }) -}) + const routes = resolve(process.cwd(), "src/routes"); + expect(existsSync(resolve(routes, "diagnostics.tsx"))).toBe(true); + expect(existsSync(resolve(routes, "_hosted/diagnostics.tsx"))).toBe(false); + expect(Route.options.beforeLoad).toBeUndefined(); + }); +}); diff --git a/apps/dashboard/src/features/diagnostics/components/diagnostics-panel.tsx b/apps/dashboard/src/features/diagnostics/components/diagnostics-panel.tsx index 0fa7a4f8..6324de9c 100644 --- a/apps/dashboard/src/features/diagnostics/components/diagnostics-panel.tsx +++ b/apps/dashboard/src/features/diagnostics/components/diagnostics-panel.tsx @@ -1,19 +1,18 @@ -import { useQuery } from "@tanstack/react-query" - -import { RequestIdCopy } from "@/features/auth/components/hosted-resource-boundary" -import { Button } from "@/components/ui/button" -import { dashboardBuildInfo, dashboardEnvironment } from "@/config/environment" -import { apiHealthQueryOptions } from "@/features/diagnostics/queries/api-health-query" -import { sessionQueryOptions } from "@/features/auth/queries/session-query" -import { ApiError, describeApiError } from "@/lib/api/errors" +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { dashboardBuildInfo, dashboardEnvironment } from "@/config/environment"; +import { RequestIdCopy } from "@/features/auth/components/hosted-resource-boundary"; +import { sessionQueryOptions } from "@/features/auth/queries/session-query"; +import { apiHealthQueryOptions } from "@/features/diagnostics/queries/api-health-query"; +import { ApiError, describeApiError } from "@/lib/api/errors"; function Row({ label, value }: { label: string; value: string }) { return (
-
{label}
-
{value}
+
{label}
+
{value}
- ) + ); } /** @@ -22,42 +21,50 @@ function Row({ label, value }: { label: string; value: string }) { * so this panel plus the correlation identifier is the whole support path. */ export function DiagnosticsPanel() { - const session = useQuery(sessionQueryOptions()) - const health = useQuery({ ...apiHealthQueryOptions(), enabled: false }) + const session = useQuery(sessionQueryOptions()); + const health = useQuery({ ...apiHealthQueryOptions(), enabled: false }); - const sessionState = - session.isPending && session.fetchStatus !== "idle" - ? "Checking…" - : session.isSuccess - ? `Signed in as ${session.data.email}` - : session.error instanceof ApiError && session.error.status === 401 - ? "Not signed in" - : session.error - ? describeApiError(session.error).description - : "Unknown" + let sessionState = "Unknown"; + if (session.isPending && session.fetchStatus !== "idle") { + sessionState = "Checking…"; + } else if (session.isSuccess) { + sessionState = `Signed in as ${session.data.email}`; + } else if ( + session.error instanceof ApiError && + session.error.status === 401 + ) { + sessionState = "Not signed in"; + } else if (session.error) { + sessionState = describeApiError(session.error).description; + } - const healthState = health.isFetching - ? "Probing…" - : health.isSuccess - ? `Reachable (status ${health.data.status})` - : health.isError - ? describeApiError(health.error).description - : "Not probed yet" + let healthState = "Not probed yet"; + if (health.isFetching) { + healthState = "Probing…"; + } else if (health.isSuccess) { + healthState = `Reachable (status ${health.data.status})`; + } else if (health.isError) { + healthState = describeApiError(health.error).description; + } + // Health is asked first because probing it is the deliberate act; a stale + // session error should not shadow the identifier for the probe just run. + const failed = + health.error instanceof ApiError ? health.error : session.error; const correlationId = - health.error instanceof ApiError - ? health.error.correlationId - : session.error instanceof ApiError - ? session.error.correlationId - : undefined + failed instanceof ApiError ? failed.correlationId : undefined; return ( -
-

+
+

Dashboard diagnostics

-

- Quote these values when reporting a problem. Mosaic does not send browser errors anywhere. +

+ Quote these values when reporting a problem. Mosaic does not send + browser errors anywhere.

@@ -65,7 +72,10 @@ export function DiagnosticsPanel() { - + {/* Identity of the artifact actually serving the API. Only rendered @@ -73,9 +83,18 @@ export function DiagnosticsPanel() { has not observed. */} {health.isSuccess ? ( <> - - - + + + ) : null}
@@ -83,7 +102,9 @@ export function DiagnosticsPanel() {
) : null}
- ) + ); } diff --git a/apps/dashboard/src/features/diagnostics/queries/api-health-query.ts b/apps/dashboard/src/features/diagnostics/queries/api-health-query.ts index a620473d..f95b9e35 100644 --- a/apps/dashboard/src/features/diagnostics/queries/api-health-query.ts +++ b/apps/dashboard/src/features/diagnostics/queries/api-health-query.ts @@ -1,11 +1,11 @@ -import { queryOptions } from "@tanstack/react-query" +import { queryOptions } from "@tanstack/react-query"; -import { getHealth } from "@/generated/api/sdk.gen" -import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" +import { getHealth } from "@/generated/api/sdk.gen"; +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client"; export const diagnosticsKeys = { apiHealth: ["diagnostics", "api-health"] as const, -} +}; /** * The liveness payload reports the serving artifact's identity alongside its @@ -15,14 +15,14 @@ export const diagnosticsKeys = { * rather than breaking the probe. */ export interface ApiLiveness { - built?: string - commit?: string - status: string - version?: string + built?: string; + commit?: string; + status: string; + version?: string; } function optionalText(value: unknown) { - return typeof value === "string" && value.trim() !== "" ? value : undefined + return typeof value === "string" && value.trim() !== "" ? value : undefined; } /** @@ -39,16 +39,16 @@ export function apiHealthQueryOptions() { client: generatedDashboardClient, signal, throwOnError: true, - }) - const payload: Record = result.data.data + }); + const payload: Record = result.data.data; return { built: optionalText(payload.built), commit: optionalText(payload.commit), status: result.data.data.status, version: optionalText(payload.version), - } + }; }, retry: false, staleTime: 0, - }) + }); } diff --git a/apps/dashboard/src/features/entitlement-grants/components/grant-versions-page.tsx b/apps/dashboard/src/features/entitlement-grants/components/grant-versions-page.tsx index 313358b3..c279e4f6 100644 --- a/apps/dashboard/src/features/entitlement-grants/components/grant-versions-page.tsx +++ b/apps/dashboard/src/features/entitlement-grants/components/grant-versions-page.tsx @@ -1,45 +1,54 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { EmptyState } from "@/components/feedback/empty-state" -import { buttonVariants } from "@/components/ui/button-variants" +import { EmptyState } from "@/components/feedback/empty-state"; +import { buttonVariants } from "@/components/ui/button-variants"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/ui/select" -import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" -import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" -import { DefinitionRow, StatusPill } from "@/features/billing-ledger/components/billing-chrome" -import { formatBillingTimestamp } from "@/features/billing-ledger/types/billing-vocabulary" +} from "@/components/ui/select"; +import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary"; +import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state"; +import { + DefinitionRow, + StatusPill, +} from "@/features/billing-ledger/components/billing-chrome"; +import { formatBillingTimestamp } from "@/features/billing-ledger/types/billing-vocabulary"; import { entitlementsQueryOptions, productsQueryOptions, -} from "@/features/catalog/queries/catalog-query" -import { PublishGrantVersionWizard } from "@/features/entitlement-grants/components/publish-grant-version-wizard" +} from "@/features/catalog/queries/catalog-query"; +import { PublishGrantVersionWizard } from "@/features/entitlement-grants/components/publish-grant-version-wizard"; import { previewGrantImpactMutationOptions, publishGrantVersionMutationOptions, -} from "@/features/entitlement-grants/mutations/grant-version-mutations" -import { grantVersionHistoryQueryOptions } from "@/features/entitlement-grants/queries/grant-version-queries" +} from "@/features/entitlement-grants/mutations/grant-version-mutations"; +import { grantVersionHistoryQueryOptions } from "@/features/entitlement-grants/queries/grant-version-queries"; import { grantPolicyFields, grantPolicyLabel, -} from "@/features/entitlement-grants/types/grant-version-view" -import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" -import { WorkflowPanel, WorkspacePage } from "@/features/orgs/components/workspace-page" -import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" -import { useOrganizationAccess } from "@/hooks/use-organization-access" -import { catalogProductsHref } from "@/lib/routing/workspace-hrefs" -import type { ProductEntitlementGrantVersion } from "@/generated/api" +} from "@/features/entitlement-grants/types/grant-version-view"; +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery"; +import { + WorkflowPanel, + WorkspacePage, +} from "@/features/orgs/components/workspace-page"; +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope"; +import type { ProductEntitlementGrantVersion } from "@/generated/api"; +import { useOrganizationAccess } from "@/hooks/use-organization-access"; +import { catalogProductsHref } from "@/lib/routing/workspace-hrefs"; interface GrantVersionsPageProps { - entitlementId?: string - onScopeChange: (scope: { entitlementId?: string; productId?: string }) => void - organizationId: string - productId?: string - projectId: string + entitlementId?: string; + onScopeChange: (scope: { + entitlementId?: string; + productId?: string; + }) => void; + organizationId: string; + productId?: string; + projectId: string; } /** @@ -59,35 +68,53 @@ export function GrantVersionsPage({ productId, projectId, }: GrantVersionsPageProps) { - const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) - const access = useOrganizationAccess(organizationId) - const queryClient = useQueryClient() - const products = useQuery({ ...productsQueryOptions(projectId), enabled: scopeReady }) - const entitlements = useQuery({ ...entitlementsQueryOptions(projectId), enabled: scopeReady }) + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope( + organizationId, + projectId + ); + const access = useOrganizationAccess(organizationId); + const queryClient = useQueryClient(); + const products = useQuery({ + ...productsQueryOptions(projectId), + enabled: scopeReady, + }); + const entitlements = useQuery({ + ...entitlementsQueryOptions(projectId), + enabled: scopeReady, + }); - const productItems = products.data?.items ?? [] - const selectedProductId = productId ?? productItems[0]?.id + const productItems = products.data?.items ?? []; + const selectedProductId = productId ?? productItems[0]?.id; const versions = useQuery({ - ...grantVersionHistoryQueryOptions(projectId, selectedProductId ?? "", entitlementId), + ...grantVersionHistoryQueryOptions( + projectId, + selectedProductId ?? "", + entitlementId + ), enabled: scopeReady && Boolean(selectedProductId), - }) + }); - const preview = useMutation(previewGrantImpactMutationOptions(projectId)) - const publish = useMutation(publishGrantVersionMutationOptions(projectId, queryClient)) + const preview = useMutation(previewGrantImpactMutationOptions(projectId)); + const publish = useMutation( + publishGrantVersionMutationOptions(projectId, queryClient) + ); - const error = project.error ?? products.error ?? entitlements.error ?? versions.error + const error = + project.error ?? products.error ?? entitlements.error ?? versions.error; const state = resolveHostedQueryState({ error, isEmpty: false, - isPending: project.isPending || (scopeReady && (products.isPending || entitlements.isPending)), + isPending: + project.isPending || + (scopeReady && (products.isPending || entitlements.isPending)), loadingDescription: "Loading grant version history for this Project.", onRetry: () => { - void versions.refetch() + versions.refetch(); }, permissionDescription: "Membership of the owning Organization is required to read grant version history.", scope: { organizationId, projectId }, - }) + }); if (scopeMismatch) { return ( @@ -101,22 +128,23 @@ export function GrantVersionsPage({ projectId={projectId} /> - ) + ); } - const productsHref = catalogProductsHref({ organizationId, projectId }) ?? "#" + const productsHref = + catalogProductsHref({ organizationId, projectId }) ?? "#"; const productOptions = productItems.map((product) => ({ label: product.internalName, value: product.id, - })) + })); const entitlementOptions = [ { label: "All Entitlements", value: "" }, ...(entitlements.data?.items ?? []).map((entitlement) => ({ label: entitlement.name, value: entitlement.id, })), - ] - const grouped = groupByEntitlement(versions.data ?? []) + ]; + const grouped = groupByEntitlement(versions.data ?? []); return (

- Published versions are immutable. Intervals are half-open and abut exactly, so every instant - is covered by at most one version per Entitlement — never a gap that would strand a purchase - with no applicable grant, never an overlap that would make the applicable version a function - of row order. + Published versions are immutable. Intervals are half-open and abut + exactly, so every instant is covered by at most one version per + Entitlement — never a gap that would strand a purchase with no + applicable grant, never an overlap that would make the applicable + version a function of row order.

@@ -148,14 +177,16 @@ export function GrantVersionsPage({
update({ entitlementId: value })} @@ -228,24 +260,31 @@ export function PublishGrantVersionWizard({ - One version history belongs to one (Product, Entitlement) pair. + One version history belongs to one (Product, Entitlement) + pair. - Takes effect at (local time) + + Takes effect at (local time) + - update({ effectiveStart: toIsoInstant(event.currentTarget.value) }) + update({ + effectiveStart: toIsoInstant(event.currentTarget.value), + }) } type="datetime-local" value={toLocalInput(proposal.effectiveStart)} /> - The projection engine selects a version by each purchase’s own effective - time, not by now. A change that silently applied to yesterday is the failure grant - versioning exists to prevent, so backdating is a separate, checked choice. + The projection engine selects a version by each + purchase’s own effective time, not by now. A change that + silently applied to yesterday is the failure grant versioning + exists to prevent, so backdating is a separate, checked + choice. @@ -253,49 +292,57 @@ export function PublishGrantVersionWizard({ update({ retroactive: event.currentTarget.checked })} + onChange={(event) => + update({ retroactive: event.currentTarget.checked }) + } type="checkbox" /> Backdate this version - - A retroactive version may add Entitlements or widen access policy, never remove - or narrow either. Taking access from a customer who did nothing wrong is the one - shape Mosaic refuses. + + A retroactive version may add Entitlements or widen access + policy, never remove or narrow either. Taking access from a + customer who did nothing wrong is the one shape Mosaic + refuses.
- + Purchase types this version covers - {(["auto_renewable_subscription", "non_consumable"] as PurchaseType[]).map( - (type) => ( - - ), - )} + {( + [ + "auto_renewable_subscription", + "non_consumable", + ] as PurchaseType[] + ).map((type) => ( + + ))}
- + Subscription states that grant access {grantPolicyFields.map((field) => ( @@ -303,20 +350,24 @@ export function PublishGrantVersionWizard({ update({ [field]: event.currentTarget.checked })} + onChange={(event) => + update({ [field]: event.currentTarget.checked }) + } type="checkbox" /> {grantPolicyLabel(field)} {grantPolicyNote(field) ? ( - + {grantPolicyNote(field)} ) : null} ))} -

{PAUSE_POLICY_NOTE}

+

+ {PAUSE_POLICY_NOTE} +

) : null} @@ -324,7 +375,9 @@ export function PublishGrantVersionWizard({ {step !== "shape" && impact ? (
-

{impactHeadline(impact)}

+

+ {impactHeadline(impact)} +

-

- Every count is from current committed state — the snapshot each customer’s - pointer names, not the whole snapshot history. Previewing writes nothing, not even - an audit event. +

+ Every count is from current committed state — the snapshot + each customer’s pointer names, not the whole snapshot + history. Previewing writes nothing, not even an audit event.

{impact.additiveSuperset === false ? ( -
- +
+

{narrowingCodeExplanation(impact.narrowingCode)}

@@ -363,21 +419,23 @@ export function PublishGrantVersionWizard({ {step === "publish" ? ( - Reason for this change + + Reason for this change + { // The value is read before the updater runs: React nulls // `currentTarget` once the handler returns, so a lazy read // inside the updater throws. - const reason = event.currentTarget.value - setProposal((current) => ({ ...current, reason })) + const reason = event.currentTarget.value; + setProposal((current) => ({ ...current, reason })); }} value={proposal.reason ?? ""} /> - Recorded with the version and the audit event. It is what an investigation reads - months from now. + Recorded with the version and the audit event. It is what an + investigation reads months from now. ) : null} @@ -392,18 +450,18 @@ export function PublishGrantVersionWizard({
{step === "shape" ? ( - ) : null} {step === "review" ? ( <> - - ) : null}
{gate.explanation && step === "publish" ? ( -

{gate.explanation}

+

+ {gate.explanation} +

) : null}

- Publishing enqueues a reprojection for every Billing Customer whose current snapshot - cites this Product, in the same transaction as the version itself. + Publishing enqueues a reprojection for every Billing Customer whose + current snapshot cites this Product, in the same transaction as the + version itself.

- ) + ); } -function StepChip({ active, index, label }: { active: boolean; index: number; label: string }) { +function StepChip({ + active, + index, + label, +}: { + active: boolean; + index: number; + label: string; +}) { return (
  • {index}. {label}
  • - ) + ); } function ImpactRow({ label, value }: { label: string; value: number }) { return (
    {label}
    -
    {value}
    +
    {value}
    - ) + ); } function defaultEffectiveStart() { - return new Date(Date.now() + 5 * 60 * 1000).toISOString() + return new Date(Date.now() + 5 * 60 * 1000).toISOString(); } function toLocalInput(iso: string | undefined) { - if (!iso) return "" - const parsed = new Date(iso) - if (Number.isNaN(parsed.getTime())) return "" - const offset = parsed.getTimezoneOffset() * 60_000 - return new Date(parsed.getTime() - offset).toISOString().slice(0, 16) + if (!iso) { + return ""; + } + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) { + return ""; + } + const offset = parsed.getTimezoneOffset() * 60_000; + return new Date(parsed.getTime() - offset).toISOString().slice(0, 16); } function toIsoInstant(local: string) { - if (!local) return "" - const parsed = new Date(local) - return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString() + if (!local) { + return ""; + } + const parsed = new Date(local); + return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString(); } diff --git a/apps/dashboard/src/features/entitlement-grants/mutations/grant-version-mutations.ts b/apps/dashboard/src/features/entitlement-grants/mutations/grant-version-mutations.ts index 2cf6b21c..184c2900 100644 --- a/apps/dashboard/src/features/entitlement-grants/mutations/grant-version-mutations.ts +++ b/apps/dashboard/src/features/entitlement-grants/mutations/grant-version-mutations.ts @@ -1,12 +1,11 @@ -import { mutationOptions, type QueryClient } from "@tanstack/react-query" - +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { grantVersionKeys } from "@/features/entitlement-grants/queries/grant-version-queries"; import { + type PublishGrantVersionRequest, previewProductEntitlementGrantImpact, publishProductEntitlementGrantVersion, - type PublishGrantVersionRequest, -} from "@/generated/api" -import { grantVersionKeys } from "@/features/entitlement-grants/queries/grant-version-queries" -import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" +} from "@/generated/api"; +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client"; /** * Preview writes nothing — not even an audit event. @@ -24,10 +23,10 @@ export function previewGrantImpactMutationOptions(projectId: string) { client: generatedDashboardClient, path: { projectId }, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } /** @@ -39,7 +38,10 @@ export function previewGrantImpactMutationOptions(projectId: string) { * history query is invalidated on success because the previously open interval * has just been closed. */ -export function publishGrantVersionMutationOptions(projectId: string, queryClient: QueryClient) { +export function publishGrantVersionMutationOptions( + projectId: string, + queryClient: QueryClient +) { return mutationOptions({ mutationFn: async (request: PublishGrantVersionRequest) => { const result = await publishProductEntitlementGrantVersion({ @@ -47,10 +49,12 @@ export function publishGrantVersionMutationOptions(projectId: string, queryClien client: generatedDashboardClient, path: { projectId }, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, onSuccess: async () => - queryClient.invalidateQueries({ queryKey: grantVersionKeys.scope(projectId) }), - }) + queryClient.invalidateQueries({ + queryKey: grantVersionKeys.scope(projectId), + }), + }); } diff --git a/apps/dashboard/src/features/entitlement-grants/queries/grant-version-queries.ts b/apps/dashboard/src/features/entitlement-grants/queries/grant-version-queries.ts index f462e7b2..aee32bcf 100644 --- a/apps/dashboard/src/features/entitlement-grants/queries/grant-version-queries.ts +++ b/apps/dashboard/src/features/entitlement-grants/queries/grant-version-queries.ts @@ -1,7 +1,7 @@ -import { queryOptions } from "@tanstack/react-query" +import { queryOptions } from "@tanstack/react-query"; -import { listProductEntitlementGrantVersions } from "@/generated/api" -import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" +import { listProductEntitlementGrantVersions } from "@/generated/api"; +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client"; /** * Grant version history is Project-scoped and read by any member of the owning @@ -9,15 +9,25 @@ import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" * rule that produced them has been handed a fact with no explanation. */ export const grantVersionKeys = { - history: (projectId: string, productId: string, entitlementId: string | undefined) => - ["entitlement-grants", projectId, "versions", productId, entitlementId ?? "all"] as const, + history: ( + projectId: string, + productId: string, + entitlementId: string | undefined + ) => + [ + "entitlement-grants", + projectId, + "versions", + productId, + entitlementId ?? "all", + ] as const, scope: (projectId: string) => ["entitlement-grants", projectId] as const, -} +}; export function grantVersionHistoryQueryOptions( projectId: string, productId: string, - entitlementId?: string, + entitlementId?: string ) { return queryOptions({ queryKey: grantVersionKeys.history(projectId, productId, entitlementId), @@ -25,11 +35,15 @@ export function grantVersionHistoryQueryOptions( const result = await listProductEntitlementGrantVersions({ client: generatedDashboardClient, path: { projectId }, - query: { limit: 200, productId, ...(entitlementId ? { entitlementId } : {}) }, + query: { + limit: 200, + productId, + ...(entitlementId ? { entitlementId } : {}), + }, signal, throwOnError: true, - }) - return result.data.data?.items ?? [] + }); + return result.data.data?.items ?? []; }, - }) + }); } diff --git a/apps/dashboard/src/features/entitlement-grants/types/grant-version-view.test.ts b/apps/dashboard/src/features/entitlement-grants/types/grant-version-view.test.ts index a9c58eca..d4bbc92c 100644 --- a/apps/dashboard/src/features/entitlement-grants/types/grant-version-view.test.ts +++ b/apps/dashboard/src/features/entitlement-grants/types/grant-version-view.test.ts @@ -1,12 +1,12 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it } from "vitest"; import { evaluatePublishGate, + type GrantProposal, isGrantVersionEditable, narrowingCodeExplanation, proposalFingerprint, - type GrantProposal, -} from "@/features/entitlement-grants/types/grant-version-view" +} from "@/features/entitlement-grants/types/grant-version-view"; const proposal: GrantProposal = { effectiveStart: "2026-08-01T00:00:00.000Z", @@ -17,9 +17,9 @@ const proposal: GrantProposal = { productId: "prod_01", reason: "Grace access was never intended to be off.", supportedPurchaseTypes: ["auto_renewable_subscription"], -} +}; -const previewed = proposalFingerprint(proposal) +const previewed = proposalFingerprint(proposal); /** * These tests protect the confirmation contract of the only Mosaic operation @@ -38,23 +38,23 @@ describe("grant version publish gate", () => { isSubmitting: false, previewedFingerprint: undefined, proposal, - }) - expect(withoutPreview.allowed).toBe(false) - expect(withoutPreview.blockedBy).toBe("preview_stale") - }) + }); + expect(withoutPreview.allowed).toBe(false); + expect(withoutPreview.blockedBy).toBe("preview_stale"); + }); it("invalidates the preview when any published field changes afterwards", () => { - const widened: GrantProposal = { ...proposal, grantsInBillingRetry: true } + const widened: GrantProposal = { ...proposal, grantsInBillingRetry: true }; const gate = evaluatePublishGate({ canManage: true, impact: { additiveSuperset: true, impactedActiveSources: 3 }, isSubmitting: false, previewedFingerprint: previewed, proposal: widened, - }) - expect(gate.allowed).toBe(false) - expect(gate.blockedBy).toBe("preview_stale") - }) + }); + expect(gate.allowed).toBe(false); + expect(gate.blockedBy).toBe("preview_stale"); + }); it("allows publishing a previewed, complete, additive proposal", () => { expect( @@ -64,9 +64,9 @@ describe("grant version publish gate", () => { isSubmitting: false, previewedFingerprint: previewed, proposal, - }).allowed, - ).toBe(true) - }) + }).allowed + ).toBe(true); + }); it("refuses a retroactive narrowing the publish call would reject anyway", () => { const gate = evaluatePublishGate({ @@ -79,10 +79,10 @@ describe("grant version publish gate", () => { isSubmitting: false, previewedFingerprint: previewed, proposal, - }) - expect(gate.allowed).toBe(false) - expect(gate.blockedBy).toBe("narrowing") - }) + }); + expect(gate.allowed).toBe(false); + expect(gate.blockedBy).toBe("narrowing"); + }); it("requires a reason and management permission", () => { expect( @@ -92,8 +92,8 @@ describe("grant version publish gate", () => { isSubmitting: false, previewedFingerprint: previewed, proposal: { ...proposal, reason: " " }, - }).blockedBy, - ).toBe("reason_required") + }).blockedBy + ).toBe("reason_required"); expect( evaluatePublishGate({ @@ -102,17 +102,17 @@ describe("grant version publish gate", () => { isSubmitting: false, previewedFingerprint: previewed, proposal, - }).blockedBy, - ).toBe("no_permission") - }) -}) + }).blockedBy + ).toBe("no_permission"); + }); +}); describe("grant version immutability", () => { it("offers no edit affordance for any published version", () => { // The API answers 409 grant_version_immutable to PATCH, PUT, and DELETE. // A UI that offers editing turns a documented rule into a failed request. - expect(isGrantVersionEditable()).toBe(false) - }) + expect(isGrantVersionEditable()).toBe(false); + }); it("explains every narrowing code the additive-superset rule can produce", () => { for (const code of [ @@ -124,11 +124,13 @@ describe("grant version immutability", () => { "purchase_type_support_narrowed", "trial_access_narrowed", ]) { - expect(narrowingCodeExplanation(code)).toBeTruthy() - expect(narrowingCodeExplanation(code)).not.toContain(code) + expect(narrowingCodeExplanation(code)).toBeTruthy(); + expect(narrowingCodeExplanation(code)).not.toContain(code); } // A code this build has not seen still explains the rule rather than // rendering a bare enum member. - expect(narrowingCodeExplanation("future_narrowing")).toContain("never remove or narrow") - }) -}) + expect(narrowingCodeExplanation("future_narrowing")).toContain( + "never remove or narrow" + ); + }); +}); diff --git a/apps/dashboard/src/features/entitlement-grants/types/grant-version-view.ts b/apps/dashboard/src/features/entitlement-grants/types/grant-version-view.ts index c605271f..6b038793 100644 --- a/apps/dashboard/src/features/entitlement-grants/types/grant-version-view.ts +++ b/apps/dashboard/src/features/entitlement-grants/types/grant-version-view.ts @@ -1,4 +1,7 @@ -import type { GrantVersionImpact, PublishGrantVersionRequest } from "@/generated/api" +import type { + GrantVersionImpact, + PublishGrantVersionRequest, +} from "@/generated/api"; /** * What a Product grants, versioned. @@ -21,11 +24,11 @@ import type { GrantVersionImpact, PublishGrantVersionRequest } from "@/generated * that answers "how many people could lose access". */ -export type GrantProposal = PublishGrantVersionRequest +export type GrantProposal = PublishGrantVersionRequest; /** Every published version is immutable. The function exists to say so once. */ export function isGrantVersionEditable() { - return false + return false; } const NARROWING_CODE_SENTENCES: Record = { @@ -43,19 +46,21 @@ const NARROWING_CODE_SENTENCES: Record = { "The version in force supports a purchase type this proposal drops. Purchases already made under it would be left granting nothing.", trial_access_narrowed: "The version in force grants access during a trial and this proposal does not. A retroactive change may only widen access.", -} +}; function humanize(value: string) { - const spaced = value.replaceAll("_", " ") - return spaced.charAt(0).toUpperCase() + spaced.slice(1) + const spaced = value.replaceAll("_", " "); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); } export function narrowingCodeExplanation(code: string | undefined) { - if (!code) return undefined + if (!code) { + return; + } return ( NARROWING_CODE_SENTENCES[code] ?? `Mosaic reported the narrowing "${humanize(code).toLowerCase()}". A retroactive grant version may add Entitlements or widen access policy, never remove or narrow either.` - ) + ); } const POLICY_LABELS: Record = { @@ -64,30 +69,33 @@ const POLICY_LABELS: Record = { grantsInGrace: "Store grace period", grantsInOneTimeOwnership: "One-time purchase ownership", grantsInTrial: "Trial period", -} +}; interface GrantAccessPolicyFields { - grantsInActive?: boolean - grantsInBillingRetry?: boolean - grantsInGrace?: boolean - grantsInOneTimeOwnership?: boolean - grantsInTrial?: boolean + grantsInActive?: boolean; + grantsInBillingRetry?: boolean; + grantsInGrace?: boolean; + grantsInOneTimeOwnership?: boolean; + grantsInTrial?: boolean; } -export const grantPolicyFields = Object.keys(POLICY_LABELS) as (keyof GrantAccessPolicyFields)[] +export const grantPolicyFields = Object.keys( + POLICY_LABELS +) as (keyof GrantAccessPolicyFields)[]; export function grantPolicyLabel(field: keyof GrantAccessPolicyFields) { - return POLICY_LABELS[field] + return POLICY_LABELS[field]; } const POLICY_NOTES: Partial> = { grantsInBillingRetry: "Contradicts both stores' documentation: neither grants access while a charge is being retried. Only an organization owner may turn this on.", - grantsInGrace: "Both stores grant access during grace, so this is on by default.", -} + grantsInGrace: + "Both stores grant access during grace, so this is on by default.", +}; export function grantPolicyNote(field: keyof GrantAccessPolicyFields) { - return POLICY_NOTES[field] + return POLICY_NOTES[field]; } /** @@ -97,7 +105,7 @@ export function grantPolicyNote(field: keyof GrantAccessPolicyFields) { * stating the rule. */ export const PAUSE_POLICY_NOTE = - "Google's pause never grants access. That is fixed and cannot be overridden by a grant version." + "Google's pause never grants access. That is fixed and cannot be overridden by a grant version."; /** * A stable identity for a proposal. @@ -119,7 +127,7 @@ export function proposalFingerprint(proposal: GrantProposal) { proposal.grantsInGrace === true, proposal.grantsInBillingRetry === true, proposal.grantsInOneTimeOwnership === true, - ]) + ]); } export type PublishBlockedReason = @@ -128,25 +136,27 @@ export type PublishBlockedReason = | "narrowing" | "no_permission" | "preview_stale" - | "reason_required" + | "reason_required"; export interface PublishGate { - allowed: boolean - blockedBy?: PublishBlockedReason - explanation?: string + allowed: boolean; + blockedBy?: PublishBlockedReason; + explanation?: string; } const BLOCKED_EXPLANATIONS: Record = { already_publishing: "Mosaic is publishing this version.", - incomplete: "Choose a Product, an Entitlement, and the instant the new version takes effect.", + incomplete: + "Choose a Product, an Entitlement, and the instant the new version takes effect.", narrowing: "This retroactive proposal would take access away. The publish call would refuse it, so it is refused here too.", - no_permission: "Publishing a grant version requires organization owner or admin permission.", + no_permission: + "Publishing a grant version requires organization owner or admin permission.", preview_stale: "Preview the impact of this exact proposal first. The confirmation has to state how many customers could lose access, and that number changes with every field.", reason_required: "Give the reason for this change. It is what an investigation reads months from now.", -} +}; /** * The one place that decides whether "Publish" is enabled. @@ -158,36 +168,47 @@ const BLOCKED_EXPLANATIONS: Record = { * make the confirmation describe something adjacent to what is published. */ export function evaluatePublishGate(input: { - canManage: boolean - impact: GrantVersionImpact | undefined - isSubmitting: boolean - previewedFingerprint: string | undefined - proposal: GrantProposal + canManage: boolean; + impact: GrantVersionImpact | undefined; + isSubmitting: boolean; + previewedFingerprint: string | undefined; + proposal: GrantProposal; }): PublishGate { const gate = (blockedBy: PublishBlockedReason): PublishGate => ({ allowed: false, blockedBy, explanation: BLOCKED_EXPLANATIONS[blockedBy], - }) + }); - if (!input.canManage) return gate("no_permission") - if (input.isSubmitting) return gate("already_publishing") + if (!input.canManage) { + return gate("no_permission"); + } + if (input.isSubmitting) { + return gate("already_publishing"); + } if ( - !input.proposal.productId || - !input.proposal.entitlementId || - !input.proposal.effectiveStart + !( + input.proposal.productId && + input.proposal.entitlementId && + input.proposal.effectiveStart + ) ) { - return gate("incomplete") + return gate("incomplete"); } if (!input.proposal.reason || input.proposal.reason.trim().length === 0) { - return gate("reason_required") + return gate("reason_required"); } - if (!input.impact || input.previewedFingerprint !== proposalFingerprint(input.proposal)) { - return gate("preview_stale") + if ( + !input.impact || + input.previewedFingerprint !== proposalFingerprint(input.proposal) + ) { + return gate("preview_stale"); + } + if (input.impact.additiveSuperset === false) { + return gate("narrowing"); } - if (input.impact.additiveSuperset === false) return gate("narrowing") - return { allowed: true } + return { allowed: true }; } /** @@ -198,19 +219,23 @@ export function evaluatePublishGate(input: { * decision. */ export function impactHeadline(impact: GrantVersionImpact | undefined) { - if (!impact) return "Nothing has been previewed yet." - const active = impact.impactedActiveSources ?? 0 + if (!impact) { + return "Nothing has been previewed yet."; + } + const active = impact.impactedActiveSources ?? 0; if (active === 0) { - return "No purchase currently granting access cites this Product, so no customer can lose access from this change." + return "No purchase currently granting access cites this Product, so no customer can lose access from this change."; } - return `${active} purchase${active === 1 ? "" : "s"} currently granting access cite this Product. That is how many customers could lose access if this change narrows what it grants.` + return `${active} purchase${active === 1 ? "" : "s"} currently granting access cite this Product. That is how many customers could lose access if this change narrows what it grants.`; } /** Half-open `[start, end)`. The absent end is the version in force now. */ export function grantIntervalLabel( effectiveStart: string | undefined, - effectiveEnd: string | undefined, + effectiveEnd: string | undefined ) { - const start = effectiveStart ?? "—" - return effectiveEnd ? `${start} → ${effectiveEnd}` : `${start} → in force now` + const start = effectiveStart ?? "—"; + return effectiveEnd + ? `${start} → ${effectiveEnd}` + : `${start} → in force now`; } diff --git a/apps/dashboard/src/features/environments/components/environment-switcher.test.tsx b/apps/dashboard/src/features/environments/components/environment-switcher.test.tsx index 520b8882..44c3db18 100644 --- a/apps/dashboard/src/features/environments/components/environment-switcher.test.tsx +++ b/apps/dashboard/src/features/environments/components/environment-switcher.test.tsx @@ -1,49 +1,75 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { - RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter, -} from "@tanstack/react-router" -import { fireEvent, render, screen } from "@testing-library/react" -import { afterEach, beforeAll, describe, expect, it } from "vitest" - -import { resetActiveEnvironmentStore } from "@/features/environments/types/active-environment-store" - -import { SidebarProvider } from "@/components/ui/sidebar" -import type { Environment } from "@/generated/api" -import { EnvironmentSwitcher } from "@/features/environments/components/environment-switcher" -import { environmentKeys } from "@/features/environments/queries/environments-query" -import { ApiError } from "@/lib/api/errors" + RouterProvider, +} from "@tanstack/react-router"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { SidebarProvider } from "@/components/ui/sidebar"; +import { EnvironmentSwitcher } from "@/features/environments/components/environment-switcher"; +import { environmentKeys } from "@/features/environments/queries/environments-query"; +import { resetActiveEnvironmentStore } from "@/features/environments/types/active-environment-store"; +import type { Environment } from "@/generated/api"; +import { ApiError } from "@/lib/api/errors"; // The sidebar reads a media query jsdom does not implement. beforeAll(() => { - if (typeof window.matchMedia === "function") return + if (typeof window.matchMedia === "function") { + return; + } window.matchMedia = (query: string) => ({ - addEventListener: () => {}, - addListener: () => {}, + addEventListener: () => { + /* stub for a browser API jsdom does not implement */ + }, + addListener: () => { + /* stub for a browser API jsdom does not implement */ + }, dispatchEvent: () => false, matches: false, media: query, onchange: null, - removeEventListener: () => {}, - removeListener: () => {}, - }) as MediaQueryList -}) - -const timestamps = { createdAt: "2026-07-30T00:00:00Z", updatedAt: "2026-07-30T00:00:00Z" } - -function environment(id: string, name: string, mode: Environment["mode"]): Environment { - return { ...timestamps, id, key: name.toLowerCase(), mode, name, projectId: "prj_01" } + removeEventListener: () => { + /* stub for a browser API jsdom does not implement */ + }, + removeListener: () => { + /* stub for a browser API jsdom does not implement */ + }, + }) as MediaQueryList; +}); + +const timestamps = { + createdAt: "2026-07-30T00:00:00Z", + updatedAt: "2026-07-30T00:00:00Z", +}; + +function environment( + id: string, + name: string, + mode: Environment["mode"] +): Environment { + return { + ...timestamps, + id, + key: name.toLowerCase(), + mode, + name, + projectId: "prj_01", + }; } -const development = environment("env_dev", "Development", "development") -const production = environment("env_prod", "Production", "production") +const development = environment("env_dev", "Development", "development"); +const production = environment("env_prod", "Production", "production"); const PATHS = [ "/orgs/$organizationId", + // Addresses that name a Project but no Environment. The switcher defends this + // case rather than assuming every Project surface spells the Environment out. + "/orgs/$organizationId/projects/$projectId", + "/orgs/$organizationId/projects/$projectId/billing/connections/$credentialId", "/orgs/$organizationId/projects/$projectId/env/$environmentKey", "/orgs/$organizationId/projects/$projectId/env/$environmentKey/apps", "/orgs/$organizationId/projects/$projectId/env/$environmentKey/catalog/products", @@ -51,56 +77,61 @@ const PATHS = [ "/orgs/$organizationId/projects/$projectId/env/$environmentKey/analytics/$surface", "/orgs/$organizationId/projects/$projectId/env/$environmentKey/billing/health", "/orgs/$organizationId/projects/$projectId/env/$environmentKey/billing/connections/$credentialId", -] as const +] as const; function renderSwitcher(queryClient: QueryClient, pathname: string) { - const rootRoute = createRootRoute() + const rootRoute = createRootRoute(); const routeTree = rootRoute.addChildren( PATHS.map((path) => createRoute({ component: EnvironmentSwitcher, getParentRoute: () => rootRoute, path, - }), - ), - ) + }) + ) + ); const router = createRouter({ history: createMemoryHistory({ initialEntries: [pathname] }), routeTree, - }) + }); return render( - , - ) + + ); } function seededClient(seed: (client: QueryClient) => void) { // staleTime keeps a remount from refetching seeded data against a server that // is not there, which would land the switcher in its error branch. const client = new QueryClient({ - defaultOptions: { queries: { retry: false, staleTime: Infinity } }, - }) - seed(client) - return client + defaultOptions: { + queries: { retry: false, staleTime: Number.POSITIVE_INFINITY }, + }, + }); + seed(client); + return client; } function withEnvironments(items: Environment[]) { return (client: QueryClient) => { - client.setQueryData(environmentKeys.list("prj_01"), { items, page: { nextCursor: "" } }) - } + client.setQueryData(environmentKeys.list("prj_01"), { + items, + page: { nextCursor: "" }, + }); + }; } afterEach(() => { // The remembered Environment outlives a render by design; leaking it between // cases would let one test decide another's default. - resetActiveEnvironmentStore() - window.localStorage.clear() -}) + resetActiveEnvironmentStore(); + window.localStorage.clear(); +}); /** * One switcher replaces the per-page selects, so it has to hold the invariant they @@ -110,132 +141,179 @@ afterEach(() => { */ describe("EnvironmentSwitcher", () => { it("switches Environment without leaving the surface", async () => { - const client = seededClient(withEnvironments([development, production])) + const client = seededClient(withEnvironments([development, production])); - renderSwitcher(client, "/orgs/org_01/projects/prj_01/monetization/env_dev/paywalls") + renderSwitcher( + client, + "/orgs/org_01/projects/prj_01/env/dev/monetization/paywalls" + ); fireEvent.click( - await screen.findByRole("button", { name: /Current environment: Development, development/ }), - ) + await screen.findByRole("button", { + name: /Current environment: Development, development/, + }) + ); - expect(await screen.findByRole("menuitem", { name: /Production/ })).toHaveAttribute( + expect( + await screen.findByRole("menuitem", { name: /Production/ }) + ).toHaveAttribute( "href", - "/orgs/org_01/projects/prj_01/monetization/env_prod/paywalls", - ) - }) + "/orgs/org_01/projects/prj_01/env/prod/monetization/paywalls" + ); + }); it("preserves the deeper surface when switching", async () => { - const client = seededClient(withEnvironments([development, production])) + const client = seededClient(withEnvironments([development, production])); - renderSwitcher(client, "/orgs/org_01/projects/prj_01/analytics/env_dev/funnel") - fireEvent.click(await screen.findByRole("button", { name: /Current environment/ })) + renderSwitcher( + client, + "/orgs/org_01/projects/prj_01/env/dev/analytics/funnel" + ); + fireEvent.click( + await screen.findByRole("button", { name: /Current environment/ }) + ); - expect(await screen.findByRole("menuitem", { name: /Production/ })).toHaveAttribute( + expect( + await screen.findByRole("menuitem", { name: /Production/ }) + ).toHaveAttribute( "href", - "/orgs/org_01/projects/prj_01/analytics/env_prod/funnel", - ) - }) + "/orgs/org_01/projects/prj_01/env/prod/analytics/funnel" + ); + }); it("marks the Environment already in scope", async () => { - const client = seededClient(withEnvironments([development, production])) + const client = seededClient(withEnvironments([development, production])); - renderSwitcher(client, "/orgs/org_01/projects/prj_01/billing/env_prod/health") - fireEvent.click(await screen.findByRole("button", { name: /Current environment: Production/ })) + renderSwitcher( + client, + "/orgs/org_01/projects/prj_01/env/prod/billing/health" + ); + fireEvent.click( + await screen.findByRole("button", { + name: /Current environment: Production/, + }) + ); - expect(await screen.findByRole("menuitem", { name: /Production/ })).toHaveAttribute( - "aria-current", - "page", - ) - expect(screen.getByRole("menuitem", { name: /Development/ })).not.toHaveAttribute( - "aria-current", - ) - }) + expect( + await screen.findByRole("menuitem", { name: /Production/ }) + ).toHaveAttribute("aria-current", "page"); + expect( + screen.getByRole("menuitem", { name: /Development/ }) + ).not.toHaveAttribute("aria-current"); + }); it("carries the current search across an in-place switch", async () => { - const client = seededClient(withEnvironments([development, production])) + const client = seededClient(withEnvironments([development, production])); renderSwitcher( client, - "/orgs/org_01/projects/prj_01/analytics/env_dev/funnel?window=28d", - ) - fireEvent.click(await screen.findByRole("button", { name: /Current environment/ })) + "/orgs/org_01/projects/prj_01/env/dev/analytics/funnel?window=28d" + ); + fireEvent.click( + await screen.findByRole("button", { name: /Current environment/ }) + ); // Analytics filters live in search; dropping them would silently reset the // view the operator had set up. - expect(await screen.findByRole("menuitem", { name: /Production/ })).toHaveAttribute( + expect( + await screen.findByRole("menuitem", { name: /Production/ }) + ).toHaveAttribute( "href", - "/orgs/org_01/projects/prj_01/analytics/env_prod/funnel?window=28d", - ) - }) + "/orgs/org_01/projects/prj_01/env/prod/analytics/funnel?window=28d" + ); + }); it("still answers the question on a surface that carries no Environment", async () => { - const client = seededClient(withEnvironments([development, production])) + const client = seededClient(withEnvironments([development, production])); - renderSwitcher(client, "/orgs/org_01/projects/prj_01/apps") + renderSwitcher(client, "/orgs/org_01/projects/prj_01"); - // The Project's first Environment stands in until a choice is made, and the - // trigger admits the address does not name it. - const trigger = await screen.findByRole("button", { name: /Current environment: Development/ }) - expect(trigger).toHaveTextContent("not in this page's address") + // The Project's first Environment stands in until a choice is made. + const trigger = await screen.findByRole("button", { + name: /Current environment: Development/, + }); - fireEvent.click(trigger) + fireEvent.click(trigger); // Nothing to navigate to, so selecting records the choice instead of moving // the operator to a page they did not ask for. - expect(await screen.findByRole("menuitem", { name: /Production/ })).not.toHaveAttribute("href") - }) + expect( + await screen.findByRole("menuitem", { name: /Production/ }) + ).not.toHaveAttribute("href"); + }); it("carries a choice made off-path onto the next Environment surface", async () => { - const client = seededClient(withEnvironments([development, production])) + const client = seededClient(withEnvironments([development, production])); - const off = renderSwitcher(client, "/orgs/org_01/projects/prj_01/apps") - fireEvent.click(await screen.findByRole("button", { name: /Current environment/ })) - fireEvent.click(await screen.findByRole("menuitem", { name: /Production/ })) - off.unmount() + const off = renderSwitcher(client, "/orgs/org_01/projects/prj_01"); + fireEvent.click( + await screen.findByRole("button", { name: /Current environment/ }) + ); + fireEvent.click( + await screen.findByRole("menuitem", { name: /Production/ }) + ); + off.unmount(); // This is the point of the store: the choice survives the page that could not // express it in its address. - renderSwitcher(client, "/orgs/org_01/projects/prj_01/catalog/products") + renderSwitcher(client, "/orgs/org_01/projects/prj_01"); expect( - await screen.findByRole("button", { name: /Current environment: Production/ }), - ).toBeInTheDocument() - }) + await screen.findByRole("button", { + name: /Current environment: Production/, + }) + ).toBeInTheDocument(); + }); it("lets the address outrank the remembered choice", async () => { - const client = seededClient(withEnvironments([development, production])) + const client = seededClient(withEnvironments([development, production])); - const off = renderSwitcher(client, "/orgs/org_01/projects/prj_01/apps") - fireEvent.click(await screen.findByRole("button", { name: /Current environment/ })) - fireEvent.click(await screen.findByRole("menuitem", { name: /Production/ })) - off.unmount() + const off = renderSwitcher(client, "/orgs/org_01/projects/prj_01"); + fireEvent.click( + await screen.findByRole("button", { name: /Current environment/ }) + ); + fireEvent.click( + await screen.findByRole("menuitem", { name: /Production/ }) + ); + off.unmount(); // Otherwise a shared link would render whatever the recipient last picked. - renderSwitcher(client, "/orgs/org_01/projects/prj_01/billing/env_dev/health") + renderSwitcher( + client, + "/orgs/org_01/projects/prj_01/env/dev/billing/health" + ); expect( - await screen.findByRole("button", { name: /Current environment: Development/ }), - ).toBeInTheDocument() - }) + await screen.findByRole("button", { + name: /Current environment: Development/, + }) + ).toBeInTheDocument(); + }); it("is absent above a Project, where Environments do not apply", () => { - const client = seededClient(withEnvironments([development, production])) + const client = seededClient(withEnvironments([development, production])); - renderSwitcher(client, "/orgs/org_01") + renderSwitcher(client, "/orgs/org_01"); - expect(screen.queryByRole("button", { name: /environment/i })).toBeNull() - }) + expect(screen.queryByRole("button", { name: /environment/i })).toBeNull(); + }); it("does not mistake a literal path segment for an Environment", async () => { - const client = seededClient(withEnvironments([development, production])) + const client = seededClient(withEnvironments([development, production])); - renderSwitcher(client, "/orgs/org_01/projects/prj_01/billing/connections/cred_01") + renderSwitcher( + client, + "/orgs/org_01/projects/prj_01/billing/connections/cred_01" + ); // "connections" reads as the Environment slot to the scope parser. Rewriting // it in place would build a route that does not exist, so this surface counts // as carrying no Environment and offers no link. - const trigger = await screen.findByRole("button", { name: /Current environment/ }) - expect(trigger).toHaveTextContent("not in this page's address") - fireEvent.click(trigger) - expect(await screen.findByRole("menuitem", { name: /Production/ })).not.toHaveAttribute("href") - }) + const trigger = await screen.findByRole("button", { + name: /Current environment/, + }); + fireEvent.click(trigger); + expect( + await screen.findByRole("menuitem", { name: /Production/ }) + ).not.toHaveAttribute("href"); + }); it("offers a retry when the Environment list cannot be read", async () => { const client = seededClient((queryClient) => { @@ -247,16 +325,19 @@ describe("EnvironmentSwitcher", () => { correlationId: "request_test", retryable: true, status: 500, - }), + }) ), retry: false, - }) - }) + }); + }); - renderSwitcher(client, "/orgs/org_01/projects/prj_01/monetization/env_dev/paywalls") + renderSwitcher( + client, + "/orgs/org_01/projects/prj_01/env/dev/monetization/paywalls" + ); expect( - await screen.findByRole("button", { name: "Retry loading environments" }), - ).toBeInTheDocument() - }) -}) + await screen.findByRole("button", { name: "Retry loading environments" }) + ).toBeInTheDocument(); + }); +}); diff --git a/apps/dashboard/src/features/environments/components/environment-switcher.tsx b/apps/dashboard/src/features/environments/components/environment-switcher.tsx index f272be6e..169de6a7 100644 --- a/apps/dashboard/src/features/environments/components/environment-switcher.tsx +++ b/apps/dashboard/src/features/environments/components/environment-switcher.tsx @@ -1,5 +1,9 @@ -import type * as React from "react" - +import { CaretUpDownIcon } from "@phosphor-icons/react/dist/ssr/CaretUpDown"; +import { CheckIcon } from "@phosphor-icons/react/dist/ssr/Check"; +import { StackIcon } from "@phosphor-icons/react/dist/ssr/Stack"; +import { Link } from "@tanstack/react-router"; +import type * as React from "react"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -7,31 +11,25 @@ import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" +} from "@/components/ui/dropdown-menu"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar, -} from "@/components/ui/sidebar" -import { CaretUpDownIcon } from "@phosphor-icons/react/dist/ssr/CaretUpDown" -import { CheckIcon } from "@phosphor-icons/react/dist/ssr/Check" -import { StackIcon } from "@phosphor-icons/react/dist/ssr/Stack" -import { Link } from "@tanstack/react-router" - -import { Button } from "@/components/ui/button" -import { useActiveEnvironment } from "@/features/environments/hooks/use-active-environment" -import { describeApiError } from "@/lib/api/errors" +} from "@/components/ui/sidebar"; +import { useActiveEnvironment } from "@/features/environments/hooks/use-active-environment"; +import { describeApiError } from "@/lib/api/errors"; // Base UI exposes the trigger width as --anchor-width on positioned popups. -const DROPDOWN_CLASSNAMES = "w-(--anchor-width) min-w-56 rounded" +const DROPDOWN_CLASSNAMES = "w-(--anchor-width) min-w-56 rounded"; function SwitcherFrame({ children }: { children: React.ReactNode }) { return ( {children} - ) + ); } /** @@ -44,22 +42,28 @@ function SwitcherFrame({ children }: { children: React.ReactNode }) { * an honest description of what is on screen. */ export function EnvironmentSwitcher() { - const { isMobile } = useSidebar() - const { active, items, pathFor, projectId, query, remember, select } = useActiveEnvironment() + const { isMobile } = useSidebar(); + const { active, items, pathFor, projectId, query, remember, select } = + useActiveEnvironment(); - if (!projectId) return null + if (!projectId) { + return null; + } if (query.isPending) { return ( - + Loading environments… - ) + ); } if (query.isError) { @@ -69,15 +73,23 @@ export function EnvironmentSwitcher() {

    {describeApiError(query.error).description}

    -
    - ) + ); } - if (!active) return null + if (!active) { + return null; + } return ( @@ -87,12 +99,14 @@ export function EnvironmentSwitcher() { render={
    - {active.name} + + {active.name} +
    @@ -101,8 +115,8 @@ export function EnvironmentSwitcher() { /> @@ -111,7 +125,7 @@ export function EnvironmentSwitcher() { {items.map((environment) => { - const target = pathFor(environment.id) + const target = pathFor(environment.id); return ( remember(environment.id) : () => select(environment.id)} - render={target ? : undefined} + onClick={ + target + ? () => remember(environment.id) + : () => select(environment.id) + } + render={ + target ? : undefined + } > {environment.id === active.id ? ( @@ -130,15 +150,15 @@ export function EnvironmentSwitcher() { ) : null} {environment.name} - + {environment.mode} - ) + ); })}
    - ) + ); } diff --git a/apps/dashboard/src/features/environments/components/environments-page.tsx b/apps/dashboard/src/features/environments/components/environments-page.tsx index 0920e079..90b1b213 100644 --- a/apps/dashboard/src/features/environments/components/environments-page.tsx +++ b/apps/dashboard/src/features/environments/components/environments-page.tsx @@ -1,21 +1,33 @@ -import { useQuery } from "@tanstack/react-query" +import { useQuery } from "@tanstack/react-query"; -import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" -import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" -import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" -import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" -import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery" -import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope" +import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary"; +import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state"; +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query"; +import { ScopeMismatchRecovery } from "@/features/orgs/components/scope-mismatch-recovery"; +import { + WorkflowPanel, + WorkspacePage, +} from "@/features/orgs/components/workspace-page"; +import { useValidatedProjectScope } from "@/features/projects/hooks/use-validated-project-scope"; interface EnvironmentsPageProps { - organizationId: string - projectId: string + organizationId: string; + projectId: string; } -export function EnvironmentsPage({ organizationId, projectId }: EnvironmentsPageProps) { - const { project, scopeMismatch, scopeReady } = useValidatedProjectScope(organizationId, projectId) - const environments = useQuery({ ...environmentsQueryOptions(projectId), enabled: scopeReady }) - const items = environments.data?.items ?? [] +export function EnvironmentsPage({ + organizationId, + projectId, +}: EnvironmentsPageProps) { + const { project, scopeMismatch, scopeReady } = useValidatedProjectScope( + organizationId, + projectId + ); + const environments = useQuery({ + ...environmentsQueryOptions(projectId), + enabled: scopeReady, + }); + const items = environments.data?.items ?? []; const state = resolveHostedQueryState({ emptyDescription: "Every project must retain Development, Staging, and Production. Retry loading this project.", @@ -24,9 +36,12 @@ export function EnvironmentsPage({ organizationId, projectId }: EnvironmentsPage isEmpty: scopeReady && environments.isSuccess && items.length === 0, isPending: project.isPending || (scopeReady && environments.isPending), loadingDescription: "Loading isolated project environments.", - onRetry: () => void environments.refetch(), - permissionDescription: "Project membership is required to view environment metadata.", - }) + onRetry: () => { + environments.refetch(); + }, + permissionDescription: + "Project membership is required to view environment metadata.", + }); if (scopeMismatch) { return ( @@ -40,7 +55,7 @@ export function EnvironmentsPage({ organizationId, projectId }: EnvironmentsPage projectId={projectId} /> - ) + ); } return ( @@ -57,8 +72,10 @@ export function EnvironmentsPage({ organizationId, projectId }: EnvironmentsPage {items.map((environment) => (
  • {environment.name}

    -

    {environment.key}

    -

    +

    + {environment.key} +

    +

    Mosaic mode · {environment.mode}

  • @@ -67,5 +84,5 @@ export function EnvironmentsPage({ organizationId, projectId }: EnvironmentsPage - ) + ); } diff --git a/apps/dashboard/src/features/environments/components/monetization-workspace.tsx b/apps/dashboard/src/features/environments/components/monetization-workspace.tsx index 98843c53..6f3b471c 100644 --- a/apps/dashboard/src/features/environments/components/monetization-workspace.tsx +++ b/apps/dashboard/src/features/environments/components/monetization-workspace.tsx @@ -1,28 +1,33 @@ -import { useQuery } from "@tanstack/react-query" -import { Link } from "@tanstack/react-router" -import type { ReactNode } from "react" +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import type { ReactNode } from "react"; -import { buttonVariants } from "@/components/ui/button-variants" -import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" -import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" -import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" -import { WorkspacePage } from "@/features/orgs/components/workspace-page" -import { projectQueryOptions } from "@/features/projects/queries/projects-query" +import { buttonVariants } from "@/components/ui/button-variants"; +import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary"; +import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state"; +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query"; +import { WorkspacePage } from "@/features/orgs/components/workspace-page"; +import { projectQueryOptions } from "@/features/projects/queries/projects-query"; +import { workspaceScopeParams } from "@/lib/routing/workspace-params"; -export type MonetizationSurface = "assets" | "experiments" | "paywalls" | "placements" | "releases" +export type MonetizationSurface = + | "assets" + | "experiments" + | "paywalls" + | "placements" + | "releases"; interface MonetizationWorkspaceProps { - actions?: ReactNode - children: ReactNode - description: string - environmentId: string - organizationId: string - projectId: string - surface: MonetizationSurface - title: string + actions?: ReactNode; + children: ReactNode; + description: string; + environmentId: string; + organizationId: string; + projectId: string; + surface: MonetizationSurface; + title: string; } - export function MonetizationWorkspace({ actions, children, @@ -31,25 +36,29 @@ export function MonetizationWorkspace({ projectId, title, }: MonetizationWorkspaceProps) { - const project = useQuery(projectQueryOptions(projectId)) - const environments = useQuery(environmentsQueryOptions(projectId)) - const items = environments.data?.items ?? [] - const environment = items.find((candidate) => candidate.id === environmentId) + const project = useQuery(projectQueryOptions(projectId)); + const environments = useQuery(environmentsQueryOptions(projectId)); + const items = environments.data?.items ?? []; + const environment = items.find((candidate) => candidate.id === environmentId); const state = resolveHostedQueryState({ - emptyDescription: "Choose a valid project Environment before managing monetization.", + emptyDescription: + "Choose a valid project Environment before managing monetization.", emptyTitle: "Environment unavailable", error: project.error ?? environments.error, isEmpty: environments.isSuccess && !environment, isPending: project.isPending || environments.isPending, loadingDescription: "Loading the selected monetization Environment.", onRetry: () => { - void project.refetch() - void environments.refetch() + project.refetch(); + environments.refetch(); }, permissionAction: ( prev} + params={(prev) => ({ + ...prev, + ...workspaceScopeParams(prev), + })} to="/orgs/$organizationId/projects/$projectId/env/$environmentKey" > Return to project @@ -57,7 +66,7 @@ export function MonetizationWorkspace({ ), permissionDescription: "Project membership with Environment access is required to manage monetization.", - }) + }); return ( - - {children} - + {children} - ) + ); } diff --git a/apps/dashboard/src/features/environments/hooks/use-active-environment.ts b/apps/dashboard/src/features/environments/hooks/use-active-environment.ts index c98d2c45..b29c44d9 100644 --- a/apps/dashboard/src/features/environments/hooks/use-active-environment.ts +++ b/apps/dashboard/src/features/environments/hooks/use-active-environment.ts @@ -1,20 +1,20 @@ -import * as React from "react" +import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useRouterState } from "@tanstack/react-router" -import { useQuery } from "@tanstack/react-query" +import { useNavigate, useRouterState } from "@tanstack/react-router"; +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; -import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query"; import { rememberEnvironmentId, rememberedEnvironmentId, subscribeToActiveEnvironment, -} from "@/features/environments/types/active-environment-store" +} from "@/features/environments/types/active-environment-store"; import { environmentAlias, environmentForAlias, -} from "@/features/environments/types/environment-alias" -import { switchEnvironmentPath } from "@/features/environments/types/environment-path" -import { readWorkspaceScope } from "@/features/orgs/types/workspace-navigation" +} from "@/features/environments/types/environment-alias"; +import { switchEnvironmentPath } from "@/features/environments/types/environment-path"; +import { readWorkspaceScope } from "@/features/orgs/types/workspace-navigation"; /** * The single place to ask "which Environment am I working in?". Pages and queries @@ -30,68 +30,86 @@ import { readWorkspaceScope } from "@/features/orgs/types/workspace-navigation" * and purchase setup, which refuse to default — should require it. */ export function useActiveEnvironment() { - const navigate = useNavigate() - const pathname = useRouterState({ select: (state) => state.location.pathname }) - const scope = readWorkspaceScope(pathname) - const projectId = scope.projectId ?? "" + const navigate = useNavigate(); + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + const scope = readWorkspaceScope(pathname); + const projectId = scope.projectId ?? ""; const query = useQuery({ ...environmentsQueryOptions(projectId), enabled: projectId.length > 0, - }) + }); // Stable identity: pathFor closes over this, and a fresh array every render would // rebuild the callback and every memo downstream of it. - const items = React.useMemo(() => query.data?.items ?? [], [query.data?.items]) + const items = useMemo(() => query.data?.items ?? [], [query.data?.items]); - const rememberedId = React.useSyncExternalStore( + const rememberedId = useSyncExternalStore( subscribeToActiveEnvironment, () => rememberedEnvironmentId(projectId), - () => undefined, - ) + () => undefined + ); // This is the mapping point: the alias in the address resolves to the Environment // here, so no page has to know that the API wants an id. Candidates are validated // against the Project's own Environments, so neither a stale remembered id nor a // malformed segment can be mistaken for a scope. - const fromPath = environmentForAlias(items, scope.environmentSegment) - const fromMemory = items.find((environment) => environment.id === rememberedId) - const active = fromPath ?? fromMemory ?? items[0] + const fromPath = environmentForAlias(items, scope.environmentSegment); + const fromMemory = items.find( + (environment) => environment.id === rememberedId + ); + const active = fromPath ?? fromMemory ?? items[0]; // Visiting an Environment-scoped page is itself a choice; remembering it is what // carries the Environment onto the surfaces that have none. - React.useEffect(() => { - if (fromPath) rememberEnvironmentId(projectId, fromPath.id) - }, [projectId, fromPath]) + useEffect(() => { + if (fromPath) { + rememberEnvironmentId(projectId, fromPath.id); + } + }, [projectId, fromPath]); /** Where this route would live under another Environment, or null if it does not name one. */ - const pathFor = React.useCallback( + const pathFor = useCallback( (environmentId: string) => { - if (!fromPath) return null - const next = items.find((environment) => environment.id === environmentId) - if (!next) return null + if (!fromPath) { + return null; + } + const next = items.find( + (environment) => environment.id === environmentId + ); + if (!next) { + return null; + } - return switchEnvironmentPath(pathname, environmentAlias(fromPath), environmentAlias(next)) + return switchEnvironmentPath( + pathname, + environmentAlias(fromPath), + environmentAlias(next) + ); }, - [fromPath, items, pathname], - ) + [fromPath, items, pathname] + ); /** Records the choice without navigating, for callers that render their own link. */ - const remember = React.useCallback( + const remember = useCallback( (environmentId: string) => rememberEnvironmentId(projectId, environmentId), - [projectId], - ) + [projectId] + ); - const select = React.useCallback( + const select = useCallback( (environmentId: string) => { - remember(environmentId) + remember(environmentId); // On a route that carries the Environment, the URL is the authority and has // to move too, keeping the surface and its search intact. - const target = pathFor(environmentId) - if (target) void navigate({ search: true, to: target }) + const target = pathFor(environmentId); + if (target) { + navigate({ search: true, to: target }); + } }, - [navigate, pathFor, remember], - ) + [navigate, pathFor, remember] + ); return { active, @@ -115,5 +133,5 @@ export function useActiveEnvironment() { query, remember, select, - } + }; } diff --git a/apps/dashboard/src/features/environments/hooks/use-route-environment.tsx b/apps/dashboard/src/features/environments/hooks/use-route-environment.tsx index bb841a4f..bca1d83d 100644 --- a/apps/dashboard/src/features/environments/hooks/use-route-environment.tsx +++ b/apps/dashboard/src/features/environments/hooks/use-route-environment.tsx @@ -1,6 +1,6 @@ -import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" -import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" -import { useActiveEnvironment } from "@/features/environments/hooks/use-active-environment" +import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary"; +import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state"; +import { useActiveEnvironment } from "@/features/environments/hooks/use-active-environment"; /** * Turns the Environment alias in the address into the id a page needs. @@ -17,9 +17,11 @@ import { useActiveEnvironment } from "@/features/environments/hooks/use-active-e * Environment for, which is what a hand-edited or stale address looks like. */ export function useRouteEnvironment() { - const { pathEnvironment, query } = useActiveEnvironment() + const { pathEnvironment, query } = useActiveEnvironment(); - if (pathEnvironment) return { environmentId: pathEnvironment.id, fallback: undefined } + if (pathEnvironment) { + return { environmentId: pathEnvironment.id, fallback: undefined }; + } const state = resolveHostedQueryState({ emptyDescription: @@ -31,12 +33,17 @@ export function useRouteEnvironment() { isEmpty: query.isSuccess, isPending: query.isPending, loadingDescription: "Loading the project's environments.", - onRetry: () => void query.refetch(), - permissionDescription: "Project membership is required to read this environment.", - }) + onRetry: () => { + query.refetch(); + }, + permissionDescription: + "Project membership is required to read this environment.", + }); return { environmentId: undefined, - fallback: {null}, - } + fallback: ( + {null} + ), + }; } diff --git a/apps/dashboard/src/features/environments/queries/environments-query.ts b/apps/dashboard/src/features/environments/queries/environments-query.ts index 3ca7b633..eafa2d0c 100644 --- a/apps/dashboard/src/features/environments/queries/environments-query.ts +++ b/apps/dashboard/src/features/environments/queries/environments-query.ts @@ -1,11 +1,11 @@ -import { queryOptions } from "@tanstack/react-query" +import { queryOptions } from "@tanstack/react-query"; -import { listEnvironments } from "@/generated/api" -import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" +import { listEnvironments } from "@/generated/api"; +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client"; export const environmentKeys = { list: (projectId: string) => ["environments", projectId] as const, -} +}; export function environmentsQueryOptions(projectId: string) { return queryOptions({ @@ -16,8 +16,8 @@ export function environmentsQueryOptions(projectId: string) { path: { projectId }, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } diff --git a/apps/dashboard/src/features/environments/types/active-environment-store.test.ts b/apps/dashboard/src/features/environments/types/active-environment-store.test.ts index f1124dd9..6a7bd061 100644 --- a/apps/dashboard/src/features/environments/types/active-environment-store.test.ts +++ b/apps/dashboard/src/features/environments/types/active-environment-store.test.ts @@ -1,93 +1,99 @@ -import { afterEach, describe, expect, it, vi } from "vitest" +import { afterEach, describe, expect, it, vi } from "vitest"; import { rememberEnvironmentId, rememberedEnvironmentId, resetActiveEnvironmentStore, subscribeToActiveEnvironment, -} from "@/features/environments/types/active-environment-store" +} from "@/features/environments/types/active-environment-store"; afterEach(() => { - resetActiveEnvironmentStore() - window.localStorage.clear() - vi.restoreAllMocks() -}) + resetActiveEnvironmentStore(); + window.localStorage.clear(); + vi.restoreAllMocks(); +}); describe("active environment store", () => { it("keeps one value per Project", () => { - rememberEnvironmentId("prj_01", "env_prod") - rememberEnvironmentId("prj_02", "env_dev") + rememberEnvironmentId("prj_01", "env_prod"); + rememberEnvironmentId("prj_02", "env_dev"); - expect(rememberedEnvironmentId("prj_01")).toBe("env_prod") - expect(rememberedEnvironmentId("prj_02")).toBe("env_dev") - expect(rememberedEnvironmentId("prj_unknown")).toBeUndefined() - }) + expect(rememberedEnvironmentId("prj_01")).toBe("env_prod"); + expect(rememberedEnvironmentId("prj_02")).toBe("env_dev"); + expect(rememberedEnvironmentId("prj_unknown")).toBeUndefined(); + }); it("returns a stable primitive so a subscribing reader does not loop", () => { - rememberEnvironmentId("prj_01", "env_prod") + rememberEnvironmentId("prj_01", "env_prod"); // useSyncExternalStore compares snapshots by identity; an object would make // every read look like a change. - expect(rememberedEnvironmentId("prj_01")).toBe(rememberedEnvironmentId("prj_01")) - }) + expect(rememberedEnvironmentId("prj_01")).toBe( + rememberedEnvironmentId("prj_01") + ); + }); it("notifies readers on a change, and not on a repeat of the same value", () => { - const listener = vi.fn() - subscribeToActiveEnvironment(listener) - listener.mockClear() + const listener = vi.fn(); + subscribeToActiveEnvironment(listener); + listener.mockClear(); - rememberEnvironmentId("prj_01", "env_prod") - expect(listener).toHaveBeenCalledTimes(1) + rememberEnvironmentId("prj_01", "env_prod"); + expect(listener).toHaveBeenCalledTimes(1); - rememberEnvironmentId("prj_01", "env_prod") - expect(listener).toHaveBeenCalledTimes(1) + rememberEnvironmentId("prj_01", "env_prod"); + expect(listener).toHaveBeenCalledTimes(1); - rememberEnvironmentId("prj_01", "env_dev") - expect(listener).toHaveBeenCalledTimes(2) - }) + rememberEnvironmentId("prj_01", "env_dev"); + expect(listener).toHaveBeenCalledTimes(2); + }); it("stops notifying after unsubscribe", () => { - const listener = vi.fn() - const unsubscribe = subscribeToActiveEnvironment(listener) - unsubscribe() - listener.mockClear() + const listener = vi.fn(); + const unsubscribe = subscribeToActiveEnvironment(listener); + unsubscribe(); + listener.mockClear(); - rememberEnvironmentId("prj_01", "env_prod") + rememberEnvironmentId("prj_01", "env_prod"); - expect(listener).not.toHaveBeenCalled() - }) + expect(listener).not.toHaveBeenCalled(); + }); it("recovers the choice from storage on the first subscription", () => { - window.localStorage.setItem("mosaic.activeEnvironment.prj_01", "env_prod") + window.localStorage.setItem("mosaic.activeEnvironment.prj_01", "env_prod"); // Hydration is deferred to subscribe, which runs in an effect: reading storage // during render would report a value the server-rendered HTML did not have. - expect(rememberedEnvironmentId("prj_01")).toBeUndefined() - subscribeToActiveEnvironment(() => {}) - expect(rememberedEnvironmentId("prj_01")).toBe("env_prod") - }) + expect(rememberedEnvironmentId("prj_01")).toBeUndefined(); + subscribeToActiveEnvironment(() => { + /* stub for a browser API jsdom does not implement */ + }); + expect(rememberedEnvironmentId("prj_01")).toBe("env_prod"); + }); it("ignores unrelated storage keys", () => { - window.localStorage.setItem("unrelated.prj_01", "env_prod") - subscribeToActiveEnvironment(() => {}) + window.localStorage.setItem("unrelated.prj_01", "env_prod"); + subscribeToActiveEnvironment(() => { + /* stub for a browser API jsdom does not implement */ + }); - expect(rememberedEnvironmentId("prj_01")).toBeUndefined() - }) + expect(rememberedEnvironmentId("prj_01")).toBeUndefined(); + }); it("keeps working in memory when storage refuses the write", () => { vi.spyOn(window.localStorage, "setItem").mockImplementation(() => { - throw new Error("quota exceeded") - }) + throw new Error("quota exceeded"); + }); - expect(() => rememberEnvironmentId("prj_01", "env_prod")).not.toThrow() - expect(rememberedEnvironmentId("prj_01")).toBe("env_prod") - }) + expect(() => rememberEnvironmentId("prj_01", "env_prod")).not.toThrow(); + expect(rememberedEnvironmentId("prj_01")).toBe("env_prod"); + }); it("refuses a blank Project or Environment rather than storing an empty key", () => { - rememberEnvironmentId("", "env_prod") - rememberEnvironmentId("prj_01", "") + rememberEnvironmentId("", "env_prod"); + rememberEnvironmentId("prj_01", ""); - expect(rememberedEnvironmentId("")).toBeUndefined() - expect(rememberedEnvironmentId("prj_01")).toBeUndefined() - }) -}) + expect(rememberedEnvironmentId("")).toBeUndefined(); + expect(rememberedEnvironmentId("prj_01")).toBeUndefined(); + }); +}); diff --git a/apps/dashboard/src/features/environments/types/active-environment-store.ts b/apps/dashboard/src/features/environments/types/active-environment-store.ts index 9cdd2cb4..b38956fe 100644 --- a/apps/dashboard/src/features/environments/types/active-environment-store.ts +++ b/apps/dashboard/src/features/environments/types/active-environment-store.ts @@ -8,19 +8,21 @@ * one their own browser happens to remember. */ -const STORAGE_PREFIX = "mosaic.activeEnvironment." +const STORAGE_PREFIX = "mosaic.activeEnvironment."; -const remembered = new Map() -const listeners = new Set<() => void>() +const remembered = new Map(); +const listeners = new Set<() => void>(); -let hydrated = false +let hydrated = false; function storageKey(projectId: string) { - return `${STORAGE_PREFIX}${projectId}` + return `${STORAGE_PREFIX}${projectId}`; } function notify() { - for (const listener of listeners) listener() + for (const listener of listeners) { + listener(); + } } /** @@ -29,16 +31,24 @@ function notify() { * touched storage would report a value the server-rendered HTML did not have. */ function hydrateOnce() { - if (hydrated) return - hydrated = true - if (typeof window === "undefined") return + if (hydrated) { + return; + } + hydrated = true; + if (typeof window === "undefined") { + return; + } try { for (let index = 0; index < window.localStorage.length; index += 1) { - const key = window.localStorage.key(index) - if (!key?.startsWith(STORAGE_PREFIX)) continue - const value = window.localStorage.getItem(key) - if (value) remembered.set(key.slice(STORAGE_PREFIX.length), value) + const key = window.localStorage.key(index); + if (!key?.startsWith(STORAGE_PREFIX)) { + continue; + } + const value = window.localStorage.getItem(key); + if (value) { + remembered.set(key.slice(STORAGE_PREFIX.length), value); + } } } catch { // Storage can be unavailable or full. The in-memory value still works for @@ -47,34 +57,43 @@ function hydrateOnce() { } export function subscribeToActiveEnvironment(listener: () => void) { - hydrateOnce() - listeners.add(listener) - if (remembered.size > 0) listener() - return () => listeners.delete(listener) + hydrateOnce(); + listeners.add(listener); + if (remembered.size > 0) { + listener(); + } + return () => listeners.delete(listener); } /** A primitive snapshot, so useSyncExternalStore never sees a new identity. */ export function rememberedEnvironmentId(projectId: string) { - return projectId ? remembered.get(projectId) : undefined + return projectId ? remembered.get(projectId) : undefined; } -export function rememberEnvironmentId(projectId: string, environmentId: string) { - if (!projectId || !environmentId) return - if (remembered.get(projectId) === environmentId) return +export function rememberEnvironmentId( + projectId: string, + environmentId: string +) { + if (!(projectId && environmentId)) { + return; + } + if (remembered.get(projectId) === environmentId) { + return; + } - remembered.set(projectId, environmentId) + remembered.set(projectId, environmentId); try { - window.localStorage.setItem(storageKey(projectId), environmentId) + window.localStorage.setItem(storageKey(projectId), environmentId); } catch { // See hydrateOnce: persistence is best effort. } - notify() + notify(); } /** Test seam. Production code has no reason to discard the choice. */ export function resetActiveEnvironmentStore() { - remembered.clear() - hydrated = false - notify() - listeners.clear() + remembered.clear(); + hydrated = false; + notify(); + listeners.clear(); } diff --git a/apps/dashboard/src/features/environments/types/environment-alias.test.ts b/apps/dashboard/src/features/environments/types/environment-alias.test.ts index c428fa29..dc18283c 100644 --- a/apps/dashboard/src/features/environments/types/environment-alias.test.ts +++ b/apps/dashboard/src/features/environments/types/environment-alias.test.ts @@ -1,58 +1,68 @@ -import { describe, expect, it } from "vitest" - -import type { Environment } from "@/generated/api" +import { describe, expect, it } from "vitest"; import { environmentAlias, environmentForAlias, -} from "@/features/environments/types/environment-alias" +} from "@/features/environments/types/environment-alias"; +import type { Environment } from "@/generated/api"; -const timestamps = { createdAt: "2026-07-30T00:00:00Z", updatedAt: "2026-07-30T00:00:00Z" } +const timestamps = { + createdAt: "2026-07-30T00:00:00Z", + updatedAt: "2026-07-30T00:00:00Z", +}; -function environment(id: string, key: string, mode: Environment["mode"]): Environment { - return { ...timestamps, id, key, mode, name: key, projectId: "prj_01" } +function environment( + id: string, + key: string, + mode: Environment["mode"] +): Environment { + return { ...timestamps, id, key, mode, name: key, projectId: "prj_01" }; } -const development = environment("env_01", "development", "development") -const staging = environment("env_02", "staging", "staging") -const production = environment("env_03", "production", "production") -const all = [development, staging, production] +const development = environment("env_01", "development", "development"); +const staging = environment("env_02", "staging", "staging"); +const production = environment("env_03", "production", "production"); +const all = [development, staging, production]; describe("environment aliases", () => { it("shortens the seeded keys for the address", () => { - expect(environmentAlias(development)).toBe("dev") - expect(environmentAlias(staging)).toBe("staging") - expect(environmentAlias(production)).toBe("prod") - }) + expect(environmentAlias(development)).toBe("dev"); + expect(environmentAlias(staging)).toBe("staging"); + expect(environmentAlias(production)).toBe("prod"); + }); it("falls back to the key it cannot shorten", () => { - expect(environmentAlias(environment("env_04", "canary", "staging"))).toBe("canary") - }) + expect(environmentAlias(environment("env_04", "canary", "staging"))).toBe( + "canary" + ); + }); it("resolves an alias back to its Environment", () => { - expect(environmentForAlias(all, "prod")).toBe(production) - expect(environmentForAlias(all, "dev")).toBe(development) - expect(environmentForAlias(all, "staging")).toBe(staging) - }) + expect(environmentForAlias(all, "prod")).toBe(production); + expect(environmentForAlias(all, "dev")).toBe(development); + expect(environmentForAlias(all, "staging")).toBe(staging); + }); it("also accepts the underlying key", () => { - expect(environmentForAlias(all, "development")).toBe(development) - expect(environmentForAlias(all, "production")).toBe(production) - }) + expect(environmentForAlias(all, "development")).toBe(development); + expect(environmentForAlias(all, "production")).toBe(production); + }); - it("still accepts an id, so links minted before the change keep working", () => { - expect(environmentForAlias(all, "env_03")).toBe(production) - }) + it("rejects an id, which the address never carries", () => { + // A segment that looks like an id is a malformed link rather than a scope, + // so it must not resolve. `switchEnvironmentPath` holds the same line. + expect(environmentForAlias(all, "env_03")).toBeUndefined(); + }); it("resolves nothing for an absent or unknown segment", () => { - expect(environmentForAlias(all, undefined)).toBeUndefined() - expect(environmentForAlias(all, "")).toBeUndefined() + expect(environmentForAlias(all, undefined)).toBeUndefined(); + expect(environmentForAlias(all, "")).toBeUndefined(); // A literal from a sibling route must not resolve to an Environment. - expect(environmentForAlias(all, "connections")).toBeUndefined() - }) + expect(environmentForAlias(all, "connections")).toBeUndefined(); + }); it("round-trips every seeded Environment", () => { for (const value of all) { - expect(environmentForAlias(all, environmentAlias(value))).toBe(value) + expect(environmentForAlias(all, environmentAlias(value))).toBe(value); } - }) -}) + }); +}); diff --git a/apps/dashboard/src/features/environments/types/environment-alias.ts b/apps/dashboard/src/features/environments/types/environment-alias.ts index fb9897c6..b8019340 100644 --- a/apps/dashboard/src/features/environments/types/environment-alias.ts +++ b/apps/dashboard/src/features/environments/types/environment-alias.ts @@ -1,4 +1,4 @@ -import type { Environment } from "@/generated/api" +import type { Environment } from "@/generated/api"; /** * The Environment as it appears in an address: `prod`, `staging`, `dev` rather than @@ -13,23 +13,23 @@ const ALIAS_BY_KEY: Record = { development: "dev", production: "prod", staging: "staging", -} +}; /** * Where a link lands when the linker has no Environment to go on — workspace entry, * for instance, which resolves before any Environment list is read. Development on * purpose: an address that guesses must never guess production. */ -export const DEFAULT_ENVIRONMENT_ALIAS = "dev" +export const DEFAULT_ENVIRONMENT_ALIAS = "dev"; const KEY_BY_ALIAS: Record = { dev: "development", prod: "production", staging: "staging", -} +}; export function environmentAlias(environment: Environment) { - return ALIAS_BY_KEY[environment.key] ?? environment.key + return ALIAS_BY_KEY[environment.key] ?? environment.key; } /** @@ -39,15 +39,17 @@ export function environmentAlias(environment: Environment) { */ export function environmentForAlias( environments: readonly Environment[], - alias: string | undefined, + alias: string | undefined ): Environment | undefined { - if (!alias) return undefined + if (!alias) { + return; + } - const key = KEY_BY_ALIAS[alias] + const key = KEY_BY_ALIAS[alias]; return environments.find( (environment) => environmentAlias(environment) === alias || environment.key === alias || - (key !== undefined && environment.key === key), - ) + (key !== undefined && environment.key === key) + ); } diff --git a/apps/dashboard/src/features/environments/types/environment-path.test.ts b/apps/dashboard/src/features/environments/types/environment-path.test.ts index b0da2c1d..9cacc6df 100644 --- a/apps/dashboard/src/features/environments/types/environment-path.test.ts +++ b/apps/dashboard/src/features/environments/types/environment-path.test.ts @@ -1,44 +1,66 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it } from "vitest"; -import { switchEnvironmentPath } from "@/features/environments/types/environment-path" +import { switchEnvironmentPath } from "@/features/environments/types/environment-path"; -const base = "/orgs/org_01/projects/prj_01" +const base = "/orgs/org_01/projects/prj_01"; describe("switchEnvironmentPath", () => { it("keeps the operator on the same surface", () => { const cases: [string, string][] = [ - [`${base}/monetization/env_dev/paywalls`, `${base}/monetization/env_prod/paywalls`], [ - `${base}/monetization/env_dev/experiments/exp_01`, - `${base}/monetization/env_prod/experiments/exp_01`, + `${base}/env/env_dev/monetization/paywalls`, + `${base}/env/env_prod/monetization/paywalls`, ], - [`${base}/analytics/env_dev/funnel`, `${base}/analytics/env_prod/funnel`], - [`${base}/billing/env_dev/customers/cus_01`, `${base}/billing/env_prod/customers/cus_01`], - ] + [ + `${base}/env/env_dev/monetization/experiments/exp_01`, + `${base}/env/env_prod/monetization/experiments/exp_01`, + ], + [ + `${base}/env/env_dev/analytics/funnel`, + `${base}/env/env_prod/analytics/funnel`, + ], + [ + `${base}/env/env_dev/billing/customers/cus_01`, + `${base}/env/env_prod/billing/customers/cus_01`, + ], + ]; for (const [pathname, expected] of cases) { - expect(switchEnvironmentPath(pathname, "env_dev", "env_prod")).toBe(expected) + expect(switchEnvironmentPath(pathname, "env_dev", "env_prod")).toBe( + expected + ); } - }) + }); it("rewrites the addressed form by alias", () => { - const addressed = "/orgs/org_01/projects/prj_01/env" + const addressed = "/orgs/org_01/projects/prj_01/env"; - expect(switchEnvironmentPath(`${addressed}/dev/catalog/products`, "dev", "prod")).toBe( - `${addressed}/prod/catalog/products`, - ) expect( - switchEnvironmentPath(`${addressed}/prod/billing/quarantine/rec_01`, "prod", "dev"), - ).toBe(`${addressed}/dev/billing/quarantine/rec_01`) + switchEnvironmentPath(`${addressed}/dev/catalog/products`, "dev", "prod") + ).toBe(`${addressed}/prod/catalog/products`); + expect( + switchEnvironmentPath( + `${addressed}/prod/billing/quarantine/rec_01`, + "prod", + "dev" + ) + ).toBe(`${addressed}/dev/billing/quarantine/rec_01`); // The alias, not the id, is what the address carries, so an id must not match. - expect(switchEnvironmentPath(`${addressed}/prod/apps`, "env_03", "env_01")).toBeNull() - }) + expect( + switchEnvironmentPath(`${addressed}/prod/apps`, "env_03", "env_01") + ).toBeNull(); + }); it("reports nothing to switch on a surface with no Environment", () => { - for (const pathname of [base, `${base}/apps`, `${base}/catalog/products`, "/workspace"]) { - expect(switchEnvironmentPath(pathname, "env_dev", "env_prod")).toBeNull() + for (const pathname of [ + base, + `${base}/apps`, + `${base}/catalog/products`, + "/workspace", + ]) { + expect(switchEnvironmentPath(pathname, "env_dev", "env_prod")).toBeNull(); } - }) + }); it("does not mistake a literal segment for an Environment", () => { // Anchoring on position alone would rewrite "connections" and "migrations", @@ -47,18 +69,26 @@ describe("switchEnvironmentPath", () => { `${base}/billing/connections/cred_01`, `${base}/billing/migrations/prog_01`, ]) { - expect(switchEnvironmentPath(pathname, "env_dev", "env_prod")).toBeNull() + expect(switchEnvironmentPath(pathname, "env_dev", "env_prod")).toBeNull(); } - }) + }); it("rewrites only the Environment, even when another segment repeats its id", () => { expect( - switchEnvironmentPath(`${base}/billing/env_dev/subscriptions/env_dev`, "env_dev", "env_prod"), - ).toBe(`${base}/billing/env_prod/subscriptions/env_dev`) - }) + switchEnvironmentPath( + `${base}/env/env_dev/billing/subscriptions/env_dev`, + "env_dev", + "env_prod" + ) + ).toBe(`${base}/env/env_prod/billing/subscriptions/env_dev`); + }); it("refuses an empty Environment id rather than building a path with an empty segment", () => { - expect(switchEnvironmentPath(`${base}/analytics/env_dev/funnel`, "env_dev", "")).toBeNull() - expect(switchEnvironmentPath(`${base}/analytics//funnel`, "", "env_prod")).toBeNull() - }) -}) + expect( + switchEnvironmentPath(`${base}/analytics/env_dev/funnel`, "env_dev", "") + ).toBeNull(); + expect( + switchEnvironmentPath(`${base}/analytics//funnel`, "", "env_prod") + ).toBeNull(); + }); +}); diff --git a/apps/dashboard/src/features/environments/types/environment-path.ts b/apps/dashboard/src/features/environments/types/environment-path.ts index a6b6ed58..f6756144 100644 --- a/apps/dashboard/src/features/environments/types/environment-path.ts +++ b/apps/dashboard/src/features/environments/types/environment-path.ts @@ -2,7 +2,7 @@ // `env`, carrying a readable alias rather than an id. // // /orgs/O/projects/P/env/prod/billing/quarantine/rec_01 -const ENVIRONMENT_SEGMENT = "env" +const ENVIRONMENT_SEGMENT = "env"; /** * Rewrites the Environment alias in place, so switching keeps the operator on the @@ -16,15 +16,19 @@ const ENVIRONMENT_SEGMENT = "env" export function switchEnvironmentPath( pathname: string, currentAlias: string, - nextAlias: string, + nextAlias: string ): string | null { - if (currentAlias === "" || nextAlias === "") return null + if (currentAlias === "" || nextAlias === "") { + return null; + } - const segments = pathname.split("/") - const index = segments.indexOf(ENVIRONMENT_SEGMENT) - if (index === -1 || segments[index + 1] !== currentAlias) return null + const segments = pathname.split("/"); + const index = segments.indexOf(ENVIRONMENT_SEGMENT); + if (index === -1 || segments[index + 1] !== currentAlias) { + return null; + } - const rewritten = [...segments] - rewritten[index + 1] = nextAlias - return rewritten.join("/") + const rewritten = [...segments]; + rewritten[index + 1] = nextAlias; + return rewritten.join("/"); } diff --git a/apps/dashboard/src/features/experiments/api/experiment-adapter-provider.tsx b/apps/dashboard/src/features/experiments/api/experiment-adapter-provider.tsx index 1c0827d9..fcb67f74 100644 --- a/apps/dashboard/src/features/experiments/api/experiment-adapter-provider.tsx +++ b/apps/dashboard/src/features/experiments/api/experiment-adapter-provider.tsx @@ -1,18 +1,18 @@ -import type { ReactNode } from "react" +import type { ReactNode } from "react"; -import type { ExperimentAdapter } from "./experiment-adapter" -import { ExperimentAdapterContext } from "./use-experiment-adapter" +import type { ExperimentAdapter } from "./experiment-adapter"; +import { ExperimentAdapterContext } from "./use-experiment-adapter"; export function ExperimentAdapterProvider({ adapter, children, }: { - adapter: ExperimentAdapter - children: ReactNode + adapter: ExperimentAdapter; + children: ReactNode; }) { return ( {children} - ) + ); } diff --git a/apps/dashboard/src/features/experiments/api/experiment-adapter.ts b/apps/dashboard/src/features/experiments/api/experiment-adapter.ts index 183e751e..9df84844 100644 --- a/apps/dashboard/src/features/experiments/api/experiment-adapter.ts +++ b/apps/dashboard/src/features/experiments/api/experiment-adapter.ts @@ -1,4 +1,5 @@ import type { + CreateMutualExclusionGroupInput, ExperimentDetail, ExperimentDraft, ExperimentDraftDocument, @@ -9,105 +10,139 @@ import type { ExperimentScope, ExperimentStatus, ExperimentValidation, - CreateMutualExclusionGroupInput, ImmutablePaywallVersionOption, MetricDefinitionOption, MutualExclusionGroupOption, MutualExclusionGroupVersion, QaOverride, QaOverrideCreated, -} from "../types/experiment" +} from "../types/experiment"; export interface ExperimentAdapter { - archive(scope: ExperimentScope, experimentId: string, reason: string): Promise - complete(scope: ExperimentScope, experimentId: string, reason: string): Promise - create( + archive: ( + scope: ExperimentScope, + experimentId: string, + reason: string + ) => Promise; + complete: ( scope: ExperimentScope, - input: { hypothesis?: string; name: string; placementId: string }, - ): Promise - createMutualExclusionGroup( + experimentId: string, + reason: string + ) => Promise; + create: ( + scope: ExperimentScope, + input: { hypothesis?: string; name: string; placementId: string } + ) => Promise; + createMutualExclusionGroup: ( scope: ExperimentScope, - input: CreateMutualExclusionGroupInput, - ): Promise - createMutualExclusionGroupVersion( + input: CreateMutualExclusionGroupInput + ) => Promise; + createMutualExclusionGroupVersion: ( scope: ExperimentScope, groupId: string, - input: Omit, - ): Promise - createQaOverride( + input: Omit + ) => Promise; + createQaOverride: ( scope: ExperimentScope, experimentId: string, input: { - expiresAt: string - experimentVersionId: string - identityType: QaOverride["identityType"] - label: string - variantId: string - }, - ): Promise - deleteQaOverride(scope: ExperimentScope, experimentId: string, overrideId: string): Promise - emergencyStop( + expiresAt: string; + experimentVersionId: string; + identityType: QaOverride["identityType"]; + label: string; + variantId: string; + } + ) => Promise; + deleteQaOverride: ( scope: ExperimentScope, experimentId: string, - reason: string, - ): Promise - get(scope: ExperimentScope, experimentId: string): Promise - history(scope: ExperimentScope, experimentId: string): Promise - list(scope: ExperimentScope): Promise - listImmutablePaywallVersions( - scope: ExperimentScope, - ): Promise - listMetricDefinitions(scope: ExperimentScope): Promise - listMutualExclusionGroups(scope: ExperimentScope): Promise - listMutualExclusionGroupVersions( + overrideId: string + ) => Promise; + emergencyStop: ( scope: ExperimentScope, - groupId: string, - ): Promise - listQaOverrides(scope: ExperimentScope, experimentId: string): Promise - publish( + experimentId: string, + reason: string + ) => Promise; + get: ( + scope: ExperimentScope, + experimentId: string + ) => Promise; + history: ( + scope: ExperimentScope, + experimentId: string + ) => Promise; + list: (scope: ExperimentScope) => Promise; + listImmutablePaywallVersions: ( + scope: ExperimentScope + ) => Promise; + listMetricDefinitions: ( + scope: ExperimentScope + ) => Promise; + listMutualExclusionGroups: ( + scope: ExperimentScope + ) => Promise; + listMutualExclusionGroupVersions: ( + scope: ExperimentScope, + groupId: string + ) => Promise; + listQaOverrides: ( + scope: ExperimentScope, + experimentId: string + ) => Promise; + publish: ( scope: ExperimentScope, experimentId: string, - expectedRevision: number, - ): Promise - requestExport( + expectedRevision: number + ) => Promise; + requestExport: ( scope: ExperimentScope, experimentId: string, - identityScoped: boolean, - ): Promise - results(scope: ExperimentScope, experimentId: string): Promise - saveDraft( + identityScoped: boolean + ) => Promise; + results: ( + scope: ExperimentScope, + experimentId: string + ) => Promise; + saveDraft: ( scope: ExperimentScope, experimentId: string, document: ExperimentDraftDocument, - expectedRevision: number, - ): Promise - transition( + expectedRevision: number + ) => Promise; + transition: ( scope: ExperimentScope, experimentId: string, target: ExperimentStatus, - reason: string, - ): Promise - validate(scope: ExperimentScope, experimentId: string): Promise + reason: string + ) => Promise; + validate: ( + scope: ExperimentScope, + experimentId: string + ) => Promise; } export class ExperimentContractPendingError extends Error { constructor() { - super("Experiment management is unavailable until the Phase 7 REST contract is deployed.") - this.name = "ExperimentContractPendingError" + super( + "Experiment management is unavailable until the Phase 7 REST contract is deployed." + ); + this.name = "ExperimentContractPendingError"; } } export class ExperimentDraftConflictError extends Error { - constructor( - readonly currentRevision: number, - readonly currentDraft?: ExperimentDraft, - ) { - super(`The server has a newer Draft revision (${currentRevision}).`) - this.name = "ExperimentDraftConflictError" + readonly currentRevision: number; + readonly currentDraft?: ExperimentDraft; + + constructor(currentRevision: number, currentDraft?: ExperimentDraft) { + super(`The server has a newer Draft revision (${currentRevision}).`); + this.name = "ExperimentDraftConflictError"; + this.currentRevision = currentRevision; + this.currentDraft = currentDraft; } } -const pending = () => Promise.reject(new ExperimentContractPendingError()) +const pending = () => Promise.reject(new ExperimentContractPendingError()); export const CONTRACT_PENDING_EXPERIMENT_ADAPTER: ExperimentAdapter = { archive: pending, @@ -132,4 +167,4 @@ export const CONTRACT_PENDING_EXPERIMENT_ADAPTER: ExperimentAdapter = { saveDraft: pending, transition: pending, validate: pending, -} +}; diff --git a/apps/dashboard/src/features/experiments/api/generated-experiment-adapter.test.ts b/apps/dashboard/src/features/experiments/api/generated-experiment-adapter.test.ts index b9f83718..149ac800 100644 --- a/apps/dashboard/src/features/experiments/api/generated-experiment-adapter.test.ts +++ b/apps/dashboard/src/features/experiments/api/generated-experiment-adapter.test.ts @@ -1,17 +1,17 @@ -import { describe, expect, it, vi } from "vitest" +import { describe, expect, it, vi } from "vitest"; +import { createGeneratedDashboardClient } from "@/lib/api/generated-dashboard-client"; +import { required } from "@/test/required"; +import type { ExperimentDraftDocument } from "../types/experiment"; +import { ExperimentDraftConflictError } from "./experiment-adapter"; +import { createGeneratedExperimentAdapter } from "./generated-experiment-adapter"; -import { createGeneratedDashboardClient } from "@/lib/api/generated-dashboard-client" -import { ExperimentDraftConflictError } from "./experiment-adapter" -import { createGeneratedExperimentAdapter } from "./generated-experiment-adapter" -import type { ExperimentDraftDocument } from "../types/experiment" - -const scope = { environmentId: "env-staging", projectId: "project" } +const scope = { environmentId: "env-staging", projectId: "project" }; function response(data: unknown, status = 200) { return new Response(JSON.stringify({ data }), { headers: { "Content-Type": "application/json" }, status, - }) + }); } function experiment(revision: number) { @@ -26,14 +26,14 @@ function experiment(revision: number) { schedule: { startsAt: "2026-07-27T00:00:00Z" }, variants: [ { - allocationBasisPoints: 5_000, + allocationBasisPoints: 5000, name: "Control", paywallId: "paywall-control", paywallVersionId: "version-control", role: "control", }, { - allocationBasisPoints: 5_000, + allocationBasisPoints: 5000, name: "Treatment A", paywallId: "paywall-treatment", paywallVersionId: "version-treatment", @@ -56,11 +56,11 @@ function experiment(revision: number) { role: "admin", state: "draft", updatedAt: "2026-07-26T01:00:00Z", - } + }; } function draftDocument(): ExperimentDraftDocument { - const document = experiment(7).currentDraft.document + const { document } = experiment(7).currentDraft; return { assignmentKeyPolicy: "identified_user", guardrailMetricVersionIds: document.guardrailMetricVersionIds, @@ -70,100 +70,114 @@ function draftDocument(): ExperimentDraftDocument { ...variant, role: variant.role === "control" ? "control" : "treatment", })), - } + }; } describe("generated Experiment adapter", () => { it("creates a subsequent mutual-exclusion Version without mutating the group root", async () => { - let request: Request | undefined - const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => { - request = input as Request - return response( - { - assignmentKeyPolicy: "identified_user", - bucketingAlgorithm: "experiment_sha256_length_prefixed_v1", - createdAt: "2026-07-26T01:00:00Z", - groupId: "group", - holdoutBasisPoints: 1_000, - id: "group-version-2", - members: [ - { allocationBasisPoints: 4_500, experimentId: "experiment-a" }, - { allocationBasisPoints: 4_500, experimentId: "experiment-b" }, - ], - versionNumber: 2, - }, - 201, - ) - }) as typeof fetch + let request: Request | undefined; + const fetchImplementation = vi.fn((input: RequestInfo | URL) => { + request = input as Request; + return Promise.resolve( + response( + { + assignmentKeyPolicy: "identified_user", + bucketingAlgorithm: "experiment_sha256_length_prefixed_v1", + createdAt: "2026-07-26T01:00:00Z", + groupId: "group", + holdoutBasisPoints: 1000, + id: "group-version-2", + members: [ + { allocationBasisPoints: 4500, experimentId: "experiment-a" }, + { allocationBasisPoints: 4500, experimentId: "experiment-b" }, + ], + versionNumber: 2, + }, + 201 + ) + ); + }) as typeof fetch; const adapter = createGeneratedExperimentAdapter( - createGeneratedDashboardClient(fetchImplementation), - ) + createGeneratedDashboardClient(fetchImplementation) + ); - const version = await adapter.createMutualExclusionGroupVersion(scope, "group", { - assignmentKeyPolicy: "identified_user", - holdoutBasisPoints: 1_000, - members: [ - { allocationBasisPoints: 4_500, experimentId: "experiment-a" }, - { allocationBasisPoints: 4_500, experimentId: "experiment-b" }, - ], - }) + const version = await adapter.createMutualExclusionGroupVersion( + scope, + "group", + { + assignmentKeyPolicy: "identified_user", + holdoutBasisPoints: 1000, + members: [ + { allocationBasisPoints: 4500, experimentId: "experiment-a" }, + { allocationBasisPoints: 4500, experimentId: "experiment-b" }, + ], + } + ); - expect(version).toMatchObject({ id: "group-version-2", versionNumber: 2 }) - expect(new URL(request!.url).pathname).toBe( - "/v1/projects/project/environments/env-staging/experiments/groups/group/versions", - ) - expect(await request!.clone().json()).toMatchObject({ - holdoutBasisPoints: 1_000, + expect(version).toMatchObject({ id: "group-version-2", versionNumber: 2 }); + expect(new URL(required(request, "request").url).pathname).toBe( + "/v1/projects/project/environments/env-staging/experiments/groups/group/versions" + ); + expect(await required(request, "request").clone().json()).toMatchObject({ + holdoutBasisPoints: 1000, members: [ - { allocationBasisPoints: 4_500, experimentId: "experiment-a" }, - { allocationBasisPoints: 4_500, experimentId: "experiment-b" }, + { allocationBasisPoints: 4500, experimentId: "experiment-a" }, + { allocationBasisPoints: 4500, experimentId: "experiment-b" }, ], - }) - }) + }); + }); it("preserves unsaved input by stopping before PUT when the server Draft is newer", async () => { - const requests: Request[] = [] - const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => { - requests.push(input as Request) - return response(experiment(8)) - }) as typeof fetch + const requests: Request[] = []; + const fetchImplementation = vi.fn((input: RequestInfo | URL) => { + requests.push(input as Request); + return Promise.resolve(response(experiment(8))); + }) as typeof fetch; const adapter = createGeneratedExperimentAdapter( - createGeneratedDashboardClient(fetchImplementation), - ) + createGeneratedDashboardClient(fetchImplementation) + ); const error = await adapter .saveDraft(scope, "experiment", draftDocument(), 7) - .catch((caught: unknown) => caught) + .catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(ExperimentDraftConflictError) - expect(error).toMatchObject({ currentRevision: 8 }) - expect(requests).toHaveLength(1) - expect(requests[0]?.method).toBe("GET") - }) + expect(error).toBeInstanceOf(ExperimentDraftConflictError); + expect(error).toMatchObject({ currentRevision: 8 }); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("GET"); + }); it("sends the strong Draft precondition and exact 10,000-bucket allocation", async () => { - const requests: Request[] = [] + const requests: Request[] = []; const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => { - const request = input as Request - requests.push(request) - if (request.method === "GET") return response(experiment(7)) - const body = (await request.clone().json()) as { document: unknown } - return response({ ...experiment(8).currentDraft, document: body.document }) - }) as typeof fetch + const request = input as Request; + requests.push(request); + if (request.method === "GET") { + return response(experiment(7)); + } + const body = (await request.clone().json()) as { document: unknown }; + return response({ + ...experiment(8).currentDraft, + document: body.document, + }); + }) as typeof fetch; const adapter = createGeneratedExperimentAdapter( - createGeneratedDashboardClient(fetchImplementation), - ) + createGeneratedDashboardClient(fetchImplementation) + ); - await adapter.saveDraft(scope, "experiment", draftDocument(), 7) + await adapter.saveDraft(scope, "experiment", draftDocument(), 7); - const update = requests[1]! - expect(update.headers.get("If-Match")).toBe('"experiment-draft:draft:7"') - expect(update.headers.get("Idempotency-Key")).toBeTruthy() + const update = required(requests[1], "requests[1]"); + expect(update.headers.get("If-Match")).toBe('"experiment-draft:draft:7"'); + expect(update.headers.get("Idempotency-Key")).toBeTruthy(); const body = (await update.clone().json()) as { - document: { variants: Array<{ allocationBasisPoints: number }> } - } - expect(body.document.variants.reduce((sum, item) => sum + item.allocationBasisPoints, 0)).toBe( - 10_000, - ) - }) -}) + document: { variants: Array<{ allocationBasisPoints: number }> }; + }; + expect( + body.document.variants.reduce( + (sum, item) => sum + item.allocationBasisPoints, + 0 + ) + ).toBe(10_000); + }); +}); diff --git a/apps/dashboard/src/features/experiments/api/generated-experiment-adapter.ts b/apps/dashboard/src/features/experiments/api/generated-experiment-adapter.ts index fecd8b26..174bd792 100644 --- a/apps/dashboard/src/features/experiments/api/generated-experiment-adapter.ts +++ b/apps/dashboard/src/features/experiments/api/generated-experiment-adapter.ts @@ -1,3 +1,9 @@ +import type { + Experiment as GeneratedExperiment, + ExperimentDraft as GeneratedExperimentDraft, + ExperimentQaOverride as GeneratedQaOverride, + ExperimentValidationIssue as GeneratedValidationIssue, +} from "@/generated/api"; import { createExperiment, createExperimentGroupVersion, @@ -20,18 +26,10 @@ import { transitionExperimentLifecycle, updateExperimentDraft, validateExperimentDraft, -} from "@/generated/api" -import type { - Experiment as GeneratedExperiment, - ExperimentDraft as GeneratedExperimentDraft, - ExperimentQaOverride as GeneratedQaOverride, - ExperimentValidationIssue as GeneratedValidationIssue, -} from "@/generated/api" -import type { Client } from "@/generated/api/client" -import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" -import { ApiError } from "@/lib/api/errors" - -import { ExperimentDraftConflictError, type ExperimentAdapter } from "./experiment-adapter" +} from "@/generated/api"; +import type { Client } from "@/generated/api/client"; +import { ApiError } from "@/lib/api/errors"; +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client"; import type { ExperimentDetail, ExperimentDraft, @@ -40,19 +38,25 @@ import type { ExperimentListItem, GuardrailResult, QaOverride, -} from "../types/experiment" +} from "../types/experiment"; +import { + type ExperimentAdapter, + ExperimentDraftConflictError, +} from "./experiment-adapter"; + +const MATURITY_WARNING = /window|matur/i; function createIdempotencyKey() { return typeof globalThis.crypto?.randomUUID === "function" ? globalThis.crypto.randomUUID() - : `mosaic-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + : `mosaic-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; } -const draftIdempotencyKeys = new Map() -const createIdempotencyKeys = new Map() +const draftIdempotencyKeys = new Map(); +const createIdempotencyKeys = new Map(); function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) + return typeof value === "object" && value !== null && !Array.isArray(value); } function mapDraft(draft: GeneratedExperimentDraft): ExperimentDraft { @@ -68,18 +72,19 @@ function mapDraft(draft: GeneratedExperimentDraft): ExperimentDraft { startsAt: draft.document.schedule.startsAt, updatedAt: draft.updatedAt, variants: draft.document.variants, - } + }; } function mapExperiment( experiment: GeneratedExperiment, - placementNames: ReadonlyMap = new Map(), + placementNames: ReadonlyMap = new Map() ): ExperimentDetail { - const activeVersion = experiment.activeVersion + const { activeVersion } = experiment; const matchingDraft = - activeVersion && experiment.currentDraft?.revision === activeVersion.sourceRevision + activeVersion && + experiment.currentDraft?.revision === activeVersion.sourceRevision ? experiment.currentDraft - : undefined + : undefined; return { activeDefinition: activeVersion ? { @@ -88,14 +93,16 @@ function mapExperiment( bucketingAlgorithm: activeVersion.bucketingAlgorithm, endsAt: activeVersion.schedule.endsAt, guardrailMetricVersionIds: activeVersion.guardrailMetricVersionIds, - mutualExclusionGroupVersionId: activeVersion.mutualExclusionGroupVersionId, + mutualExclusionGroupVersionId: + activeVersion.mutualExclusionGroupVersionId, primaryMetricVersionId: activeVersion.primaryMetricVersionId, publishedAt: activeVersion.publishedAt, qaPolicyEnabled: matchingDraft?.document.qaPolicy.enabled, sourceRevision: activeVersion.sourceRevision, startsAt: activeVersion.schedule.startsAt, variants: activeVersion.variants.map((variant) => ({ - allocationBasisPoints: variant.allocationEnd - variant.allocationStart, + allocationBasisPoints: + variant.allocationEnd - variant.allocationStart, id: variant.id, name: variant.name, paywallId: variant.paywallId, @@ -114,15 +121,18 @@ function mapExperiment( })), activeVersionId: experiment.activeVersion?.id, activeVersionNumber: experiment.activeVersion?.versionNumber, - currentDraft: experiment.currentDraft ? mapDraft(experiment.currentDraft) : undefined, + currentDraft: experiment.currentDraft + ? mapDraft(experiment.currentDraft) + : undefined, hypothesis: experiment.hypothesis, id: experiment.id, name: experiment.name, placementId: experiment.placementId, - placementName: placementNames.get(experiment.placementId) ?? experiment.placementId, + placementName: + placementNames.get(experiment.placementId) ?? experiment.placementId, status: experiment.state, updatedAt: experiment.updatedAt, - } + }; } function mapIssue(issue: GeneratedValidationIssue): ExperimentIssue { @@ -134,18 +144,21 @@ function mapIssue(issue: GeneratedValidationIssue): ExperimentIssue { recoveryAction: issue.recoveryAction, severity: issue.severity, title: issue.code.replaceAll("_", " "), - } + }; } function mapQaOverride(override: GeneratedQaOverride): QaOverride { return { expiresAt: override.expiresAt, id: override.id, - identityType: override.identityType === "installation" ? "installation" : "identified_user", + identityType: + override.identityType === "installation" + ? "installation" + : "identified_user", label: override.safeLabel, variantId: override.variantId, visibleSelectorDigest: override.selectorDigest ?? "Pending", - } + }; } function generatedDocument(document: ExperimentDraftDocument) { @@ -157,21 +170,25 @@ function generatedDocument(document: ExperimentDraftDocument) { qaPolicy: { enabled: document.qaEnabled ?? true }, schedule: { endsAt: document.endsAt, startsAt: document.startsAt }, variants: document.variants.map((variant) => ({ ...variant })), - } + }; } function conflictFrom(error: unknown) { - if (!(error instanceof ApiError) || error.status !== 409) return null - const details = isRecord(error.details) ? error.details : undefined - const currentRevision = details?.currentRevision - if (typeof currentRevision !== "number") return null - return new ExperimentDraftConflictError(currentRevision) + if (!(error instanceof ApiError) || error.status !== 409) { + return null; + } + const details = isRecord(error.details) ? error.details : undefined; + const currentRevision = details?.currentRevision; + if (typeof currentRevision !== "number") { + return null; + } + return new ExperimentDraftConflictError(currentRevision); } function warningIssue( code: string, message: string, - severity: ExperimentIssue["severity"] = "warning", + severity: ExperimentIssue["severity"] = "warning" ): ExperimentIssue { return { code, @@ -183,29 +200,35 @@ function warningIssue( "Resolve the underlying issue and wait for fresh aggregates. Do not change active allocation in place.", severity, title: code.replaceAll("_", " "), - } + }; } function stringValue(record: Record, key: string) { - return typeof record[key] === "string" ? record[key] : undefined + return typeof record[key] === "string" ? record[key] : undefined; } function mapResultWarning(warning: unknown, index: number): ExperimentIssue { if (!isRecord(warning)) { - return warningIssue(`result_warning_${index + 1}`, String(warning)) + return warningIssue(`result_warning_${index + 1}`, String(warning)); } - const code = stringValue(warning, "code") ?? `result_warning_${index + 1}` + const code = stringValue(warning, "code") ?? `result_warning_${index + 1}`; const mapped = warningIssue( code, - stringValue(warning, "message") ?? stringValue(warning, "summary") ?? "Result warning", - warning.severity === "critical" || warning.severity === "info" ? warning.severity : "warning", - ) + stringValue(warning, "message") ?? + stringValue(warning, "summary") ?? + "Result warning", + warning.severity === "critical" || warning.severity === "info" + ? warning.severity + : "warning" + ); return { ...mapped, - investigation: stringValue(warning, "investigation") ?? mapped.investigation, - recoveryAction: stringValue(warning, "recoveryAction") ?? mapped.recoveryAction, + investigation: + stringValue(warning, "investigation") ?? mapped.investigation, + recoveryAction: + stringValue(warning, "recoveryAction") ?? mapped.recoveryAction, title: stringValue(warning, "title") ?? mapped.title, - } + }; } function mapGuardrailResult(guardrail: unknown): GuardrailResult { @@ -214,51 +237,58 @@ function mapGuardrailResult(guardrail: unknown): GuardrailResult { name: String(guardrail), severity: "warning" as const, summary: String(guardrail), - } + }; } - const severity = guardrail.severity + const { severity } = guardrail; const mappedSeverity: GuardrailResult["severity"] = severity === "ok" || severity === "critical" || severity === "unavailable" || severity === "warning" ? severity - : "warning" + : "warning"; return { code: stringValue(guardrail, "code"), - estimate: typeof guardrail.estimate === "number" ? guardrail.estimate : undefined, + estimate: + typeof guardrail.estimate === "number" ? guardrail.estimate : undefined, investigation: stringValue(guardrail, "investigation"), - name: stringValue(guardrail, "name") ?? stringValue(guardrail, "title") ?? "Guardrail", + name: + stringValue(guardrail, "name") ?? + stringValue(guardrail, "title") ?? + "Guardrail", recoveryAction: stringValue(guardrail, "recoveryAction"), severity: mappedSeverity, summary: - stringValue(guardrail, "summary") ?? stringValue(guardrail, "message") ?? "Unavailable", - } + stringValue(guardrail, "summary") ?? + stringValue(guardrail, "message") ?? + "Unavailable", + }; } export function createGeneratedExperimentAdapter( - client: Client = generatedDashboardClient, + client: Client = generatedDashboardClient ): ExperimentAdapter { return { - async archive(scope, experimentId, reason) { - return this.transition(scope, experimentId, "archived", reason) + archive(scope, experimentId, reason) { + return this.transition(scope, experimentId, "archived", reason); }, - async complete(scope, experimentId, reason) { - return this.transition(scope, experimentId, "completed", reason) + complete(scope, experimentId, reason) { + return this.transition(scope, experimentId, "completed", reason); }, async create(scope, input) { - const requestScope = `${scope.projectId}:${scope.environmentId}:${input.placementId}:${input.name}` - const key = createIdempotencyKeys.get(requestScope) ?? createIdempotencyKey() - createIdempotencyKeys.set(requestScope, key) + const requestScope = `${scope.projectId}:${scope.environmentId}:${input.placementId}:${input.name}`; + const key = + createIdempotencyKeys.get(requestScope) ?? createIdempotencyKey(); + createIdempotencyKeys.set(requestScope, key); const result = await createExperiment({ body: input, client, headers: { "Idempotency-Key": key }, path: scope, throwOnError: true, - }) - createIdempotencyKeys.delete(requestScope) - return mapExperiment(result.data.data) + }); + createIdempotencyKeys.delete(requestScope); + return mapExperiment(result.data.data); }, async createMutualExclusionGroup(scope, input) { const result = await createExperimentGroupVersion({ @@ -266,16 +296,17 @@ export function createGeneratedExperimentAdapter( client, path: scope, throwOnError: true, - }) - const { group, version } = result.data.data + }); + const { group, version } = result.data.data; return { - assignmentKeyPolicy: version.assignmentKeyPolicy as typeof input.assignmentKeyPolicy, + assignmentKeyPolicy: + version.assignmentKeyPolicy as typeof input.assignmentKeyPolicy, holdoutBasisPoints: version.holdoutBasisPoints, id: group.id, members: version.members, name: group.name, versionId: version.id, - } + }; }, async createMutualExclusionGroupVersion(scope, groupId, input) { const result = await createExperimentMutualExclusionGroupVersion({ @@ -283,17 +314,18 @@ export function createGeneratedExperimentAdapter( client, path: { ...scope, groupId }, throwOnError: true, - }) - const version = result.data.data + }); + const version = result.data.data; return { - assignmentKeyPolicy: version.assignmentKeyPolicy as typeof input.assignmentKeyPolicy, + assignmentKeyPolicy: + version.assignmentKeyPolicy as typeof input.assignmentKeyPolicy, createdAt: version.createdAt, groupId: version.groupId, holdoutBasisPoints: version.holdoutBasisPoints, id: version.id, members: version.members, versionNumber: version.versionNumber, - } + }; }, async createQaOverride(scope, experimentId, input) { const result = await createExperimentQaOverride({ @@ -307,15 +339,18 @@ export function createGeneratedExperimentAdapter( client, path: { ...scope, experimentId }, throwOnError: true, - }) - return { override: mapQaOverride(result.data.data.override), token: result.data.data.token } + }); + return { + override: mapQaOverride(result.data.data.override), + token: result.data.data.token, + }; }, async deleteQaOverride(scope, experimentId, overrideId) { await revokeExperimentQaOverride({ client, path: { ...scope, experimentId, overrideId }, throwOnError: true, - }) + }); }, async emergencyStop(scope, experimentId, reason) { const result = await transitionExperimentLifecycle({ @@ -323,8 +358,8 @@ export function createGeneratedExperimentAdapter( client, path: { ...scope, experimentId, lifecycleAction: "emergency-stop" }, throwOnError: true, - }) - return mapExperiment(result.data.data) + }); + return mapExperiment(result.data.data); }, async get(scope, experimentId) { const [experimentResult, placementsResult] = await Promise.all([ @@ -338,18 +373,21 @@ export function createGeneratedExperimentAdapter( path: { projectId: scope.projectId }, throwOnError: true, }), - ]) + ]); const names = new Map( - placementsResult.data.data.items.map((placement) => [placement.id, placement.name]), - ) - return mapExperiment(experimentResult.data.data, names) + placementsResult.data.data.items.map((placement) => [ + placement.id, + placement.name, + ]) + ); + return mapExperiment(experimentResult.data.data, names); }, async history(scope, experimentId) { const result = await listExperimentHistory({ client, path: { ...scope, experimentId }, throwOnError: true, - }) + }); return result.data.data.items.map((entry) => ({ actorLabel: entry.actorId, createdAt: entry.createdAt, @@ -357,7 +395,7 @@ export function createGeneratedExperimentAdapter( reason: entry.reason, releaseId: entry.releaseId, summary: `${entry.fromState} → ${entry.toState}`, - })) + })); }, async list(scope) { const [experimentsResult, placementsResult] = await Promise.all([ @@ -367,20 +405,23 @@ export function createGeneratedExperimentAdapter( path: { projectId: scope.projectId }, throwOnError: true, }), - ]) + ]); const names = new Map( - placementsResult.data.data.items.map((placement) => [placement.id, placement.name]), - ) - return experimentsResult.data.data.items.map((experiment): ExperimentListItem => - mapExperiment(experiment, names), - ) + placementsResult.data.data.items.map((placement) => [ + placement.id, + placement.name, + ]) + ); + return experimentsResult.data.data.items.map( + (experiment): ExperimentListItem => mapExperiment(experiment, names) + ); }, async listImmutablePaywallVersions(scope) { const paywallResult = await listPaywalls({ client, path: { projectId: scope.projectId }, throwOnError: true, - }) + }); const versions = await Promise.all( paywallResult.data.data.items .filter((paywall) => paywall.status === "active") @@ -389,30 +430,34 @@ export function createGeneratedExperimentAdapter( client, path: { paywallId: paywall.id, projectId: scope.projectId }, throwOnError: true, - }) + }); return result.data.data.items - .filter((version) => version.environmentId === scope.environmentId) + .filter( + (version) => version.environmentId === scope.environmentId + ) .map((version) => ({ createdAt: version.createdAt, id: version.id, paywallId: paywall.id, paywallName: paywall.name, versionNumber: version.versionNumber, - })) - }), - ) - return versions.flat() + })); + }) + ); + return versions.flat(); }, async listMetricDefinitions(scope) { const result = await listExperimentMetricDefinitions({ client, path: scope, throwOnError: true, - }) + }); return result.data.data.items.map((metric) => ({ assignmentUnit: metric.assignmentUnit, authority: - metric.authority === "provider_confirmed" ? "provider_confirmed" : "client_observed", + metric.authority === "provider_confirmed" + ? "provider_confirmed" + : "client_observed", availability: metric.availability, definition: metric.definition, eligibleAsGuardrail: metric.guardrailEligible, @@ -421,24 +466,32 @@ export function createGeneratedExperimentAdapter( id: metric.id, name: metric.name, versionId: `${metric.id}@${metric.version}`, - })) + })); }, async listMutualExclusionGroups(scope) { const result = await listExperimentGroups({ client, path: scope, throwOnError: true, - }) - return result.data.data.items - .filter((group) => group.status === "active" && group.activeVersionId) - .map((group) => ({ id: group.id, name: group.name, versionId: group.activeVersionId! })) + }); + return result.data.data.items.flatMap((group) => + group.status === "active" && group.activeVersionId + ? [ + { + id: group.id, + name: group.name, + versionId: group.activeVersionId, + }, + ] + : [] + ); }, async listMutualExclusionGroupVersions(scope, groupId) { const result = await listExperimentMutualExclusionGroupVersions({ client, path: { ...scope, groupId }, throwOnError: true, - }) + }); return result.data.data.items.map((version) => ({ assignmentKeyPolicy: version.assignmentKeyPolicy === "installation" || @@ -451,17 +504,17 @@ export function createGeneratedExperimentAdapter( id: version.id, members: version.members, versionNumber: version.versionNumber, - })) + })); }, async listQaOverrides(scope, experimentId) { const result = await listExperimentQaOverrides({ client, path: { ...scope, experimentId }, throwOnError: true, - }) + }); return result.data.data.items .filter((override) => override.status === "active") - .map(mapQaOverride) + .map(mapQaOverride); }, async publish(scope, experimentId, expectedRevision) { await publishExperiment({ @@ -469,8 +522,8 @@ export function createGeneratedExperimentAdapter( client, path: { ...scope, experimentId }, throwOnError: true, - }) - return this.get(scope, experimentId) + }); + return this.get(scope, experimentId); }, async requestExport(scope, experimentId, identityScoped) { const result = await createExperimentRawExport({ @@ -478,14 +531,17 @@ export function createGeneratedExperimentAdapter( client, path: { ...scope, experimentId }, throwOnError: true, - }) - const job = result.data.data + }); + const job = result.data.data; return { expiresAt: job.expiresAt, id: job.id, identityScoped, - status: job.status === "leased" || job.status === "recomputing" ? "running" : job.status, - } + status: + job.status === "leased" || job.status === "recomputing" + ? "running" + : job.status, + }; }, async results(scope, experimentId) { const [result, detail, metrics] = await Promise.all([ @@ -504,46 +560,65 @@ export function createGeneratedExperimentAdapter( path: scope, throwOnError: true, }), - ]) - const data = result.data.data + ]); + const { data } = result.data; const names = new Map( - detail.data.data.activeVersion?.variants.map((variant) => [variant.id, variant.name]) ?? [], - ) - const warnings = (data.warnings as readonly unknown[]).map(mapResultWarning) + detail.data.data.activeVersion?.variants.map((variant) => [ + variant.id, + variant.name, + ]) ?? [] + ); + const warnings = (data.warnings as readonly unknown[]).map( + mapResultWarning + ); if (data.srm.status === "mismatch") { warnings.unshift({ ...warningIssue( "sample_ratio_mismatch", data.srm.explanation, - data.srm.severity === "critical" ? "critical" : "warning", + data.srm.severity === "critical" ? "critical" : "warning" ), investigation: data.srm.investigationSteps.join(" "), - }) + }); } else if (data.srm.status === "insufficient_sample") { - warnings.unshift(warningIssue("srm_insufficient_sample", data.srm.explanation, "info")) + warnings.unshift( + warningIssue("srm_insufficient_sample", data.srm.explanation, "info") + ); } const freshnessMinutes = data.freshness - ? Math.max(0, Math.floor((Date.now() - new Date(data.freshness).getTime()) / 60_000)) - : undefined - const primaryId = detail.data.data.activeVersion?.primaryMetricVersionId + ? Math.max( + 0, + Math.floor( + (Date.now() - new Date(data.freshness).getTime()) / 60_000 + ) + ) + : undefined; + const primaryId = detail.data.data.activeVersion?.primaryMetricVersionId; const primary = metrics.data.data.items.find( - (metric) => `${metric.id}@${metric.version}` === primaryId, - ) + (metric) => `${metric.id}@${metric.version}` === primaryId + ); return { aggregateUpdatedAt: data.freshness, - attributionWindowMature: !data.warnings.some((warning) => /window|matur/i.test(warning)), + attributionWindowMature: !data.warnings.some((warning) => + MATURITY_WARNING.test(warning) + ), fallbackExposures: data.variants.reduce( (total, variant) => total + variant.fallbackPresentations, - 0, + 0 ), freshnessMinutes, - guardrails: (data.guardrails as readonly unknown[]).map(mapGuardrailResult), + guardrails: (data.guardrails as readonly unknown[]).map( + mapGuardrailResult + ), interim: data.interim, issues: warnings, primaryMetricName: primary?.name ?? primaryId ?? "Primary metric", primaryMetricAuthority: - primary?.authority === "provider_confirmed" ? "provider_confirmed" : "client_observed", - primaryMetricAvailability: primary?.availability ?? "trusted_source_unavailable", + primary?.authority === "provider_confirmed" + ? "provider_confirmed" + : "client_observed", + primaryMetricAvailability: + primary?.availability ?? "trusted_source_unavailable", primaryMetricEventFilter: primary?.eventFilter ?? {}, srm: data.srm, treatments: data.lifts.map((lift) => ({ @@ -556,90 +631,118 @@ export function createGeneratedExperimentAdapter( allocationBasisPoints: variant.allocationBasisPoints, conversions: variant.uniqueConversions, estimate: variant.estimate, - interval: { high: variant.wilson95.upper, low: variant.wilson95.lower }, + interval: { + high: variant.wilson95.upper, + low: variant.wilson95.lower, + }, name: names.get(variant.variantId) ?? variant.variantId, - role: variant.role === "control" ? ("control" as const) : ("treatment" as const), + role: + variant.role === "control" + ? ("control" as const) + : ("treatment" as const), uniqueExposures: variant.uniqueExposures, variantId: variant.variantId, })), - } + }; }, async saveDraft(scope, experimentId, document, expectedRevision) { const detail = await getExperiment({ client, path: { ...scope, experimentId }, throwOnError: true, - }) - const currentDraft = detail.data.data.currentDraft - if (!currentDraft) throw new Error("This Experiment has no editable Draft.") + }); + const { currentDraft } = detail.data.data; + if (!currentDraft) { + throw new Error("This Experiment has no editable Draft."); + } if (currentDraft.revision !== expectedRevision) { - throw new ExperimentDraftConflictError(currentDraft.revision, mapDraft(currentDraft)) + throw new ExperimentDraftConflictError( + currentDraft.revision, + mapDraft(currentDraft) + ); } - const requestScope = `${scope.projectId}:${scope.environmentId}:${experimentId}:${expectedRevision}` - const key = draftIdempotencyKeys.get(requestScope) ?? createIdempotencyKey() - draftIdempotencyKeys.set(requestScope, key) + const requestScope = `${scope.projectId}:${scope.environmentId}:${experimentId}:${expectedRevision}`; + const key = + draftIdempotencyKeys.get(requestScope) ?? createIdempotencyKey(); + draftIdempotencyKeys.set(requestScope, key); try { const result = await updateExperimentDraft({ body: { document: generatedDocument(document), expectedRevision }, client, headers: { "Idempotency-Key": key, - "If-Match": JSON.stringify(`experiment-draft:${currentDraft.id}:${expectedRevision}`), + "If-Match": JSON.stringify( + `experiment-draft:${currentDraft.id}:${expectedRevision}` + ), }, path: { ...scope, experimentId }, throwOnError: true, - }) - draftIdempotencyKeys.delete(requestScope) - return mapDraft(result.data.data) + }); + draftIdempotencyKeys.delete(requestScope); + return mapDraft(result.data.data); } catch (error) { - throw conflictFrom(error) ?? error + throw conflictFrom(error) ?? error; } }, async transition(scope, experimentId, target, reason) { - let action: "schedule" | "start" | "pause" | "resume" | "stop" | "complete" | "archive" + let action: + | "schedule" + | "start" + | "pause" + | "resume" + | "stop" + | "complete" + | "archive"; if (target === "running") { const current = await getExperiment({ client, path: { ...scope, experimentId }, throwOnError: true, - }) - action = current.data.data.state === "paused" ? "resume" : "start" + }); + action = current.data.data.state === "paused" ? "resume" : "start"; } else if (target === "scheduled") { - action = "schedule" + action = "schedule"; } else if ( target === "paused" || target === "stopped" || target === "completed" || target === "archived" ) { - action = - target === "stopped" - ? "stop" - : target === "paused" - ? "pause" - : target === "completed" - ? "complete" - : "archive" + action = (() => { + if (target === "stopped") { + return "stop"; + } + if (target === "paused") { + return "pause"; + } + if (target === "completed") { + return "complete"; + } + return "archive"; + })(); } else { - throw new Error("Draft is not a lifecycle action.") + throw new Error("Draft is not a lifecycle action."); } const result = await transitionExperimentLifecycle({ body: { reason }, client, path: { ...scope, experimentId, lifecycleAction: action }, throwOnError: true, - }) - return mapExperiment(result.data.data) + }); + return mapExperiment(result.data.data); }, async validate(scope, experimentId) { const result = await validateExperimentDraft({ client, path: { ...scope, experimentId }, throwOnError: true, - }) - return { canPublish: result.data.data.valid, issues: result.data.data.issues.map(mapIssue) } + }); + return { + canPublish: result.data.data.valid, + issues: result.data.data.issues.map(mapIssue), + }; }, - } + }; } -export const generatedExperimentAdapter = createGeneratedExperimentAdapter() +export const generatedExperimentAdapter = createGeneratedExperimentAdapter(); diff --git a/apps/dashboard/src/features/experiments/api/use-experiment-adapter.ts b/apps/dashboard/src/features/experiments/api/use-experiment-adapter.ts index 4481e776..fedbb293 100644 --- a/apps/dashboard/src/features/experiments/api/use-experiment-adapter.ts +++ b/apps/dashboard/src/features/experiments/api/use-experiment-adapter.ts @@ -1,10 +1,12 @@ -import { createContext, useContext } from "react" +import { createContext, useContext } from "react"; -import type { ExperimentAdapter } from "./experiment-adapter" -import { generatedExperimentAdapter } from "./generated-experiment-adapter" +import type { ExperimentAdapter } from "./experiment-adapter"; +import { generatedExperimentAdapter } from "./generated-experiment-adapter"; -export const ExperimentAdapterContext = createContext(generatedExperimentAdapter) +export const ExperimentAdapterContext = createContext( + generatedExperimentAdapter +); export function useExperimentAdapter() { - return useContext(ExperimentAdapterContext) + return useContext(ExperimentAdapterContext); } diff --git a/apps/dashboard/src/features/experiments/components/experiment-builder.tsx b/apps/dashboard/src/features/experiments/components/experiment-builder.tsx index 8757f940..e03c5fed 100644 --- a/apps/dashboard/src/features/experiments/components/experiment-builder.tsx +++ b/apps/dashboard/src/features/experiments/components/experiment-builder.tsx @@ -1,65 +1,69 @@ -import { Plus, Trash } from "@phosphor-icons/react" -import { useForm } from "@tanstack/react-form" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { Link, useNavigate } from "@tanstack/react-router" -import { useEffect, useMemo, useState } from "react" +import { Plus, Trash } from "@phosphor-icons/react"; +import { useForm } from "@tanstack/react-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect, useMemo, useState } from "react"; -import { ErrorState } from "@/components/feedback/error-state" -import { LoadingState } from "@/components/feedback/loading-state" -import { Button } from "@/components/ui/button" -import { buttonVariants } from "@/components/ui/button-variants" -import { Field, FieldError, FieldLabel } from "@/components/ui/field" -import { Input } from "@/components/ui/input" +import { ErrorState } from "@/components/feedback/error-state"; +import { LoadingState } from "@/components/feedback/loading-state"; +import { Button } from "@/components/ui/button"; +import { buttonVariants } from "@/components/ui/button-variants"; +import { Field, FieldError, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/ui/select" -import { describeApiError } from "@/lib/api/errors" -import { useOrganizationAccess } from "@/hooks/use-organization-access" -import { useHostedPublishingAdapter } from "@/features/publishing/api/use-hosted-publishing-adapter" -import { placementsQueryOptions } from "@/features/placements/queries/placement-queries" -import { WorkflowPanel } from "@/features/orgs/components/workspace-page" -import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" -import { ExperimentDraftConflictError } from "../api/experiment-adapter" -import { useExperimentAdapter } from "../api/use-experiment-adapter" +} from "@/components/ui/select"; +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query"; +import { WorkflowPanel } from "@/features/orgs/components/workspace-page"; +import { placementsQueryOptions } from "@/features/placements/queries/placement-queries"; +import { useHostedPublishingAdapter } from "@/features/publishing/api/use-hosted-publishing-adapter"; +import { useOrganizationAccess } from "@/hooks/use-organization-access"; +import { describeApiError } from "@/lib/api/errors"; +import { workspaceScopeParams } from "@/lib/routing/workspace-params"; +import { ExperimentDraftConflictError } from "../api/experiment-adapter"; +import { useExperimentAdapter } from "../api/use-experiment-adapter"; import { createExperimentMutationOptions, saveExperimentDraftMutationOptions, -} from "../mutations/experiment-mutations" -import { experimentBuilderResourcesQueryOptions } from "../queries/experiment-queries" +} from "../mutations/experiment-mutations"; +import { experimentBuilderResourcesQueryOptions } from "../queries/experiment-queries"; import { canSelectMetric, describeMetricEventFilter, - localDateTimeToUtc, - utcToLocalDateTime, - validateAllocation, type ExperimentDetail, type ExperimentDraftDocument, type ExperimentScope, type ExperimentVariantDraft, -} from "../types/experiment" -import { MutualExclusionGroupManager } from "./mutual-exclusion-group-manager" + localDateTimeToUtc, + utcToLocalDateTime, + validateAllocation, +} from "../types/experiment"; +import { MutualExclusionGroupManager } from "./mutual-exclusion-group-manager"; interface ExperimentBuilderProps extends ExperimentScope { - experiment?: ExperimentDetail - organizationId: string + experiment?: ExperimentDetail; + organizationId: string; } function splitFor(count: number) { - const base = Math.floor(10_000 / count) + const base = Math.floor(10_000 / count); return Array.from({ length: count }, (_, index) => - index === count - 1 ? 10_000 - base * (count - 1) : base, - ) + index === count - 1 ? 10_000 - base * (count - 1) : base + ); } const ASSIGNMENT_IDENTITY_OPTIONS = [ { label: "Identified user (requires identity)", value: "identified_user" }, - { label: "Identified user, otherwise installation", value: "identified_user_or_installation" }, + { + label: "Identified user, otherwise installation", + value: "identified_user_or_installation", + }, { label: "Installation", value: "installation" }, -] +]; export function ExperimentBuilder({ environmentId, @@ -67,55 +71,81 @@ export function ExperimentBuilder({ organizationId, projectId, }: ExperimentBuilderProps) { - const scope = useMemo(() => ({ environmentId, projectId }), [environmentId, projectId]) - const adapter = useExperimentAdapter() - const publishingAdapter = useHostedPublishingAdapter() - const queryClient = useQueryClient() - const navigate = useNavigate() - const access = useOrganizationAccess(organizationId) - const resources = useQuery(experimentBuilderResourcesQueryOptions(scope, adapter)) - const environments = useQuery(environmentsQueryOptions(projectId)) - const placements = useQuery(placementsQueryOptions(scope, publishingAdapter)) - const initialVariants = experiment?.currentDraft?.variants ?? [] + const scope = useMemo( + () => ({ environmentId, projectId }), + [environmentId, projectId] + ); + const adapter = useExperimentAdapter(); + const publishingAdapter = useHostedPublishingAdapter(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const access = useOrganizationAccess(organizationId); + const resources = useQuery( + experimentBuilderResourcesQueryOptions(scope, adapter) + ); + const environments = useQuery(environmentsQueryOptions(projectId)); + const placements = useQuery(placementsQueryOptions(scope, publishingAdapter)); + const initialVariants = experiment?.currentDraft?.variants ?? []; const [treatmentCount, setTreatmentCount] = useState( Math.max( 1, - Math.min(3, initialVariants.filter((variant) => variant.role === "treatment").length || 1), - ), - ) - const [localError, setLocalError] = useState() - const [isSavingCreatedDraft, setIsSavingCreatedDraft] = useState(false) - const createMutation = useMutation(createExperimentMutationOptions(scope, adapter, queryClient)) + Math.min( + 3, + initialVariants.filter((variant) => variant.role === "treatment") + .length || 1 + ) + ) + ); + const [localError, setLocalError] = useState(); + const [isSavingCreatedDraft, setIsSavingCreatedDraft] = useState(false); + const createMutation = useMutation( + createExperimentMutationOptions(scope, adapter, queryClient) + ); const saveMutation = useMutation( - saveExperimentDraftMutationOptions(scope, experiment?.id ?? "new", adapter, queryClient), - ) - const control = initialVariants.find((variant) => variant.role === "control") - const treatments = initialVariants.filter((variant) => variant.role === "treatment") + saveExperimentDraftMutationOptions( + scope, + experiment?.id ?? "new", + adapter, + queryClient + ) + ); + const control = initialVariants.find((variant) => variant.role === "control"); + const treatments = initialVariants.filter( + (variant) => variant.role === "treatment" + ); const form = useForm({ defaultValues: { - allocation0: String((control?.allocationBasisPoints ?? 5_000) / 100), - allocation1: String((treatments[0]?.allocationBasisPoints ?? 5_000) / 100), + allocation0: String((control?.allocationBasisPoints ?? 5000) / 100), + allocation1: String((treatments[0]?.allocationBasisPoints ?? 5000) / 100), allocation2: String((treatments[1]?.allocationBasisPoints ?? 0) / 100), allocation3: String((treatments[2]?.allocationBasisPoints ?? 0) / 100), - assignmentKeyPolicy: experiment?.currentDraft?.assignmentKeyPolicy ?? "identified_user", + assignmentKeyPolicy: + experiment?.currentDraft?.assignmentKeyPolicy ?? "identified_user", controlVersionId: control?.paywallVersionId ?? "", endsAt: utcToLocalDateTime(experiment?.currentDraft?.endsAt), - guardrailMetricVersionIds: [...(experiment?.currentDraft?.guardrailMetricVersionIds ?? [])], - hypothesis: experiment?.hypothesis ?? experiment?.currentDraft?.hypothesis ?? "", - mutualExclusionGroupVersionId: experiment?.currentDraft?.mutualExclusionGroupVersionId ?? "", + guardrailMetricVersionIds: [ + ...(experiment?.currentDraft?.guardrailMetricVersionIds ?? []), + ], + hypothesis: + experiment?.hypothesis ?? experiment?.currentDraft?.hypothesis ?? "", + mutualExclusionGroupVersionId: + experiment?.currentDraft?.mutualExclusionGroupVersionId ?? "", name: experiment?.name ?? "", placementId: experiment?.placementId ?? "", - primaryMetricVersionId: experiment?.currentDraft?.primaryMetricVersionId ?? "", + primaryMetricVersionId: + experiment?.currentDraft?.primaryMetricVersionId ?? "", qaEnabled: experiment?.currentDraft?.qaEnabled ?? true, - scheduleMode: experiment ? ("scheduled" as const) : ("immediate" as const), + scheduleMode: experiment + ? ("scheduled" as const) + : ("immediate" as const), startsAt: utcToLocalDateTime(experiment?.currentDraft?.startsAt), treatment1VersionId: treatments[0]?.paywallVersionId ?? "", treatment2VersionId: treatments[1]?.paywallVersionId ?? "", treatment3VersionId: treatments[2]?.paywallVersionId ?? "", }, onSubmit: async ({ value }) => { - const versionOptions = resources.data?.paywallVersions ?? [] + const versionOptions = resources.data?.paywallVersions ?? []; const variantInputs = [ { allocation: value.allocation0, @@ -141,55 +171,80 @@ export function ExperimentBuilder({ role: "treatment" as const, versionId: value.treatment3VersionId, }, - ].slice(0, treatmentCount + 1) + ].slice(0, treatmentCount + 1); const variants: ExperimentVariantDraft[] = variantInputs.map((item) => { - const version = versionOptions.find((candidate) => candidate.id === item.versionId) + const version = versionOptions.find( + (candidate) => candidate.id === item.versionId + ); return { allocationBasisPoints: Math.round(Number(item.allocation) * 100), name: item.name, paywallId: version?.paywallId ?? "", paywallVersionId: item.versionId, role: item.role, - } - }) - const allocationError = validateAllocation(variants) - if (allocationError) throw new Error(allocationError) - if (variants.some((variant) => !variant.paywallVersionId || !variant.paywallId)) { - throw new Error("Choose an eligible immutable Paywall Version for every Variant.") + }; + }); + const allocationError = validateAllocation(variants); + if (allocationError) { + throw new Error(allocationError); + } + if ( + variants.some( + (variant) => !(variant.paywallVersionId && variant.paywallId) + ) + ) { + throw new Error( + "Choose an eligible immutable Paywall Version for every Variant." + ); + } + if ( + new Set(variants.map((variant) => variant.paywallVersionId)).size !== + variants.length + ) { + throw new Error( + "Each Variant must reference a different immutable Paywall Version." + ); } - if (new Set(variants.map((variant) => variant.paywallVersionId)).size !== variants.length) { - throw new Error("Each Variant must reference a different immutable Paywall Version.") + if (!value.primaryMetricVersionId) { + throw new Error("Choose a primary metric."); } - if (!value.primaryMetricVersionId) throw new Error("Choose a primary metric.") const primaryMetric = resources.data?.metrics.find( - (metric) => metric.versionId === value.primaryMetricVersionId, - ) - if (!primaryMetric || !canSelectMetric(primaryMetric)) { - throw new Error("Choose a primary metric whose trusted source is available.") + (metric) => metric.versionId === value.primaryMetricVersionId + ); + if (!(primaryMetric && canSelectMetric(primaryMetric))) { + throw new Error( + "Choose a primary metric whose trusted source is available." + ); } if (value.scheduleMode === "scheduled" && !value.startsAt) { - throw new Error("Choose an inclusive local start time.") + throw new Error("Choose an inclusive local start time."); } const startsAt = value.scheduleMode === "immediate" ? new Date().toISOString() - : localDateTimeToUtc(value.startsAt) - const endsAt = localDateTimeToUtc(value.endsAt) - if (!startsAt) throw new Error("Choose a valid start time.") - if (endsAt && new Date(startsAt).getTime() >= new Date(endsAt).getTime()) { - throw new Error("The schedule end must be later than its start.") + : localDateTimeToUtc(value.startsAt); + const endsAt = localDateTimeToUtc(value.endsAt); + if (!startsAt) { + throw new Error("Choose a valid start time."); + } + if ( + endsAt && + new Date(startsAt).getTime() >= new Date(endsAt).getTime() + ) { + throw new Error("The schedule end must be later than its start."); } const document: ExperimentDraftDocument = { assignmentKeyPolicy: value.assignmentKeyPolicy, endsAt, guardrailMetricVersionIds: value.guardrailMetricVersionIds, hypothesis: value.hypothesis.trim() || undefined, - mutualExclusionGroupVersionId: value.mutualExclusionGroupVersionId || undefined, + mutualExclusionGroupVersionId: + value.mutualExclusionGroupVersionId || undefined, primaryMetricVersionId: value.primaryMetricVersionId, qaEnabled: value.qaEnabled, startsAt, variants, - } + }; const target = experiment ?? createMutation.data ?? @@ -197,65 +252,104 @@ export function ExperimentBuilder({ hypothesis: value.hypothesis.trim() || undefined, name: value.name.trim(), placementId: value.placementId, - })) - const revision = conflict?.currentRevision ?? target.currentDraft?.revision ?? 1 + })); + const revision = + conflict?.currentRevision ?? target.currentDraft?.revision ?? 1; if (experiment) { - await saveMutation.mutateAsync({ document, expectedRevision: revision }) + await saveMutation.mutateAsync({ + document, + expectedRevision: revision, + }); } else { - setIsSavingCreatedDraft(true) + setIsSavingCreatedDraft(true); try { - await adapter.saveDraft(scope, target.id, document, revision) + await adapter.saveDraft(scope, target.id, document, revision); await queryClient.invalidateQueries({ queryKey: ["experiments", projectId, environmentId], - }) + }); } finally { - setIsSavingCreatedDraft(false) + setIsSavingCreatedDraft(false); } } await navigate({ - params: (prev) => ({ ...prev, experimentId: target.id }), + params: (prev) => ({ + ...prev, + ...workspaceScopeParams(prev), + experimentId: target.id, + }), to: "/orgs/$organizationId/projects/$projectId/env/$environmentKey/monetization/experiments/$experimentId", - }) + }); }, - }) + }); + const handleClick = useCallback(() => { + saveMutation.reset(); + form + .handleSubmit() + .catch((submitError: unknown) => + setLocalError( + submitError instanceof Error + ? submitError.message + : "The Draft could not be saved." + ) + ); + }, [form, saveMutation]); useEffect(() => { - if (!resources.data) return - const firstVersion = resources.data.paywallVersions[0]?.id + if (!resources.data) { + return; + } + const firstVersion = resources.data.paywallVersions[0]?.id; const firstMetric = resources.data.metrics.find( - (metric) => metric.eligibleAsPrimary && canSelectMetric(metric), - )?.versionId - if (!form.getFieldValue("controlVersionId") && firstVersion) - form.setFieldValue("controlVersionId", firstVersion) - if (!form.getFieldValue("primaryMetricVersionId") && firstMetric) - form.setFieldValue("primaryMetricVersionId", firstMetric) - }, [form, resources.data]) + (metric) => metric.eligibleAsPrimary && canSelectMetric(metric) + )?.versionId; + if (!form.getFieldValue("controlVersionId") && firstVersion) { + form.setFieldValue("controlVersionId", firstVersion); + } + if (!form.getFieldValue("primaryMetricVersionId") && firstMetric) { + form.setFieldValue("primaryMetricVersionId", firstMetric); + } + }, [form, resources.data]); useEffect(() => { - const environment = environments.data?.items.find((item) => item.id === environmentId) - if (environment?.mode === "production") form.setFieldValue("qaEnabled", false) - }, [environmentId, environments.data, form]) + const environment = environments.data?.items.find( + (item) => item.id === environmentId + ); + if (environment?.mode === "production") { + form.setFieldValue("qaEnabled", false); + } + }, [environmentId, environments.data, form]); - if (resources.isPending || placements.isPending || environments.isPending || access.isPending) { + if ( + resources.isPending || + placements.isPending || + environments.isPending || + access.isPending + ) { return ( - ) + ); } - if (resources.error || placements.error || environments.error || access.error) { - const error = resources.error ?? placements.error ?? environments.error ?? access.error + if ( + resources.error || + placements.error || + environments.error || + access.error + ) { + const error = + resources.error ?? placements.error ?? environments.error ?? access.error; return ( { - void resources.refetch() - void placements.refetch() - void environments.refetch() + resources.refetch(); + placements.refetch(); + environments.refetch(); }} /> - ) + ); } if (!access.canManage) { return ( @@ -263,21 +357,24 @@ export function ExperimentBuilder({ description="Owner or admin access is required to create or change an Experiment." title="Read-only access" /> - ) + ); } - const versions = resources.data.paywallVersions + const versions = resources.data.paywallVersions; const placementOptions = [ { label: "Choose Placement", value: "" }, - ...placements.data.map((placement) => ({ label: placement.name, value: placement.id })), - ] + ...placements.data.map((placement) => ({ + label: placement.name, + value: placement.id, + })), + ]; const versionOptions = [ { label: "Choose immutable Version", value: "" }, ...versions.map((version) => ({ label: `${version.paywallName} · v${version.versionNumber}`, value: version.id, })), - ] + ]; const metricOptions = [ { disabled: false, label: "Choose metric", value: "" }, ...resources.data.metrics @@ -289,17 +386,26 @@ export function ExperimentBuilder({ }`, value: metric.versionId, })), - ] + ]; const groupOptions = [ { label: "No group", value: "" }, - ...resources.data.groups.map((group) => ({ label: group.name, value: group.versionId })), - ] - const error = saveMutation.error ?? createMutation.error - const conflict = error instanceof ExperimentDraftConflictError ? error : undefined + ...resources.data.groups.map((group) => ({ + label: group.name, + value: group.versionId, + })), + ]; + const error = saveMutation.error ?? createMutation.error; + const conflict = + error instanceof ExperimentDraftConflictError ? error : undefined; function applyEvenSplit(nextTreatmentCount = treatmentCount) { - splitFor(nextTreatmentCount + 1).forEach((allocation, index) => - form.setFieldValue(`allocation${index}` as "allocation0", String(allocation / 100)), - ) + for (const [index, allocation] of splitFor( + nextTreatmentCount + 1 + ).entries()) { + form.setFieldValue( + `allocation${index}` as "allocation0", + String(allocation / 100) + ); + } } return ( @@ -307,56 +413,74 @@ export function ExperimentBuilder({
    { - event.preventDefault() - event.stopPropagation() - setLocalError(undefined) - void form.handleSubmit().catch((submitError: unknown) => { + event.preventDefault(); + event.stopPropagation(); + setLocalError(undefined); + form.handleSubmit().catch((submitError: unknown) => { setLocalError( - submitError instanceof Error ? submitError.message : "The Draft could not be saved.", - ) - }) + submitError instanceof Error + ? submitError.message + : "The Draft could not be saved." + ); + }); }} >
    (value.trim() ? undefined : "Enter an internal name."), + onSubmit: ({ value }) => + value.trim() ? undefined : "Enter an internal name.", }} > {(field) => ( - 0 || undefined}> - Internal name + 0 || undefined} + > + + Internal name + field.handleChange(event.currentTarget.value)} + onChange={(event) => + field.handleChange(event.currentTarget.value) + } + value={field.state.value} + /> + ({ + message, + }))} /> - ({ message }))} /> )} (value ? undefined : "Choose a Placement.") }} + validators={{ + onSubmit: ({ value }) => + value ? undefined : "Choose a Placement.", + }} > {(field) => ( - Placement + + Placement + field.handleChange(event.currentTarget.value)} + onChange={(event) => + field.handleChange(event.currentTarget.value) + } placeholder="Clarifying annual value increases purchase starts." + value={field.state.value} /> )} @@ -388,20 +516,33 @@ export function ExperimentBuilder({
    {versions.length === 0 ? ( -
    -

    No eligible immutable Paywall Versions

    -

    - Publish at least two Paywall Versions in this Environment, then return and retry. - Draft Paywalls are intentionally excluded. +

    +

    + No eligible immutable Paywall Versions +

    +

    + Publish at least two Paywall Versions in this Environment, + then return and retry. Draft Paywalls are intentionally + excluded.

    prev} + className={buttonVariants({ + className: "mt-3", + size: "sm", + variant: "outline", + })} + params={(prev) => ({ + ...prev, + ...workspaceScopeParams(prev), + })} to="/orgs/$organizationId/projects/$projectId/env/$environmentKey/monetization/paywalls" > Open Paywalls @@ -413,16 +554,23 @@ export function ExperimentBuilder({ | "controlVersionId" | "treatment1VersionId" | "treatment2VersionId" - | "treatment3VersionId" = + | "treatment3VersionId" = (() => { + if (index === 0) { + return "controlVersionId"; + } + if (index === 1) { + return "treatment1VersionId"; + } + if (index === 2) { + return "treatment2VersionId"; + } + return "treatment3VersionId"; + })(); + const allocationField = `allocation${index}` as "allocation0"; + const role = index === 0 - ? "controlVersionId" - : index === 1 - ? "treatment1VersionId" - : index === 2 - ? "treatment2VersionId" - : "treatment3VersionId" - const allocationField = `allocation${index}` as "allocation0" - const role = index === 0 ? "Control" : `Treatment ${String.fromCharCode(64 + index)}` + ? "Control" + : `Treatment ${String.fromCharCode(64 + index)}`; return (
    {(field) => ( - {role} Paywall Version + + {role} Paywall Version + + field.handleChange(event.currentTarget.value) + } step="0.01" type="number" value={field.state.value} - onChange={(event) => field.handleChange(event.currentTarget.value)} />

    - {Math.round(Number(field.state.value || 0) * 100)} basis points + {Math.round(Number(field.state.value || 0) * 100)}{" "} + basis points

    )} @@ -475,9 +633,9 @@ export function ExperimentBuilder({
    - ) + ); })}
    -
    {(field) => ( - Assignment identity + + Assignment identity + field.handleChange(value)} @@ -563,56 +731,67 @@ export function ExperimentBuilder({ {resources.data.metrics.find( - (metric) => metric.versionId === field.state.value, + (metric) => metric.versionId === field.state.value ) ? (

    Assignment unit: unique assignment key · Filter:{" "} {describeMetricEventFilter( resources.data.metrics.find( - (metric) => metric.versionId === field.state.value, - )!.eventFilter, + (metric) => metric.versionId === field.state.value + )?.eventFilter )}

    ) : (

    - Unavailable metrics remain visible for diagnosis but cannot be selected. + Unavailable metrics remain visible for diagnosis but + cannot be selected.

    )}
    )}
    - + {(field) => (
    - Guardrails + Guardrails {resources.data.metrics .filter((metric) => metric.eligibleAsGuardrail) .map((metric) => { - const checked = field.state.value.includes(metric.versionId) - const available = canSelectMetric(metric) + const checked = field.state.value.includes( + metric.versionId + ); + const available = canSelectMetric(metric); return ( -
    )} @@ -620,13 +799,19 @@ export function ExperimentBuilder({ {(field) => ( - Mutual-exclusion group + + Mutual-exclusion group + Start as soon as published - - The required start timestamp is set when you save. Actual delivery cannot - begin before the server publishes the immutable Version. + + The required start timestamp is set when you save. + Actual delivery cannot begin before the server publishes + the immutable Version. @@ -687,12 +875,16 @@ export function ExperimentBuilder({ {(field) => ( - Start (local time) + + Start (local time) + + field.handleChange(event.currentTarget.value) + } type="datetime-local" value={field.state.value} - onChange={(event) => field.handleChange(event.currentTarget.value)} /> )} @@ -700,12 +892,16 @@ export function ExperimentBuilder({ {(field) => ( - End (local time, optional) + + End (local time, optional) + + field.handleChange(event.currentTarget.value) + } type="datetime-local" value={field.state.value} - onChange={(event) => field.handleChange(event.currentTarget.value)} /> )} @@ -719,74 +915,89 @@ export function ExperimentBuilder({ ]} > {([mode, startsAtValue, endsAtValue]) => ( -
    +

    Exact UTC conversion

    -

    +

    Start:{" "} {mode === "immediate" ? "Set to the current instant when saved; publication is the earliest effective start." - : (localDateTimeToUtc(startsAtValue) ?? "Choose a valid local time.")} + : (localDateTimeToUtc(startsAtValue) ?? + "Choose a valid local time.")}

    - End: {localDateTimeToUtc(endsAtValue) ?? "No scheduled end."} + End:{" "} + {localDateTimeToUtc(endsAtValue) ?? "No scheduled end."}

    )}

    Your current timezone is{" "} - {Intl.DateTimeFormat().resolvedOptions().timeZone || "the device timezone"}. - Unreliable device time uses normal Placement behavior. + {Intl.DateTimeFormat().resolvedOptions().timeZone || + "the device timezone"} + . Unreliable device time uses normal Placement behavior.

    - {conflict ? ( -
    -

    Draft changed on the server

    -

    - Your unsaved input is preserved. The server is at revision {conflict.currentRevision}. - Your unsaved input is preserved. Review it, then retry against the latest revision in - place. -

    - -
    - ) : error || localError ? ( -

    - {error?.message ?? localError} -

    - ) : null} -
    + {(() => { + if (conflict) { + return ( +
    +

    Draft changed on the server

    +

    + Your unsaved input is preserved. The server is at revision{" "} + {conflict.currentRevision}. Your unsaved input is preserved. + Review it, then retry against the latest revision in place. +

    + +
    + ); + } + if (error || localError) { + return ( +

    + {error?.message ?? localError} +

    + ); + } + return null; + })()} +
    {experiment ? : null} - ) + ); } diff --git a/apps/dashboard/src/features/experiments/components/experiment-results.test.tsx b/apps/dashboard/src/features/experiments/components/experiment-results.test.tsx index b795916a..dae11f01 100644 --- a/apps/dashboard/src/features/experiments/components/experiment-results.test.tsx +++ b/apps/dashboard/src/features/experiments/components/experiment-results.test.tsx @@ -1,10 +1,10 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { render, screen } from "@testing-library/react" -import { describe, expect, it } from "vitest" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; -import type { ExperimentAdapter } from "../api/experiment-adapter" -import { ExperimentAdapterProvider } from "../api/experiment-adapter-provider" -import { ExperimentResultsPanel } from "./experiment-results" +import type { ExperimentAdapter } from "../api/experiment-adapter"; +import { ExperimentAdapterProvider } from "../api/experiment-adapter-provider"; +import { ExperimentResultsPanel } from "./experiment-results"; describe("Experiment result interpretation", () => { it("shows uncertainty and an actionable SRM warning without declaring a winner", async () => { @@ -27,8 +27,10 @@ describe("Experiment result interpretation", () => { code: "sample_ratio_mismatch", continues: true, investigation: "Check exposure instrumentation by Variant.", - message: "Observed exposure allocation differs from the immutable allocation.", - recoveryAction: "Inspect instrumentation; do not reallocate traffic in place.", + message: + "Observed exposure allocation differs from the immutable allocation.", + recoveryAction: + "Inspect instrumentation; do not reallocate traffic in place.", severity: "critical" as const, title: "Sample-ratio mismatch", }, @@ -36,7 +38,9 @@ describe("Experiment result interpretation", () => { primaryMetricName: "Presentation to purchase start", primaryMetricAuthority: "provider_confirmed" as const, primaryMetricAvailability: "trusted_source_unavailable" as const, - primaryMetricEventFilter: { "payload.reason": "provider_unavailable" as const }, + primaryMetricEventFilter: { + "payload.reason": "provider_unavailable" as const, + }, srm: { cells: [ { @@ -56,7 +60,7 @@ describe("Experiment result interpretation", () => { ], degreesOfFreedom: 1, exclusions: ["QA overrides"], - pValue: 0.00001, + pValue: 0.000_01, severity: "critical" as const, statistic: 32, status: "mismatch" as const, @@ -71,7 +75,7 @@ describe("Experiment result interpretation", () => { ], variants: [ { - allocationBasisPoints: 5_000, + allocationBasisPoints: 5000, conversions: 10, estimate: 0.1, interval: { high: 0.17, low: 0.06 }, @@ -81,7 +85,7 @@ describe("Experiment result interpretation", () => { variantId: "control", }, { - allocationBasisPoints: 5_000, + allocationBasisPoints: 5000, conversions: 12, estimate: 0.12, interval: { high: 0.19, low: 0.07 }, @@ -92,8 +96,10 @@ describe("Experiment result interpretation", () => { }, ], }), - } as unknown as ExperimentAdapter - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + } as unknown as ExperimentAdapter; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); render( @@ -103,18 +109,26 @@ describe("Experiment result interpretation", () => { scope={{ environmentId: "staging", projectId: "project" }} /> - , - ) + + ); - expect(await screen.findByText("Sample-ratio mismatch")).toBeInTheDocument() - expect(screen.getByText("Do not interpret yet")).toBeInTheDocument() - expect(screen.getByText("Observed estimate")).toBeInTheDocument() - expect(screen.getByText("Descriptive lift")).toBeInTheDocument() - expect(screen.getByText(/95% Wilson estimates/)).toBeInTheDocument() - expect(screen.getByText(/95% Newcombe interval/)).toBeInTheDocument() - expect(screen.getByText(/No automatic winner is selected/i)).toBeInTheDocument() - expect(screen.getByText(/Trusted source: trusted source unavailable/i)).toBeInTheDocument() - expect(screen.getByText(/payload.reason = provider_unavailable/i)).toBeInTheDocument() - expect(screen.queryByText(/^Winner$/i)).not.toBeInTheDocument() - }) -}) + expect( + await screen.findByText("Sample-ratio mismatch") + ).toBeInTheDocument(); + expect(screen.getByText("Do not interpret yet")).toBeInTheDocument(); + expect(screen.getByText("Observed estimate")).toBeInTheDocument(); + expect(screen.getByText("Descriptive lift")).toBeInTheDocument(); + expect(screen.getByText(/95% Wilson estimates/)).toBeInTheDocument(); + expect(screen.getByText(/95% Newcombe interval/)).toBeInTheDocument(); + expect( + screen.getByText(/No automatic winner is selected/i) + ).toBeInTheDocument(); + expect( + screen.getByText(/Trusted source: trusted source unavailable/i) + ).toBeInTheDocument(); + expect( + screen.getByText(/payload.reason = provider_unavailable/i) + ).toBeInTheDocument(); + expect(screen.queryByText(/^Winner$/i)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/dashboard/src/features/experiments/components/experiment-results.tsx b/apps/dashboard/src/features/experiments/components/experiment-results.tsx index 167802cb..562c7720 100644 --- a/apps/dashboard/src/features/experiments/components/experiment-results.tsx +++ b/apps/dashboard/src/features/experiments/components/experiment-results.tsx @@ -1,28 +1,35 @@ -import { useQuery } from "@tanstack/react-query" +import { useQuery } from "@tanstack/react-query"; +import { ErrorState } from "@/components/feedback/error-state"; +import { LoadingState } from "@/components/feedback/loading-state"; +import { WorkflowPanel } from "@/features/orgs/components/workspace-page"; +import { useExperimentAdapter } from "../api/use-experiment-adapter"; +import { experimentResultsQueryOptions } from "../queries/experiment-queries"; +import { + describeMetricEventFilter, + type ExperimentScope, +} from "../types/experiment"; +import { ExperimentIssueCard } from "./experiment-status"; -import { LoadingState } from "@/components/feedback/loading-state" -import { ErrorState } from "@/components/feedback/error-state" -import { WorkflowPanel } from "@/features/orgs/components/workspace-page" -import { useExperimentAdapter } from "../api/use-experiment-adapter" -import { experimentResultsQueryOptions } from "../queries/experiment-queries" -import { describeMetricEventFilter, type ExperimentScope } from "../types/experiment" -import { ExperimentIssueCard } from "./experiment-status" +const MATURITY_ISSUE = /sample|fresh|matur|srm|ratio/i; function percent(value: number) { - return new Intl.NumberFormat(undefined, { style: "percent", maximumFractionDigits: 2 }).format( - value, - ) + return new Intl.NumberFormat(undefined, { + style: "percent", + maximumFractionDigits: 2, + }).format(value); } export function ExperimentResultsPanel({ experimentId, scope, }: { - experimentId: string - scope: ExperimentScope + experimentId: string; + scope: ExperimentScope; }) { - const adapter = useExperimentAdapter() - const query = useQuery(experimentResultsQueryOptions(scope, experimentId, adapter)) + const adapter = useExperimentAdapter(); + const query = useQuery( + experimentResultsQueryOptions(scope, experimentId, adapter) + ); if (query.isPending) { return ( @@ -30,47 +37,65 @@ export function ExperimentResultsPanel({ description="Loading unique-unit results and uncertainty." title="Loading results" /> - ) + ); } if (query.error) { - return void query.refetch()} /> + return ( + { + query.refetch(); + }} + /> + ); } - const results = query.data + const results = query.data; const doNotInterpret = results.interim || !results.attributionWindowMature || results.freshnessMinutes === undefined || results.srm.status !== "ok" || results.issues.some((issue) => - /sample|fresh|matur|srm|ratio/i.test(`${issue.code} ${issue.title} ${issue.message}`), - ) + MATURITY_ISSUE.test(`${issue.code} ${issue.title} ${issue.message}`) + ); return (
    {doNotInterpret ? ( -
    -

    Do not interpret yet

    -

    - Results are descriptive while sample ratio, sample size, freshness, or the attribution - window is unresolved. Investigate the warnings and wait for mature, fresh aggregates - before making a decision. +

    +

    Do not interpret yet

    +

    + Results are descriptive while sample ratio, sample size, freshness, + or the attribution window is unresolved. Investigate the warnings + and wait for mature, fresh aggregates before making a decision.

    ) : null} -
    -

    - {results.interim ? "Interim descriptive results" : "Final descriptive results"} +

    +

    + {results.interim + ? "Interim descriptive results" + : "Final descriptive results"}

    -

    - {results.primaryMetricName}. No automatic winner is selected. Freshness:{" "} +

    + {results.primaryMetricName}. No automatic winner is selected. + Freshness:{" "} {results.freshnessMinutes === undefined ? "unavailable" : `${results.freshnessMinutes} minutes ago`} .

    -

    - Authority: {results.primaryMetricAuthority.replace("_", " ")} · Assignment unit: unique - assignment key · Filter: {describeMetricEventFilter(results.primaryMetricEventFilter)} · - Trusted source: {results.primaryMetricAvailability.replaceAll("_", " ")}. +

    + Authority: {results.primaryMetricAuthority.replace("_", " ")} · + Assignment unit: unique assignment key · Filter:{" "} + {describeMetricEventFilter(results.primaryMetricEventFilter)} · + Trusted source:{" "} + {results.primaryMetricAvailability.replaceAll("_", " ")}.

    {results.issues.length ? ( @@ -81,14 +106,15 @@ export function ExperimentResultsPanel({

    ) : null}
    @@ -127,11 +153,18 @@ export function ExperimentResultsPanel({ ({variant.allocationBasisPoints} bp) - - - + + + ))} @@ -140,29 +173,33 @@ export function ExperimentResultsPanel({ {results.treatments.length ? (
      {results.treatments.map((lift) => { const variant = results.variants.find( - (candidate) => candidate.variantId === lift.variantId, - ) + (candidate) => candidate.variantId === lift.variantId + ); return (
    • {variant?.name ?? "Treatment"} - Absolute {percent(lift.absoluteLift)} ({percent(lift.interval.low)}– - {percent(lift.interval.high)}) + Absolute {percent(lift.absoluteLift)} ( + {percent(lift.interval.low)}–{percent(lift.interval.high)}) {lift.relativeLift === undefined ? ( - Relative lift unavailable + + Relative lift unavailable + ) : ( - Relative {percent(lift.relativeLift)} + + Relative {percent(lift.relativeLift)} + )}
    • - ) + ); })}
    ) : ( @@ -172,11 +209,13 @@ export function ExperimentResultsPanel({ )}
    - {results.srm.status.replaceAll("_", " ")} + + {results.srm.status.replaceAll("_", " ")} + Severity: {results.srm.severity} χ² {results.srm.statistic.toFixed(3)} df {results.srm.degreesOfFreedom} @@ -185,7 +224,8 @@ export function ExperimentResultsPanel({
    - Conversion and uncertainty by Variant, with allocation, unique exposures, conversions, - observed estimate, and the 95% Wilson interval. + Conversion and uncertainty by Variant, with allocation, unique + exposures, conversions, observed estimate, and the 95% Wilson + interval.
    {variant.uniqueExposures}{variant.conversions}{percent(variant.estimate)} + {variant.uniqueExposures} + + {variant.conversions} + + {percent(variant.estimate)} + - {percent(variant.interval.low)}–{percent(variant.interval.high)} + {percent(variant.interval.low)}– + {percent(variant.interval.high)}
    @@ -207,13 +247,17 @@ export function ExperimentResultsPanel({ {results.srm.cells.map((cell) => ( - + ))} @@ -221,7 +265,7 @@ export function ExperimentResultsPanel({
    - Sample ratio mismatch cells: observed versus expected assignment counts per Variant. + Sample ratio mismatch cells: observed versus expected assignment + counts per Variant.
    - {results.variants.find((variant) => variant.variantId === cell.variantId) - ?.name ?? cell.variantId} + {results.variants.find( + (variant) => variant.variantId === cell.variantId + )?.name ?? cell.variantId} {cell.observed}{cell.expected.toFixed(1)} + {cell.expected.toFixed(1)} + - {percent(cell.observedShare)} / {percent(cell.expectedShare)} + {percent(cell.observedShare)} /{" "} + {percent(cell.expectedShare)}
    {results.srm.exclusions.length ? ( -

    +

    Excluded: {results.srm.exclusions.join(", ")}.

    ) : null} @@ -233,14 +277,22 @@ export function ExperimentResultsPanel({
  • {guardrail.name} - {guardrail.severity} + + {guardrail.severity} +
    -

    {guardrail.summary}

    +

    + {guardrail.summary} +

    {guardrail.estimate === undefined ? null : ( -

    Observed estimate: {percent(guardrail.estimate)}

    +

    + Observed estimate: {percent(guardrail.estimate)} +

    )} {guardrail.code ? ( -

    {guardrail.code}

    +

    + {guardrail.code} +

    ) : null} {guardrail.investigation ? (

    @@ -260,10 +312,14 @@ export function ExperimentResultsPanel({

    Fallback exposures
    -
    {results.fallbackExposures}
    +
    + {results.fallbackExposures} +
    -
    Attribution and late-event window
    +
    + Attribution and late-event window +
    {results.attributionWindowMature ? "Mature" : "Still maturing"}
    @@ -279,5 +335,5 @@ export function ExperimentResultsPanel({
    - ) + ); } diff --git a/apps/dashboard/src/features/experiments/components/experiment-status.tsx b/apps/dashboard/src/features/experiments/components/experiment-status.tsx index 9f8af43b..ce152b25 100644 --- a/apps/dashboard/src/features/experiments/components/experiment-status.tsx +++ b/apps/dashboard/src/features/experiments/components/experiment-status.tsx @@ -1,22 +1,30 @@ -import { WarningCircle } from "@phosphor-icons/react" +import { WarningCircle } from "@phosphor-icons/react"; -import { cn } from "@/lib/utils" -import type { ExperimentIssue, ExperimentStatus } from "../types/experiment" +import { cn } from "@/lib/utils"; +import type { ExperimentIssue, ExperimentStatus } from "../types/experiment"; -export function ExperimentStatusBadge({ status }: { status: ExperimentStatus }) { +export function ExperimentStatusBadge({ + status, +}: { + status: ExperimentStatus; +}) { return ( {status} - ) + ); } export function ExperimentIssueCard({ issue }: { issue: ExperimentIssue }) { @@ -26,22 +34,39 @@ export function ExperimentIssueCard({ issue }: { issue: ExperimentIssue }) { "rounded border p-4", (issue.severity === "critical" || issue.severity === "error") && "border-destructive/40 bg-destructive/5", - issue.severity === "warning" && "border-amber-600/35 bg-amber-500/5", + issue.severity === "warning" && "border-amber-600/35 bg-amber-500/5" )} - role={issue.severity === "critical" || issue.severity === "error" ? "alert" : "status"} + role={ + issue.severity === "critical" || issue.severity === "error" + ? "alert" + : "status" + } >
    - +
    -

    {issue.title}

    -

    {issue.code}

    -

    {issue.message}

    +

    {issue.title}

    +

    + {issue.code} +

    +

    + {issue.message} +

    Does the Experiment continue?
    -
    {issue.continues ? "Yes — assignment continues." : "No — action is blocked."}
    +
    + {issue.continues + ? "Yes — assignment continues." + : "No — action is blocked."} +
    {issue.affectedResources?.length ? (
    @@ -63,5 +88,5 @@ export function ExperimentIssueCard({ issue }: { issue: ExperimentIssue }) {
    - ) + ); } diff --git a/apps/dashboard/src/features/experiments/components/experiment-workspace.tsx b/apps/dashboard/src/features/experiments/components/experiment-workspace.tsx index 3b3a43ad..acd32b15 100644 --- a/apps/dashboard/src/features/experiments/components/experiment-workspace.tsx +++ b/apps/dashboard/src/features/experiments/components/experiment-workspace.tsx @@ -1,29 +1,34 @@ -import { DownloadSimple, ShieldWarning, StopCircle } from "@phosphor-icons/react" -import { useForm } from "@tanstack/react-form" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { Link } from "@tanstack/react-router" -import { useState } from "react" +import { + DownloadSimple, + ShieldWarning, + StopCircle, +} from "@phosphor-icons/react"; +import { useForm } from "@tanstack/react-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { useCallback, useState } from "react"; -import { ErrorState } from "@/components/feedback/error-state" -import { LiveAnnouncer } from "@/components/feedback/live-announcer" -import { LoadingState } from "@/components/feedback/loading-state" -import { Button } from "@/components/ui/button" -import { buttonVariants } from "@/components/ui/button-variants" -import { Field, FieldLabel } from "@/components/ui/field" -import { Input } from "@/components/ui/input" +import { ErrorState } from "@/components/feedback/error-state"; +import { LiveAnnouncer } from "@/components/feedback/live-announcer"; +import { LoadingState } from "@/components/feedback/loading-state"; +import { Button } from "@/components/ui/button"; +import { buttonVariants } from "@/components/ui/button-variants"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/ui/select" -import { MonetizationWorkspace } from "@/features/environments/components/monetization-workspace" -import { environmentsQueryOptions } from "@/features/environments/queries/environments-query" -import { WorkflowPanel } from "@/features/orgs/components/workspace-page" -import { useOrganizationAccess } from "@/hooks/use-organization-access" -import { cn } from "@/lib/utils" -import { useExperimentAdapter } from "../api/use-experiment-adapter" +} from "@/components/ui/select"; +import { MonetizationWorkspace } from "@/features/environments/components/monetization-workspace"; +import { environmentsQueryOptions } from "@/features/environments/queries/environments-query"; +import { WorkflowPanel } from "@/features/orgs/components/workspace-page"; +import { useOrganizationAccess } from "@/hooks/use-organization-access"; +import { workspaceScopeParams } from "@/lib/routing/workspace-params"; +import { cn } from "@/lib/utils"; +import { useExperimentAdapter } from "../api/use-experiment-adapter"; import { createQaOverrideMutationOptions, deleteQaOverrideMutationOptions, @@ -32,84 +37,93 @@ import { publishExperimentMutationOptions, transitionExperimentMutationOptions, validateExperimentMutationOptions, -} from "../mutations/experiment-mutations" +} from "../mutations/experiment-mutations"; import { - experimentHistoryQueryOptions, experimentBuilderResourcesQueryOptions, + experimentHistoryQueryOptions, experimentQaQueryOptions, experimentQueryOptions, -} from "../queries/experiment-queries" +} from "../queries/experiment-queries"; import { canRequestExperimentExport, - lifecycleActions, + type ExperimentDetail, type ExperimentScope, type ExperimentStatus, - type ExperimentDetail, -} from "../types/experiment" -import { ExperimentBuilder } from "./experiment-builder" -import { ExperimentResultsPanel } from "./experiment-results" -import { ExperimentIssueCard, ExperimentStatusBadge } from "./experiment-status" -import { MutualExclusionGroupManager } from "./mutual-exclusion-group-manager" + lifecycleActions, +} from "../types/experiment"; +import { ExperimentBuilder } from "./experiment-builder"; +import { ExperimentResultsPanel } from "./experiment-results"; +import { + ExperimentIssueCard, + ExperimentStatusBadge, +} from "./experiment-status"; +import { MutualExclusionGroupManager } from "./mutual-exclusion-group-manager"; function formatInstant(value?: string) { - if (!value) return "No scheduled end" - const date = new Date(value) - return `${date.toLocaleString()} (${date.toISOString()})` + if (!value) { + return "No scheduled end"; + } + const date = new Date(value); + return `${date.toLocaleString()} (${date.toISOString()})`; } function ImmutableActiveDefinition({ - environmentId, experiment, - organizationId, - projectId, scope, }: { - environmentId: string - experiment: ExperimentDetail - organizationId: string - projectId: string - scope: ExperimentScope + environmentId: string; + experiment: ExperimentDetail; + organizationId: string; + projectId: string; + scope: ExperimentScope; }) { - const adapter = useExperimentAdapter() + const adapter = useExperimentAdapter(); const resources = useQuery({ ...experimentBuilderResourcesQueryOptions(scope, adapter), enabled: Boolean(experiment.activeDefinition), - }) - const definition = experiment.activeDefinition + }); + const definition = experiment.activeDefinition; if (!definition) { return (

    - The API did not return an active Version definition. Use History for attribution and do - not infer setup from the current Draft. + The API did not return an active Version definition. Use History for + attribution and do not infer setup from the current Draft.

    - ) + ); } const primary = resources.data?.metrics.find( - (metric) => metric.versionId === definition.primaryMetricVersionId, - ) + (metric) => metric.versionId === definition.primaryMetricVersionId + ); const group = resources.data?.groups.find( - (candidate) => candidate.versionId === definition.mutualExclusionGroupVersionId, - ) + (candidate) => + candidate.versionId === definition.mutualExclusionGroupVersionId + ); return (
    Assignment identity
    -
    {definition.assignmentKeyPolicy.replaceAll("_", " ")}
    +
    + {definition.assignmentKeyPolicy.replaceAll("_", " ")} +
    Assignment algorithm
    -
    {definition.bucketingAlgorithm}
    +
    + {definition.bucketingAlgorithm} +
    Allocation version
    -
    {definition.allocationVersion}
    +
    + {definition.allocationVersion} +
    Source Draft revision
    @@ -124,7 +138,9 @@ function ImmutableActiveDefinition({
    -
    Mutual-exclusion Group Version
    +
    + Mutual-exclusion Group Version +
    {definition.mutualExclusionGroupVersionId ? `${group?.name ? `${group.name} · ` : ""}${definition.mutualExclusionGroupVersionId}` @@ -146,11 +162,15 @@ function ImmutableActiveDefinition({
    QA policy at publication
    - {definition.qaPolicyEnabled === undefined - ? "Not exposed by the active Version contract" - : definition.qaPolicyEnabled - ? "Enabled" - : "Disabled"} + {(() => { + if (definition.qaPolicyEnabled === undefined) { + return "Not exposed by the active Version contract"; + } + if (definition.qaPolicyEnabled) { + return "Enabled"; + } + return "Disabled"; + })()}
    @@ -159,21 +179,25 @@ function ImmutableActiveDefinition({
      {definition.variants.map((variant) => { const version = resources.data?.paywallVersions.find( - (candidate) => candidate.id === variant.paywallVersionId, - ) + (candidate) => candidate.id === variant.paywallVersionId + ); return ( -
    • +
    • - {variant.name} · {variant.role} + {variant.name} ·{" "} + {variant.role}

      -

      +

      {version ? `${version.paywallName} · v${version.versionNumber}` : `Paywall ${variant.paywallId}`}

      -

      +

      Version {variant.paywallVersionId}

      @@ -181,18 +205,28 @@ function ImmutableActiveDefinition({

      {(variant.allocationBasisPoints / 100).toFixed(2)}%

      -

      {variant.allocationBasisPoints} bp

      +

      + {variant.allocationBasisPoints} bp +

    ({ ...prev, paywallId: variant.paywallId })} + className={buttonVariants({ + className: "mt-3", + size: "sm", + variant: "outline", + })} + params={(prev) => ({ + ...prev, + ...workspaceScopeParams(prev), + paywallId: variant.paywallId, + })} to="/orgs/$organizationId/projects/$projectId/env/$environmentKey/monetization/paywalls/$paywallId" > Open Paywall
  • - ) + ); })}
    @@ -200,41 +234,55 @@ function ImmutableActiveDefinition({ {definition.guardrailMetricVersionIds.length ? (
      {definition.guardrailMetricVersionIds.map((id) => { - const metric = resources.data?.metrics.find((candidate) => candidate.versionId === id) + const metric = resources.data?.metrics.find( + (candidate) => candidate.versionId === id + ); return (
    • {metric?.name ?? id} - + {metric ? `${metric.authority.replaceAll("_", " ")} · ${metric.definition}` : `Metric Version ${id}`}
    • - ) + ); })}
    ) : ( -

    No guardrails were published.

    +

    + No guardrails were published. +

    )}

    - Active definitions cannot be edited. Create a new Experiment, using this definition as - your review reference. + Active definitions cannot be edited. Create a new Experiment, using + this definition as your review reference.

    prev} + params={(prev) => ({ + ...prev, + ...workspaceScopeParams(prev), + })} to="/orgs/$organizationId/projects/$projectId/env/$environmentKey/monetization/experiments/new" > Create new Experiment
    - ) + ); } -type WorkspaceTab = "overview" | "variants" | "metrics" | "schedule" | "results" | "history" | "qa" +type WorkspaceTab = + | "overview" + | "variants" + | "metrics" + | "schedule" + | "results" + | "history" + | "qa"; const tabs: readonly { id: WorkspaceTab; label: string }[] = [ { id: "overview", label: "Overview" }, { id: "variants", label: "Variants" }, @@ -243,7 +291,7 @@ const tabs: readonly { id: WorkspaceTab; label: string }[] = [ { id: "results", label: "Results" }, { id: "history", label: "History" }, { id: "qa", label: "QA Overrides" }, -] +]; function LifecyclePanel({ canManage, @@ -251,104 +299,130 @@ function LifecyclePanel({ scope, status, }: { - canManage: boolean - experimentId: string - scope: ExperimentScope - status: ExperimentStatus + canManage: boolean; + experimentId: string; + scope: ExperimentScope; + status: ExperimentStatus; }) { - const adapter = useExperimentAdapter() - const queryClient = useQueryClient() + const adapter = useExperimentAdapter(); + const queryClient = useQueryClient(); const mutation = useMutation( - transitionExperimentMutationOptions(scope, experimentId, adapter, queryClient), - ) - const [target, setTarget] = useState("") + transitionExperimentMutationOptions( + scope, + experimentId, + adapter, + queryClient + ) + ); + const [target, setTarget] = useState(""); const form = useForm({ defaultValues: { reason: "" }, onSubmit: async ({ value, formApi }) => { - if (!target) return - await mutation.mutateAsync({ reason: value.reason.trim(), target }) - setTarget("") - formApi.reset() + if (!target) { + return; + } + await mutation.mutateAsync({ reason: value.reason.trim(), target }); + setTarget(""); + formApi.reset(); }, - }) - const actions = lifecycleActions(status) + }); + const actions = lifecycleActions(status); return ( - {!canManage ? ( -

    - Owner or admin access is required for lifecycle actions. -

    - ) : actions.length ? ( -
    { - event.preventDefault() - void form.handleSubmit() - }} - > -
    - {actions.map((action) => ( - - ))} -
    - {target ? ( - <> - - value.trim().length >= 8 - ? undefined - : "Provide a safe reason (at least 8 characters).", - }} - > - {(field) => ( - - Reason for {target} - field.handleChange(event.currentTarget.value)} - /> - - )} - -

    - Offline devices apply this state only after accepting the new release. Mosaic does - not claim universal immediate delivery. + {(() => { + if (canManage) { + return (() => { + if (actions.length) { + return ( + { + event.preventDefault(); + form.handleSubmit(); + }} + > +

    + {actions.map((action) => ( + + ))} +
    + {target ? ( + <> + + value.trim().length >= 8 + ? undefined + : "Provide a safe reason (at least 8 characters).", + }} + > + {(field) => ( + + + Reason for {target} + + + field.handleChange(event.currentTarget.value) + } + value={field.state.value} + /> + + )} + +

    + Offline devices apply this state only after accepting + the new release. Mosaic does not claim universal + immediate delivery. +

    + + + ) : null} + {mutation.error ? ( +

    + {mutation.error.message} +

    + ) : null} +
    + ); + } + return ( +

    + This terminal state cannot return to Running.

    - - - ) : null} - {mutation.error ? ( -

    - {mutation.error.message} -

    - ) : null} - - ) : ( -

    - This terminal state cannot return to Running. -

    - )} + ); + })(); + } + return ( +

    + Owner or admin access is required for lifecycle actions. +

    + ); + })()}
    - ) + ); } function ValidationPanel({ @@ -357,33 +431,39 @@ function ValidationPanel({ revision, scope, }: { - canManage: boolean - experimentId: string - revision?: number - scope: ExperimentScope + canManage: boolean; + experimentId: string; + revision?: number; + scope: ExperimentScope; }) { - const adapter = useExperimentAdapter() - const queryClient = useQueryClient() - const validation = useMutation(validateExperimentMutationOptions(scope, experimentId, adapter)) + const adapter = useExperimentAdapter(); + const queryClient = useQueryClient(); + const validation = useMutation( + validateExperimentMutationOptions(scope, experimentId, adapter) + ); + const handleClick = useCallback(() => validation.mutate(), [validation]); const publish = useMutation( - publishExperimentMutationOptions(scope, experimentId, adapter, queryClient), - ) + publishExperimentMutationOptions(scope, experimentId, adapter, queryClient) + ); return (
    {exportMutation.data ? ( -

    - Export {exportMutation.data.id} is {exportMutation.data.status}. Return to Analytics - exports to download it when complete. +

    + Export {exportMutation.data.id} is {exportMutation.data.status}. + Return to Analytics exports to download it when complete.

    ) : null} {exportMutation.error ? ( -

    +

    {exportMutation.error.message}

    ) : null} @@ -557,24 +668,27 @@ function HistoryPanel({
      {history.data.map((entry) => (
    1. -

      {entry.summary}

      -

      - {new Date(entry.createdAt).toLocaleString()} · {entry.actorLabel ?? "System"} +

      {entry.summary}

      +

      + {new Date(entry.createdAt).toLocaleString()} ·{" "} + {entry.actorLabel ?? "System"} {entry.releaseId ? ` · Release ${entry.releaseId}` : ""}

      - {entry.reason ?

      {entry.reason}

      : null} + {entry.reason ? ( +

      {entry.reason}

      + ) : null}
    2. ))}
    - ) + ); } const QA_IDENTITY_OPTIONS = [ { label: "Identified user", value: "identified_user" }, { label: "Installation", value: "installation" }, -] +]; function QaPanel({ activeVersionId, @@ -584,22 +698,22 @@ function QaPanel({ scope, variants, }: { - activeVersionId?: string - canManage: boolean - experimentId: string - isProduction: boolean - scope: ExperimentScope - variants: readonly { id?: string; name: string }[] + activeVersionId?: string; + canManage: boolean; + experimentId: string; + isProduction: boolean; + scope: ExperimentScope; + variants: readonly { id?: string; name: string }[]; }) { - const adapter = useExperimentAdapter() - const queryClient = useQueryClient() - const qa = useQuery(experimentQaQueryOptions(scope, experimentId, adapter)) + const adapter = useExperimentAdapter(); + const queryClient = useQueryClient(); + const qa = useQuery(experimentQaQueryOptions(scope, experimentId, adapter)); const create = useMutation( - createQaOverrideMutationOptions(scope, experimentId, adapter, queryClient), - ) + createQaOverrideMutationOptions(scope, experimentId, adapter, queryClient) + ); const remove = useMutation( - deleteQaOverrideMutationOptions(scope, experimentId, adapter, queryClient), - ) + deleteQaOverrideMutationOptions(scope, experimentId, adapter, queryClient) + ); const form = useForm({ defaultValues: { identityType: "identified_user" as const, @@ -607,38 +721,62 @@ function QaPanel({ variantId: variants[0]?.id ?? "", }, onSubmit: async ({ value, formApi }) => { - const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() - if (!activeVersionId) return - await create.mutateAsync({ ...value, experimentVersionId: activeVersionId, expiresAt }) - formApi.reset() + const expiresAt = new Date( + Date.now() + 24 * 60 * 60 * 1000 + ).toISOString(); + if (!activeVersionId) { + return; + } + await create.mutateAsync({ + ...value, + experimentVersionId: activeVersionId, + expiresAt, + }); + formApi.reset(); }, - }) + }); const variantOptions = variants.map((variant) => ({ label: variant.name, value: variant.id ?? "", - })) - if (qa.isPending) return - if (qa.error) - return void qa.refetch()} /> + })); + if (qa.isPending) { + return ; + } + if (qa.error) { + return ( + { + qa.refetch(); + }} + /> + ); + } return (
    {isProduction ? ( -
    -

    QA Overrides are prohibited in production

    -

    - A production candidate containing an override is rejected completely. +

    +

    + QA Overrides are prohibited in production +

    +

    + A production candidate containing an override is rejected + completely.

    ) : ( { - event.preventDefault() - void form.handleSubmit() + event.preventDefault(); + form.handleSubmit(); }} > @@ -647,8 +785,10 @@ function QaPanel({ Safe label + field.handleChange(event.currentTarget.value) + } value={field.state.value} - onChange={(event) => field.handleChange(event.currentTarget.value)} /> )} @@ -682,7 +822,9 @@ function QaPanel({ Identity type + field.handleChange(event.currentTarget.value) + } value={selectedGroup?.name ?? field.state.value} - onChange={(event) => field.handleChange(event.currentTarget.value)} /> )} @@ -193,10 +243,14 @@ export function MutualExclusionGroupManager({ scope }: { scope: ExperimentScope {(field) => ( - Group assignment identity + + Group assignment identity + setAllocations((current) => ({ ...current, [experimentId]: event.currentTarget.value, })) } + step="0.01" + type="number" + value={allocations[experimentId] ?? "0"} />

    - % · {Math.round(Number(allocations[experimentId] ?? 0) * 100)} bp + % ·{" "} + {Math.round( + Number(allocations[experimentId] ?? 0) * 100 + )}{" "} + bp

    - ) + ); }) ) : (

    @@ -274,52 +337,69 @@ export function MutualExclusionGroupManager({ scope }: { scope: ExperimentScope {(field) => ( - Normal Placement holdout (%) + + Normal Placement holdout (%) + + field.handleChange(event.currentTarget.value) + } step="0.01" type="number" value={field.state.value} - onChange={(event) => field.handleChange(event.currentTarget.value)} />

    - {Math.round(Number(field.state.value || 0) * 100)} basis points + {Math.round(Number(field.state.value || 0) * 100)} basis + points

    )}

    - Group Versions reference stable Experiment IDs, not published Experiment Versions. A new - Group Version never mutates history. Failure of the selected Experiment uses normal - Placement; it never selects another member. + Group Versions reference stable Experiment IDs, not published + Experiment Versions. A new Group Version never mutates history. + Failure of the selected Experiment uses normal Placement; it never + selects another member.

    {localError || createGroup.error || createVersion.error ? (

    - {createGroup.error?.message ?? createVersion.error?.message ?? localError} + {createGroup.error?.message ?? + createVersion.error?.message ?? + localError}

    ) : null} {createGroup.data ? ( -

    +

    Created {createGroup.data.name} with immutable group Version{" "} {createGroup.data.versionId}.

    ) : null} {createVersion.data ? ( -

    - Created immutable group Version {createVersion.data.versionNumber}. +

    + Created immutable group Version {createVersion.data.versionNumber} + .

    ) : null} - - ) + ); } diff --git a/apps/dashboard/src/features/experiments/components/new-experiment-page.tsx b/apps/dashboard/src/features/experiments/components/new-experiment-page.tsx index 06d32b72..979bb4fb 100644 --- a/apps/dashboard/src/features/experiments/components/new-experiment-page.tsx +++ b/apps/dashboard/src/features/experiments/components/new-experiment-page.tsx @@ -1,10 +1,10 @@ -import { MonetizationWorkspace } from "@/features/environments/components/monetization-workspace" -import { ExperimentBuilder } from "./experiment-builder" +import { MonetizationWorkspace } from "@/features/environments/components/monetization-workspace"; +import { ExperimentBuilder } from "./experiment-builder"; export function NewExperimentPage(props: { - environmentId: string - organizationId: string - projectId: string + environmentId: string; + organizationId: string; + projectId: string; }) { return ( - ) + ); } diff --git a/apps/dashboard/src/features/experiments/mutations/experiment-mutations.ts b/apps/dashboard/src/features/experiments/mutations/experiment-mutations.ts index 9a085e32..fc79748b 100644 --- a/apps/dashboard/src/features/experiments/mutations/experiment-mutations.ts +++ b/apps/dashboard/src/features/experiments/mutations/experiment-mutations.ts @@ -1,175 +1,210 @@ -import { mutationOptions, type QueryClient } from "@tanstack/react-query" +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; -import type { ExperimentAdapter } from "../api/experiment-adapter" -import { experimentKeys } from "../queries/experiment-queries" +import type { ExperimentAdapter } from "../api/experiment-adapter"; +import { experimentKeys } from "../queries/experiment-queries"; import type { CreateMutualExclusionGroupInput, ExperimentDraftDocument, ExperimentScope, ExperimentStatus, QaOverride, -} from "../types/experiment" +} from "../types/experiment"; async function invalidateExperiment( queryClient: QueryClient, scope: ExperimentScope, - experimentId?: string, + experimentId?: string ) { - await queryClient.invalidateQueries({ queryKey: experimentKeys.list(scope) }) + await queryClient.invalidateQueries({ queryKey: experimentKeys.list(scope) }); if (experimentId) { await Promise.all([ - queryClient.invalidateQueries({ queryKey: experimentKeys.detail(scope, experimentId) }), - queryClient.invalidateQueries({ queryKey: experimentKeys.history(scope, experimentId) }), - queryClient.invalidateQueries({ queryKey: experimentKeys.results(scope, experimentId) }), - ]) + queryClient.invalidateQueries({ + queryKey: experimentKeys.detail(scope, experimentId), + }), + queryClient.invalidateQueries({ + queryKey: experimentKeys.history(scope, experimentId), + }), + queryClient.invalidateQueries({ + queryKey: experimentKeys.results(scope, experimentId), + }), + ]); } } export function createExperimentMutationOptions( scope: ExperimentScope, adapter: ExperimentAdapter, - queryClient: QueryClient, + queryClient: QueryClient ) { return mutationOptions({ - mutationFn: (input: { hypothesis?: string; name: string; placementId: string }) => - adapter.create(scope, input), - onSuccess: (experiment) => invalidateExperiment(queryClient, scope, experiment.id), - }) + mutationFn: (input: { + hypothesis?: string; + name: string; + placementId: string; + }) => adapter.create(scope, input), + onSuccess: (experiment) => + invalidateExperiment(queryClient, scope, experiment.id), + }); } export function createExperimentGroupMutationOptions( scope: ExperimentScope, adapter: ExperimentAdapter, - queryClient: QueryClient, + queryClient: QueryClient ) { return mutationOptions({ mutationFn: (input: CreateMutualExclusionGroupInput) => adapter.createMutualExclusionGroup(scope, input), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: experimentKeys.all }) + await queryClient.invalidateQueries({ queryKey: experimentKeys.all }); }, - }) + }); } export function createExperimentGroupVersionMutationOptions( scope: ExperimentScope, groupId: string, adapter: ExperimentAdapter, - queryClient: QueryClient, + queryClient: QueryClient ) { return mutationOptions({ mutationFn: (input: Omit) => adapter.createMutualExclusionGroupVersion(scope, groupId, input), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: experimentKeys.all }) + await queryClient.invalidateQueries({ queryKey: experimentKeys.all }); }, - }) + }); } export function saveExperimentDraftMutationOptions( scope: ExperimentScope, experimentId: string, adapter: ExperimentAdapter, - queryClient: QueryClient, + queryClient: QueryClient ) { return mutationOptions({ - mutationFn: (input: { document: ExperimentDraftDocument; expectedRevision: number }) => - adapter.saveDraft(scope, experimentId, input.document, input.expectedRevision), + mutationFn: (input: { + document: ExperimentDraftDocument; + expectedRevision: number; + }) => + adapter.saveDraft( + scope, + experimentId, + input.document, + input.expectedRevision + ), onSuccess: async () => { - await invalidateExperiment(queryClient, scope, experimentId) + await invalidateExperiment(queryClient, scope, experimentId); }, - }) + }); } export function validateExperimentMutationOptions( scope: ExperimentScope, experimentId: string, - adapter: ExperimentAdapter, + adapter: ExperimentAdapter ) { - return mutationOptions({ mutationFn: () => adapter.validate(scope, experimentId) }) + return mutationOptions({ + mutationFn: () => adapter.validate(scope, experimentId), + }); } export function publishExperimentMutationOptions( scope: ExperimentScope, experimentId: string, adapter: ExperimentAdapter, - queryClient: QueryClient, + queryClient: QueryClient ) { return mutationOptions({ mutationFn: (expectedRevision: number) => adapter.publish(scope, experimentId, expectedRevision), onSuccess: () => invalidateExperiment(queryClient, scope, experimentId), - }) + }); } export function transitionExperimentMutationOptions( scope: ExperimentScope, experimentId: string, adapter: ExperimentAdapter, - queryClient: QueryClient, + queryClient: QueryClient ) { return mutationOptions({ mutationFn: (input: { reason: string; target: ExperimentStatus }) => - input.target === "archived" - ? adapter.archive(scope, experimentId, input.reason) - : input.target === "completed" - ? adapter.complete(scope, experimentId, input.reason) - : adapter.transition(scope, experimentId, input.target, input.reason), + (() => { + if (input.target === "archived") { + return adapter.archive(scope, experimentId, input.reason); + } + if (input.target === "completed") { + return adapter.complete(scope, experimentId, input.reason); + } + return adapter.transition( + scope, + experimentId, + input.target, + input.reason + ); + })(), onSuccess: () => invalidateExperiment(queryClient, scope, experimentId), - }) + }); } export function emergencyStopMutationOptions( scope: ExperimentScope, experimentId: string, adapter: ExperimentAdapter, - queryClient: QueryClient, + queryClient: QueryClient ) { return mutationOptions({ - mutationFn: (reason: string) => adapter.emergencyStop(scope, experimentId, reason), + mutationFn: (reason: string) => + adapter.emergencyStop(scope, experimentId, reason), onSuccess: () => invalidateExperiment(queryClient, scope, experimentId), - }) + }); } export function createQaOverrideMutationOptions( scope: ExperimentScope, experimentId: string, adapter: ExperimentAdapter, - queryClient: QueryClient, + queryClient: QueryClient ) { return mutationOptions({ mutationFn: (input: { - expiresAt: string - experimentVersionId: string - identityType: QaOverride["identityType"] - label: string - variantId: string + expiresAt: string; + experimentVersionId: string; + identityType: QaOverride["identityType"]; + label: string; + variantId: string; }) => adapter.createQaOverride(scope, experimentId, input), onSuccess: () => - queryClient.invalidateQueries({ queryKey: experimentKeys.qa(scope, experimentId) }), - }) + queryClient.invalidateQueries({ + queryKey: experimentKeys.qa(scope, experimentId), + }), + }); } export function deleteQaOverrideMutationOptions( scope: ExperimentScope, experimentId: string, adapter: ExperimentAdapter, - queryClient: QueryClient, + queryClient: QueryClient ) { return mutationOptions({ - mutationFn: (overrideId: string) => adapter.deleteQaOverride(scope, experimentId, overrideId), + mutationFn: (overrideId: string) => + adapter.deleteQaOverride(scope, experimentId, overrideId), onSuccess: () => - queryClient.invalidateQueries({ queryKey: experimentKeys.qa(scope, experimentId) }), - }) + queryClient.invalidateQueries({ + queryKey: experimentKeys.qa(scope, experimentId), + }), + }); } export function exportExperimentMutationOptions( scope: ExperimentScope, experimentId: string, - adapter: ExperimentAdapter, + adapter: ExperimentAdapter ) { return mutationOptions({ mutationFn: (identityScoped: boolean) => adapter.requestExport(scope, experimentId, identityScoped), - }) + }); } diff --git a/apps/dashboard/src/features/experiments/queries/experiment-queries.ts b/apps/dashboard/src/features/experiments/queries/experiment-queries.ts index 7f2da500..fb2db136 100644 --- a/apps/dashboard/src/features/experiments/queries/experiment-queries.ts +++ b/apps/dashboard/src/features/experiments/queries/experiment-queries.ts @@ -1,17 +1,29 @@ -import { queryOptions } from "@tanstack/react-query" +import { queryOptions } from "@tanstack/react-query"; -import type { ExperimentAdapter } from "../api/experiment-adapter" -import type { ExperimentScope } from "../types/experiment" +import type { ExperimentAdapter } from "../api/experiment-adapter"; +import type { ExperimentScope } from "../types/experiment"; -const scopeKey = (scope: ExperimentScope) => [scope.projectId, scope.environmentId] as const +const scopeKey = (scope: ExperimentScope) => + [scope.projectId, scope.environmentId] as const; export const experimentKeys = { all: ["experiments"] as const, detail: (scope: ExperimentScope, experimentId: string) => - [...experimentKeys.all, ...scopeKey(scope), "detail", experimentId] as const, + [ + ...experimentKeys.all, + ...scopeKey(scope), + "detail", + experimentId, + ] as const, history: (scope: ExperimentScope, experimentId: string) => - [...experimentKeys.all, ...scopeKey(scope), "history", experimentId] as const, - list: (scope: ExperimentScope) => [...experimentKeys.all, ...scopeKey(scope), "list"] as const, + [ + ...experimentKeys.all, + ...scopeKey(scope), + "history", + experimentId, + ] as const, + list: (scope: ExperimentScope) => + [...experimentKeys.all, ...scopeKey(scope), "list"] as const, metrics: (scope: ExperimentScope) => [...experimentKeys.all, ...scopeKey(scope), "metrics"] as const, groups: (scope: ExperimentScope) => @@ -23,93 +35,109 @@ export const experimentKeys = { qa: (scope: ExperimentScope, experimentId: string) => [...experimentKeys.all, ...scopeKey(scope), "qa", experimentId] as const, results: (scope: ExperimentScope, experimentId: string) => - [...experimentKeys.all, ...scopeKey(scope), "results", experimentId] as const, -} + [ + ...experimentKeys.all, + ...scopeKey(scope), + "results", + experimentId, + ] as const, +}; -export function experimentsQueryOptions(scope: ExperimentScope, adapter: ExperimentAdapter) { +export function experimentsQueryOptions( + scope: ExperimentScope, + adapter: ExperimentAdapter +) { return queryOptions({ queryKey: [...experimentKeys.list(scope), adapter], queryFn: () => adapter.list(scope), - }) + }); } -export function experimentGroupsQueryOptions(scope: ExperimentScope, adapter: ExperimentAdapter) { +export function experimentGroupsQueryOptions( + scope: ExperimentScope, + adapter: ExperimentAdapter +) { return queryOptions({ queryKey: [...experimentKeys.groups(scope), adapter], queryFn: () => adapter.listMutualExclusionGroups(scope), - }) + }); } export function experimentGroupVersionsQueryOptions( scope: ExperimentScope, groupId: string, - adapter: ExperimentAdapter, + adapter: ExperimentAdapter ) { return queryOptions({ enabled: Boolean(groupId), queryKey: [...experimentKeys.groupVersions(scope, groupId), adapter], queryFn: () => adapter.listMutualExclusionGroupVersions(scope, groupId), - }) + }); } export function experimentQueryOptions( scope: ExperimentScope, experimentId: string, - adapter: ExperimentAdapter, + adapter: ExperimentAdapter ) { return queryOptions({ queryKey: [...experimentKeys.detail(scope, experimentId), adapter], queryFn: () => adapter.get(scope, experimentId), - }) + }); } export function experimentBuilderResourcesQueryOptions( scope: ExperimentScope, - adapter: ExperimentAdapter, + adapter: ExperimentAdapter ) { return queryOptions({ - queryKey: [...experimentKeys.all, ...scopeKey(scope), "builder-resources", adapter], + queryKey: [ + ...experimentKeys.all, + ...scopeKey(scope), + "builder-resources", + adapter, + ], queryFn: async () => { const [paywallVersions, metrics, groups] = await Promise.all([ adapter.listImmutablePaywallVersions(scope), adapter.listMetricDefinitions(scope), adapter.listMutualExclusionGroups(scope), - ]) - return { groups, metrics, paywallVersions } + ]); + return { groups, metrics, paywallVersions }; }, - }) + }); } export function experimentResultsQueryOptions( scope: ExperimentScope, experimentId: string, - adapter: ExperimentAdapter, + adapter: ExperimentAdapter ) { return queryOptions({ queryKey: [...experimentKeys.results(scope, experimentId), adapter], queryFn: () => adapter.results(scope, experimentId), refetchInterval: 60_000, - }) + }); } export function experimentHistoryQueryOptions( scope: ExperimentScope, experimentId: string, - adapter: ExperimentAdapter, + adapter: ExperimentAdapter ) { return queryOptions({ queryKey: [...experimentKeys.history(scope, experimentId), adapter], queryFn: () => adapter.history(scope, experimentId), - }) + }); } export function experimentQaQueryOptions( scope: ExperimentScope, experimentId: string, - adapter: ExperimentAdapter, + adapter: ExperimentAdapter ) { return queryOptions({ queryKey: [...experimentKeys.qa(scope, experimentId), adapter], queryFn: () => adapter.listQaOverrides(scope, experimentId), - }) + }); } diff --git a/apps/dashboard/src/features/experiments/types/experiment.test.ts b/apps/dashboard/src/features/experiments/types/experiment.test.ts index 47cf5656..422cd3c9 100644 --- a/apps/dashboard/src/features/experiments/types/experiment.test.ts +++ b/apps/dashboard/src/features/experiments/types/experiment.test.ts @@ -1,21 +1,21 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it } from "vitest"; import { + canRequestExperimentExport, canSelectMetric, describeMetricEventFilter, - canRequestExperimentExport, + type ExperimentVariantDraft, lifecycleActions, localDateTimeToUtc, + type MetricDefinitionOption, utcToLocalDateTime, validateAllocation, validateMutualExclusionAllocation, - type ExperimentVariantDraft, - type MetricDefinitionOption, -} from "./experiment" +} from "./experiment"; function variant( role: "control" | "treatment", - allocationBasisPoints: number, + allocationBasisPoints: number ): ExperimentVariantDraft { return { allocationBasisPoints, @@ -23,7 +23,7 @@ function variant( paywallId: `paywall-${role}-${allocationBasisPoints}`, paywallVersionId: `version-${role}-${allocationBasisPoints}`, role, - } + }; } describe("Experiment authoring invariants", () => { @@ -39,61 +39,63 @@ describe("Experiment authoring invariants", () => { id: "provider_unavailability_rate", name: "Provider unavailability rate", versionId: "provider_unavailability_rate@1", - } + }; - expect(canSelectMetric(metric)).toBe(false) + expect(canSelectMetric(metric)).toBe(false); expect(describeMetricEventFilter(metric.eventFilter)).toBe( - "payload.reason = provider_unavailable", - ) - expect(canSelectMetric({ ...metric, availability: "available" })).toBe(true) - }) + "payload.reason = provider_unavailable" + ); + expect(canSelectMetric({ ...metric, availability: "available" })).toBe( + true + ); + }); it("rejects allocation that does not cover all 10,000 buckets", () => { - expect(validateAllocation([variant("control", 5_000), variant("treatment", 4_999)])).toMatch( - /10,000 basis points/, - ) expect( - validateAllocation([variant("control", 5_000), variant("treatment", 5_000)]), - ).toBeUndefined() - }) + validateAllocation([variant("control", 5000), variant("treatment", 4999)]) + ).toMatch(/10,000 basis points/); + expect( + validateAllocation([variant("control", 5000), variant("treatment", 5000)]) + ).toBeUndefined(); + }); it("keeps terminal lifecycle states from returning to Running", () => { - expect(lifecycleActions("stopped")).toEqual(["archived"]) - expect(lifecycleActions("completed")).toEqual(["archived"]) - expect(lifecycleActions("archived")).toEqual([]) - }) + expect(lifecycleActions("stopped")).toEqual(["archived"]); + expect(lifecycleActions("completed")).toEqual(["archived"]); + expect(lifecycleActions("archived")).toEqual([]); + }); it("reserves identity-scoped export for owners", () => { - expect(canRequestExperimentExport("owner", true)).toBe(true) - expect(canRequestExperimentExport("admin", true)).toBe(false) - expect(canRequestExperimentExport("admin", false)).toBe(true) - expect(canRequestExperimentExport("member", false)).toBe(false) - }) + expect(canRequestExperimentExport("owner", true)).toBe(true); + expect(canRequestExperimentExport("admin", true)).toBe(false); + expect(canRequestExperimentExport("admin", false)).toBe(true); + expect(canRequestExperimentExport("member", false)).toBe(false); + }); it("requires stable Experiment roots and holdout to cover all buckets exactly once", () => { expect( validateMutualExclusionAllocation( [ - { allocationBasisPoints: 4_500, experimentId: "experiment-a" }, - { allocationBasisPoints: 4_500, experimentId: "experiment-b" }, + { allocationBasisPoints: 4500, experimentId: "experiment-a" }, + { allocationBasisPoints: 4500, experimentId: "experiment-b" }, ], - 1_000, - ), - ).toBeUndefined() + 1000 + ) + ).toBeUndefined(); expect( validateMutualExclusionAllocation( [ - { allocationBasisPoints: 5_000, experimentId: "experiment-a" }, - { allocationBasisPoints: 5_000, experimentId: "experiment-a" }, + { allocationBasisPoints: 5000, experimentId: "experiment-a" }, + { allocationBasisPoints: 5000, experimentId: "experiment-a" }, ], - 0, - ), - ).toMatch(/only once/) - }) + 0 + ) + ).toMatch(/only once/); + }); it("round-trips a UTC schedule through datetime-local without changing the instant", () => { - const instant = "2026-07-27T12:34:00.000Z" + const instant = "2026-07-27T12:34:00.000Z"; - expect(localDateTimeToUtc(utcToLocalDateTime(instant))).toBe(instant) - }) -}) + expect(localDateTimeToUtc(utcToLocalDateTime(instant))).toBe(instant); + }); +}); diff --git a/apps/dashboard/src/features/experiments/types/experiment.ts b/apps/dashboard/src/features/experiments/types/experiment.ts index 8089c9d3..04a22791 100644 --- a/apps/dashboard/src/features/experiments/types/experiment.ts +++ b/apps/dashboard/src/features/experiments/types/experiment.ts @@ -1,293 +1,321 @@ export type ExperimentStatus = - "draft" | "scheduled" | "running" | "paused" | "stopped" | "completed" | "archived" + | "draft" + | "scheduled" + | "running" + | "paused" + | "stopped" + | "completed" + | "archived"; export type AssignmentKeyPolicy = - "installation" | "identified_user" | "identified_user_or_installation" + | "installation" + | "identified_user" + | "identified_user_or_installation"; -export type ExperimentRole = "control" | "treatment" +export type ExperimentRole = "control" | "treatment"; export interface ExperimentScope { - environmentId: string - projectId: string + environmentId: string; + projectId: string; } export interface ExperimentVariantDraft { - allocationBasisPoints: number - id?: string - name: string - paywallId: string - paywallVersionId: string - role: ExperimentRole + allocationBasisPoints: number; + id?: string; + name: string; + paywallId: string; + paywallVersionId: string; + role: ExperimentRole; } export interface ExperimentDraftDocument { - assignmentKeyPolicy: AssignmentKeyPolicy - endsAt?: string - guardrailMetricVersionIds: readonly string[] - hypothesis?: string - mutualExclusionGroupVersionId?: string - primaryMetricVersionId: string - qaEnabled?: boolean - startsAt: string - variants: readonly ExperimentVariantDraft[] + assignmentKeyPolicy: AssignmentKeyPolicy; + endsAt?: string; + guardrailMetricVersionIds: readonly string[]; + hypothesis?: string; + mutualExclusionGroupVersionId?: string; + primaryMetricVersionId: string; + qaEnabled?: boolean; + startsAt: string; + variants: readonly ExperimentVariantDraft[]; } export interface ExperimentDraft extends ExperimentDraftDocument { - id: string - revision: number - updatedAt: string + id: string; + revision: number; + updatedAt: string; } export interface ExperimentListItem { - activeVersionId?: string - activeVersionNumber?: number - id: string - name: string - placementId: string - placementName: string - status: ExperimentStatus - updatedAt: string + activeVersionId?: string; + activeVersionNumber?: number; + id: string; + name: string; + placementId: string; + placementName: string; + status: ExperimentStatus; + updatedAt: string; } export interface ExperimentDetail extends ExperimentListItem { - activeDefinition?: ExperimentActiveDefinition - activeVariants?: readonly ExperimentVariantDraft[] - activeVersionId?: string - currentDraft?: ExperimentDraft - hypothesis?: string + activeDefinition?: ExperimentActiveDefinition; + activeVariants?: readonly ExperimentVariantDraft[]; + activeVersionId?: string; + currentDraft?: ExperimentDraft; + hypothesis?: string; } export interface ExperimentActiveDefinition extends ExperimentDraftDocument { - allocationVersion: string - bucketingAlgorithm: string - publishedAt: string - qaPolicyEnabled?: boolean - sourceRevision: number + allocationVersion: string; + bucketingAlgorithm: string; + publishedAt: string; + qaPolicyEnabled?: boolean; + sourceRevision: number; } export interface ImmutablePaywallVersionOption { - createdAt: string - id: string - paywallId: string - paywallName: string - versionNumber: number + createdAt: string; + id: string; + paywallId: string; + paywallName: string; + versionNumber: number; } export interface MetricDefinitionOption { - assignmentUnit: "assignment_key" - authority: "client_observed" | "provider_confirmed" - availability: "available" | "trusted_source_unavailable" - definition: string - eligibleAsGuardrail: boolean - eligibleAsPrimary: boolean - eventFilter: { "payload.reason"?: "provider_unavailable" } - id: string - name: string - versionId: string + assignmentUnit: "assignment_key"; + authority: "client_observed" | "provider_confirmed"; + availability: "available" | "trusted_source_unavailable"; + definition: string; + eligibleAsGuardrail: boolean; + eligibleAsPrimary: boolean; + eventFilter: { "payload.reason"?: "provider_unavailable" }; + id: string; + name: string; + versionId: string; } export interface MutualExclusionGroupOption { - assignmentKeyPolicy?: AssignmentKeyPolicy - holdoutBasisPoints?: number - id: string - members?: readonly { allocationBasisPoints: number; experimentId: string }[] - name: string - versionId: string + assignmentKeyPolicy?: AssignmentKeyPolicy; + holdoutBasisPoints?: number; + id: string; + members?: readonly { allocationBasisPoints: number; experimentId: string }[]; + name: string; + versionId: string; } export interface CreateMutualExclusionGroupInput { - assignmentKeyPolicy: AssignmentKeyPolicy - holdoutBasisPoints: number - members: readonly { allocationBasisPoints: number; experimentId: string }[] - name: string + assignmentKeyPolicy: AssignmentKeyPolicy; + holdoutBasisPoints: number; + members: readonly { allocationBasisPoints: number; experimentId: string }[]; + name: string; } export interface MutualExclusionGroupVersion { - assignmentKeyPolicy: AssignmentKeyPolicy - createdAt: string - groupId: string - holdoutBasisPoints: number - id: string - members: readonly { allocationBasisPoints: number; experimentId: string }[] - versionNumber: number + assignmentKeyPolicy: AssignmentKeyPolicy; + createdAt: string; + groupId: string; + holdoutBasisPoints: number; + id: string; + members: readonly { allocationBasisPoints: number; experimentId: string }[]; + versionNumber: number; } -export type ExperimentIssueSeverity = "info" | "warning" | "critical" | "error" +export type ExperimentIssueSeverity = "info" | "warning" | "critical" | "error"; export interface ExperimentIssue { - affectedResources?: readonly string[] - code: string - continues: boolean - investigation?: string - message: string - recoveryAction?: string - severity: ExperimentIssueSeverity - title: string + affectedResources?: readonly string[]; + code: string; + continues: boolean; + investigation?: string; + message: string; + recoveryAction?: string; + severity: ExperimentIssueSeverity; + title: string; } export interface ExperimentValidation { - canPublish: boolean - issues: readonly ExperimentIssue[] + canPublish: boolean; + issues: readonly ExperimentIssue[]; } export interface Interval { - high: number - low: number + high: number; + low: number; } export interface VariantResult { - allocationBasisPoints: number - conversions: number - estimate: number - interval: Interval - name: string - role: ExperimentRole - uniqueExposures: number - variantId: string + allocationBasisPoints: number; + conversions: number; + estimate: number; + interval: Interval; + name: string; + role: ExperimentRole; + uniqueExposures: number; + variantId: string; } export interface TreatmentLift { - absoluteLift: number - interval: Interval - relativeLift?: number - variantId: string + absoluteLift: number; + interval: Interval; + relativeLift?: number; + variantId: string; } export interface GuardrailResult { - code?: string - estimate?: number - investigation?: string - name: string - recoveryAction?: string - severity: "ok" | "warning" | "critical" | "unavailable" - summary: string + code?: string; + estimate?: number; + investigation?: string; + name: string; + recoveryAction?: string; + severity: "ok" | "warning" | "critical" | "unavailable"; + summary: string; } export interface ExperimentResults { - aggregateUpdatedAt?: string - attributionWindowMature: boolean - fallbackExposures: number - freshnessMinutes?: number - guardrails: readonly GuardrailResult[] - interim: boolean - issues: readonly ExperimentIssue[] - observationEndsAt?: string - observationStartsAt?: string - primaryMetricName: string - primaryMetricAuthority: "client_observed" | "provider_confirmed" - primaryMetricAvailability: "available" | "trusted_source_unavailable" - primaryMetricEventFilter: { "payload.reason"?: "provider_unavailable" } + aggregateUpdatedAt?: string; + attributionWindowMature: boolean; + fallbackExposures: number; + freshnessMinutes?: number; + guardrails: readonly GuardrailResult[]; + interim: boolean; + issues: readonly ExperimentIssue[]; + observationEndsAt?: string; + observationStartsAt?: string; + primaryMetricAuthority: "client_observed" | "provider_confirmed"; + primaryMetricAvailability: "available" | "trusted_source_unavailable"; + primaryMetricEventFilter: { "payload.reason"?: "provider_unavailable" }; + primaryMetricName: string; srm: { cells: readonly { - expected: number - expectedShare: number - observed: number - observedShare: number - variantId: string - }[] - degreesOfFreedom: number - exclusions: readonly string[] - pValue: number - severity: "none" | "warning" | "critical" - statistic: number - status: "insufficient_sample" | "ok" | "mismatch" - } - treatments: readonly TreatmentLift[] - variants: readonly VariantResult[] + expected: number; + expectedShare: number; + observed: number; + observedShare: number; + variantId: string; + }[]; + degreesOfFreedom: number; + exclusions: readonly string[]; + pValue: number; + severity: "none" | "warning" | "critical"; + statistic: number; + status: "insufficient_sample" | "ok" | "mismatch"; + }; + treatments: readonly TreatmentLift[]; + variants: readonly VariantResult[]; } export interface ExperimentHistoryEntry { - actorLabel?: string - createdAt: string - id: string - reason?: string - releaseId?: string - summary: string + actorLabel?: string; + createdAt: string; + id: string; + reason?: string; + releaseId?: string; + summary: string; } export interface QaOverride { - expiresAt: string - id: string - identityType: "installation" | "identified_user" - label: string - variantId: string - visibleSelectorDigest: string + expiresAt: string; + id: string; + identityType: "installation" | "identified_user"; + label: string; + variantId: string; + visibleSelectorDigest: string; } export interface QaOverrideCreated { - override: QaOverride - token: string + override: QaOverride; + token: string; } export interface ExperimentExportJob { - expiresAt?: string - id: string - identityScoped: boolean - status: "queued" | "running" | "completed" | "failed" | "expired" + expiresAt?: string; + id: string; + identityScoped: boolean; + status: "queued" | "running" | "completed" | "failed" | "expired"; } export function allocationTotal(variants: readonly ExperimentVariantDraft[]) { - return variants.reduce((total, variant) => total + variant.allocationBasisPoints, 0) + return variants.reduce( + (total, variant) => total + variant.allocationBasisPoints, + 0 + ); } export function canSelectMetric(metric: MetricDefinitionOption) { - return metric.availability === "available" + return metric.availability === "available"; } -export function describeMetricEventFilter(filter: MetricDefinitionOption["eventFilter"]) { - return filter["payload.reason"] +export function describeMetricEventFilter( + filter: MetricDefinitionOption["eventFilter"] | undefined +) { + return filter?.["payload.reason"] ? `payload.reason = ${filter["payload.reason"]}` - : "All qualifying events" + : "All qualifying events"; } export function localDateTimeToUtc(value?: string) { - if (!value) return undefined - const date = new Date(value) - return Number.isNaN(date.getTime()) ? undefined : date.toISOString() + if (!value) { + return; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); } export function utcToLocalDateTime(value?: string) { - if (!value) return "" - const date = new Date(value) - if (Number.isNaN(date.getTime())) return "" - const offset = date.getTimezoneOffset() * 60_000 - return new Date(date.getTime() - offset).toISOString().slice(0, 16) + if (!value) { + return ""; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ""; + } + const offset = date.getTimezoneOffset() * 60_000; + return new Date(date.getTime() - offset).toISOString().slice(0, 16); } -export function validateAllocation(variants: readonly ExperimentVariantDraft[]) { +export function validateAllocation( + variants: readonly ExperimentVariantDraft[] +) { if (variants.length < 2 || variants.length > 4) { - return "Use exactly one Control and one to three Treatments." + return "Use exactly one Control and one to three Treatments."; } if (variants.filter((variant) => variant.role === "control").length !== 1) { - return "Choose exactly one Control." + return "Choose exactly one Control."; } if (variants.some((variant) => variant.allocationBasisPoints <= 0)) { - return "Every Variant needs a positive allocation." + return "Every Variant needs a positive allocation."; } if (allocationTotal(variants) !== 10_000) { - return "Traffic split must total exactly 100% (10,000 basis points)." + return "Traffic split must total exactly 100% (10,000 basis points)."; } - return undefined } export function validateMutualExclusionAllocation( members: readonly { allocationBasisPoints: number; experimentId: string }[], - holdoutBasisPoints: number, + holdoutBasisPoints: number ) { - if (members.length < 2) return "Select at least two Experiments." - if (new Set(members.map((member) => member.experimentId)).size !== members.length) { - return "Each Experiment can appear only once." + if (members.length < 2) { + return "Select at least two Experiments."; } - if (holdoutBasisPoints < 0 || members.some((member) => member.allocationBasisPoints <= 0)) { - return "Every group member needs a positive allocation and holdout cannot be negative." + if ( + new Set(members.map((member) => member.experimentId)).size !== + members.length + ) { + return "Each Experiment can appear only once."; } if ( - holdoutBasisPoints + members.reduce((sum, member) => sum + member.allocationBasisPoints, 0) !== + holdoutBasisPoints < 0 || + members.some((member) => member.allocationBasisPoints <= 0) + ) { + return "Every group member needs a positive allocation and holdout cannot be negative."; + } + if ( + holdoutBasisPoints + + members.reduce((sum, member) => sum + member.allocationBasisPoints, 0) !== 10_000 ) { - return "Experiment allocations plus normal-Placement holdout must total exactly 100% (10,000 basis points)." + return "Experiment allocations plus normal-Placement holdout must total exactly 100% (10,000 basis points)."; } - return undefined } export function lifecycleActions(status: ExperimentStatus) { @@ -299,13 +327,15 @@ export function lifecycleActions(status: ExperimentStatus) { stopped: ["archived"], completed: ["archived"], archived: [], - } - return actions[status] + }; + return actions[status]; } export function canRequestExperimentExport( role: "owner" | "admin" | "member" | undefined, - identityScoped: boolean, + identityScoped: boolean ) { - return identityScoped ? role === "owner" : role === "owner" || role === "admin" + return identityScoped + ? role === "owner" + : role === "owner" || role === "admin"; } diff --git a/apps/dashboard/src/features/members/components/members-page.tsx b/apps/dashboard/src/features/members/components/members-page.tsx index 81acc00b..99625131 100644 --- a/apps/dashboard/src/features/members/components/members-page.tsx +++ b/apps/dashboard/src/features/members/components/members-page.tsx @@ -1,8 +1,8 @@ -import { useState } from "react" -import { useForm } from "@tanstack/react-form" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useForm } from "@tanstack/react-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; -import { Button } from "@/components/ui/button" +import { Button } from "@/components/ui/button"; import { Dialog, DialogClose, @@ -12,41 +12,49 @@ import { DialogHeader, DialogTitle, DialogTrigger, -} from "@/components/ui/dialog" -import { Field, FieldDescription, FieldLabel } from "@/components/ui/field" -import { Input } from "@/components/ui/input" +} from "@/components/ui/dialog"; +import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/ui/select" -import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" -import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" -import { addMemberMutationOptions } from "@/features/members/mutations/member-mutations" -import { membersQueryOptions } from "@/features/members/queries/members-query" -import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" +} from "@/components/ui/select"; +import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary"; +import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state"; +import { addMemberMutationOptions } from "@/features/members/mutations/member-mutations"; +import { membersQueryOptions } from "@/features/members/queries/members-query"; +import { + WorkflowPanel, + WorkspacePage, +} from "@/features/orgs/components/workspace-page"; const ROLE_OPTIONS = [ { label: "Member", value: "member" }, { label: "Admin", value: "admin" }, -] +]; export function MembersPage({ organizationId }: { organizationId: string }) { - const queryClient = useQueryClient() - const members = useQuery(membersQueryOptions(organizationId)) - const mutation = useMutation(addMemberMutationOptions(organizationId, queryClient)) - const items = members.data?.items ?? [] - const [addOpen, setAddOpen] = useState(false) + const queryClient = useQueryClient(); + const members = useQuery(membersQueryOptions(organizationId)); + const mutation = useMutation( + addMemberMutationOptions(organizationId, queryClient) + ); + const items = members.data?.items ?? []; + const [addOpen, setAddOpen] = useState(false); const form = useForm({ defaultValues: { actorId: "", role: "member" as "admin" | "member" }, onSubmit: async ({ value }) => { - await mutation.mutateAsync({ actorId: value.actorId.trim(), role: value.role }) - form.reset() - setAddOpen(false) + await mutation.mutateAsync({ + actorId: value.actorId.trim(), + role: value.role, + }); + form.reset(); + setAddOpen(false); }, - }) + }); const state = resolveHostedQueryState({ emptyDescription: "Add an existing actor after the approved identity boundary can identify them.", @@ -55,18 +63,21 @@ export function MembersPage({ organizationId }: { organizationId: string }) { isEmpty: members.isSuccess && items.length === 0, isPending: members.isPending, loadingDescription: "Loading organization membership.", - onRetry: () => void members.refetch(), - permissionDescription: "Only organization owners and admins can manage memberships.", - }) - const canManageMembers = state.kind === "empty" || state.kind === "ready" + onRetry: () => { + members.refetch(); + }, + permissionDescription: + "Only organization owners and admins can manage memberships.", + }); + const canManageMembers = state.kind === "empty" || state.kind === "ready"; const addDialog = ( { - setAddOpen(open) + setAddOpen(open); if (!open) { - form.reset() - mutation.reset() + form.reset(); + mutation.reset(); } }} open={addOpen} @@ -75,16 +86,16 @@ export function MembersPage({ organizationId }: { organizationId: string }) {
    { - event.preventDefault() - event.stopPropagation() - void form.handleSubmit() + event.preventDefault(); + event.stopPropagation(); + form.handleSubmit(); }} > Add member - Invitations and email delivery remain deferred; this accepts an existing Actor ID - only. + Invitations and email delivery remain deferred; this accepts an + existing Actor ID only.
    @@ -98,7 +109,9 @@ export function MembersPage({ organizationId }: { organizationId: string }) { placeholder="actor_…" value={field.state.value} /> - Identity-provider-neutral subject ID. + + Identity-provider-neutral subject ID. + )} @@ -108,7 +121,9 @@ export function MembersPage({ organizationId }: { organizationId: string }) { Role 0 || undefined} autoComplete="organization" @@ -65,20 +82,33 @@ export function CreateOrganizationPage() { placeholder="Acme Mobile" value={field.state.value} /> - Visible to every member of this organization. - ({ message }))} /> + + Visible to every member of this organization. + + ({ + message, + }))} + /> )} {mutation.error && - !(mutation.error instanceof ApiError && mutation.error.status === 401) ? ( + !( + mutation.error instanceof ApiError && mutation.error.status === 401 + ) ? (

    {mutation.error.message}

    ) : null} - [state.canSubmit, state.isSubmitting]}> + [state.canSubmit, state.isSubmitting]} + > {([canSubmit, isSubmitting]) => ( - )} @@ -86,5 +116,5 @@ export function CreateOrganizationPage() { - ) + ); } diff --git a/apps/dashboard/src/features/orgs/components/organization-overview-page.tsx b/apps/dashboard/src/features/orgs/components/organization-overview-page.tsx index 72b91157..e4234395 100644 --- a/apps/dashboard/src/features/orgs/components/organization-overview-page.tsx +++ b/apps/dashboard/src/features/orgs/components/organization-overview-page.tsx @@ -1,17 +1,21 @@ -import { useQuery } from "@tanstack/react-query" -import { Link } from "@tanstack/react-router" +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; -import { buttonVariants } from "@/components/ui/button-variants" -import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" -import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" -import { organizationQueryOptions } from "@/features/orgs/queries/organizations-query" -import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" -import { projectsQueryOptions } from "@/features/projects/queries/projects-query" +import { buttonVariants } from "@/components/ui/button-variants"; +import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary"; +import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state"; +import { + WorkflowPanel, + WorkspacePage, +} from "@/features/orgs/components/workspace-page"; +import { organizationQueryOptions } from "@/features/orgs/queries/organizations-query"; +import { projectsQueryOptions } from "@/features/projects/queries/projects-query"; +import { workspaceScopeParams } from "@/lib/routing/workspace-params"; interface OrganizationOverviewPageProps { - onProjectStatusChange: (status: "active" | "archived") => void - organizationId: string - projectStatus: "active" | "archived" + onProjectStatusChange: (status: "active" | "archived") => void; + organizationId: string; + projectStatus: "active" | "archived"; } export function OrganizationOverviewPage({ @@ -19,10 +23,12 @@ export function OrganizationOverviewPage({ organizationId, projectStatus, }: OrganizationOverviewPageProps) { - const organization = useQuery(organizationQueryOptions(organizationId)) - const projects = useQuery(projectsQueryOptions(organizationId, projectStatus)) - const projectItems = projects.data?.items ?? [] - const error = organization.error ?? projects.error + const organization = useQuery(organizationQueryOptions(organizationId)); + const projects = useQuery( + projectsQueryOptions(organizationId, projectStatus) + ); + const projectItems = projects.data?.items ?? []; + const error = organization.error ?? projects.error; const state = resolveHostedQueryState({ emptyAction: projectStatus === "active" ? ( @@ -38,17 +44,22 @@ export function OrganizationOverviewPage({ projectStatus === "active" ? "Create a project to group apps, environments, and the project-wide Catalog." : "Archived Projects appear here and can be opened to restore them.", - emptyTitle: projectStatus === "active" ? "No active projects" : "No archived projects", + emptyTitle: + projectStatus === "active" + ? "No active projects" + : "No archived projects", error, - isEmpty: organization.isSuccess && projects.isSuccess && projectItems.length === 0, + isEmpty: + organization.isSuccess && projects.isSuccess && projectItems.length === 0, isPending: organization.isPending || projects.isPending, loadingDescription: "Loading organization and projects.", onRetry: () => { - void organization.refetch() - void projects.refetch() + organization.refetch(); + projects.refetch(); }, - permissionDescription: "You must be a member of this organization to view its projects.", - }) + permissionDescription: + "You must be a member of this organization to view its projects.", + }); return ( -
    +
    -
    + - +
      {projectItems.map((project) => (
    • {project.name}

      -

      {project.key}

      +

      + {project.key} +

      prev} + className="mt-5 inline-flex font-medium text-primary text-sm hover:underline" + params={(prev) => ({ + ...prev, + ...workspaceScopeParams(prev), + })} to="/orgs/$organizationId/projects/$projectId/env/$environmentKey" > Open project @@ -115,5 +135,5 @@ export function OrganizationOverviewPage({ - ) + ); } diff --git a/apps/dashboard/src/features/orgs/components/organization-switcher.test.tsx b/apps/dashboard/src/features/orgs/components/organization-switcher.test.tsx index cbfd7569..635de5e4 100644 --- a/apps/dashboard/src/features/orgs/components/organization-switcher.test.tsx +++ b/apps/dashboard/src/features/orgs/components/organization-switcher.test.tsx @@ -1,41 +1,54 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { - RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter, -} from "@tanstack/react-router" -import { fireEvent, render, screen } from "@testing-library/react" -import { beforeAll, describe, expect, it } from "vitest" + RouterProvider, +} from "@tanstack/react-router"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeAll, describe, expect, it } from "vitest"; -import { SidebarProvider } from "@/components/ui/sidebar" -import type { BootstrapOrganization, Project, Role } from "@/generated/api" -import { OrganizationSwitcher } from "@/features/orgs/components/organization-switcher" -import { workspaceBootstrapKeys } from "@/features/orgs/queries/workspace-bootstrap-query" -import { ApiError } from "@/lib/api/errors" +import { SidebarProvider } from "@/components/ui/sidebar"; +import { OrganizationSwitcher } from "@/features/orgs/components/organization-switcher"; +import { workspaceBootstrapKeys } from "@/features/orgs/queries/workspace-bootstrap-query"; +import type { BootstrapOrganization, Project, Role } from "@/generated/api"; +import { ApiError } from "@/lib/api/errors"; // The sidebar reads a media query to decide between its desktop and mobile // presentation; jsdom does not implement matchMedia. beforeAll(() => { - if (typeof window.matchMedia === "function") return + if (typeof window.matchMedia === "function") { + return; + } window.matchMedia = (query: string) => ({ - addEventListener: () => {}, - addListener: () => {}, + addEventListener: () => { + /* stub for a browser API jsdom does not implement */ + }, + addListener: () => { + /* stub for a browser API jsdom does not implement */ + }, dispatchEvent: () => false, matches: false, media: query, onchange: null, - removeEventListener: () => {}, - removeListener: () => {}, - }) as MediaQueryList -}) + removeEventListener: () => { + /* stub for a browser API jsdom does not implement */ + }, + removeListener: () => { + /* stub for a browser API jsdom does not implement */ + }, + }) as MediaQueryList; +}); -const timestamps = { createdAt: "2026-07-30T00:00:00Z", updatedAt: "2026-07-30T00:00:00Z" } +const timestamps = { + createdAt: "2026-07-30T00:00:00Z", + updatedAt: "2026-07-30T00:00:00Z", +}; function project(id: string, name: string, organizationId: string): Project { - return { ...timestamps, id, key: id, name, organizationId, status: "active" } + return { ...timestamps, id, key: id, name, organizationId, status: "active" }; } function organization( @@ -45,7 +58,7 @@ function organization( projectCount, projects = [], role = "owner" as Role, - }: { projectCount?: number; projects?: Project[]; role?: Role } = {}, + }: { projectCount?: number; projects?: Project[]; role?: Role } = {} ): BootstrapOrganization { return { organization: { ...timestamps, id, name }, @@ -53,19 +66,19 @@ function organization( projects, projectsTruncated: (projectCount ?? projects.length) > projects.length, role, - } + }; } function renderSwitcher( queryClient: QueryClient, - { organizationId, pathname }: { organizationId?: string; pathname: string }, + { organizationId, pathname }: { organizationId?: string; pathname: string } ) { const component = () => ( - ) - const rootRoute = createRootRoute() + ); + const rootRoute = createRootRoute(); const routeTree = rootRoute.addChildren([ createRoute({ getParentRoute: () => rootRoute, path: "/", component }), createRoute({ getParentRoute: () => rootRoute, path: "/orgs/new" }), @@ -83,34 +96,40 @@ function renderSwitcher( path: "/orgs/$organizationId/projects/$projectId/env/$environmentKey", component, }), - ]) + ]); const router = createRouter({ history: createMemoryHistory({ initialEntries: [pathname] }), routeTree, - }) + }); return render( - , - ) + + ); } function seededClient(seed: (client: QueryClient) => void) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - seed(client) - return client + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + seed(client); + return client; } function withBootstrap(organizations: BootstrapOrganization[]) { return (client: QueryClient) => { - client.setQueryData(workspaceBootstrapKeys.all, { organizations }) - } + client.setQueryData(workspaceBootstrapKeys.all, { organizations }); + }; } async function openSwitcher() { - fireEvent.click(await screen.findByRole("button", { name: /Switch project or organization/ })) + fireEvent.click( + await screen.findByRole("button", { + name: /Switch project or organization/, + }) + ); } /** @@ -124,56 +143,64 @@ describe("OrganizationSwitcher", () => { const client = seededClient( withBootstrap([ organization("org_01", "Northwind", { - projects: [project("prj_01", "Mobile", "org_01"), project("prj_02", "Web", "org_01")], + projects: [ + project("prj_01", "Mobile", "org_01"), + project("prj_02", "Web", "org_01"), + ], }), - ]), - ) + ]) + ); renderSwitcher(client, { organizationId: "org_01", - pathname: "/orgs/org_01/projects/prj_02", - }) + pathname: "/orgs/org_01/projects/prj_02/env/dev", + }); - const trigger = await screen.findByRole("button", { name: /Current project: Web/ }) - expect(trigger).toHaveAccessibleName(/Current organization: Northwind/) - fireEvent.click(trigger) + const trigger = await screen.findByRole("button", { + name: /Current project: Web/, + }); + expect(trigger).toHaveAccessibleName(/Current organization: Northwind/); + fireEvent.click(trigger); - expect(await screen.findByRole("menuitem", { name: "Mobile" })).toHaveAttribute( - "href", - "/orgs/org_01/projects/prj_01", - ) + expect( + await screen.findByRole("menuitem", { name: "Mobile" }) + ).toHaveAttribute("href", "/orgs/org_01/projects/prj_01/env/dev"); expect(screen.getByRole("menuitem", { name: "Web" })).toHaveAttribute( "href", - "/orgs/org_01/projects/prj_02", - ) - expect(screen.getByRole("menuitem", { name: "Add project" })).toHaveAttribute( - "href", - "/orgs/org_01/projects/new", - ) - }) + "/orgs/org_01/projects/prj_02/env/dev" + ); + expect( + screen.getByRole("menuitem", { name: "Add project" }) + ).toHaveAttribute("href", "/orgs/org_01/projects/new"); + }); it("moves between Organizations from the switch submenu", async () => { const client = seededClient( withBootstrap([ - organization("org_01", "Northwind", { projects: [project("prj_01", "Mobile", "org_01")] }), + organization("org_01", "Northwind", { + projects: [project("prj_01", "Mobile", "org_01")], + }), organization("org_02", "Contoso"), - ]), - ) + ]) + ); - renderSwitcher(client, { organizationId: "org_01", pathname: "/orgs/org_01" }) - await openSwitcher() + renderSwitcher(client, { + organizationId: "org_01", + pathname: "/orgs/org_01", + }); + await openSwitcher(); - fireEvent.click(await screen.findByRole("menuitem", { name: /Switch organization/ })) + fireEvent.click( + await screen.findByRole("menuitem", { name: /Switch organization/ }) + ); - expect(await screen.findByRole("menuitem", { name: "Contoso" })).toHaveAttribute( - "href", - "/orgs/org_02", - ) - expect(screen.getByRole("menuitem", { name: "Add organization" })).toHaveAttribute( - "href", - "/orgs/new", - ) - }) + expect( + await screen.findByRole("menuitem", { name: "Contoso" }) + ).toHaveAttribute("href", "/orgs/org_02"); + expect( + screen.getByRole("menuitem", { name: "Add organization" }) + ).toHaveAttribute("href", "/orgs/new"); + }); it("offers a retry instead of claiming there are no Projects when the read fails", async () => { const client = seededClient((queryClient) => { @@ -185,21 +212,25 @@ describe("OrganizationSwitcher", () => { correlationId: "request_test", retryable: true, status: 500, - }), + }) ), retry: false, - }) - }) + }); + }); - renderSwitcher(client, { pathname: "/" }) - await openSwitcher() + renderSwitcher(client, { pathname: "/" }); + await openSwitcher(); - expect(await screen.findByRole("button", { name: "Retry loading organizations" })).toBeVisible() - expect(screen.queryByText("You do not belong to an organization yet.")).toBeNull() + expect( + await screen.findByRole("button", { name: "Retry loading organizations" }) + ).toBeVisible(); + expect( + screen.queryByText("You do not belong to an organization yet.") + ).toBeNull(); // "Add project" needs an Organization in scope; offering it here would be a // dead link. - expect(screen.queryByRole("menuitem", { name: "Add project" })).toBeNull() - }) + expect(screen.queryByRole("menuitem", { name: "Add project" })).toBeNull(); + }); it("withholds Add project from a member, who cannot create one", async () => { const client = seededClient( @@ -208,19 +239,24 @@ describe("OrganizationSwitcher", () => { projects: [project("prj_01", "Mobile", "org_01")], role: "member", }), - ]), - ) + ]) + ); - renderSwitcher(client, { organizationId: "org_01", pathname: "/orgs/org_01" }) - await openSwitcher() + renderSwitcher(client, { + organizationId: "org_01", + pathname: "/orgs/org_01", + }); + await openSwitcher(); // Polled rather than read once: the popup remounts as the router settles, so // a handle taken from findBy can be detached by the time it is asserted on. await expect - .poll(() => screen.queryByRole("menuitem", { name: "Mobile" })?.getAttribute("href")) - .toBe("/orgs/org_01/projects/prj_01") - expect(screen.queryByRole("menuitem", { name: "Add project" })).toBeNull() - }) + .poll(() => + screen.queryByRole("menuitem", { name: "Mobile" })?.getAttribute("href") + ) + .toBe("/orgs/org_01/projects/prj_01/env/dev"); + expect(screen.queryByRole("menuitem", { name: "Add project" })).toBeNull(); + }); it("says how many Projects exist when the snapshot is capped", async () => { const client = seededClient( @@ -229,15 +265,17 @@ describe("OrganizationSwitcher", () => { projectCount: 40, projects: [project("prj_01", "Mobile", "org_01")], }), - ]), - ) + ]) + ); - renderSwitcher(client, { organizationId: "org_01", pathname: "/orgs/org_01" }) - await openSwitcher() + renderSwitcher(client, { + organizationId: "org_01", + pathname: "/orgs/org_01", + }); + await openSwitcher(); - expect(await screen.findByRole("menuitem", { name: "View all 40 projects" })).toHaveAttribute( - "href", - "/orgs/org_01", - ) - }) -}) + expect( + await screen.findByRole("menuitem", { name: "View all 40 projects" }) + ).toHaveAttribute("href", "/orgs/org_01"); + }); +}); diff --git a/apps/dashboard/src/features/orgs/components/organization-switcher.tsx b/apps/dashboard/src/features/orgs/components/organization-switcher.tsx index 624482bb..3e064965 100644 --- a/apps/dashboard/src/features/orgs/components/organization-switcher.tsx +++ b/apps/dashboard/src/features/orgs/components/organization-switcher.tsx @@ -1,5 +1,11 @@ -import * as React from "react" - +import { ArrowsLeftRightIcon } from "@phosphor-icons/react/dist/ssr/ArrowsLeftRight"; +import { CaretUpDownIcon } from "@phosphor-icons/react/dist/ssr/CaretUpDown"; +import { PlusIcon } from "@phosphor-icons/react/dist/ssr/Plus"; +import { useQuery } from "@tanstack/react-query"; +import { Link, useRouterState } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -11,30 +17,23 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" +} from "@/components/ui/dropdown-menu"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar, -} from "@/components/ui/sidebar" -import { ArrowsLeftRightIcon } from "@phosphor-icons/react/dist/ssr/ArrowsLeftRight" -import { CaretUpDownIcon } from "@phosphor-icons/react/dist/ssr/CaretUpDown" -import { PlusIcon } from "@phosphor-icons/react/dist/ssr/Plus" -import { useQuery } from "@tanstack/react-query" -import { Link, useRouterState } from "@tanstack/react-router" - -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" -import { workspaceBootstrapQueryOptions } from "../queries/workspace-bootstrap-query" -import { readWorkspaceScope } from "../types/workspace-navigation" -import { describeApiError } from "@/lib/api/errors" +} from "@/components/ui/sidebar"; +import { describeApiError } from "@/lib/api/errors"; +import { workspaceScopeParamsWithDefault } from "@/lib/routing/workspace-params"; +import { workspaceBootstrapQueryOptions } from "../queries/workspace-bootstrap-query"; +import { readWorkspaceScope } from "../types/workspace-navigation"; const AVATAR_CLASSNAMES = - "bg-sidebar-primary text-sidebar-primary-foreground size-4 rounded data-[size=lg]:size-4 data-[size=sm]:size-4 after:rounded-none" + "bg-sidebar-primary text-sidebar-primary-foreground size-4 rounded data-[size=lg]:size-4 data-[size=sm]:size-4 after:rounded-none"; // Base UI exposes the trigger width as --anchor-width on positioned popups. -const DROPDOWN_CLASSNAMES = "w-(--anchor-width) min-w-56 rounded" +const DROPDOWN_CLASSNAMES = "w-(--anchor-width) min-w-56 rounded"; function Initial({ value }: { value: string }) { return ( @@ -43,7 +42,7 @@ function Initial({ value }: { value: string }) { {value.charAt(0).toUpperCase()} - ) + ); } /** @@ -52,49 +51,65 @@ function Initial({ value }: { value: string }) { * Both read the single bootstrap snapshot entry already fetched, so opening the * menu costs no request. */ -export function OrganizationSwitcher({ organizationId }: { organizationId?: string }) { - const { isMobile } = useSidebar() - const bootstrap = useQuery(workspaceBootstrapQueryOptions()) +export function OrganizationSwitcher({ + organizationId, +}: { + organizationId?: string; +}) { + const { isMobile } = useSidebar(); + const bootstrap = useQuery(workspaceBootstrapQueryOptions()); // The shell passes the Organization it read from the path; the Project comes // from the same place, so the switcher does not need a second prop threaded // through every caller. const projectId = useRouterState({ select: (state) => readWorkspaceScope(state.location.pathname).projectId, - }) + }); - const organizations = React.useMemo( + const organizations = useMemo( () => bootstrap.data?.organizations ?? [], - [bootstrap.data?.organizations], - ) + [bootstrap.data?.organizations] + ); - const current = React.useMemo( - () => organizations.find((entry) => entry.organization.id === organizationId), - [organizations, organizationId], - ) + const current = useMemo( + () => + organizations.find((entry) => entry.organization.id === organizationId), + [organizations, organizationId] + ); - const currentProject = React.useMemo( + const currentProject = useMemo( () => current?.projects.find((project) => project.id === projectId), - [current, projectId], - ) + [current, projectId] + ); - const organizationLabel = current - ? current.organization.name - : bootstrap.isPending - ? "Loading organizations" - : "Select organization" - const triggerLabel = currentProject ? currentProject.name : organizationLabel - const canCreateProject = current?.role === "owner" || current?.role === "admin" + const organizationLabel = (() => { + if (current) { + return current.organization.name; + } + if (bootstrap.isPending) { + return "Loading organizations"; + } + return "Select organization"; + })(); + const triggerLabel = currentProject ? currentProject.name : organizationLabel; + const canCreateProject = + current?.role === "owner" || current?.role === "admin"; const failure = bootstrap.isError ? (

      {describeApiError(bootstrap.error).description}

      -
      - ) : null + ) : null; return ( @@ -109,14 +124,16 @@ export function OrganizationSwitcher({ organizationId }: { organizationId?: stri ? `Switch project or organization. Current organization: ${organizationLabel}. Current project: ${currentProject.name}` : `Switch project or organization. Current organization: ${organizationLabel}` } - className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground inline-flex w-full items-center justify-between" + className="inline-flex w-full items-center justify-between data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" >
      - {triggerLabel} + + {triggerLabel} + {currentProject ? ( - + {organizationLabel} ) : null} @@ -128,8 +145,8 @@ export function OrganizationSwitcher({ organizationId }: { organizationId?: stri /> @@ -138,55 +155,75 @@ export function OrganizationSwitcher({ organizationId }: { organizationId?: stri {bootstrap.isPending ? ( -

      +

      Loading projects…

      ) : ( (failure ?? - (!current ? ( -

      - Choose an organization to see its projects. -

      - ) : current.projects.length === 0 ? ( -

      - {current.organization.name} has no projects yet. -

      - ) : ( - <> - {current.projects.map((project) => ( - prev} - to="/orgs/$organizationId/projects/$projectId/env/$environmentKey" - /> - } - > - - {project.name} - - ))} - {current.projectsTruncated ? ( - // Presenting a capped list as the whole list would hide - // Projects the operator owns. - - } - > - - View all {current.projectCount} projects - - - ) : null} - - ))) + (() => { + if (current) { + return (() => { + if (current.projects.length === 0) { + return ( +

      + {current.organization.name} has no projects yet. +

      + ); + } + return ( + <> + {current.projects.map((project) => ( + ({ + ...prev, + ...workspaceScopeParamsWithDefault(prev), + // Each entry names its own Project; inheriting + // it from the address pointed every row at + // whichever Project was already in scope. + organizationId: current.organization.id, + projectId: project.id, + })} + to="/orgs/$organizationId/projects/$projectId/env/$environmentKey" + /> + } + > + + {project.name} + + ))} + {current.projectsTruncated ? ( + // Presenting a capped list as the whole list would hide + // Projects the operator owns. + + } + > + + View all {current.projectCount} projects + + + ) : null} + + ); + })(); + } + return ( +

      + Choose an organization to see its projects. +

      + ); + })()) )} {/* Only owners and admins may create a Project, so offering the @@ -204,7 +241,7 @@ export function OrganizationSwitcher({ organizationId }: { organizationId?: stri
      -
      +
      Add project
      @@ -215,7 +252,7 @@ export function OrganizationSwitcher({ organizationId }: { organizationId?: stri - + Switch organization @@ -225,13 +262,16 @@ export function OrganizationSwitcher({ organizationId }: { organizationId?: stri {bootstrap.isPending ? ( -

      +

      Loading organizations…

      ) : ( (failure ?? (organizations.length === 0 ? ( -

      +

      You do not belong to an organization yet.

      ) : ( @@ -247,7 +287,9 @@ export function OrganizationSwitcher({ organizationId }: { organizationId?: stri } > - {entry.organization.name} + + {entry.organization.name} + )) ))) @@ -255,11 +297,14 @@ export function OrganizationSwitcher({ organizationId }: { organizationId?: stri - }> + } + >
      -
      +
      Add organization
      @@ -270,5 +315,5 @@ export function OrganizationSwitcher({ organizationId }: { organizationId?: stri - ) + ); } diff --git a/apps/dashboard/src/features/orgs/components/scope-mismatch-recovery.tsx b/apps/dashboard/src/features/orgs/components/scope-mismatch-recovery.tsx index 79536d0f..8bc3d9b0 100644 --- a/apps/dashboard/src/features/orgs/components/scope-mismatch-recovery.tsx +++ b/apps/dashboard/src/features/orgs/components/scope-mismatch-recovery.tsx @@ -1,40 +1,45 @@ -import { Link } from "@tanstack/react-router" +import { Link } from "@tanstack/react-router"; -import { buttonVariants } from "@/components/ui/button-variants" -import type { NestedScopeMismatch } from "@/features/orgs/types/nested-scope" +import { buttonVariants } from "@/components/ui/button-variants"; +import type { NestedScopeMismatch } from "@/features/orgs/types/nested-scope"; +import { workspaceScopeParams } from "@/lib/routing/workspace-params"; interface ScopeMismatchRecoveryProps { - mismatch: Exclude - organizationId: string - projectId: string + mismatch: Exclude; + organizationId: string; + projectId: string; } export function ScopeMismatchRecovery({ mismatch, organizationId, - projectId, }: ScopeMismatchRecoveryProps) { return (
      -

      +

      Scope mismatch

      -

      - This URL does not match the loaded {mismatch === "project" ? "Project" : "resource"} +

      + This URL does not match the loaded{" "} + {mismatch === "project" ? "Project" : "resource"}

      -

      - Mosaic stopped before enabling actions. Return through the routed workspace instead of - continuing with identifiers from different parent scopes. +

      + Mosaic stopped before enabling actions. Return through the routed + workspace instead of continuing with identifiers from different parent + scopes.

      {mismatch === "resource" ? ( prev} + params={(prev) => ({ + ...prev, + ...workspaceScopeParams(prev), + })} to="/orgs/$organizationId/projects/$projectId/env/$environmentKey" > Return to Project @@ -48,10 +53,13 @@ export function ScopeMismatchRecovery({ Return to Organization )} - + Choose another workspace
      - ) + ); } diff --git a/apps/dashboard/src/features/orgs/components/workspace-entry-redirect.test.tsx b/apps/dashboard/src/features/orgs/components/workspace-entry-redirect.test.tsx index ca2cf343..91a3d3cf 100644 --- a/apps/dashboard/src/features/orgs/components/workspace-entry-redirect.test.tsx +++ b/apps/dashboard/src/features/orgs/components/workspace-entry-redirect.test.tsx @@ -1,55 +1,78 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { - RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter, -} from "@tanstack/react-router" -import { render, screen, waitFor } from "@testing-library/react" -import { beforeAll, describe, expect, it } from "vitest" + RouterProvider, +} from "@tanstack/react-router"; +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeAll, describe, expect, it } from "vitest"; -import { SidebarProvider } from "@/components/ui/sidebar" -import type { BootstrapOrganization, Project } from "@/generated/api" -import { WorkspaceEntryRedirect } from "@/features/orgs/components/workspace-entry-redirect" -import { workspaceBootstrapKeys } from "@/features/orgs/queries/workspace-bootstrap-query" -import { ApiError } from "@/lib/api/errors" +import { SidebarProvider } from "@/components/ui/sidebar"; +import { WorkspaceEntryRedirect } from "@/features/orgs/components/workspace-entry-redirect"; +import { workspaceBootstrapKeys } from "@/features/orgs/queries/workspace-bootstrap-query"; +import type { BootstrapOrganization, Project } from "@/generated/api"; +import { ApiError } from "@/lib/api/errors"; // The degraded branch renders the workspace shell's Organization list, which // reads a media query jsdom does not implement. beforeAll(() => { - if (typeof window.matchMedia === "function") return + if (typeof window.matchMedia === "function") { + return; + } window.matchMedia = (query: string) => ({ - addEventListener: () => {}, - addListener: () => {}, + addEventListener: () => { + /* stub for a browser API jsdom does not implement */ + }, + addListener: () => { + /* stub for a browser API jsdom does not implement */ + }, dispatchEvent: () => false, matches: false, media: query, onchange: null, - removeEventListener: () => {}, - removeListener: () => {}, - }) as MediaQueryList -}) - -const timestamps = { createdAt: "2026-07-30T00:00:00Z", updatedAt: "2026-07-30T00:00:00Z" } + removeEventListener: () => { + /* stub for a browser API jsdom does not implement */ + }, + removeListener: () => { + /* stub for a browser API jsdom does not implement */ + }, + }) as MediaQueryList; +}); + +const timestamps = { + createdAt: "2026-07-30T00:00:00Z", + updatedAt: "2026-07-30T00:00:00Z", +}; function project(id: string, organizationId: string): Project { - return { ...timestamps, id, key: id, name: id, organizationId, status: "active" } + return { + ...timestamps, + id, + key: id, + name: id, + organizationId, + status: "active", + }; } -function organization(id: string, projects: Project[] = []): BootstrapOrganization { +function organization( + id: string, + projects: Project[] = [] +): BootstrapOrganization { return { organization: { ...timestamps, id, name: id }, projectCount: projects.length, projects, projectsTruncated: false, role: "owner", - } + }; } function renderEntry(queryClient: QueryClient) { - const rootRoute = createRootRoute() + const rootRoute = createRootRoute(); const routeTree = rootRoute.addChildren([ createRoute({ component: WorkspaceEntryRedirect, @@ -57,7 +80,10 @@ function renderEntry(queryClient: QueryClient) { path: "/workspace", }), createRoute({ getParentRoute: () => rootRoute, path: "/orgs/new" }), - createRoute({ getParentRoute: () => rootRoute, path: "/orgs/$organizationId" }), + createRoute({ + getParentRoute: () => rootRoute, + path: "/orgs/$organizationId", + }), createRoute({ getParentRoute: () => rootRoute, path: "/orgs/$organizationId/projects/new", @@ -66,28 +92,30 @@ function renderEntry(queryClient: QueryClient) { getParentRoute: () => rootRoute, path: "/orgs/$organizationId/projects/$projectId/env/$environmentKey", }), - ]) + ]); const router = createRouter({ history: createMemoryHistory({ initialEntries: ["/workspace"] }), routeTree, - }) + }); render( - , - ) + + ); - return router + return router; } function seededClient(seed: (client: QueryClient) => void) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - seed(client) - return client + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + seed(client); + return client; } /** @@ -102,42 +130,48 @@ describe("WorkspaceEntryRedirect", () => { const client = seededClient((queryClient) => { queryClient.setQueryData(workspaceBootstrapKeys.all, { organizations: [organization("org_01", [project("prj_01", "org_01")])], - }) - }) + }); + }); - const router = renderEntry(client) + const router = renderEntry(client); await waitFor(() => { - expect(router.state.location.pathname).toBe("/orgs/org_01/projects/prj_01") - }) - }) + expect(router.state.location.pathname).toBe( + "/orgs/org_01/projects/prj_01/env/dev" + ); + }); + }); it("sends an operator with no Organizations to create one", async () => { const client = seededClient((queryClient) => { - queryClient.setQueryData(workspaceBootstrapKeys.all, { organizations: [] }) - }) + queryClient.setQueryData(workspaceBootstrapKeys.all, { + organizations: [], + }); + }); - const router = renderEntry(client) + const router = renderEntry(client); await waitFor(() => { - expect(router.state.location.pathname).toBe("/orgs/new") - }) - }) + expect(router.state.location.pathname).toBe("/orgs/new"); + }); + }); it("replaces the entry route so Back does not bounce forward again", async () => { const client = seededClient((queryClient) => { queryClient.setQueryData(workspaceBootstrapKeys.all, { organizations: [organization("org_01", [project("prj_01", "org_01")])], - }) - }) + }); + }); - const router = renderEntry(client) + const router = renderEntry(client); await waitFor(() => { - expect(router.state.location.pathname).toBe("/orgs/org_01/projects/prj_01") - }) + expect(router.state.location.pathname).toBe( + "/orgs/org_01/projects/prj_01/env/dev" + ); + }); - expect(router.history.canGoBack()).toBe(false) - }) + expect(router.history.canGoBack()).toBe(false); + }); it("shows the Organization list with a retry when the snapshot cannot be read", async () => { const client = seededClient((queryClient) => { @@ -149,15 +183,17 @@ describe("WorkspaceEntryRedirect", () => { correlationId: "request_test", retryable: true, status: 500, - }), + }) ), retry: false, - }) - }) + }); + }); - const router = renderEntry(client) + const router = renderEntry(client); - expect(await screen.findByRole("heading", { name: "Organizations" })).toBeVisible() - expect(router.state.location.pathname).toBe("/workspace") - }) -}) + expect( + await screen.findByRole("heading", { name: "Organizations" }) + ).toBeVisible(); + expect(router.state.location.pathname).toBe("/workspace"); + }); +}); diff --git a/apps/dashboard/src/features/orgs/components/workspace-entry-redirect.tsx b/apps/dashboard/src/features/orgs/components/workspace-entry-redirect.tsx index 3164ac43..0e5ef996 100644 --- a/apps/dashboard/src/features/orgs/components/workspace-entry-redirect.tsx +++ b/apps/dashboard/src/features/orgs/components/workspace-entry-redirect.tsx @@ -1,15 +1,15 @@ -import * as React from "react" +import { useQuery } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router" -import { useQuery } from "@tanstack/react-query" +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useMemo } from "react"; -import { RoutePendingState } from "@/components/feedback/route-feedback" -import { WorkspaceHome } from "@/features/orgs/components/workspace-home" +import { RoutePendingState } from "@/components/feedback/route-feedback"; +import { WorkspaceHome } from "@/features/orgs/components/workspace-home"; +import { workspaceBootstrapQueryOptions } from "@/features/orgs/queries/workspace-bootstrap-query"; import { resolveWorkspaceEntry, workspaceEntryNavigation, -} from "@/features/orgs/types/workspace-entry" -import { workspaceBootstrapQueryOptions } from "@/features/orgs/queries/workspace-bootstrap-query" +} from "@/features/orgs/types/workspace-entry"; /** * Entry resolves here rather than in the route's beforeLoad. The session lives in @@ -20,26 +20,30 @@ import { workspaceBootstrapQueryOptions } from "@/features/orgs/queries/workspac * hydration, so this holds for both a typed URL and an in-app navigation. */ export function WorkspaceEntryRedirect() { - const navigate = useNavigate() - const bootstrap = useQuery(workspaceBootstrapQueryOptions()) + const navigate = useNavigate(); + const bootstrap = useQuery(workspaceBootstrapQueryOptions()); // Memoised on the cached snapshot: resolveWorkspaceEntry returns a fresh // object each call, which would re-arm the effect on every render. - const target = React.useMemo( + const target = useMemo( () => (bootstrap.data ? resolveWorkspaceEntry(bootstrap.data) : undefined), - [bootstrap.data], - ) + [bootstrap.data] + ); - React.useEffect(() => { - if (!target) return + useEffect(() => { + if (!target) { + return; + } // Replaced, not pushed: Back must leave the workspace rather than land on // this route again and bounce forward. - void navigate({ ...workspaceEntryNavigation(target), replace: true }) - }, [navigate, target]) + navigate({ ...workspaceEntryNavigation(target), replace: true }); + }, [navigate, target]); // A failed bootstrap must not strand the operator on a blank redirect. The // Organization list reports the outage and offers a retry. - if (bootstrap.isError) return + if (bootstrap.isError) { + return ; + } - return + return ; } diff --git a/apps/dashboard/src/features/orgs/components/workspace-home.tsx b/apps/dashboard/src/features/orgs/components/workspace-home.tsx index c0d74d8f..69bde583 100644 --- a/apps/dashboard/src/features/orgs/components/workspace-home.tsx +++ b/apps/dashboard/src/features/orgs/components/workspace-home.tsx @@ -1,32 +1,39 @@ -import { ArrowRightIcon } from "@phosphor-icons/react/dist/ssr/ArrowRight" -import { PlusIcon } from "@phosphor-icons/react/dist/ssr/Plus" -import { useQuery } from "@tanstack/react-query" -import { Link } from "@tanstack/react-router" +import { ArrowRightIcon } from "@phosphor-icons/react/dist/ssr/ArrowRight"; +import { PlusIcon } from "@phosphor-icons/react/dist/ssr/Plus"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; -import { buttonVariants } from "@/components/ui/button-variants" -import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary" -import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state" -import { organizationsQueryOptions } from "@/features/orgs/queries/organizations-query" -import { WorkspacePage, WorkflowPanel } from "@/features/orgs/components/workspace-page" +import { buttonVariants } from "@/components/ui/button-variants"; +import { HostedResourceBoundary } from "@/features/auth/components/hosted-resource-boundary"; +import { resolveHostedQueryState } from "@/features/auth/types/hosted-query-state"; +import { + WorkflowPanel, + WorkspacePage, +} from "@/features/orgs/components/workspace-page"; +import { organizationsQueryOptions } from "@/features/orgs/queries/organizations-query"; export function WorkspaceHome() { - const organizations = useQuery(organizationsQueryOptions()) - const items = organizations.data?.items ?? [] + const organizations = useQuery(organizationsQueryOptions()); + const items = organizations.data?.items ?? []; const state = resolveHostedQueryState({ emptyAction: ( Create organization ), - emptyDescription: "Create an organization to establish the tenant and membership boundary.", + emptyDescription: + "Create an organization to establish the tenant and membership boundary.", emptyTitle: "No organizations yet", error: organizations.error, isEmpty: organizations.isSuccess && items.length === 0, isPending: organizations.isPending, loadingDescription: "Loading organizations from the hosted workspace.", - onRetry: () => void organizations.refetch(), - permissionDescription: "Organization membership is required to view this workspace.", - }) + onRetry: () => { + organizations.refetch(); + }, + permissionDescription: + "Organization membership is required to view this workspace.", + }); return ( (
    • - {organization.name} - + + {organization.name} + + {organization.id} @@ -66,5 +75,5 @@ export function WorkspaceHome() { - ) + ); } diff --git a/apps/dashboard/src/features/orgs/components/workspace-page.tsx b/apps/dashboard/src/features/orgs/components/workspace-page.tsx index 0dde7ba1..3fd8e85a 100644 --- a/apps/dashboard/src/features/orgs/components/workspace-page.tsx +++ b/apps/dashboard/src/features/orgs/components/workspace-page.tsx @@ -1,13 +1,13 @@ -import { Separator } from "@/components/ui/separator" -import { SidebarTrigger } from "@/components/ui/sidebar" -import type { ReactNode } from "react" +import type { ReactNode } from "react"; +import { Separator } from "@/components/ui/separator"; +import { SidebarTrigger } from "@/components/ui/sidebar"; interface WorkspacePageProps { - actions?: ReactNode - children: ReactNode - description: string - eyebrow?: string - title: string + actions?: ReactNode; + children: ReactNode; + description: string; + eyebrow?: string; + title: string; } export function WorkspacePage({ @@ -22,33 +22,44 @@ export function WorkspacePage({
      - +
      -

      +

      {eyebrow}

      -

      {title}

      +

      {title}

      - {actions ?
      {actions}
      : null} + {actions ? ( +
      {actions}
      + ) : null}
      -

      {description}

      +

      + {description} +

      {children}
      - ) + ); } interface WorkflowPanelProps { - children: ReactNode - description?: string - title: string + children: ReactNode; + description?: string; + title: string; } -export function WorkflowPanel({ children, description, title }: WorkflowPanelProps) { +export function WorkflowPanel({ + children, + description, + title, +}: WorkflowPanelProps) { return (

      {title}

      {description ? ( -

      {description}

      +

      + {description} +

      ) : null}
      {children}
      - ) + ); } export function ScopeBadge({ children }: { children: ReactNode }) { return ( - + {children} - ) + ); } diff --git a/apps/dashboard/src/features/orgs/mutations/organization-mutations.ts b/apps/dashboard/src/features/orgs/mutations/organization-mutations.ts index 8d180753..ea60e579 100644 --- a/apps/dashboard/src/features/orgs/mutations/organization-mutations.ts +++ b/apps/dashboard/src/features/orgs/mutations/organization-mutations.ts @@ -1,8 +1,10 @@ -import { mutationOptions, type QueryClient } from "@tanstack/react-query" - -import { createOrganization, type CreateOrganizationRequest } from "@/generated/api" -import { organizationKeys } from "@/features/orgs/queries/organizations-query" -import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { organizationKeys } from "@/features/orgs/queries/organizations-query"; +import { + type CreateOrganizationRequest, + createOrganization, +} from "@/generated/api"; +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client"; export function createOrganizationMutationOptions(queryClient: QueryClient) { return mutationOptions({ @@ -11,9 +13,10 @@ export function createOrganizationMutationOptions(queryClient: QueryClient) { body, client: generatedDashboardClient, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - onSuccess: async () => queryClient.invalidateQueries({ queryKey: organizationKeys.all }), - }) + onSuccess: async () => + queryClient.invalidateQueries({ queryKey: organizationKeys.all }), + }); } diff --git a/apps/dashboard/src/features/orgs/queries/organizations-query.ts b/apps/dashboard/src/features/orgs/queries/organizations-query.ts index bfc89df0..1fbb630b 100644 --- a/apps/dashboard/src/features/orgs/queries/organizations-query.ts +++ b/apps/dashboard/src/features/orgs/queries/organizations-query.ts @@ -1,13 +1,14 @@ -import { queryOptions } from "@tanstack/react-query" +import { queryOptions } from "@tanstack/react-query"; -import { getOrganization, listOrganizations } from "@/generated/api" -import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" +import { getOrganization, listOrganizations } from "@/generated/api"; +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client"; export const organizationKeys = { all: ["organizations"] as const, - detail: (organizationId: string) => ["organizations", "detail", organizationId] as const, + detail: (organizationId: string) => + ["organizations", "detail", organizationId] as const, list: () => ["organizations", "list"] as const, -} +}; export function organizationsQueryOptions() { return queryOptions({ @@ -17,10 +18,10 @@ export function organizationsQueryOptions() { client: generatedDashboardClient, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } export function organizationQueryOptions(organizationId: string) { @@ -32,8 +33,8 @@ export function organizationQueryOptions(organizationId: string) { path: { organizationId }, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } diff --git a/apps/dashboard/src/features/orgs/queries/workspace-bootstrap-query.ts b/apps/dashboard/src/features/orgs/queries/workspace-bootstrap-query.ts index b56f66c6..f9f20f8d 100644 --- a/apps/dashboard/src/features/orgs/queries/workspace-bootstrap-query.ts +++ b/apps/dashboard/src/features/orgs/queries/workspace-bootstrap-query.ts @@ -1,11 +1,11 @@ -import { queryOptions } from "@tanstack/react-query" +import { queryOptions } from "@tanstack/react-query"; -import { getWorkspaceBootstrap } from "@/generated/api" -import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client" +import { getWorkspaceBootstrap } from "@/generated/api"; +import { generatedDashboardClient } from "@/lib/api/generated-dashboard-client"; export const workspaceBootstrapKeys = { all: ["workspace-bootstrap"] as const, -} +}; /** * One read that answers both "which Organizations does this operator have?" and @@ -21,8 +21,8 @@ export function workspaceBootstrapQueryOptions() { client: generatedDashboardClient, signal, throwOnError: true, - }) - return result.data.data + }); + return result.data.data; }, - }) + }); } diff --git a/apps/dashboard/src/features/orgs/types/nested-scope.test.ts b/apps/dashboard/src/features/orgs/types/nested-scope.test.ts index 42b61cd3..31f28131 100644 --- a/apps/dashboard/src/features/orgs/types/nested-scope.test.ts +++ b/apps/dashboard/src/features/orgs/types/nested-scope.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it } from "vitest"; -import { detectNestedScopeMismatch } from "@/features/orgs/types/nested-scope" +import { detectNestedScopeMismatch } from "@/features/orgs/types/nested-scope"; describe("nested hosted-route scope", () => { it("enables a project-scoped list only when the Project belongs to the routed Organization", () => { @@ -9,9 +9,9 @@ describe("nested hosted-route scope", () => { expectedOrganizationId: "organization_one", expectedProjectId: "project_one", project: { id: "project_one", organizationId: "organization_one" }, - }), - ).toBeNull() - }) + }) + ).toBeNull(); + }); it("rejects a Project that does not belong to the routed Organization", () => { expect( @@ -22,9 +22,9 @@ describe("nested hosted-route scope", () => { id: "project_one", organizationId: "organization_other", }, - }), - ).toBe("project") - }) + }) + ).toBe("project"); + }); it("rejects a resource that does not belong to the routed Project", () => { expect( @@ -37,7 +37,7 @@ describe("nested hosted-route scope", () => { organizationId: "organization_one", }, resource: { id: "product_one", projectId: "project_other" }, - }), - ).toBe("resource") - }) -}) + }) + ).toBe("resource"); + }); +}); diff --git a/apps/dashboard/src/features/orgs/types/nested-scope.ts b/apps/dashboard/src/features/orgs/types/nested-scope.ts index f9be55f3..99481967 100644 --- a/apps/dashboard/src/features/orgs/types/nested-scope.ts +++ b/apps/dashboard/src/features/orgs/types/nested-scope.ts @@ -1,22 +1,22 @@ interface ProjectScopeRecord { - id: string - organizationId: string + id: string; + organizationId: string; } interface ResourceScopeRecord { - id: string - projectId: string + id: string; + projectId: string; } interface NestedScopeInput { - expectedOrganizationId: string - expectedProjectId: string - expectedResourceId?: string - project?: ProjectScopeRecord - resource?: ResourceScopeRecord + expectedOrganizationId: string; + expectedProjectId: string; + expectedResourceId?: string; + project?: ProjectScopeRecord; + resource?: ResourceScopeRecord; } -export type NestedScopeMismatch = "project" | "resource" | null +export type NestedScopeMismatch = "project" | "resource" | null; export function detectNestedScopeMismatch({ expectedOrganizationId, @@ -27,9 +27,10 @@ export function detectNestedScopeMismatch({ }: NestedScopeInput): NestedScopeMismatch { if ( project && - (project.id !== expectedProjectId || project.organizationId !== expectedOrganizationId) + (project.id !== expectedProjectId || + project.organizationId !== expectedOrganizationId) ) { - return "project" + return "project"; } if ( @@ -37,8 +38,8 @@ export function detectNestedScopeMismatch({ (resource.projectId !== expectedProjectId || (expectedResourceId !== undefined && resource.id !== expectedResourceId)) ) { - return "resource" + return "resource"; } - return null + return null; } diff --git a/apps/dashboard/src/features/orgs/types/workspace-entry.test.ts b/apps/dashboard/src/features/orgs/types/workspace-entry.test.ts index d7f8db71..62725ef7 100644 --- a/apps/dashboard/src/features/orgs/types/workspace-entry.test.ts +++ b/apps/dashboard/src/features/orgs/types/workspace-entry.test.ts @@ -1,9 +1,12 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it } from "vitest"; +import { DEFAULT_ENVIRONMENT_ALIAS } from "@/features/environments/types/environment-alias"; +import { resolveWorkspaceEntry } from "@/features/orgs/types/workspace-entry"; +import type { BootstrapOrganization, Project, Role } from "@/generated/api"; -import type { BootstrapOrganization, Project, Role } from "@/generated/api" -import { resolveWorkspaceEntry } from "@/features/orgs/types/workspace-entry" - -const timestamps = { createdAt: "2026-07-30T00:00:00Z", updatedAt: "2026-07-30T00:00:00Z" } +const timestamps = { + createdAt: "2026-07-30T00:00:00Z", + updatedAt: "2026-07-30T00:00:00Z", +}; function project(id: string, organizationId: string): Project { return { @@ -13,12 +16,15 @@ function project(id: string, organizationId: string): Project { name: id, organizationId, status: "active", - } + }; } function organization( id: string, - { projects = [], role = "owner" as Role }: { projects?: Project[]; role?: Role } = {}, + { + projects = [], + role = "owner" as Role, + }: { projects?: Project[]; role?: Role } = {} ): BootstrapOrganization { return { organization: { ...timestamps, id, name: id }, @@ -26,7 +32,7 @@ function organization( projects, projectsTruncated: false, role, - } + }; } describe("resolveWorkspaceEntry", () => { @@ -34,8 +40,8 @@ describe("resolveWorkspaceEntry", () => { expect(resolveWorkspaceEntry({ organizations: [] })).toEqual({ reason: "no-organizations", to: "/orgs/new", - }) - }) + }); + }); it("lands in the first Project of the first Organization", () => { const entry = resolveWorkspaceEntry({ @@ -45,14 +51,18 @@ describe("resolveWorkspaceEntry", () => { }), organization("org_02", { projects: [project("prj_03", "org_02")] }), ], - }) + }); expect(entry).toEqual({ - params: { organizationId: "org_01", projectId: "prj_01" }, + params: { + environmentKey: DEFAULT_ENVIRONMENT_ALIAS, + organizationId: "org_01", + projectId: "prj_01", + }, reason: "resolved", to: "/orgs/$organizationId/projects/$projectId/env/$environmentKey", - }) - }) + }); + }); it("sends an owner of an empty first Organization to create a Project there", () => { // The first Organization decides even though a later one already has a @@ -63,39 +73,46 @@ describe("resolveWorkspaceEntry", () => { organization("org_01"), organization("org_02", { projects: [project("prj_01", "org_02")] }), ], - }) + }); expect(entry).toEqual({ params: { organizationId: "org_01" }, reason: "no-projects", to: "/orgs/$organizationId/projects/new", - }) - }) + }); + }); it("routes a member of an empty Organization past a create form they cannot submit", () => { const entry = resolveWorkspaceEntry({ organizations: [ organization("org_01", { role: "member" }), - organization("org_02", { projects: [project("prj_01", "org_02")], role: "member" }), + organization("org_02", { + projects: [project("prj_01", "org_02")], + role: "member", + }), ], - }) + }); expect(entry).toEqual({ - params: { organizationId: "org_02", projectId: "prj_01" }, + params: { + environmentKey: DEFAULT_ENVIRONMENT_ALIAS, + organizationId: "org_02", + projectId: "prj_01", + }, reason: "resolved", to: "/orgs/$organizationId/projects/$projectId/env/$environmentKey", - }) - }) + }); + }); it("stops at the Organization overview when a member has nowhere to land", () => { const entry = resolveWorkspaceEntry({ organizations: [organization("org_01", { role: "member" })], - }) + }); expect(entry).toEqual({ params: { organizationId: "org_01" }, reason: "cannot-create-project", to: "/orgs/$organizationId", - }) - }) -}) + }); + }); +}); diff --git a/apps/dashboard/src/features/orgs/types/workspace-entry.ts b/apps/dashboard/src/features/orgs/types/workspace-entry.ts index a6153a92..50154ab2 100644 --- a/apps/dashboard/src/features/orgs/types/workspace-entry.ts +++ b/apps/dashboard/src/features/orgs/types/workspace-entry.ts @@ -1,5 +1,8 @@ -import type { BootstrapOrganization, WorkspaceBootstrap } from "@/generated/api" -import { DEFAULT_ENVIRONMENT_ALIAS } from "@/features/environments/types/environment-alias" +import { DEFAULT_ENVIRONMENT_ALIAS } from "@/features/environments/types/environment-alias"; +import type { + BootstrapOrganization, + WorkspaceBootstrap, +} from "@/generated/api"; /** * Where entry sends an operator, as a router target so the decision is asserted @@ -8,20 +11,24 @@ import { DEFAULT_ENVIRONMENT_ALIAS } from "@/features/environments/types/environ export type WorkspaceEntryTarget = | { reason: "no-organizations"; to: "/orgs/new" } | { - params: { organizationId: string } - reason: "no-projects" - to: "/orgs/$organizationId/projects/new" + params: { organizationId: string }; + reason: "no-projects"; + to: "/orgs/$organizationId/projects/new"; } | { - params: { organizationId: string } - reason: "cannot-create-project" - to: "/orgs/$organizationId" + params: { organizationId: string }; + reason: "cannot-create-project"; + to: "/orgs/$organizationId"; } | { - params: { environmentKey: string; organizationId: string; projectId: string } - reason: "resolved" - to: "/orgs/$organizationId/projects/$projectId/env/$environmentKey" - } + params: { + environmentKey: string; + organizationId: string; + projectId: string; + }; + reason: "resolved"; + to: "/orgs/$organizationId/projects/$projectId/env/$environmentKey"; + }; /** * The router payload for a target, without the `reason` the resolver carries for @@ -30,11 +37,11 @@ export type WorkspaceEntryTarget = export function workspaceEntryNavigation(target: WorkspaceEntryTarget) { return target.to === "/orgs/new" ? { to: target.to } - : { params: target.params, to: target.to } + : { params: target.params, to: target.to }; } function canCreateProject(entry: BootstrapOrganization) { - return entry.role === "owner" || entry.role === "admin" + return entry.role === "owner" || entry.role === "admin"; } /** @@ -48,17 +55,22 @@ function canCreateProject(entry: BootstrapOrganization) { * Organization that does have a Project, and land on the Organization overview * when none does. */ -export function resolveWorkspaceEntry(bootstrap: WorkspaceBootstrap): WorkspaceEntryTarget { - const organizations = bootstrap.organizations - const [first] = organizations +export function resolveWorkspaceEntry( + bootstrap: WorkspaceBootstrap +): WorkspaceEntryTarget { + const { organizations } = bootstrap; + const [first] = organizations; if (!first) { - return { reason: "no-organizations", to: "/orgs/new" } + return { reason: "no-organizations", to: "/orgs/new" }; } - const populated = organizations.find((entry) => entry.projects.length > 0) - const target = first.projects.length > 0 || canCreateProject(first) ? first : (populated ?? first) + const populated = organizations.find((entry) => entry.projects.length > 0); + const target = + first.projects.length > 0 || canCreateProject(first) + ? first + : (populated ?? first); - const [project] = target.projects + const [project] = target.projects; if (project) { return { params: { @@ -70,7 +82,7 @@ export function resolveWorkspaceEntry(bootstrap: WorkspaceBootstrap): WorkspaceE }, reason: "resolved", to: "/orgs/$organizationId/projects/$projectId/env/$environmentKey", - } + }; } if (canCreateProject(target)) { @@ -78,12 +90,12 @@ export function resolveWorkspaceEntry(bootstrap: WorkspaceBootstrap): WorkspaceE params: { organizationId: target.organization.id }, reason: "no-projects", to: "/orgs/$organizationId/projects/new", - } + }; } return { params: { organizationId: target.organization.id }, reason: "cannot-create-project", to: "/orgs/$organizationId", - } + }; } diff --git a/apps/dashboard/src/features/orgs/types/workspace-navigation.test.ts b/apps/dashboard/src/features/orgs/types/workspace-navigation.test.ts index 386fe1e3..056c5f91 100644 --- a/apps/dashboard/src/features/orgs/types/workspace-navigation.test.ts +++ b/apps/dashboard/src/features/orgs/types/workspace-navigation.test.ts @@ -1,69 +1,74 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it } from "vitest"; import { isEnvironmentSurface, isProjectWideSurface, readWorkspaceScope, -} from "@/features/orgs/types/workspace-navigation" +} from "@/features/orgs/types/workspace-navigation"; describe("hosted workspace route scope", () => { it("derives organization and project identity from the URL without a client store", () => { - const productRoute = "/orgs/org_one/projects/project_one/catalog/products/product_one" + const productRoute = + "/orgs/org_one/projects/project_one/catalog/products/product_one"; expect(readWorkspaceScope(productRoute)).toEqual({ - environmentId: undefined, + environmentSegment: undefined, organizationId: "org_one", projectId: "project_one", - }) - expect(isProjectWideSurface(productRoute)).toBe(true) - expect(isEnvironmentSurface(productRoute)).toBe(false) - }) + }); + expect(isProjectWideSurface(productRoute)).toBe(true); + expect(isEnvironmentSurface(productRoute)).toBe(false); + }); it("does not treat creation sentinels as selected scope", () => { expect(readWorkspaceScope("/orgs/new")).toEqual({ - environmentId: undefined, + environmentSegment: undefined, organizationId: undefined, projectId: undefined, - }) + }); expect( - isEnvironmentSurface("/orgs/org_one/projects/project_one/settings/api-keys"), - ).toBe(true) - }) + isEnvironmentSurface( + "/orgs/org_one/projects/project_one/env/prod/settings/api-keys" + ) + ).toBe(true); + }); it("keeps monetization Environment identity URL-owned", () => { - const route = "/orgs/org_one/projects/project_one/monetization/env_staging/paywalls" + const route = + "/orgs/org_one/projects/project_one/env/staging/monetization/paywalls"; expect(readWorkspaceScope(route)).toEqual({ - environmentId: "env_staging", + environmentSegment: "staging", organizationId: "org_one", projectId: "project_one", - }) - expect(isEnvironmentSurface(route)).toBe(true) - expect(isProjectWideSurface(route)).toBe(false) - }) + }); + expect(isEnvironmentSurface(route)).toBe(true); + expect(isProjectWideSurface(route)).toBe(false); + }); it("keeps Analytics Environment identity URL-owned", () => { - const route = "/orgs/org_one/projects/project_one/analytics/env_production/paywalls" + const route = + "/orgs/org_one/projects/project_one/env/prod/analytics/funnel"; expect(readWorkspaceScope(route)).toEqual({ - environmentId: "env_production", + environmentSegment: "prod", organizationId: "org_one", projectId: "project_one", - }) - expect(isEnvironmentSurface(route)).toBe(true) - expect(isProjectWideSurface(route)).toBe(false) - }) + }); + expect(isEnvironmentSurface(route)).toBe(true); + expect(isProjectWideSurface(route)).toBe(false); + }); it("keeps Billing Environment identity URL-owned", () => { const route = - "/orgs/org_one/projects/project_one/billing/env_production/projection-health" + "/orgs/org_one/projects/project_one/env/prod/billing/projection-health"; expect(readWorkspaceScope(route)).toEqual({ - environmentId: "env_production", + environmentSegment: "prod", organizationId: "org_one", projectId: "project_one", - }) - expect(isEnvironmentSurface(route)).toBe(true) - expect(isProjectWideSurface(route)).toBe(false) - }) -}) + }); + expect(isEnvironmentSurface(route)).toBe(true); + expect(isProjectWideSurface(route)).toBe(false); + }); +}); diff --git a/apps/dashboard/src/features/orgs/types/workspace-navigation.ts b/apps/dashboard/src/features/orgs/types/workspace-navigation.ts index 7b44f286..51a3723b 100644 --- a/apps/dashboard/src/features/orgs/types/workspace-navigation.ts +++ b/apps/dashboard/src/features/orgs/types/workspace-navigation.ts @@ -4,19 +4,20 @@ export interface WorkspaceScope { * Resolving it to an Environment is useActiveEnvironment's job, because only the * Project's own Environment list can say whether the segment means anything. */ - environmentSegment?: string - organizationId?: string - projectId?: string + environmentSegment?: string; + organizationId?: string; + projectId?: string; } export function readWorkspaceScope(pathname: string): WorkspaceScope { - const segments = pathname.split("/").filter(Boolean) - const organizationIndex = segments.indexOf("orgs") - const projectIndex = segments.indexOf("projects") - const environmentIndex = segments.indexOf("env") + const segments = pathname.split("/").filter(Boolean); + const organizationIndex = segments.indexOf("orgs"); + const projectIndex = segments.indexOf("projects"); + const environmentIndex = segments.indexOf("env"); return { - environmentSegment: environmentIndex >= 0 ? segments[environmentIndex + 1] : undefined, + environmentSegment: + environmentIndex >= 0 ? segments[environmentIndex + 1] : undefined, organizationId: organizationIndex >= 0 && segments[organizationIndex + 1] !== "new" ? segments[organizationIndex + 1] @@ -25,14 +26,14 @@ export function readWorkspaceScope(pathname: string): WorkspaceScope { projectIndex >= 0 && segments[projectIndex + 1] !== "new" ? segments[projectIndex + 1] : undefined, - } + }; } export function isProjectWideSurface(pathname: string) { - return pathname.includes("/apps") || pathname.includes("/catalog/") + return pathname.includes("/apps") || pathname.includes("/catalog/"); } /** Every surface under a Project now names an Environment, uniformly, under `/env`. */ export function isEnvironmentSurface(pathname: string) { - return pathname.includes("/env/") + return pathname.includes("/env/"); } diff --git a/apps/dashboard/src/features/paywall-editor/components/canvas-preview-device.tsx b/apps/dashboard/src/features/paywall-editor/components/canvas-preview-device.tsx index a0f9eaa1..e502be9b 100644 --- a/apps/dashboard/src/features/paywall-editor/components/canvas-preview-device.tsx +++ b/apps/dashboard/src/features/paywall-editor/components/canvas-preview-device.tsx @@ -1,23 +1,26 @@ -import { BatteryHighIcon } from "@phosphor-icons/react/dist/ssr/BatteryHigh" -import { CellSignalFullIcon } from "@phosphor-icons/react/dist/ssr/CellSignalFull" -import { WifiHighIcon } from "@phosphor-icons/react/dist/ssr/WifiHigh" -import type { CSSProperties, MouseEvent, ReactNode } from "react" - +import { BatteryHighIcon } from "@phosphor-icons/react/dist/ssr/BatteryHigh"; +import { CellSignalFullIcon } from "@phosphor-icons/react/dist/ssr/CellSignalFull"; +import { WifiHighIcon } from "@phosphor-icons/react/dist/ssr/WifiHigh"; +import type { CSSProperties, MouseEvent, ReactNode } from "react"; +import type { + CanvasDeviceGeometry, + CanvasDeviceNodeGeometry, +} from "@/features/paywall-editor/components/canvas-preview-geometry"; import type { CanvasDeviceMaterial, CanvasDevicePreset, -} from "@/features/paywall-editor/constants/canvas-devices" +} from "@/features/paywall-editor/constants/canvas-devices"; import type { - CanvasDeviceGeometry, - CanvasDeviceNodeGeometry, -} from "@/features/paywall-editor/components/canvas-preview-geometry" -import type { MosaicDocument, Screen, StackComponent } from "@/features/paywall-editor/types/editor" -import type { StudioCanvasPreferences } from "@/features/paywall-editor/types/studio-workspace" + MosaicDocument, + Screen, + StackComponent, +} from "@/features/paywall-editor/types/editor"; +import type { StudioCanvasPreferences } from "@/features/paywall-editor/types/studio-workspace"; import { resolvedBackground, resolvedProtocolColor, resolvedShadow, -} from "@/features/paywall-editor/utils/protocol-styles" +} from "@/features/paywall-editor/utils/protocol-styles"; const FRAME_MATERIALS: Record = { aluminum: { @@ -36,31 +39,37 @@ const FRAME_MATERIALS: Record = { background: "linear-gradient(145deg, #e0e1df 0%, #7f827f 18%, #303330 50%, #b9bbb8 80%, #545754 100%)", }, -} +}; function alignmentStyle(alignment: StackComponent["crossAxisAlignment"]) { switch (alignment) { case "start": - return "flex-start" + return "flex-start"; case "center": - return "center" + return "center"; case "end": - return "flex-end" + return "flex-end"; case "stretch": - return "stretch" + return "stretch"; + default: { + const unhandled: never = alignment; + throw new Error(`Unhandled alignment: ${JSON.stringify(unhandled)}`); + } } } -function distributionStyle(distribution: StackComponent["mainAxisDistribution"]) { - return distribution === "spaceBetween" ? "space-between" : distribution +function distributionStyle( + distribution: StackComponent["mainAxisDistribution"] +) { + return distribution === "spaceBetween" ? "space-between" : distribution; } function HardwareButtons({ orientation, preset, }: { - orientation: StudioCanvasPreferences["orientation"] - preset: CanvasDevicePreset + orientation: StudioCanvasPreferences["orientation"]; + preset: CanvasDevicePreset; }) { if (preset.formFactor === "tablet") { return orientation === "portrait" ? ( @@ -73,7 +82,7 @@ function HardwareButtons({ - ) + ); } return orientation === "portrait" ? ( @@ -88,71 +97,75 @@ function HardwareButtons({ - ) + ); } function DeviceSensor({ orientation, preset, }: { - orientation: StudioCanvasPreferences["orientation"] - preset: CanvasDevicePreset + orientation: StudioCanvasPreferences["orientation"]; + preset: CanvasDevicePreset; }) { - const landscape = orientation === "landscape" + const landscape = orientation === "landscape"; if (preset.frame.sensor === "bezel-camera") { return ( - ) + ); } if (preset.frame.sensor === "dynamic-island") { return ( - ) + ); } return ( - ) + ); } function IosCellularSignal({ compact }: { compact: boolean }) { return ( - ) + ); } function IosWifiSignal({ compact }: { compact: boolean }) { return ( - ) + ); } function IosBattery({ compact, dark }: { compact: boolean; dark: boolean }) { return ( - + - ) + ); } -function IosStatusIndicators({ compact, dark }: { compact: boolean; dark: boolean }) { +function IosStatusIndicators({ + compact, + dark, +}: { + compact: boolean; + dark: boolean; +}) { return ( - ) + ); } function AndroidStatusIndicators({ compact }: { compact: boolean }) { return ( - + - ) + ); } function SystemStatusBar({ @@ -261,21 +295,42 @@ function SystemStatusBar({ orientation, preset, }: { - appearance: StudioCanvasPreferences["appearance"] - orientation: StudioCanvasPreferences["orientation"] - preset: CanvasDevicePreset + appearance: StudioCanvasPreferences["appearance"]; + orientation: StudioCanvasPreferences["orientation"]; + preset: CanvasDevicePreset; }) { - const isTablet = preset.formFactor === "tablet" - const isLandscape = orientation === "landscape" - const dark = appearance === "dark" - const time = preset.platform === "ios" ? "9:41" : "12:45" - const statusBarHeight = isTablet ? 26 : isLandscape ? 28 : preset.platform === "ios" ? 62 : 34 - const inlinePadding = - preset.id === "iphone-17-pro-max" ? 40 : preset.id === "iphone-17-pro" ? 35 : isTablet ? 18 : 22 + const isTablet = preset.formFactor === "tablet"; + const isLandscape = orientation === "landscape"; + const dark = appearance === "dark"; + const time = preset.platform === "ios" ? "9:41" : "12:45"; + const statusBarHeight = (() => { + if (isTablet) { + return 26; + } + if (isLandscape) { + return 28; + } + if (preset.platform === "ios") { + return 62; + } + return 34; + })(); + const inlinePadding = (() => { + if (preset.id === "iphone-17-pro-max") { + return 40; + } + if (preset.id === "iphone-17-pro") { + return 35; + } + if (isTablet) { + return 18; + } + return 22; + })(); return (
      { + if (isTablet) { + return 7; + } + if (isLandscape) { + return 7; + } + if (preset.platform === "ios") { + return 18; + } + return 9; + })(), paddingInline: inlinePadding, }} > { + if (isTablet) { + return "text-[11px] leading-none"; + } + if (preset.platform === "ios") { + return "text-[15px] leading-none"; + } + return "text-[13px] leading-none"; + })()} style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif", @@ -312,24 +380,24 @@ function SystemStatusBar({ )}
      - ) + ); } function SystemGestureBar({ appearance, platform, }: { - appearance: StudioCanvasPreferences["appearance"] - platform: CanvasDevicePreset["platform"] + appearance: StudioCanvasPreferences["appearance"]; + platform: CanvasDevicePreset["platform"]; }) { return ( - ) + ); } export function CanvasPreviewDevice({ @@ -352,35 +420,40 @@ export function CanvasPreviewDevice({ onRootClick, onRootSelect, }: { - active: boolean - canvas: StudioCanvasPreferences - children: ReactNode - direction: "ltr" | "rtl" - document: MosaicDocument - geometry: CanvasDeviceGeometry - initial: boolean - layout: Screen["layout"] - nodeGeometry: CanvasDeviceNodeGeometry - presentation: "screen" | "sheet" - preset: CanvasDevicePreset - rootHidden: boolean - screenLabel: string - selectedComponentId: string | null - zoom: number - onFrameSelect: () => void - onRootClick: (event: MouseEvent) => void - onRootSelect: () => void + active: boolean; + canvas: StudioCanvasPreferences; + children: ReactNode; + direction: "ltr" | "rtl"; + document: MosaicDocument; + geometry: CanvasDeviceGeometry; + initial: boolean; + layout: Screen["layout"]; + nodeGeometry: CanvasDeviceNodeGeometry; + presentation: "screen" | "sheet"; + preset: CanvasDevicePreset; + rootHidden: boolean; + screenLabel: string; + selectedComponentId: string | null; + zoom: number; + onFrameSelect: () => void; + onRootClick: (event: MouseEvent) => void; + onRootSelect: () => void; }) { - const root = layout.content - const layoutBackground = resolvedBackground(document, layout.background) - const rootBackground = resolvedBackground(document, root.appearance?.background) - const respectsSafeArea = layout.safeArea === "respect" - const safeArea = respectsSafeArea ? geometry.safeArea : { top: 0, right: 0, bottom: 0, left: 0 } + const root = layout.content; + const layoutBackground = resolvedBackground(document, layout.background); + const rootBackground = resolvedBackground( + document, + root.appearance?.background + ); + const respectsSafeArea = layout.safeArea === "respect"; + const safeArea = respectsSafeArea + ? geometry.safeArea + : { top: 0, right: 0, bottom: 0, left: 0 }; return ( -
      {preset.label} · {preset.displayLabel} @@ -440,7 +512,9 @@ export function CanvasPreviewDevice({
      - + {layoutBackground.video ? (
      - ) + + ); } diff --git a/apps/dashboard/src/features/paywall-editor/components/canvas-preview-geometry.ts b/apps/dashboard/src/features/paywall-editor/components/canvas-preview-geometry.ts index e4ff8a75..0ccac6ce 100644 --- a/apps/dashboard/src/features/paywall-editor/components/canvas-preview-geometry.ts +++ b/apps/dashboard/src/features/paywall-editor/components/canvas-preview-geometry.ts @@ -1,37 +1,39 @@ -import { STUDIO_CANVAS_ZOOM_BOUNDS } from "@/features/paywall-editor/constants/studio-workspace" -import { getCanvasDevicePreset } from "@/features/paywall-editor/constants/canvas-devices" +import { getCanvasDevicePreset } from "@/features/paywall-editor/constants/canvas-devices"; +import { STUDIO_CANVAS_ZOOM_BOUNDS } from "@/features/paywall-editor/constants/studio-workspace"; import type { StudioCanvasDevice, StudioCanvasOrientation, StudioCanvasPreferences, -} from "@/features/paywall-editor/types/studio-workspace" +} from "@/features/paywall-editor/types/studio-workspace"; export interface CanvasDeviceGeometry { - readonly height: number + readonly height: number; readonly safeArea: { - readonly top: number - readonly right: number - readonly bottom: number - readonly left: number - } - readonly width: number + readonly top: number; + readonly right: number; + readonly bottom: number; + readonly left: number; + }; + readonly width: number; } export interface CanvasDeviceNodeGeometry { - readonly height: number - readonly shellHeight: number - readonly shellWidth: number - readonly width: number + readonly height: number; + readonly shellHeight: number; + readonly shellWidth: number; + readonly width: number; } -export const CANVAS_DEVICE_LABEL_HEIGHT = 38 +export const CANVAS_DEVICE_LABEL_HEIGHT = 38; export function resolveCanvasDeviceGeometry( device: StudioCanvasDevice, - orientation: StudioCanvasOrientation, + orientation: StudioCanvasOrientation ): CanvasDeviceGeometry { - const portrait = getCanvasDevicePreset(device).portrait - if (orientation === "portrait") return portrait + const { portrait } = getCanvasDevicePreset(device); + if (orientation === "portrait") { + return portrait; + } return { width: portrait.height, height: portrait.width, @@ -41,23 +43,23 @@ export function resolveCanvasDeviceGeometry( bottom: portrait.safeArea.right, left: portrait.safeArea.bottom, }, - } + }; } export function resolveCanvasDeviceNodeGeometry( device: StudioCanvasDevice, - orientation: StudioCanvasOrientation, + orientation: StudioCanvasOrientation ): CanvasDeviceNodeGeometry { - const preset = getCanvasDevicePreset(device) - const screen = resolveCanvasDeviceGeometry(device, orientation) - const shellWidth = screen.width + preset.frame.bezel * 2 - const shellHeight = screen.height + preset.frame.bezel * 2 + const preset = getCanvasDevicePreset(device); + const screen = resolveCanvasDeviceGeometry(device, orientation); + const shellWidth = screen.width + preset.frame.bezel * 2; + const shellHeight = screen.height + preset.frame.bezel * 2; return { width: shellWidth, height: shellHeight + CANVAS_DEVICE_LABEL_HEIGHT, shellHeight, shellWidth, - } + }; } export function calculateCanvasScale({ @@ -66,17 +68,21 @@ export function calculateCanvasScale({ geometry, preferences, }: { - availableHeight: number - availableWidth: number - geometry: Pick - preferences: Pick + availableHeight: number; + availableWidth: number; + geometry: Pick; + preferences: Pick; }) { - if (preferences.fitMode === "manual") return preferences.zoom - if (availableWidth <= 0 || availableHeight <= 0) return 1 + if (preferences.fitMode === "manual") { + return preferences.zoom; + } + if (availableWidth <= 0 || availableHeight <= 0) { + return 1; + } const fitted = Math.min( (availableWidth - 48) / geometry.width, (availableHeight - 48) / geometry.height, - STUDIO_CANVAS_ZOOM_BOUNDS.max, - ) - return Math.max(STUDIO_CANVAS_ZOOM_BOUNDS.min, fitted) + STUDIO_CANVAS_ZOOM_BOUNDS.max + ); + return Math.max(STUDIO_CANVAS_ZOOM_BOUNDS.min, fitted); } diff --git a/apps/dashboard/src/features/paywall-editor/components/canvas-preview-node-primitives.tsx b/apps/dashboard/src/features/paywall-editor/components/canvas-preview-node-primitives.tsx index a76450b2..46985006 100644 --- a/apps/dashboard/src/features/paywall-editor/components/canvas-preview-node-primitives.tsx +++ b/apps/dashboard/src/features/paywall-editor/components/canvas-preview-node-primitives.tsx @@ -1,48 +1,58 @@ /* eslint-disable react-refresh/only-export-components -- renderer primitives and their pure style functions form one internal module. */ -import { useEffect, useRef } from "react" -import type { CSSProperties, ReactNode } from "react" -import { useEditorActions } from "@/features/paywall-editor/stores/editor-store-context" +import type { CSSProperties, ReactNode } from "react"; +import { useEffect, useRef } from "react"; + +import { useEditorActions } from "@/features/paywall-editor/stores/editor-store-context"; import type { MockProductDefinition, MosaicDocument, ProtocolNode, -} from "@/features/paywall-editor/types/editor" -import { resolveLocalizedText } from "@/features/paywall-editor/utils/document-tree" +} from "@/features/paywall-editor/types/editor"; +import { resolveLocalizedText } from "@/features/paywall-editor/utils/document-tree-mutations"; import { axisSizingCss, resolvedBackground, resolvedProtocolColor, resolvedShadow, -} from "@/features/paywall-editor/utils/protocol-styles" -import { fillAxisIsBounded, fixedSizingClipsOverflow } from "@/features/paywall-editor/utils/sizing" -import { interpolateProductText } from "@/lib/mosaic-protocol" +} from "@/features/paywall-editor/utils/protocol-styles"; +import { + fillAxisIsBounded, + fixedSizingClipsOverflow, +} from "@/features/paywall-editor/utils/sizing"; +import { interpolateProductText } from "@/lib/mosaic-protocol"; + +const PRICE_TOKEN = /\{\{\s*product\.price\s*\}\}/; export interface PreviewProductContext { - readonly cardId: string - readonly name: string - readonly price: string - readonly productReferenceId: string - readonly selected: boolean - readonly visualSelected: boolean - readonly selectorId: string + readonly cardId: string; + readonly name: string; + readonly price: string; + readonly productReferenceId: string; + readonly selected: boolean; + readonly selectorId: string; + readonly visualSelected: boolean; } -type ProductCardNode = Extract +type ProductCardNode = Extract; -type IconName = Extract["name"] +type IconName = Extract["name"]; export const RTL_ICON_NAMES: Partial> = { arrowBackward: "arrowForward", arrowForward: "arrowBackward", chevronBackward: "chevronForward", chevronForward: "chevronBackward", -} +}; -export function frameStyle(document: MosaicDocument, node: ProtocolNode): CSSProperties { - const outer = "outerInsets" in node ? node.outerInsets : undefined - const sizing = "sizing" in node ? node.sizing : undefined - const badgePlacement = node.type === "productBadge" ? node.placement : undefined +export function frameStyle( + document: MosaicDocument, + node: ProtocolNode +): CSSProperties { + const outer = "outerInsets" in node ? node.outerInsets : undefined; + const sizing = "sizing" in node ? node.sizing : undefined; + const badgePlacement = + node.type === "productBadge" ? node.placement : undefined; return { width: axisSizingCss(sizing?.width, { axis: "width", @@ -68,61 +78,78 @@ export function frameStyle(document: MosaicDocument, node: ProtocolNode): CSSPro insetBlockStart: badgePlacement.anchor.startsWith("top") ? badgePlacement.inset : undefined, - insetInlineEnd: badgePlacement.anchor.endsWith("End") ? badgePlacement.inset : undefined, + insetInlineEnd: badgePlacement.anchor.endsWith("End") + ? badgePlacement.inset + : undefined, insetInlineStart: badgePlacement.anchor.endsWith("Start") ? badgePlacement.inset : undefined, zIndex: 1, } : {}), - } + }; } -export function resolveProductTemplate(value: string, product: PreviewProductContext | undefined) { - if (!product) return value +export function resolveProductTemplate( + value: string, + product: PreviewProductContext | undefined +) { + if (!product) { + return value; + } const resolved = interpolateProductText(value, { name: product.name, fallbackName: product.name, price: product.price, - }) - return resolved.available ? resolved.value : "" + }); + return resolved.available ? resolved.value : ""; } export function productCardRequiresPrice( document: MosaicDocument, card: ProductCardNode, - locale: string, + locale: string ) { const usesPrice = (value: Parameters[1]) => - /\{\{\s*product\.price\s*\}\}/.test(resolveLocalizedText(document, value, locale)) - if (card.accessibility && usesPrice(card.accessibility.label)) return true + PRICE_TOKEN.test(resolveLocalizedText(document, value, locale)); + if (card.accessibility && usesPrice(card.accessibility.label)) { + return true; + } function nodeRequiresPrice(node: ProtocolNode): boolean { - if (node.type === "text") return usesPrice(node.value) + if (node.type === "text") { + return usesPrice(node.value); + } if (node.type === "stack" || node.type === "productBadge") { - return node.children.some(nodeRequiresPrice) + return node.children.some(nodeRequiresPrice); } - return false + return false; } - return card.children.some(nodeRequiresPrice) + return card.children.some(nodeRequiresPrice); } -type PreviewAppearance = { - background?: NonNullable["appearance"]>["background"] - border?: NonNullable["appearance"]>["border"] - clipContent?: boolean - cornerRadius?: number - opacity?: number - padding?: { top: number; start: number; bottom: number; end: number } - shadow?: NonNullable["appearance"]>["shadow"] +interface PreviewAppearance { + background?: NonNullable< + Extract["appearance"] + >["background"]; + border?: NonNullable< + Extract["appearance"] + >["border"]; + clipContent?: boolean; + cornerRadius?: number; + opacity?: number; + padding?: { top: number; start: number; bottom: number; end: number }; + shadow?: NonNullable< + Extract["appearance"] + >["shadow"]; } export function appearanceStyle( document: MosaicDocument, - value: PreviewAppearance | undefined, + value: PreviewAppearance | undefined ): CSSProperties { - const background = resolvedBackground(document, value?.background) + const background = resolvedBackground(document, value?.background); return { ...(background.mediaType === "video" ? {} : background.style), borderColor: resolvedProtocolColor(document, value?.border?.color), @@ -136,12 +163,12 @@ export function appearanceStyle( paddingInlineEnd: value?.padding?.end, paddingInlineStart: value?.padding?.start, boxShadow: resolvedShadow(document, value?.shadow), - } + }; } export function typographyStyle( document: MosaicDocument, - typography: Extract["typography"], + typography: Extract["typography"] ): CSSProperties { return { color: resolvedProtocolColor(document, typography.color), @@ -162,57 +189,84 @@ export function typographyStyle( WebkitLineClamp: typography.maxLines, } : {}), - } + }; } export function productPrice(product: MockProductDefinition | undefined) { - return product?.availability === "available" ? product.localizedPrice : "" + return product?.availability === "available" ? product.localizedPrice : ""; } export function alignmentStyle( - alignment: Extract["crossAxisAlignment"], + alignment: Extract< + ProtocolNode, + { type: "stack" | "button" } + >["crossAxisAlignment"] ) { switch (alignment) { case "start": - return "flex-start" + return "flex-start"; case "center": - return "center" + return "center"; case "end": - return "flex-end" + return "flex-end"; case "stretch": - return "stretch" + return "stretch"; + default: { + const unhandled: never = alignment; + throw new Error(`Unhandled alignment: ${JSON.stringify(unhandled)}`); + } } } export function distributionStyle( - distribution: Extract["mainAxisDistribution"], + distribution: Extract< + ProtocolNode, + { type: "stack" | "button" } + >["mainAxisDistribution"] ) { - return distribution === "spaceBetween" ? "space-between" : distribution + return distribution === "spaceBetween" ? "space-between" : distribution; } -export function subtreeIncludesId(node: ProtocolNode, id: string | null): boolean { - if (!id) return false - if (node.id === id) return true - if (node.type === "stack") return node.children.some((child) => subtreeIncludesId(child, id)) +export function subtreeIncludesId( + node: ProtocolNode, + id: string | null +): boolean { + if (!id) { + return false; + } + if (node.id === id) { + return true; + } + if (node.type === "stack") { + return node.children.some((child) => subtreeIncludesId(child, id)); + } if (node.type === "button") { - return [...node.children, ...(node.inProgressChildren ?? [])].some((child) => - subtreeIncludesId(child, id), - ) + return [...node.children, ...(node.inProgressChildren ?? [])].some( + (child) => subtreeIncludesId(child, id) + ); } if (node.type === "carousel") { - return node.pages.some((page) => subtreeIncludesId(page.content, id)) + return node.pages.some((page) => subtreeIncludesId(page.content, id)); } if (node.type === "productSelector") { - return node.cards.some((card) => subtreeIncludesId(card, id)) + return node.cards.some((card) => subtreeIncludesId(card, id)); } if (node.type === "productCard" || node.type === "productBadge") { - return node.children.some((child) => subtreeIncludesId(child, id)) + return node.children.some((child) => subtreeIncludesId(child, id)); } - return false + return false; } -export function headingElement(level: number): "h1" | "h2" | "h3" | "h4" | "h5" | "h6" { - return `h${Math.min(6, Math.max(1, level))}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6" +export function headingElement( + level: number +): "h1" | "h2" | "h3" | "h4" | "h5" | "h6" { + return `h${Math.min(6, Math.max(1, level))}` as + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6"; } export function InlineEditor({ @@ -225,17 +279,17 @@ export function InlineEditor({ style, value, }: { - ariaLabel: string - className: string - multiline: boolean - onCancel: () => void - onCommit: () => void - onUpdate: (value: string) => void - style?: CSSProperties - value: string + ariaLabel: string; + className: string; + multiline: boolean; + onCancel: () => void; + onCommit: () => void; + onUpdate: (value: string) => void; + style?: CSSProperties; + value: string; }) { - const fieldRef = useRef(null) - useEffect(() => fieldRef.current?.focus(), []) + const fieldRef = useRef(null); + useEffect(() => fieldRef.current?.focus(), []); return (