diff --git a/.run/Frontend localhost.run.xml b/.run/Frontend localhost.run.xml
index 7dc8b2b7db..cd78cf5baa 100644
--- a/.run/Frontend localhost.run.xml
+++ b/.run/Frontend localhost.run.xml
@@ -1,6 +1,6 @@
-
+
@@ -9,4 +9,4 @@
-
+
\ No newline at end of file
diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationController.kt
index 5599c1016e..dc3ab87143 100644
--- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationController.kt
+++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationController.kt
@@ -7,7 +7,7 @@ package io.tolgee.api.v2.controllers.organization
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import io.tolgee.component.mtBucketSizeProvider.PayAsYouGoCreditsProvider
-import io.tolgee.component.translationsLimitProvider.TranslationsLimitProvider
+import io.tolgee.component.translationsLimitProvider.LimitsProvider
import io.tolgee.configuration.tolgee.TolgeeProperties
import io.tolgee.constants.Message
import io.tolgee.dtos.queryResults.organization.OrganizationView
@@ -89,7 +89,7 @@ class OrganizationController(
private val imageUploadService: ImageUploadService,
private val mtCreditConsumer: MtCreditsService,
private val organizationStatsService: OrganizationStatsService,
- private val translationsLimitProvider: TranslationsLimitProvider,
+ private val limitsProvider: LimitsProvider,
private val projectService: ProjectService,
private val payAsYouGoCreditsProvider: PayAsYouGoCreditsProvider,
) {
@@ -259,7 +259,7 @@ class OrganizationController(
@PathVariable("organizationId") organizationId: Long,
@PathVariable("userId") userId: Long,
) {
- organizationRoleService.removeUser(organizationId, userId)
+ organizationRoleService.removeUser(userId, organizationId)
}
@PutMapping("/{id:[0-9]+}/avatar", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@@ -311,11 +311,16 @@ class OrganizationController(
): PublicUsageModel {
val organization = organizationService.get(organizationId)
val creditBalances = mtCreditConsumer.getCreditBalances(organization.id)
- val currentTranslationSlots = organizationStatsService.getCurrentTranslationSlotCount(organizationId)
+ val currentTranslationSlots = organizationStatsService.getTranslationSlotCount(organizationId)
val currentPayAsYouGoMtCredits = payAsYouGoCreditsProvider.getUsedPayAsYouGoCredits(organization)
val availablePayAsYouGoMtCredits = payAsYouGoCreditsProvider.getPayAsYouGoAvailableCredits(organization)
- val currentTranslations = organizationStatsService.getCurrentTranslationCount(organizationId)
+ val currentTranslations = organizationStatsService.getTranslationCount(organizationId)
+ val currentSeats = organizationStatsService.getSeatCountToCountSeats(organizationId)
+ val currentKeys = organizationStatsService.getKeyCount(organizationId)
+ val limits = limitsProvider.getLimits(organizationId)
+
return PublicUsageModel(
+ isPayAsYouGo = limits.isPayAsYouGo,
organizationId = organizationId,
creditBalance = creditBalances.creditBalance / 100,
includedMtCredits = creditBalances.bucketSize / 100,
@@ -325,10 +330,23 @@ class OrganizationController(
availablePayAsYouGoMtCredits = availablePayAsYouGoMtCredits,
currentTranslations = currentTranslations,
currentTranslationSlots = currentTranslationSlots,
- includedTranslations = translationsLimitProvider.getPlanTranslations(organization),
- includedTranslationSlots = translationsLimitProvider.getPlanTranslationSlots(organization),
- translationSlotsLimit = translationsLimitProvider.getTranslationSlotsLimit(organization),
- translationsLimit = translationsLimitProvider.getTranslationLimit(organization),
+
+ includedTranslations = limits.strings.included,
+ translationsLimit = limits.strings.limit,
+
+ includedTranslationSlots = limits.translationSlots.included,
+ translationSlotsLimit = limits.translationSlots.limit,
+
+ includedKeys = limits.keys.included,
+ keysLimit = limits.keys.limit,
+
+ includedSeats = limits.seats.included,
+ seatsLimit = limits.seats.limit,
+
+ currentKeys = currentKeys,
+ currentSeats = currentSeats,
+
+ usedMtCredits = creditBalances.usedCredits / 100,
)
}
diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsController.kt
index f15f89ba9d..22c9bebb34 100644
--- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsController.kt
+++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/project/ProjectsController.kt
@@ -269,7 +269,7 @@ class ProjectsController(
if (userId == authenticationFacade.authenticatedUser.id) {
throw BadRequestException(Message.CAN_NOT_REVOKE_OWN_PERMISSIONS)
}
- permissionService.revoke(projectId, userId)
+ permissionService.revoke(userId, projectId)
}
@PutMapping(value = ["/{projectId:[0-9]+}/leave"])
diff --git a/backend/api/src/main/kotlin/io/tolgee/controllers/AuthProviderChangeController.kt b/backend/api/src/main/kotlin/io/tolgee/controllers/AuthProviderChangeController.kt
index 1641bfa7fa..9b320a29fa 100644
--- a/backend/api/src/main/kotlin/io/tolgee/controllers/AuthProviderChangeController.kt
+++ b/backend/api/src/main/kotlin/io/tolgee/controllers/AuthProviderChangeController.kt
@@ -21,7 +21,7 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@CrossOrigin(origins = ["*"])
-@RequestMapping("/v2/auth-provider") // TODO: I should probably use the v2
+@RequestMapping("/v2/auth-provider")
@AuthenticationTag
@OpenApiHideFromPublicDocs
class AuthProviderChangeController(
diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/PlanIncludedUsageModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/PlanIncludedUsageModel.kt
index 644595c7f8..b05829cce8 100644
--- a/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/PlanIncludedUsageModel.kt
+++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/PlanIncludedUsageModel.kt
@@ -11,4 +11,5 @@ open class PlanIncludedUsageModel(
var translationSlots: Long = -1L,
var translations: Long = -1L,
var mtCredits: Long = -1L,
+ var keys: Long = -1L,
) : RepresentationModel(), Serializable
diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/PlanPricesModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/PlanPricesModel.kt
index fb3feabafd..3be6275c19 100644
--- a/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/PlanPricesModel.kt
+++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/PlanPricesModel.kt
@@ -13,4 +13,7 @@ open class PlanPricesModel(
val perThousandMtCredits: BigDecimal? = BigDecimal.ZERO,
val subscriptionMonthly: BigDecimal = BigDecimal.ZERO,
val subscriptionYearly: BigDecimal = BigDecimal.ZERO,
+ val perThousandKeys: BigDecimal = BigDecimal.ZERO,
) : RepresentationModel(), Serializable
+
+// TODO: Test it always counts usage, to handle situation when user switches between free plan, strings or key based plans.
diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/uasge/UsageModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/uasge/UsageModel.kt
index b70f701ca0..d2eecb2a85 100644
--- a/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/uasge/UsageModel.kt
+++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/uasge/UsageModel.kt
@@ -19,5 +19,6 @@ open class UsageModel(
val seats: AverageProportionalUsageItemModel = AverageProportionalUsageItemModel(),
val translations: AverageProportionalUsageItemModel = AverageProportionalUsageItemModel(),
val credits: SumUsageItemModel?,
+ val keys: AverageProportionalUsageItemModel = AverageProportionalUsageItemModel(),
val total: BigDecimal = 0.toBigDecimal(),
) : RepresentationModel(), Serializable
diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PublicUsageModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PublicUsageModel.kt
index f5f32fcff9..d855cd8dc5 100644
--- a/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PublicUsageModel.kt
+++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PublicUsageModel.kt
@@ -8,6 +8,11 @@ import java.io.Serializable
@Suppress("unused")
@Relation(collectionRelation = "plans", itemRelation = "plan")
open class PublicUsageModel(
+ @Schema(
+ description = "Whether the current plan is pay-as-you-go of fixed. " +
+ "For pay-as-you-go plans, the spending limit is the top limit."
+ )
+ val isPayAsYouGo: Boolean,
val organizationId: Long,
@Schema(description = "Current balance of standard credits. Standard credits are refilled every month")
val creditBalance: Long,
@@ -21,6 +26,10 @@ open class PublicUsageModel(
val creditBalanceNextRefillAt: Long,
@Schema(description = "Currently used credits over credits included in plan and extra credits")
val currentPayAsYouGoMtCredits: Long,
+
+ @Schema(description = "Currently used credits including credits used over the limit")
+ val usedMtCredits: Long,
+
@Schema(
description =
"The maximum amount organization can spend" +
@@ -57,6 +66,44 @@ open class PublicUsageModel(
"(For pay us you go, the top limit is the spending limit)",
)
val translationsLimit: Long,
+
+ @Schema(
+ description =
+ "How many keys are included in current subscription plan. " +
+ "How many keys can organization use without additional costs.",
+ )
+ val includedKeys: Long,
+
+ @Schema(
+ description = """How many keys are currently stored by organization""",
+ )
+ val currentKeys: Long,
+
+ @Schema(
+ description =
+ "How many keys can be stored until reaching the limit. " +
+ "(For pay us you go, the top limit is the spending limit)",
+ )
+ val keysLimit: Long,
+
+ @Schema(
+ description =
+ "How many seats are included in current subscription plan. " +
+ "How many seats can organization use without additional costs.",
+ )
+ val includedSeats: Long,
+
+ @Schema(
+ description = """How seats are currently used by organization""",
+ )
+ val currentSeats: Long,
+
+ @Schema(
+ description =
+ "How many seats can be stored until reaching the limit. " +
+ "(For pay us you go, the top limit is the spending limit)",
+ )
+ val seatsLimit: Long,
) : RepresentationModel(), Serializable {
@Schema(
deprecated = true,
diff --git a/backend/app/src/test/kotlin/io/tolgee/service/organizationRole/OrganizationRoleCachingTest.kt b/backend/app/src/test/kotlin/io/tolgee/service/organizationRole/OrganizationRoleCachingTest.kt
index 05c5330fcd..f68e1bf96f 100644
--- a/backend/app/src/test/kotlin/io/tolgee/service/organizationRole/OrganizationRoleCachingTest.kt
+++ b/backend/app/src/test/kotlin/io/tolgee/service/organizationRole/OrganizationRoleCachingTest.kt
@@ -79,7 +79,7 @@ class OrganizationRoleCachingTest : AbstractSpringTest() {
@Test
fun `it evicts on remove user`() {
populateCache(testData.pepaOrg.id, testData.pepa.id)
- organizationRoleService.removeUser(testData.pepaOrg.id, testData.pepa.id)
+ organizationRoleService.removeUser(testData.pepa.id, testData.pepaOrg.id)
assertCacheEvicted(testData.pepaOrg.id, testData.pepa.id)
}
diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/iterceptor/ActivityDatabaseInterceptor.kt b/backend/data/src/main/kotlin/io/tolgee/activity/iterceptor/ActivityDatabaseInterceptor.kt
index 3ee1c35bd7..bdc9a51409 100644
--- a/backend/data/src/main/kotlin/io/tolgee/activity/iterceptor/ActivityDatabaseInterceptor.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/activity/iterceptor/ActivityDatabaseInterceptor.kt
@@ -61,7 +61,7 @@ class ActivityDatabaseInterceptor : Interceptor, Logging {
propertyNames: Array?,
types: Array?,
): Boolean {
- preCommitEventsPublisher.onUpdate(entity)
+ preCommitEventsPublisher.onUpdate(entity, previousState, propertyNames)
interceptedEventsManager.onFieldModificationsActivity(
entity,
currentState,
diff --git a/backend/data/src/main/kotlin/io/tolgee/activity/iterceptor/PreCommitEventPublisher.kt b/backend/data/src/main/kotlin/io/tolgee/activity/iterceptor/PreCommitEventPublisher.kt
index b18f25c064..69c9e411cb 100644
--- a/backend/data/src/main/kotlin/io/tolgee/activity/iterceptor/PreCommitEventPublisher.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/activity/iterceptor/PreCommitEventPublisher.kt
@@ -12,8 +12,8 @@ class PreCommitEventPublisher(private val applicationContext: ApplicationContext
applicationContext.publishEvent(OnEntityPrePersist(this, entity))
}
- fun onUpdate(entity: Any?) {
- applicationContext.publishEvent(OnEntityPreUpdate(this, entity))
+ fun onUpdate(entity: Any?, previousState: Array?, propertyNames: Array?) {
+ applicationContext.publishEvent(OnEntityPreUpdate(this, entity, previousState, propertyNames))
}
fun onDelete(entity: Any?) {
diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/processors/GenericAutoTranslationChunkProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/batch/processors/GenericAutoTranslationChunkProcessor.kt
index a42309d717..01e522deef 100644
--- a/backend/data/src/main/kotlin/io/tolgee/batch/processors/GenericAutoTranslationChunkProcessor.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/batch/processors/GenericAutoTranslationChunkProcessor.kt
@@ -10,8 +10,8 @@ import io.tolgee.constants.Message
import io.tolgee.exceptions.FormalityNotSupportedException
import io.tolgee.exceptions.LanguageNotSupportedException
import io.tolgee.exceptions.OutOfCreditsException
-import io.tolgee.exceptions.PlanTranslationLimitExceeded
-import io.tolgee.exceptions.TranslationSpendingLimitExceeded
+import io.tolgee.exceptions.limits.PlanTranslationLimitExceeded
+import io.tolgee.exceptions.limits.TranslationSpendingLimitExceeded
import io.tolgee.service.key.KeyService
import io.tolgee.service.language.LanguageService
import io.tolgee.service.translation.AutoTranslationService
diff --git a/backend/data/src/main/kotlin/io/tolgee/component/translationsLimitProvider/BaseLimitsProvider.kt b/backend/data/src/main/kotlin/io/tolgee/component/translationsLimitProvider/BaseLimitsProvider.kt
new file mode 100644
index 0000000000..b4ae2b9719
--- /dev/null
+++ b/backend/data/src/main/kotlin/io/tolgee/component/translationsLimitProvider/BaseLimitsProvider.kt
@@ -0,0 +1,31 @@
+package io.tolgee.component.translationsLimitProvider
+
+import io.tolgee.dtos.UsageLimits
+import io.tolgee.model.Organization
+import org.springframework.stereotype.Component
+
+@Component
+class BaseLimitsProvider : LimitsProvider {
+ override fun getLimits(organizationId: Long): UsageLimits {
+ return UsageLimits(
+ isPayAsYouGo = false,
+ keys = UsageLimits.Limit(
+ included = -1,
+ limit = -1
+ ),
+ seats = UsageLimits.Limit(
+ included = -1,
+ limit = -1
+ ),
+ strings = UsageLimits.Limit(
+ included = -1,
+ limit = -1
+ ),
+ translationSlots = UsageLimits.Limit(
+ included = -1,
+ limit = -1
+ ),
+ isTrial = false
+ )
+ }
+}
diff --git a/backend/data/src/main/kotlin/io/tolgee/component/translationsLimitProvider/BaseTranslationsLimitProvider.kt b/backend/data/src/main/kotlin/io/tolgee/component/translationsLimitProvider/BaseTranslationsLimitProvider.kt
deleted file mode 100644
index 83bb3bfc73..0000000000
--- a/backend/data/src/main/kotlin/io/tolgee/component/translationsLimitProvider/BaseTranslationsLimitProvider.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package io.tolgee.component.translationsLimitProvider
-
-import io.tolgee.model.Organization
-import org.springframework.stereotype.Component
-
-@Component
-class BaseTranslationsLimitProvider : TranslationsLimitProvider {
- override fun getTranslationSlotsLimit(organization: Organization?): Long = -1
-
- override fun getTranslationLimit(organization: Organization?): Long = -1
-
- override fun getPlanTranslations(organization: Organization?): Long = -1
-
- override fun getPlanTranslationSlots(organization: Organization?): Long = -1
-}
diff --git a/backend/data/src/main/kotlin/io/tolgee/component/translationsLimitProvider/LimitsProvider.kt b/backend/data/src/main/kotlin/io/tolgee/component/translationsLimitProvider/LimitsProvider.kt
new file mode 100644
index 0000000000..722b39d92f
--- /dev/null
+++ b/backend/data/src/main/kotlin/io/tolgee/component/translationsLimitProvider/LimitsProvider.kt
@@ -0,0 +1,7 @@
+package io.tolgee.component.translationsLimitProvider
+
+import io.tolgee.dtos.UsageLimits
+
+interface LimitsProvider {
+ fun getLimits(organizationId: Long): UsageLimits
+}
diff --git a/backend/data/src/main/kotlin/io/tolgee/component/translationsLimitProvider/TranslationsLimitProvider.kt b/backend/data/src/main/kotlin/io/tolgee/component/translationsLimitProvider/TranslationsLimitProvider.kt
deleted file mode 100644
index cb2309fe2f..0000000000
--- a/backend/data/src/main/kotlin/io/tolgee/component/translationsLimitProvider/TranslationsLimitProvider.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package io.tolgee.component.translationsLimitProvider
-
-import io.tolgee.model.Organization
-
-interface TranslationsLimitProvider {
- /**
- * Returns number of translation slots limit for organization
- * (This is for plans where useSlots = true)
- */
- fun getTranslationSlotsLimit(organization: Organization?): Long
-
- /**
- * Returns number of translations
- * (This is for plan types)
- */
- fun getTranslationLimit(organization: Organization?): Long
-
- fun getPlanTranslations(organization: Organization?): Long
-
- fun getPlanTranslationSlots(organization: Organization?): Long
-}
diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt
index 9e41cf2fe7..094478836d 100644
--- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt
@@ -270,6 +270,11 @@ enum class Message {
CANNOT_UPDATE_WITHOUT_MODIFICATION,
CURRENT_SUBSCRIPTION_IS_NOT_TRIALING,
SORTING_AND_PAGING_IS_NOT_SUPPORTED_WHEN_USING_CURSOR,
+ STRINGS_METRIC_ARE_NOT_SUPPORTED,
+ KEYS_SEATS_METRIC_ARE_NOT_SUPPORTED_FOR_SLOTS_FIXED_TYPE,
+ PLAN_KEY_LIMIT_EXCEEDED,
+ KEYS_SPENDING_LIMIT_EXCEEDED,
+ PLAN_SEAT_LIMIT_EXCEEDED,
;
val code: String
diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/AdditionalTestDataSaver.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/AdditionalTestDataSaver.kt
index 671e9269fd..a070f42676 100644
--- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/AdditionalTestDataSaver.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/AdditionalTestDataSaver.kt
@@ -6,4 +6,12 @@ interface AdditionalTestDataSaver {
fun save(builder: TestDataBuilder)
fun clean(builder: TestDataBuilder)
+
+ fun beforeSave(builder: TestDataBuilder) {}
+
+ fun afterSave(builder: TestDataBuilder) {}
+
+ fun beforeClean(builder: TestDataBuilder) {}
+
+ fun afterClean(builder: TestDataBuilder) {}
}
diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt
index ba2d99d180..5bb9782be0 100644
--- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt
@@ -4,11 +4,13 @@ import io.tolgee.activity.ActivityHolder
import io.tolgee.component.eventListeners.LanguageStatsListener
import io.tolgee.development.testDataBuilder.builders.*
import io.tolgee.development.testDataBuilder.builders.slack.SlackUserConnectionBuilder
+import io.tolgee.model.Project
import io.tolgee.service.TenantService
import io.tolgee.service.automations.AutomationService
import io.tolgee.service.bigMeta.BigMetaService
import io.tolgee.service.contentDelivery.ContentDeliveryConfigService
import io.tolgee.service.dataImport.ImportService
+import io.tolgee.service.invitation.InvitationService
import io.tolgee.service.key.*
import io.tolgee.service.language.LanguageService
import io.tolgee.service.machineTranslation.MtServiceConfigService
@@ -67,6 +69,7 @@ class TestDataService(
private val automationService: AutomationService,
private val contentDeliveryConfigService: ContentDeliveryConfigService,
private val languageStatsListener: LanguageStatsListener,
+ private val invitationService: InvitationService
) : Logging {
@Transactional
fun saveTestData(ft: TestDataBuilder.() -> Unit): TestDataBuilder {
@@ -80,6 +83,7 @@ class TestDataService(
fun saveTestData(builder: TestDataBuilder) {
activityHolder.enableAutoCompletion = false
languageStatsListener.bypass = true
+ runBeforeSaveMethodsOfAdditionalSavers(builder)
prepare()
// Projects have to be stored in separate transaction since projectHolder's
@@ -88,8 +92,8 @@ class TestDataService(
// To be able to save project in its separate transaction,
// user/organization has to be stored first.
executeInNewTransaction(transactionManager) {
- saveOrganizationData(builder)
saveAllUsers(builder)
+ saveOrganizationData(builder)
}
executeInNewTransaction(transactionManager) {
@@ -107,10 +111,13 @@ class TestDataService(
updateLanguageStats(builder)
activityHolder.enableAutoCompletion = true
languageStatsListener.bypass = false
+
+ runAfterSaveMethodsOfAdditionalSavers(builder)
}
@Transactional
fun cleanTestData(builder: TestDataBuilder) {
+ runBeforeCleanMethodsOfAdditionalSavers(builder)
tryUntilItDoesntBreakConstraint {
executeInNewTransaction(transactionManager) {
builder.data.userAccounts.forEach {
@@ -128,6 +135,7 @@ class TestDataService(
}
}
}
+ runAfterCleanMethodsOfAdditionalSavers(builder)
}
additionalTestDataSavers.forEach { dataSaver ->
@@ -162,6 +170,7 @@ class TestDataService(
}
private fun saveOrganizationDependants(builder: TestDataBuilder) {
+ saveOrganizationInvitations(builder)
saveOrganizationRoles(builder)
saveOrganizationAvatars(builder)
saveAllMtCreditBuckets(builder)
@@ -207,6 +216,7 @@ class TestDataService(
private fun saveAllProjectDependants(builder: ProjectBuilder) {
saveApiKeys(builder)
saveLanguages(builder)
+ saveProjectInvitations(builder.testDataBuilder.data.invitations, builder.self)
savePermissions(builder)
saveMtServiceConfigs(builder)
saveAllNamespaces(builder)
@@ -377,7 +387,8 @@ class TestDataService(
}
private fun savePermissions(builder: ProjectBuilder) {
- permissionService.saveAll(builder.data.permissions.map { it.self })
+ val toSave = builder.data.permissions.map { it.self }
+ permissionService.saveAll(toSave)
}
private fun saveAllKeyDependants(keyBuilders: List) {
@@ -520,4 +531,49 @@ class TestDataService(
companion object {
private val passwordHashCache = mutableMapOf()
}
+
+ private fun runBeforeSaveMethodsOfAdditionalSavers(builder: TestDataBuilder) {
+ executeInNewTransaction(transactionManager) {
+ additionalTestDataSavers.forEach {
+ it.beforeSave(builder)
+ }
+ }
+ }
+
+
+ private fun runAfterSaveMethodsOfAdditionalSavers(builder: TestDataBuilder) {
+ executeInNewTransaction(transactionManager) {
+ additionalTestDataSavers.forEach {
+ it.afterSave(builder)
+ }
+ }
+ }
+
+ private fun runBeforeCleanMethodsOfAdditionalSavers(builder: TestDataBuilder) {
+ executeInNewTransaction(transactionManager) {
+ additionalTestDataSavers.forEach {
+ it.beforeClean(builder)
+ }
+ }
+ }
+
+ private fun runAfterCleanMethodsOfAdditionalSavers(builder: TestDataBuilder) {
+ executeInNewTransaction(transactionManager) {
+ additionalTestDataSavers.forEach {
+ it.afterClean(builder)
+ }
+ }
+ }
+
+ private fun saveOrganizationInvitations(builder: TestDataBuilder) {
+ builder.data.invitations.filter { it.self.organizationRole != null }.forEach {
+ invitationService.save(it.self)
+ }
+ }
+
+ private fun saveProjectInvitations(invitations: MutableList, self: Project) {
+ invitations.filter { it.self.permission?.project == self }.forEach {
+ invitationService.save(it.self)
+ }
+ }
}
diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/InvitationBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/InvitationBuilder.kt
new file mode 100644
index 0000000000..416d5ccd59
--- /dev/null
+++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/InvitationBuilder.kt
@@ -0,0 +1,8 @@
+package io.tolgee.development.testDataBuilder.builders
+
+import io.tolgee.model.Invitation
+import org.apache.commons.lang3.RandomStringUtils
+
+class InvitationBuilder : BaseEntityDataBuilder() {
+ override val self: Invitation = Invitation(code = RandomStringUtils.randomAlphanumeric(50))
+}
diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/OrganizationBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/OrganizationBuilder.kt
index 3706e5133d..05396ea6c9 100644
--- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/OrganizationBuilder.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/OrganizationBuilder.kt
@@ -7,6 +7,8 @@ import io.tolgee.model.OrganizationRole
import io.tolgee.model.Permission
import io.tolgee.model.SsoTenant
import io.tolgee.model.UserAccount
+import io.tolgee.model.enums.OrganizationRoleType
+import io.tolgee.model.enums.ProjectPermissionType
import io.tolgee.model.enums.ProjectPermissionType.VIEW
import io.tolgee.model.slackIntegration.OrganizationSlackWorkspace
import org.springframework.core.io.ClassPathResource
@@ -56,4 +58,15 @@ class OrganizationBuilder(
data.tenant = builder
return builder
}
+
+ fun inviteUser(buildRole: OrganizationRoleBuilder.() -> Unit = {}): InvitationBuilder {
+ val invitationBuilder = InvitationBuilder()
+ testDataBuilder.data.invitations.add(invitationBuilder)
+ addRole {
+ this.invitation = invitationBuilder.self
+ type = OrganizationRoleType.OWNER
+ invitationBuilder.self.organizationRole = this
+ }.build(buildRole)
+ return invitationBuilder
+ }
}
diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt
index f77559cd45..0dc23eb92d 100644
--- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt
@@ -9,6 +9,7 @@ import io.tolgee.model.contentDelivery.ContentDeliveryConfig
import io.tolgee.model.contentDelivery.ContentStorage
import io.tolgee.model.dataImport.Import
import io.tolgee.model.dataImport.ImportSettings
+import io.tolgee.model.enums.ProjectPermissionType
import io.tolgee.model.key.Key
import io.tolgee.model.key.Namespace
import io.tolgee.model.key.screenshotReference.KeyScreenshotReference
@@ -77,6 +78,20 @@ class ProjectBuilder(
fun addTaskKey(ft: FT) = addOperation(data.taskKeys, ft)
+ fun inviteUser(buildPermission: PermissionBuilder.() -> Unit = {}): InvitationBuilder {
+ val invitationBuilder = InvitationBuilder()
+ testDataBuilder.data.invitations.add(invitationBuilder)
+ addPermission {
+ this.project = this@ProjectBuilder.self
+ this.invitation = invitationBuilder.self
+ this.type = ProjectPermissionType.MANAGE
+ invitationBuilder.self.permission = this
+ this.user = null
+ }.build(buildPermission)
+ return invitationBuilder
+
+ }
+
fun addKey(
namespace: String? = null,
keyName: String,
diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TestDataBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TestDataBuilder.kt
index f5f07b21ad..3257515dbb 100644
--- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TestDataBuilder.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TestDataBuilder.kt
@@ -15,6 +15,7 @@ class TestDataBuilder(fn: (TestDataBuilder.() -> Unit) = {}) {
val projects = mutableListOf()
val organizations = mutableListOf()
val mtCreditBuckets = mutableListOf()
+ val invitations = mutableListOf()
/**
* These data are populated by external modules and saved via one of the
diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/MtCreditBalanceDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/MtCreditBalanceDto.kt
index a0e61d8f30..74f281e2db 100644
--- a/backend/data/src/main/kotlin/io/tolgee/dtos/MtCreditBalanceDto.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/dtos/MtCreditBalanceDto.kt
@@ -3,7 +3,11 @@ package io.tolgee.dtos
import java.util.*
data class MtCreditBalanceDto(
+ /** Used credits in cents */
+ val usedCredits: Long,
+ /** Remaining credits in cents */
val creditBalance: Long,
+ /** Credits included in the plan (or in the Bucket size) in cents */
val bucketSize: Long,
val refilledAt: Date,
val nextRefillAt: Date,
diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/UsageLimits.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/UsageLimits.kt
new file mode 100644
index 0000000000..12240df3b7
--- /dev/null
+++ b/backend/data/src/main/kotlin/io/tolgee/dtos/UsageLimits.kt
@@ -0,0 +1,27 @@
+package io.tolgee.dtos
+
+/**
+ * This class represents usage limits for a subscription plan
+ *
+ * Translation slots are legacy unit
+ */
+data class UsageLimits(
+ val isPayAsYouGo: Boolean,
+ val isTrial: Boolean,
+ val strings: Limit,
+ val keys: Limit,
+ val seats: Limit,
+
+ //
+ // Legacy units
+ //
+ val translationSlots: Limit,
+) {
+ data class Limit(
+ /** What's included in the plan */
+ val included: Long,
+
+ /** What's the maximum value before using all the usage from spending limit */
+ val limit: Long,
+ )
+}
diff --git a/backend/data/src/main/kotlin/io/tolgee/events/OnEntityPreUpdate.kt b/backend/data/src/main/kotlin/io/tolgee/events/OnEntityPreUpdate.kt
index 72b2802384..1c87043a64 100644
--- a/backend/data/src/main/kotlin/io/tolgee/events/OnEntityPreUpdate.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/events/OnEntityPreUpdate.kt
@@ -6,4 +6,6 @@ import org.springframework.context.ApplicationEvent
class OnEntityPreUpdate(
override val source: PreCommitEventPublisher,
override val entity: Any?,
+ val previousState: Array?,
+ val propertyNames: Array?,
) : ApplicationEvent(source), EntityPreCommitEvent
diff --git a/backend/data/src/main/kotlin/io/tolgee/events/OnOrganizationNameUpdated.kt b/backend/data/src/main/kotlin/io/tolgee/events/OnOrganizationNameUpdated.kt
new file mode 100644
index 0000000000..5dcfe564c9
--- /dev/null
+++ b/backend/data/src/main/kotlin/io/tolgee/events/OnOrganizationNameUpdated.kt
@@ -0,0 +1,8 @@
+package io.tolgee.events
+
+import io.tolgee.model.Organization
+
+class OnOrganizationNameUpdated(
+ val oldName: String,
+ val organization: Organization,
+)
diff --git a/backend/data/src/main/kotlin/io/tolgee/exceptions/limits/KeysSpendingLimitExceeded.kt b/backend/data/src/main/kotlin/io/tolgee/exceptions/limits/KeysSpendingLimitExceeded.kt
new file mode 100644
index 0000000000..6a6e497a0f
--- /dev/null
+++ b/backend/data/src/main/kotlin/io/tolgee/exceptions/limits/KeysSpendingLimitExceeded.kt
@@ -0,0 +1,10 @@
+package io.tolgee.exceptions.limits
+
+import io.tolgee.constants.Message
+import io.tolgee.exceptions.BadRequestException
+
+class KeysSpendingLimitExceeded(required: Long, limit: Long) :
+ BadRequestException(
+ Message.KEYS_SPENDING_LIMIT_EXCEEDED,
+ params = listOf(required, limit),
+ )
diff --git a/backend/data/src/main/kotlin/io/tolgee/exceptions/limits/PlanKeysLimitExceeded.kt b/backend/data/src/main/kotlin/io/tolgee/exceptions/limits/PlanKeysLimitExceeded.kt
new file mode 100644
index 0000000000..9d506ea0b6
--- /dev/null
+++ b/backend/data/src/main/kotlin/io/tolgee/exceptions/limits/PlanKeysLimitExceeded.kt
@@ -0,0 +1,7 @@
+package io.tolgee.exceptions.limits
+
+import io.tolgee.constants.Message
+import io.tolgee.exceptions.BadRequestException
+
+class PlanKeysLimitExceeded(required: Long, limit: Long) :
+ BadRequestException(Message.PLAN_KEY_LIMIT_EXCEEDED, params = listOf(required, limit))
diff --git a/backend/data/src/main/kotlin/io/tolgee/exceptions/limits/PlanSeatLimitExceeded.kt b/backend/data/src/main/kotlin/io/tolgee/exceptions/limits/PlanSeatLimitExceeded.kt
new file mode 100644
index 0000000000..336d513feb
--- /dev/null
+++ b/backend/data/src/main/kotlin/io/tolgee/exceptions/limits/PlanSeatLimitExceeded.kt
@@ -0,0 +1,7 @@
+package io.tolgee.exceptions.limits
+
+import io.tolgee.constants.Message
+import io.tolgee.exceptions.BadRequestException
+
+class PlanSeatLimitExceeded(required: Long, limit: Long) :
+ BadRequestException(Message.PLAN_SEAT_LIMIT_EXCEEDED, params = listOf(required, limit))
diff --git a/backend/data/src/main/kotlin/io/tolgee/exceptions/PlanTranslationLimitExceeded.kt b/backend/data/src/main/kotlin/io/tolgee/exceptions/limits/PlanTranslationLimitExceeded.kt
similarity index 70%
rename from backend/data/src/main/kotlin/io/tolgee/exceptions/PlanTranslationLimitExceeded.kt
rename to backend/data/src/main/kotlin/io/tolgee/exceptions/limits/PlanTranslationLimitExceeded.kt
index 326ea8da1f..a1c70dc4bf 100644
--- a/backend/data/src/main/kotlin/io/tolgee/exceptions/PlanTranslationLimitExceeded.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/exceptions/limits/PlanTranslationLimitExceeded.kt
@@ -1,6 +1,7 @@
-package io.tolgee.exceptions
+package io.tolgee.exceptions.limits
import io.tolgee.constants.Message
+import io.tolgee.exceptions.BadRequestException
class PlanTranslationLimitExceeded(required: Long, limit: Long) :
BadRequestException(Message.PLAN_TRANSLATION_LIMIT_EXCEEDED, params = listOf(required, limit))
diff --git a/backend/data/src/main/kotlin/io/tolgee/exceptions/limits/SeatSpendingLimitExceeded.kt b/backend/data/src/main/kotlin/io/tolgee/exceptions/limits/SeatSpendingLimitExceeded.kt
new file mode 100644
index 0000000000..1dfb2b940c
--- /dev/null
+++ b/backend/data/src/main/kotlin/io/tolgee/exceptions/limits/SeatSpendingLimitExceeded.kt
@@ -0,0 +1,10 @@
+package io.tolgee.exceptions.limits
+
+import io.tolgee.constants.Message
+import io.tolgee.exceptions.BadRequestException
+
+class SeatSpendingLimitExceeded(required: Long, limit: Long) :
+ BadRequestException(
+ Message.SEATS_SPENDING_LIMIT_EXCEEDED,
+ params = listOf(required, limit),
+ )
diff --git a/backend/data/src/main/kotlin/io/tolgee/exceptions/TranslationSpendingLimitExceeded.kt b/backend/data/src/main/kotlin/io/tolgee/exceptions/limits/TranslationSpendingLimitExceeded.kt
similarity index 72%
rename from backend/data/src/main/kotlin/io/tolgee/exceptions/TranslationSpendingLimitExceeded.kt
rename to backend/data/src/main/kotlin/io/tolgee/exceptions/limits/TranslationSpendingLimitExceeded.kt
index d03a502673..8dc965e0c1 100644
--- a/backend/data/src/main/kotlin/io/tolgee/exceptions/TranslationSpendingLimitExceeded.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/exceptions/limits/TranslationSpendingLimitExceeded.kt
@@ -1,6 +1,7 @@
-package io.tolgee.exceptions
+package io.tolgee.exceptions.limits
import io.tolgee.constants.Message
+import io.tolgee.exceptions.BadRequestException
class TranslationSpendingLimitExceeded(required: Long, limit: Long) :
BadRequestException(
diff --git a/backend/data/src/main/kotlin/io/tolgee/model/OrganizationRole.kt b/backend/data/src/main/kotlin/io/tolgee/model/OrganizationRole.kt
index 4ec93947a9..a31ae26a5e 100644
--- a/backend/data/src/main/kotlin/io/tolgee/model/OrganizationRole.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/model/OrganizationRole.kt
@@ -1,13 +1,7 @@
package io.tolgee.model
import io.tolgee.model.enums.OrganizationRoleType
-import jakarta.persistence.Entity
-import jakarta.persistence.EnumType
-import jakarta.persistence.Enumerated
-import jakarta.persistence.ManyToOne
-import jakarta.persistence.OneToOne
-import jakarta.persistence.Table
-import jakarta.persistence.UniqueConstraint
+import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
import org.hibernate.annotations.ColumnDefault
@@ -21,7 +15,7 @@ import org.hibernate.annotations.ColumnDefault
],
)
class OrganizationRole(
- @OneToOne
+ @OneToOne(fetch = FetchType.LAZY)
var invitation: Invitation? = null,
@Enumerated(EnumType.ORDINAL)
var type: OrganizationRoleType,
diff --git a/backend/data/src/main/kotlin/io/tolgee/model/Permission.kt b/backend/data/src/main/kotlin/io/tolgee/model/Permission.kt
index 1f3dcb9562..a54321c2e3 100644
--- a/backend/data/src/main/kotlin/io/tolgee/model/Permission.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/model/Permission.kt
@@ -6,26 +6,10 @@ import io.tolgee.dtos.request.project.LanguagePermissions
import io.tolgee.model.enums.ProjectPermissionType
import io.tolgee.model.enums.Scope
import io.tolgee.model.translationAgency.TranslationAgency
-import jakarta.persistence.Column
-import jakarta.persistence.Entity
-import jakarta.persistence.EntityListeners
-import jakarta.persistence.EnumType
-import jakarta.persistence.Enumerated
-import jakarta.persistence.FetchType
-import jakarta.persistence.GeneratedValue
-import jakarta.persistence.GenerationType
-import jakarta.persistence.Id
-import jakarta.persistence.Index
-import jakarta.persistence.JoinColumn
-import jakarta.persistence.JoinTable
-import jakarta.persistence.ManyToMany
-import jakarta.persistence.ManyToOne
-import jakarta.persistence.OneToOne
-import jakarta.persistence.PrePersist
-import jakarta.persistence.PreUpdate
-import jakarta.persistence.Table
+import jakarta.persistence.*
import org.hibernate.annotations.Parameter
import org.hibernate.annotations.Type
+import org.springframework.beans.factory.annotation.Configurable
@Suppress("LeakingThis")
@Entity
@@ -138,10 +122,6 @@ class Permission(
@ManyToOne
var project: Project? = null
- val userId: Long?
- get() = this.user?.id
- val invitationId: Long?
- get() = this.invitation?.id
override val projectId: Long?
get() = this.project?.id
override val organizationId: Long?
@@ -156,7 +136,9 @@ class Permission(
get() = this.stateChangeLanguages.map { it.id }.toSet()
companion object {
+ @Configurable
class PermissionListeners {
+
@PrePersist
@PreUpdate
fun prePersist(permission: Permission) {
@@ -170,7 +152,7 @@ class Permission(
permission.viewLanguages.isNotEmpty() ||
permission.translateLanguages.isNotEmpty() ||
permission.stateChangeLanguages.isNotEmpty()
- )
+ )
) {
throw IllegalStateException("Organization base permission cannot have language permissions")
}
diff --git a/backend/data/src/main/kotlin/io/tolgee/model/Project.kt b/backend/data/src/main/kotlin/io/tolgee/model/Project.kt
index 81858232e3..bd04f27d74 100644
--- a/backend/data/src/main/kotlin/io/tolgee/model/Project.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/model/Project.kt
@@ -141,10 +141,6 @@ class Project(
return findLanguageOptional(tag).orElse(null)
}
- fun getLanguage(tag: String): Language {
- return findLanguage(tag) ?: throw NotFoundException()
- }
-
companion object {
@Configurable
class ProjectListener {
diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRepository.kt
index 29e183672a..19a32bd32d 100644
--- a/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRepository.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRepository.kt
@@ -228,4 +228,40 @@ interface OrganizationRepository : JpaRepository {
id: Long,
currentUserId: Long,
): OrganizationView?
+
+
+ /**
+ * Returns all organizations where user is counted as seat
+ * For translation agencies, we don't count them as seats
+ */
+ @Query(
+ """
+ select distinct o.id from Organization o
+ left join o.memberRoles orl
+ left join o.projects pr
+ left join pr.permissions perm on perm.agency is null
+ where orl.user.id = :userId or perm.user.id = :userId
+ """
+ )
+ fun getAllUsersOrganizationsToCountUsageFor(userId: Long): Set
+
+ @Query(
+ """
+ select ua.id $ALL_USERS_IN_ORGANIZATION_QUERY_TO_COUNT_USAGE_FOR
+ """
+ )
+ fun getAllUserIdsInOrganizationToCountSeats(organizationId: Long): Set
+
+ companion object {
+ /**
+ * Query to count all users in organization to count seats
+ */
+ const val ALL_USERS_IN_ORGANIZATION_QUERY_TO_COUNT_USAGE_FOR = """from UserAccount ua
+ left join ua.organizationRoles orl
+ left join orl.organization o on o.deletedAt is null and o.id = :organizationId
+ left join ua.permissions p on p.agency is null
+ left join p.project pr on pr.deletedAt is null and pr.organizationOwner.id = :organizationId
+ where ua.deletedAt is null and ua.disabledAt is null
+ and (pr is not null or o is not null)"""
+ }
}
diff --git a/backend/data/src/main/kotlin/io/tolgee/service/invitation/InvitationService.kt b/backend/data/src/main/kotlin/io/tolgee/service/invitation/InvitationService.kt
index ed879b04af..bce6d79071 100644
--- a/backend/data/src/main/kotlin/io/tolgee/service/invitation/InvitationService.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/service/invitation/InvitationService.kt
@@ -1,5 +1,6 @@
package io.tolgee.service.invitation
+import io.tolgee.component.CurrentDateProvider
import io.tolgee.component.email.InvitationEmailSender
import io.tolgee.component.reporting.BusinessEventPublisher
import io.tolgee.component.reporting.OnBusinessEventToCaptureEvent
@@ -20,11 +21,10 @@ import io.tolgee.security.authentication.AuthenticationFacade
import io.tolgee.service.organization.OrganizationRoleService
import io.tolgee.service.security.PermissionService
import io.tolgee.util.Logging
+import io.tolgee.util.addMonths
import org.apache.commons.lang3.RandomStringUtils
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
-import java.time.Duration
-import java.time.Instant
import java.util.*
@Service
@@ -35,6 +35,7 @@ class InvitationService(
private val permissionService: PermissionService,
private val invitationEmailSender: InvitationEmailSender,
private val businessEventPublisher: BusinessEventPublisher,
+ private val currentDateProvider: CurrentDateProvider,
) : Logging {
@Transactional
fun create(params: CreateProjectInvitationParams): Invitation {
@@ -64,6 +65,11 @@ class InvitationService(
return invitationRepository.save(invitation)
}
+ @Transactional
+ fun save(invitation: Invitation): Invitation {
+ return invitationRepository.save(invitation)
+ }
+
@Transactional
fun create(params: CreateOrganizationInvitationParams): Invitation {
checkEmailNotAlreadyInvited(params)
@@ -94,7 +100,7 @@ class InvitationService(
@Transactional
fun removeExpired() {
- invitationRepository.deleteAllByCreatedAtLessThan(Date.from(Instant.now().minus(Duration.ofDays(30))))
+ invitationRepository.deleteAllByCreatedAtLessThan(currentDateProvider.date.addMonths(-1))
}
@Transactional
diff --git a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/mtCreditsConsumption/MtCreditBucketService.kt b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/mtCreditsConsumption/MtCreditBucketService.kt
index d07f7d3d40..2e0520db2f 100644
--- a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/mtCreditsConsumption/MtCreditBucketService.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/mtCreditsConsumption/MtCreditBucketService.kt
@@ -170,6 +170,7 @@ class MtCreditBucketService(
bucketSize = bucket.bucketSize,
refilledAt = bucket.refilled,
nextRefillAt = bucket.getNextRefillDate(),
+ usedCredits = bucket.bucketSize - bucket.credits,
)
}
diff --git a/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt b/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt
index 651de66d9f..63fe985607 100644
--- a/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt
@@ -27,7 +27,6 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
-@Transactional
class OrganizationRoleService(
private val organizationRoleRepository: OrganizationRoleRepository,
private val authenticationFacade: AuthenticationFacade,
@@ -210,6 +209,7 @@ class OrganizationRoleService(
}
@CacheEvict(Caches.ORGANIZATION_ROLES, key = "{#organization.id, #user.id}")
+ @Transactional
fun grantRoleToUser(
user: UserAccount,
organization: Organization,
@@ -228,12 +228,13 @@ class OrganizationRoleService(
}
fun leave(organizationId: Long) {
- this.removeUser(organizationId, authenticationFacade.authenticatedUser.id)
+ this.removeUser(authenticationFacade.authenticatedUser.id, organizationId)
}
+ @Transactional
fun removeUser(
- organizationId: Long,
userId: Long,
+ organizationId: Long,
) {
val managedBy = getManagedBy(userId)
if (managedBy != null && managedBy.id == organizationId) {
diff --git a/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationStatsService.kt b/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationStatsService.kt
index 9b7b23ae8f..4b9cb10b81 100644
--- a/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationStatsService.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationStatsService.kt
@@ -1,5 +1,6 @@
package io.tolgee.service.organization
+import io.tolgee.repository.OrganizationRepository.Companion.ALL_USERS_IN_ORGANIZATION_QUERY_TO_COUNT_USAGE_FOR
import jakarta.persistence.EntityManager
import org.springframework.stereotype.Service
import java.math.BigDecimal
@@ -32,7 +33,7 @@ class OrganizationStatsService(
.singleResult as Long
}
- fun getCurrentTranslationSlotCount(organizationId: Long): Long {
+ fun getTranslationSlotCount(organizationId: Long): Long {
val result =
entityManager.createNativeQuery(
"""
@@ -53,14 +54,34 @@ class OrganizationStatsService(
return result.toLong()
}
- fun getCurrentTranslationCount(organizationId: Long): Long {
+ fun getSeatCountToCountSeats(organizationId: Long): Long {
return entityManager.createQuery(
"""
- select count(t) from Translation t where
- t.language.deletedAt is null and
- t.key.project.organizationOwner.id = :organizationId and
- t.state <> io.tolgee.model.enums.TranslationState.UNTRANSLATED and t.key.project.deletedAt is null
+ select count(distinct ua.id) $ALL_USERS_IN_ORGANIZATION_QUERY_TO_COUNT_USAGE_FOR
""".trimIndent(),
- ).setParameter("organizationId", organizationId).singleResult as Long? ?: 0
+ ).setParameter("organizationId", organizationId).singleResult as Long
}
+
+ fun getTranslationCount(organizationId: Long): Long {
+ return entityManager.createQuery(
+ """
+ select count(t.id) from Translation t
+ join t.key k
+ join k.project p on p.deletedAt is null
+ join t.language l on l.deletedAt is null
+ where p.organizationOwner.id = :organizationId and t.text is not null and t.text <> ''
+ """.trimIndent(),
+ ).setParameter("organizationId", organizationId).singleResult as Long
+ }
+
+ fun getKeyCount(organizationId: Long): Long {
+ return entityManager.createQuery(
+ """
+ select count(k.id) from Key k
+ join k.project p on p.deletedAt is null
+ where p.organizationOwner.id = :organizationId
+ """.trimIndent(),
+ ).setParameter("organizationId", organizationId).singleResult as Long
+ }
+
}
diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt
index efcbcb71e3..994591a288 100644
--- a/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/service/security/PermissionService.kt
@@ -401,8 +401,8 @@ class PermissionService(
}
fun revoke(
- projectId: Long,
userId: Long,
+ projectId: Long,
) {
val data = this.getProjectPermissionData(projectId, userId)
if (data.organizationRole != null) {
diff --git a/backend/data/src/main/kotlin/io/tolgee/util/BypassableListener.kt b/backend/data/src/main/kotlin/io/tolgee/util/BypassableListener.kt
new file mode 100644
index 0000000000..551a7d040a
--- /dev/null
+++ b/backend/data/src/main/kotlin/io/tolgee/util/BypassableListener.kt
@@ -0,0 +1,19 @@
+package io.tolgee.util
+
+interface BypassableListener {
+ var bypass: Boolean
+
+ fun bypassingListener(fn: () -> T): T {
+ val oldBypass = bypass
+ bypass = true
+ val ret = fn()
+ bypass = oldBypass
+ return ret
+ }
+
+ fun executeIfNotBypassed(fn: () -> T) {
+ if (!bypass) {
+ fn()
+ }
+ }
+}
diff --git a/backend/data/src/main/kotlin/io/tolgee/util/loggerExtension.kt b/backend/data/src/main/kotlin/io/tolgee/util/loggerExtension.kt
index a3a78c1b1b..db77c729b0 100644
--- a/backend/data/src/main/kotlin/io/tolgee/util/loggerExtension.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/util/loggerExtension.kt
@@ -31,6 +31,10 @@ interface Logging {
}
}
+inline fun T.logger(): Logger {
+ return LoggerFactory.getLogger(T::class.java)
+}
+
val T.logger: Logger get() = LoggerFactory.getLogger(javaClass)
fun Logger.trace(message: () -> String) {
diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/AbstractE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/AbstractE2eDataController.kt
index 8c344fd869..339a112175 100644
--- a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/AbstractE2eDataController.kt
+++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/AbstractE2eDataController.kt
@@ -1,5 +1,7 @@
package io.tolgee.controllers.internal.e2eData
+import io.tolgee.data.StandardTestDataResult
+import io.tolgee.data.service.TestDataGeneratingService
import io.tolgee.development.testDataBuilder.TestDataService
import io.tolgee.development.testDataBuilder.builders.TestDataBuilder
import io.tolgee.service.organization.OrganizationService
@@ -40,32 +42,15 @@ abstract class AbstractE2eDataController {
@Autowired
private lateinit var applicationContext: ApplicationContext
+ @Autowired
+ private lateinit var testDataGeneratingService: TestDataGeneratingService
+
open fun afterTestDataStored(data: TestDataBuilder) {}
@GetMapping(value = ["/generate-standard"])
@Transactional
open fun generate(): StandardTestDataResult {
- val data = this.testData
- testDataService.saveTestData(data)
- afterTestDataStored(data)
- return getStandardResult(data)
- }
-
- fun getStandardResult(data: TestDataBuilder): StandardTestDataResult {
- return StandardTestDataResult(
- projects =
- data.data.projects.map {
- StandardTestDataResult.ProjectModel(name = it.self.name, id = it.self.id)
- },
- users =
- data.data.userAccounts.map {
- StandardTestDataResult.UserModel(name = it.self.name, username = it.self.username, id = it.self.id)
- },
- organizations =
- data.data.organizations.map {
- StandardTestDataResult.OrganizationModel(id = it.self.id, name = it.self.name, slug = it.self.slug)
- },
- )
+ return testDataGeneratingService.generate(testData, this::afterTestDataStored)
}
@GetMapping(value = ["/clean"])
@@ -82,27 +67,4 @@ abstract class AbstractE2eDataController {
}
}
}
-
- data class StandardTestDataResult(
- val projects: List,
- val users: List,
- val organizations: List,
- ) {
- data class UserModel(
- val name: String,
- val username: String,
- val id: Long,
- )
-
- data class ProjectModel(
- val name: String,
- val id: Long,
- )
-
- data class OrganizationModel(
- val id: Long,
- val slug: String,
- val name: String,
- )
- }
}
diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt
index de81eec61d..36dfbf4802 100644
--- a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt
+++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt
@@ -1,6 +1,7 @@
package io.tolgee.controllers.internal.e2eData
import io.swagger.v3.oas.annotations.Hidden
+import io.tolgee.data.StandardTestDataResult
import io.tolgee.development.testDataBuilder.builders.TestDataBuilder
import io.tolgee.development.testDataBuilder.data.PermissionsTestData
import io.tolgee.model.enums.ProjectPermissionType
diff --git a/backend/development/src/main/kotlin/io/tolgee/data/StandardTestDataResult.kt b/backend/development/src/main/kotlin/io/tolgee/data/StandardTestDataResult.kt
new file mode 100644
index 0000000000..ad1aaa2199
--- /dev/null
+++ b/backend/development/src/main/kotlin/io/tolgee/data/StandardTestDataResult.kt
@@ -0,0 +1,31 @@
+package io.tolgee.data
+
+data class StandardTestDataResult(
+ val projects: List,
+ val users: List,
+ val organizations: List,
+ val invitations: List
+) {
+ data class UserModel(
+ val name: String,
+ val username: String,
+ val id: Long,
+ )
+
+ data class ProjectModel(
+ val name: String,
+ val id: Long,
+ )
+
+ data class OrganizationModel(
+ val id: Long,
+ val slug: String,
+ val name: String,
+ )
+
+ data class InvitationModel(
+ val projectId: Long?,
+ val organizationId: Long?,
+ val code: String,
+ )
+}
diff --git a/backend/development/src/main/kotlin/io/tolgee/data/service/TestDataGeneratingService.kt b/backend/development/src/main/kotlin/io/tolgee/data/service/TestDataGeneratingService.kt
new file mode 100644
index 0000000000..58b59b133a
--- /dev/null
+++ b/backend/development/src/main/kotlin/io/tolgee/data/service/TestDataGeneratingService.kt
@@ -0,0 +1,46 @@
+package io.tolgee.data.service
+
+import io.tolgee.data.StandardTestDataResult
+import io.tolgee.development.testDataBuilder.TestDataService
+import io.tolgee.development.testDataBuilder.builders.TestDataBuilder
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+
+@Service
+class TestDataGeneratingService(
+ private val testDataService: TestDataService
+) {
+ @Transactional
+ fun generate(
+ testData: TestDataBuilder,
+ afterTestDataStored: (TestDataBuilder) -> Unit = {}
+ ): StandardTestDataResult {
+ testDataService.saveTestData(testData)
+ afterTestDataStored(testData)
+ return getStandardResult(testData)
+ }
+
+ private fun getStandardResult(data: TestDataBuilder): StandardTestDataResult {
+ return StandardTestDataResult(
+ projects =
+ data.data.projects.map {
+ StandardTestDataResult.ProjectModel(name = it.self.name, id = it.self.id)
+ },
+ users =
+ data.data.userAccounts.map {
+ StandardTestDataResult.UserModel(name = it.self.name, username = it.self.username, id = it.self.id)
+ },
+ organizations =
+ data.data.organizations.map {
+ StandardTestDataResult.OrganizationModel(id = it.self.id, name = it.self.name, slug = it.self.slug)
+ },
+ invitations = data.data.invitations.map {
+ StandardTestDataResult.InvitationModel(
+ projectId = it.self.permission?.project?.id,
+ organizationId = it.self.permission?.organization?.id,
+ code = it.self.code
+ )
+ }
+ )
+ }
+}
diff --git a/backend/misc/src/main/kotlin/io/tolgee/fixtures/dateFromString.kt b/backend/misc/src/main/kotlin/io/tolgee/fixtures/dateFromString.kt
new file mode 100644
index 0000000000..9bba2b10ef
--- /dev/null
+++ b/backend/misc/src/main/kotlin/io/tolgee/fixtures/dateFromString.kt
@@ -0,0 +1,15 @@
+package io.tolgee.fixtures
+
+import java.time.LocalDate
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.*
+
+fun dateFromString(
+ dateString: String,
+ pattern: String = "yyyy-MM-dd",
+): Date {
+ val formatter = DateTimeFormatter.ofPattern(pattern)
+ val localDate = LocalDate.parse(dateString, formatter)
+ return Date.from(localDate.atStartOfDay(ZoneId.of("UTC")).toInstant())
+}
diff --git a/backend/testing/src/main/kotlin/io/tolgee/AbstractSpringTest.kt b/backend/testing/src/main/kotlin/io/tolgee/AbstractSpringTest.kt
index 9b6167ec6e..7029b96979 100644
--- a/backend/testing/src/main/kotlin/io/tolgee/AbstractSpringTest.kt
+++ b/backend/testing/src/main/kotlin/io/tolgee/AbstractSpringTest.kt
@@ -1,6 +1,7 @@
package io.tolgee
import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.kotlin.readValue
import io.tolgee.activity.ActivityService
import io.tolgee.component.AllCachesProvider
import io.tolgee.component.CurrentDateProvider
@@ -19,6 +20,8 @@ import io.tolgee.configuration.tolgee.machineTranslation.TolgeeMachineTranslatio
import io.tolgee.constants.MtServiceType
import io.tolgee.development.DbPopulatorReal
import io.tolgee.development.testDataBuilder.TestDataService
+import io.tolgee.fixtures.andGetContentAsString
+import io.tolgee.fixtures.andIsOk
import io.tolgee.repository.EmailVerificationRepository
import io.tolgee.repository.KeyRepository
import io.tolgee.repository.OrganizationRepository
@@ -56,6 +59,7 @@ import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.cache.CacheManager
import org.springframework.context.ApplicationContext
+import org.springframework.test.web.servlet.ResultActions
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.TransactionStatus
@@ -223,6 +227,9 @@ abstract class AbstractSpringTest : AbstractTransactionalTest() {
@Autowired
lateinit var allCachesProvider: AllCachesProvider
+ @Autowired
+ lateinit var objectMapper: ObjectMapper
+
@BeforeEach
fun clearCaches() {
allCachesProvider.getAllCaches().forEach { cacheName ->
@@ -284,4 +291,19 @@ abstract class AbstractSpringTest : AbstractTransactionalTest() {
open fun moveCurrentDate(duration: Duration) {
currentDateProvider.move(duration)
}
+
+ protected inline fun ResultActions.getContent(): T {
+ val stringContent = this.andGetContentAsString
+ return objectMapper.readValue(stringContent)
+ }
+
+ protected fun ResultActions.getIdFromResponse(): Long {
+ this.andIsOk
+ val response: Map = getContent()
+ try {
+ return (response["id"] as Number).toLong()
+ } catch (e: Exception) {
+ throw Error("Response does not contain id", e)
+ }
+ }
}
diff --git a/e2e/cypress/common/XpathBuilder.ts b/e2e/cypress/common/XpathBuilder.ts
index e81c80649a..82979e0790 100644
--- a/e2e/cypress/common/XpathBuilder.ts
+++ b/e2e/cypress/common/XpathBuilder.ts
@@ -1,44 +1,36 @@
export function XPathBuilder(initialXpath = '') {
- let xpath = initialXpath;
+ const xpath = initialXpath;
function descendant(tag = '*') {
- xpath += `//${tag}`;
- return builder;
+ return XPathBuilder(`${xpath}//${tag}`);
}
function attributeEquals(attribute: string, value: string) {
- xpath += `[@${attribute}='${value}']`;
- return builder;
+ return XPathBuilder(`${xpath}[@${attribute}='${value}']`);
}
function withAttribute(attribute: string) {
- xpath += `[@${attribute}]`;
- return builder;
+ return XPathBuilder(`${xpath}[@${attribute}]`);
}
function closestAncestor(tag = '*') {
- xpath += `/ancestor::${tag}`;
- return builder;
+ return XPathBuilder(`${xpath}/ancestor::${tag}`);
}
function descendantOrSelf(tag = '*') {
- xpath += `/descendant-or-self::${tag}`;
- return builder;
+ return XPathBuilder(`${xpath}/descendant-or-self::${tag}`);
}
function containsText(text: string) {
- xpath += `[contains(text(), '${text}')]`;
- return builder;
+ return XPathBuilder(`${xpath}[contains(text(), '${text}')]`);
}
function hasText(text: string) {
- xpath += `[text()='${text}']`;
- return builder;
+ return XPathBuilder(`${xpath}[text()='${text}']`);
}
function withDataCy(dataCy: DataCy.Value) {
- attributeEquals('data-cy', dataCy);
- return builder;
+ return attributeEquals('data-cy', dataCy);
}
function getElement() {
@@ -46,11 +38,7 @@ export function XPathBuilder(initialXpath = '') {
}
function getInputUnderDataCy(dataCy: DataCy.Value) {
- return builder
- .descendant()
- .withDataCy(dataCy)
- .descendant('input')
- .getElement();
+ return descendant().withDataCy(dataCy).descendant('input').getElement();
}
const builder = {
@@ -73,3 +61,5 @@ export function XPathBuilder(initialXpath = '') {
export function buildXpath(initialXpath = '') {
return XPathBuilder(initialXpath);
}
+
+export type XpathBuilderType = ReturnType;
diff --git a/e2e/cypress/common/apiCalls/testData/generator.ts b/e2e/cypress/common/apiCalls/testData/generator.ts
index f191e1272f..0f78175734 100644
--- a/e2e/cypress/common/apiCalls/testData/generator.ts
+++ b/e2e/cypress/common/apiCalls/testData/generator.ts
@@ -25,6 +25,7 @@ export type TestDataStandardResponse = {
projects: { name: string; id: number }[];
users: { username: string; name: string; id: number }[];
organizations: { id: number; name: string; slug: string }[];
+ invitations: { code: string; projectId: number; organizationId: number }[];
};
export const generateTestDataObject = (resource: string) => ({
diff --git a/e2e/cypress/common/namespace.ts b/e2e/cypress/common/namespace.ts
deleted file mode 100644
index 4d92f11e6b..0000000000
--- a/e2e/cypress/common/namespace.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export function selectNamespace(namespace: string) {
- cy.gcy('search-select').click();
- cy.gcy('search-select-new').click();
- cy.gcy('namespaces-select-text-field').type(namespace);
- return cy.gcy('namespaces-select-confirm').click();
-}
diff --git a/e2e/cypress/common/translations.ts b/e2e/cypress/common/translations.ts
index 33a220d4fa..c7a9687189 100644
--- a/e2e/cypress/common/translations.ts
+++ b/e2e/cypress/common/translations.ts
@@ -8,9 +8,13 @@ import { HOST } from './constants';
import { ProjectDTO } from '../../../webapp/src/service/response.types';
import { waitForGlobalLoading } from './loading';
import { assertMessage, dismissMenu, gcy, gcyAdvanced } from './shared';
-import { selectNamespace } from './namespace';
import { buildXpath } from './XpathBuilder';
import Chainable = Cypress.Chainable;
+import {
+ E2KeyCreateDialog,
+ KeyDialogFillProps,
+} from '../compounds/E2KeyCreateDialog';
+import { E2TranslationsView } from '../compounds/E2TranslationsView';
export function getCellCancelButton() {
return cy.gcy('translations-cell-cancel-button');
@@ -34,60 +38,11 @@ export const getPluralEditor = (variant: string) => {
);
};
-type Props = {
- key: string;
- translation?: string | Record;
- tag?: string;
- namespace?: string;
- description?: string;
- variableName?: string;
- assertPresenceOfNamespaceSelectBox?: boolean;
-};
-
-export function createTranslation({
- key,
- translation,
- tag,
- namespace,
- description,
- variableName,
- assertPresenceOfNamespaceSelectBox,
-}: Props) {
+export function createTranslation(props: KeyDialogFillProps) {
waitForGlobalLoading();
- cy.gcy('translations-add-button').click();
- if (assertPresenceOfNamespaceSelectBox != undefined) {
- cy.gcy('namespaces-selector').should(
- assertPresenceOfNamespaceSelectBox ? 'exist' : 'not.exist'
- );
- }
- cy.gcy('translation-create-key-input').type(key);
- if (namespace) {
- selectNamespace(namespace);
- }
- if (description) {
- cy.gcy('translation-create-description-input').type(description);
- }
- if (tag) {
- cy.gcy('translations-tag-input').type(tag);
- cy.gcy('tag-autocomplete-option').contains(`Add "${tag}"`).click();
- }
- if (typeof translation === 'string') {
- cy.gcy('translation-editor').first().type(translation);
- } else if (typeof translation === 'object') {
- cy.gcy('key-plural-checkbox').click();
- if (variableName) {
- cy.gcy('key-plural-checkbox-expand').click();
- cy.gcy('key-plural-variable-name').type(variableName);
- }
- Object.entries(translation).forEach(([key, value]) => {
- gcyAdvanced({ value: 'translation-editor', variant: key })
- .find('[contenteditable]')
- .type(value);
- });
- }
-
- cy.gcy('global-form-save-button').click();
- assertMessage('Key created');
+ const translationsView = new E2TranslationsView();
+ const keyCreateDialog = translationsView.openKeyCreateDialog();
+ keyCreateDialog.fillAndSave(props);
}
export function selectLangsInLocalstorage(projectId: number, langs: string[]) {
diff --git a/e2e/cypress/compounds/E2KeyCreateDialog.ts b/e2e/cypress/compounds/E2KeyCreateDialog.ts
new file mode 100644
index 0000000000..3a491b841f
--- /dev/null
+++ b/e2e/cypress/compounds/E2KeyCreateDialog.ts
@@ -0,0 +1,105 @@
+import { assertMessage, gcyAdvanced } from '../common/shared';
+import { E2NamespaceSelector } from './E2NamespaceSelector';
+
+export class E2KeyCreateDialog {
+ fill({
+ key,
+ translation,
+ tag,
+ namespace,
+ description,
+ plural,
+ }: KeyDialogFillProps) {
+ this.getKeyNameInput().type(key);
+ if (namespace) {
+ this.selectNamespace(namespace);
+ }
+ if (description) {
+ this.getDescriptionInput().type(description);
+ }
+ if (tag) {
+ this.addNewTag(tag);
+ }
+
+ this.setSingularTranslation(translation);
+ this.setPluralTranslation(plural);
+ }
+
+ save() {
+ cy.gcy('global-form-save-button').click();
+ }
+
+ fillAndSave(props: KeyDialogFillProps) {
+ this.fill(props);
+ this.save();
+ }
+
+ getKeyNameInput() {
+ return cy.gcy('translation-create-key-input');
+ }
+
+ getDescriptionInput() {
+ return cy.gcy('translation-create-description-input');
+ }
+
+ getTagInput() {
+ return cy.gcy('translations-tag-input');
+ }
+
+ getTagAutocompleteOption() {
+ return cy.gcy('tag-autocomplete-option');
+ }
+
+ getTranslationInput() {
+ return cy.gcy('translation-editor').first();
+ }
+
+ setSingularTranslation(translation?: string) {
+ if (!translation) {
+ return;
+ }
+ this.getTranslationInput().type(translation);
+ }
+
+ addNewTag(tag: string) {
+ this.getTagInput().type(tag);
+ this.getTagAutocompleteOption().contains(`Add "${tag}"`).click();
+ }
+
+ setPluralTranslation(plural?: KeyDialogFillProps['plural']) {
+ if (!plural) {
+ return;
+ }
+
+ cy.gcy('key-plural-checkbox').click();
+ if (plural.variableName) {
+ cy.gcy('key-plural-checkbox-expand').click();
+ cy.gcy('key-plural-variable-name').type(plural.variableName);
+ }
+ Object.entries(plural.formValues).forEach(([key, value]) => {
+ gcyAdvanced({ value: 'translation-editor', variant: key })
+ .find('[contenteditable]')
+ .type(value);
+ });
+ }
+
+ selectNamespace(namespace: string) {
+ new E2NamespaceSelector().selectNamespace(namespace);
+ }
+
+ getNamespaceSelectElement() {
+ return new E2NamespaceSelector().getNamespaceSelect();
+ }
+}
+
+export type KeyDialogFillProps = {
+ key: string;
+ translation?: string;
+ plural?: {
+ variableName?: string;
+ formValues: Record;
+ };
+ tag?: string;
+ namespace?: string;
+ description?: string;
+};
diff --git a/e2e/cypress/compounds/E2NamespaceSelector.ts b/e2e/cypress/compounds/E2NamespaceSelector.ts
new file mode 100644
index 0000000000..863dc0332a
--- /dev/null
+++ b/e2e/cypress/compounds/E2NamespaceSelector.ts
@@ -0,0 +1,16 @@
+export class E2NamespaceSelector {
+ getNamespaceSelect() {
+ return cy.gcy('search-select');
+ }
+
+ selectNamespace(namespace: string) {
+ this.getNamespaceSelect().click();
+ cy.gcy('search-select-new').click();
+ cy.gcy('namespaces-select-text-field').type(namespace);
+ return cy.gcy('namespaces-select-confirm').click();
+ }
+}
+
+export const selectNamespace = (namespace: string) => {
+ new E2NamespaceSelector().selectNamespace(namespace);
+};
diff --git a/e2e/cypress/compounds/E2TranslationsView.ts b/e2e/cypress/compounds/E2TranslationsView.ts
new file mode 100644
index 0000000000..e34bf5efcf
--- /dev/null
+++ b/e2e/cypress/compounds/E2TranslationsView.ts
@@ -0,0 +1,22 @@
+import { HOST } from '../common/constants';
+import { E2KeyCreateDialog, KeyDialogFillProps } from './E2KeyCreateDialog';
+
+export class E2TranslationsView {
+ visit(projectId: number) {
+ return cy.visit(`${HOST}/projects/${projectId}/translations`);
+ }
+
+ getAddButton() {
+ return cy.gcy('translations-add-button');
+ }
+
+ openKeyCreateDialog() {
+ this.getAddButton().click();
+ return new E2KeyCreateDialog();
+ }
+
+ createKey(props: KeyDialogFillProps) {
+ const dialog = this.openKeyCreateDialog();
+ dialog.fillAndSave(props);
+ }
+}
diff --git a/e2e/cypress/compounds/projectMembers/E2ProjectMembersInvitationDialog.ts b/e2e/cypress/compounds/projectMembers/E2ProjectMembersInvitationDialog.ts
new file mode 100644
index 0000000000..72b8564a61
--- /dev/null
+++ b/e2e/cypress/compounds/projectMembers/E2ProjectMembersInvitationDialog.ts
@@ -0,0 +1,18 @@
+export class E2ProjectMembersInvitationDialog {
+ getEmailField() {
+ return cy.gcy('invitation-dialog-input-field');
+ }
+
+ typeEmail(email: string) {
+ this.getEmailField().type(email);
+ }
+
+ clickInvite() {
+ cy.gcy('invitation-dialog-invite-button').click();
+ }
+
+ fillAndInvite(email: string) {
+ this.typeEmail(email);
+ this.clickInvite();
+ }
+}
diff --git a/e2e/cypress/compounds/projectMembers/E2ProjectMembersView.ts b/e2e/cypress/compounds/projectMembers/E2ProjectMembersView.ts
new file mode 100644
index 0000000000..cf7cfe411e
--- /dev/null
+++ b/e2e/cypress/compounds/projectMembers/E2ProjectMembersView.ts
@@ -0,0 +1,13 @@
+import { HOST } from '../../common/constants';
+import { E2ProjectMembersInvitationDialog } from './E2ProjectMembersInvitationDialog';
+
+export class E2ProjectMembersView {
+ visit(projectId: number) {
+ cy.visit(`${HOST}/projects/${projectId}/manage/permissions`);
+ }
+
+ openInvitationDialog() {
+ cy.gcy('invite-generate-button').click();
+ return new E2ProjectMembersInvitationDialog();
+ }
+}
diff --git a/e2e/cypress/compounds/tasks/E2OrderTranslationDialog.ts b/e2e/cypress/compounds/tasks/E2OrderTranslationDialog.ts
new file mode 100644
index 0000000000..1b4a213691
--- /dev/null
+++ b/e2e/cypress/compounds/tasks/E2OrderTranslationDialog.ts
@@ -0,0 +1,16 @@
+import { E2TaskForm } from './E2TaskForm';
+
+export class E2OrderTranslationDialog {
+ selectAgency() {
+ cy.gcy('translation-agency-item').contains('Agency 1').click();
+ cy.gcy('order-translation-next').click();
+ }
+
+ getTaskForm() {
+ return new E2TaskForm();
+ }
+
+ submit() {
+ cy.gcy('order-translation-submit').click();
+ }
+}
diff --git a/e2e/cypress/compounds/tasks/E2TaskForm.ts b/e2e/cypress/compounds/tasks/E2TaskForm.ts
new file mode 100644
index 0000000000..f20ccae745
--- /dev/null
+++ b/e2e/cypress/compounds/tasks/E2TaskForm.ts
@@ -0,0 +1,26 @@
+import { dismissMenu } from '../../common/shared';
+
+export class E2TaskForm {
+ fill({ name, languages }: { name: string | undefined; languages: string[] }) {
+ this.setName(name);
+ this.setLanguages(languages);
+ }
+
+ setName(name: string | undefined) {
+ if (name) {
+ cy.gcy('create-task-field-name').type(name);
+ }
+ }
+
+ clearStateFilters() {
+ cy.gcy('translations-state-filter-clear').click();
+ }
+
+ setLanguages(languages: string[]) {
+ cy.gcy('create-task-field-languages').click();
+ languages.forEach((l) => {
+ cy.gcy('create-task-field-languages-item').contains(l).click();
+ });
+ dismissMenu();
+ }
+}
diff --git a/e2e/cypress/compounds/tasks/E2TasksView.ts b/e2e/cypress/compounds/tasks/E2TasksView.ts
new file mode 100644
index 0000000000..f1b50ce024
--- /dev/null
+++ b/e2e/cypress/compounds/tasks/E2TasksView.ts
@@ -0,0 +1,16 @@
+import { E2OrderTranslationDialog } from './E2OrderTranslationDialog';
+import { HOST } from '../../common/constants';
+
+export class E2TasksView {
+ /**
+ * Navigates to the Tasks page for the given project ID.
+ */
+ visit(projectId: number) {
+ cy.visit(`${HOST}/projects/${projectId}/tasks`);
+ }
+
+ openOrderTranslationDialog() {
+ cy.gcy('tasks-header-order-translation').click();
+ return new E2OrderTranslationDialog();
+ }
+}
diff --git a/e2e/cypress/e2e/translations/base.cy.ts b/e2e/cypress/e2e/translations/base.cy.ts
index 5f46dd7fad..4dae3dca5d 100644
--- a/e2e/cypress/e2e/translations/base.cy.ts
+++ b/e2e/cypress/e2e/translations/base.cy.ts
@@ -14,6 +14,7 @@ import {
getClosestContainingText,
} from '../../common/xPath';
import { visitProjectLanguages } from '../../common/shared';
+import { E2TranslationsView } from '../../compounds/E2TranslationsView';
describe('Translations Base', () => {
let project: ProjectDTO = null;
@@ -49,11 +50,15 @@ describe('Translations Base', () => {
() => {
cy.wait(100);
cy.gcy('global-empty-state').should('be.visible');
- createTranslation({
+
+ const translationsView = new E2TranslationsView();
+ const keyCreateDialog = translationsView.openKeyCreateDialog();
+ keyCreateDialog.getNamespaceSelectElement().should('not.exist');
+ keyCreateDialog.fillAndSave({
key: 'Test key',
translation: 'Translated test key',
- assertPresenceOfNamespaceSelectBox: false,
});
+
cy.contains('Key created').should('be.visible');
cy.wait(100);
cy.xpath(getAnyContainingText('Key', 'a'))
@@ -83,7 +88,9 @@ describe('Translations Base', () => {
cy.gcy('global-empty-state').should('be.visible');
createTranslation({
key: 'test-key',
- translation: { one: '# key', other: '# keys' },
+ plural: {
+ formValues: { one: '# key', other: '# keys' },
+ },
});
getTranslationCell('test-key', 'en')
.findDcy('translation-plural-parameter')
@@ -99,8 +106,10 @@ describe('Translations Base', () => {
cy.gcy('global-empty-state').should('be.visible');
createTranslation({
key: 'test-key',
- translation: { one: '# key', other: '# keys' },
- variableName: 'testVariable',
+ plural: {
+ variableName: 'testVariable',
+ formValues: { one: '# key', other: '# keys' },
+ },
});
getTranslationCell('test-key', 'en')
.findDcy('translation-plural-parameter')
@@ -116,11 +125,14 @@ describe('Translations Base', () => {
enableNamespaces(project.id);
cy.wait(100);
cy.gcy('global-empty-state').should('be.visible');
- createTranslation({
+
+ const translationsView = new E2TranslationsView();
+ const keyCreateDialog = translationsView.openKeyCreateDialog();
+ keyCreateDialog.getNamespaceSelectElement().should('exist');
+ keyCreateDialog.fillAndSave({
key: 'Test key',
translation: 'Translated test key',
namespace: 'test-ns',
- assertPresenceOfNamespaceSelectBox: true,
});
cy.gcy('translations-namespace-banner')
diff --git a/e2e/cypress/e2e/translations/batchJobs.cy.ts b/e2e/cypress/e2e/translations/batchJobs.cy.ts
index b88f01ca84..bd13d7602a 100644
--- a/e2e/cypress/e2e/translations/batchJobs.cy.ts
+++ b/e2e/cypress/e2e/translations/batchJobs.cy.ts
@@ -10,13 +10,13 @@ import {
} from '../../common/batchOperations';
import { TestDataStandardResponse } from '../../common/apiCalls/testData/generator';
import { enableNamespaces, login } from '../../common/apiCalls/common';
-import { selectNamespace } from '../../common/namespace';
import { assertHasState } from '../../common/state';
import {
assertExportLanguagesSelected,
checkZipContent,
getFileName,
} from '../../common/export';
+import { selectNamespace } from '../../compounds/E2NamespaceSelector';
describe('Batch jobs', { scrollBehavior: false }, () => {
const downloadsFolder = Cypress.config('downloadsFolder');
diff --git a/e2e/cypress/e2e/translations/namespaces.cy.ts b/e2e/cypress/e2e/translations/namespaces.cy.ts
index ac27763d19..8d86b762fe 100644
--- a/e2e/cypress/e2e/translations/namespaces.cy.ts
+++ b/e2e/cypress/e2e/translations/namespaces.cy.ts
@@ -11,7 +11,7 @@ import {
getPopover,
selectInSelect,
} from '../../common/shared';
-import { selectNamespace } from '../../common/namespace';
+import { selectNamespace } from '../../compounds/E2NamespaceSelector';
describe('namespaces in translations', () => {
beforeEach(() => {
diff --git a/e2e/cypress/e2e/translations/with5translations/withViews.cy.ts b/e2e/cypress/e2e/translations/with5translations/withViews.cy.ts
index d1be242050..795a31264d 100644
--- a/e2e/cypress/e2e/translations/with5translations/withViews.cy.ts
+++ b/e2e/cypress/e2e/translations/with5translations/withViews.cy.ts
@@ -17,8 +17,8 @@ import {
visitTranslations,
} from '../../../common/translations';
import { gcy } from '../../../common/shared';
-import { selectNamespace } from '../../../common/namespace';
import { enableNamespaces } from '../../../common/apiCalls/common';
+import { selectNamespace } from '../../../compounds/E2NamespaceSelector';
describe('Views with 5 Translations', () => {
let project: ProjectDTO = null;
@@ -44,7 +44,7 @@ describe('Views with 5 Translations', () => {
cy.contains('Cool key 04').should('be.visible');
});
- it('insert base into translation', () => {
+ it('inserts base into translation', () => {
selectLangsInLocalstorage(project.id, ['en', 'cs']);
visitTranslations(project.id);
diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts
index e50057588f..a4cdb4a2f2 100644
--- a/e2e/cypress/support/dataCyType.d.ts
+++ b/e2e/cypress/support/dataCyType.d.ts
@@ -18,12 +18,17 @@ declare namespace DataCy {
"administration-billing-edit-custom-plan-button" |
"administration-billing-exclusive-plan-chip" |
"administration-billing-trial-badge" |
- "administration-cloud-plan-field-feature" |
"administration-cloud-plan-field-free" |
+ "administration-cloud-plan-field-included-keys" |
"administration-cloud-plan-field-included-mt-credits" |
+ "administration-cloud-plan-field-included-seats" |
"administration-cloud-plan-field-included-translations" |
+ "administration-cloud-plan-field-metric-type" |
+ "administration-cloud-plan-field-metric-type-item" |
"administration-cloud-plan-field-name" |
"administration-cloud-plan-field-price-monthly" |
+ "administration-cloud-plan-field-price-per-seat" |
+ "administration-cloud-plan-field-price-per-thousand-keys" |
"administration-cloud-plan-field-price-per-thousand-mt-credits" |
"administration-cloud-plan-field-price-per-thousand-translations" |
"administration-cloud-plan-field-price-yearly" |
@@ -44,7 +49,6 @@ declare namespace DataCy {
"administration-ee-license-key-input" |
"administration-ee-license-release-key-button" |
"administration-ee-plan-cancel-button" |
- "administration-ee-plan-field-feature" |
"administration-ee-plan-field-free" |
"administration-ee-plan-field-included-mt-credits" |
"administration-ee-plan-field-included-seats" |
@@ -70,6 +74,7 @@ declare namespace DataCy {
"administration-organizations-list-item" |
"administration-organizations-projects-button" |
"administration-organizations-settings-button" |
+ "administration-plan-field-feature" |
"administration-plan-field-non-commercial" |
"administration-plan-selector" |
"administration-subscriptions-cloud-plan-name" |
@@ -122,10 +127,12 @@ declare namespace DataCy {
"batch-select-item" |
"billing-actual-period" |
"billing-actual-period-end" |
+ "billing-actual-used-keys" |
"billing-actual-used-monthly-credits" |
+ "billing-actual-used-seats" |
"billing-actual-used-strings" |
- "billing-estimated-costs" |
- "billing-estimated-costs-open-button" |
+ "billing-expected-usage" |
+ "billing-expected-usage-open-button" |
"billing-invoice-item-number" |
"billing-invoice-usage-button" |
"billing-invoices-list" |
@@ -133,8 +140,14 @@ declare namespace DataCy {
"billing-period-switch" |
"billing-plan" |
"billing-plan-action-button" |
+ "billing-plan-included-credits" |
+ "billing-plan-included-keys" |
+ "billing-plan-included-seats" |
+ "billing-plan-included-strings" |
+ "billing-plan-included-translation-slots" |
"billing-plan-monthly-price" |
"billing-plan-price-extra-seat" |
+ "billing-plan-price-extra-thousand-keys" |
"billing-plan-price-extra-thousand-mt-credits" |
"billing-plan-price-extra-thousand-strings" |
"billing-plan-subtitle" |
@@ -144,6 +157,10 @@ declare namespace DataCy {
"billing-subscriptions-self-hosted-ee-button" |
"billing-upgrade-preview-confirm-button" |
"billing-usage-table" |
+ "billing-usage-table-credits" |
+ "billing-usage-table-keys" |
+ "billing-usage-table-seats" |
+ "billing-usage-table-translations" |
"billing_period_annual" |
"cell-key-screenshot-dropzone" |
"cell-key-screenshot-file-input" |
@@ -189,6 +206,7 @@ declare namespace DataCy {
"edit-pat-dialog-description-input" |
"edit-pat-dialog-title" |
"empty-scope-dialog" |
+ "expected-usage-dialog" |
"expiration-date-field" |
"expiration-date-picker" |
"expiration-select" |
@@ -283,6 +301,7 @@ declare namespace DataCy {
"integrate-select-api-key-step-content" |
"integrate-select-api-key-step-label" |
"integrate-weapon-selector-button" |
+ "invitation-accepted-success-message" |
"invitation-dialog-close-button" |
"invitation-dialog-input-field" |
"invitation-dialog-invite-button" |
@@ -415,6 +434,7 @@ declare namespace DataCy {
"permissions-menu-inherited-message" |
"permissions-menu-reset-to-organization" |
"permissions-menu-save" |
+ "plan_seat_limit_exceeded_while_accepting_invitation_message" |
"project-ai-prompt-dialog-description-input" |
"project-ai-prompt-dialog-save" |
"project-dashboard-activity-chart" |
@@ -508,6 +528,7 @@ declare namespace DataCy {
"search-select-item" |
"search-select-new" |
"search-select-search" |
+ "seat_spending_limit_exceeded_while_accepting_invitation_message" |
"self-hosted-ee-active-plan" |
"sensitive-dialog-otp-input" |
"sensitive-dialog-password-input" |
diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/UsageModelAssembler.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/UsageModelAssembler.kt
index f7370a6ac8..869a871c6d 100644
--- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/UsageModelAssembler.kt
+++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/UsageModelAssembler.kt
@@ -18,6 +18,7 @@ class UsageModelAssembler : RepresentationModelAssembler
subscriptionPrice = data.subscriptionPrice,
seats = this.periodToModel(data.seatsUsage),
translations = this.periodToModel(data.translationsUsage),
+ keys = this.periodToModel(data.keysUsage),
credits = data.creditsUsage?.let { sumToModel(it) },
total = data.total,
appliedStripeCredits = data.appliedStripeCredits,
diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/UsageData.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/UsageData.kt
index d3a95e459b..16c8c04b91 100644
--- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/UsageData.kt
+++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/UsageData.kt
@@ -5,14 +5,16 @@ import java.math.BigDecimal
data class UsageData(
val seatsUsage: List,
val translationsUsage: List,
+ val keysUsage: List,
val creditsUsage: SumUsageItem?,
val subscriptionPrice: BigDecimal?,
val appliedStripeCredits: BigDecimal?,
) {
val total: BigDecimal
get() =
- seatsUsage.sumOf { it.total } + translationsUsage.sumOf { it.total } + (
- subscriptionPrice
- ?: 0.toBigDecimal()
- ) + (creditsUsage?.total ?: 0.toBigDecimal())
+ seatsUsage.sumOf { it.total } +
+ translationsUsage.sumOf { it.total } +
+ keysUsage.sumOf { it.total } +
+ (subscriptionPrice ?: 0.toBigDecimal()) +
+ (creditsUsage?.total ?: 0.toBigDecimal())
}
diff --git a/webapp/src/component/common/StringsHint.tsx b/webapp/src/component/common/StringsHint.tsx
index 7df45c822f..22845e99ce 100644
--- a/webapp/src/component/common/StringsHint.tsx
+++ b/webapp/src/component/common/StringsHint.tsx
@@ -10,3 +10,21 @@ export const StringsHint: React.FC = ({ children }) => {
);
};
+
+export const KeysHint: React.FC = ({ children }) => {
+ const { t } = useTranslate();
+ return (
+
+ {children}
+
+ );
+};
+
+export const SeatsHint: React.FC = ({ children }) => {
+ const { t } = useTranslate();
+ return (
+
+ {children}
+
+ );
+};
diff --git a/webapp/src/component/security/AcceptInvitationView.tsx b/webapp/src/component/security/AcceptInvitationView.tsx
index 63d417ea57..965e965614 100644
--- a/webapp/src/component/security/AcceptInvitationView.tsx
+++ b/webapp/src/component/security/AcceptInvitationView.tsx
@@ -13,6 +13,8 @@ import { AvatarImg } from 'tg.component/common/avatar/AvatarImg';
import LoadingButton from 'tg.component/common/form/LoadingButton';
import { FullPageLoading } from 'tg.component/common/FullPageLoading';
import { TranslatedError } from 'tg.translationTools/TranslatedError';
+import { ApiError } from 'tg.service/http/ApiError';
+import { useMessage } from 'tg.hooks/useSuccessMessage';
export const FULL_PAGE_BREAK_POINT = '(max-width: 700px)';
@@ -44,6 +46,7 @@ const AcceptInvitationView: React.FC = () => {
const history = useHistory();
const match = useRouteMatch();
const { t } = useTranslate();
+ const message = useMessage();
useWindowTitle(t('accept_invitation_title'));
@@ -53,6 +56,28 @@ const AcceptInvitationView: React.FC = () => {
const acceptCode = useApiMutation({
url: '/v2/invitations/{code}/accept',
method: 'get',
+ fetchOptions: {
+ disableErrorNotification: true,
+ },
+ options: {
+ onError(e: ApiError) {
+ if (e.code == 'plan_seat_limit_exceeded') {
+ message.error(
+
+
+
+ );
+ return;
+ }
+ if (e.code == 'seats_spending_limit_exceeded') {
+ message.error(
+
+
+
+ );
+ }
+ },
+ },
});
const invitationInfo = useApiQuery({
@@ -63,7 +88,7 @@ const AcceptInvitationView: React.FC = () => {
},
options: {
onError(e) {
- history.replace(LINKS.PROJECT.build());
+ history.replace(LINKS.ROOT.build());
if (e.code) {
messageService.error();
}
@@ -81,7 +106,11 @@ const AcceptInvitationView: React.FC = () => {
{
onSuccess() {
refetchInitialData();
- messageService.success();
+ messageService.success(
+
+
+
+ );
},
onSettled() {
history.replace(LINKS.PROJECTS.build());
diff --git a/webapp/src/constants/GlobalValidationSchema.tsx b/webapp/src/constants/GlobalValidationSchema.tsx
index e97bce29af..752b68e4ab 100644
--- a/webapp/src/constants/GlobalValidationSchema.tsx
+++ b/webapp/src/constants/GlobalValidationSchema.tsx
@@ -314,8 +314,10 @@ export class Validation {
prices: Yup.object().when('type', {
is: 'PAY_AS_YOU_GO',
then: Yup.object({
- perThousandMtCredits: Yup.number().moreThan(0),
- perThousandTranslations: Yup.number().moreThan(0),
+ perThousandMtCredits: Yup.number().min(0),
+ perThousandTranslations: Yup.number().min(0),
+ perSeat: Yup.number().min(0),
+ perThousandKeys: Yup.number().min(0),
}),
}),
free: Yup.boolean(),
diff --git a/webapp/src/ee/billing/Subscriptions/cloud/PlansCloudList.tsx b/webapp/src/ee/billing/Subscriptions/cloud/PlansCloudList.tsx
index 84ace930c7..18624c660a 100644
--- a/webapp/src/ee/billing/Subscriptions/cloud/PlansCloudList.tsx
+++ b/webapp/src/ee/billing/Subscriptions/cloud/PlansCloudList.tsx
@@ -3,7 +3,7 @@ import { styled } from '@mui/material';
import { components } from 'tg.service/billingApiSchema.generated';
import { PlanType } from '../../component/Plan/types';
import { BillingPeriodType } from '../../component/Price/PeriodSwitch';
-import { FreePlan } from '../../component/Plan/FreePlan';
+import { FreePlan } from '../../component/Plan/freePlan/FreePlan';
import { useCloudPlans } from './useCloudPlans';
import { isPlanPeriodDependant } from '../../component/Plan/plansTools';
import { CloudPlanItem } from './CloudPlanItem';
diff --git a/webapp/src/ee/billing/Subscriptions/cloud/useCloudPlans.tsx b/webapp/src/ee/billing/Subscriptions/cloud/useCloudPlans.tsx
index 3ebd7d1d7d..d284cb46e9 100644
--- a/webapp/src/ee/billing/Subscriptions/cloud/useCloudPlans.tsx
+++ b/webapp/src/ee/billing/Subscriptions/cloud/useCloudPlans.tsx
@@ -53,6 +53,7 @@ export const useCloudPlans = () => {
hasYearlyPrice: false,
public: true,
nonCommercial: false,
+ metricType: 'KEYS_SEATS',
});
const parentForPublic: PlanType[] = [];
diff --git a/webapp/src/ee/billing/Subscriptions/selfHosted/PlansSelfHostedList.tsx b/webapp/src/ee/billing/Subscriptions/selfHosted/PlansSelfHostedList.tsx
index 77fb901413..e4191a5c5c 100644
--- a/webapp/src/ee/billing/Subscriptions/selfHosted/PlansSelfHostedList.tsx
+++ b/webapp/src/ee/billing/Subscriptions/selfHosted/PlansSelfHostedList.tsx
@@ -52,6 +52,7 @@ export const PlansSelfHostedList: React.FC = ({
hasYearlyPrice: false,
public: true,
nonCommercial: false,
+ metricType: 'KEYS_SEATS',
});
const parentForPublic: PlanType[] = [];
diff --git a/webapp/src/ee/billing/Subscriptions/selfHosted/SelfHostedEeActiveSubscription.tsx b/webapp/src/ee/billing/Subscriptions/selfHosted/SelfHostedEeActiveSubscription.tsx
index 12e943b292..c45a49681b 100644
--- a/webapp/src/ee/billing/Subscriptions/selfHosted/SelfHostedEeActiveSubscription.tsx
+++ b/webapp/src/ee/billing/Subscriptions/selfHosted/SelfHostedEeActiveSubscription.tsx
@@ -74,6 +74,7 @@ export const SelfHostedEeActiveSubscription: FC = ({
mb={1}
>
{
content: {
'application/json': {
...values,
+ metricType: 'KEYS_SEATS',
stripeProductId: values.stripeProductId,
forOrganizationIds: values.public
? []
@@ -78,6 +79,7 @@ export const AdministrationEePlanCreateView = () => {
seats: 0,
translations: 0,
mtCredits: 0,
+ keys: 0,
},
forOrganizationIds: [],
enabledFeatures: [],
diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationEePlanEditView.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationEePlanEditView.tsx
index 7024df6371..032e4e5aa7 100644
--- a/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationEePlanEditView.tsx
+++ b/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationEePlanEditView.tsx
@@ -84,6 +84,7 @@ export const AdministrationEePlanEditView = () => {
content: {
'application/json': {
...values,
+ metricType: 'KEYS_SEATS',
stripeProductId: values.stripeProductId!,
forOrganizationIds: values.public
? []
diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanFormBase.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanFormBase.tsx
index 7e78fff5b3..3f82eff143 100644
--- a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanFormBase.tsx
+++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanFormBase.tsx
@@ -8,8 +8,12 @@ type CloudPlanModel = components['schemas']['CloudPlanRequest'];
type EnabledFeature =
components['schemas']['CloudPlanRequest']['enabledFeatures'][number];
+export type MetricType =
+ components['schemas']['CloudPlanRequest']['metricType'];
+
export type CloudPlanFormData = {
type: CloudPlanModel['type'];
+ metricType: MetricType;
name: string;
prices: CloudPlanModel['prices'];
includedUsage: CloudPlanModel['includedUsage'];
diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/EePlanForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/EePlanForm.tsx
index 41684c1155..dcbec8b95e 100644
--- a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/EePlanForm.tsx
+++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/EePlanForm.tsx
@@ -15,6 +15,7 @@ import { useBillingApiQuery } from 'tg.service/http/useQueryApi';
import { Validation } from 'tg.constants/GlobalValidationSchema';
import LoadingButton from 'tg.component/common/form/LoadingButton';
import { EePlanOrganizations } from './EePlanOrganizations';
+import { PlanEnabledFeaturesField } from './fields/PlanEnabledFeaturesField';
type SelfHostedEePlanRequest = components['schemas']['SelfHostedEePlanRequest'];
type EnabledFeature =
@@ -39,6 +40,7 @@ type Props = {
loading: boolean | undefined;
};
+// TODO: Refactor this so it's split into smaller components
export function EePlanForm({ planId, initialData, onSubmit, loading }: Props) {
const { t } = useTranslate();
@@ -47,11 +49,6 @@ export function EePlanForm({ planId, initialData, onSubmit, loading }: Props) {
method: 'get',
});
- const featuresLoadable = useBillingApiQuery({
- url: '/v2/administration/billing/features',
- method: 'get',
- });
-
const products = productsLoadable.data?._embedded?.stripeProducts;
return (
@@ -173,44 +170,7 @@ export function EePlanForm({ planId, initialData, onSubmit, loading }: Props) {
label={t('administration_ee_plan_field_included_mt_credits')}
/>
-
-
- {t('administration_ee_plan_form_features_title')}
-
-
- {(props: FieldProps) =>
- featuresLoadable.data?.map((feature) => {
- const values = props.field.value;
-
- const toggleField = () => {
- let newValues = values;
- if (values.includes(feature)) {
- newValues = values.filter((val) => val !== feature);
- } else {
- newValues = [...values, feature];
- }
- props.form.setFieldValue(props.field.name, newValues);
- };
-
- return (
-
- }
- label={feature}
- />
- );
- }) || []
- }
-
-
-
+
= ({ parentName, isUpdate, canEditPrices }) => {
const { t } = useTranslate();
- const featuresLoadable = useBillingApiQuery({
- url: '/v2/administration/billing/features',
- method: 'get',
- });
+ const { setFieldValue, errors } = useFormikContext();
- const productsLoadable = useBillingApiQuery({
- url: '/v2/administration/billing/stripe-products',
- method: 'get',
- });
-
- const products = productsLoadable.data?._embedded?.stripeProducts;
-
- const { setFieldValue, values: formValues } = useFormikContext();
-
- const values: CloudPlanFormData = parentName
- ? formValues[parentName]
- : formValues;
+ const { values } = useCloudPlanFormValues(parentName);
parentName = parentName ? parentName + '.' : '';
- const typeOptions = [
- { value: 'PAY_AS_YOU_GO', label: 'Pay as you go', enabled: !values.free },
- { value: 'FIXED', label: 'Fixed', enabled: true },
- { value: 'SLOTS_FIXED', label: 'Slots fixed', enabled: true },
- ];
-
- const enabledTypeOptions = typeOptions.filter((t) => t.enabled);
-
function onFreeChange() {
setFieldValue(`${parentName}free`, !values.free);
}
- useEffect(() => {
- if (!enabledTypeOptions.find((o) => o.value === values.type)) {
- setFieldValue(`${parentName}type`, enabledTypeOptions[0].value);
- }
- }, [values.free]);
-
return (
<>
-
-
- {({ field, form, meta }: FieldProps) => {
- return (
-
- label.toLowerCase().includes(prompt.toLowerCase())
- }
- SelectProps={{
- // @ts-ignore
- 'data-cy': 'administration-cloud-plan-field-stripe-product',
- label: t('administration_cloud_plan_field_stripe_product'),
- size: 'small',
- fullWidth: true,
- variant: 'outlined',
- error: (meta.touched && meta.error) || '',
- }}
- value={field.value}
- onChange={(val) => form.setFieldValue(field.name, val)}
- items={[
- { value: '', name: 'None' },
- ...(products?.map(({ id, name }) => ({
- value: id,
- name: `${id} ${name}`,
- })) || []),
- ]}
- />
- );
- }}
-
+
+
+
-
-
- {t('administration_cloud_plan_form_features_title')}
-
-
- {(props: FieldProps) =>
- featuresLoadable.data?.map((feature) => {
- const values = props.field.value;
-
- const toggleField = () => {
- let newValues: string[];
- if (values.includes(feature)) {
- newValues = values.filter((val) => val !== feature);
- } else {
- newValues = [...values, feature];
- }
- props.form.setFieldValue(props.field.name, newValues);
- };
-
- return (
-
- }
- label={feature}
- />
- );
- }) || []
- }
-
-
+
>
);
diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanIncludedUsage.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanIncludedUsage.tsx
new file mode 100644
index 0000000000..349c5b762d
--- /dev/null
+++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanIncludedUsage.tsx
@@ -0,0 +1,78 @@
+import { FC } from 'react';
+import { TextField } from 'tg.component/common/form/fields/TextField';
+import { Box, Typography } from '@mui/material';
+import { useTranslate } from '@tolgee/react';
+import { useCloudPlanFormValues } from '../useCloudPlanFormValues';
+
+type CloudPlanPricesProps = {
+ parentName: string | undefined;
+};
+
+export const CloudPlanIncludedUsage: FC = ({
+ parentName,
+}) => {
+ const { t } = useTranslate();
+
+ const { values } = useCloudPlanFormValues(parentName);
+
+ return (
+ <>
+
+ {t('administration_cloud_plan_form_limits_title')}
+
+
+
+
+ {values.metricType == 'STRINGS' && (
+
+ )}
+
+ {values.metricType == 'KEYS_SEATS' && (
+ <>
+
+
+ >
+ )}
+
+ >
+ );
+};
diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanMetricTypeSelectField.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanMetricTypeSelectField.tsx
new file mode 100644
index 0000000000..86b6e73491
--- /dev/null
+++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanMetricTypeSelectField.tsx
@@ -0,0 +1,50 @@
+import React, { FC } from 'react';
+import { Select } from 'tg.component/common/form/fields/Select';
+import { MenuItem } from '@mui/material';
+import { useTranslate } from '@tolgee/react';
+import { useCloudPlanFormValues } from '../useCloudPlanFormValues';
+import { MetricType } from '../CloudPlanFormBase';
+
+type CloudPlanMetricTypeSelectFieldProps = {
+ parentName?: string;
+};
+
+export const CloudPlanMetricTypeSelectField: FC<
+ CloudPlanMetricTypeSelectFieldProps
+> = ({ parentName }) => {
+ const { t } = useTranslate();
+
+ const { values } = useCloudPlanFormValues(parentName);
+
+ const options = [
+ { value: 'KEYS_SEATS', label: 'Keys & Seats' },
+ { value: 'STRINGS', label: 'Strings' },
+ ] satisfies { value: MetricType; label: string }[];
+
+ if (values.type == 'SLOTS_FIXED') {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanPrices.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanPrices.tsx
index 3a2d66e9af..a8889e24cd 100644
--- a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanPrices.tsx
+++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanPrices.tsx
@@ -4,6 +4,8 @@ import { Box, Typography } from '@mui/material';
import { useTranslate } from '@tolgee/react';
import { CloudPlanFormData } from '../CloudPlanFormBase';
import { useFormikContext } from 'formik';
+import { getCloudPlanInitialValues } from '../getCloudPlanInitialValues';
+import { useCloudPlanFormValues } from '../useCloudPlanFormValues';
type CloudPlanPricesProps = {
parentName: string | undefined;
@@ -12,16 +14,16 @@ type CloudPlanPricesProps = {
export const CloudPlanPrices: FC = ({ parentName }) => {
const { t } = useTranslate();
- const { setFieldValue, getFieldProps } =
- useFormikContext();
-
// Here we store the non-zero prices to be able to restore them when the plan is set back to non-free
const [nonZeroPrices, setNonZeroPrices] =
useState();
- const free = getFieldProps(`${parentName}free`).value;
- const prices = getFieldProps(`${parentName}prices`).value;
- const type = getFieldProps(`${parentName}type`).value;
+ const { values, setFieldValue } = useCloudPlanFormValues(parentName);
+
+ const free = values.free;
+ const prices = values.prices;
+ const type = values.type;
+ const metricType = values.metricType;
function setPriceValuesZero() {
setNonZeroPrices(prices);
@@ -30,11 +32,11 @@ export const CloudPlanPrices: FC = ({ parentName }) => {
{}
);
- setFieldValue(`${parentName}prices`, zeroPrices);
+ setFieldValue(`prices`, zeroPrices);
}
function setPriceValuesNonZero() {
- setFieldValue(`${parentName}prices`, nonZeroPrices);
+ setFieldValue(`prices`, nonZeroPrices);
}
useEffect(() => {
@@ -53,12 +55,15 @@ export const CloudPlanPrices: FC = ({ parentName }) => {
return (
<>
-
+
{t('administration_cloud_plan_form_prices_title')}
+
+ {t('administration_cloud_plan_form_prices_per_subscription')}
+
@@ -78,17 +83,18 @@ export const CloudPlanPrices: FC = ({ parentName }) => {
type="number"
fullWidth
/>
-
+
+
+
+ {t('administration_cloud_plan_form_prices_per_usage')}
+
+
+
= ({ parentName }) => {
fullWidth
disabled={type !== 'PAY_AS_YOU_GO'}
/>
+ {metricType == 'STRINGS' && (
+ <>
+
+ >
+ )}
+ {metricType == 'KEYS_SEATS' && (
+ <>
+
+
+ >
+ )}
>
);
diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanPricesAndLimits.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanPricesAndLimits.tsx
index 6b7537e577..d7e535d245 100644
--- a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanPricesAndLimits.tsx
+++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanPricesAndLimits.tsx
@@ -1,50 +1,17 @@
import { FC } from 'react';
import { useTranslate } from '@tolgee/react';
-import { Box, Tooltip, Typography } from '@mui/material';
-import { TextField } from 'tg.component/common/form/fields/TextField';
-import { CloudPlanFormData } from '../CloudPlanFormBase';
+import { Box, Tooltip } from '@mui/material';
import { CloudPlanPrices } from './CloudPlanPrices';
+import { CloudPlanIncludedUsage } from './CloudPlanIncludedUsage';
export const CloudPlanPricesAndLimits: FC<{
parentName?: string;
- values: CloudPlanFormData;
canEditPrices: boolean;
-}> = ({ values, parentName, canEditPrices }) => {
- const { t } = useTranslate();
-
+}> = ({ parentName, canEditPrices }) => {
return (
-
- {t('administration_cloud_plan_form_limits_title')}
-
-
-
-
-
+
);
};
diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanTypeSelectField.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanTypeSelectField.tsx
new file mode 100644
index 0000000000..b9d5c8dadd
--- /dev/null
+++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanTypeSelectField.tsx
@@ -0,0 +1,56 @@
+import React, { FC, useEffect } from 'react';
+import { Select } from 'tg.component/common/form/fields/Select';
+import { MenuItem } from '@mui/material';
+import { useTranslate } from '@tolgee/react';
+import { useCloudPlanFormValues } from '../useCloudPlanFormValues';
+
+type PlanTypeSelectFieldProps = {
+ parentName?: string;
+};
+
+export const CloudPlanTypeSelectField: FC = ({
+ parentName,
+}) => {
+ const { t } = useTranslate();
+
+ const { values, setFieldValue } = useCloudPlanFormValues(parentName);
+
+ const typeOptions = [
+ { value: 'PAY_AS_YOU_GO', label: 'Pay as you go', enabled: !values.free },
+ { value: 'FIXED', label: 'Fixed', enabled: true },
+ { value: 'SLOTS_FIXED', label: 'Slots fixed (legacy)', enabled: true },
+ ];
+
+ const enabledTypeOptions = typeOptions.filter((t) => t.enabled);
+
+ useEffect(() => {
+ if (!enabledTypeOptions.find((o) => o.value === values.type)) {
+ setFieldValue(`type`, enabledTypeOptions[0].value);
+ }
+ }, [values.free]);
+
+ return (
+
+ );
+};
diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanEnabledFeaturesField.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanEnabledFeaturesField.tsx
new file mode 100644
index 0000000000..7a52485167
--- /dev/null
+++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanEnabledFeaturesField.tsx
@@ -0,0 +1,60 @@
+import React, { FC } from 'react';
+import { Box, Checkbox, FormControlLabel, Typography } from '@mui/material';
+import { Field, FieldProps } from 'formik';
+import { useBillingApiQuery } from 'tg.service/http/useQueryApi';
+import { useTranslate } from '@tolgee/react';
+
+type EnabledFeaturesFieldProps = {
+ parentName: string;
+};
+
+export const PlanEnabledFeaturesField: FC = ({
+ parentName,
+}) => {
+ const featuresLoadable = useBillingApiQuery({
+ url: '/v2/administration/billing/features',
+ method: 'get',
+ });
+
+ const { t } = useTranslate();
+
+ return (
+
+
+ {t('administration_cloud_plan_form_features_title')}
+
+
+ {(props: FieldProps) =>
+ featuresLoadable.data?.map((feature) => {
+ const values = props.field.value;
+
+ const toggleField = () => {
+ let newValues: string[];
+ if (values.includes(feature)) {
+ newValues = values.filter((val) => val !== feature);
+ } else {
+ newValues = [...values, feature];
+ }
+ props.form.setFieldValue(props.field.name, newValues);
+ };
+
+ return (
+
+ }
+ label={feature}
+ />
+ );
+ }) || []
+ }
+
+
+ );
+};
diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanStripeProductSelectField.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanStripeProductSelectField.tsx
new file mode 100644
index 0000000000..194f8afee3
--- /dev/null
+++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanStripeProductSelectField.tsx
@@ -0,0 +1,54 @@
+import React, { FC } from 'react';
+import { Field, FieldProps } from 'formik';
+import { SearchSelect } from 'tg.component/searchSelect/SearchSelect';
+import { useBillingApiQuery } from 'tg.service/http/useQueryApi';
+import { useTranslate } from '@tolgee/react';
+
+type StripeProductSelectFieldProps = {
+ parentName?: string;
+};
+
+export const PlanStripeProductSelectField: FC<
+ StripeProductSelectFieldProps
+> = ({ parentName }) => {
+ const productsLoadable = useBillingApiQuery({
+ url: '/v2/administration/billing/stripe-products',
+ method: 'get',
+ });
+
+ const products = productsLoadable.data?._embedded?.stripeProducts;
+
+ const { t } = useTranslate();
+
+ return (
+
+ {({ field, form, meta }: FieldProps) => {
+ return (
+
+ label.toLowerCase().includes(prompt.toLowerCase())
+ }
+ SelectProps={{
+ // @ts-ignore
+ 'data-cy': 'administration-cloud-plan-field-stripe-product',
+ label: t('administration_cloud_plan_field_stripe_product'),
+ size: 'small',
+ fullWidth: true,
+ variant: 'outlined',
+ error: (meta.touched && meta.error) || '',
+ }}
+ value={field.value}
+ onChange={(val) => form.setFieldValue(field.name, val)}
+ items={[
+ { value: '', name: 'None' },
+ ...(products?.map(({ id, name }) => ({
+ value: id,
+ name: `${id} ${name}`,
+ })) || []),
+ ]}
+ />
+ );
+ }}
+
+ );
+};
diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/getCloudPlanInitialValues.ts b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/getCloudPlanInitialValues.ts
index e9bd655bd6..e166e0ad07 100644
--- a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/getCloudPlanInitialValues.ts
+++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/getCloudPlanInitialValues.ts
@@ -13,20 +13,21 @@ export const getCloudPlanInitialValues = (
perSeat: planData.prices.perSeat ?? 0,
subscriptionMonthly: planData.prices.subscriptionMonthly ?? 0,
subscriptionYearly: planData.prices.subscriptionYearly ?? 0,
+ perThousandKeys: planData.prices.perThousandKeys ?? 0,
},
includedUsage: {
- seats: planData.includedUsage.seats,
- mtCredits: planData.includedUsage.mtCredits,
+ ...planData.includedUsage,
translations:
planData.type === 'SLOTS_FIXED'
? planData.includedUsage.translationSlots
: planData.includedUsage.translations,
},
- };
+ } as CloudPlanFormData;
}
return {
type: 'PAY_AS_YOU_GO',
+ metricType: 'KEYS_SEATS',
name: '',
stripeProductId: '',
prices: {
@@ -35,11 +36,13 @@ export const getCloudPlanInitialValues = (
perThousandTranslations: 0,
subscriptionMonthly: 0,
subscriptionYearly: 0,
+ perThousandKeys: 0,
},
includedUsage: {
seats: 0,
translations: 0,
mtCredits: 0,
+ keys: 0,
},
enabledFeatures: [],
public: false,
diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/useCloudPlanFormValues.ts b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/useCloudPlanFormValues.ts
new file mode 100644
index 0000000000..52e2577895
--- /dev/null
+++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/useCloudPlanFormValues.ts
@@ -0,0 +1,12 @@
+import { useFormikContext } from 'formik';
+import { CloudPlanFormData } from './CloudPlanFormBase';
+
+export const useCloudPlanFormValues = (parentName?: string) => {
+ const { values, setFieldValue } = useFormikContext();
+
+ return {
+ values: (parentName ? values[parentName] : values) as CloudPlanFormData,
+ setFieldValue: (name: string, value: any) =>
+ setFieldValue(`${parentName}${name}`, value),
+ };
+};
diff --git a/webapp/src/ee/billing/common/usage/EstimatedCosts.tsx b/webapp/src/ee/billing/common/usage/ExpectedUsage.tsx
similarity index 83%
rename from webapp/src/ee/billing/common/usage/EstimatedCosts.tsx
rename to webapp/src/ee/billing/common/usage/ExpectedUsage.tsx
index 4022dec7ad..b0a443c5f0 100644
--- a/webapp/src/ee/billing/common/usage/EstimatedCosts.tsx
+++ b/webapp/src/ee/billing/common/usage/ExpectedUsage.tsx
@@ -4,7 +4,7 @@ import { FC, useState } from 'react';
import { useMoneyFormatter } from 'tg.hooks/useLocale';
import { useTranslate } from '@tolgee/react';
import { Box, Tooltip } from '@mui/material';
-import { UsageDialogButton } from './UsageDialogButton';
+import { ExpectedUsageDialogButton } from './ExpectedUsageDialogButton';
export type EstimatedCostsProps = {
useUsage: (
@@ -13,7 +13,7 @@ export type EstimatedCostsProps = {
estimatedCosts?: number;
};
-export const EstimatedCosts: FC = ({
+export const ExpectedUsage: FC = ({
useUsage,
estimatedCosts,
}) => {
@@ -26,11 +26,7 @@ export const EstimatedCosts: FC = ({
const usage = useUsage(open);
return (
-
+
= ({
{formatMoney(estimatedCosts || 0)}
- setOpen(true)}
diff --git a/webapp/src/ee/billing/common/usage/UsageDialogButton.tsx b/webapp/src/ee/billing/common/usage/ExpectedUsageDialogButton.tsx
similarity index 85%
rename from webapp/src/ee/billing/common/usage/UsageDialogButton.tsx
rename to webapp/src/ee/billing/common/usage/ExpectedUsageDialogButton.tsx
index 5f3fbd9906..5b10483913 100644
--- a/webapp/src/ee/billing/common/usage/UsageDialogButton.tsx
+++ b/webapp/src/ee/billing/common/usage/ExpectedUsageDialogButton.tsx
@@ -7,7 +7,7 @@ import Dialog from '@mui/material/Dialog';
import { UsageTable } from './UsageTable';
import { EmptyListMessage } from 'tg.component/common/EmptyListMessage';
-export const UsageDialogButton: FC<{
+export const ExpectedUsageDialogButton: FC<{
usageData?: components['schemas']['UsageModel'];
loading: boolean;
onOpen: () => void;
@@ -25,12 +25,17 @@ export const UsageDialogButton: FC<{
-
);
};
diff --git a/webapp/src/ee/billing/component/Plan/Plan.tsx b/webapp/src/ee/billing/component/Plan/Plan.tsx
index 2854591123..38c6ba4069 100644
--- a/webapp/src/ee/billing/component/Plan/Plan.tsx
+++ b/webapp/src/ee/billing/component/Plan/Plan.tsx
@@ -100,6 +100,7 @@ export const Plan: FC = ({
)}
{
)}
- {plan.includedUsage && (
- <>
-
-
-
-
-
- >
- )}
+
);
diff --git a/webapp/src/ee/billing/component/Plan/freePlan/FreePlanLimits.tsx b/webapp/src/ee/billing/component/Plan/freePlan/FreePlanLimits.tsx
new file mode 100644
index 0000000000..ff56496984
--- /dev/null
+++ b/webapp/src/ee/billing/component/Plan/freePlan/FreePlanLimits.tsx
@@ -0,0 +1,55 @@
+import * as React from 'react';
+import { FC } from 'react';
+import {
+ IncludedCredits,
+ IncludedKeys,
+ IncludedSeats,
+ IncludedStrings,
+} from '../../IncludedItem';
+import { PlanType } from '../types';
+import { useTheme } from '@mui/material';
+
+export interface FreePlanLimitsProps {
+ plan: PlanType;
+}
+
+export const FreePlanLimits: FC = ({ plan }) => {
+ const theme = useTheme();
+ const highlightColor = theme.palette.primary.main;
+
+ return (
+ <>
+ {plan.includedUsage && (
+ <>
+ {plan.metricType == 'STRINGS' && (
+
+ )}
+
+ {plan.metricType == 'KEYS_SEATS' && (
+
+ )}
+
+
+
+
+ >
+ )}
+ >
+ );
+};
diff --git a/webapp/src/ee/billing/component/Plan/plansTools.ts b/webapp/src/ee/billing/component/Plan/plansTools.ts
index 05e283b57f..3bea11a41e 100644
--- a/webapp/src/ee/billing/component/Plan/plansTools.ts
+++ b/webapp/src/ee/billing/component/Plan/plansTools.ts
@@ -29,7 +29,9 @@ export function excludePreviousPlanFeatures(
}
}
-export function isPlanLegacy(plan: PlanType) {
+export function isPlanLegacy(plan: {
+ includedUsage?: PlanType['includedUsage'];
+}) {
const slots = plan.includedUsage?.translationSlots;
return slots !== undefined && slots !== -1;
}
diff --git a/webapp/src/ee/billing/component/Price/PayAsYouGoPrices.tsx b/webapp/src/ee/billing/component/Price/PayAsYouGoPrices.tsx
index 8a2dafe820..8392839790 100644
--- a/webapp/src/ee/billing/component/Price/PayAsYouGoPrices.tsx
+++ b/webapp/src/ee/billing/component/Price/PayAsYouGoPrices.tsx
@@ -24,7 +24,12 @@ export const PayAsYouGoPrices = ({
className,
hideTitle,
}: Props) => {
- const { perSeat, perThousandMtCredits, perThousandTranslations } = prices;
+ const {
+ perSeat,
+ perThousandMtCredits,
+ perThousandTranslations,
+ perThousandKeys,
+ } = prices;
const { t } = useTranslate();
const formatPrice = useMoneyFormatter();
@@ -42,14 +47,6 @@ export const PayAsYouGoPrices = ({
)}
- {Boolean(perSeat) && (
-
- )}
-
{Boolean(perThousandTranslations) && (
)}
+ {Boolean(perThousandKeys) && (
+
+ )}
+
+ {Boolean(perSeat) && (
+
+ )}
+
{Boolean(perThousandMtCredits) && (
= (props) => {
+ const items = [
+ {
+ getLabel: (params: { limit: number; used: number }) => (
+
+ ),
+ progress: props.stringsProgress,
+ },
+ {
+ getLabel: (params: { limit: number; used: number }) => (
+
+ ),
+ progress: props.seatsProgress,
+ },
+ {
+ getLabel: (params: { limit: number; used: number }) => (
+
+ ),
+ progress: props.keysProgress,
+ },
+ {
+ getLabel: (params: { limit: number; used: number }) => (
+
+ ),
+ progress: props.creditProgress,
+ },
+ ];
-export const UsageDetailed: React.FC = ({
- translationsUsed,
- translationsMax,
- creditUsed,
- creditMax,
- isPayAsYouGo,
- usesSlots,
-}) => {
return (
-
-
- {usesSlots ? (
- item.progress.isInUse)
+ .map((item, index) => (
+
+
+ {item.getLabel({
+ limit: Math.round(item.progress.included),
+ used: Math.round(item.progress.used),
+ })}
+
+
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
+
+ ))}
);
};
diff --git a/webapp/src/ee/billing/component/UserMenu/BillingMenuItem.tsx b/webapp/src/ee/billing/component/UserMenu/BillingMenuItem.tsx
index 33c91f1e86..dfaeb717eb 100644
--- a/webapp/src/ee/billing/component/UserMenu/BillingMenuItem.tsx
+++ b/webapp/src/ee/billing/component/UserMenu/BillingMenuItem.tsx
@@ -10,9 +10,9 @@ import {
usePreferredOrganization,
useUser,
} from 'tg.globalContext/helpers';
-import { getProgressData } from '../utils';
import { CircularBillingProgress } from '../CircularBillingProgress';
import { BillingMenuItemsProps } from '../../../../eeSetup/EeModuleType';
+import { getProgressData } from '../getProgressData';
export const BillingMenuItem: FC = ({ onClose }) => {
const { t } = useTranslate();
@@ -20,7 +20,7 @@ export const BillingMenuItem: FC = ({ onClose }) => {
const { preferredOrganization } = usePreferredOrganization();
const { usage } = useOrganizationUsage();
- const progressData = usage && getProgressData(usage);
+ const progressData = usage && getProgressData({ usage });
const config = useConfig();
const user = useUser()!;
@@ -53,9 +53,9 @@ export const BillingMenuItem: FC = ({ onClose }) => {
{progressData && (
)}
diff --git a/webapp/src/ee/billing/component/getProgressData.ts b/webapp/src/ee/billing/component/getProgressData.ts
new file mode 100644
index 0000000000..a156df71d4
--- /dev/null
+++ b/webapp/src/ee/billing/component/getProgressData.ts
@@ -0,0 +1,69 @@
+import { components } from 'tg.service/apiSchema.generated';
+import { BILLING_CRITICAL_FRACTION } from './constants';
+
+type UsageModel = components['schemas']['PublicUsageModel'];
+
+export const getProgressData = ({ usage }: { usage: UsageModel }) => {
+ const usesSlots = usage.translationSlotsLimit !== -1;
+
+ const translationSlotsProgress = new ProgressItem(
+ usage.includedTranslationSlots,
+ usage.currentTranslationSlots
+ );
+
+ const stringsProgress = new ProgressItem(
+ usage.includedTranslations,
+ usage.currentTranslations
+ );
+
+ const keysProgress = new ProgressItem(usage.includedKeys, usage.currentKeys);
+
+ const seatsProgress = new ProgressItem(
+ usage.includedSeats,
+ usage.currentSeats
+ );
+
+ const creditProgress = new ProgressItem(
+ usage.includedMtCredits,
+ usage.usedMtCredits
+ );
+
+ const mostCriticalProgress = Math.max(
+ translationSlotsProgress.progress,
+ stringsProgress.progress,
+ keysProgress.progress,
+ seatsProgress.progress
+ );
+
+ const isCritical =
+ !usage.isPayAsYouGo &&
+ Number(mostCriticalProgress) > BILLING_CRITICAL_FRACTION;
+
+ return {
+ usesSlots,
+ translationSlotsProgress,
+ stringsProgress,
+ keysProgress,
+ seatsProgress,
+ creditProgress,
+ mostCriticalProgress,
+ isCritical,
+ };
+};
+
+export type ProgressData = ReturnType;
+
+export class ProgressItem {
+ isInUse: boolean;
+
+ constructor(public included: number, public used: number) {
+ this.isInUse = included > 0;
+ }
+
+ get progress() {
+ if (!this.isInUse) {
+ return 0;
+ }
+ return this.used / this.included;
+ }
+}
diff --git a/webapp/src/ee/billing/component/topBar/TrialChipTooltip.tsx b/webapp/src/ee/billing/component/topBar/TrialChipTooltip.tsx
index 77ce61e5b6..4310ab8fe2 100644
--- a/webapp/src/ee/billing/component/topBar/TrialChipTooltip.tsx
+++ b/webapp/src/ee/billing/component/topBar/TrialChipTooltip.tsx
@@ -132,6 +132,7 @@ export const TrialChipTooltip: FC = ({
})}
/>
{
- const usesSlots = usage.translationSlotsLimit !== -1;
- const translationsUsed = usesSlots
- ? usage.currentTranslationSlots
- : usage.currentTranslations;
-
- const translationsMax = usesSlots
- ? usage.includedTranslationSlots
- : usage.includedTranslations;
-
- const translationsLimit = usesSlots
- ? usage.translationSlotsLimit
- : usage.translationsLimit;
- const translationsProgress = translationsUsed / translationsMax;
- const isPayAsYouGo = translationsLimit > translationsMax;
-
- const creditMax = usage.includedMtCredits;
- const creditUsed =
- creditMax - usage.creditBalance + usage.currentPayAsYouGoMtCredits;
-
- const creditProgress = creditUsed / creditMax;
-
- const creditProgressWithExtraUnnormalized =
- (creditUsed - usage.extraCreditBalance) / creditMax;
-
- const creditProgressExtra =
- creditProgressWithExtraUnnormalized <= 0
- ? 0
- : creditProgressWithExtraUnnormalized;
-
- const moreCriticalProgress = Math.max(
- translationsProgress,
- creditProgressExtra
- );
-
- const isCritical =
- !isPayAsYouGo && Number(moreCriticalProgress) > BILLING_CRITICAL_FRACTION;
-
- return {
- usesSlots,
- translationsUsed,
- translationsMax,
- translationsProgress,
- isPayAsYouGo,
- creditUsed,
- creditMax,
- creditProgress,
- moreCriticalProgress,
- isCritical,
- };
-};
diff --git a/webapp/src/ee/billing/currentCloudSubscription/CloudEstimatedCosts.tsx b/webapp/src/ee/billing/currentCloudSubscription/CloudEstimatedCosts.tsx
index b823cd721e..038c246c65 100644
--- a/webapp/src/ee/billing/currentCloudSubscription/CloudEstimatedCosts.tsx
+++ b/webapp/src/ee/billing/currentCloudSubscription/CloudEstimatedCosts.tsx
@@ -1,7 +1,7 @@
import { FC } from 'react';
import { useOrganization } from 'tg.views/organizations/useOrganization';
import { useBillingApiQuery } from 'tg.service/http/useQueryApi';
-import { EstimatedCosts } from '../common/usage/EstimatedCosts';
+import { ExpectedUsage } from '../common/usage/ExpectedUsage';
export const CloudEstimatedCosts: FC<{ estimatedCosts: number }> = (props) => {
const organization = useOrganization();
@@ -18,5 +18,5 @@ export const CloudEstimatedCosts: FC<{ estimatedCosts: number }> = (props) => {
},
});
- return ;
+ return ;
};
diff --git a/webapp/src/ee/billing/currentCloudSubscription/CurrentCloudSubscriptionInfo.tsx b/webapp/src/ee/billing/currentCloudSubscription/CurrentCloudSubscriptionInfo.tsx
index f716a6fc9a..9c9092e6bd 100644
--- a/webapp/src/ee/billing/currentCloudSubscription/CurrentCloudSubscriptionInfo.tsx
+++ b/webapp/src/ee/billing/currentCloudSubscription/CurrentCloudSubscriptionInfo.tsx
@@ -13,12 +13,16 @@ import {
} from '../BillingSection';
import { PlanMetric } from './PlanMetric';
import { MtHint } from 'tg.component/billing/MtHint';
-import { StringsHint } from 'tg.component/common/StringsHint';
-import { getProgressData } from '../component/utils';
+import {
+ KeysHint,
+ SeatsHint,
+ StringsHint,
+} from 'tg.component/common/StringsHint';
import { BillingPeriodInfo } from './BillingPeriodInfo';
import { CloudEstimatedCosts } from './CloudEstimatedCosts';
import { SubscriptionsTrialAlert } from './subscriptionsTrialAlert/SubscriptionsTrialAlert';
import { TrialInfo } from './TrialInfo';
+import { getProgressData } from '../component/getProgressData';
type CloudSubscriptionModel =
billingComponents['schemas']['CloudSubscriptionModel'];
@@ -44,16 +48,13 @@ export const CurrentCloudSubscriptionInfo: FC = ({
const { t } = useTranslate();
const formatDate = useDateFormatter();
- const progressData = getProgressData(usage);
+ const isPayAsYouGo =
+ activeSubscription.plan.type === 'PAY_AS_YOU_GO' &&
+ activeSubscription.status !== 'TRIALING';
- const {
- translationsUsed,
- translationsMax,
- creditMax,
- creditUsed,
- isPayAsYouGo,
- usesSlots,
- } = progressData;
+ const progressData = getProgressData({
+ usage: usage,
+ });
const planName =
activeSubscription.status === 'TRIALING' ? (
@@ -98,23 +99,60 @@ export const CurrentCloudSubscriptionInfo: FC = ({
)}
-
+ )}
+
+ {activeSubscription.plan.metricType === 'STRINGS' && (
+ }}
/>
- )
- }
- currentQuantity={translationsUsed}
- totalQuantity={translationsMax}
- periodEnd={activeSubscription.currentPeriodEnd}
- isPayAsYouGo={isPayAsYouGo}
- data-cy="billing-actual-used-strings"
- />
+ }
+ progress={progressData.stringsProgress}
+ periodEnd={activeSubscription.currentPeriodEnd}
+ isPayAsYouGo={isPayAsYouGo}
+ data-cy="billing-actual-used-strings"
+ />
+ )}
+
+ {activeSubscription.plan.metricType === 'KEYS_SEATS' && (
+ <>
+ }}
+ />
+ }
+ progress={progressData.keysProgress}
+ periodEnd={activeSubscription.currentPeriodEnd}
+ isPayAsYouGo={isPayAsYouGo}
+ data-cy="billing-actual-used-keys"
+ />
+ }}
+ />
+ }
+ progress={progressData.seatsProgress}
+ periodEnd={activeSubscription.currentPeriodEnd}
+ isPayAsYouGo={isPayAsYouGo}
+ data-cy="billing-actual-used-seats"
+ />
+ >
+ )}
+
= ({
params={{ hint: }}
/>
}
- currentQuantity={creditUsed}
- totalQuantity={creditMax || 0}
+ progress={progressData.creditProgress}
periodEnd={activeSubscription.currentPeriodEnd}
isPayAsYouGo={isPayAsYouGo}
data-cy="billing-actual-used-monthly-credits"
diff --git a/webapp/src/ee/billing/currentCloudSubscription/PlanMetric.tsx b/webapp/src/ee/billing/currentCloudSubscription/PlanMetric.tsx
index c890f92081..e354199d64 100644
--- a/webapp/src/ee/billing/currentCloudSubscription/PlanMetric.tsx
+++ b/webapp/src/ee/billing/currentCloudSubscription/PlanMetric.tsx
@@ -3,6 +3,7 @@ import clsx from 'clsx';
import { useNumberFormatter } from 'tg.hooks/useLocale';
import { BillingProgress } from '../component/BillingProgress';
import { BILLING_CRITICAL_FRACTION } from '../component/constants';
+import { ProgressItem } from '../component/getProgressData';
export const StyledMetrics = styled('div')`
display: grid;
@@ -36,8 +37,7 @@ const StyledValue = styled('span')`
type Props = {
name: string | React.ReactNode;
- currentQuantity: number;
- totalQuantity?: number;
+ progress: ProgressItem;
periodEnd?: number;
isPayAsYouGo?: boolean;
'data-cy'?: string;
@@ -45,19 +45,17 @@ type Props = {
export const PlanMetric: React.FC = ({
name,
- currentQuantity,
- totalQuantity,
+ progress,
isPayAsYouGo,
...props
}) => {
const formatNumber = useNumberFormatter();
- const showProgress = totalQuantity !== undefined;
- const progress = currentQuantity / totalQuantity!;
+ const showProgress = progress.included !== undefined;
const valueClass = isPayAsYouGo
- ? totalQuantity && currentQuantity > totalQuantity
+ ? progress?.included && progress.used > progress.included
? 'over'
: 'sufficient'
- : progress > BILLING_CRITICAL_FRACTION
+ : progress?.progress ?? 0 > BILLING_CRITICAL_FRACTION
? 'low'
: 'sufficient';
@@ -66,17 +64,18 @@ export const PlanMetric: React.FC = ({
{name}
- {formatNumber(currentQuantity)}
+ {formatNumber(progress?.used ?? 0)}
- {showProgress ? ` / ${formatNumber(totalQuantity!)}` : ''}
+
+ {showProgress ? ` / ${formatNumber(progress.included)}` : ''}
+
{showProgress && (
diff --git a/webapp/src/ee/billing/currentCloudSubscription/subscriptionsTrialAlert/ReachingTheLimitMessage.tsx b/webapp/src/ee/billing/currentCloudSubscription/subscriptionsTrialAlert/ReachingTheLimitMessage.tsx
index e6def82151..f8184d41e9 100644
--- a/webapp/src/ee/billing/currentCloudSubscription/subscriptionsTrialAlert/ReachingTheLimitMessage.tsx
+++ b/webapp/src/ee/billing/currentCloudSubscription/subscriptionsTrialAlert/ReachingTheLimitMessage.tsx
@@ -10,10 +10,20 @@ export const ReachingTheLimitMessage: FC = (
return null;
}
- const runningOutOfMtCredits = props.usage.creditProgress > 0.9;
- const runningOutOfTranslations = props.usage.translationsProgress > 0.9;
+ const runningOutOfMtCredits = props.usage.creditProgress.progress > 0.9;
+ const runningOutOfTranslations =
+ props.usage.translationSlotsProgress.progress > 0.9;
+ const runningOutOfKeys = props.usage.keysProgress.progress > 0.9;
+ const runningOutOfSeats = props.usage.seatsProgress.progress > 0.9;
+ const runningOutOfStrings = props.usage.stringsProgress.progress > 0.9;
- if (!runningOutOfMtCredits && !runningOutOfTranslations) {
+ if (
+ !runningOutOfMtCredits &&
+ !runningOutOfTranslations &&
+ !runningOutOfKeys &&
+ !runningOutOfSeats &&
+ !runningOutOfStrings
+ ) {
return null;
}
diff --git a/webapp/src/ee/billing/currentCloudSubscription/subscriptionsTrialAlert/SubscriptionsTrialAlert.tsx b/webapp/src/ee/billing/currentCloudSubscription/subscriptionsTrialAlert/SubscriptionsTrialAlert.tsx
index 9b2af77e5e..c1577fd2e7 100644
--- a/webapp/src/ee/billing/currentCloudSubscription/subscriptionsTrialAlert/SubscriptionsTrialAlert.tsx
+++ b/webapp/src/ee/billing/currentCloudSubscription/subscriptionsTrialAlert/SubscriptionsTrialAlert.tsx
@@ -1,8 +1,8 @@
import { FC } from 'react';
import { Alert } from '@mui/material';
import { components } from 'tg.service/billingApiSchema.generated';
-import { ProgressData } from '../../component/utils';
import { TrialAlertContent } from './TrialAlertContent';
+import { ProgressData } from '../../component/getProgressData';
export type SubscriptionsTrialAlertProps = {
subscription: components['schemas']['CloudSubscriptionModel'];
diff --git a/webapp/src/ee/billing/limitPopover/PlanLimitPopover.tsx b/webapp/src/ee/billing/limitPopover/PlanLimitPopover.tsx
index 8d0c5d7071..f8033be11a 100644
--- a/webapp/src/ee/billing/limitPopover/PlanLimitPopover.tsx
+++ b/webapp/src/ee/billing/limitPopover/PlanLimitPopover.tsx
@@ -15,9 +15,9 @@ import {
useOrganizationUsage,
usePreferredOrganization,
} from 'tg.globalContext/helpers';
-import { USAGE_ELEMENT_ID } from '../component/Usage';
+import { USAGE_ELEMENT_ID } from '../component/CriticalUsageCircle';
import { UsageDetailed } from '../component/UsageDetailed';
-import { getProgressData } from '../component/utils';
+import { getProgressData } from '../component/getProgressData';
const StyledDialogContent = styled(DialogContent)`
display: grid;
@@ -46,7 +46,7 @@ export const PlanLimitPopover: React.FC = ({ open, onClose }) => {
};
const anchorEl = document.getElementById(USAGE_ELEMENT_ID);
- const progressData = usage && getProgressData(usage);
+ const progressData = usage && getProgressData({ usage });
return progressData ? (
= ({ open, onClose }) => {
-
+
diff --git a/webapp/src/ee/billing/limitPopover/SpendingLimitExceeded.tsx b/webapp/src/ee/billing/limitPopover/SpendingLimitExceeded.tsx
index a40223d9f8..00102376d5 100644
--- a/webapp/src/ee/billing/limitPopover/SpendingLimitExceeded.tsx
+++ b/webapp/src/ee/billing/limitPopover/SpendingLimitExceeded.tsx
@@ -10,9 +10,9 @@ import {
import { T } from '@tolgee/react';
import { useOrganizationUsage } from 'tg.globalContext/helpers';
-import { USAGE_ELEMENT_ID } from '../component/Usage';
-import { getProgressData } from '../component/utils';
+import { USAGE_ELEMENT_ID } from '../component/CriticalUsageCircle';
import { SpendingLimitExceededDescription } from 'tg.component/security/SignUp/SpendingLimitExceededDesciption';
+import { getProgressData } from '../component/getProgressData';
const StyledDialogContent = styled(DialogContent)`
display: grid;
@@ -32,7 +32,7 @@ export const SpendingLimitExceededPopover: React.FC = ({
const { usage } = useOrganizationUsage();
const anchorEl = document.getElementById(USAGE_ELEMENT_ID);
- const progressData = usage && getProgressData(usage);
+ const progressData = usage && getProgressData({ usage });
return progressData ? (
{
const actions = {
showMessage(m: Message) {
- enqueueSnackbar(m.text, { variant: m.variant, action });
+ enqueueSnackbar(m.text, {
+ variant: m.variant,
+ action,
+ style: { maxWidth: 700 },
+ });
},
};
diff --git a/webapp/src/hooks/useSuccessMessage.tsx b/webapp/src/hooks/useSuccessMessage.tsx
index c189690009..eede1a8630 100644
--- a/webapp/src/hooks/useSuccessMessage.tsx
+++ b/webapp/src/hooks/useSuccessMessage.tsx
@@ -3,8 +3,6 @@ import { messageService } from '../service/MessageService';
export const useSuccessMessage = () =>
messageService.success.bind(messageService);
-export const useErrorMessage = () => messageService.error.bind(messageService);
-
export const useMessage = () => ({
success: messageService.success.bind(messageService),
error: messageService.error.bind(messageService),
diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts
index 41959fd05d..02805dddf4 100644
--- a/webapp/src/service/apiSchema.generated.ts
+++ b/webapp/src/service/apiSchema.generated.ts
@@ -2022,7 +2022,9 @@ export interface components {
| "cannot_cancel_trial"
| "cannot_update_without_modification"
| "current_subscription_is_not_trialing"
- | "sorting_and_paging_is_not_supported_when_using_cursor";
+ | "sorting_and_paging_is_not_supported_when_using_cursor"
+ | "strings_metric_are_not_supported"
+ | "keys_seats_metric_are_not_supported_for_slots_fixed_type";
params?: { [key: string]: unknown }[];
};
ExistenceEntityDescription: {
@@ -3434,6 +3436,8 @@ export interface components {
viewLanguageIds?: number[];
};
PlanIncludedUsageModel: {
+ /** Format: int64 */
+ keys: number;
/** Format: int64 */
mtCredits: number;
/** Format: int64 */
@@ -3445,6 +3449,7 @@ export interface components {
};
PlanPricesModel: {
perSeat: number;
+ perThousandKeys: number;
perThousandMtCredits?: number;
perThousandTranslations?: number;
subscriptionMonthly: number;
@@ -3805,11 +3810,21 @@ export interface components {
* @description Date when credits were refilled. (In epoch format)
*/
creditBalanceRefilledAt: number;
+ /**
+ * Format: int64
+ * @description How many keys are currently stored by organization
+ */
+ currentKeys: number;
/**
* Format: int64
* @description Currently used credits over credits included in plan and extra credits
*/
currentPayAsYouGoMtCredits: number;
+ /**
+ * Format: int64
+ * @description How seats are currently used by organization
+ */
+ currentSeats: number;
/**
* Format: int64
* @description How many translations slots are currently used by organization
@@ -3828,11 +3843,21 @@ export interface components {
* This option is not available anymore and this field is kept only for backward compatibility purposes and is always 0.
*/
extraCreditBalance: number;
+ /**
+ * Format: int64
+ * @description How many keys are included in current subscription plan. How many keys can organization use without additional costs.
+ */
+ includedKeys: number;
/**
* Format: int64
* @description How many credits are included in your current plan
*/
includedMtCredits: number;
+ /**
+ * Format: int64
+ * @description How many seats are included in current subscription plan. How many seats can organization use without additional costs.
+ */
+ includedSeats: number;
/**
* Format: int64
* @description How many translation slots are included in current subscription plan. How many translation slots can organization use without additional costs
@@ -3843,8 +3868,20 @@ export interface components {
* @description How many translations are included in current subscription plan. How many translations can organization use without additional costs
*/
includedTranslations: number;
+ /** @description Whether the current plan is pay-as-you-go of fixed. For pay-as-you-go plans, the spending limit is the top limit. */
+ isPayAsYouGo: boolean;
+ /**
+ * Format: int64
+ * @description How many keys can be stored until reaching the limit. (For pay us you go, the top limit is the spending limit)
+ */
+ keysLimit: number;
/** Format: int64 */
organizationId: number;
+ /**
+ * Format: int64
+ * @description How many seats can be stored until reaching the limit. (For pay us you go, the top limit is the spending limit)
+ */
+ seatsLimit: number;
/**
* Format: int64
* @description How many translations can be stored within your organization
@@ -3855,6 +3892,11 @@ export interface components {
* @description How many translations can be stored until reaching the limit. (For pay us you go, the top limit is the spending limit)
*/
translationsLimit: number;
+ /**
+ * Format: int64
+ * @description Currently used credits including credits used over the limit
+ */
+ usedMtCredits: number;
};
QuickStartModel: {
completedSteps: string[];
@@ -4481,7 +4523,9 @@ export interface components {
| "cannot_cancel_trial"
| "cannot_update_without_modification"
| "current_subscription_is_not_trialing"
- | "sorting_and_paging_is_not_supported_when_using_cursor";
+ | "sorting_and_paging_is_not_supported_when_using_cursor"
+ | "strings_metric_are_not_supported"
+ | "keys_seats_metric_are_not_supported_for_slots_fixed_type";
params?: { [key: string]: unknown }[];
success: boolean;
};
diff --git a/webapp/src/service/billingApiSchema.generated.ts b/webapp/src/service/billingApiSchema.generated.ts
index bcdba5ca43..6653191708 100644
--- a/webapp/src/service/billingApiSchema.generated.ts
+++ b/webapp/src/service/billingApiSchema.generated.ts
@@ -222,6 +222,7 @@ export interface components {
/** Format: int64 */
id: number;
includedUsage: components["schemas"]["PlanIncludedUsageModel"];
+ metricType: "KEYS_SEATS" | "STRINGS";
name: string;
nonCommercial: boolean;
prices: components["schemas"]["PlanPricesModel"];
@@ -322,6 +323,7 @@ export interface components {
/** Format: int64 */
id: number;
includedUsage: components["schemas"]["PlanIncludedUsageModel"];
+ metricType: "KEYS_SEATS" | "STRINGS";
name: string;
nonCommercial: boolean;
prices: components["schemas"]["PlanPricesModel"];
@@ -354,6 +356,7 @@ export interface components {
forOrganizationIds: number[];
free: boolean;
includedUsage: components["schemas"]["PlanIncludedUsageRequest"];
+ metricType: "KEYS_SEATS" | "STRINGS";
name: string;
nonCommercial: boolean;
/** Format: date-time */
@@ -738,7 +741,13 @@ export interface components {
| "subscription_not_scheduled_for_cancellation"
| "cannot_cancel_trial"
| "cannot_update_without_modification"
- | "current_subscription_is_not_trialing";
+ | "current_subscription_is_not_trialing"
+ | "sorting_and_paging_is_not_supported_when_using_cursor"
+ | "strings_metric_are_not_supported"
+ | "keys_seats_metric_are_not_supported_for_slots_fixed_type"
+ | "plan_key_limit_exceeded"
+ | "keys_spending_limit_exceeded"
+ | "plan_seat_limit_exceeded";
params?: { [key: string]: unknown }[];
};
ExampleItem: {
@@ -912,6 +921,8 @@ export interface components {
viewLanguageIds?: number[];
};
PlanIncludedUsageModel: {
+ /** Format: int64 */
+ keys: number;
/** Format: int64 */
mtCredits: number;
/** Format: int64 */
@@ -922,6 +933,8 @@ export interface components {
translations: number;
};
PlanIncludedUsageRequest: {
+ /** Format: int64 */
+ keys: number;
/** Format: int64 */
mtCredits: number;
/** Format: int64 */
@@ -931,6 +944,7 @@ export interface components {
};
PlanPricesModel: {
perSeat: number;
+ perThousandKeys: number;
perThousandMtCredits?: number;
perThousandTranslations?: number;
subscriptionMonthly: number;
@@ -938,6 +952,7 @@ export interface components {
};
PlanPricesRequest: {
perSeat?: number;
+ perThousandKeys?: number;
perThousandMtCredits?: number;
perThousandTranslations?: number;
subscriptionMonthly: number;
@@ -1065,6 +1080,7 @@ export interface components {
forOrganizationIds: number[];
free: boolean;
includedUsage: components["schemas"]["PlanIncludedUsageRequest"];
+ metricType: "KEYS_SEATS" | "STRINGS";
name: string;
nonCommercial: boolean;
/** Format: date-time */
@@ -1232,6 +1248,7 @@ export interface components {
/** @description Relevant for invoices only. When there are applied stripe credits, we need to reduce the total price by this amount. */
appliedStripeCredits?: number;
credits?: components["schemas"]["SumUsageItemModel"];
+ keys: components["schemas"]["AverageProportionalUsageItemModel"];
seats: components["schemas"]["AverageProportionalUsageItemModel"];
subscriptionPrice?: number;
total: number;
@@ -2892,7 +2909,7 @@ export interface operations {
parameters: {
path: {
organizationId: number;
- type: "SEATS" | "TRANSLATIONS";
+ type: "SEATS" | "TRANSLATIONS" | "KEYS";
};
};
responses: {
@@ -3093,7 +3110,7 @@ export interface operations {
path: {
organizationId: number;
invoiceId: number;
- type: "SEATS" | "TRANSLATIONS";
+ type: "SEATS" | "TRANSLATIONS" | "KEYS";
};
};
responses: {
diff --git a/webapp/src/service/http/ApiHttpService.tsx b/webapp/src/service/http/ApiHttpService.tsx
index 1d0978c2f8..6282807471 100644
--- a/webapp/src/service/http/ApiHttpService.tsx
+++ b/webapp/src/service/http/ApiHttpService.tsx
@@ -61,12 +61,10 @@ export class ApiHttpService {
if (r.status >= 400) {
const responseData = await ApiHttpService.getResObject(r);
const resultError = new ApiError('Api error', responseData);
+
resultError.setErrorHandler(() =>
handleApiError(r, responseData, init, options)
);
- if (r.status === 400) {
- errorAction(responseData.code);
- }
if (
r.status == 403 &&
diff --git a/webapp/src/service/http/errorAction.ts b/webapp/src/service/http/errorAction.ts
index 55412cf0b5..e00f888fa2 100644
--- a/webapp/src/service/http/errorAction.ts
+++ b/webapp/src/service/http/errorAction.ts
@@ -1,15 +1,31 @@
import { globalContext } from 'tg.globalContext/globalActions';
+/**
+ * Performs action for defined error codes
+ *
+ * @return Returns true if the error is handled and other error handling should be skipped
+ */
export const errorAction = (code: string) => {
switch (code) {
case 'plan_translation_limit_exceeded':
globalContext.actions?.incrementPlanLimitErrors();
- break;
+ return true;
case 'translation_spending_limit_exceeded':
globalContext.actions?.incrementSpendingLimitErrors();
- break;
+ return true;
case 'seats_spending_limit_exceeded':
globalContext.actions?.incrementSpendingLimitErrors();
- break;
+ return true;
+ case 'plan_seat_limit_exceeded':
+ globalContext.actions?.incrementPlanLimitErrors();
+ return true;
+ case 'keys_spending_limit_exceeded':
+ globalContext.actions?.incrementSpendingLimitErrors();
+ return true;
+ case 'plan_key_limit_exceeded':
+ globalContext.actions?.incrementPlanLimitErrors();
+ return true;
+ default:
+ return false;
}
};
diff --git a/webapp/src/service/http/handleApiError.tsx b/webapp/src/service/http/handleApiError.tsx
index c5c8ffa0cb..efe8efa6a7 100644
--- a/webapp/src/service/http/handleApiError.tsx
+++ b/webapp/src/service/http/handleApiError.tsx
@@ -7,6 +7,7 @@ import { parseErrorResponse } from 'tg.fixtures/errorFIxtures';
import { RequestOptions } from './ApiHttpService';
import { globalContext } from 'tg.globalContext/globalActions';
import { LINKS } from 'tg.constants/links';
+import { errorAction } from './errorAction';
export const handleApiError = (
r: Response,
@@ -63,6 +64,12 @@ export const handleApiError = (
}
if (r.status == 400 && !options.disableErrorNotification) {
+ const handledByAction = errorAction(resObject.code);
+
+ if (handledByAction) {
+ return;
+ }
+
const parsed = parseErrorResponse(resObject);
parsed.forEach((message) =>
messageService.error()
diff --git a/webapp/src/translationTools/useErrorTranslation.ts b/webapp/src/translationTools/useErrorTranslation.ts
index a1b4ec6873..3aa251473e 100644
--- a/webapp/src/translationTools/useErrorTranslation.ts
+++ b/webapp/src/translationTools/useErrorTranslation.ts
@@ -165,6 +165,8 @@ export function useErrorTranslation() {
return t('sso_domain_not_allowed');
case 'sso_login_forced_for_this_account':
return t('sso_login_forced_for_this_account');
+ case 'plan_seat_limit_exceeded':
+ return t('plan_seat_limit_exceeded');
default:
return code;
}
diff --git a/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx b/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx
index e28355f9cc..8a6d44b709 100644
--- a/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx
+++ b/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx
@@ -15,7 +15,7 @@ import {
useIsAdmin,
usePreferredOrganization,
} from 'tg.globalContext/helpers';
-import { Usage } from 'tg.ee';
+import { CriticalUsageCircle } from 'tg.ee';
type OrganizationModel = components['schemas']['OrganizationModel'];
@@ -122,7 +122,7 @@ export const BaseOrganizationSettingsView: React.FC = ({
{...otherProps}
loading={organizationLoadable.isLoading || loading}
navigation={[...navigationPrefix, ...(navigation || [])]}
- navigationRight={}
+ navigationRight={}
menuItems={menuItems}
hideChildrenOnLoading={false}
>
diff --git a/webapp/src/views/projects/BaseProjectView.tsx b/webapp/src/views/projects/BaseProjectView.tsx
index b441cede03..1bfb6a6474 100644
--- a/webapp/src/views/projects/BaseProjectView.tsx
+++ b/webapp/src/views/projects/BaseProjectView.tsx
@@ -7,7 +7,7 @@ import { OrganizationSwitch } from 'tg.component/organizationSwitch/Organization
import { LINKS, PARAMS } from 'tg.constants/links';
import { useProject } from 'tg.hooks/useProject';
import { BatchOperationsSummary } from './translations/BatchOperations/OperationsSummary/OperationsSummary';
-import { Usage } from 'tg.ee';
+import { CriticalUsageCircle } from 'tg.ee';
type Props = BaseViewProps;
@@ -44,7 +44,7 @@ export const BaseProjectView: React.FC = ({
navigationRight={
-
+
}
/>
diff --git a/webapp/src/views/projects/ProjectListView.tsx b/webapp/src/views/projects/ProjectListView.tsx
index b78edb5661..4a48c019cd 100644
--- a/webapp/src/views/projects/ProjectListView.tsx
+++ b/webapp/src/views/projects/ProjectListView.tsx
@@ -14,7 +14,7 @@ import { Link } from 'react-router-dom';
import { useIsAdmin, usePreferredOrganization } from 'tg.globalContext/helpers';
import { OrganizationSwitch } from 'tg.component/organizationSwitch/OrganizationSwitch';
import { QuickStartHighlight } from 'tg.component/layout/QuickStartGuide/QuickStartHighlight';
-import { Usage } from 'tg.ee';
+import { CriticalUsageCircle } from 'tg.ee';
const StyledWrapper = styled('div')`
display: flex;
@@ -86,7 +86,7 @@ export const ProjectListView = () => {
[],
[t('projects_title'), LINKS.PROJECTS.build()],
]}
- navigationRight={}
+ navigationRight={}
loading={listPermitted.isFetching}
>