diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/EventHistorySanityCheckPlugin.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/EventHistorySanityCheckPlugin.scala index 232d7113a8..c985cb6683 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/EventHistorySanityCheckPlugin.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/EventHistorySanityCheckPlugin.scala @@ -16,6 +16,7 @@ import org.lfdecentralizedtrust.splice.http.v0.definitions.UpdateHistoryReassign import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.SpliceTestConsoleEnvironment import org.scalatest.concurrent.Eventually import org.scalatest.matchers.should.Matchers +import org.scalatest.time.{Millis, Seconds, Span} import org.scalatest.{Inspectors, LoneElement} import scala.annotation.tailrec @@ -34,7 +35,16 @@ class EventHistorySanityCheckPlugin( ): Unit = { val initializedScans = environment.scans.local.filter(_.is_initialized) if (initializedScans.nonEmpty) { - compareEventHistories(initializedScans) + // getEventHistory only serves events up to min(update, verdict) ingestion cursor + // (ScanEventStore.getCurrentMigrationCap), and verdict ingestion from the mediator lags + // behind update ingestion. At teardown this can hide even long-ingested updates, such as + // the DsoRules_AddSv exercise that compareEventHistories requires to appear in the founder + // history, so retry: each attempt re-fetches the histories until the cursors catch up. + eventually(compareEventHistories(initializedScans))( + PatienceConfig(timeout = Span(20, Seconds), interval = Span(500, Millis)), + implicitly[org.scalatest.enablers.Retrying[Unit]], + implicitly[org.scalactic.source.Position], + ) } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvFrontendIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvFrontendIntegrationTest.scala index 5591a73d02..1e39e21e7b 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvFrontendIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvFrontendIntegrationTest.scala @@ -63,7 +63,7 @@ class SvFrontendIntegrationTest }, )( "logged in in the sv ui", - _ => find(id("app-title")).value.text should matchText("SUPER VALIDATOR OPERATIONS"), + _ => find(id("app-title")).value.text should matchText("Supervalidator Operations"), ) } } @@ -1419,7 +1419,7 @@ class SvFrontendIntegrationTest } } - "NEW UI: Grant and Revoke Featured App Right" in { implicit env => + "NEW UI: Grant, Update and Revoke Featured App Right" in { implicit env => val providerParty = sv3Backend.getDsoInfo().svParty val providerPartyId = providerParty.toProtoPrimitive val activityWeight = BigDecimal("2.5") @@ -1435,23 +1435,8 @@ class SvFrontendIntegrationTest clue("vote the grant request to execution before creating revoke request") { val grantTrackingCid = eventually() { - val voteRequest: Contract[VoteRequest.ContractId, VoteRequest] = sv1Backend - .listVoteRequests() - .find { request => - val requestCid = request.contractId.contractId - val trackingCid = - if (request.payload.trackingCid.isPresent) { - Some(request.payload.trackingCid.get.contractId) - } else { - None - } - requestCid == grantProposalContractId || trackingCid.contains(grantProposalContractId) - } - .getOrElse( - fail( - s"Could not find vote request for grant proposal contract id: $grantProposalContractId" - ) - ) + val voteRequest: Contract[VoteRequest.ContractId, VoteRequest] = + getVoteRequestForProposal(grantProposalContractId) if (voteRequest.payload.trackingCid.isPresent) voteRequest.payload.trackingCid.get else voteRequest.contractId @@ -1474,6 +1459,38 @@ class SvFrontendIntegrationTest } } + val newActivityWeight = BigDecimal("3.0") + + val updateProposalContractId = assertCreateProposal( + "SRARC_UpdateFeaturedAppRight", + "update-featured-app", + ) { implicit webDriver => + fillOutTextField("update-featured-app-partyId", providerPartyId) + selectFirstMuiOption("update-featured-app-rightCid-dropdown") + fillOutTextField("update-featured-app-activityWeight", newActivityWeight.toString) + fillOutTextField("update-featured-app-reason", "increasing weight") + } + + clue("vote the update request to execution") { + val updateTrackingCid = eventually() { + val voteRequest = getVoteRequestForProposal(updateProposalContractId) + if (voteRequest.payload.trackingCid.isPresent) voteRequest.payload.trackingCid.get + else voteRequest.contractId + } + + eventuallySucceeds() { + sv3Backend.castVote(updateTrackingCid, isAccepted = true, "", "") + } + + eventually() { + val featuredAppRight = sv1ScanBackend.lookupFeaturedAppRight(providerParty) + featuredAppRight shouldBe a[Some[?]] + featuredAppRight.value.payload.activityWeight.toScala.map( + BigDecimal(_) + ) shouldBe Some(newActivityWeight) + } + } + // Now create a Revoke proposal by selecting a contract ID from the provider's dropdown. assertCreateProposal("SRARC_RevokeFeaturedAppRight", "revoke-featured-app") { implicit webDriver => @@ -1655,6 +1672,29 @@ class SvFrontendIntegrationTest } } + def getVoteRequestForProposal( + proposalContractId: String + )(implicit env: SpliceTestConsoleEnvironment) = { + val voteRequest: Contract[VoteRequest.ContractId, VoteRequest] = sv1Backend + .listVoteRequests() + .find { request => + val requestCid = request.contractId.contractId + val trackingCid = + if (request.payload.trackingCid.isPresent) { + Some(request.payload.trackingCid.get.contractId) + } else { + None + } + requestCid == proposalContractId || trackingCid.contains(proposalContractId) + } + .getOrElse( + fail( + s"Could not find vote request for proposal contract id: $proposalContractId" + ) + ) + voteRequest + } + def changeAction(actionName: String)(implicit webDriver: WebDriverType) = { eventually() { find( diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingAddlIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingAddlIntegrationTest.scala index 25be93015f..bff3b008fc 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingAddlIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingAddlIntegrationTest.scala @@ -13,6 +13,7 @@ import org.lfdecentralizedtrust.splice.sv.util.{SvOnboardingToken, SvUtil} import scala.jdk.OptionConverters.* import org.lfdecentralizedtrust.splice.sv.admin.api.client.commands.HttpSvPublicAppClient.SvOnboardingStatus import org.lfdecentralizedtrust.splice.util.{SvTestUtil, WalletTestUtil} +import com.digitalasset.canton.console.CommandFailure import com.digitalasset.canton.logging.SuppressionRule import com.digitalasset.canton.topology.transaction.ParticipantPermission import org.slf4j.event.Level @@ -349,7 +350,11 @@ class SvOnboardingAddlIntegrationTest } clue("create a amulet again with actAs = DSO") { withCommandRetryPolicy(_ => _ => false) { - assertThrowsAndLogsCommandFailures( + // Suppress at ERROR level only: background WARNs (e.g. the SV app failing to read the + // bft sequencers list while sv2's scan is unavailable) must not fail the log assertion. + loggerFactory.assertThrowsAndLogsSuppressing[CommandFailure]( + SuppressionRule.LevelAndAbove(Level.ERROR) + )( createAmulet( sv1ValidatorBackend.participantClientWithAdminToken, sv1UserId, diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletIntegrationTest.scala index f37c9c21d5..45d888648a 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletIntegrationTest.scala @@ -34,6 +34,8 @@ import com.digitalasset.canton.discard.Implicits.DiscardOps import org.apache.pekko.http.scaladsl.Http import org.apache.pekko.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes} import org.apache.pekko.http.scaladsl.model.headers.{Authorization, OAuth2BearerToken} +import org.scalatest.concurrent.PatienceConfiguration +import org.scalatest.time.{Seconds, Span} import org.slf4j.event.Level import java.time.Duration @@ -225,9 +227,12 @@ class WalletIntegrationTest val tapsAfter = Range(0, 3).map(_ => Future(Try(aliceWalletClient.tap(10)))) - // Wait for all futures to complete - val successfulTaps = (tapsBefore ++ tapsAfter).map(_.futureValue).count(_.isSuccess) - if (failedAcceptF.futureValue.isSuccess) + // Wait for all futures to complete. The stale accept forces the treasury to filter + // and retry batches, so under load this can exceed the default patience. + val patience = PatienceConfiguration.Timeout(Span(60, Seconds)) + val successfulTaps = + (tapsBefore ++ tapsAfter).map(_.futureValue(patience)).count(_.isSuccess) + if (failedAcceptF.futureValue(patience).isSuccess) fail("The AcceptTransferOffer action unexpectedly succeeded") successfulTaps should be( diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanVerdictIngestionService.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanVerdictIngestionService.scala index 302df6e015..ee476d12a8 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanVerdictIngestionService.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanVerdictIngestionService.scala @@ -243,30 +243,38 @@ class ScanVerdictIngestionService( // Compute app activity records (before DB transaction). // Records have verdictRowId = DUMMY_VERDICT_ROW_ID // the store resolves actual row_ids during insertion. - (appActivityRecords, lastArchivedRoundO) <- appActivityComputationO match { - case Some(appActivityComputation) => - for { - records <- appActivityComputation.computeActivities(summariesWithVerdicts).map { - _.flatMap { case (summary, _, recordO) => - recordO.map(summary.sequencingTime -> _) + (appActivityRecords, firstActiveRoundO, lastArchivedRoundO) <- + appActivityComputationO match { + case Some(appActivityComputation) => + val recordTimes = + verdicts.map(v => CantonTimestamp.tryFromProtoTimestamp(v.getRecordTime)) + for { + records <- appActivityComputation.computeActivities(summariesWithVerdicts).map { + _.flatMap { case (summary, _, recordO) => + recordO.map(summary.sequencingTime -> _) + } } - } - lastArchivedRoundO <- verdicts - .map(v => CantonTimestamp.tryFromProtoTimestamp(v.getRecordTime)) - .maxOption match { - case Some(maxRecordTime) => - appActivityComputation.lookupLatestArchivedOpenMiningRound(maxRecordTime) - case None => Future.successful(None) - } - } yield (records, lastArchivedRoundO) - case None => Future.successful((Seq.empty, None)) - } + firstActiveRoundO <- recordTimes.minOption match { + case Some(minRecordTime) => + appActivityComputation.lookupActiveOpenMiningRound(minRecordTime) + case None => Future.successful(None) + } + lastArchivedRoundO <- recordTimes.maxOption match { + case Some(maxRecordTime) => + appActivityComputation.lookupLatestArchivedOpenMiningRound(maxRecordTime) + case None => Future.successful(None) + } + } yield (records, firstActiveRoundO, lastArchivedRoundO) + case None => Future.successful((Seq.empty, None, None)) + } _ <- ensureVerdictsHaveTrafficSummaries(verdicts, summaryByTime) _ <- store.insertVerdictsWithAppActivityRecords( items, appActivityRecords, - lastArchivedRoundO, + hasTrafficSummaries = summaryByTime.nonEmpty, + firstActiveRoundO = firstActiveRoundO, + lastArchivedRoundO = lastArchivedRoundO, ) } yield { val lastRecordTime = verdicts.lastOption diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/rewards/AppActivityComputation.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/rewards/AppActivityComputation.scala index 30b4b9ede5..be99679eee 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/rewards/AppActivityComputation.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/rewards/AppActivityComputation.scala @@ -49,6 +49,14 @@ class AppActivityComputation( )(implicit tc: TraceContext): Future[Option[Long]] = rewardsReferenceStore.lookupLatestArchivedOpenMiningRound(asOf) + /** The OpenMiningRound round number active at asOf, if the round data has been ingested. */ + def lookupActiveOpenMiningRound( + asOf: CantonTimestamp + )(implicit tc: TraceContext): Future[Option[Long]] = + rewardsReferenceStore + .lookupActiveOpenMiningRounds(Seq(asOf)) + .map(_.get(asOf).map { case (roundNumber, _) => roundNumber }) + /** Compute app activity records for a batch of verdicts. * * Records are returned with verdictRowId = DUMMY_VERDICT_ROW_ID as a placeholder. @@ -125,8 +133,12 @@ class AppActivityComputation( } case None => // Skip activity record computation as we don't have the necessary round data ingested. - // This can happen for freshly onboarded SVs, but is not - // expected to happen once the first activity record has been computed. + // This can happen for freshly onboarded SVs if the reward + // reference store does not have the data for any of the + // sequencingTime(s) in this batch. + // OTOH this cannot happen after ingestion starts because + // lookupActiveOpenMiningRounds blocks until the reference store + // has caught up to all the sequencingTime(s) in this batch. logger.debug( s"No round data found for sequencingTime=${summary.sequencingTime}, skipping activity record computation" ) diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbAppActivityRecordStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbAppActivityRecordStore.scala index a60594dc25..3016348a34 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbAppActivityRecordStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbAppActivityRecordStore.scala @@ -300,13 +300,16 @@ class DbAppActivityRecordStore( /** Insert activity records and ensure the meta row exists. * Creates the meta row when enough information is available to * determine which rounds have complete activity, even when no - * activity records exist (e.g., no featured app providers). + * activity records exist (e.g., no featured app providers), + * but only if traffic-summaries could be obtained for this batch. * On a fresh firstSV with no archived rounds, bootstraps round 0 * as complete. */ def insertAppActivityRecordsDBIO( items: Seq[AppActivityRecordT], firstRecordTimeMicros: Long, + hasTrafficSummaries: Boolean, + firstActiveRoundO: Option[Long] = None, lastArchivedRoundO: Option[Long] = None, )(implicit tc: TraceContext): DBIO[Unit] = { val insertRecords = @@ -316,15 +319,9 @@ class DbAppActivityRecordStore( logger.info(s"Inserted ${items.size} app activity records.") } - // earliestRound: the lowest round covered by this ingestion batch. - // - From activity records when present - // - From lastArchivedRound when no featured apps produced records - // - From bootstrap (-1) on a fresh firstSV with no archived rounds - val earliestRound = items - .map(_.roundNumber) - .minOption - .orElse(lastArchivedRoundO) - .orElse(if (isFirstSv) Some(-1L) else None) + // earliestRound: the oldest round open at the earliest record_time of this batch. + // or (-1) on firstSV, as it is expected to have complete data for the first round. + val earliestRound = if (isFirstSv) Some(-1L) else firstActiveRoundO // lastArchived: the highest round archived as of this verdict batch. // - From the caller when available @@ -337,10 +334,12 @@ class DbAppActivityRecordStore( for { _ <- insertRecords ensureResult <- earliestRound match { - case Some(earliest) => + case Some(earliest) if hasTrafficSummaries => ensureMetaDBIO((firstRecordTimeMicros, earliest), lastArchived) - case None => - // No archived rounds and not firstSV — skip meta creation. + case _ => + // Either we have no rounds info and this is not firstSV, + // or we have not started obtaining the traffic summaries yet + // — skip meta creation. // A later verdict batch will create it. DBIO.successful(Resume: MetaCheckResult) } diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanVerdictStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanVerdictStore.scala index 3e5cdf0646..38b7f2e6a1 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanVerdictStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanVerdictStore.scala @@ -479,12 +479,17 @@ class DbScanVerdictStore( * * @param items verdicts with transaction view constructors * @param appActivityRecords activity records with placeholder verdictRowIds + * @param hasTrafficSummaries whether traffic summaries were fetched for this batch + * @param firstActiveRoundO the OpenMiningRound round active at the earliest + * record time of the batch * @param lastArchivedRoundO the highest archived OpenMiningRound round as of the * max record time of the batch */ def insertVerdictsWithAppActivityRecords( items: NonEmptyList[(VerdictT, Long => Seq[TransactionViewT])], appActivityRecords: Seq[(CantonTimestamp, AppActivityRecordT)], + hasTrafficSummaries: Boolean, + firstActiveRoundO: Option[Long] = None, lastArchivedRoundO: Option[Long] = None, )(implicit tc: TraceContext): Future[Unit] = { import profile.api.jdbcActionExtensionMethods @@ -498,6 +503,8 @@ class DbScanVerdictStore( _ <- insertAppActivityRecordsDBIO( resolvedAppActivityRecords, items.head._1.recordTime.toMicros, + hasTrafficSummaries, + firstActiveRoundO, lastArchivedRoundO, ) } yield () @@ -545,12 +552,20 @@ class DbScanVerdictStore( private def insertAppActivityRecordsDBIO( items: Seq[AppActivityRecordT], firstRecordTimeMicros: Long, + hasTrafficSummaries: Boolean, + firstActiveRoundO: Option[Long], lastArchivedRoundO: Option[Long], )(implicit tc: TraceContext): DBIO[Unit] = appActivityRecordStoreO match { case None => DBIO.successful(()) case Some(s) => - s.insertAppActivityRecordsDBIO(items, firstRecordTimeMicros, lastArchivedRoundO) + s.insertAppActivityRecordsDBIO( + items, + firstRecordTimeMicros, + hasTrafficSummaries, + firstActiveRoundO, + lastArchivedRoundO, + ) } private def afterFilters( diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbAppActivityRecordStoreTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbAppActivityRecordStoreTest.scala index b4b57c17c0..2ab1c4045c 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbAppActivityRecordStoreTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbAppActivityRecordStoreTest.scala @@ -169,6 +169,8 @@ class DbAppActivityRecordStoreTest _ <- verdictStore.insertVerdictsWithAppActivityRecords( NonEmptyList.of(verdict1 -> noViews, verdict2 -> noViews), appActivityRecords, + hasTrafficSummaries = true, + firstActiveRoundO = Some(10L), lastArchivedRoundO = Some(9L), ) @@ -209,6 +211,8 @@ class DbAppActivityRecordStoreTest _ <- verdictStore.insertVerdictsWithAppActivityRecords( NonEmptyList.of(mkVerdict(verdictStore, "update-mono-1", baseTs) -> noViews), Seq(baseTs -> mkRecord(0L, 10L, Seq("app1::provider"), Seq(100L))), + hasTrafficSummaries = true, + firstActiveRoundO = Some(10L), lastArchivedRoundO = Some(9L), ) // A later batch without activity records still advances the round @@ -217,10 +221,13 @@ class DbAppActivityRecordStoreTest mkVerdict(verdictStore, "update-mono-2", baseTs.plusSeconds(1L)) -> noViews ), Seq.empty, + hasTrafficSummaries = true, + firstActiveRoundO = Some(11L), lastArchivedRoundO = Some(10L), ) meta <- appStore.lookupActivityRecordMeta(1, 0) } yield { + meta.value.earliestIngestedRound shouldBe 10L meta.value.lastArchivedRound shouldBe Some(10L) } } @@ -233,40 +240,96 @@ class DbAppActivityRecordStoreTest _ <- verdictStore.insertVerdictsWithAppActivityRecords( NonEmptyList.of(mkVerdict(verdictStore, "update-no-meta", baseTs) -> noViews), Seq.empty, - lastArchivedRoundO = Some(7L), + hasTrafficSummaries = true, + firstActiveRoundO = Some(7L), + lastArchivedRoundO = None, ) meta <- appStore.lookupActivityRecordMeta(1, 0) } yield { - // Meta row is created with earliestRound = lastArchivedRound - // because verdict ingestion is active even without activity records + // Meta row is created using the firstActiveRoundO + // even though there are no activity records meta shouldBe defined meta.value.earliestIngestedRound shouldBe 7L - meta.value.lastArchivedRound shouldBe Some(7L) + meta.value.lastArchivedRound shouldBe None + } + } + + "Does not create meta row when traffic summaries are absent" in { + for { + (appStore, verdictStore) <- newStores() + baseTs = CantonTimestamp.now() + + _ <- verdictStore.insertVerdictsWithAppActivityRecords( + NonEmptyList.of(mkVerdict(verdictStore, "update-no-meta", baseTs) -> noViews), + Seq.empty, + hasTrafficSummaries = false, + lastArchivedRoundO = Some(7L), + ) + v <- verdictStore.getVerdictByUpdateId("update-no-meta") + countAfter <- countRecords() + meta <- appStore.lookupActivityRecordMeta(1, 0) + } yield { + v shouldBe defined + countAfter shouldBe 0L + meta shouldBe None } } - "insert verdicts without activity records when appActivityRecords is empty" in { + "on a fresh firstSV, does not create meta row when traffic summaries are absent" in { + for { + (appStore, verdictStore) <- newStores(isFirstSv = true) + baseTs = CantonTimestamp.now() + + _ <- verdictStore.insertVerdictsWithAppActivityRecords( + NonEmptyList.of(mkVerdict(verdictStore, "update-firstsv-2", baseTs) -> noViews), + Seq.empty, + hasTrafficSummaries = false, + ) + // Even on firstSV, missing traffic summaries defer meta creation + // to a later batch. + metaBefore <- appStore.lookupActivityRecordMeta(1, 0) + + // A later batch with traffic summaries creates the meta row. + _ <- verdictStore.insertVerdictsWithAppActivityRecords( + NonEmptyList.of( + mkVerdict(verdictStore, "update-firstsv-3", baseTs.plusSeconds(1L)) -> noViews + ), + Seq.empty, + hasTrafficSummaries = true, + ) + metaAfter <- appStore.lookupActivityRecordMeta(1, 0) + } yield { + metaBefore shouldBe None + + metaAfter shouldBe defined + metaAfter.value.earliestIngestedRound shouldBe -1L + metaAfter.value.lastArchivedRound shouldBe Some(0L) + } + } + + "insert verdicts without activity records, when reward reference store does not have data asOf" in { for { (appStore, verdictStore) <- newStores() baseTs = CantonTimestamp.now() verdict = mkVerdict(verdictStore, "update-no-activity", baseTs) + // firstActiveRoundO is None, as reward reference store began ingestion after baseTx _ <- verdictStore.insertVerdictsWithAppActivityRecords( NonEmptyList.of(verdict -> noViews), Seq.empty, + hasTrafficSummaries = true, + firstActiveRoundO = None, + lastArchivedRoundO = None, ) v <- verdictStore.getVerdictByUpdateId("update-no-activity") countAfter <- countRecords() - // No meta row should be created when there are no activity records meta <- appStore.lookupActivityRecordMeta(1, 0) } yield { v shouldBe defined countAfter shouldBe 0L - // Non-firstSV with no lastArchivedRound: meta row is not created - // because it would have last_archived_round = NULL, making no - // rounds complete. + // Non-firstSV with no firstActiveRoundO: meta row is not created meta shouldBe None } } @@ -289,6 +352,7 @@ class DbAppActivityRecordStoreTest _ <- verdictStore.insertVerdictsWithAppActivityRecords( NonEmptyList.of(verdict1 -> noViews, verdict2 -> noViews, verdict3 -> noViews), appActivityRecords, + hasTrafficSummaries = true, ) v1 <- verdictStore.getVerdictByUpdateId("update-with-1") @@ -335,6 +399,7 @@ class DbAppActivityRecordStoreTest _ <- verdictStore.insertVerdictsWithAppActivityRecords( NonEmptyList.of(verdict -> noViews), appActivityRecords, + hasTrafficSummaries = true, ) v <- verdictStore.getVerdictByUpdateId("update-mismatch") @@ -1051,7 +1116,9 @@ class DbAppActivityRecordStoreTest /** Creates both an app activity record store and a verdict store backed by * the same UpdateHistory, for testing insertVerdictsWithAppActivityRecords. */ - private def newStores(): Future[(DbAppActivityRecordStore, DbScanVerdictStore)] = { + private def newStores( + isFirstSv: Boolean = false + ): Future[(DbAppActivityRecordStore, DbScanVerdictStore)] = { val participantId = mkParticipantId("activity-test") val updateHistory = new UpdateHistory( storage.underlying, @@ -1070,7 +1137,7 @@ class DbAppActivityRecordStoreTest storage.underlying, updateHistory, DbAppActivityRecordStore.IngestionVersions(1, 0), - isFirstSv = false, + isFirstSv, loggerFactory, ) val verdictStore = new DbScanVerdictStore( diff --git a/apps/sv/frontend/index.html b/apps/sv/frontend/index.html index c2f95b8233..32252600a0 100644 --- a/apps/sv/frontend/index.html +++ b/apps/sv/frontend/index.html @@ -7,7 +7,7 @@ diff --git a/apps/sv/frontend/src/App.tsx b/apps/sv/frontend/src/App.tsx index 2ec65affcb..812a5385cf 100644 --- a/apps/sv/frontend/src/App.tsx +++ b/apps/sv/frontend/src/App.tsx @@ -39,6 +39,7 @@ import { useConfigPollInterval, useSvConfig } from './utils'; import { Governance } from './routes/governance'; import { VoteRequestDetails } from './routes/voteRequestDetails'; import { CreateProposal } from './routes/createProposal'; +import DelegateElection from './routes/delegateElection'; const Providers: React.FC = ({ children }) => { const config = useSvConfig(); @@ -95,6 +96,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> } /> @@ -111,8 +113,8 @@ const App: React.FC = () => { - Super Validator Operations - + Supervalidator Operations + diff --git a/apps/sv/frontend/src/__tests__/governance/forms/create-unallocated-unclaimed-activity-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/create-unallocated-unclaimed-activity-form.test.tsx index 5c13445a01..67858a367d 100644 --- a/apps/sv/frontend/src/__tests__/governance/forms/create-unallocated-unclaimed-activity-form.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/forms/create-unallocated-unclaimed-activity-form.test.tsx @@ -10,7 +10,11 @@ import { describe, expect, test } from 'vitest'; import App from '../../../App'; import { CreateUnallocatedUnclaimedActivityRecordForm } from '../../../components/forms/CreateUnallocatedUnclaimedActivityRecordForm'; import { SvConfigProvider } from '../../../utils'; -import { PROPOSAL_SUMMARY_SUBTITLE, PROPOSAL_SUMMARY_TITLE } from '../../../utils/constants'; +import { + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + PROPOSAL_REVIEW_TITLE, + PROPOSAL_SUMMARY_SUBTITLE, +} from '../../../utils/constants'; import { Wrapper } from '../../helpers'; import { svPartyId } from '../../mocks/constants'; import { server, svUrl } from '../../setup/setup'; @@ -47,7 +51,7 @@ describe('Create Unallocated Unclaimed Activity Record Form', () => { expect( screen.getByTestId('create-unallocated-unclaimed-activity-record-form') ).toBeInTheDocument(); - expect(screen.getByText('Proposal type')).toBeInTheDocument(); + expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument(); const actionInput = screen.getByTestId('create-unallocated-unclaimed-activity-record-action'); expect(actionInput).toBeInTheDocument(); @@ -368,7 +372,7 @@ describe('Create Unallocated Unclaimed Activity Record Form', () => { await user.click(submitButton); - expect(screen.getByText(PROPOSAL_SUMMARY_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument(); }); test('should show error on form if submission fails', async () => { diff --git a/apps/sv/frontend/src/__tests__/governance/forms/grant-revoke-featured-app-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/grant-revoke-featured-app-form.test.tsx index 4b8665866c..14e724e78f 100644 --- a/apps/sv/frontend/src/__tests__/governance/forms/grant-revoke-featured-app-form.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/forms/grant-revoke-featured-app-form.test.tsx @@ -13,7 +13,11 @@ import dayjs from 'dayjs'; import { GrantRevokeFeaturedAppForm } from '../../../components/forms/GrantRevokeFeaturedAppForm'; import { server, svUrl } from '../../setup/setup'; import { http, HttpResponse } from 'msw'; -import { PROPOSAL_SUMMARY_SUBTITLE, PROPOSAL_SUMMARY_TITLE } from '../../../utils/constants'; +import { + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + PROPOSAL_REVIEW_TITLE, + PROPOSAL_SUMMARY_SUBTITLE, +} from '../../../utils/constants'; describe('SV user can', () => { test('login and see the SV party ID', async () => { @@ -45,7 +49,7 @@ describe('Grant Featured App Form', () => { ); expect(screen.getByTestId('grant-featured-app-form')).toBeInTheDocument(); - expect(screen.getByText('Proposal type')).toBeInTheDocument(); + expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument(); const actionInput = screen.getByTestId('grant-featured-app-action'); expect(actionInput).toBeInTheDocument(); @@ -212,7 +216,7 @@ describe('Grant Featured App Form', () => { await user.click(submitButton); - expect(screen.getByText(PROPOSAL_SUMMARY_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument(); }); test('activity weight is optional and is sent to backend as null when left blank', async () => { @@ -389,7 +393,7 @@ describe('Revoke Featured App Form', () => { ); expect(screen.getByTestId('revoke-featured-app-form')).toBeInTheDocument(); - expect(screen.getByText('Proposal type')).toBeInTheDocument(); + expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument(); const actionInput = screen.getByTestId('revoke-featured-app-action'); expect(actionInput).toBeInTheDocument(); @@ -542,7 +546,7 @@ describe('Revoke Featured App Form', () => { await user.click(submitButton); - expect(screen.getByText(PROPOSAL_SUMMARY_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument(); expect(screen.getByTestId('revokeProviderPartyId-title').textContent).toBe('Provider Party ID'); expect(screen.getByTestId('revokeProviderPartyId-field').textContent).toBe( 'a-party-id::1014912492' diff --git a/apps/sv/frontend/src/__tests__/governance/forms/offboard-sv-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/offboard-sv-form.test.tsx index 3d95c438be..2e32ba122f 100644 --- a/apps/sv/frontend/src/__tests__/governance/forms/offboard-sv-form.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/forms/offboard-sv-form.test.tsx @@ -13,7 +13,11 @@ import dayjs from 'dayjs'; import { OffboardSvForm } from '../../../components/forms/OffboardSvForm'; import { server, svUrl } from '../../setup/setup'; import { http, HttpResponse } from 'msw'; -import { PROPOSAL_SUMMARY_SUBTITLE, PROPOSAL_SUMMARY_TITLE } from '../../../utils/constants'; +import { + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + PROPOSAL_REVIEW_TITLE, + PROPOSAL_SUMMARY_SUBTITLE, +} from '../../../utils/constants'; describe('SV user can', () => { test('login and see the SV party ID', async () => { @@ -45,7 +49,7 @@ describe('Offboard SV Form', () => { ); expect(screen.getByTestId('offboard-sv-form')).toBeInTheDocument(); - expect(screen.getByText('Proposal type')).toBeInTheDocument(); + expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument(); const actionInput = screen.getByTestId('offboard-sv-action'); expect(actionInput).toBeInTheDocument(); @@ -231,7 +235,7 @@ describe('Offboard SV Form', () => { await user.click(submitButton); - expect(screen.getByText(PROPOSAL_SUMMARY_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument(); }); test('should show error on form if submission fails', async () => { diff --git a/apps/sv/frontend/src/__tests__/governance/forms/set-amulet-rules-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/set-amulet-rules-form.test.tsx index ff010751fd..a94e95a967 100644 --- a/apps/sv/frontend/src/__tests__/governance/forms/set-amulet-rules-form.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/forms/set-amulet-rules-form.test.tsx @@ -13,7 +13,11 @@ import { SetAmuletConfigRulesForm } from '../../../components/forms/SetAmuletCon import dayjs from 'dayjs'; import { dateTimeFormatISO } from '@canton-network/splice-common-frontend-utils'; import { server, svUrl } from '../../setup/setup'; -import { PROPOSAL_SUMMARY_SUBTITLE, PROPOSAL_SUMMARY_TITLE } from '../../../utils/constants'; +import { + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + PROPOSAL_REVIEW_TITLE, + PROPOSAL_SUMMARY_SUBTITLE, +} from '../../../utils/constants'; describe('SV user can', () => { test('login and see the SV party ID', async () => { @@ -45,7 +49,7 @@ describe('Set Amulet Config Rules Form', () => { ); expect(screen.getByTestId('set-amulet-config-rules-form')).toBeInTheDocument(); - expect(screen.getByText('Proposal type')).toBeInTheDocument(); + expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument(); const actionInput = screen.getByTestId('set-amulet-config-rules-action'); expect(actionInput).toBeInTheDocument(); @@ -278,7 +282,7 @@ describe('Set Amulet Config Rules Form', () => { await user.click(submitButton); - expect(screen.getByText(PROPOSAL_SUMMARY_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument(); expect(screen.queryByText('JSON')).not.toBeInTheDocument(); expect(screen.getByTestId('json-diff-toggle')).toHaveTextContent('Show JSON'); }); diff --git a/apps/sv/frontend/src/__tests__/governance/forms/set-dso-rules-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/set-dso-rules-form.test.tsx index 2825f1d132..644d582812 100644 --- a/apps/sv/frontend/src/__tests__/governance/forms/set-dso-rules-form.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/forms/set-dso-rules-form.test.tsx @@ -12,6 +12,10 @@ import { http, HttpResponse } from 'msw'; import { describe, expect, test } from 'vitest'; import App from '../../../App'; import { SetDsoConfigRulesForm } from '../../../components/forms/SetDsoConfigRulesForm'; +import { + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + PROPOSAL_REVIEW_TITLE, +} from '../../../utils/constants'; import { SvConfigProvider } from '../../../utils'; import { Wrapper } from '../../helpers'; import { svPartyId } from '../../mocks/constants'; @@ -47,7 +51,7 @@ describe('Set DSO Config Rules Form', () => { ); expect(screen.getByTestId('set-dso-config-rules-form')).toBeInTheDocument(); - expect(screen.getByText('Proposal type')).toBeInTheDocument(); + expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument(); const actionInput = screen.getByTestId('set-dso-config-rules-action'); expect(actionInput).toBeInTheDocument(); @@ -267,7 +271,7 @@ describe('Set DSO Config Rules Form', () => { await user.click(submitButton); - expect(screen.getByText('Proposal Summary')).toBeInTheDocument(); + expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument(); expect(screen.queryByText('JSON')).not.toBeInTheDocument(); expect(screen.getByTestId('json-diff-toggle')).toHaveTextContent('Show JSON'); }); diff --git a/apps/sv/frontend/src/__tests__/governance/forms/update-featured-app-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/update-featured-app-form.test.tsx new file mode 100644 index 0000000000..a6409b986f --- /dev/null +++ b/apps/sv/frontend/src/__tests__/governance/forms/update-featured-app-form.test.tsx @@ -0,0 +1,348 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { dateTimeFormatISO } from '@canton-network/splice-common-frontend-utils'; +import dayjs from 'dayjs'; +import { SvConfigProvider } from '../../../utils'; +import App from '../../../App'; +import { svPartyId } from '../../mocks/constants'; +import { Wrapper } from '../../helpers'; +import { UpdateFeaturedAppForm } from '../../../components/forms/UpdateFeaturedAppForm'; +import { server, svUrl } from '../../setup/setup'; +import { http, HttpResponse } from 'msw'; +import { + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + CREATE_PROPOSAL_LABEL_PROVIDER_PARTY_ID, + PROPOSAL_REVIEW_TITLE, + PROPOSAL_SUMMARY_SUBTITLE, +} from '../../../utils/constants'; + +// Logs in once so the admin client has an access token for the rest of the file. +describe('SV user can', () => { + test('login and see the SV party ID', async () => { + const user = userEvent.setup(); + render( + + + + ); + + expect(await screen.findByText('Log In')).toBeInTheDocument(); + + const input = screen.getByRole('textbox'); + await user.type(input, 'sv1'); + + const button = screen.getByRole('button', { name: 'Log In' }); + await user.click(button); + + expect(await screen.findAllByDisplayValue(svPartyId)).not.toBe([]); + }); +}); + +describe('Update Featured App Form', () => { + const fillOutUpdateForm = async (user: ReturnType) => { + const summaryInput = screen.getByTestId('update-featured-app-summary'); + await user.type(summaryInput, 'Summary of the proposal'); + + const urlInput = screen.getByTestId('update-featured-app-url'); + await user.type(urlInput, 'https://example.com'); + + const partyIdInput = screen.getByTestId('update-featured-app-partyId'); + await user.type(partyIdInput, 'a-party-id::1014912492'); + fireEvent.blur(partyIdInput); + + await waitFor(() => { + expect(screen.queryByText('Loading featured app rights...')).not.toBeInTheDocument(); + }); + + const rightCidDropdown = screen.getByTestId('update-featured-app-rightCid-dropdown'); + await waitFor( + () => { + expect(rightCidDropdown).not.toBeDisabled(); + }, + { timeout: 3000 } + ); + fireEvent.change(rightCidDropdown, { target: { value: 'rightCid123' } }); + fireEvent.blur(rightCidDropdown); + await waitFor(() => { + expect(screen.getByTestId('update-featured-app-rightCid-error').textContent).toBeFalsy(); + }); + + const activityWeightInput = screen.getByTestId('update-featured-app-activityWeight'); + await user.type(activityWeightInput, '2.5'); + + const reasonInput = screen.getByTestId('update-featured-app-reason'); + await user.type(reasonInput, 'test'); + }; + + test('should render all Form components', () => { + render( + + + + ); + + expect(screen.getByTestId('update-featured-app-form')).toBeInTheDocument(); + expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument(); + + const actionInput = screen.getByTestId('update-featured-app-action'); + expect(actionInput).toBeInTheDocument(); + expect(actionInput.textContent).toBe('Update Featured Application'); + + const summaryInput = screen.getByTestId('update-featured-app-summary'); + expect(summaryInput).toBeInTheDocument(); + expect(summaryInput.getAttribute('value')).toBeNull(); + + const summarySubtitle = screen.getByTestId('update-featured-app-summary-subtitle'); + expect(summarySubtitle).toBeInTheDocument(); + expect(summarySubtitle.textContent).toBe(PROPOSAL_SUMMARY_SUBTITLE); + + const urlInput = screen.getByTestId('update-featured-app-url'); + expect(urlInput).toBeInTheDocument(); + expect(urlInput.getAttribute('value')).toBe(''); + + const partyIdInput = screen.getByTestId('update-featured-app-partyId'); + expect(partyIdInput).toBeInTheDocument(); + expect(partyIdInput.getAttribute('value')).toBe(''); + + const partyIdTitle = screen.getByTestId('update-featured-app-partyId-title'); + expect(partyIdTitle).toBeInTheDocument(); + expect(partyIdTitle.textContent).toBe(CREATE_PROPOSAL_LABEL_PROVIDER_PARTY_ID); + + const rightCidDropdown = screen.getByTestId('update-featured-app-rightCid-dropdown'); + expect(rightCidDropdown).toBeInTheDocument(); + expect(rightCidDropdown).toBeDisabled(); + + expect(screen.getByTestId('update-featured-app-activityWeight')).toBeInTheDocument(); + expect(screen.getByTestId('update-featured-app-reason')).toBeInTheDocument(); + + expect(screen.getByText('Review Proposal')).toBeInTheDocument(); + }); + + test('activity weight is required', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const activityWeightInput = screen.getByTestId('update-featured-app-activityWeight'); + const actionInput = screen.getByTestId('update-featured-app-action'); + + await user.click(activityWeightInput); + await user.click(actionInput); // blur to trigger validation + + await waitFor(() => { + expect(screen.getByTestId('update-featured-app-activityWeight-error').textContent).toBe( + 'Weight is required' + ); + }); + }); + + test('should send new activity weight and reason to backend', async () => { + let requestBody = ''; + server.use( + http.post(`${svUrl}/v0/admin/sv/voterequest/create`, async ({ request }) => { + requestBody = await request.text(); + return HttpResponse.json({}); + }) + ); + + const user = userEvent.setup(); + + render( + + + + ); + + await fillOutUpdateForm(user); + + const actionInput = screen.getByTestId('update-featured-app-action'); + await user.click(actionInput); // blur to trigger validation + + const submitButton = screen.getByTestId('submit-button'); + await waitFor(() => { + expect(submitButton).not.toBeDisabled(); + }); + + await user.click(submitButton); // review proposal + await user.click(submitButton); // submit proposal + + await waitFor(() => { + expect(requestBody).toContain('"newActivityWeight":"2.5"'); + expect(requestBody).toContain('"reason":"test"'); + }); + }); + + test('activity weight rejects negative numbers and more than 10 decimal places', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const activityWeightInput = screen.getByTestId('update-featured-app-activityWeight'); + const activityWeightError = screen.getByTestId('update-featured-app-activityWeight-error'); + + await user.type(activityWeightInput, '-1'); + await waitFor(() => { + expect(activityWeightError.textContent).toBe('Weight must be a valid non-negative number'); + }); + + await user.clear(activityWeightInput); + await user.type(activityWeightInput, '1.1234567891'); + await waitFor(() => { + expect(activityWeightError.textContent).toBe(''); + }); + + await user.clear(activityWeightInput); + await user.type(activityWeightInput, '1.12345678912'); + await waitFor(() => { + expect(activityWeightError.textContent).toBe('Weight can have at most 10 decimal places'); + }); + }); + + test('reason is required', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const reasonInput = screen.getByTestId('update-featured-app-reason'); + const actionInput = screen.getByTestId('update-featured-app-action'); + + await user.click(reasonInput); + await user.click(actionInput); // blur to trigger validation + + await waitFor(() => { + expect(screen.getByTestId('update-featured-app-reason-error').textContent).toBe( + 'Reason is required' + ); + }); + }); + + test('communicates when the provider has no featured app rights to update', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const partyIdInput = screen.getByTestId('update-featured-app-partyId'); + await user.type(partyIdInput, 'no-rights-party::1014912492'); + fireEvent.blur(partyIdInput); + + await waitFor(() => { + expect(screen.getByTestId('update-featured-app-rightCid')).toHaveTextContent( + 'No featured application rights found for this provider' + ); + }); + + expect(screen.getByTestId('update-featured-app-rightCid-dropdown')).toBeDisabled(); + }); + + test('expiry date must be in the future', async () => { + render( + + + + ); + + const expiryDateInput = screen.getByTestId('update-featured-app-expiry-date-field'); + expect(expiryDateInput).toBeInTheDocument(); + + const thePast = dayjs().subtract(1, 'day').format(dateTimeFormatISO); + const theFuture = dayjs().add(1, 'day').format(dateTimeFormatISO); + + fireEvent.change(expiryDateInput, { target: { value: thePast } }); + + await waitFor(() => { + expect(screen.queryByText('Expiration must be in the future')).toBeInTheDocument(); + }); + + fireEvent.change(expiryDateInput, { target: { value: theFuture } }); + + await waitFor(() => { + expect(screen.queryByText('Expiration must be in the future')).not.toBeInTheDocument(); + }); + }); + + test('effective date must be after expiry date', async () => { + render( + + + + ); + + const expiryDateInput = screen.getByTestId('update-featured-app-expiry-date-field'); + const effectiveDateInput = screen.getByTestId('update-featured-app-effective-date-field'); + + const expiryDate = dayjs().add(1, 'week'); + const effectiveDate = expiryDate.subtract(1, 'day'); + + fireEvent.change(expiryDateInput, { target: { value: expiryDate.format(dateTimeFormatISO) } }); + fireEvent.change(effectiveDateInput, { + target: { value: effectiveDate.format(dateTimeFormatISO) }, + }); + + await waitFor(() => { + expect( + screen.queryByText('Effective Date must be after expiration date') + ).toBeInTheDocument(); + }); + + const validEffectiveDate = expiryDate.add(1, 'day').format(dateTimeFormatISO); + + fireEvent.change(effectiveDateInput, { target: { value: validEffectiveDate } }); + + await waitFor(() => { + expect( + screen.queryByText('Effective Date must be after expiration date') + ).not.toBeInTheDocument(); + }); + }); + + test('should show proposal review page after form completion', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await fillOutUpdateForm(user); + + const actionInput = screen.getByTestId('update-featured-app-action'); + await user.click(actionInput); // blur to trigger validation + + const submitButton = screen.getByTestId('submit-button'); + await waitFor(() => { + expect(submitButton).not.toBeDisabled(); + }); + + await user.click(submitButton); // review proposal + + expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument(); + expect(screen.getByTestId('updateProviderPartyId-field').textContent).toBe( + 'a-party-id::1014912492' + ); + expect(screen.getByTestId('updateRight-field').textContent).toBe('rightCid123'); + expect(screen.getByTestId('config-change-current-value').textContent).toBe('1.0'); + expect(screen.getByTestId('config-change-new-value').textContent).toBe('2.5'); + expect(screen.getByTestId('updateReason-field').textContent).toBe('test'); + }); +}); diff --git a/apps/sv/frontend/src/__tests__/governance/forms/update-sv-reward-weight-form-test.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/update-sv-reward-weight-form-test.test.tsx index a9bd4985ee..2d6892d4bb 100644 --- a/apps/sv/frontend/src/__tests__/governance/forms/update-sv-reward-weight-form-test.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/forms/update-sv-reward-weight-form-test.test.tsx @@ -13,7 +13,10 @@ import { dateTimeFormatISO } from '@canton-network/splice-common-frontend-utils' import dayjs from 'dayjs'; import { server, svUrl } from '../../setup/setup'; import { http, HttpResponse } from 'msw'; -import { PROPOSAL_SUMMARY_SUBTITLE } from '../../../utils/constants'; +import { + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + PROPOSAL_SUMMARY_SUBTITLE, +} from '../../../utils/constants'; describe('SV user can', () => { test('login and see the SV party ID', async () => { @@ -45,7 +48,7 @@ describe('Update Super Validator Reward Weight Form', () => { ); expect(screen.getByTestId('update-sv-reward-weight-form')).toBeInTheDocument(); - expect(screen.getByText('Proposal type')).toBeInTheDocument(); + expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument(); const actionInput = screen.getByTestId('update-sv-reward-weight-action'); expect(actionInput).toBeInTheDocument(); diff --git a/apps/sv/frontend/src/__tests__/governance/governance-page.test.tsx b/apps/sv/frontend/src/__tests__/governance/governance-page.test.tsx index b4e44cba28..9ae19a7424 100644 --- a/apps/sv/frontend/src/__tests__/governance/governance-page.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/governance-page.test.tsx @@ -138,7 +138,7 @@ describe('Governance Page', () => { await user.click(viewDetailsLink); - const proposalDetails = screen.getByTestId('proposal-details-title'); + const proposalDetails = screen.getByTestId('proposal-details-proposal-details'); expect(proposalDetails).toBeInTheDocument(); }); @@ -156,7 +156,7 @@ describe('Governance Page', () => { await user.click(viewDetailsLink); - const proposalDetails = screen.getByTestId('proposal-details-title'); + const proposalDetails = screen.getByTestId('proposal-details-proposal-details'); expect(proposalDetails).toBeInTheDocument(); const action = screen.getByTestId('proposal-details-action-value'); diff --git a/apps/sv/frontend/src/__tests__/governance/proposal-details-content.test.tsx b/apps/sv/frontend/src/__tests__/governance/proposal-details-content.test.tsx index b79df961b7..4c5e973667 100644 --- a/apps/sv/frontend/src/__tests__/governance/proposal-details-content.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/proposal-details-content.test.tsx @@ -13,6 +13,7 @@ import { ProposalVote, ProposalVotingInformation, UnclaimedActivityRecordProposal, + UpdateFeatureAppProposal, UpdateSvRewardWeightProposal, } from '../../utils/types'; import userEvent from '@testing-library/user-event'; @@ -158,8 +159,8 @@ describe('Proposal Details Content', () => { ); - const pageTitle = screen.getByTestId('proposal-details-title'); - expect(pageTitle.textContent).toMatch(/Proposal Details/); + const pageTitle = screen.getByTestId('proposal-details-proposal-details'); + expect(pageTitle).toBeInTheDocument(); const action = screen.getByTestId('proposal-details-action-value'); expect(action.textContent).toMatch(/Offboard Member/); @@ -285,6 +286,94 @@ describe('Proposal Details Content', () => { expect(rightContractIdValue.textContent).toMatch(/rightContractId/); }); + test('should render update featured app proposal details', async () => { + const updateFeaturedAppDetails = { + actionName: 'Update Featured Application', + action: 'SRARC_UpdateFeaturedAppRight', + proposal: { + rightContractId: 'rightCid123', + newActivityWeight: '2.5', + reason: 'boosting rewards', + } as UpdateFeatureAppProposal, + } as ProposalDetails; + + render( + + + + ); + + const action = screen.getByTestId('proposal-details-action-value'); + expect(action.textContent).toMatch('Update Featured Application'); + + const updateFeaturedAppSection = screen.getByTestId( + 'proposal-details-update-feature-app-section' + ); + expect(updateFeaturedAppSection).toBeInTheDocument(); + + const updateFeaturedAppLabel = screen.getByTestId('proposal-details-update-feature-app-label'); + expect(updateFeaturedAppLabel.textContent).toMatch('Featured Application Contract ID'); + + const updateFeaturedAppValue = screen.getByTestId('proposal-details-update-feature-app-value'); + expect(updateFeaturedAppValue.textContent).toMatch('rightCid123'); + + await waitFor(() => { + const currentFeaturedAppWeight = screen.getByTestId('config-change-current-value'); + expect(currentFeaturedAppWeight.textContent).toMatch('1.0'); + }); + + const newFeaturedAppWeight = screen.getByTestId('config-change-new-value'); + expect(newFeaturedAppWeight.textContent).toMatch('2.5'); + + const updateFeaturedReasonLabel = screen.getByTestId( + 'proposal-details-update-feature-reason-label' + ); + expect(updateFeaturedReasonLabel.textContent).toMatch('Reason'); + + const updateFeaturedReasonValue = screen.getByTestId( + 'proposal-details-update-feature-reason-value' + ); + expect(updateFeaturedReasonValue.textContent).toMatch('boosting rewards'); + }); + + test('should show only new weight when featured app right is not found', async () => { + const updateFeaturedAppDetails = { + actionName: 'Update Featured Application', + action: 'SRARC_UpdateFeaturedAppRight', + proposal: { + rightContractId: 'archivedRightCid', // <- not 'rightCid123', so the mock returns not-found + newActivityWeight: '2.5', + reason: 'boosting rewards', + } as UpdateFeatureAppProposal, + } as ProposalDetails; + + render( + + + + ); + + // new value still shows + await waitFor(() => { + const newFeaturedAppWeight = screen.getByTestId('config-change-new-value'); + expect(newFeaturedAppWeight.textContent).toMatch('2.5'); + }); + // ...but there's no current-value box (contract archived → currentWeight '') + expect(screen.queryByTestId('config-change-current-value')).toBeNull(); + }); + test('should render update sv reward weight proposal details', () => { const svToUpdate = 'sv2'; const updateSvRewardWeightDetails = { diff --git a/apps/sv/frontend/src/__tests__/governance/proposal-summary.test.tsx b/apps/sv/frontend/src/__tests__/governance/proposal-summary.test.tsx index cbbd9b3f9e..d497aabb74 100644 --- a/apps/sv/frontend/src/__tests__/governance/proposal-summary.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/proposal-summary.test.tsx @@ -29,22 +29,22 @@ describe('Review Proposal Component', () => { /> ); - expect(screen.getByTestId('action-title').textContent).toBe('Action'); + expect(screen.getByTestId('action-title').textContent).toBe('Proposal Type'); expect(screen.getByTestId('action-field').textContent).toBe(actionName); - expect(screen.getByTestId('url-title').textContent).toBe('URL'); + expect(screen.getByTestId('url-title').textContent).toBe('Supporting URL'); expect(screen.getByTestId('url-field').textContent).toBe(url); - expect(screen.getByTestId('summary-title').textContent).toBe('Summary'); + expect(screen.getByTestId('summary-title').textContent).toBe('Proposal Summary'); expect(screen.getByTestId('summary-field').textContent).toBe(summary); expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline'); expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate); - expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date'); + expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective At'); expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate); - expect(screen.getByTestId('offboardMember-title').textContent).toBe('Offboard Member'); + expect(screen.getByTestId('offboardMember-title').textContent).toBe('Member'); expect(screen.getByTestId('offboardMember-field').textContent).toBe(offboardMember); }); @@ -66,13 +66,12 @@ describe('Review Proposal Component', () => { /> ); - expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date'); + expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective At'); expect(screen.getByTestId('effectiveDate-field').textContent).toBe('Threshold'); }); test('should render review proposal component for sv reward weight', () => { const actionName = 'Update Super Validator Reward Weight'; - const title = 'Super Validator Reward Weight'; const svRewardWeightMember = 'Digital-Asset-Eng-2'; const currentWeight = '1000'; const svRewardWeight = '99'; @@ -93,22 +92,22 @@ describe('Review Proposal Component', () => { /> ); - expect(screen.getByTestId('action-title').textContent).toBe('Action'); + expect(screen.getByTestId('action-title').textContent).toBe('Proposal Type'); expect(screen.getByTestId('action-field').textContent).toBe(actionName); - expect(screen.getByTestId('url-title').textContent).toBe('URL'); + expect(screen.getByTestId('url-title').textContent).toBe('Supporting URL'); expect(screen.getByTestId('url-field').textContent).toBe(url); - expect(screen.getByTestId('summary-title').textContent).toBe('Summary'); + expect(screen.getByTestId('summary-title').textContent).toBe('Proposal Summary'); expect(screen.getByTestId('summary-field').textContent).toBe(summary); expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline'); expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate); - expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date'); + expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective At'); expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate); - expect(screen.getByTestId('config-change-field-label').textContent).toBe(title); + expect(screen.getByTestId('config-change-field-label').textContent).toBe('Weight'); expect(screen.getByTestId('config-change-current-value').textContent).toBe(currentWeight); expect(screen.getByTestId('config-change-new-value').textContent).toBe(svRewardWeight); }); @@ -133,19 +132,19 @@ describe('Review Proposal Component', () => { /> ); - expect(screen.getByTestId('action-title').textContent).toBe('Action'); + expect(screen.getByTestId('action-title').textContent).toBe('Proposal Type'); expect(screen.getByTestId('action-field').textContent).toBe(actionName); - expect(screen.getByTestId('url-title').textContent).toBe('URL'); + expect(screen.getByTestId('url-title').textContent).toBe('Supporting URL'); expect(screen.getByTestId('url-field').textContent).toBe(url); - expect(screen.getByTestId('summary-title').textContent).toBe('Summary'); + expect(screen.getByTestId('summary-title').textContent).toBe('Proposal Summary'); expect(screen.getByTestId('summary-field').textContent).toBe(summary); expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline'); expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate); - expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date'); + expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective At'); expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate); expect(screen.getByTestId('grantRight-title').textContent).toBe('Provider Party ID'); @@ -177,19 +176,19 @@ describe('Review Proposal Component', () => { /> ); - expect(screen.getByTestId('action-title').textContent).toBe('Action'); + expect(screen.getByTestId('action-title').textContent).toBe('Proposal Type'); expect(screen.getByTestId('action-field').textContent).toBe(actionName); - expect(screen.getByTestId('url-title').textContent).toBe('URL'); + expect(screen.getByTestId('url-title').textContent).toBe('Supporting URL'); expect(screen.getByTestId('url-field').textContent).toBe(url); - expect(screen.getByTestId('summary-title').textContent).toBe('Summary'); + expect(screen.getByTestId('summary-title').textContent).toBe('Proposal Summary'); expect(screen.getByTestId('summary-field').textContent).toBe(summary); expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline'); expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate); - expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date'); + expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective At'); expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate); expect(screen.getByTestId('revokeProviderPartyId-title').textContent).toBe('Provider Party ID'); @@ -201,6 +200,64 @@ describe('Review Proposal Component', () => { expect(screen.getByTestId('revokeRight-field').textContent).toBe(contractId); }); + test('should render review proposal component for update feature application', () => { + const actionName = 'Update Featured Application'; + const providerPartyId = 'a-party-id::1014912492'; + const rightCid = 'bcde123456'; + const currentActivityWeight = '1.0'; + const newActivityWeight = '2.5'; + const reason = 'boosting rewards'; + + render( + {}} + onSubmit={() => {}} + /> + ); + + expect(screen.getByTestId('action-title').textContent).toBe('Proposal Type'); + expect(screen.getByTestId('action-field').textContent).toBe(actionName); + + expect(screen.getByTestId('url-title').textContent).toBe('Supporting URL'); + expect(screen.getByTestId('url-field').textContent).toBe(url); + + expect(screen.getByTestId('summary-title').textContent).toBe('Proposal Summary'); + expect(screen.getByTestId('summary-field').textContent).toBe(summary); + + expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline'); + expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate); + + expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective At'); + expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate); + + expect(screen.getByTestId('updateProviderPartyId-title').textContent).toBe('Provider Party ID'); + expect(screen.getByTestId('updateProviderPartyId-field').textContent).toBe(providerPartyId); + + expect(screen.getByTestId('updateRight-title').textContent).toBe( + 'Featured Application Contract ID' + ); + expect(screen.getByTestId('updateRight-field').textContent).toBe(rightCid); + + expect(screen.getByTestId('config-change-current-value').textContent).toBe( + currentActivityWeight + ); + expect(screen.getByTestId('config-change-new-value').textContent).toBe(newActivityWeight); + + expect(screen.getByTestId('updateReason-title').textContent).toBe('Reason'); + expect(screen.getByTestId('updateReason-field').textContent).toBe(reason); + }); + test('should render review proposal component for dso rules config', () => { const actionName = 'Set DSO Rules Configuration'; const numThresholdTitle = 'Number of Unclaimed Rewards Threshold'; @@ -235,22 +292,22 @@ describe('Review Proposal Component', () => { /> ); - expect(screen.getByTestId('action-title').textContent).toBe('Action'); + expect(screen.getByTestId('action-title').textContent).toBe('Proposal Type'); expect(screen.getByTestId('action-field').textContent).toBe(actionName); - expect(screen.getByTestId('url-title').textContent).toBe('URL'); + expect(screen.getByTestId('url-title').textContent).toBe('Supporting URL'); expect(screen.getByTestId('url-field').textContent).toBe(url); - expect(screen.getByTestId('summary-title').textContent).toBe('Summary'); + expect(screen.getByTestId('summary-title').textContent).toBe('Proposal Summary'); expect(screen.getByTestId('summary-field').textContent).toBe(summary); expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline'); expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate); - expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date'); + expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective At'); expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate); - expect(screen.getByText('Proposed Changes')).toBeDefined(); + expect(screen.getByText('Proposed Configuration Changes')).toBeDefined(); expect(screen.getByText(numThresholdTitle)).toBeDefined(); expect(screen.getByText(voteCooldownTitle)).toBeDefined(); @@ -307,22 +364,22 @@ describe('Review Proposal Component', () => { /> ); - expect(screen.getByTestId('action-title').textContent).toBe('Action'); + expect(screen.getByTestId('action-title').textContent).toBe('Proposal Type'); expect(screen.getByTestId('action-field').textContent).toBe(actionName); - expect(screen.getByTestId('url-title').textContent).toBe('URL'); + expect(screen.getByTestId('url-title').textContent).toBe('Supporting URL'); expect(screen.getByTestId('url-field').textContent).toBe(url); - expect(screen.getByTestId('summary-title').textContent).toBe('Summary'); + expect(screen.getByTestId('summary-title').textContent).toBe('Proposal Summary'); expect(screen.getByTestId('summary-field').textContent).toBe(summary); expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline'); expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate); - expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date'); + expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective At'); expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate); - expect(screen.getByText('Proposed Changes')).toBeDefined(); + expect(screen.getByText('Proposed Configuration Changes')).toBeDefined(); expect(screen.getByText(feeTitle)).toBeDefined(); expect(screen.getByText(feeRateTitle)).toBeDefined(); diff --git a/apps/sv/frontend/src/__tests__/mocks/handlers/sv-api.ts b/apps/sv/frontend/src/__tests__/mocks/handlers/sv-api.ts index d184170e42..e335af9bf9 100644 --- a/apps/sv/frontend/src/__tests__/mocks/handlers/sv-api.ts +++ b/apps/sv/frontend/src/__tests__/mocks/handlers/sv-api.ts @@ -273,7 +273,7 @@ export const buildSvMock = (svUrl: string): HttpHandler[] => [ { template_id: 'featured-app-right-template-id', contract_id: 'rightCid123', - payload: {}, + payload: { activityWeight: '1.0' }, created_event_blob: '', created_at: '2026-02-26T13:00:00.000000Z', }, @@ -293,7 +293,7 @@ export const buildSvMock = (svUrl: string): HttpHandler[] => [ ? { template_id: 'featured-app-right-template-id', contract_id: 'rightCid123', - payload: { provider: 'a-party-id::1014912492' }, + payload: { provider: 'a-party-id::1014912492', activityWeight: '1.0' }, created_event_blob: '', created_at: '2026-02-26T13:00:00.000000Z', } diff --git a/apps/sv/frontend/src/__tests__/sv.test.tsx b/apps/sv/frontend/src/__tests__/sv.test.tsx index dc1f618048..fa6a398bc1 100644 --- a/apps/sv/frontend/src/__tests__/sv.test.tsx +++ b/apps/sv/frontend/src/__tests__/sv.test.tsx @@ -41,19 +41,12 @@ describe('SV user can', () => { expect(await screen.findAllByDisplayValue(svPartyId)).toBeDefined(); }); - test('can see the network name banner', async () => { - userEvent.setup(); - render(); - - await screen.findByText('You are on ScratchNet'); - }); - test('browse to the validator onboarding tab', async () => { const user = userEvent.setup(); render(); - expect(await screen.findByText('Validator Onboarding')).toBeDefined(); - await user.click(screen.getByText('Validator Onboarding')); + expect(await screen.findByText('Validators')).toBeDefined(); + await user.click(screen.getByText('Validators')); expect(await screen.findByText('Validator Onboarding Secrets')).toBeDefined(); }); @@ -62,8 +55,8 @@ describe('SV user can', () => { const user = userEvent.setup(); render(); - expect(await screen.findByText('Validator Onboarding')).toBeDefined(); - await user.click(screen.getByText('Validator Onboarding')); + expect(await screen.findByText('Validators')).toBeDefined(); + await user.click(screen.getByText('Validators')); const partyHintInput = screen.getByTestId('create-party-hint'); await user.type(partyHintInput, 'wrong-input'); @@ -306,8 +299,8 @@ describe('An AddFutureAmuletConfigSchedule request', () => { const user = userEvent.setup(); render(); - expect(await screen.findByText('Validator Onboarding')).toBeDefined(); - await user.click(screen.getByText('Validator Onboarding')); + expect(await screen.findByText('Validators')).toBeDefined(); + await user.click(screen.getByText('Validators')); expect(await screen.findByText('Validator Licenses')).toBeDefined(); diff --git a/apps/sv/frontend/src/components/Layout.tsx b/apps/sv/frontend/src/components/Layout.tsx index 80c3facaea..8604f5af56 100644 --- a/apps/sv/frontend/src/components/Layout.tsx +++ b/apps/sv/frontend/src/components/Layout.tsx @@ -1,34 +1,47 @@ // Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 import * as React from 'react'; -import { - Header, - Loading, - useUserState, - useVotesHooks, -} from '@canton-network/splice-common-frontend'; +import { Loading, useUserState, useVotesHooks } from '@canton-network/splice-common-frontend'; -import { Logout } from '@mui/icons-material'; -import { Box, Button, Divider, GlobalStyles, Stack, Typography } from '@mui/material'; -import Container from '@mui/material/Container'; -import Link from '@mui/material/Link'; +import { Box, Container, GlobalStyles } from '@mui/material'; +import { useLocation } from 'react-router'; import { partyIdScrollGlobalStyles } from './beta/identifierStyles'; import PartyIdScrollTracks from './PartyIdScrollTracks'; - +import SvNavigationShell from './layout/SvNavigationShell'; +import { SvNavLinkItem } from './layout/SvNavLink'; import { useFeatureSupport } from '../contexts/SvContext'; -import { useNetworkInstanceName } from '../hooks/index'; +import { CONTENT_MAX_WIDTH, layoutTokens, PAGE_PX } from '../theme/tokens'; import { useSvConfig } from '../utils'; interface LayoutProps { children: React.ReactNode; } -const Layout: React.FC = (props: LayoutProps) => { +const pathnameToPageName = (pathname: string, amuletName: string): string => { + if (pathname.startsWith('/governance')) { + return 'Governance'; + } + if (pathname.startsWith('/validator-onboarding')) { + return 'Validators'; + } + if (pathname.startsWith('/amulet-price')) { + return `${amuletName} Price`; + } + return 'Global Synchronizer Information'; +}; + +const contentShellSx = { + maxWidth: CONTENT_MAX_WIDTH, + mx: 'auto', + px: PAGE_PX, + width: '100%', +}; + +const Layout: React.FC = ({ children }) => { const config = useSvConfig(); const { logout } = useUserState(); - const networkInstanceName = useNetworkInstanceName(); - const networkInstanceNameColor = `colors.${networkInstanceName?.toLowerCase()}`; + const location = useLocation(); const featureSupport = useFeatureSupport(); const votesHooks = useVotesHooks(); @@ -43,57 +56,30 @@ const Layout: React.FC = (props: LayoutProps) => { return ; } - const navLinks = [ - { name: 'Information', path: 'dso' }, - { name: 'Validator Onboarding', path: 'validator-onboarding' }, - { name: `${config.spliceInstanceNames.amuletName} Price`, path: 'amulet-price' }, - { name: 'Governance', path: 'governance', badgeCount: actionsPending?.length }, + const navLinks: SvNavLinkItem[] = [ + { name: 'Global Synchronizer Information', path: '/dso' }, + { + name: 'Governance', + path: '/governance', + end: false, + badgeCount: actionsPending?.length, + }, + { name: `${config.spliceInstanceNames.amuletName} Price`, path: '/amulet-price' }, + { name: 'Validators', path: '/validator-onboarding' }, ]; + const pageName = pathnameToPageName(location.pathname, config.spliceInstanceNames.amuletName); + return ( - + - {networkInstanceName === undefined ? ( - <> - ) : ( - - - You are on {networkInstanceName} - - - )} - -
- - - - -
-
+ - - {props.children} + + + {children} + ); diff --git a/apps/sv/frontend/src/components/form-components/ConfigField.tsx b/apps/sv/frontend/src/components/form-components/ConfigField.tsx index 61a0216eaa..043883678f 100644 --- a/apps/sv/frontend/src/components/form-components/ConfigField.tsx +++ b/apps/sv/frontend/src/components/form-components/ConfigField.tsx @@ -8,6 +8,12 @@ import relativeTime from 'dayjs/plugin/relativeTime'; import { useFieldContext } from '../../hooks/formContext'; import type { ConfigChange, PendingConfigFieldInfo } from '../../utils/types'; import { nextScheduledSynchronizerUpgradeFormat } from '@canton-network/splice-common-frontend-utils'; +import { + configFieldFieldSx, + configFieldInputSx, + configFieldNameSx, + configFieldRowSx, +} from '../../themes/fieldStyles'; dayjs.extend(relativeTime); @@ -55,12 +61,16 @@ export const ConfigField: React.FC = props => { const textFieldProps = { variant: 'outlined' as const, - size: 'small' as const, color: field.state.meta.isDefaultValue ? ('primary' as const) : ('secondary' as const), focused: !field.state.meta.isDefaultValue, autoComplete: 'off' as const, + sx: configFieldFieldSx, + slotProps: { + input: { + sx: configFieldInputSx, + }, + }, inputProps: { - sx: { textAlign: 'right' }, 'data-testid': `config-field-${configChange.fieldName}`, }, disabled: isDisabled, @@ -68,28 +78,20 @@ export const ConfigField: React.FC = props => { return ( <> - - + + {configChange.label} {configChange.fieldName} - + = { + thresholdDeadline: CREATE_PROPOSAL_LABEL_THRESHOLD_DEADLINE, + mustMintBefore: CREATE_PROPOSAL_LABEL_MUST_MINT_BEFORE, +}; export interface DateFieldProps { title?: string; + fieldLabel?: DateFieldLabel; description?: string; minDate?: Dayjs; id: string; } export const DateField: React.FC = props => { - const { title, description, minDate, id } = props; + const { fieldLabel, minDate, id } = props; + const title = props.title ?? (fieldLabel ? DATE_FIELD_LABELS[fieldLabel] : undefined); + const description = + props.description ?? + (fieldLabel === 'thresholdDeadline' ? THRESHOLD_DEADLINE_SUBTITLE : undefined); const field = useFieldContext(); const dateValue = useMemo(() => dayjs(field.state.value), [field.state.value]); return ( - - {title && ( - - {title} - - )} - - {description && ( - - {description} - - )} + + {title && {title}} + + {description && {description}} = props => { format={dateTimeFormatISO} minDateTime={minDate || dayjs()} ampm={false} + onClose={() => field.handleBlur()} onChange={newDate => field.handleChange(newDate?.format(dateTimeFormatISO)!)} enableAccessibleFieldDOMStructure={false} + slots={{ + openPickerIcon: KeyboardArrowDown, + }} slotProps={{ textField: { fullWidth: true, variant: 'outlined', id: `${id}-field`, + error: !field.state.meta.isValid, helperText: field.state.meta.errors?.[0], onBlur: field.handleBlur, + sx: datePickerFieldSx, inputProps: { 'data-testid': `${id}-field`, }, }, + openPickerButton: { + sx: { + color: 'text.light', + marginRight: 0, + padding: 0, + cursor: 'pointer', + '& .MuiSvgIcon-root': { + fontSize: 16, + }, + }, + }, }} /> diff --git a/apps/sv/frontend/src/components/form-components/EffectiveDateField.tsx b/apps/sv/frontend/src/components/form-components/EffectiveDateField.tsx index 26c6b0216c..39c93af27d 100644 --- a/apps/sv/frontend/src/components/form-components/EffectiveDateField.tsx +++ b/apps/sv/frontend/src/components/form-components/EffectiveDateField.tsx @@ -9,7 +9,9 @@ import dayjs from 'dayjs'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { EffectivityType } from '../../utils/types'; import React, { useMemo } from 'react'; +import { CREATE_PROPOSAL_LABEL_EFFECTIVE_AT } from '../../utils/constants'; import { RadioSelector } from './RadioSelector'; +import { datePickerFieldSx } from '../../themes/fieldStyles'; const effectiveAtDisplayFormat = 'YYYY-MM-DD HH:mm'; @@ -22,7 +24,7 @@ export interface EffectiveDateFieldProps { export const EffectiveDateField: React.FC = props => { const { initialEffectiveDate, id } = props; - const title = props.title ?? 'Effective At'; + const title = props.title ?? CREATE_PROPOSAL_LABEL_EFFECTIVE_AT; const dateDescription = props.description ?? 'Select the block at which the proposal will take effect'; @@ -106,46 +108,10 @@ export const EffectiveDateField: React.FC = props => { error: !field.state.meta.isValid, helperText: field.state.meta.errors?.[0], onBlur: field.handleBlur, + sx: datePickerFieldSx, inputProps: { 'data-testid': `${id}-field`, }, - sx: theme => ({ - width: '100%', - '& .MuiOutlinedInput-root': { - backgroundColor: '#363636', - borderRadius: '4px', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - alignSelf: 'stretch', - flexWrap: 'nowrap', - padding: '13px 16px', - overflow: 'hidden', - minHeight: 'unset', - '& fieldset': { - border: 'none', - borderRadius: '4px', - }, - }, - '& .MuiOutlinedInput-input': { - ...theme.typography.body2, - flex: 1, - minWidth: 0, - padding: 0, - lineHeight: '22px', - color: theme.palette.text.light, - backgroundColor: 'transparent', - borderRadius: 0, - WebkitBoxShadow: 'none', - }, - '& .MuiInputAdornment-root': { - flexShrink: 0, - marginLeft: theme.spacing(1.25), - }, - '& .MuiFormHelperText-root': { - mx: 0, - }, - }), }, }} /> diff --git a/apps/sv/frontend/src/components/form-components/FormControls.tsx b/apps/sv/frontend/src/components/form-components/FormControls.tsx index e661a7ba86..f409fdcf3d 100644 --- a/apps/sv/frontend/src/components/form-components/FormControls.tsx +++ b/apps/sv/frontend/src/components/form-components/FormControls.tsx @@ -2,8 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { Box, Button } from '@mui/material'; +import { useState } from 'react'; +import { + createProposalCancelButtonSx, + createProposalSubmitButtonSx, +} from '../../constants/formButtonStyles'; import { useFormContext } from '../../hooks/formContext'; import { useNavigate } from 'react-router'; +import { CancelProposalDialog } from '../governance/CancelProposalDialog'; export interface FormControlsProps { showConfirmation?: boolean; @@ -14,51 +20,68 @@ export const FormControls: React.FC = props => { const { showConfirmation, onEdit } = props; const form = useFormContext(); const navigate = useNavigate(); + const [cancelDialogOpen, setCancelDialogOpen] = useState(false); + const submitTitle = showConfirmation ? 'Submit Proposal' : 'Review Proposal'; const cancelTitle = showConfirmation ? 'Edit Proposal' : 'Cancel'; const handleCancel = () => { if (showConfirmation) { onEdit(); - } else { - navigate('/governance/proposals/create'); + return; } + setCancelDialogOpen(true); + }; + + const handleConfirmCancel = () => { + setCancelDialogOpen(false); + navigate('/governance/proposals'); }; return ( - - + + + [state.canSubmit, state.isSubmitting]} + children={([canSubmit, isSubmitting]) => ( + + )} + /> + - [state.canSubmit, state.isSubmitting]} - children={([canSubmit, isSubmitting]) => ( - - )} + setCancelDialogOpen(false)} + onConfirm={handleConfirmCancel} /> - + ); }; diff --git a/apps/sv/frontend/src/components/form-components/ProposalSummaryField.tsx b/apps/sv/frontend/src/components/form-components/ProposalSummaryField.tsx index f6a7a83e53..659cb94869 100644 --- a/apps/sv/frontend/src/components/form-components/ProposalSummaryField.tsx +++ b/apps/sv/frontend/src/components/form-components/ProposalSummaryField.tsx @@ -6,9 +6,15 @@ import { useFieldContext } from '../../hooks/formContext'; import { useDsoInfos } from '../../contexts/SvContext'; import { DEFAULT_PROPOSAL_SUMMARY_MAX_LENGTH, + CREATE_PROPOSAL_LABEL_PROPOSAL_SUMMARY, PROPOSAL_SUMMARY_SUBTITLE, - PROPOSAL_SUMMARY_TITLE, } from '../../utils/constants'; +import { + fieldDescriptionSx, + fieldSectionSx, + fieldSectionTitleSx, + proposalSummaryFieldSx, +} from '../../themes/fieldStyles'; export interface ProposalSummaryFieldProps { id: string; @@ -27,11 +33,17 @@ export const ProposalSummaryField: React.FC = props = const currentLength = field.state.value.length; return ( - - - {title || PROPOSAL_SUMMARY_TITLE} + + + {title || CREATE_PROPOSAL_LABEL_PROPOSAL_SUMMARY} {optional && ( - + optional )} @@ -39,32 +51,32 @@ export const ProposalSummaryField: React.FC = props = field.handleChange(e.target.value)} error={!field.state.meta.isValid} helperText={field.state.meta.errors?.[0]} inputProps={{ 'data-testid': id, maxLength }} - id={id} /> - + {subtitle || PROPOSAL_SUMMARY_SUBTITLE} diff --git a/apps/sv/frontend/src/components/form-components/ProposalTypeField.tsx b/apps/sv/frontend/src/components/form-components/ProposalTypeField.tsx index d7c0c06dd0..6e70e5077d 100644 --- a/apps/sv/frontend/src/components/form-components/ProposalTypeField.tsx +++ b/apps/sv/frontend/src/components/form-components/ProposalTypeField.tsx @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { Box, Typography } from '@mui/material'; +import { fieldBodySx, fieldSectionTitleSx } from '../../themes/fieldStyles'; import { useFieldContext } from '../../hooks/formContext'; +import { CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE } from '../../utils/constants'; export interface ProposalTypeFieldProps { id: string; @@ -10,16 +12,21 @@ export interface ProposalTypeFieldProps { } export const ProposalTypeField: React.FC = props => { - const { id, title = 'Proposal type' } = props; + const { id, title = CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE } = props; const field = useFieldContext(); return ( - + {title} - + {field.state.value} diff --git a/apps/sv/frontend/src/components/form-components/SelectField.tsx b/apps/sv/frontend/src/components/form-components/SelectField.tsx index 95bb6d8fb5..354e4350d9 100644 --- a/apps/sv/frontend/src/components/form-components/SelectField.tsx +++ b/apps/sv/frontend/src/components/form-components/SelectField.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { KeyboardArrowDown } from '@mui/icons-material'; import { Box, FormControl, @@ -13,6 +14,7 @@ import { import type { FormEvent } from 'react'; import { useFieldContext } from '../../hooks/formContext'; import { scrollableSelectFieldSx } from '../beta/identifierStyles'; +import { fieldSectionSx, fieldSectionTitleSx, selectFieldSx } from '../../themes/fieldStyles'; export type Option = { key: string; value: string }; export interface SelectFieldProps { @@ -38,16 +40,19 @@ export const SelectField: React.FC = props => { const isError = !field.state.meta.isValid && !showPlaceholder; return ( - - - {title} - + + {title} - +