diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt index 2c00a01d5b..8c47f91662 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt @@ -42,6 +42,7 @@ import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile +@Suppress("SpringJavaInjectionPointsAutowiringInspection") @RestController @RequestMapping("/v2/user") @Tag(name = "User", description = "Manipulates currently authenticated user") diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/notification/NotificationController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/notification/NotificationController.kt index e88d74cee3..ae7b523d7c 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/notification/NotificationController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/notification/NotificationController.kt @@ -33,6 +33,7 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +@Suppress("SpringJavaInjectionPointsAutowiringInspection") @RestController @CrossOrigin(origins = ["*"]) @RequestMapping( diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationProjectController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationProjectController.kt index fe85855e03..05c0526fc0 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationProjectController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationProjectController.kt @@ -6,17 +6,22 @@ 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.dtos.cacheable.OrganizationLanguageDto import io.tolgee.exceptions.NotFoundException import io.tolgee.facade.ProjectWithStatsFacade +import io.tolgee.hateoas.language.OrganizationLanguageModel +import io.tolgee.hateoas.language.OrganizationLanguageModelAssembler import io.tolgee.hateoas.project.ProjectModel import io.tolgee.hateoas.project.ProjectModelAssembler import io.tolgee.hateoas.project.ProjectWithStatsModel import io.tolgee.model.views.ProjectWithLanguagesView import io.tolgee.security.authorization.UseDefaultPermissions +import io.tolgee.service.language.LanguageService import io.tolgee.service.organization.OrganizationService import io.tolgee.service.project.ProjectService import org.springdoc.core.annotations.ParameterObject import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort import org.springframework.data.web.PagedResourcesAssembler import org.springframework.data.web.SortDefault import org.springframework.hateoas.PagedModel @@ -38,6 +43,9 @@ class OrganizationProjectController( private val projectService: ProjectService, private val projectModelAssembler: ProjectModelAssembler, private val projectWithStatsFacade: ProjectWithStatsFacade, + private val languageService: LanguageService, + private val organizationLanguageModelAssembler: OrganizationLanguageModelAssembler, + private val pagedOrganizationLanguageAssembler: PagedResourcesAssembler, ) { @GetMapping("/{id:[0-9]+}/projects") @Operation( @@ -110,4 +118,22 @@ class OrganizationProjectController( return getAllWithStatistics(pageable, search, organization.id) } ?: throw NotFoundException() } + + @Operation( + summary = "Get all languages in use by projects owned by specified organization", + description = "Returns all languages in use by projects owned by specified organization", + ) + @GetMapping("/{organizationId:[0-9]+}/languages") + @UseDefaultPermissions + fun getAllLanguagesInUse( + @ParameterObject + @SortDefault("base", direction = Sort.Direction.DESC) + @SortDefault("tag", direction = Sort.Direction.ASC) + pageable: Pageable, + @RequestParam("search") search: String?, + @PathVariable organizationId: Long, + ): PagedModel { + val languages = languageService.getPagedByOrganization(organizationId, pageable, search) + return pagedOrganizationLanguageAssembler.toModel(languages, organizationLanguageModelAssembler) + } } 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 936220f6aa..d557a3ca69 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 @@ -53,7 +53,7 @@ import org.springframework.http.MediaType import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile -@Suppress(names = ["MVCPathVariableInspection", "SpringJavaInjectionPointsAutowiringInspection"]) +@Suppress("MVCPathVariableInspection", "SpringJavaInjectionPointsAutowiringInspection") @RestController @CrossOrigin(origins = ["*"]) @RequestMapping(value = ["/v2/projects"]) diff --git a/backend/api/src/main/kotlin/io/tolgee/component/PreferredOrganizationFacade.kt b/backend/api/src/main/kotlin/io/tolgee/component/PreferredOrganizationFacade.kt index ccde831c16..858ed9e411 100644 --- a/backend/api/src/main/kotlin/io/tolgee/component/PreferredOrganizationFacade.kt +++ b/backend/api/src/main/kotlin/io/tolgee/component/PreferredOrganizationFacade.kt @@ -8,6 +8,7 @@ import io.tolgee.service.organization.OrganizationService import io.tolgee.service.security.UserPreferencesService import org.springframework.stereotype.Component +@Suppress("SpringJavaInjectionPointsAutowiringInspection") @Component class PreferredOrganizationFacade( private val authenticationFacade: AuthenticationFacade, diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/webhooks/WebhookConfigModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/webhooks/WebhookConfigModel.kt index b88acb5db0..c6cba4ae3f 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/webhooks/WebhookConfigModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/webhooks/WebhookConfigModel.kt @@ -21,4 +21,4 @@ class WebhookConfigModel( description = """Date of the last webhook request.""", ) var lastExecuted: Long?, -) : RepresentationModel(), Serializable +) : RepresentationModel(), Serializable diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/language/OrganizationLanguageModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/language/OrganizationLanguageModel.kt new file mode 100644 index 0000000000..c6a5e3e964 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/language/OrganizationLanguageModel.kt @@ -0,0 +1,20 @@ +package io.tolgee.hateoas.language + +import io.swagger.v3.oas.annotations.media.Schema +import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.core.Relation + +@Suppress("unused") +@Relation(collectionRelation = "languages", itemRelation = "language") +open class OrganizationLanguageModel( + @Schema(example = "Czech", description = "Language name in english") + val name: String, + @Schema(example = "cs-CZ", description = "Language tag according to BCP 47 definition") + var tag: String, + @Schema(example = "čeština", description = "Language name in this language") + var originalName: String? = null, + @Schema(example = "\uD83C\uDDE8\uD83C\uDDFF", description = "Language flag emoji as UTF-8 emoji") + var flagEmoji: String? = null, + @Schema(example = "false", description = "Whether is base language of any project") + var base: Boolean, +) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/language/OrganizationLanguageModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/language/OrganizationLanguageModelAssembler.kt new file mode 100644 index 0000000000..b4f0344915 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/language/OrganizationLanguageModelAssembler.kt @@ -0,0 +1,23 @@ +package io.tolgee.hateoas.language + +import io.tolgee.api.v2.controllers.organization.OrganizationProjectController +import io.tolgee.dtos.cacheable.OrganizationLanguageDto +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport +import org.springframework.stereotype.Component + +@Component +class OrganizationLanguageModelAssembler : + RepresentationModelAssemblerSupport( + OrganizationProjectController::class.java, + OrganizationLanguageModel::class.java, + ) { + override fun toModel(languageDto: OrganizationLanguageDto): OrganizationLanguageModel { + return OrganizationLanguageModel( + name = languageDto.name, + originalName = languageDto.originalName, + tag = languageDto.tag, + flagEmoji = languageDto.flagEmoji, + base = languageDto.base, + ) + } +} diff --git a/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiGroupBuilder.kt b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiGroupBuilder.kt index 9564a74ab3..15b0eb3e6a 100644 --- a/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiGroupBuilder.kt +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/openApi/OpenApiGroupBuilder.kt @@ -7,6 +7,7 @@ import io.tolgee.openApiDocs.OpenApiCloudExtension import io.tolgee.openApiDocs.OpenApiEeExtension import io.tolgee.openApiDocs.OpenApiOrderExtension import io.tolgee.openApiDocs.OpenApiSelfHostedExtension +import io.tolgee.openApiDocs.OpenApiUnstableOperationExtension import io.tolgee.security.authentication.AllowApiAccess import org.springdoc.core.models.GroupedOpenApi import org.springframework.web.method.HandlerMethod @@ -74,9 +75,10 @@ class OpenApiGroupBuilder( private fun addMethodExtensions() { customizeOperations { operation, handlerMethod, _ -> addOperationOrder(handlerMethod, operation) - addOperationEeExtension(handlerMethod, operation) - addSelfHostedOperationEeExtension(handlerMethod, operation) - addCloudOperationEeExtension(handlerMethod, operation) + addExtensionFor(handlerMethod, operation, OpenApiEeExtension::class.java, "x-ee") + addExtensionFor(handlerMethod, operation, OpenApiCloudExtension::class.java, "x-cloud") + addExtensionFor(handlerMethod, operation, OpenApiSelfHostedExtension::class.java, "x-self-hosted") + addExtensionFor(handlerMethod, operation, OpenApiUnstableOperationExtension::class.java, "x-unstable") operation } } @@ -91,40 +93,21 @@ class OpenApiGroupBuilder( } } - private fun addOperationEeExtension( + private fun addExtensionFor( handlerMethod: HandlerMethod, operation: Operation, + annotationClass: Class, + extensionName: String, + value: ((T) -> Any?)? = null, ) { - val eeExtensionAnnotation = - handlerMethod.getMethodAnnotation(OpenApiEeExtension::class.java) - ?: handlerMethod.method.declaringClass.getAnnotation(OpenApiEeExtension::class.java) ?: null - if (eeExtensionAnnotation != null) { - operation.addExtension("x-ee", true) + val annotation = + handlerMethod.getMethodAnnotation(annotationClass) + ?: handlerMethod.method.declaringClass.getAnnotation(annotationClass) ?: null + if (annotation == null) { + return } - } - private fun addCloudOperationEeExtension( - handlerMethod: HandlerMethod, - operation: Operation, - ) { - val eeExtensionAnnotation = - handlerMethod.getMethodAnnotation(OpenApiCloudExtension::class.java) - ?: handlerMethod.method.declaringClass.getAnnotation(OpenApiCloudExtension::class.java) ?: null - if (eeExtensionAnnotation != null) { - operation.addExtension("x-cloud", true) - } - } - - private fun addSelfHostedOperationEeExtension( - handlerMethod: HandlerMethod, - operation: Operation, - ) { - val eeExtensionAnnotation = - handlerMethod.getMethodAnnotation(OpenApiSelfHostedExtension::class.java) - ?: handlerMethod.method.declaringClass.getAnnotation(OpenApiSelfHostedExtension::class.java) ?: null - if (eeExtensionAnnotation != null) { - operation.addExtension("x-self-hosted", true) - } + operation.addExtension(extensionName, value?.invoke(annotation) ?: true) } private fun addTagOrders() { diff --git a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/metadata/Metadata.kt b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/metadata/Metadata.kt index 4d9c444185..8d83810ec6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/metadata/Metadata.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/metadata/Metadata.kt @@ -3,6 +3,7 @@ package io.tolgee.component.machineTranslation.metadata data class Metadata( val examples: List = emptyList(), val closeItems: List = emptyList(), + val glossaryTerms: List = emptyList(), val keyDescription: String?, val projectDescription: String?, val languageDescription: String?, diff --git a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/metadata/TranslationGlossaryItem.kt b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/metadata/TranslationGlossaryItem.kt new file mode 100644 index 0000000000..bbc8928f22 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/metadata/TranslationGlossaryItem.kt @@ -0,0 +1,11 @@ +package io.tolgee.component.machineTranslation.metadata + +data class TranslationGlossaryItem( + val source: String, + val target: String? = null, + val description: String? = null, + val isNonTranslatable: Boolean? = null, + val isCaseSensitive: Boolean? = null, + val isAbbreviation: Boolean? = null, + val isForbiddenTerm: Boolean? = null, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/Feature.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Feature.kt index 948729d617..aac0c1a92b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Feature.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Feature.kt @@ -21,7 +21,7 @@ enum class Feature { TASKS, SSO, ORDER_TRANSLATION, - + GLOSSARY, ; companion object { 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 613f0d55ff..a3ae5e37a8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -281,6 +281,10 @@ enum class Message { PLAN_SEAT_LIMIT_EXCEEDED, INSTANCE_NOT_USING_LICENSE_KEY, INVALID_PATH, + GLOSSARY_NOT_FOUND, + GLOSSARY_TERM_NOT_FOUND, + GLOSSARY_TERM_TRANSLATION_NOT_FOUND, + GLOSSARY_NON_TRANSLATABLE_TERM_CANNOT_BE_TRANSLATED, ; val code: String 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 a977ab9b04..3bb024a9b3 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 @@ -189,6 +189,7 @@ class TestDataService( saveAllMtCreditBuckets(builder) saveSlackWorkspaces(builder) saveOrganizationTenants(builder) + saveGlossaryData(builder) } private fun saveSlackWorkspaces(builder: TestDataBuilder) { @@ -215,6 +216,47 @@ class TestDataService( tenantService.saveAll(builder.data.organizations.mapNotNull { it.data.tenant?.self }) } + private fun saveGlossaryData(builder: TestDataBuilder) { + val builders = saveGlossaries(builder) + saveGlossariesDependants(builders) + } + + private fun saveGlossaries(builder: TestDataBuilder): List { + val builders = builder.data.organizations.flatMap { it.data.glossaries } + builders.forEach { + entityManager.persist(it.self) + } + return builders + } + + private fun saveGlossariesDependants(builders: List) { + saveGlossaryTermData(builders) + } + + private fun saveGlossaryTermData(builders: List) { + val builders = saveGlossaryTerms(builders) + saveGlossaryTermsDependants(builders) + } + + private fun saveGlossaryTerms(builders: List): List { + val builders = builders.flatMap { it.data.terms } + builders.forEach { + entityManager.persist(it.self) + } + return builders + } + + private fun saveGlossaryTermsDependants(builders: List) { + saveGlossaryTranslations(builders) + } + + private fun saveGlossaryTranslations(builders: List) { + val builders = builders.flatMap { it.data.translations } + builders.forEach { + entityManager.persist(it.self) + } + } + private fun finalize() { entityManager.flush() clearEntityManager() diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/GlossaryBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/GlossaryBuilder.kt new file mode 100644 index 0000000000..940b6f9999 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/GlossaryBuilder.kt @@ -0,0 +1,28 @@ +package io.tolgee.development.testDataBuilder.builders + +import io.tolgee.development.testDataBuilder.FT +import io.tolgee.model.Project +import io.tolgee.model.glossary.Glossary +import io.tolgee.model.glossary.GlossaryTerm + +class GlossaryBuilder( + val organizationBuilder: OrganizationBuilder, +) : BaseEntityDataBuilder() { + override var self: Glossary = Glossary().apply { + organizationOwner = organizationBuilder.self + organizationBuilder.self.glossaries.add(this) + } + + class DATA { + val terms = mutableListOf() + } + + var data = DATA() + + fun addTerm(ft: FT) = addOperation(data.terms, ft) + + fun assignProject(project: Project) { + self.assignedProjects.add(project) + project.glossaries.add(self) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/GlossaryTermBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/GlossaryTermBuilder.kt new file mode 100644 index 0000000000..bd5aff81b0 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/GlossaryTermBuilder.kt @@ -0,0 +1,22 @@ +package io.tolgee.development.testDataBuilder.builders + +import io.tolgee.development.testDataBuilder.FT +import io.tolgee.model.glossary.GlossaryTerm +import io.tolgee.model.glossary.GlossaryTermTranslation + +class GlossaryTermBuilder( + val glossaryBuilder: GlossaryBuilder, +) : BaseEntityDataBuilder() { + override var self: GlossaryTerm = GlossaryTerm().apply { + glossary = glossaryBuilder.self + glossaryBuilder.self.terms.add(this) + } + + class DATA { + val translations = mutableListOf() + } + + var data = DATA() + + fun addTranslation(ft: FT) = addOperation(data.translations, ft) +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/GlossaryTermTranslationBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/GlossaryTermTranslationBuilder.kt new file mode 100644 index 0000000000..16a5f7cc57 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/GlossaryTermTranslationBuilder.kt @@ -0,0 +1,14 @@ +package io.tolgee.development.testDataBuilder.builders + +import io.tolgee.model.glossary.GlossaryTermTranslation + +class GlossaryTermTranslationBuilder( + val glossaryTermBuilder: GlossaryTermBuilder, +) : BaseEntityDataBuilder() { + override var self: GlossaryTermTranslation = GlossaryTermTranslation( + languageTag = "en" + ).apply { + term = glossaryTermBuilder.self + glossaryTermBuilder.self.translations.add(this) + } +} 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 61dc4c1533..30a0ac786b 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 @@ -9,6 +9,7 @@ import io.tolgee.model.SsoTenant import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.model.enums.ProjectPermissionType.VIEW +import io.tolgee.model.glossary.Glossary import io.tolgee.model.slackIntegration.OrganizationSlackWorkspace import org.springframework.core.io.ClassPathResource @@ -18,6 +19,7 @@ class OrganizationBuilder( class DATA { var roles: MutableList = mutableListOf() var avatarFile: ClassPathResource? = null + val glossaries = mutableListOf() var slackWorkspaces: MutableList = mutableListOf() var tenant: SsoTenantBuilder? = null } @@ -70,4 +72,6 @@ class OrganizationBuilder( } val projects get() = testDataBuilder.data.projects.filter { it.self.organizationOwner.id == self.id } + + fun addGlossary(ft: FT) = addOperation(data.glossaries, ft) } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/GlossaryTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/GlossaryTestData.kt new file mode 100644 index 0000000000..50c91a0a16 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/GlossaryTestData.kt @@ -0,0 +1,93 @@ +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.development.testDataBuilder.builders.TestDataBuilder +import io.tolgee.model.Organization +import io.tolgee.model.Project +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.model.glossary.Glossary +import io.tolgee.model.glossary.GlossaryTerm +import io.tolgee.model.glossary.GlossaryTermTranslation + +class GlossaryTestData { + lateinit var userOwner: UserAccount + lateinit var userMaintainer: UserAccount + lateinit var organization: Organization + lateinit var project: Project + lateinit var glossary: Glossary + lateinit var term: GlossaryTerm + lateinit var translation: GlossaryTermTranslation + + lateinit var trademarkTerm: GlossaryTerm + lateinit var forbiddenTerm: GlossaryTerm + + val root: TestDataBuilder = TestDataBuilder().apply { + addUserAccount { + username = "Owner" + }.build { + userOwner = self + + project = addProject(defaultOrganizationBuilder.self) { + name = "TheProject" + }.self + + defaultOrganizationBuilder.build { + organization = self + + addRole { + user = addUserAccount { + username = "Maintainer" + }.build { + userMaintainer = self + }.self + type = OrganizationRoleType.MAINTAINER + } + + glossary = addGlossary { + name = "Test Glossary" + baseLanguageTag = "en" + }.build { + term = addTerm { + description = "The description" + }.build { + translation = addTranslation { + languageTag = "en" + text = "Term" + }.self + }.self + + trademarkTerm = addTerm { + description = "Trademark" + flagNonTranslatable = true + flagCaseSensitive = true + }.build { + addTranslation { + languageTag = "en" + text = "Apple" + } + }.self + + forbiddenTerm = addTerm { + description = "Forbidden term" + flagForbiddenTerm = true + }.build { + addTranslation { + languageTag = "en" + text = "fun" + } + + addTranslation { + languageTag = "cs" + text = "zábava" + } + }.self + }.self + + addGlossary { + name = "Empty Glossary" + baseLanguageTag = "cs" + } + } + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/OrganizationLanguageDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/OrganizationLanguageDto.kt new file mode 100644 index 0000000000..6b11115933 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/OrganizationLanguageDto.kt @@ -0,0 +1,9 @@ +package io.tolgee.dtos.cacheable + +interface OrganizationLanguageDto { + val name: String + val tag: String + val originalName: String? + val flagEmoji: String? + val base: Boolean +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/language/LanguageFilters.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/language/LanguageFilters.kt index e8b6eed89a..39cf7b5f82 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/language/LanguageFilters.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/language/LanguageFilters.kt @@ -12,4 +12,9 @@ open class LanguageFilters { description = """Filter languages without id""", ) var filterNotId: List? = null + + @field:Parameter( + description = """Filter languages by name or tag""", + ) + var search: String? = null } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/Organization.kt b/backend/data/src/main/kotlin/io/tolgee/model/Organization.kt index 73c64c04f1..29f10a7fff 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/Organization.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/Organization.kt @@ -1,6 +1,7 @@ package io.tolgee.model import com.fasterxml.jackson.annotation.JsonIgnore +import io.tolgee.model.glossary.Glossary import io.tolgee.model.slackIntegration.OrganizationSlackWorkspace import jakarta.persistence.CascadeType import jakarta.persistence.Column @@ -55,6 +56,13 @@ class Organization( ) var projects: MutableList = mutableListOf() + @OneToMany(fetch = FetchType.LAZY, mappedBy = "organizationOwner") + @field:Filter( + name = "deletedFilter", + condition = "(deleted_at IS NULL)", + ) + var glossaries: MutableSet = mutableSetOf() + @OneToMany(mappedBy = "preferredOrganization") var preferredBy: MutableList = mutableListOf() 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 fa1957f0eb..8b09e9d5ef 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/Project.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/Project.kt @@ -6,6 +6,7 @@ import io.tolgee.api.ISimpleProject import io.tolgee.model.automations.Automation import io.tolgee.model.contentDelivery.ContentDeliveryConfig import io.tolgee.model.contentDelivery.ContentStorage +import io.tolgee.model.glossary.Glossary import io.tolgee.model.key.Key import io.tolgee.model.key.Namespace import io.tolgee.model.mtServiceConfig.MtServiceConfig @@ -18,6 +19,7 @@ import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Pattern import jakarta.validation.constraints.Size import org.hibernate.annotations.ColumnDefault +import org.hibernate.annotations.Filter import org.springframework.beans.factory.ObjectFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Configurable @@ -93,6 +95,13 @@ class Project( @ActivityLoggedProp var defaultNamespace: Namespace? = null + @ManyToMany(fetch = FetchType.LAZY, mappedBy = "assignedProjects") + @field:Filter( + name = "deletedFilter", + condition = "(deleted_at IS NULL)", + ) + var glossaries: MutableSet = mutableSetOf() + @ActivityLoggedProp override var avatarHash: String? = null diff --git a/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityRevision.kt b/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityRevision.kt index 4daa00567a..114690f5cc 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityRevision.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/activity/ActivityRevision.kt @@ -72,6 +72,11 @@ class ActivityRevision : java.io.Serializable { */ var projectId: Long? = null +// /** +// * Glossary of the change +// */ +// var glossaryId: Long? = null // TODO + @OneToMany(mappedBy = "activityRevision") var describingRelations: MutableList = mutableListOf() diff --git a/backend/data/src/main/kotlin/io/tolgee/model/glossary/Glossary.kt b/backend/data/src/main/kotlin/io/tolgee/model/glossary/Glossary.kt new file mode 100644 index 0000000000..018c7b95a5 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/glossary/Glossary.kt @@ -0,0 +1,49 @@ +package io.tolgee.model.glossary + +import io.tolgee.activity.annotation.ActivityLoggedEntity +import io.tolgee.activity.annotation.ActivityLoggedProp +import io.tolgee.model.Organization +import io.tolgee.model.Project +import io.tolgee.model.SoftDeletable +import io.tolgee.model.StandardAuditModel +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.JoinColumn +import jakarta.persistence.JoinTable +import jakarta.persistence.ManyToMany +import jakarta.persistence.ManyToOne +import jakarta.persistence.OneToMany +import jakarta.persistence.Temporal +import jakarta.persistence.TemporalType +import jakarta.validation.constraints.Size +import java.util.Date + +@Entity +@ActivityLoggedEntity +class Glossary( + @field:Size(min = 3, max = 50) + @ActivityLoggedProp + var name: String = "", + @ActivityLoggedProp + @Column(nullable = false) + var baseLanguageTag: String? = null, +) : StandardAuditModel(), SoftDeletable { + @OneToMany(fetch = FetchType.LAZY, mappedBy = "glossary") + var terms: MutableList = mutableListOf() + + @ManyToOne(optional = true, fetch = FetchType.LAZY) + lateinit var organizationOwner: Organization + + @ManyToMany(fetch = FetchType.LAZY, cascade = [CascadeType.PERSIST]) + @JoinTable( + name = "glossary_project", + joinColumns = [JoinColumn(name = "project_id")], + inverseJoinColumns = [JoinColumn(name = "glossary_id")], + ) + var assignedProjects: MutableSet = mutableSetOf() + + @Temporal(TemporalType.TIMESTAMP) + override var deletedAt: Date? = null +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/glossary/GlossaryTerm.kt b/backend/data/src/main/kotlin/io/tolgee/model/glossary/GlossaryTerm.kt new file mode 100644 index 0000000000..d3c06af17f --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/glossary/GlossaryTerm.kt @@ -0,0 +1,32 @@ +package io.tolgee.model.glossary + +import io.tolgee.activity.annotation.ActivityLoggedEntity +import io.tolgee.activity.annotation.ActivityLoggedProp +import io.tolgee.model.StandardAuditModel +import jakarta.persistence.* + +@Entity +@ActivityLoggedEntity +class GlossaryTerm( + @Column(columnDefinition = "text") + @ActivityLoggedProp + var description: String? = null, +) : StandardAuditModel() { + @ManyToOne + lateinit var glossary: Glossary + + @OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.REMOVE], mappedBy = "term") + var translations: MutableList = mutableListOf() + + @ActivityLoggedProp + var flagNonTranslatable: Boolean = false + + @ActivityLoggedProp + var flagCaseSensitive: Boolean = false + + @ActivityLoggedProp + var flagAbbreviation: Boolean = false + + @ActivityLoggedProp + var flagForbiddenTerm: Boolean = false +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/glossary/GlossaryTermTranslation.kt b/backend/data/src/main/kotlin/io/tolgee/model/glossary/GlossaryTermTranslation.kt new file mode 100644 index 0000000000..9fbc571d29 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/glossary/GlossaryTermTranslation.kt @@ -0,0 +1,45 @@ +package io.tolgee.model.glossary + +import io.tolgee.activity.annotation.ActivityLoggedEntity +import io.tolgee.activity.annotation.ActivityLoggedProp +import io.tolgee.model.StandardAuditModel +import io.tolgee.util.find +import jakarta.persistence.* +import java.util.* + +@Entity +@EntityListeners(GlossaryTermTranslation.Companion.GlossaryTermTranslationListener::class) +@ActivityLoggedEntity +@Table( + indexes = [ + Index(columnList = "first_word_lowercased"), + ], + uniqueConstraints = [ + UniqueConstraint(columnNames = ["term_id", "language_tag"]), + ], +) +class GlossaryTermTranslation( + var languageTag: String, + @Column(columnDefinition = "text", nullable = false) + @ActivityLoggedProp + var text: String? = null, +) : StandardAuditModel() { + @ManyToOne + lateinit var term: GlossaryTerm + + @Column(name = "first_word_lowercased", columnDefinition = "text", nullable = false) + var firstWordLowercased: String? = null + + companion object { + val WORD_REGEX = Regex("\\p{L}+") + + class GlossaryTermTranslationListener { + @PrePersist + @PreUpdate + fun updateFirstWordLowercased(translation: GlossaryTermTranslation) { + val locale = Locale.forLanguageTag(translation.languageTag) ?: Locale.ROOT + translation.firstWordLowercased = translation.text?.lowercase(locale)?.find(WORD_REGEX) + } + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/openApiDocs/OpenApiUnstableOperationExtension.kt b/backend/data/src/main/kotlin/io/tolgee/openApiDocs/OpenApiUnstableOperationExtension.kt new file mode 100644 index 0000000000..6ae37fc210 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/openApiDocs/OpenApiUnstableOperationExtension.kt @@ -0,0 +1,3 @@ +package io.tolgee.openApiDocs + +annotation class OpenApiUnstableOperationExtension() diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/LanguageRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/LanguageRepository.kt index 681416386c..a913327c20 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/LanguageRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/LanguageRepository.kt @@ -1,6 +1,7 @@ package io.tolgee.repository import io.tolgee.dtos.cacheable.LanguageDto +import io.tolgee.dtos.cacheable.OrganizationLanguageDto import io.tolgee.dtos.request.language.LanguageFilters import io.tolgee.model.Language import org.springframework.context.annotation.Lazy @@ -11,17 +12,6 @@ import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository import java.util.* -const val LANGUAGE_FILTERS = """ - ( - :#{#filters.filterId} is null - or l.id in :#{#filters.filterId} - ) - and ( - :#{#filters.filterNotId} is null - or l.id not in :#{#filters.filterNotId} - ) -""" - @Repository @Lazy interface LanguageRepository : JpaRepository { @@ -55,7 +45,7 @@ interface LanguageRepository : JpaRepository { l.originalName, l.flagEmoji, l.aiTranslatorPromptDescription, - coalesce((l.id = l.project.baseLanguage.id), false) + coalesce((l.id = l.project.baseLanguage.id), false) as base ) from Language l where l.project.id = :projectId and l.deletedAt is null @@ -94,6 +84,55 @@ interface LanguageRepository : JpaRepository { languageIds: List, ): List + @Query( + value = """ + with base_distinct_tags AS $DISTINCT_TAGS_BASE_SUBQUERY, + non_base_distinct_tags AS $DISTINCT_TAGS_NON_BASE_SUBQUERY + select * + from ( + select + l.name as name, + l.tag as tag, + l.original_name as originalName, + l.flag_emoji as flagEmoji, + ( + CASE + WHEN l.id IN (SELECT id FROM base_distinct_tags) THEN true + ELSE false + END + ) as base + from language l + where l.id in ( + select id from base_distinct_tags + ) + or l.id in ( + select id from non_base_distinct_tags + ) + ) as result + """, + countQuery = """ + with base_distinct_tags AS $DISTINCT_TAGS_BASE_SUBQUERY, + non_base_distinct_tags AS $DISTINCT_TAGS_NON_BASE_SUBQUERY + select count(*) + from ( + select l.id + from language l + where l.id in ( + select id from base_distinct_tags + ) + or l.id in ( + select id from non_base_distinct_tags + ) + ) as result + """, + nativeQuery = true, + ) + fun findAllByOrganizationId( + organizationId: Long?, + pageable: Pageable, + search: String?, + ): Page + @Query( """ select l @@ -130,4 +169,65 @@ interface LanguageRepository : JpaRepository { """, ) fun findAllDtosByProjectId(projectId: Long): List + + companion object { + const val LANGUAGE_FILTERS = """ + ( + ( + :#{#filters.filterId} is null + or l.id in :#{#filters.filterId} + ) + and ( + :#{#filters.filterNotId} is null + or l.id not in :#{#filters.filterNotId} + ) + ) +""" + + const val SEARCH_FILTER = """ + ( + :search is null or (lower(l.name) like lower(concat('%', cast(:search as text), '%')) + or lower(l.tag) like lower(concat('%', cast(:search as text),'%'))) + ) +""" + + const val ORGANIZATION_FILTER = """ + ( + o.id = :organizationId + and o.deleted_at is null + and p.deleted_at is null + and l.deleted_at is null + ) +""" + + const val DISTINCT_TAGS_BASE_SUBQUERY = """ + ( + select min(l.id) as id, l.tag as tag + from language l + join project p on p.id = l.project_id + join organization o on p.organization_owner_id = o.id + where $ORGANIZATION_FILTER + and l.id = p.base_language_id + and $SEARCH_FILTER + group by l.tag + ) +""" + + const val DISTINCT_TAGS_NON_BASE_SUBQUERY = """ + ( + select min(l.id) as id, l.tag as tag + from language l + join project p on p.id = l.project_id + join organization o on p.organization_owner_id = o.id + where $ORGANIZATION_FILTER + and l.id != p.base_language_id + and $SEARCH_FILTER + and l.tag not in ( + select tag + from base_distinct_tags + ) + group by l.tag + ) +""" + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/contentDelivery/ContentDeliveryConfigService.kt b/backend/data/src/main/kotlin/io/tolgee/service/contentDelivery/ContentDeliveryConfigService.kt index 07b839a15c..83e0ceaf98 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/contentDelivery/ContentDeliveryConfigService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/contentDelivery/ContentDeliveryConfigService.kt @@ -23,6 +23,7 @@ import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import kotlin.random.Random +@Suppress("SpringJavaInjectionPointsAutowiringInspection") @Service class ContentDeliveryConfigService( private val contentDeliveryConfigRepository: ContentDeliveryConfigRepository, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/language/LanguageService.kt b/backend/data/src/main/kotlin/io/tolgee/service/language/LanguageService.kt index 900069c970..49131a613b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/language/LanguageService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/language/LanguageService.kt @@ -6,6 +6,7 @@ import io.tolgee.component.CurrentDateProvider import io.tolgee.constants.Caches import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.LanguageDto +import io.tolgee.dtos.cacheable.OrganizationLanguageDto import io.tolgee.dtos.request.LanguageRequest import io.tolgee.dtos.request.language.LanguageFilters import io.tolgee.exceptions.NotFoundException @@ -345,6 +346,14 @@ class LanguageService( return this.languageRepository.findAllByProjectId(projectId, pageable, filters ?: LanguageFilters()) } + fun getPagedByOrganization( + organizationId: Long, + pageable: Pageable, + search: String?, + ): Page { + return this.languageRepository.findAllByOrganizationId(organizationId, pageable, search) + } + fun findByIdIn(ids: Iterable): List { return languageRepository.findAllById(ids) } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MetadataProvider.kt b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MetadataProvider.kt index 40b74e2bf6..cc2e3eeb27 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MetadataProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MetadataProvider.kt @@ -34,6 +34,13 @@ class MetadataProvider( metadataKey.keyId, ) } ?: listOf(), + glossaryTerms = + mtGlossaryTermsProvider.glossaryTermsFor( + project = context.project, + sourceLanguageTag = context.baseLanguage.tag, + targetLanguageTag = targetLanguage.tag, + text = metadataKey.baseTranslationText, + ).toList(), keyDescription = keyDescription, projectDescription = context.project.aiTranslatorPromptDescription, languageDescription = targetLanguage.aiTranslatorPromptDescription, @@ -104,4 +111,8 @@ class MetadataProvider( private val translationMemoryService: TranslationMemoryService by lazy { context.applicationContext.getBean(TranslationMemoryService::class.java) } + + private val mtGlossaryTermsProvider by lazy { + context.applicationContext.getBean(MtGlossaryTermsProvider::class.java) + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtGlossaryTermsProvider.kt b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtGlossaryTermsProvider.kt new file mode 100644 index 0000000000..4ec9e37c9e --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtGlossaryTermsProvider.kt @@ -0,0 +1,13 @@ +package io.tolgee.service.machineTranslation + +import io.tolgee.component.machineTranslation.metadata.TranslationGlossaryItem +import io.tolgee.dtos.cacheable.ProjectDto + +interface MtGlossaryTermsProvider { + fun glossaryTermsFor( + project: ProjectDto, + sourceLanguageTag: String, + targetLanguageTag: String, + text: String, + ): Set +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtGlossaryTermsProviderOssImpl.kt b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtGlossaryTermsProviderOssImpl.kt new file mode 100644 index 0000000000..85f8453381 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtGlossaryTermsProviderOssImpl.kt @@ -0,0 +1,17 @@ +package io.tolgee.service.machineTranslation + +import io.tolgee.component.machineTranslation.metadata.TranslationGlossaryItem +import io.tolgee.dtos.cacheable.ProjectDto +import org.springframework.stereotype.Service + +@Service +class MtGlossaryTermsProviderOssImpl : MtGlossaryTermsProvider { + override fun glossaryTermsFor( + project: ProjectDto, + sourceLanguageTag: String, + targetLanguageTag: String, + text: String, + ): Set { + return emptySet() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt index 56bd92697e..32c5b86fb9 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt @@ -85,7 +85,7 @@ class ProjectService( return projectRepository.find(id) } - fun findAll(ids: List): List { + fun findAll(ids: Iterable): List { return projectRepository.findAllById(ids) } diff --git a/backend/data/src/main/kotlin/io/tolgee/util/stringUtil.kt b/backend/data/src/main/kotlin/io/tolgee/util/stringUtil.kt index 1d50caf49a..90eb7a0783 100644 --- a/backend/data/src/main/kotlin/io/tolgee/util/stringUtil.kt +++ b/backend/data/src/main/kotlin/io/tolgee/util/stringUtil.kt @@ -2,3 +2,7 @@ package io.tolgee.util val String.nullIfEmpty: String? get() = this.ifEmpty { null } + +fun String.findAll(regex: Regex): Sequence = regex.findAll(this).map { it.value } + +fun String.find(regex: Regex): String? = regex.find(this)?.value diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 848162790b..4be14bc37a 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -4203,4 +4203,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/data/src/test/kotlin/io/tolgee/service/machineTranslation/MtBatchTranslatorTest.kt b/backend/data/src/test/kotlin/io/tolgee/service/machineTranslation/MtBatchTranslatorTest.kt index 7a0854c579..bac3d33c41 100644 --- a/backend/data/src/test/kotlin/io/tolgee/service/machineTranslation/MtBatchTranslatorTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/service/machineTranslation/MtBatchTranslatorTest.kt @@ -199,6 +199,12 @@ class MtBatchTranslatorTest { .thenReturn(projectServiceMock) val projectDtoMock = mock(ProjectDto::class.java) whenever(projectServiceMock.getDto(any())).thenReturn(projectDtoMock) + + val mtGlossaryTermsProviderMock = mock() + whenever(applicationContextMock.getBean(MtGlossaryTermsProvider::class.java)) + .thenReturn(mtGlossaryTermsProviderMock) + whenever(mtGlossaryTermsProviderMock.glossaryTermsFor(any(), any(), any(), any())) + .thenReturn(emptySet()) return applicationContextMock } diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthTokenType.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthTokenType.kt index 3a2753524a..16574e1884 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthTokenType.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthTokenType.kt @@ -18,6 +18,10 @@ package io.tolgee.security.authentication enum class AuthTokenType { ANY, + + /** Personal Access Token */ ONLY_PAT, + + /** Project Api Key */ ONLY_PAK, } diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 3b37880e51..23eb3dfbf5 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -105,6 +105,7 @@ declare namespace DataCy { "api-key-list-item-regenerate-button" | "api-keys-create-edit-dialog" | "api-keys-project-select-item" | + "assigned-projects-select" | "assignee-search-select-popover" | "assignee-select" | "auto-avatar-img" | @@ -184,6 +185,11 @@ declare namespace DataCy { "content-delivery-storage-selector" | "content-delivery-storage-selector-item" | "content-delivery-subtitle" | + "create-glossary-field-name" | + "create-glossary-submit" | + "create-glossary-term-field-description" | + "create-glossary-term-field-text" | + "create-glossary-term-submit" | "create-task-field-description" | "create-task-field-languages" | "create-task-field-languages-item" | @@ -249,6 +255,14 @@ declare namespace DataCy { "global-plus-button" | "global-search-field" | "global-user-menu-button" | + "glossaries-empty-add-button" | + "glossaries-list-more-button" | + "glossary-base-language-select" | + "glossary-delete-button" | + "glossary-edit-button" | + "glossary-list-languages" | + "glossary-view-button" | + "glossary-view-language-select" | "import-conflict-resolution-dialog" | "import-conflicts-not-resolved-dialog" | "import-conflicts-not-resolved-dialog-cancel-button" | @@ -479,6 +493,7 @@ declare namespace DataCy { "project-menu-item-dashboard" | "project-menu-item-developer" | "project-menu-item-export" | + "project-menu-item-glossaries" | "project-menu-item-import" | "project-menu-item-integrate" | "project-menu-item-languages" | diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/AdvancedPermissionController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/AdvancedPermissionController.kt index 6315df4ef8..58d688a4c6 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/AdvancedPermissionController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/AdvancedPermissionController.kt @@ -19,6 +19,7 @@ import io.tolgee.service.organization.OrganizationRoleService import org.springdoc.core.annotations.ParameterObject import org.springframework.web.bind.annotation.* +@Suppress("SpringJavaInjectionPointsAutowiringInspection") @RestController @RequestMapping("/v2/") @Tag(name = "Advanced permissions") diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/AiPromptCustomizationController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/AiPromptCustomizationController.kt index a5e2e367e0..b948930af2 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/AiPromptCustomizationController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/AiPromptCustomizationController.kt @@ -29,7 +29,7 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/v2/") -@Suppress("MVCPathVariableInspection") +@Suppress("MVCPathVariableInspection", "SpringJavaInjectionPointsAutowiringInspection") @Tag(name = "AI Customization") @OpenApiEeExtension class AiPromptCustomizationController( diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoAuthController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoAuthController.kt index 83f095f284..1d77bc7543 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoAuthController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoAuthController.kt @@ -11,6 +11,7 @@ import io.tolgee.ee.data.SsoUrlResponse import io.tolgee.service.TenantService import org.springframework.web.bind.annotation.* +@Suppress("SpringJavaInjectionPointsAutowiringInspection") @RestController @RequestMapping("/api/public") @AuthenticationTag diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderController.kt index 68d4c4e240..9f7bd5fe2c 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderController.kt @@ -3,7 +3,6 @@ package io.tolgee.ee.api.v2.controllers import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider -import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.constants.Feature import io.tolgee.constants.Message import io.tolgee.dtos.sso.SsoTenantDto @@ -24,6 +23,7 @@ import io.tolgee.service.organization.OrganizationService import jakarta.validation.Valid import org.springframework.web.bind.annotation.* +@Suppress("SpringJavaInjectionPointsAutowiringInspection") @RestController @CrossOrigin(origins = ["*"]) @RequestMapping(value = ["/v2/organizations/{organizationId:[0-9]+}/sso"]) @@ -34,7 +34,6 @@ class SsoProviderController( private val ssoTenantAssembler: SsoTenantAssembler, private val enabledFeaturesProvider: EnabledFeaturesProvider, private val organizationService: OrganizationService, - private val properties: TolgeeProperties, ) { @RequiresOrganizationRole(role = OrganizationRoleType.OWNER) @PutMapping("") diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/TaskController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/TaskController.kt index 3f35b57e20..87efb0656d 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/TaskController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/TaskController.kt @@ -40,7 +40,7 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @RestController -@Suppress("MVCPathVariableInspection") +@Suppress("MVCPathVariableInspection", "SpringJavaInjectionPointsAutowiringInspection") @CrossOrigin(origins = ["*"]) @RequestMapping( value = [ diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/UserTasksController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/UserTasksController.kt index db11b55c23..6ceeb2f531 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/UserTasksController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/UserTasksController.kt @@ -16,6 +16,7 @@ import org.springframework.data.web.PagedResourcesAssembler import org.springframework.hateoas.PagedModel import org.springframework.web.bind.annotation.* +@Suppress("SpringJavaInjectionPointsAutowiringInspection") @RestController @CrossOrigin(origins = ["*"]) @RequestMapping(value = ["/v2/user-tasks"]) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/WebhookConfigController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/WebhookConfigController.kt index 9feb457091..cadd82aef9 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/WebhookConfigController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/WebhookConfigController.kt @@ -10,6 +10,7 @@ import io.tolgee.dtos.request.WebhookConfigRequest import io.tolgee.ee.api.v2.hateoas.assemblers.WebhookConfigModelAssembler import io.tolgee.ee.data.WebhookTestResponse import io.tolgee.ee.service.WebhookConfigService +import io.tolgee.hateoas.ee.webhooks.WebhookConfigModel import io.tolgee.model.enums.Scope import io.tolgee.model.webhook.WebhookConfig import io.tolgee.openApiDocs.OpenApiEeExtension @@ -23,7 +24,7 @@ import org.springframework.data.web.PagedResourcesAssembler import org.springframework.hateoas.PagedModel import org.springframework.web.bind.annotation.* -@Suppress("MVCPathVariableInspection") +@Suppress("MVCPathVariableInspection", "SpringJavaInjectionPointsAutowiringInspection") @RestController @CrossOrigin(origins = ["*"]) @RequestMapping( @@ -49,7 +50,7 @@ class WebhookConfigController( fun create( @Valid @RequestBody dto: WebhookConfigRequest, - ): io.tolgee.hateoas.ee.webhooks.WebhookConfigModel { + ): WebhookConfigModel { enabledFeaturesProvider.checkFeatureEnabled( organizationId = projectHolder.project.organizationOwnerId, Feature.WEBHOOKS, @@ -68,7 +69,7 @@ class WebhookConfigController( id: Long, @Valid @RequestBody dto: WebhookConfigRequest, - ): io.tolgee.hateoas.ee.webhooks.WebhookConfigModel { + ): WebhookConfigModel { enabledFeaturesProvider.checkFeatureEnabled( organizationId = projectHolder.project.organizationOwnerId, Feature.WEBHOOKS, @@ -83,7 +84,7 @@ class WebhookConfigController( @AllowApiAccess fun list( @ParameterObject pageable: Pageable, - ): PagedModel { + ): PagedModel { val page = webhookConfigService.findAllInProject(projectHolder.project.id, pageable) return pageModelAssembler.toModel(page, webhookConfigModelAssembler) } @@ -105,7 +106,7 @@ class WebhookConfigController( @AllowApiAccess fun get( @PathVariable id: Long, - ): io.tolgee.hateoas.ee.webhooks.WebhookConfigModel { + ): WebhookConfigModel { return webhookConfigModelAssembler.toModel(webhookConfigService.get(projectHolder.project.id, id)) } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryController.kt new file mode 100644 index 0000000000..9ee2029e96 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryController.kt @@ -0,0 +1,150 @@ +package io.tolgee.ee.api.v2.controllers.glossary + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider +import io.tolgee.constants.Feature +import io.tolgee.ee.api.v2.hateoas.assemblers.glossary.GlossaryModelAssembler +import io.tolgee.ee.api.v2.hateoas.assemblers.glossary.SimpleGlossaryModelAssembler +import io.tolgee.ee.api.v2.hateoas.model.glossary.GlossaryModel +import io.tolgee.ee.api.v2.hateoas.model.glossary.SimpleGlossaryModel +import io.tolgee.ee.data.glossary.CreateGlossaryRequest +import io.tolgee.ee.data.glossary.UpdateGlossaryRequest +import io.tolgee.ee.service.glossary.GlossaryService +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.model.glossary.Glossary +import io.tolgee.security.OrganizationHolder +import io.tolgee.security.authentication.AllowApiAccess +import io.tolgee.security.authentication.AuthTokenType +import io.tolgee.security.authorization.RequiresOrganizationRole +import io.tolgee.security.authorization.UseDefaultPermissions +import jakarta.validation.Valid +import org.springdoc.core.annotations.ParameterObject +import org.springframework.data.domain.Pageable +import org.springframework.data.web.PagedResourcesAssembler +import org.springframework.hateoas.PagedModel +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Suppress("SpringJavaInjectionPointsAutowiringInspection") +@RestController +@RequestMapping("/v2/organizations/{organizationId:[0-9]+}/glossaries") +@Tag(name = "Glossary") +class GlossaryController( + private val glossaryService: GlossaryService, + private val glossaryModelAssembler: GlossaryModelAssembler, + private val simpleGlossaryModelAssembler: SimpleGlossaryModelAssembler, + private val pagedAssembler: PagedResourcesAssembler, + private val organizationHolder: OrganizationHolder, + private val enabledFeaturesProvider: EnabledFeaturesProvider, +) { + @PostMapping + @Operation(summary = "Create glossary") + @AllowApiAccess(AuthTokenType.ONLY_PAT) + @RequiresOrganizationRole(OrganizationRoleType.MAINTAINER) + @Transactional + fun create( + @PathVariable + organizationId: Long, + @RequestBody @Valid + dto: CreateGlossaryRequest, + ): GlossaryModel { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + val glossary = glossaryService.create(organizationHolder.organizationEntity, dto) + return glossaryModelAssembler.toModel(glossary) + } + + @PutMapping("/{glossaryId:[0-9]+}") + @Operation(summary = "Update glossary") + @AllowApiAccess(AuthTokenType.ONLY_PAT) + @RequiresOrganizationRole(OrganizationRoleType.MAINTAINER) + @Transactional + fun update( + @PathVariable + organizationId: Long, + @PathVariable + glossaryId: Long, + @RequestBody @Valid + dto: UpdateGlossaryRequest, + ): GlossaryModel { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + val organization = organizationHolder.organization + val glossary = glossaryService.update(organization.id, glossaryId, dto) + return glossaryModelAssembler.toModel(glossary) + } + + @DeleteMapping("/{glossaryId:[0-9]+}") + @Operation(summary = "Delete glossary") + @AllowApiAccess(AuthTokenType.ONLY_PAT) + @RequiresOrganizationRole(OrganizationRoleType.MAINTAINER) + @Transactional + fun delete( + @PathVariable + organizationId: Long, + @PathVariable + glossaryId: Long, + ) { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + glossaryService.delete(organizationHolder.organization.id, glossaryId) + } + + @GetMapping("/{glossaryId:[0-9]+}") + @Operation(summary = "Get glossary") + @AllowApiAccess(AuthTokenType.ONLY_PAT) + @UseDefaultPermissions + fun get( + @PathVariable + organizationId: Long, + @PathVariable + glossaryId: Long, + ): GlossaryModel { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + val organization = organizationHolder.organization + val glossary = glossaryService.get(organization.id, glossaryId) + return glossaryModelAssembler.toModel(glossary) + } + + @GetMapping() + @Operation(summary = "Get all organization glossaries") + @AllowApiAccess(AuthTokenType.ONLY_PAT) + @UseDefaultPermissions + fun getAll( + @PathVariable + organizationId: Long, + @ParameterObject pageable: Pageable, + @RequestParam("search") search: String?, + ): PagedModel { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + val organization = organizationHolder.organization + val glossaries = glossaryService.findAllPaged(organization.id, pageable, search) + return pagedAssembler.toModel(glossaries, simpleGlossaryModelAssembler) + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryLanguagesController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryLanguagesController.kt new file mode 100644 index 0000000000..a3929ca947 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryLanguagesController.kt @@ -0,0 +1,51 @@ +package io.tolgee.ee.api.v2.controllers.glossary + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider +import io.tolgee.constants.Feature +import io.tolgee.ee.data.glossary.GlossaryLanguageDto +import io.tolgee.ee.service.glossary.GlossaryService +import io.tolgee.ee.service.glossary.GlossaryTermTranslationService +import io.tolgee.security.OrganizationHolder +import io.tolgee.security.authentication.AllowApiAccess +import io.tolgee.security.authentication.AuthTokenType +import io.tolgee.security.authorization.UseDefaultPermissions +import org.springframework.hateoas.CollectionModel +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Suppress("SpringJavaInjectionPointsAutowiringInspection") +@RestController +@RequestMapping("/v2/organizations/{organizationId:[0-9]+}/glossaries/{glossaryId:[0-9]+}/languages") +@Tag(name = "Glossary languages") +class GlossaryLanguagesController( + private val glossaryService: GlossaryService, + private val glossaryTermTranslationService: GlossaryTermTranslationService, + private val organizationHolder: OrganizationHolder, + private val enabledFeaturesProvider: EnabledFeaturesProvider, +) { + @GetMapping() + @Operation(summary = "Get all languages in use by the glossary") + @AllowApiAccess(AuthTokenType.ONLY_PAT) + @UseDefaultPermissions + fun getLanguages( + @PathVariable + organizationId: Long, + @PathVariable + glossaryId: Long, + ): CollectionModel { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + val glossary = glossaryService.get(organizationId, glossaryId) + val languages = glossaryTermTranslationService.getDistinctLanguageTags(organizationId, glossaryId) + return languages.map { + GlossaryLanguageDto(it, glossary.baseLanguageTag == it) + }.let { CollectionModel.of(it) } + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryTermController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryTermController.kt new file mode 100644 index 0000000000..013238152d --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryTermController.kt @@ -0,0 +1,213 @@ +package io.tolgee.ee.api.v2.controllers.glossary + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider +import io.tolgee.constants.Feature +import io.tolgee.ee.api.v2.hateoas.assemblers.glossary.GlossaryTermTranslationModelAssembler +import io.tolgee.ee.api.v2.hateoas.assemblers.glossary.SimpleGlossaryTermModelAssembler +import io.tolgee.ee.api.v2.hateoas.assemblers.glossary.SimpleGlossaryTermWithTranslationsModelAssembler +import io.tolgee.ee.api.v2.hateoas.model.glossary.SimpleGlossaryTermModel +import io.tolgee.ee.api.v2.hateoas.model.glossary.SimpleGlossaryTermWithTranslationsModel +import io.tolgee.ee.data.glossary.CreateGlossaryTermWithTranslationRequest +import io.tolgee.ee.data.glossary.CreateUpdateGlossaryTermResponse +import io.tolgee.ee.data.glossary.DeleteMultipleGlossaryTermsRequest +import io.tolgee.ee.data.glossary.UpdateGlossaryTermWithTranslationRequest +import io.tolgee.ee.service.glossary.GlossaryTermService +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.model.glossary.GlossaryTerm +import io.tolgee.security.OrganizationHolder +import io.tolgee.security.authentication.AllowApiAccess +import io.tolgee.security.authentication.AuthTokenType +import io.tolgee.security.authorization.RequiresOrganizationRole +import io.tolgee.security.authorization.UseDefaultPermissions +import jakarta.validation.Valid +import org.springdoc.core.annotations.ParameterObject +import org.springframework.data.domain.Pageable +import org.springframework.data.web.PagedResourcesAssembler +import org.springframework.hateoas.CollectionModel +import org.springframework.hateoas.PagedModel +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.* + +@Suppress("SpringJavaInjectionPointsAutowiringInspection") +@RestController +@RequestMapping("/v2/organizations/{organizationId:[0-9]+}/glossaries/{glossaryId:[0-9]+}") +@Tag(name = "Glossary term") +class GlossaryTermController( + private val glossaryTermService: GlossaryTermService, + private val simpleGlossaryTermModelAssembler: SimpleGlossaryTermModelAssembler, + private val simpleGlossaryTermWithTranslationsModelAssembler: SimpleGlossaryTermWithTranslationsModelAssembler, + private val glossaryTermTranslationModelAssembler: GlossaryTermTranslationModelAssembler, + private val pagedAssembler: PagedResourcesAssembler, + private val organizationHolder: OrganizationHolder, + private val enabledFeaturesProvider: EnabledFeaturesProvider, +) { + @PostMapping("/terms") + @Operation(summary = "Create a new glossary term") + @AllowApiAccess(AuthTokenType.ONLY_PAT) + @RequiresOrganizationRole(OrganizationRoleType.MAINTAINER) + @Transactional + fun create( + @PathVariable + organizationId: Long, + @PathVariable + glossaryId: Long, + @RequestBody + dto: CreateGlossaryTermWithTranslationRequest, + ): CreateUpdateGlossaryTermResponse { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + val (term, translation) = glossaryTermService.createWithTranslation(organizationId, glossaryId, dto) + return CreateUpdateGlossaryTermResponse( + term = simpleGlossaryTermModelAssembler.toModel(term), + translation = translation?.let { glossaryTermTranslationModelAssembler.toModel(translation) }, + ) + } + + @DeleteMapping("/terms") + @Operation(summary = "Batch delete multiple terms") + @AllowApiAccess(AuthTokenType.ONLY_PAT) + @RequiresOrganizationRole(OrganizationRoleType.MAINTAINER) + @Transactional + fun deleteMultiple( + @PathVariable organizationId: Long, + @PathVariable glossaryId: Long, + @RequestBody @Valid dto: DeleteMultipleGlossaryTermsRequest, + ) { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + glossaryTermService.deleteMultiple(organizationId, glossaryId, dto.termIds) + } + + @PutMapping("/terms/{termId:[0-9]+}") + @Operation(summary = "Update glossary term") + @AllowApiAccess(AuthTokenType.ONLY_PAT) + @RequiresOrganizationRole(OrganizationRoleType.MAINTAINER) + @Transactional + fun update( + @PathVariable organizationId: Long, + @PathVariable glossaryId: Long, + @PathVariable termId: Long, + @RequestBody @Valid dto: UpdateGlossaryTermWithTranslationRequest, + ): CreateUpdateGlossaryTermResponse { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + val (term, translation) = glossaryTermService.updateWithTranslation(organizationId, glossaryId, termId, dto) + return CreateUpdateGlossaryTermResponse( + term = simpleGlossaryTermModelAssembler.toModel(term), + translation = translation?.let { glossaryTermTranslationModelAssembler.toModel(translation) }, + ) + } + + @DeleteMapping("/terms/{termId:[0-9]+}") + @Operation(summary = "Delete glossary term") + @AllowApiAccess(AuthTokenType.ONLY_PAT) + @RequiresOrganizationRole(OrganizationRoleType.MAINTAINER) + @Transactional + fun delete( + @PathVariable organizationId: Long, + @PathVariable glossaryId: Long, + @PathVariable termId: Long, + ) { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + glossaryTermService.delete(organizationId, glossaryId, termId) + } + + @GetMapping("/terms/{termId:[0-9]+}") + @Operation(summary = "Get glossary term") + @AllowApiAccess(AuthTokenType.ONLY_PAT) + @UseDefaultPermissions + fun get( + @PathVariable organizationId: Long, + @PathVariable glossaryId: Long, + @PathVariable termId: Long, + ): SimpleGlossaryTermModel { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + val glossaryTerm = glossaryTermService.get(organizationId, glossaryId, termId) + return simpleGlossaryTermModelAssembler.toModel(glossaryTerm) + } + + @GetMapping("/terms") + @Operation(summary = "Get all glossary terms") + @AllowApiAccess(AuthTokenType.ONLY_PAT) + @UseDefaultPermissions + fun getAll( + @PathVariable organizationId: Long, + @PathVariable glossaryId: Long, + @ParameterObject pageable: Pageable, + @RequestParam("search", required = false) search: String?, + @RequestParam("languageTags", required = false) languageTags: List?, + ): PagedModel { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + val terms = glossaryTermService.findAllPaged(organizationId, glossaryId, pageable, search, languageTags?.toSet()) + return pagedAssembler.toModel(terms, simpleGlossaryTermModelAssembler) + } + + @GetMapping("/termsWithTranslations") + @Operation(summary = "Get all glossary terms with translations") + @AllowApiAccess(AuthTokenType.ONLY_PAT) + @UseDefaultPermissions + fun getAllWithTranslations( + @PathVariable organizationId: Long, + @PathVariable glossaryId: Long, + @ParameterObject pageable: Pageable, + @RequestParam("search", required = false) search: String?, + @RequestParam("languageTags", required = false) languageTags: List?, + ): PagedModel { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + val terms = + glossaryTermService.findAllWithTranslationsPaged( + organizationId, + glossaryId, + pageable, + search, + languageTags?.toSet(), + ) + return pagedAssembler.toModel(terms, simpleGlossaryTermWithTranslationsModelAssembler) + } + + @GetMapping("/termsIds") + @Operation(summary = "Get all glossary terms ids") + @AllowApiAccess(AuthTokenType.ONLY_PAT) + @UseDefaultPermissions + fun getAllIds( + @PathVariable organizationId: Long, + @PathVariable glossaryId: Long, + @RequestParam("search", required = false) search: String?, + @RequestParam("languageTags", required = false) languageTags: List?, + ): CollectionModel { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + val terms = glossaryTermService.findAllIds(organizationId, glossaryId, search, languageTags?.toSet()) + return CollectionModel.of(terms) + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryTermHighlightsController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryTermHighlightsController.kt new file mode 100644 index 0000000000..f926255845 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryTermHighlightsController.kt @@ -0,0 +1,58 @@ +package io.tolgee.ee.api.v2.controllers.glossary + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider +import io.tolgee.constants.Feature +import io.tolgee.ee.api.v2.hateoas.assemblers.glossary.GlossaryTermModelAssembler +import io.tolgee.ee.data.glossary.GlossaryTermHighlightDto +import io.tolgee.ee.service.glossary.GlossaryTermService +import io.tolgee.model.enums.Scope +import io.tolgee.openApiDocs.OpenApiUnstableOperationExtension +import io.tolgee.security.OrganizationHolder +import io.tolgee.security.ProjectHolder +import io.tolgee.security.authentication.AllowApiAccess +import io.tolgee.security.authorization.RequiresProjectPermissions +import org.springframework.hateoas.CollectionModel +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Suppress("SpringJavaInjectionPointsAutowiringInspection") +@RestController +@RequestMapping("/v2/projects/{projectId:[0-9]+}/glossary-highlights") +@OpenApiUnstableOperationExtension +@Tag(name = "Glossary term highlights") +class GlossaryTermHighlightsController( + private val projectHolder: ProjectHolder, + private val glossaryTermService: GlossaryTermService, + private val modelAssembler: GlossaryTermModelAssembler, + private val organizationHolder: OrganizationHolder, + private val enabledFeaturesProvider: EnabledFeaturesProvider, +) { + @GetMapping + @Operation(summary = "Returns glossary term highlights for specified text") + @RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW]) + @AllowApiAccess + fun getHighlights( + @RequestParam("text") + text: String, + @RequestParam("languageTag") + languageTag: String, + ): CollectionModel { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + return glossaryTermService.getHighlights( + organizationHolder.organization.id, + projectHolder.project.id, + text, + languageTag, + ).map { + GlossaryTermHighlightDto(it.position, modelAssembler.toModel(it.value.term)) + }.let { CollectionModel.of(it) } + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryTermTranslationController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryTermTranslationController.kt new file mode 100644 index 0000000000..30de62b1c0 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryTermTranslationController.kt @@ -0,0 +1,86 @@ +package io.tolgee.ee.api.v2.controllers.glossary + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider +import io.tolgee.constants.Feature +import io.tolgee.ee.api.v2.hateoas.assemblers.glossary.GlossaryTermTranslationModelAssembler +import io.tolgee.ee.api.v2.hateoas.model.glossary.GlossaryTermTranslationModel +import io.tolgee.ee.data.glossary.UpdateGlossaryTermTranslationRequest +import io.tolgee.ee.service.glossary.GlossaryTermService +import io.tolgee.ee.service.glossary.GlossaryTermTranslationService +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.security.OrganizationHolder +import io.tolgee.security.authentication.AllowApiAccess +import io.tolgee.security.authentication.AuthTokenType +import io.tolgee.security.authorization.RequiresOrganizationRole +import io.tolgee.security.authorization.UseDefaultPermissions +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.* + +@Suppress("SpringJavaInjectionPointsAutowiringInspection") +@RestController +@RequestMapping( + "/v2/organizations/{organizationId:[0-9]+}/glossaries/{glossaryId:[0-9]+}/terms/{termId:[0-9]+}/translations", +) +@Tag(name = "Glossary term translations") +class GlossaryTermTranslationController( + private val glossaryTermService: GlossaryTermService, + private val glossaryTermTranslationService: GlossaryTermTranslationService, + private val modelAssembler: GlossaryTermTranslationModelAssembler, + private val organizationHolder: OrganizationHolder, + private val enabledFeaturesProvider: EnabledFeaturesProvider, +) { + @PostMapping() + @Operation(summary = "Set a new glossary term translation for language") + @AllowApiAccess(AuthTokenType.ONLY_PAT) + @RequiresOrganizationRole(OrganizationRoleType.MAINTAINER) + @Transactional + fun update( + @PathVariable + organizationId: Long, + @PathVariable + glossaryId: Long, + @PathVariable + termId: Long, + @RequestBody + dto: UpdateGlossaryTermTranslationRequest, + ): GlossaryTermTranslationModel { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + val glossaryTerm = glossaryTermService.get(organizationId, glossaryId, termId) + val translation = glossaryTermTranslationService.updateOrCreate(glossaryTerm, dto) + return translation?.let { modelAssembler.toModel(translation) } ?: GlossaryTermTranslationModel.defaultValue( + dto.languageTag, + ) + } + + @GetMapping("/{languageTag}") + @Operation(summary = "Get glossary term translation for language") + @AllowApiAccess(AuthTokenType.ONLY_PAT) + @UseDefaultPermissions + fun get( + @PathVariable + organizationId: Long, + @PathVariable + glossaryId: Long, + @PathVariable + termId: Long, + @PathVariable + languageTag: String, + ): GlossaryTermTranslationModel { + enabledFeaturesProvider.checkFeatureEnabled( + organizationHolder.organization.id, + Feature.GLOSSARY, + ) + + val glossaryTerm = glossaryTermService.get(organizationId, glossaryId, termId) + val translation = glossaryTermTranslationService.find(glossaryTerm, languageTag) + return translation?.let { modelAssembler.toModel(translation) } ?: GlossaryTermTranslationModel.defaultValue( + languageTag, + ) + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/internal/DebugFeaturesController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/internal/DebugFeaturesController.kt index 3c2fb96ec4..4828b46e3f 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/internal/DebugFeaturesController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/internal/DebugFeaturesController.kt @@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +@Suppress("SpringJavaInjectionPointsAutowiringInspection") @RestController @CrossOrigin(origins = ["*"]) @Hidden diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/slack/OrganizationSlackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/slack/OrganizationSlackController.kt index df730e2b73..6a8e1f6cc4 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/slack/OrganizationSlackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/slack/OrganizationSlackController.kt @@ -24,6 +24,7 @@ import org.springframework.dao.DataIntegrityViolationException import org.springframework.hateoas.CollectionModel import org.springframework.web.bind.annotation.* +@Suppress("SpringJavaInjectionPointsAutowiringInspection") @RestController @CrossOrigin(origins = ["*"]) @RequestMapping(value = ["/v2/organizations/{organizationId:[0-9]+}/slack"]) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/slack/SlackLoginController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/slack/SlackLoginController.kt index 2441cae4ed..eb741da31e 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/slack/SlackLoginController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/slack/SlackLoginController.kt @@ -23,6 +23,7 @@ import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.util.Logging import org.springframework.web.bind.annotation.* +@Suppress("SpringJavaInjectionPointsAutowiringInspection") @RestController @CrossOrigin(origins = ["*"]) @RequestMapping(value = ["/v2/slack"]) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/slack/SlackSlashCommandController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/slack/SlackSlashCommandController.kt index ac3b01cdc3..a6bf11e959 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/slack/SlackSlashCommandController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/slack/SlackSlashCommandController.kt @@ -27,6 +27,7 @@ import io.tolgee.util.I18n import io.tolgee.util.Logging import org.springframework.web.bind.annotation.* +@Suppress("SpringJavaInjectionPointsAutowiringInspection") @RestController @CrossOrigin(origins = ["*"]) @RequestMapping(value = ["/v2/public/slack"]) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/GlossaryModelAssembler.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/GlossaryModelAssembler.kt new file mode 100644 index 0000000000..d7eb4f2079 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/GlossaryModelAssembler.kt @@ -0,0 +1,28 @@ +package io.tolgee.ee.api.v2.hateoas.assemblers.glossary + +import io.tolgee.ee.api.v2.controllers.glossary.GlossaryController +import io.tolgee.ee.api.v2.hateoas.model.glossary.GlossaryModel +import io.tolgee.hateoas.organization.SimpleOrganizationModelAssembler +import io.tolgee.hateoas.project.SimpleProjectModelAssembler +import io.tolgee.model.glossary.Glossary +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport +import org.springframework.stereotype.Component + +@Component +class GlossaryModelAssembler( + private val simpleOrganizationModelAssembler: SimpleOrganizationModelAssembler, + private val simpleProjectModelAssembler: SimpleProjectModelAssembler, +) : RepresentationModelAssemblerSupport( + GlossaryController::class.java, + GlossaryModel::class.java, + ) { + override fun toModel(entity: Glossary): GlossaryModel { + return GlossaryModel( + id = entity.id, + name = entity.name, + baseLanguageTag = entity.baseLanguageTag, + organizationOwner = simpleOrganizationModelAssembler.toModel(entity.organizationOwner), + assignedProjects = simpleProjectModelAssembler.toCollectionModel(entity.assignedProjects), + ) + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/GlossaryTermModelAssembler.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/GlossaryTermModelAssembler.kt new file mode 100644 index 0000000000..d036160d5c --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/GlossaryTermModelAssembler.kt @@ -0,0 +1,29 @@ +package io.tolgee.ee.api.v2.hateoas.assemblers.glossary + +import io.tolgee.ee.api.v2.controllers.glossary.GlossaryTermController +import io.tolgee.ee.api.v2.hateoas.model.glossary.GlossaryTermModel +import io.tolgee.model.glossary.GlossaryTerm +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport +import org.springframework.stereotype.Component + +@Component +class GlossaryTermModelAssembler( + private val glossaryModelAssembler: GlossaryModelAssembler, + private val glossaryTermTranslationModelAssembler: GlossaryTermTranslationModelAssembler, +) : RepresentationModelAssemblerSupport( + GlossaryTermController::class.java, + GlossaryTermModel::class.java, + ) { + override fun toModel(entity: GlossaryTerm): GlossaryTermModel { + return GlossaryTermModel( + id = entity.id, + glossary = glossaryModelAssembler.toModel(entity.glossary), + description = entity.description, + flagNonTranslatable = entity.flagNonTranslatable, + flagCaseSensitive = entity.flagCaseSensitive, + flagAbbreviation = entity.flagAbbreviation, + flagForbiddenTerm = entity.flagForbiddenTerm, + translations = entity.translations.map { glossaryTermTranslationModelAssembler.toModel(it) }, + ) + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/GlossaryTermTranslationModelAssembler.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/GlossaryTermTranslationModelAssembler.kt new file mode 100644 index 0000000000..f72f8b65c6 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/GlossaryTermTranslationModelAssembler.kt @@ -0,0 +1,21 @@ +package io.tolgee.ee.api.v2.hateoas.assemblers.glossary + +import io.tolgee.ee.api.v2.controllers.glossary.GlossaryTermController +import io.tolgee.ee.api.v2.hateoas.model.glossary.GlossaryTermTranslationModel +import io.tolgee.model.glossary.GlossaryTermTranslation +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport +import org.springframework.stereotype.Component + +@Component +class GlossaryTermTranslationModelAssembler : + RepresentationModelAssemblerSupport( + GlossaryTermController::class.java, + GlossaryTermTranslationModel::class.java, + ) { + override fun toModel(entity: GlossaryTermTranslation): GlossaryTermTranslationModel { + return GlossaryTermTranslationModel( + languageTag = entity.languageTag, + text = entity.text ?: "", + ) + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/SimpleGlossaryModelAssembler.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/SimpleGlossaryModelAssembler.kt new file mode 100644 index 0000000000..20984dd617 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/SimpleGlossaryModelAssembler.kt @@ -0,0 +1,24 @@ +package io.tolgee.ee.api.v2.hateoas.assemblers.glossary + +import io.tolgee.ee.api.v2.controllers.glossary.GlossaryController +import io.tolgee.ee.api.v2.hateoas.model.glossary.SimpleGlossaryModel +import io.tolgee.hateoas.project.SimpleProjectModelAssembler +import io.tolgee.model.glossary.Glossary +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport +import org.springframework.stereotype.Component + +@Component +class SimpleGlossaryModelAssembler( + private val simpleProjectModelAssembler: SimpleProjectModelAssembler, +) : RepresentationModelAssemblerSupport( + GlossaryController::class.java, + SimpleGlossaryModel::class.java, + ) { + override fun toModel(entity: Glossary): SimpleGlossaryModel = + SimpleGlossaryModel( + id = entity.id, + name = entity.name, + baseLanguageTag = entity.baseLanguageTag, + assignedProjects = simpleProjectModelAssembler.toCollectionModel(entity.assignedProjects), + ) +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/SimpleGlossaryTermModelAssembler.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/SimpleGlossaryTermModelAssembler.kt new file mode 100644 index 0000000000..ded73fa897 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/SimpleGlossaryTermModelAssembler.kt @@ -0,0 +1,25 @@ +package io.tolgee.ee.api.v2.hateoas.assemblers.glossary + +import io.tolgee.ee.api.v2.controllers.glossary.GlossaryTermController +import io.tolgee.ee.api.v2.hateoas.model.glossary.SimpleGlossaryTermModel +import io.tolgee.model.glossary.GlossaryTerm +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport +import org.springframework.stereotype.Component + +@Component +class SimpleGlossaryTermModelAssembler : + RepresentationModelAssemblerSupport( + GlossaryTermController::class.java, + SimpleGlossaryTermModel::class.java, + ) { + override fun toModel(entity: GlossaryTerm): SimpleGlossaryTermModel { + return SimpleGlossaryTermModel( + id = entity.id, + description = entity.description, + flagNonTranslatable = entity.flagNonTranslatable, + flagCaseSensitive = entity.flagCaseSensitive, + flagAbbreviation = entity.flagAbbreviation, + flagForbiddenTerm = entity.flagForbiddenTerm, + ) + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/SimpleGlossaryTermWithTranslationsModelAssembler.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/SimpleGlossaryTermWithTranslationsModelAssembler.kt new file mode 100644 index 0000000000..76c43f012d --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/glossary/SimpleGlossaryTermWithTranslationsModelAssembler.kt @@ -0,0 +1,27 @@ +package io.tolgee.ee.api.v2.hateoas.assemblers.glossary + +import io.tolgee.ee.api.v2.controllers.glossary.GlossaryTermController +import io.tolgee.ee.api.v2.hateoas.model.glossary.SimpleGlossaryTermWithTranslationsModel +import io.tolgee.model.glossary.GlossaryTerm +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport +import org.springframework.stereotype.Component + +@Component +class SimpleGlossaryTermWithTranslationsModelAssembler( + private val glossaryTermTranslationModelAssembler: GlossaryTermTranslationModelAssembler, +) : RepresentationModelAssemblerSupport( + GlossaryTermController::class.java, + SimpleGlossaryTermWithTranslationsModel::class.java, + ) { + override fun toModel(entity: GlossaryTerm): SimpleGlossaryTermWithTranslationsModel { + return SimpleGlossaryTermWithTranslationsModel( + id = entity.id, + description = entity.description, + flagNonTranslatable = entity.flagNonTranslatable, + flagCaseSensitive = entity.flagCaseSensitive, + flagAbbreviation = entity.flagAbbreviation, + flagForbiddenTerm = entity.flagForbiddenTerm, + translations = entity.translations.map { glossaryTermTranslationModelAssembler.toModel(it) }, + ) + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/GlossaryModel.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/GlossaryModel.kt new file mode 100644 index 0000000000..02b409efd2 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/GlossaryModel.kt @@ -0,0 +1,16 @@ +package io.tolgee.ee.api.v2.hateoas.model.glossary + +import io.tolgee.hateoas.organization.SimpleOrganizationModel +import io.tolgee.hateoas.project.SimpleProjectModel +import org.springframework.hateoas.CollectionModel +import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.core.Relation + +@Relation(collectionRelation = "glossaries", itemRelation = "glossary") +class GlossaryModel( + val id: Long, + val name: String, + val baseLanguageTag: String?, + val organizationOwner: SimpleOrganizationModel, + val assignedProjects: CollectionModel, +) : RepresentationModel() diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/GlossaryTermModel.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/GlossaryTermModel.kt new file mode 100644 index 0000000000..0ff2cd0863 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/GlossaryTermModel.kt @@ -0,0 +1,16 @@ +package io.tolgee.ee.api.v2.hateoas.model.glossary + +import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.core.Relation + +@Relation(collectionRelation = "glossaryTerms", itemRelation = "glossaryTerm") +class GlossaryTermModel( + val id: Long, + val glossary: GlossaryModel, + val description: String?, + val flagNonTranslatable: Boolean, + val flagCaseSensitive: Boolean, + val flagAbbreviation: Boolean, + val flagForbiddenTerm: Boolean, + val translations: List, +) : RepresentationModel() diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/GlossaryTermTranslationModel.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/GlossaryTermTranslationModel.kt new file mode 100644 index 0000000000..9601be0cac --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/GlossaryTermTranslationModel.kt @@ -0,0 +1,14 @@ +package io.tolgee.ee.api.v2.hateoas.model.glossary + +import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.core.Relation + +@Relation(collectionRelation = "glossaryTermTranslations", itemRelation = "glossaryTermTranslation") +class GlossaryTermTranslationModel( + val languageTag: String, + val text: String, +) : RepresentationModel() { + companion object { + fun defaultValue(languageTag: String) = GlossaryTermTranslationModel(languageTag, "") + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/SimpleGlossaryModel.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/SimpleGlossaryModel.kt new file mode 100644 index 0000000000..f01fbd3f12 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/SimpleGlossaryModel.kt @@ -0,0 +1,14 @@ +package io.tolgee.ee.api.v2.hateoas.model.glossary + +import io.tolgee.hateoas.project.SimpleProjectModel +import org.springframework.hateoas.CollectionModel +import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.core.Relation + +@Relation(collectionRelation = "glossaries", itemRelation = "glossary") +class SimpleGlossaryModel( + val id: Long, + val name: String, + val baseLanguageTag: String?, + val assignedProjects: CollectionModel, +) : RepresentationModel() diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/SimpleGlossaryTermModel.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/SimpleGlossaryTermModel.kt new file mode 100644 index 0000000000..ea5e752c56 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/SimpleGlossaryTermModel.kt @@ -0,0 +1,14 @@ +package io.tolgee.ee.api.v2.hateoas.model.glossary + +import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.core.Relation + +@Relation(collectionRelation = "glossaryTerms", itemRelation = "glossaryTerm") +class SimpleGlossaryTermModel( + val id: Long, + val description: String?, + val flagNonTranslatable: Boolean, + val flagCaseSensitive: Boolean, + val flagAbbreviation: Boolean, + val flagForbiddenTerm: Boolean, +) : RepresentationModel() diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/SimpleGlossaryTermWithTranslationsModel.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/SimpleGlossaryTermWithTranslationsModel.kt new file mode 100644 index 0000000000..4b11d63a8c --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/model/glossary/SimpleGlossaryTermWithTranslationsModel.kt @@ -0,0 +1,15 @@ +package io.tolgee.ee.api.v2.hateoas.model.glossary + +import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.core.Relation + +@Relation(collectionRelation = "glossaryTerms", itemRelation = "glossaryTerm") +class SimpleGlossaryTermWithTranslationsModel( + val id: Long, + val description: String?, + val flagNonTranslatable: Boolean, + val flagCaseSensitive: Boolean, + val flagAbbreviation: Boolean, + val flagForbiddenTerm: Boolean, + val translations: List, +) : RepresentationModel() diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/contentDelivery/ContentStorageProviderEeImpl.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/contentDelivery/ContentStorageProviderEeImpl.kt index 3891eed9f0..40c4a8d1a3 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/contentDelivery/ContentStorageProviderEeImpl.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/contentDelivery/ContentStorageProviderEeImpl.kt @@ -9,6 +9,7 @@ import io.tolgee.service.project.ProjectService import org.springframework.context.annotation.Primary import org.springframework.stereotype.Component +@Suppress("SpringJavaInjectionPointsAutowiringInspection") @Component @Primary class ContentStorageProviderEeImpl( diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/machineTranslation/CloudTolgeeTranslateApiServiceImpl.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/machineTranslation/CloudTolgeeTranslateApiServiceImpl.kt index 1157e83535..d1e4734451 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/machineTranslation/CloudTolgeeTranslateApiServiceImpl.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/machineTranslation/CloudTolgeeTranslateApiServiceImpl.kt @@ -43,6 +43,18 @@ class CloudTolgeeTranslateApiServiceImpl( val closeItems = params.metadata?.closeItems?.map { item -> TolgeeTranslateExample(item.key, item.source, item.target) } val examples = params.metadata?.examples?.map { item -> TolgeeTranslateExample(item.key, item.source, item.target) } + val glossary = + params.metadata?.glossaryTerms?.map { item -> + TolgeeTranslateGlossaryItem( + item.source, + item.target, + item.description, + item.isNonTranslatable, + item.isCaseSensitive, + item.isAbbreviation, + item.isForbiddenTerm, + ) + } val requestBody = TolgeeTranslateRequest( @@ -52,6 +64,7 @@ class CloudTolgeeTranslateApiServiceImpl( params.sourceTag, params.targetTag, examples, + glossary, closeItems, priority = if (params.isBatch) "low" else "high", params.formality, @@ -130,6 +143,7 @@ class CloudTolgeeTranslateApiServiceImpl( val source: String, val target: String?, val examples: List?, + val glossary: List?, val closeItems: List?, val priority: String = "low", val formality: Formality? = null, @@ -145,6 +159,16 @@ class CloudTolgeeTranslateApiServiceImpl( var target: String, ) + class TolgeeTranslateGlossaryItem( + var source: String, + var target: String? = null, + var description: String? = null, + var isNonTranslatable: Boolean? = null, + var isCaseSensitive: Boolean? = null, + var isAbbreviation: Boolean? = null, + var isForbiddenTerm: Boolean? = null, + ) + class TolgeeTranslateResponse(val output: String, val contextDescription: String?) } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/CreateGlossaryRequest.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/CreateGlossaryRequest.kt new file mode 100644 index 0000000000..cecd9ee2fc --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/CreateGlossaryRequest.kt @@ -0,0 +1,24 @@ +package io.tolgee.ee.data.glossary + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size + +class CreateGlossaryRequest { + @Schema(example = "My glossary", description = "Glossary name") + @field:NotBlank + @field:Size(max = 50) + var name: String = "" + + @Schema(example = "cs-CZ", description = "Language tag according to BCP 47 definition") + @field:NotBlank + @field:Size(max = 20) + @field:Pattern(regexp = "^[^,]*$", message = "can not contain coma") + var baseLanguageTag: String? = null + + @Schema(description = "Projects assigned to glossary") + @field:NotNull + var assignedProjects: MutableSet? = null +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/CreateGlossaryTermRequest.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/CreateGlossaryTermRequest.kt new file mode 100644 index 0000000000..45d7d14909 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/CreateGlossaryTermRequest.kt @@ -0,0 +1,18 @@ +package io.tolgee.ee.data.glossary + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Size + +open class CreateGlossaryTermRequest { + @Schema(example = "", description = "Glossary term description") + @field:Size(max = 500) + var description: String? = null + + var flagNonTranslatable: Boolean = false + + var flagCaseSensitive: Boolean = false + + var flagAbbreviation: Boolean = false + + var flagForbiddenTerm: Boolean = false +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/CreateGlossaryTermWithTranslationRequest.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/CreateGlossaryTermWithTranslationRequest.kt new file mode 100644 index 0000000000..e66c2a001c --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/CreateGlossaryTermWithTranslationRequest.kt @@ -0,0 +1,8 @@ +package io.tolgee.ee.data.glossary + +import jakarta.validation.constraints.Size + +class CreateGlossaryTermWithTranslationRequest : CreateGlossaryTermRequest() { + @field:Size(min = 0, max = 50) + var text: String = "" +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/CreateUpdateGlossaryTermResponse.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/CreateUpdateGlossaryTermResponse.kt new file mode 100644 index 0000000000..ed511d1b92 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/CreateUpdateGlossaryTermResponse.kt @@ -0,0 +1,9 @@ +package io.tolgee.ee.data.glossary + +import io.tolgee.ee.api.v2.hateoas.model.glossary.GlossaryTermTranslationModel +import io.tolgee.ee.api.v2.hateoas.model.glossary.SimpleGlossaryTermModel + +data class CreateUpdateGlossaryTermResponse( + val term: SimpleGlossaryTermModel, + val translation: GlossaryTermTranslationModel?, +) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/DeleteMultipleGlossaryTermsRequest.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/DeleteMultipleGlossaryTermsRequest.kt new file mode 100644 index 0000000000..3c97b187ad --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/DeleteMultipleGlossaryTermsRequest.kt @@ -0,0 +1,5 @@ +package io.tolgee.ee.data.glossary + +open class DeleteMultipleGlossaryTermsRequest { + var termIds: Set = setOf() +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/GlossaryLanguageDto.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/GlossaryLanguageDto.kt new file mode 100644 index 0000000000..c2a698c2b4 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/GlossaryLanguageDto.kt @@ -0,0 +1,6 @@ +package io.tolgee.ee.data.glossary + +data class GlossaryLanguageDto( + val tag: String, + val base: Boolean, +) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/GlossaryTermHighlight.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/GlossaryTermHighlight.kt new file mode 100644 index 0000000000..69b5e0f560 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/GlossaryTermHighlight.kt @@ -0,0 +1,8 @@ +package io.tolgee.ee.data.glossary + +import io.tolgee.model.glossary.GlossaryTermTranslation + +data class GlossaryTermHighlight( + val position: Position, + val value: GlossaryTermTranslation, +) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/GlossaryTermHighlightDto.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/GlossaryTermHighlightDto.kt new file mode 100644 index 0000000000..064eae1e25 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/GlossaryTermHighlightDto.kt @@ -0,0 +1,8 @@ +package io.tolgee.ee.data.glossary + +import io.tolgee.ee.api.v2.hateoas.model.glossary.GlossaryTermModel + +data class GlossaryTermHighlightDto( + val position: Position, + val value: GlossaryTermModel, +) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/GlossaryTermWithTranslationsView.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/GlossaryTermWithTranslationsView.kt new file mode 100644 index 0000000000..80ea99307a --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/GlossaryTermWithTranslationsView.kt @@ -0,0 +1,13 @@ +package io.tolgee.ee.data.glossary + +import io.tolgee.model.glossary.GlossaryTermTranslation + +interface GlossaryTermWithTranslationsView { + val id: Long + val description: String? + val flagNonTranslatable: Boolean + val flagCaseSensitive: Boolean + val flagAbbreviation: Boolean + val flagForbiddenTerm: Boolean + val translations: Set? +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/Position.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/Position.kt new file mode 100644 index 0000000000..b0ea57eb53 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/Position.kt @@ -0,0 +1,6 @@ +package io.tolgee.ee.data.glossary + +data class Position( + val start: Int, + val end: Int, +) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/UpdateGlossaryRequest.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/UpdateGlossaryRequest.kt new file mode 100644 index 0000000000..d4cd22bf1e --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/UpdateGlossaryRequest.kt @@ -0,0 +1,24 @@ +package io.tolgee.ee.data.glossary + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.annotation.Nullable +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size + +class UpdateGlossaryRequest { + @Schema(example = "My glossary", description = "Glossary name") + @field:NotBlank + @field:Size(max = 50) + var name: String = "" + + @Schema(example = "cs-CZ", description = "Language tag according to BCP 47 definition") + @field:NotBlank + @field:Size(max = 20) + @field:Pattern(regexp = "^[^,]*$", message = "can not contain coma") + var baseLanguageTag: String? = null + + @Schema(description = "Projects assigned to glossary; when null, assigned projects will be kept unchanged.") + @field:Nullable + var assignedProjects: MutableSet? = null +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/UpdateGlossaryTermRequest.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/UpdateGlossaryTermRequest.kt new file mode 100644 index 0000000000..13f9b0522e --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/UpdateGlossaryTermRequest.kt @@ -0,0 +1,16 @@ +package io.tolgee.ee.data.glossary + +import jakarta.validation.constraints.Size + +open class UpdateGlossaryTermRequest { + @field:Size(max = 500) + var description: String? = null + + var flagNonTranslatable: Boolean? = null + + var flagCaseSensitive: Boolean? = null + + var flagAbbreviation: Boolean? = null + + var flagForbiddenTerm: Boolean? = null +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/UpdateGlossaryTermTranslationRequest.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/UpdateGlossaryTermTranslationRequest.kt new file mode 100644 index 0000000000..7cc506d067 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/UpdateGlossaryTermTranslationRequest.kt @@ -0,0 +1,18 @@ +package io.tolgee.ee.data.glossary + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size + +class UpdateGlossaryTermTranslationRequest { + @Schema(example = "Translated text to language of languageTag", description = "Translation text") + @field:Size(max = 50) + var text: String = "" + + @Schema(example = "cs-CZ", description = "Language tag according to BCP 47 definition") + @field:NotBlank + @field:Size(max = 20) + @field:Pattern(regexp = "^[^,]*$", message = "can not contain coma") + var languageTag: String = "" +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/UpdateGlossaryTermWithTranslationRequest.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/UpdateGlossaryTermWithTranslationRequest.kt new file mode 100644 index 0000000000..a0aef95274 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/glossary/UpdateGlossaryTermWithTranslationRequest.kt @@ -0,0 +1,8 @@ +package io.tolgee.ee.data.glossary + +import jakarta.validation.constraints.Size + +class UpdateGlossaryTermWithTranslationRequest : UpdateGlossaryTermRequest() { + @field:Size(min = 0, max = 50) + var text: String? = null +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryRepository.kt new file mode 100644 index 0000000000..5d8508ec89 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryRepository.kt @@ -0,0 +1,89 @@ +package io.tolgee.ee.repository.glossary + +import io.tolgee.model.glossary.Glossary +import org.springframework.context.annotation.Lazy +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository +import java.util.Date + +@Repository +@Lazy +interface GlossaryRepository : JpaRepository { + @Query( + """ + from Glossary + where organizationOwner.id = :organizationId + and organizationOwner.deletedAt is null + and id = :glossaryId + and deletedAt is null + """, + ) + fun find( + organizationId: Long, + glossaryId: Long, + ): Glossary? + + @Query( + """ + from Glossary + where organizationOwner.id = :organizationId + and organizationOwner.deletedAt is null + and deletedAt is null + """, + ) + fun findByOrganizationId(organizationId: Long): List + + @Query( + """ + from Glossary + where organizationOwner.id = :organizationId + and organizationOwner.deletedAt is null + and deletedAt is null + and (lower(name) like lower(concat('%', coalesce(:search, ''), '%')) or :search is null) + """, + ) + fun findByOrganizationIdPaged( + organizationId: Long, + pageable: Pageable, + search: String?, + ): Page + + @Query( + """ + delete from glossary_project gp + using glossary g + where gp.glossary_id = :glossaryId + and gp.project_id = :projectId + and gp.glossary_id = g.id + and g.organization_owner_id = :organizationId + and g.deleted_at is null + """, + nativeQuery = true, + ) + @Modifying + fun unassignProject( + organizationId: Long, + glossaryId: Long, + projectId: Long, + ): Int + + @Query( + """ + update Glossary + set deletedAt = :deletedAt + where organizationOwner.id = :organizationId + and id = :glossaryId + and deletedAt is null + """, + ) + @Modifying + fun softDelete( + organizationId: Long, + glossaryId: Long, + deletedAt: Date, + ): Int +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryTermRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryTermRepository.kt new file mode 100644 index 0000000000..5defc01e76 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryTermRepository.kt @@ -0,0 +1,124 @@ +package io.tolgee.ee.repository.glossary + +import io.tolgee.model.glossary.Glossary +import io.tolgee.model.glossary.GlossaryTerm +import org.springframework.context.annotation.Lazy +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +@Repository +@Lazy +interface GlossaryTermRepository : JpaRepository { + @Query( + """ + from GlossaryTerm + where glossary.organizationOwner.id = :organizationId + and glossary.organizationOwner.deletedAt is null + and glossary.id = :glossaryId + and glossary.deletedAt is null + and id = :id + """, + ) + fun find( + organizationId: Long, + glossaryId: Long, + id: Long, + ): GlossaryTerm? + + fun findByGlossary(glossary: Glossary): List + + @Query( + """ + from GlossaryTerm te + left join GlossaryTermTranslation tr on tr.term.id = te.id + and tr.languageTag = te.glossary.baseLanguageTag + and (:languageTags is null or tr.languageTag in :languageTags) + where te.glossary.organizationOwner.id = :organizationId + and te.glossary.organizationOwner.deletedAt is null + and te.glossary.id = :glossaryId + and te.glossary.deletedAt is null + and (:search is null or + lower(te.description) like lower(concat('%', coalesce(:search, ''), '%')) or + lower(tr.text) like lower(concat('%', coalesce(:search, '') , '%')) + ) + """, + ) + fun findPaged( + organizationId: Long, + glossaryId: Long, + pageable: Pageable, + search: String?, + languageTags: Set?, + ): Page + + @Query( + """ + from GlossaryTerm te + left join GlossaryTermTranslation tr on tr.term.id = te.id + and tr.languageTag = te.glossary.baseLanguageTag + and (:languageTags is null or tr.languageTag in :languageTags) + where te.glossary = :glossary + and ( + :search is null or + lower(te.description) like lower(concat('%', coalesce(:search, ''), '%')) or + lower(tr.text) like lower(concat('%', coalesce(:search, ''), '%')) + ) + """, + ) + fun findByGlossaryPaged( + glossary: Glossary, + pageable: Pageable, + search: String?, + languageTags: Set?, + ): Page + + @Query( + """ + from GlossaryTerm te + join fetch te.translations + left join GlossaryTermTranslation tr on tr.term.id = te.id + and tr.languageTag = te.glossary.baseLanguageTag + and (:languageTags is null or tr.languageTag in :languageTags) + where te.glossary = :glossary + and ( + :search is null or + lower(te.description) like lower(concat('%', coalesce(:search, ''), '%')) or + lower(tr.text) like lower(concat('%', coalesce(:search, ''), '%')) + ) + """, + ) + fun findByGlossaryWithTranslationsPaged( + glossary: Glossary, + pageable: Pageable, + search: String?, + languageTags: Set?, + ): Page + + @Query( + """ + select te.id from GlossaryTerm te + left join GlossaryTermTranslation tr on tr.term.id = te.id + and tr.languageTag = te.glossary.baseLanguageTag + and (:languageTags is null or tr.languageTag in :languageTags) + where te.glossary = :glossary + and ( + :search is null or + lower(te.description) like lower(concat('%', coalesce(:search, ''), '%')) or + lower(tr.text) like lower(concat('%', coalesce(:search, ''), '%')) + ) + """, + ) + fun findAllIds( + glossary: Glossary, + search: String?, + languageTags: Set?, + ): List + + fun deleteByGlossaryAndIdIn( + glossary: Glossary, + ids: Collection, + ) +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryTermTranslationRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryTermTranslationRepository.kt new file mode 100644 index 0000000000..11aba89d30 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryTermTranslationRepository.kt @@ -0,0 +1,87 @@ +package io.tolgee.ee.repository.glossary + +import io.tolgee.model.glossary.Glossary +import io.tolgee.model.glossary.GlossaryTerm +import io.tolgee.model.glossary.GlossaryTermTranslation +import org.springframework.context.annotation.Lazy +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +@Repository +@Lazy +interface GlossaryTermTranslationRepository : JpaRepository { + @Query( + """ + select distinct t.languageTag + from GlossaryTermTranslation t + where t.term.glossary.id = :glossaryId + and t.term.glossary.deletedAt is null + and t.term.glossary.organizationOwner.id = :organizationId + and t.term.glossary.organizationOwner.deletedAt is null + """, + ) + fun findDistinctLanguageTagsByGlossary( + organizationId: Long, + glossaryId: Long, + ): Set + + @Query( + """ + from GlossaryTermTranslation gtt + where + gtt.term = :term and + (gtt.languageTag = :languageTag or gtt.term.flagNonTranslatable) + """, + ) + fun findByTermAndLanguageTag( + term: GlossaryTerm, + languageTag: String, + ): GlossaryTermTranslation? + + fun deleteByTermAndLanguageTag( + term: GlossaryTerm, + languageTag: String, + ) + + fun deleteAllByTermAndLanguageTagIsNot( + term: GlossaryTerm, + languageTag: String, + ) + + @Query( + """ + from GlossaryTermTranslation gtt + join gtt.term.glossary.assignedProjects ap + where + gtt.firstWordLowercased in :texts and + (gtt.languageTag = :languageTag or gtt.term.flagNonTranslatable) and + ap.id = :assignedProjectId and + gtt.term.glossary.organizationOwner.id = :organizationId and + gtt.term.glossary.deletedAt is null + """, + ) + fun findByFirstWordLowercasedAndLanguageTagAndAssignedProjectIdAndOrganizationId( + texts: Collection, + languageTag: String, + assignedProjectId: Long, + organizationId: Long, + ): Set + + @Modifying + @Query( + """ + update GlossaryTermTranslation gtt + set gtt.languageTag = :newBaseLanguageTag + where gtt.term.glossary = :glossary + and gtt.languageTag = :oldBaseLanguageTag + and gtt.term.flagNonTranslatable = true + """, + ) + fun updateBaseLanguage( + glossary: Glossary, + oldBaseLanguageTag: String?, + newBaseLanguageTag: String?, + ) +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/security/thirdParty/SsoDelegateEe.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/security/thirdParty/SsoDelegateEe.kt index 000bb2765b..7bfa59b7a9 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/security/thirdParty/SsoDelegateEe.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/security/thirdParty/SsoDelegateEe.kt @@ -41,6 +41,7 @@ import org.springframework.web.client.RestClientException import org.springframework.web.client.RestTemplate import java.util.* +@Suppress("SpringJavaInjectionPointsAutowiringInspection") @Primary @Component @Order(Ordered.HIGHEST_PRECEDENCE) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/EeInvitationServiceEeImpl.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/EeInvitationServiceEeImpl.kt index 81ea7a7c45..9bbe8286df 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/EeInvitationServiceEeImpl.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/EeInvitationServiceEeImpl.kt @@ -10,6 +10,7 @@ import org.springframework.context.annotation.Primary import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +@Suppress("SpringJavaInjectionPointsAutowiringInspection") @Service @Primary class EeInvitationServiceEeImpl( diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryService.kt new file mode 100644 index 0000000000..ccea7d0df7 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryService.kt @@ -0,0 +1,135 @@ +package io.tolgee.ee.service.glossary + +import io.tolgee.component.CurrentDateProvider +import io.tolgee.constants.Message +import io.tolgee.ee.data.glossary.CreateGlossaryRequest +import io.tolgee.ee.data.glossary.UpdateGlossaryRequest +import io.tolgee.ee.repository.glossary.GlossaryRepository +import io.tolgee.exceptions.NotFoundException +import io.tolgee.model.Organization +import io.tolgee.model.glossary.Glossary +import io.tolgee.service.project.ProjectService +import jakarta.transaction.Transactional +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service + +@Service +class GlossaryService( + private val glossaryRepository: GlossaryRepository, + private val glossaryTermTranslationService: GlossaryTermTranslationService, + private val projectService: ProjectService, + private val currentDateProvider: CurrentDateProvider, +) { + fun findAll(organizationId: Long): List { + return glossaryRepository.findByOrganizationId(organizationId) + } + + fun findAllPaged( + organizationId: Long, + pageable: Pageable, + search: String?, + ): Page { + return glossaryRepository.findByOrganizationIdPaged(organizationId, pageable, search) + } + + fun find( + organizationId: Long, + glossaryId: Long, + ): Glossary? { + return glossaryRepository.find(organizationId, glossaryId) + } + + fun get( + organizationId: Long, + glossaryId: Long, + ): Glossary { + return find(organizationId, glossaryId) ?: throw NotFoundException(Message.GLOSSARY_NOT_FOUND) + } + + @Transactional + fun create( + organization: Organization, + dto: CreateGlossaryRequest, + ): Glossary { + val glossary = + Glossary( + name = dto.name, + ).apply { + organizationOwner = organization + baseLanguageTag = dto.baseLanguageTag + + updateAssignedProjects(this, dto.assignedProjects ?: emptySet()) + } + return glossaryRepository.save(glossary) + } + + @Transactional + fun update( + organizationId: Long, + glossaryId: Long, + dto: UpdateGlossaryRequest, + ): Glossary { + val glossary = get(organizationId, glossaryId) + glossary.name = dto.name + if (dto.baseLanguageTag != glossary.baseLanguageTag) { + glossaryTermTranslationService.updateBaseLanguage( + glossary, + glossary.baseLanguageTag, + dto.baseLanguageTag, + ) + } + glossary.baseLanguageTag = dto.baseLanguageTag + val newAssignedProjects = dto.assignedProjects + if (newAssignedProjects != null) { + updateAssignedProjects(glossary, newAssignedProjects) + } + return glossaryRepository.save(glossary) + } + + private fun updateAssignedProjects( + glossary: Glossary, + newAssignedProjects: Iterable, + ) { + glossary.assignedProjects.clear() + val projects = projectService.findAll(newAssignedProjects) + projects.forEach { + if (it.organizationOwner.id != glossary.organizationOwner.id) { + // Project belongs to another organization + throw NotFoundException(Message.PROJECT_NOT_FOUND) + } + } + glossary.assignedProjects.addAll(projects) + } + + @Transactional + fun delete( + organizationId: Long, + glossaryId: Long, + ) { + glossaryRepository.softDelete(organizationId, glossaryId, currentDateProvider.date) + } + + fun assignProject( + organizationId: Long, + glossaryId: Long, + projectId: Long, + ) { + val glossary = get(organizationId, glossaryId) + val project = projectService.get(projectId) + if (project.organizationOwner.id != organizationId) { + // Project belongs to another organization + throw NotFoundException(Message.PROJECT_NOT_FOUND) + } + glossary.assignedProjects.add(project) + glossaryRepository.save(glossary) + } + + fun unassignProject( + organizationId: Long, + glossaryId: Long, + projectId: Long, + ) { + glossaryRepository.unassignProject(organizationId, glossaryId, projectId) + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryTermService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryTermService.kt new file mode 100644 index 0000000000..ac09f3d1e7 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryTermService.kt @@ -0,0 +1,269 @@ +package io.tolgee.ee.service.glossary + +import io.tolgee.component.machineTranslation.metadata.TranslationGlossaryItem +import io.tolgee.constants.Message +import io.tolgee.dtos.cacheable.ProjectDto +import io.tolgee.ee.data.glossary.* +import io.tolgee.ee.repository.glossary.GlossaryTermRepository +import io.tolgee.exceptions.NotFoundException +import io.tolgee.model.glossary.Glossary +import io.tolgee.model.glossary.GlossaryTerm +import io.tolgee.model.glossary.GlossaryTermTranslation +import io.tolgee.model.glossary.GlossaryTermTranslation.Companion.WORD_REGEX +import io.tolgee.service.machineTranslation.MtGlossaryTermsProvider +import io.tolgee.util.findAll +import jakarta.transaction.Transactional +import org.springframework.context.annotation.Primary +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import java.util.* + +@Primary +@Service +class GlossaryTermService( + private val glossaryTermRepository: GlossaryTermRepository, + private val glossaryService: GlossaryService, + private val glossaryTermTranslationService: GlossaryTermTranslationService, +) : MtGlossaryTermsProvider { + fun find( + organizationId: Long, + glossaryId: Long, + termId: Long, + ): GlossaryTerm? { + return glossaryTermRepository.find(organizationId, glossaryId, termId) + } + + fun findAll( + organizationId: Long, + glossaryId: Long, + ): List { + val glossary = glossaryService.get(organizationId, glossaryId) + return glossaryTermRepository.findByGlossary(glossary) + } + + fun findAllPaged( + organizationId: Long, + glossaryId: Long, + pageable: Pageable, + search: String?, + languageTags: Set?, + ): Page { + val glossary = glossaryService.get(organizationId, glossaryId) + return glossaryTermRepository.findByGlossaryPaged(glossary, pageable, search, languageTags) + } + + fun findAllWithTranslationsPaged( + organizationId: Long, + glossaryId: Long, + pageable: Pageable, + search: String?, + languageTags: Set?, + ): Page { + val glossary = glossaryService.get(organizationId, glossaryId) + return glossaryTermRepository.findByGlossaryWithTranslationsPaged(glossary, pageable, search, languageTags) + } + + fun findAllIds( + organizationId: Long, + glossaryId: Long, + search: String?, + languageTags: Set?, + ): List { + val glossary = glossaryService.get(organizationId, glossaryId) + return glossaryTermRepository.findAllIds(glossary, search, languageTags) + } + + fun get( + organizationId: Long, + glossaryId: Long, + termId: Long, + ): GlossaryTerm { + return find(organizationId, glossaryId, termId) + ?: throw NotFoundException(Message.GLOSSARY_TERM_NOT_FOUND) + } + + fun create( + organizationId: Long, + glossaryId: Long, + request: CreateGlossaryTermRequest, + ): GlossaryTerm { + val glossary = glossaryService.get(organizationId, glossaryId) + val glossaryTerm = + GlossaryTerm( + description = request.description, + ).apply { + this.glossary = glossary + description = request.description + + flagNonTranslatable = request.flagNonTranslatable + flagCaseSensitive = request.flagCaseSensitive + flagAbbreviation = request.flagAbbreviation + flagForbiddenTerm = request.flagForbiddenTerm + } + return glossaryTermRepository.save(glossaryTerm) + } + + fun createWithTranslation( + organizationId: Long, + glossaryId: Long, + request: CreateGlossaryTermWithTranslationRequest, + ): Pair { + val glossary = glossaryService.get(organizationId, glossaryId) + val glossaryTerm = create(organizationId, glossaryId, request) + val translation = + UpdateGlossaryTermTranslationRequest().apply { + languageTag = glossary.baseLanguageTag!! + text = request.text + } + return glossaryTerm to + glossaryTermTranslationService.create( + glossaryTerm, + translation, + ) + } + + fun update( + organizationId: Long, + glossaryId: Long, + termId: Long, + dto: UpdateGlossaryTermRequest, + ): GlossaryTerm { + val glossaryTerm = get(organizationId, glossaryId, termId) + if (!glossaryTerm.flagNonTranslatable && dto.flagNonTranslatable == true) { + glossaryTermTranslationService.deleteAllNonBaseTranslations(glossaryTerm) + } + glossaryTerm.apply { + description = dto.description + flagNonTranslatable = dto.flagNonTranslatable ?: flagNonTranslatable + flagCaseSensitive = dto.flagCaseSensitive ?: flagCaseSensitive + flagAbbreviation = dto.flagAbbreviation ?: flagAbbreviation + flagForbiddenTerm = dto.flagForbiddenTerm ?: flagForbiddenTerm + } + return glossaryTermRepository.save(glossaryTerm) + } + + fun updateWithTranslation( + organizationId: Long, + glossaryId: Long, + termId: Long, + request: UpdateGlossaryTermWithTranslationRequest, + ): Pair { + val glossary = glossaryService.get(organizationId, glossaryId) + val glossaryTerm = update(organizationId, glossaryId, termId, request) + val translationText = request.text + if (translationText == null) { + return glossaryTerm to + glossaryTermTranslationService.find( + glossaryTerm, + glossary.baseLanguageTag!!, + ) + } + + val translation = + UpdateGlossaryTermTranslationRequest().apply { + languageTag = glossary.baseLanguageTag!! + text = translationText + } + return glossaryTerm to + glossaryTermTranslationService.updateOrCreate( + glossaryTerm, + translation, + ) + } + + fun delete( + organizationId: Long, + glossaryId: Long, + termId: Long, + ) { + val glossaryTerm = get(organizationId, glossaryId, termId) + delete(glossaryTerm) + } + + fun delete(glossaryTerm: GlossaryTerm) { + glossaryTermRepository.delete(glossaryTerm) + } + + fun deleteMultiple( + organizationId: Long, + glossaryId: Long, + termIds: Collection, + ) { + val glossary = glossaryService.get(organizationId, glossaryId) + deleteMultiple(glossary, termIds) + } + + fun deleteMultiple( + glossary: Glossary, + termIds: Collection, + ) { + glossaryTermRepository.deleteByGlossaryAndIdIn(glossary, termIds) + } + + fun getHighlights( + organizationId: Long, + projectId: Long, + text: String, + languageTag: String, + ): Set { + val words = text.findAll(WORD_REGEX).filter { it.isNotEmpty() }.toSet() + val translations = glossaryTermTranslationService.findAll(organizationId, projectId, words, languageTag) + + val locale = Locale.forLanguageTag(languageTag) ?: Locale.ROOT + val textLowercased = text.lowercase(locale) + + return translations.flatMap { translation -> + findTranslationPositions(text, textLowercased, translation, locale).map { position -> + GlossaryTermHighlight(position, translation) + } + }.toSet() + } + + private fun findTranslationPositions( + textOriginal: String, + textLowercased: String, + translation: GlossaryTermTranslation, + locale: Locale, + ): Sequence { + val term = translation.text ?: return emptySequence() + if (translation.term.flagCaseSensitive) { + return findPositions(textOriginal, term) + } + + val termLowercase = term.lowercase(locale) + return findPositions(textLowercased, termLowercase) + } + + private fun findPositions( + text: String, + search: String, + ): Sequence { + val regex = Regex.escape(search).toRegex() + val matches = regex.findAll(text) + return matches.map { Position(it.range.first, it.range.last + 1) } + } + + @Transactional + override fun glossaryTermsFor( + project: ProjectDto, + sourceLanguageTag: String, + targetLanguageTag: String, + text: String, + ): Set = + getHighlights(project.organizationOwnerId, project.id, text, sourceLanguageTag) + .filter { !it.value.text.isNullOrEmpty() } + .map { + val term = it.value.term + val targetTranslation = term.translations.find { it.languageTag == targetLanguageTag } + TranslationGlossaryItem( + source = it.value.text ?: "", + target = targetTranslation?.text, + description = term.description, + isNonTranslatable = term.flagNonTranslatable, + isCaseSensitive = term.flagCaseSensitive, + isAbbreviation = term.flagAbbreviation, + isForbiddenTerm = term.flagForbiddenTerm, + ) + }.toSet() +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryTermTranslationService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryTermTranslationService.kt new file mode 100644 index 0000000000..f3709c2c03 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryTermTranslationService.kt @@ -0,0 +1,110 @@ +package io.tolgee.ee.service.glossary + +import io.tolgee.constants.Message +import io.tolgee.ee.data.glossary.UpdateGlossaryTermTranslationRequest +import io.tolgee.ee.repository.glossary.GlossaryTermTranslationRepository +import io.tolgee.exceptions.BadRequestException +import io.tolgee.exceptions.NotFoundException +import io.tolgee.model.glossary.Glossary +import io.tolgee.model.glossary.GlossaryTerm +import io.tolgee.model.glossary.GlossaryTermTranslation +import org.springframework.stereotype.Service +import java.util.* + +@Service +class GlossaryTermTranslationService( + private val glossaryTermTranslationRepository: GlossaryTermTranslationRepository, +) { + fun getDistinctLanguageTags( + organizationId: Long, + glossaryId: Long, + ): Set { + return glossaryTermTranslationRepository.findDistinctLanguageTagsByGlossary(organizationId, glossaryId) + } + + fun create( + term: GlossaryTerm, + dto: UpdateGlossaryTermTranslationRequest, + ): GlossaryTermTranslation? { + if (dto.text.isEmpty()) { + return null + } + + val translation = + GlossaryTermTranslation( + languageTag = dto.languageTag, + text = dto.text, + ).apply { + this.term = term + } + return glossaryTermTranslationRepository.save(translation) + } + + fun updateOrCreate( + term: GlossaryTerm, + dto: UpdateGlossaryTermTranslationRequest, + ): GlossaryTermTranslation? { + if (term.flagNonTranslatable && dto.languageTag != term.glossary.baseLanguageTag) { + throw BadRequestException(Message.GLOSSARY_NON_TRANSLATABLE_TERM_CANNOT_BE_TRANSLATED) + } + + if (dto.text.isEmpty()) { + glossaryTermTranslationRepository.deleteByTermAndLanguageTag(term, dto.languageTag) + return null + } + + val translation = glossaryTermTranslationRepository.findByTermAndLanguageTag(term, dto.languageTag) + if (translation == null) { + return create(term, dto) + } + + translation.text = dto.text + return glossaryTermTranslationRepository.save(translation) + } + + fun deleteAllNonBaseTranslations(term: GlossaryTerm) { + glossaryTermTranslationRepository.deleteAllByTermAndLanguageTagIsNot(term, term.glossary.baseLanguageTag!!) + } + + fun find( + term: GlossaryTerm, + languageTag: String, + ): GlossaryTermTranslation? { + return glossaryTermTranslationRepository.findByTermAndLanguageTag(term, languageTag) + } + + fun findAll( + organizationId: Long, + projectId: Long, + words: Set, + languageTag: String, + ): Set { + val locale = Locale.forLanguageTag(languageTag) ?: Locale.ROOT + return glossaryTermTranslationRepository + .findByFirstWordLowercasedAndLanguageTagAndAssignedProjectIdAndOrganizationId( + words.map { it.lowercase(locale) }, + languageTag, + projectId, + organizationId, + ) + } + + fun get( + term: GlossaryTerm, + languageTag: String, + ): GlossaryTermTranslation { + return find(term, languageTag) ?: throw NotFoundException(Message.GLOSSARY_TERM_TRANSLATION_NOT_FOUND) + } + + fun updateBaseLanguage( + glossary: Glossary, + oldBaseLanguageTag: String?, + newBaseLanguageTag: String?, + ) { + glossaryTermTranslationRepository.updateBaseLanguage( + glossary, + oldBaseLanguageTag, + newBaseLanguageTag, + ) + } +} diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryControllerTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryControllerTest.kt new file mode 100644 index 0000000000..0191e4cf2f --- /dev/null +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryControllerTest.kt @@ -0,0 +1,163 @@ +package io.tolgee.ee.api.v2.controllers.glossary + +import io.tolgee.constants.Feature +import io.tolgee.development.testDataBuilder.data.GlossaryTestData +import io.tolgee.ee.component.PublicEnabledFeaturesProvider +import io.tolgee.ee.data.glossary.CreateGlossaryRequest +import io.tolgee.ee.data.glossary.UpdateGlossaryRequest +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsBadRequest +import io.tolgee.fixtures.andIsNotFound +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.isValidId +import io.tolgee.fixtures.node +import io.tolgee.testing.AuthorizedControllerTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +@AutoConfigureMockMvc +class GlossaryControllerTest : AuthorizedControllerTest() { + @Autowired + private lateinit var enabledFeaturesProvider: PublicEnabledFeaturesProvider + + lateinit var testData: GlossaryTestData + + @BeforeEach + fun setup() { + enabledFeaturesProvider.forceEnabled = setOf(Feature.GLOSSARY) + testData = GlossaryTestData() + testDataService.saveTestData(testData.root) + userAccount = testData.userOwner + } + + @AfterEach + fun cleanup() { + testDataService.cleanTestData(testData.root) + userAccount = null + enabledFeaturesProvider.forceEnabled = null + } + + @Test + fun `returns all glossaries`() { + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries") + .andIsOk.andAssertThatJson { + node("_embedded.glossaries") { + isArray.hasSize(2) + node("[0].id").isValidId + node("[0].name").isEqualTo("Test Glossary") + node("[1].name").isEqualTo("Empty Glossary") + } + } + } + + @Test + fun `does not return all glossaries when feature disabled`() { + enabledFeaturesProvider.forceEnabled = emptySet() + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries") + .andIsBadRequest + } + + @Test + fun `returns single glossary`() { + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries/${testData.glossary.id}") + .andIsOk.andAssertThatJson { + node("id").isValidId + node("name").isEqualTo("Test Glossary") + node("baseLanguageTag").isEqualTo("en") + } + } + + @Test + fun `does not return single glossary when feature disabled`() { + enabledFeaturesProvider.forceEnabled = emptySet() + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries/${testData.glossary.id}") + .andIsBadRequest + } + + @Test + fun `creates glossary`() { + val request = CreateGlossaryRequest().apply { + name = "New Glossary" + baseLanguageTag = "en" + assignedProjects = mutableSetOf(testData.project.id) + } + performAuthPost("/v2/organizations/${testData.organization.id}/glossaries", request) + .andIsOk.andAssertThatJson { + node("id").isValidId.satisfies({ + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries/$it") + .andIsOk.andAssertThatJson { + node("id").isValidId.isEqualTo(it) + node("name").isEqualTo("New Glossary") + node("baseLanguageTag").isEqualTo("en") + } + }) + node("name").isEqualTo("New Glossary") + node("baseLanguageTag").isEqualTo("en") + } + } + + @Test + fun `does not create glossary when feature disabled`() { + enabledFeaturesProvider.forceEnabled = emptySet() + val request = CreateGlossaryRequest().apply { + name = "New Glossary" + baseLanguageTag = "en" + assignedProjects = mutableSetOf(testData.project.id) + } + performAuthPost("/v2/organizations/${testData.organization.id}/glossaries", request) + .andIsBadRequest + } + + @Test + fun `updates glossary`() { + val request = UpdateGlossaryRequest().apply { + name = "Updated Glossary" + baseLanguageTag = "de" + } + performAuthPut("/v2/organizations/${testData.organization.id}/glossaries/${testData.glossary.id}", request) + .andIsOk.andAssertThatJson { + node("id").isValidId + node("name").isEqualTo("Updated Glossary") + node("baseLanguageTag").isEqualTo("de") + } + + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries/${testData.glossary.id}") + .andIsOk.andAssertThatJson { + node("id").isValidId + node("name").isEqualTo("Updated Glossary") + node("baseLanguageTag").isEqualTo("de") + } + } + + @Test + fun `does not update glossary when feature disabled`() { + enabledFeaturesProvider.forceEnabled = emptySet() + val request = UpdateGlossaryRequest().apply { + name = "Updated Glossary" + baseLanguageTag = "de" + } + performAuthPut("/v2/organizations/${testData.organization.id}/glossaries/${testData.glossary.id}", request) + .andIsBadRequest + } + + @Test + fun `deletes glossary`() { + performAuthDelete("/v2/organizations/${testData.organization.id}/glossaries/${testData.glossary.id}") + .andIsOk + + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries/${testData.glossary.id}") + .andIsNotFound + } + + @Test + fun `does not delete glossary when feature disabled`() { + enabledFeaturesProvider.forceEnabled = emptySet() + performAuthDelete("/v2/organizations/${testData.organization.id}/glossaries/${testData.glossary.id}") + .andIsBadRequest + } +} diff --git a/webapp/src/component/common/TooltipCard.tsx b/webapp/src/component/common/TooltipCard.tsx new file mode 100644 index 0000000000..d05ae26b48 --- /dev/null +++ b/webapp/src/component/common/TooltipCard.tsx @@ -0,0 +1,9 @@ +import { Card, styled } from '@mui/material'; + +export const TooltipCard = styled(Card)` + margin-top: ${({ theme }) => theme.spacing(1)}; + padding: ${({ theme }) => theme.spacing(2)}; + border-radius: ${({ theme }) => theme.spacing(2)}; + min-width: 400px; + max-width: 500px; +`; diff --git a/webapp/src/component/common/form/fields/SearchField.tsx b/webapp/src/component/common/form/fields/SearchField.tsx index 06d77e9c7f..ecbe552ac9 100644 --- a/webapp/src/component/common/form/fields/SearchField.tsx +++ b/webapp/src/component/common/form/fields/SearchField.tsx @@ -8,7 +8,8 @@ import { stopAndPrevent } from 'tg.fixtures/eventHandler'; const SearchField = ( props: { initial?: string; - onSearch: (value: string) => void; + onSearch?: (value: string) => void; + clearCallbackRef?: React.MutableRefObject<(() => void) | null>; } & ComponentProps ) => { const [search, setSearch] = useState(props.initial || ''); @@ -16,14 +17,21 @@ const SearchField = ( const theme = useTheme(); const { t } = useTranslate(); - const { onSearch, ...otherProps } = props; + const { onSearch, clearCallbackRef, ...otherProps } = props; useEffect(() => { if (debouncedSearch !== props.initial) { - onSearch(debouncedSearch); + onSearch?.(debouncedSearch); } }, [debouncedSearch]); + if (clearCallbackRef !== undefined) { + clearCallbackRef.current = () => { + setSearch(''); + onSearch?.(''); + }; + } + return ( ReactNode; itemSeparator?: () => ReactNode; - getKey?: (item: DataItem) => any; + getKey?: (item: DataItem) => React.Key; } & OverridableListWrappers ) => { const { data, pagination } = props; diff --git a/webapp/src/views/projects/translations/TranslationsTable/useScrollStatus.ts b/webapp/src/component/common/useScrollStatus.ts similarity index 100% rename from webapp/src/views/projects/translations/TranslationsTable/useScrollStatus.ts rename to webapp/src/component/common/useScrollStatus.ts diff --git a/webapp/src/views/projects/ProjectLanguages.tsx b/webapp/src/component/languages/CircledLanguageIconList.tsx similarity index 80% rename from webapp/src/views/projects/ProjectLanguages.tsx rename to webapp/src/component/languages/CircledLanguageIconList.tsx index ec0fbc3f87..a4821402d4 100644 --- a/webapp/src/views/projects/ProjectLanguages.tsx +++ b/webapp/src/component/languages/CircledLanguageIconList.tsx @@ -1,4 +1,4 @@ -import { Box, Tooltip, styled } from '@mui/material'; +import { Box, styled, Tooltip } from '@mui/material'; import { stopBubble } from 'tg.fixtures/eventHandler'; import { components } from 'tg.service/apiSchema.generated'; import { @@ -6,7 +6,7 @@ import { CircledPill, } from 'tg.component/languages/CircledLanguageIcon'; -type ProjectWithStatsModel = components['schemas']['ProjectWithStatsModel']; +type LanguageModel = components['schemas']['LanguageModel']; const StyledCircledLanguageIcon = styled(CircledLanguageIcon)` cursor: default; `; @@ -19,12 +19,16 @@ const StyledCircledPill = styled(CircledPill)` } `; -export const ProjectLanguages = ({ p }: { p: ProjectWithStatsModel }) => { +export const CircledLanguageIconList = ({ + languages, +}: { + languages: LanguageModel[]; +}) => { const maxLangs = 10; - const showNumber = maxLangs < p.languages.length - 2; + const showNumber = maxLangs < languages.length - 2; const showingLanguages = showNumber - ? p.languages.slice(0, maxLangs) - : p.languages; + ? languages.slice(0, maxLangs) + : languages; return ( <> {showingLanguages.map((l) => ( @@ -45,7 +49,7 @@ export const ProjectLanguages = ({ p }: { p: ProjectWithStatsModel }) => { disableInteractive title={ - {p.languages.slice(maxLangs).map((l, i) => ( + {languages.slice(maxLangs).map((l, i) => ( @@ -61,7 +65,7 @@ export const ProjectLanguages = ({ p }: { p: ProjectWithStatsModel }) => { className: 'wrapped', }} > - +{p.languages.length - maxLangs} + +{languages.length - maxLangs} diff --git a/webapp/src/component/layout/BaseSettingsView/BaseSettingsView.tsx b/webapp/src/component/layout/BaseSettingsView/BaseSettingsView.tsx index 463e918fe4..032b7963c0 100644 --- a/webapp/src/component/layout/BaseSettingsView/BaseSettingsView.tsx +++ b/webapp/src/component/layout/BaseSettingsView/BaseSettingsView.tsx @@ -1,23 +1,21 @@ -import { Box, Container, styled, Typography } from '@mui/material'; +import { Box, styled } from '@mui/material'; -import { - BaseView, - BaseViewProps, - getBaseViewWidth, -} from 'tg.component/layout/BaseView'; +import { BaseView, BaseViewProps } from 'tg.component/layout/BaseView'; import { SettingsMenu, SettingsMenuItem } from './SettingsMenu'; -import { BaseViewAddButton } from '../BaseViewAddButton'; +import { getBaseViewWidth } from 'tg.component/layout/BaseViewWidth'; +import { HeaderBar } from 'tg.component/layout/HeaderBar'; const StyledWrapper = styled('div')` - display: flex; + display: grid; gap: 32px; + grid-template-columns: auto 1fr; @container main-container (max-width: 800px) { - flex-direction: column; + grid-template-columns: none; } `; -const StyledContainer = styled(Container)` - display: flex; +const StyledContainer = styled(Box)` + display: grid; padding: 0px !important; container: main-container / inline-size; `; @@ -36,40 +34,33 @@ type Props = BaseViewProps & { export const BaseSettingsView: React.FC = ({ children, - title, menuItems, - addLinkTo, maxWidth = 'normal', - onAdd, - addLabel, + allCentered = true, ...otherProps }) => { const containerMaxWidth = getBaseViewWidth(maxWidth); return ( - + - - - {title && ( - - - {title} - - {(addLinkTo || onAdd) && ( - - - - )} - - )} + + + {children} diff --git a/webapp/src/component/layout/BaseView.tsx b/webapp/src/component/layout/BaseView.tsx index 28339ed279..968c51e89c 100644 --- a/webapp/src/component/layout/BaseView.tsx +++ b/webapp/src/component/layout/BaseView.tsx @@ -1,22 +1,19 @@ -import { ReactNode } from 'react'; -import { Box, styled, Typography } from '@mui/material'; +import { FC, ReactNode } from 'react'; +import { Box, styled } from '@mui/material'; -import { SecondaryBarSearchField } from 'tg.component/layout/SecondaryBarSearchField'; import { Navigation } from 'tg.component/navigation/Navigation'; import { SecondaryBar } from './SecondaryBar'; import { useWindowTitle } from 'tg.hooks/useWindowTitle'; -import { BaseViewAddButton } from './BaseViewAddButton'; import { useGlobalLoading } from 'tg.component/GlobalLoading'; +import { + BaseViewWidth, + getBaseViewWidth, +} from 'tg.component/layout/BaseViewWidth'; +import { HeaderBar, HeaderBarProps } from 'tg.component/layout/HeaderBar'; export const BASE_VIEW_PADDING = 24; -const widthMap = { - wide: 1200, - normal: 900, - narrow: 600, -}; - const StyledContainer = styled(Box)` display: grid; width: 100%; @@ -36,39 +33,23 @@ const StyledContainerInner = styled(Box)` margin-bottom: 0px; `; -type BaseViewWidth = keyof typeof widthMap | number | undefined; - -export function getBaseViewWidth(width: BaseViewWidth) { - return typeof width === 'string' ? widthMap[width] : width; -} - -export interface BaseViewProps { +export type BaseViewProps = { windowTitle: string; loading?: boolean; - title?: ReactNode; - onAdd?: () => void; - addLinkTo?: string; - addLabel?: string; - addComponent?: React.ReactNode; children: (() => ReactNode) | ReactNode; - onSearch?: (string) => void; - searchPlaceholder?: string; navigation?: React.ComponentProps['path']; customNavigation?: ReactNode; - customHeader?: ReactNode; navigationRight?: ReactNode; - switcher?: ReactNode; hideChildrenOnLoading?: boolean; maxWidth?: BaseViewWidth; allCentered?: boolean; 'data-cy'?: string; - initialSearch?: string; overflow?: string; wrapperProps?: React.ComponentProps; stretch?: boolean; -} +} & Omit; -export const BaseView = (props: BaseViewProps) => { +export const BaseView: FC = (props) => { const hideChildrenOnLoading = props.hideChildrenOnLoading === undefined || props.hideChildrenOnLoading; @@ -78,14 +59,6 @@ export const BaseView = (props: BaseViewProps) => { const displayNavigation = props.customNavigation || props.navigation; - const displayHeader = - props.title || - props.customHeader || - props.onSearch || - props.onAdd || - props.addComponent || - props.addLinkTo; - const maxWidth = getBaseViewWidth(props.maxWidth); return ( @@ -118,49 +91,7 @@ export const BaseView = (props: BaseViewProps) => { )} - {displayHeader && ( - - - {props.customHeader || ( - - - {props.title && ( - {props.title} - )} - {typeof props.onSearch === 'function' && ( - - - - )} - - - {props.switcher && ( - - {props.switcher} - - )} - {props.addComponent - ? props.addComponent - : (props.onAdd || props.addLinkTo) && ( - - )} - - - )} - - - )} + ['variant']; + onAdd?: () => void; + addLinkTo?: string; + addLabel?: string; + addComponent?: React.ReactNode; + onSearch?: (string) => void; + searchPlaceholder?: string; + customHeader?: ReactNode; + switcher?: ReactNode; + maxWidth?: BaseViewWidth; + initialSearch?: string; +}; + +export const HeaderBar: React.VFC = (props) => { + const maxWidth = getBaseViewWidth(props.maxWidth); + + const displayHeader = + props.title !== undefined || + props.customHeader || + props.onSearch || + props.onAdd || + props.addComponent || + props.addLinkTo; + + if (props.headerBarDisable || !displayHeader) { + return null; + } + return ( + + + {props.customHeader || ( + + + {props.title !== undefined && ( + + {props.title} + + )} + {typeof props.onSearch === 'function' && ( + + + + )} + + + {props.switcher && ( + + {props.switcher} + + )} + {props.addComponent + ? props.addComponent + : (props.onAdd || props.addLinkTo) && ( + + )} + + + )} + + + ); +}; diff --git a/webapp/src/component/layout/SecondaryBar.tsx b/webapp/src/component/layout/SecondaryBar.tsx index 0578f44d8b..10b8bf6e1a 100644 --- a/webapp/src/component/layout/SecondaryBar.tsx +++ b/webapp/src/component/layout/SecondaryBar.tsx @@ -7,18 +7,20 @@ const StyledBox = styled(Box)` type Props = React.ComponentProps & { noBorder?: boolean; + reducedSpacing?: boolean; }; export const SecondaryBar: FunctionComponent = ({ noBorder, + reducedSpacing, ...props }) => ( diff --git a/webapp/src/component/searchSelect/InfiniteMultiSearchSelect.tsx b/webapp/src/component/searchSelect/InfiniteMultiSearchSelect.tsx new file mode 100644 index 0000000000..fbd8d2f865 --- /dev/null +++ b/webapp/src/component/searchSelect/InfiniteMultiSearchSelect.tsx @@ -0,0 +1,186 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Box, IconButton, Menu, styled } from '@mui/material'; +import { SmoothProgress } from 'tg.component/SmoothProgress'; +import { InfiniteSearchSelectContent } from 'tg.component/searchSelect/InfiniteSearchSelectContent'; +import { components } from 'tg.service/apiSchema.generated'; +import { UseInfiniteQueryResult } from 'react-query'; +import { ApiError } from 'tg.service/http/ApiError'; +import { FakeInput } from 'tg.component/FakeInput'; +import { stopAndPrevent } from 'tg.fixtures/eventHandler'; +import { XClose } from '@untitled-ui/icons-react'; +import { ArrowDropDown } from 'tg.component/CustomIcons'; +import { TextField } from 'tg.component/common/TextField'; + +const StyledClearButton = styled(IconButton)` + margin: ${({ theme }) => theme.spacing(-1, -0.5, -1, -0.25)}; +`; + +type PagedResult = { + _embedded?: any; + page?: components['schemas']['PageMetadata']; +}; + +type Props = { + items?: T[]; + selected?: S[]; + queryResult: UseInfiniteQueryResult; + itemKey: (item: T) => React.Key; + search: string; + onClearSelected?: () => void; + onSearchChange?: (search: string) => void; + onFetchMore?: () => void; + renderItem?: ( + props: React.HTMLAttributes & { + key: any; + }, + item: T + ) => React.ReactNode; + labelItem: (item: S) => string; + label?: string; + error?: React.ReactNode; + searchPlaceholder?: string; + displaySearch?: boolean; + disabled?: boolean; + minHeight?: boolean; +}; + +export function InfiniteMultiSearchSelect({ + items, + selected, + queryResult, + itemKey, + search, + onClearSelected, + onSearchChange, + onFetchMore, + renderItem, + labelItem, + label, + error, + searchPlaceholder, + displaySearch, + disabled, + minHeight, +}: Props) { + const [showSearchHint, setShowSearchHint] = useState(false); + + const renderOption = + renderItem ?? + ((_, item: T) => { + return itemKey(item); + }); + + const totalItems = queryResult?.data?.pages[0]?.page?.totalElements; + + useEffect(() => { + if (!showSearchHint && (totalItems ?? 0) > 10) { + setShowSearchHint(true); + } + }, [totalItems]); + const showSearch = + (displaySearch ?? true) && (showSearchHint || Boolean(search.length)); + + const [open, setOpen] = useState(false); + const anchorEl = useRef(null); + + const selectedItemsLabel = useMemo(() => { + return selected?.map((v) => v && labelItem(v))?.join(', '); + }, [selected]); + + const close = () => { + setOpen(false); + onSearchChange && onSearchChange(''); + }; + + return ( + <> + setOpen(true), + disabled: disabled, + ref: anchorEl, + fullWidth: true, + sx: { + cursor: 'pointer', + }, + readOnly: true, + inputComponent: FakeInput, + margin: 'dense', + endAdornment: ( + + {Boolean(selected?.length && !disabled) && ( + onClearSelected?.())} + tabIndex={-1} + > + + + )} + setOpen(true)} + tabIndex={-1} + sx={{ pointerEvents: 'none' }} + disabled={disabled} + > + + + + ), + }} + /> + + {open && ( + + + {Boolean(totalItems !== undefined) && ( + + + + )} + + )} + + ); +} diff --git a/webapp/src/component/searchSelect/InfiniteSearchSelect.tsx b/webapp/src/component/searchSelect/InfiniteSearchSelect.tsx new file mode 100644 index 0000000000..e55ece3a95 --- /dev/null +++ b/webapp/src/component/searchSelect/InfiniteSearchSelect.tsx @@ -0,0 +1,187 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Box, IconButton, Menu, styled } from '@mui/material'; +import { SmoothProgress } from 'tg.component/SmoothProgress'; +import { InfiniteSearchSelectContent } from 'tg.component/searchSelect/InfiniteSearchSelectContent'; +import { components } from 'tg.service/apiSchema.generated'; +import { UseInfiniteQueryResult } from 'react-query'; +import { ApiError } from 'tg.service/http/ApiError'; +import { FakeInput } from 'tg.component/FakeInput'; +import { stopAndPrevent } from 'tg.fixtures/eventHandler'; +import { XClose } from '@untitled-ui/icons-react'; +import { ArrowDropDown } from 'tg.component/CustomIcons'; +import { TextField } from 'tg.component/common/TextField'; + +const StyledClearButton = styled(IconButton)` + margin: ${({ theme }) => theme.spacing(-1, -0.5, -1, -0.25)}; +`; + +type PagedResult = { + _embedded?: any; + page?: components['schemas']['PageMetadata']; +}; + +type Props = { + items?: T[]; + selected?: S; + queryResult: UseInfiniteQueryResult; + itemKey: (item: T) => React.Key; + search: string; + onClearSelected?: () => void; + onSearchChange?: (search: string) => void; + onFetchMore?: () => void; + renderItem?: ( + props: React.HTMLAttributes & { + key: any; + }, + item: T + ) => React.ReactNode; + labelItem: (item: S) => string; + label?: string; + error?: React.ReactNode; + searchPlaceholder?: string; + displaySearch?: boolean; + disabled?: boolean; +}; + +export function InfiniteSearchSelect({ + items, + selected, + queryResult, + itemKey, + search, + onClearSelected, + onSearchChange, + onFetchMore, + renderItem, + labelItem, + label, + error, + searchPlaceholder, + displaySearch, + disabled, +}: Props) { + const [showSearchHint, setShowSearchHint] = useState(false); + + const renderOption = + renderItem ?? + ((_, item: T) => { + return itemKey(item); + }); + + const totalItems = queryResult?.data?.pages[0]?.page?.totalElements; + + useEffect(() => { + if (!showSearchHint && (totalItems ?? 0) > 10) { + setShowSearchHint(true); + } + }, [totalItems]); + const showSearch = + (displaySearch ?? true) && (showSearchHint || Boolean(search.length)); + + const [open, setOpen] = useState(false); + const anchorEl = useRef(null); + + const selectedItemLabel = useMemo(() => { + if (selected !== undefined) { + return labelItem(selected); + } + return ''; + }, [selected]); + + const close = () => { + setOpen(false); + onSearchChange && onSearchChange(''); + }; + + return ( + <> + setOpen(true), + disabled: disabled, + ref: anchorEl, + fullWidth: true, + sx: { + cursor: 'pointer', + }, + readOnly: true, + inputComponent: FakeInput, + margin: 'dense', + endAdornment: ( + + {Boolean(selected !== undefined && !disabled) && ( + onClearSelected?.())} + tabIndex={-1} + > + + + )} + setOpen(true)} + tabIndex={-1} + sx={{ pointerEvents: 'none' }} + disabled={disabled} + > + + + + ), + }} + /> + + {open && ( + + + {Boolean(totalItems !== undefined) && ( + + + + )} + + )} + + ); +} diff --git a/webapp/src/component/searchSelect/InfiniteSearchSelectContent.tsx b/webapp/src/component/searchSelect/InfiniteSearchSelectContent.tsx index 880b4b899b..667553f51d 100644 --- a/webapp/src/component/searchSelect/InfiniteSearchSelectContent.tsx +++ b/webapp/src/component/searchSelect/InfiniteSearchSelectContent.tsx @@ -3,10 +3,10 @@ import { Autocomplete, Box } from '@mui/material'; import { useTranslate } from '@tolgee/react'; import { - StyledWrapper, StyledHeading, StyledInput, StyledInputWrapper, + StyledWrapper, } from 'tg.component/searchSelect/SearchStyled'; const FETCH_NEXT_PAGE_SCROLL_THRESHOLD_IN_PIXELS = 220; @@ -23,11 +23,12 @@ function PaperComponent(props) { return ; } -type Props = { +type Props = { open: boolean; onClose?: () => void; anchorEl?: HTMLElement; items: T[] | undefined; + itemKey: (item: T) => React.Key; displaySearch?: boolean; searchPlaceholder?: string; search?: string; @@ -43,15 +44,15 @@ type Props = { }, option: T ) => React.ReactNode; - getOptionLabel: (item: T) => string; ListboxProps?: React.HTMLAttributes; }; -export function InfiniteSearchSelectContent({ +export function InfiniteSearchSelectContent({ open, onClose, anchorEl, items, + itemKey, displaySearch, searchPlaceholder, search, @@ -61,7 +62,6 @@ export function InfiniteSearchSelectContent({ maxWidth, compareFunction, renderOption, - getOptionLabel, ListboxProps, onGetMoreData, }: Props) { @@ -98,11 +98,11 @@ export function InfiniteSearchSelectContent({ ? onSearch(value) : setInputValue(value); }} - getOptionLabel={getOptionLabel} + getOptionLabel={() => ''} PopperComponent={PopperComponent} PaperComponent={PaperComponent} renderOption={(props, item) => ( - + {renderOption(props, item)} )} diff --git a/webapp/src/component/searchSelect/MultiselectItem.tsx b/webapp/src/component/searchSelect/MultiselectItem.tsx new file mode 100644 index 0000000000..f32be548b5 --- /dev/null +++ b/webapp/src/component/searchSelect/MultiselectItem.tsx @@ -0,0 +1,56 @@ +import { Checkbox, ListItemText, MenuItemProps, styled } from '@mui/material'; +import React from 'react'; +import { CompactMenuItem } from 'tg.component/ListComponents'; + +const StyledMenuItem = styled(CompactMenuItem)` + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + padding-left: 4px !important; + + & .hidden { + opacity: 0; + transition: opacity ease-in 0.1s; + } + + &:hover .hidden { + opacity: 1; + } + + gap: 8px; +`; + +const StyledListItemText = styled(ListItemText)` + overflow: hidden; +`; + +const StyledCheckbox = styled(Checkbox)` + margin: -8px -8px -8px 0px; +`; + +type Props = MenuItemProps & { + label: React.ReactNode; + selected: boolean; +}; + +export const MultiselectItem = React.forwardRef(function MultiselectItem( + { label, selected, disabled, ...other }: Props, + ref +) { + return ( + + + + + ); +}); diff --git a/webapp/src/component/searchSelect/SelectItem.tsx b/webapp/src/component/searchSelect/SelectItem.tsx new file mode 100644 index 0000000000..b30d7d5d49 --- /dev/null +++ b/webapp/src/component/searchSelect/SelectItem.tsx @@ -0,0 +1,45 @@ +import { ListItemText, MenuItemProps, styled } from '@mui/material'; +import React from 'react'; +import { CompactMenuItem } from 'tg.component/ListComponents'; + +const StyledMenuItem = styled(CompactMenuItem)` + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + + & .hidden { + opacity: 0; + transition: opacity ease-in 0.1s; + } + + &:hover .hidden { + opacity: 1; + } + + gap: 8px; +`; + +const StyledListItemText = styled(ListItemText)` + overflow: hidden; +`; + +type Props = MenuItemProps & { + label: React.ReactNode; + selected: boolean; +}; + +export const SelectItem = React.forwardRef(function MultiselectItem( + { label, selected, ...other }: Props, + ref +) { + return ( + + + + ); +}); diff --git a/webapp/src/constants/GlobalValidationSchema.tsx b/webapp/src/constants/GlobalValidationSchema.tsx index 99055d5b00..af8b5fb51b 100644 --- a/webapp/src/constants/GlobalValidationSchema.tsx +++ b/webapp/src/constants/GlobalValidationSchema.tsx @@ -469,6 +469,31 @@ export class Validation { name: Yup.string().min(3).required(), email: Yup.string().min(3).required(), }); + + static readonly GLOSSARY_CREATE_FORM = (t: TranslateFunction) => + Yup.object().shape({ + name: Yup.string().min(3).required(), + baseLanguage: Yup.object() + .required() + .shape({ + tag: Yup.string().min(1).required(), + }), + assignedProjects: Yup.array().of( + Yup.object().shape({ + id: Yup.number().required(), + }) + ), + }); + + static readonly GLOSSARY_TERM_CREATE_FORM = (t: TranslateFunction) => + Yup.object().shape({ + text: Yup.string().min(1).required(), + description: Yup.string().optional().nullable(), + nonTranslatable: Yup.boolean(), + caseSensitive: Yup.boolean(), + abbreviation: Yup.boolean(), + forbidden: Yup.boolean(), + }); } let GLOBAL_VALIDATION_DEBOUNCE_TIMER: any = undefined; diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index ec16c3c016..c8e63cf230 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -59,6 +59,7 @@ export enum PARAMS { USER_ID = 'userID', VERIFICATION_CODE = 'verificationCode', ORGANIZATION_SLUG = 'slug', + GLOSSARY_ID = 'glossaryId', TRANSLATION_ID = 'translationId', PLAN_ID = 'planId', TA_ID = 'taId', @@ -71,8 +72,6 @@ export class LINKS { * Authentication */ - static MY_TASKS = Link.ofRoot('my-tasks'); - static LOGIN = Link.ofRoot('login'); static OAUTH_RESPONSE = Link.ofParent( @@ -177,6 +176,8 @@ export class LINKS { 'disable-mfa' ); + static MY_TASKS = Link.ofRoot('my-tasks'); + /** * Administration */ @@ -297,6 +298,18 @@ export class LINKS { 'self-hosted-ee' ); + static ORGANIZATION_GLOSSARIES = Link.ofParent( + LINKS.ORGANIZATION, + 'glossaries' + ); + + static ORGANIZATION_GLOSSARY = Link.ofParent( + LINKS.ORGANIZATION_GLOSSARIES, + p(PARAMS.GLOSSARY_ID) + ); + + static ORGANIZATION_GLOSSARY_VIEW = LINKS.ORGANIZATION_GLOSSARY; + /** * Slack */ @@ -421,3 +434,15 @@ export const getTaskUrl = (projectId: number, taskNumber: number) => { [PARAMS.PROJECT_ID]: projectId, })}?number=${taskNumber}`; }; + +export const getGlossaryTermSearchUrl = ( + organizationSlug: string, + glossaryId: number, + search: string +) => { + const encodedSearch = encodeURIComponent(search.toString()); + return `${LINKS.ORGANIZATION_GLOSSARY.build({ + [PARAMS.ORGANIZATION_SLUG]: organizationSlug, + [PARAMS.GLOSSARY_ID]: glossaryId, + })}?search=${encodedSearch}`; +}; diff --git a/webapp/src/custom.d.ts b/webapp/src/custom.d.ts index 9d303ab81f..51cde7d271 100644 --- a/webapp/src/custom.d.ts +++ b/webapp/src/custom.d.ts @@ -106,4 +106,8 @@ declare module 'react' { interface HTMLAttributes extends AriaAttributes, DOMAttributes { webkitdirectory?: boolean; } + + type KeyOf = { + [K in keyof T]-?: T[K] extends Key ? K : never; + }[keyof T]; } diff --git a/webapp/src/ee/glossary/components/AddFirstGlossaryMessage.tsx b/webapp/src/ee/glossary/components/AddFirstGlossaryMessage.tsx new file mode 100644 index 0000000000..2e2a031b63 --- /dev/null +++ b/webapp/src/ee/glossary/components/AddFirstGlossaryMessage.tsx @@ -0,0 +1,76 @@ +import { Button, Card, Link, styled, Typography } from '@mui/material'; +import { T } from '@tolgee/react'; +import Box from '@mui/material/Box'; +import EmptyImage from 'tg.svgs/icons/glossary-empty.svg?react'; +import React from 'react'; + +const StyledCard = styled(Card)` + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; + border-radius: 20px; + background-color: ${({ theme }) => + theme.palette.tokens.background.onDefaultGrey}; +`; + +const StyledImage = styled(Box)` + max-width: 100%; + color: ${({ theme }) => theme.palette.text.secondary}; +`; + +const StyledText = styled('div')` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin: ${({ theme }) => theme.spacing(8, 8, 2, 8)}; +`; + +const StyledButton = styled(Button)` + margin-top: ${({ theme }) => theme.spacing(4)}; + margin-bottom: ${({ theme }) => theme.spacing(8)}; +`; + +export type AddFirstGlossaryMessageProps = { + height?: string; + onCreateClick?: () => void; +}; + +export const AddFirstGlossaryMessage: React.VFC< + AddFirstGlossaryMessageProps +> = (props) => { + return ( + + + + + + + + ), + }} + /> + + + + + + {props.onCreateClick && ( + + + + )} + + ); +}; diff --git a/webapp/src/ee/glossary/components/AssignedProjectsSelect.tsx b/webapp/src/ee/glossary/components/AssignedProjectsSelect.tsx new file mode 100644 index 0000000000..e90755f190 --- /dev/null +++ b/webapp/src/ee/glossary/components/AssignedProjectsSelect.tsx @@ -0,0 +1,131 @@ +import { components } from 'tg.service/apiSchema.generated'; +import React, { ComponentProps, useState } from 'react'; +import Box from '@mui/material/Box'; +import { useField, useFormikContext } from 'formik'; +import { useTranslate } from '@tolgee/react'; +import { useApiInfiniteQuery } from 'tg.service/http/useQueryApi'; +import { useDebounce } from 'use-debounce'; +import { InfiniteMultiSearchSelect } from 'tg.component/searchSelect/InfiniteMultiSearchSelect'; +import { MultiselectItem } from 'tg.component/searchSelect/MultiselectItem'; +import { usePreferredOrganization } from 'tg.globalContext/helpers'; + +type SimpleProjectModel = components['schemas']['SimpleProjectModel']; +type SelectedProjectModel = { + id: number; + name: string; +}; + +type Props = { + name: string; + disabled?: boolean; +} & Omit, 'children'>; + +export const AssignedProjectsSelect: React.VFC = ({ + name, + disabled, + ...boxProps +}) => { + const { preferredOrganization } = usePreferredOrganization(); + const context = useFormikContext(); + const { t } = useTranslate(); + const [field, meta] = useField(name); + const value = field.value as SelectedProjectModel[]; + // Formik returns error as object - schema is incorrect... + const error = (meta.error as any)?.id; + + const [search, setSearch] = useState(''); + const [searchDebounced] = useDebounce(search, 500); + + const query = { + search: searchDebounced, + size: 30, + }; + const dataLoadable = useApiInfiniteQuery({ + url: '/v2/organizations/{id}/projects', + method: 'get', + path: { id: preferredOrganization!.id }, + query, + options: { + keepPreviousData: true, + refetchOnMount: true, + noGlobalLoading: true, + getNextPageParam: (lastPage) => { + if ( + lastPage.page && + lastPage.page.number! < lastPage.page.totalPages! - 1 + ) { + return { + path: { id: preferredOrganization!.id }, + query: { + ...query, + page: lastPage.page!.number! + 1, + }, + }; + } else { + return null; + } + }, + }, + }); + + const data = dataLoadable.data?.pages.flatMap( + (p) => p._embedded?.projects ?? [] + ); + + const handleFetchMore = () => { + if (dataLoadable.hasNextPage && !dataLoadable.isFetching) { + dataLoadable.fetchNextPage(); + } + }; + + const setValue = (v: SelectedProjectModel[]) => + context.setFieldValue(name, v); + const toggleSelected = (item: SimpleProjectModel) => { + if (value.some((v) => v.id === item.id)) { + setValue(value.filter((v) => item.id !== v.id)); + return; + } + const itemSelected = { + id: item.id, + name: item.name, + }; + setValue([itemSelected, ...value]); + }; + + function renderItem(props: any, item: SimpleProjectModel) { + const selected = value.some((v) => v.id === item.id); + return ( + toggleSelected(item)} + /> + ); + } + + function labelItem(item: SelectedProjectModel) { + return item.name; + } + + return ( + + item.id} + search={search} + onClearSelected={() => setValue([])} + onSearchChange={setSearch} + onFetchMore={handleFetchMore} + renderItem={renderItem} + labelItem={labelItem} + label={t('create_glossary_field_project')} + error={meta.touched && error} + searchPlaceholder={t('project_select_search_placeholder')} + disabled={disabled} + /> + + ); +}; diff --git a/webapp/src/ee/glossary/components/GlossariesEmptyListMessage.tsx b/webapp/src/ee/glossary/components/GlossariesEmptyListMessage.tsx new file mode 100644 index 0000000000..23e3f994af --- /dev/null +++ b/webapp/src/ee/glossary/components/GlossariesEmptyListMessage.tsx @@ -0,0 +1,25 @@ +import { ComponentProps, default as React, VFC } from 'react'; +import { Box } from '@mui/material'; + +import { + AddFirstGlossaryMessage, + AddFirstGlossaryMessageProps, +} from './AddFirstGlossaryMessage'; +import { EmptyState } from 'tg.component/common/EmptyState'; + +type Props = { + loading?: boolean; + wrapperProps?: ComponentProps; +} & AddFirstGlossaryMessageProps; + +export const GlossariesEmptyListMessage: VFC = ({ + loading, + wrapperProps, + ...otherProps +}) => { + return ( + + + + ); +}; diff --git a/webapp/src/ee/glossary/components/GlossariesPanel.tsx b/webapp/src/ee/glossary/components/GlossariesPanel.tsx new file mode 100644 index 0000000000..4e6a636b92 --- /dev/null +++ b/webapp/src/ee/glossary/components/GlossariesPanel.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { + PanelContentData, + PanelContentProps, +} from 'tg.views/projects/translations/ToolsPanel/common/types'; +import { useGlossaryTermHighlights } from '../hooks/useGlossaryTermHighlights'; +import { TabMessage } from 'tg.views/projects/translations/ToolsPanel/common/TabMessage'; +import { T } from '@tolgee/react'; +import { Box, Button, styled, Tooltip } from '@mui/material'; +import { LinkExternal02 } from '@untitled-ui/icons-react'; +import { Link } from 'react-router-dom'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { GlossaryTermPreview } from './GlossaryTermPreview'; + +const StyledContainer = styled('div')` + display: flex; + flex-direction: column; + align-items: stretch; + margin-top: 4px; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledContent = styled(Box)` + display: flex; + flex-direction: column; + align-items: stretch; + gap: ${({ theme }) => theme.spacing(2)}; + margin: ${({ theme }) => theme.spacing(0, 1.5)}; +`; + +const fetchTermsHighlights = ({ keyData, baseLanguage }: PanelContentData) => { + const languageTag = baseLanguage.tag; + const text = keyData.translations[languageTag]?.text; + + return useGlossaryTermHighlights({ text, languageTag }); +}; + +export const GlossariesPanel: React.VFC = (data) => { + const { language, baseLanguage, project } = data; + const terms = fetchTermsHighlights(data); + + if (terms.length === 0) { + return ( + + + + + + + + } + > + + + + + + ); + } + + const found: number[] = []; + const previews = terms + .map((v) => v.value) + .filter((term) => { + if (found.includes(term.id)) { + return false; + } + found.push(term.id); + return true; + }) + .map((term) => { + return ( + + ); + }); + return ( + + {previews} + + ); +}; + +export const glossariesCount = (data: PanelContentData) => + fetchTermsHighlights(data).length; diff --git a/webapp/src/ee/glossary/components/GlossaryBaseLanguageSelect.tsx b/webapp/src/ee/glossary/components/GlossaryBaseLanguageSelect.tsx new file mode 100644 index 0000000000..0641509cf1 --- /dev/null +++ b/webapp/src/ee/glossary/components/GlossaryBaseLanguageSelect.tsx @@ -0,0 +1,131 @@ +import { components } from 'tg.service/apiSchema.generated'; +import React, { ComponentProps, useState } from 'react'; +import Box from '@mui/material/Box'; +import { useField, useFormikContext } from 'formik'; +import { useTranslate } from '@tolgee/react'; +import { useApiInfiniteQuery } from 'tg.service/http/useQueryApi'; +import { useDebounce } from 'use-debounce'; +import { SelectItem } from 'tg.component/searchSelect/SelectItem'; +import { InfiniteSearchSelect } from 'tg.component/searchSelect/InfiniteSearchSelect'; +import { LanguageValue } from 'tg.component/languages/LanguageValue'; +import { usePreferredOrganization } from 'tg.globalContext/helpers'; + +type OrganizationLanguageModel = + components['schemas']['OrganizationLanguageModel']; +type SelectedLanguageModel = { + tag: string; + name: string; + flagEmoji?: string; +}; + +type Props = { + name: string; + disabled?: boolean; +} & Omit, 'children'>; + +export const GlossaryBaseLanguageSelect: React.VFC = ({ + name, + disabled, + ...boxProps +}) => { + const { preferredOrganization } = usePreferredOrganization(); + const context = useFormikContext(); + const { t } = useTranslate(); + const [field, meta] = useField(name); + const value = field.value as SelectedLanguageModel | undefined; + // Formik returns error as object - schema is incorrect... + const error = (meta.error as any)?.tag; + + const [search, setSearch] = useState(''); + const [searchDebounced] = useDebounce(search, 500); + + const query = { + search: searchDebounced, + size: 30, + }; + const dataLoadable = useApiInfiniteQuery({ + url: '/v2/organizations/{organizationId}/languages', + method: 'get', + path: { organizationId: preferredOrganization!.id }, + query, + options: { + keepPreviousData: true, + refetchOnMount: true, + noGlobalLoading: true, + getNextPageParam: (lastPage) => { + if ( + lastPage.page && + lastPage.page.number! < lastPage.page.totalPages! - 1 + ) { + return { + path: { id: preferredOrganization!.id }, + query: { + ...query, + page: lastPage.page!.number! + 1, + }, + }; + } else { + return null; + } + }, + }, + }); + + const data = dataLoadable.data?.pages.flatMap( + (p) => p._embedded?.languages ?? [] + ); + + const handleFetchMore = () => { + if (dataLoadable.hasNextPage && !dataLoadable.isFetching) { + dataLoadable.fetchNextPage(); + } + }; + + const setValue = (v?: SelectedLanguageModel) => + context.setFieldValue(name, v); + const setSelected = (item: OrganizationLanguageModel) => { + const itemSelected: SelectedLanguageModel = { + tag: item.tag, + name: item.name, + flagEmoji: item.flagEmoji, + }; + setValue(itemSelected); + }; + + function renderItem(props: any, item: OrganizationLanguageModel) { + const selected = value?.tag === item.tag; + return ( + } + onClick={() => setSelected(item)} + /> + ); + } + + function labelItem(item: SelectedLanguageModel) { + return item.name + (item.flagEmoji ? ' ' + item.flagEmoji : ''); + } + + return ( + + item.tag} + search={search} + onClearSelected={() => setValue(undefined)} + onSearchChange={setSearch} + onFetchMore={handleFetchMore} + renderItem={renderItem} + labelItem={labelItem} + label={t('create_glossary_field_base_language')} + error={meta.touched && error} + searchPlaceholder={t('language_search_placeholder')} + disabled={disabled} + /> + + ); +}; diff --git a/webapp/src/ee/glossary/components/GlossaryBatchToolbar.tsx b/webapp/src/ee/glossary/components/GlossaryBatchToolbar.tsx new file mode 100644 index 0000000000..1f24516f2a --- /dev/null +++ b/webapp/src/ee/glossary/components/GlossaryBatchToolbar.tsx @@ -0,0 +1,134 @@ +import { + Card, + Checkbox, + MenuItem, + Select, + styled, + Typography, +} from '@mui/material'; +import React from 'react'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; +import { ChevronRight } from '@untitled-ui/icons-react'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { confirmation } from 'tg.hooks/confirmation'; +import { T } from '@tolgee/react'; +import { SelectionService } from 'tg.service/useSelectionService'; +import { messageService } from 'tg.service/MessageService'; +import { TranslatedError } from 'tg.translationTools/TranslatedError'; +import { usePreferredOrganization } from 'tg.globalContext/helpers'; +import { useGlossary } from 'tg.ee.module/glossary/hooks/useGlossary'; + +const StyledCard = styled(Card)` + display: flex; + align-items: center; + flex-wrap: wrap; + flex-direction: row; + gap: ${({ theme }) => theme.spacing(1.5)}; + padding: ${({ theme }) => theme.spacing(1, 1.5)}; + margin: ${({ theme }) => theme.spacing(2, 1)}; + // FIXME: The help button in corner is in a way when on small screen + margin-left: ${({ theme }) => theme.spacing(5)}; + border-radius: ${({ theme }) => theme.spacing(1)}; + background-color: ${({ theme }) => + theme.palette.mode === 'dark' + ? theme.palette.emphasis[200] + : theme.palette.emphasis[50]}; + transition: background-color 300ms ease-in-out, visibility 0ms; + -webkit-box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.25); + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.25); +`; + +const StyledCheckbox = styled(Checkbox)` + margin: ${({ theme }) => theme.spacing(0, -1.5, 0, -1)}; +`; + +type Props = { + selectionService: SelectionService; +}; + +export const GlossaryBatchToolbar: React.VFC = ({ + selectionService, +}) => { + const { preferredOrganization } = usePreferredOrganization(); + const glossary = useGlossary(); + + const deleteSelectedMutation = useApiMutation({ + url: '/v2/organizations/{organizationId}/glossaries/{glossaryId}/terms', + method: 'delete', + invalidatePrefix: + '/v2/organizations/{organizationId}/glossaries/{glossaryId}/terms', + }); + + const onDeleteSelected = () => { + confirmation({ + title: , + message: ( + + ), + onConfirm: () => { + deleteSelectedMutation.mutate( + { + path: { + organizationId: preferredOrganization!.id, + glossaryId: glossary.id, + }, + content: { + 'application/json': { + termIds: selectionService.selected, + }, + }, + }, + { + onSuccess() { + selectionService.unselectAll(); + }, + onError(e) { + messageService.error( + + ); + }, + } + ); + }, + }); + }; + + const canDelete = ['OWNER', 'MAINTAINER'].includes( + preferredOrganization?.currentUserRole || '' + ); + + return ( + 0 ? 'visible' : 'hidden', + }} + > + + {`${selectionService.selected.length} / ${selectionService.total}`} + + + + + + ); +}; diff --git a/webapp/src/ee/glossary/components/GlossaryEmptyListMessage.tsx b/webapp/src/ee/glossary/components/GlossaryEmptyListMessage.tsx new file mode 100644 index 0000000000..66c54ab1be --- /dev/null +++ b/webapp/src/ee/glossary/components/GlossaryEmptyListMessage.tsx @@ -0,0 +1,111 @@ +import React, { ComponentProps } from 'react'; +import { Box, Button, Card, Link, styled, Typography } from '@mui/material'; +import { PlusCircle, UploadCloud02 } from '@untitled-ui/icons-react'; +import { EmptyState } from 'tg.component/common/EmptyState'; +import { T } from '@tolgee/react'; + +const StyledBox = styled(Box)` + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; + align-items: center; + justify-content: center; + text-align: center; + margin: ${({ theme }) => theme.spacing(2)}; + flex-wrap: wrap; +`; + +const StyledCard = styled(Card)` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: ${({ theme }) => theme.spacing(2)}; + border-radius: 20px; + width: 490px; + padding: ${({ theme }) => theme.spacing(4)}; + background-color: ${({ theme }) => + theme.palette.tokens.background.onDefaultGrey}; +`; + +const StyledPlusCircle = styled(PlusCircle)` + color: ${({ theme }) => theme.palette.primary.light}; + width: 32px; + height: 32px; +`; + +const StyledUploadCloud02 = styled(UploadCloud02)` + color: ${({ theme }) => theme.palette.primary.light}; + width: 32px; + height: 32px; +`; + +const StyledDescription = styled(Typography)` + color: ${({ theme }) => theme.palette.text.secondary}; + margin-bottom: ${({ theme }) => theme.spacing(5)}; +`; + +type Props = { + loading?: boolean; + wrapperProps?: ComponentProps; + onCreate?: () => void; + onImport?: () => void; +}; + +export const GlossaryEmptyListMessage: React.VFC = ({ + loading, + wrapperProps, + onCreate, + onImport, +}) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/webapp/src/ee/glossary/components/GlossaryListItem.tsx b/webapp/src/ee/glossary/components/GlossaryListItem.tsx new file mode 100644 index 0000000000..9f5707783b --- /dev/null +++ b/webapp/src/ee/glossary/components/GlossaryListItem.tsx @@ -0,0 +1,139 @@ +import { Box, Grid, styled, Typography } from '@mui/material'; +import { useHistory } from 'react-router-dom'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import React from 'react'; +import { components } from 'tg.service/apiSchema.generated'; +import { T } from '@tolgee/react'; +import { CircledLanguageIconList } from 'tg.component/languages/CircledLanguageIconList'; +import { languageInfo } from '@tginternal/language-util/lib/generated/languageInfo'; +import { GlossaryListItemMenu } from 'tg.ee.module/glossary/components/GlossaryListItemMenu'; +import { usePreferredOrganization } from 'tg.globalContext/helpers'; + +type SimpleGlossaryModel = components['schemas']['SimpleGlossaryModel']; + +const StyledContainer = styled('div')` + display: grid; + grid-template-columns: 1fr 1fr 1fr 70px; + grid-template-areas: 'name projects languages controls'; + padding: ${({ theme }) => theme.spacing(1.5, 2.5)}; + align-items: center; + cursor: pointer; + tab-index: 0; + background-color: ${({ theme }) => theme.palette.background.default}; + @container (max-width: 599px) { + grid-gap: ${({ theme }) => theme.spacing(1, 2)}; + grid-template-columns: 1fr 1fr 70px; + grid-template-areas: + 'name projects controls' + 'languages languages languages'; + } +`; + +const StyledName = styled('div')` + grid-area: name; + display: flex; + overflow: hidden; + margin-right: ${({ theme }) => theme.spacing(2)}; + @container (max-width: 599px) { + margin-right: 0px; + } +`; + +const StyledProjects = styled('div')` + grid-area: projects; + display: flex; + overflow: hidden; + margin-right: ${({ theme }) => theme.spacing(2)}; + @container (max-width: 599px) { + margin-right: 0px; + } +`; + +const StyledLanguages = styled('div')` + grid-area: languages; + @container (max-width: 599px) { + justify-content: flex-start; + } +`; + +const StyledControls = styled('div')` + grid-area: controls; +`; + +const StyledNameText = styled(Typography)` + font-size: 16px; + font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-word; +`; + +type Props = { + glossary: SimpleGlossaryModel; +}; + +export const GlossaryListItem: React.VFC = ({ glossary }) => { + const { preferredOrganization } = usePreferredOrganization(); + + const history = useHistory(); + const assignedProjects = glossary.assignedProjects._embedded?.projects; + const languageTag = glossary.baseLanguageTag!; + const languageData = languageInfo[languageTag]; + const languages = [ + { + base: true, + flagEmoji: languageData?.flags?.[0] || '', + id: 0, + name: languageData?.englishName || languageTag, + originalName: languageData?.originalName || languageTag, + tag: languageTag, + }, + // Future improvement - display all languages used in glossary + // not just base language + ]; + + return ( + + history.push( + LINKS.ORGANIZATION_GLOSSARY.build({ + [PARAMS.GLOSSARY_ID]: glossary.id, + [PARAMS.ORGANIZATION_SLUG]: preferredOrganization?.slug || '', + }) + ) + } + > + + {glossary.name} + + + + {assignedProjects?.length === 1 ? ( + assignedProjects[0].name + ) : (assignedProjects?.length || 0) === 0 ? ( + + ) : ( + + )} + + + + + + + + + + + + + + ); +}; diff --git a/webapp/src/ee/glossary/components/GlossaryListItemMenu.tsx b/webapp/src/ee/glossary/components/GlossaryListItemMenu.tsx new file mode 100644 index 0000000000..2b2ebe4916 --- /dev/null +++ b/webapp/src/ee/glossary/components/GlossaryListItemMenu.tsx @@ -0,0 +1,127 @@ +import { IconButton, Menu, MenuItem, Tooltip } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; +import React, { FC } from 'react'; +import { DotsVertical } from '@untitled-ui/icons-react'; +import { stopBubble } from 'tg.fixtures/eventHandler'; +import { components } from 'tg.service/apiSchema.generated'; +import { confirmation } from 'tg.hooks/confirmation'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { Link } from 'react-router-dom'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { GlossaryCreateEditDialog } from 'tg.ee.module/glossary/views/GlossaryCreateEditDialog'; +import { usePreferredOrganization } from 'tg.globalContext/helpers'; + +type SimpleGlossaryModel = components['schemas']['SimpleGlossaryModel']; + +type Props = { + glossary: SimpleGlossaryModel; +}; + +export const GlossaryListItemMenu: FC = ({ glossary }) => { + const { preferredOrganization } = usePreferredOrganization(); + + const { t } = useTranslate(); + const [anchorEl, setAnchorEl] = React.useState(null); + const [isEditing, setIsEditing] = React.useState(false); + + const canManage = ['OWNER', 'MAINTAINER'].includes( + preferredOrganization?.currentUserRole || '' + ); + + const deleteMutation = useApiMutation({ + url: '/v2/organizations/{organizationId}/glossaries/{glossaryId}', + method: 'delete', + invalidatePrefix: '/v2/organizations/{organizationId}/glossaries', + }); + + const handleOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const onDelete = () => { + setAnchorEl(null); + confirmation({ + title: , + message: , + hardModeText: glossary.name.toUpperCase(), + onConfirm() { + deleteMutation.mutate({ + path: { + organizationId: preferredOrganization!.id, + glossaryId: glossary.id, + }, + }); + }, + }); + }; + + return ( + <> + + { + e.stopPropagation(); + handleOpen(e); + }} + data-cy="glossaries-list-more-button" + aria-label={t('glossaries_list_more_button')} + size="small" + > + + + + + setAnchorEl(null)} + onClick={stopBubble()} + > + + + + {canManage && ( + { + setAnchorEl(null); + setIsEditing(true); + }} + > + + + )} + {canManage && ( + + + + )} + + + {isEditing && ( + setIsEditing(false)} + onFinished={() => setIsEditing(false)} + editGlossaryId={glossary.id} + /> + )} + + ); +}; diff --git a/webapp/src/ee/glossary/components/GlossaryListStyledRowCell.tsx b/webapp/src/ee/glossary/components/GlossaryListStyledRowCell.tsx new file mode 100644 index 0000000000..d91e773825 --- /dev/null +++ b/webapp/src/ee/glossary/components/GlossaryListStyledRowCell.tsx @@ -0,0 +1,22 @@ +import { styled } from '@mui/material'; +import Box from '@mui/material/Box'; + +export const GlossaryListStyledRowCell = styled(Box)` + display: grid; + padding: ${({ theme }) => theme.spacing(1.5, 1.5)}; + border-top: 1px solid ${({ theme }) => theme.palette.divider1}; + + &.clickable { + cursor: pointer; + + &:focus-within { + background: ${({ theme }) => theme.palette.cell.selected}; + } + + &:hover { + --cell-background: ${({ theme }) => theme.palette.cell.hover}; + background: ${({ theme }) => theme.palette.cell.hover}; + transition: background 0.1s ease-in; + } + } +`; diff --git a/webapp/src/ee/glossary/components/GlossaryListTermCell.tsx b/webapp/src/ee/glossary/components/GlossaryListTermCell.tsx new file mode 100644 index 0000000000..8ab9de3224 --- /dev/null +++ b/webapp/src/ee/glossary/components/GlossaryListTermCell.tsx @@ -0,0 +1,109 @@ +import { Checkbox, styled } from '@mui/material'; +import { GlossaryListStyledRowCell } from 'tg.ee.module/glossary/components/GlossaryListStyledRowCell'; +import clsx from 'clsx'; +import React from 'react'; +import { components } from 'tg.service/apiSchema.generated'; +import { LimitedHeightText } from 'tg.component/LimitedHeightText'; +import Box from '@mui/material/Box'; +import { GlossaryTermCreateUpdateDialog } from 'tg.ee.module/glossary/views/GlossaryTermCreateUpdateDialog'; +import { GlossaryTermTags } from 'tg.ee.module/glossary/components/GlossaryTermTags'; +import { SelectionService } from 'tg.service/useSelectionService'; + +type SimpleGlossaryTermWithTranslationsModel = + components['schemas']['SimpleGlossaryTermWithTranslationsModel']; + +const StyledRowTermCell = styled(GlossaryListStyledRowCell)` + grid-template-areas: + 'checkbox text' + '. description' + '. tags'; + '.. ..'; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto auto 1fr; +`; + +const StyledCheckbox = styled(Checkbox)` + grid-area: checkbox; + margin-left: ${({ theme }) => theme.spacing(-1.5)}; + margin-top: ${({ theme }) => theme.spacing(-1.5)}; +`; + +const StyledText = styled(Box)` + grid-area: text; + overflow: hidden; + margin-bottom: ${({ theme }) => theme.spacing(0.5)}; +`; + +const StyledDescription = styled(Box)` + grid-area: description; + overflow: hidden; + margin: ${({ theme }) => theme.spacing(0.5, 0)}; + color: ${({ theme }) => theme.palette.text.secondary}; + font-size: ${({ theme }) => theme.typography.caption.fontSize}; +`; + +const StyledTags = styled(Box)` + grid-area: tags; + margin-top: ${({ theme }) => theme.spacing(0.5)}; + margin-bottom: ${({ theme }) => theme.spacing(0.25)}; +`; + +type Props = { + item: SimpleGlossaryTermWithTranslationsModel; + editEnabled: boolean; + baseLanguage: string | undefined; + selectionService: SelectionService; +}; + +export const GlossaryListTermCell: React.VFC = ({ + item, + editEnabled, + baseLanguage, + selectionService, +}) => { + const [isEditingTerm, setIsEditingTerm] = React.useState(false); + + const baseTranslation = item.translations?.find( + (t) => t.languageTag === baseLanguage + ); + + return ( + setIsEditingTerm(true) : undefined + } + > + selectionService.toggle(item.id)} + onClick={(e) => e.stopPropagation()} + disabled={selectionService.isLoading} + /> + + + {baseTranslation?.text} + + + {item.description && ( + + {item.description} + + )} + + + + {editEnabled && isEditingTerm && ( + setIsEditingTerm(false)} + onFinished={() => setIsEditingTerm(false)} + editTermId={item.id} + /> + )} + + ); +}; diff --git a/webapp/src/ee/glossary/components/GlossaryListTranslationCell.tsx b/webapp/src/ee/glossary/components/GlossaryListTranslationCell.tsx new file mode 100644 index 0000000000..046f37aa3d --- /dev/null +++ b/webapp/src/ee/glossary/components/GlossaryListTranslationCell.tsx @@ -0,0 +1,180 @@ +import clsx from 'clsx'; +import { Button, styled, Tooltip } from '@mui/material'; +import React from 'react'; +import { GlossaryListStyledRowCell } from 'tg.ee.module/glossary/components/GlossaryListStyledRowCell'; +import { components } from 'tg.service/apiSchema.generated'; +import { TextField } from 'tg.component/common/TextField'; +import { T } from '@tolgee/react'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import Box from '@mui/material/Box'; +import { LimitedHeightText } from 'tg.component/LimitedHeightText'; +import { usePreferredOrganization } from 'tg.globalContext/helpers'; +import { useGlossary } from 'tg.ee.module/glossary/hooks/useGlossary'; + +type GlossaryTermTranslationModel = + components['schemas']['GlossaryTermTranslationModel']; + +const StyledRowTranslationCell = styled(GlossaryListStyledRowCell)` + grid-template-areas: 'text'; + + border-left: 1px solid ${({ theme }) => theme.palette.divider1}; + + &.editing { + z-index: 1; + background: transparent !important; + box-shadow: ${({ theme }) => + theme.palette.mode === 'dark' + ? '0px 0px 7px rgba(0, 0, 0, 1)' + : '0px 0px 10px rgba(0, 0, 0, 0.2)'} !important; + } +`; + +const StyledEditBox = styled('div')` + grid-area: text; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; + flex-flow: column; +`; + +const StyledControls = styled('div')` + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + flex-grow: 1; +`; + +type Props = { + termId: number; + translation?: GlossaryTermTranslationModel; + languageTag: string; + editEnabled: boolean; + editDisabledReason?: React.ReactNode; + isEditing?: boolean; + onEdit?: () => void; + onCancel?: () => void; + onSave?: () => void; +}; + +export const GlossaryListTranslationCell: React.VFC = ({ + termId, + translation, + languageTag, + editEnabled, + editDisabledReason, + isEditing, + onEdit, + onCancel, + onSave, +}) => { + const { preferredOrganization } = usePreferredOrganization(); + const glossary = useGlossary(); + + const [value, setValue] = React.useState(translation?.text || ''); + const [replacementText, setReplacementText] = React.useState(''); + + const isSaveLoading = false; + + const saveMutation = useApiMutation({ + url: '/v2/organizations/{organizationId}/glossaries/{glossaryId}/terms/{termId}/translations', + method: 'post', + invalidatePrefix: + '/v2/organizations/{organizationId}/glossaries/{glossaryId}/terms', + }); + + const handleEdit = () => { + onEdit?.(); + setValue(replacementText || translation?.text || ''); + }; + + const save = () => { + saveMutation.mutate( + { + path: { + organizationId: preferredOrganization!.id, + glossaryId: glossary.id, + termId, + }, + content: { + 'application/json': { + languageTag: languageTag, + text: value, + }, + }, + }, + { + onSuccess: () => { + setReplacementText(value); + onSave?.(); + }, + } + ); + }; + + const onHandleEdit = editEnabled && !isEditing ? handleEdit : undefined; + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + if (e.shiftKey) { + return; + } + + e.preventDefault(); + save(); + } + }; + + return ( + + + {!isEditing ? ( + + + {translation?.text} + + + ) : ( + + { + setValue(e.target.value); + }} + value={value} + onKeyDown={handleKeyDown} + multiline + minRows={3} + autoFocus + /> + + + + + + + + )} + + + ); +}; diff --git a/webapp/src/ee/glossary/components/GlossaryTermPreview.tsx b/webapp/src/ee/glossary/components/GlossaryTermPreview.tsx new file mode 100644 index 0000000000..a8256f91a5 --- /dev/null +++ b/webapp/src/ee/glossary/components/GlossaryTermPreview.tsx @@ -0,0 +1,133 @@ +import { + Box, + Card, + IconButton, + styled, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; +import React from 'react'; +import { GlossaryTermPreviewProps } from '../../../eeSetup/EeModuleType'; +import { + ArrowNarrowRight, + BookClosed, + LinkExternal02, +} from '@untitled-ui/icons-react'; +import { GlossaryTermTags } from 'tg.ee.module/glossary/components/GlossaryTermTags'; +import { languageInfo } from '@tginternal/language-util/lib/generated/languageInfo'; +import { FlagImage } from 'tg.component/languages/FlagImage'; +import { T } from '@tolgee/react'; +import { Link } from 'react-router-dom'; +import { getGlossaryTermSearchUrl } from 'tg.constants/links'; + +const StyledContainer = styled(Box)` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(1.5)}; +`; + +const StyledInnerCard = styled(Card)` + padding: ${({ theme }) => theme.spacing(1.5)}; + background-color: ${({ theme }) => theme.palette.tokens.text._states.hover}; +`; + +const StyledTitleWrapper = styled(Box)` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledTitleTextWrapper = styled(Box)` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(1)}; + margin-right: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledTitle = styled(Typography)``; + +const StyledGap = styled('div')` + flex-grow: 1; +`; + +const StyledDescription = styled(Typography)` + color: ${({ theme }) => theme.palette.text.secondary}; + font-size: 13px; +`; + +const StyledEmptyDescription = styled(StyledDescription)` + font-style: italic; +`; + +export const GlossaryTermPreview: React.VFC = ({ + term, + languageTag, + targetLanguageTag, + showIcon, +}) => { + const theme = useTheme(); + const realLanguageTag = term.flagNonTranslatable + ? term.glossary.baseLanguageTag + : languageTag; + const translation = term.translations.find( + (t) => t.languageTag === realLanguageTag + ); + const targetTranslation = term.translations.find( + (t) => t.languageTag === targetLanguageTag + ); + const targetLanguageFlag = targetLanguageTag + ? languageInfo[targetLanguageTag]?.flags?.[0] + : undefined; + return ( + + + {showIcon && } + + {translation?.text} + {targetTranslation && + languageTag != targetLanguageTag && + !term.flagNonTranslatable && ( + <> + + {targetLanguageFlag && ( + + )} + + {targetTranslation.text} + + + )} + + + } + > + + + + + + + + {term.description ? ( + {term.description} + ) : ( + + + + )} + + + ); +}; diff --git a/webapp/src/ee/glossary/components/GlossaryTermTags.tsx b/webapp/src/ee/glossary/components/GlossaryTermTags.tsx new file mode 100644 index 0000000000..f0569410d6 --- /dev/null +++ b/webapp/src/ee/glossary/components/GlossaryTermTags.tsx @@ -0,0 +1,65 @@ +import { components } from 'tg.service/apiSchema.generated'; +import { Chip, styled, useTheme } from '@mui/material'; +import React from 'react'; +import { PropsOf } from '@emotion/react/dist/emotion-react.cjs'; +import { useTranslate } from '@tolgee/react'; + +type SimpleGlossaryTermModel = components['schemas']['SimpleGlossaryTermModel']; + +const StyledTags = styled('div')` + display: flex; + flex-wrap: wrap; + align-items: flex-start; + overflow: hidden; + + gap: ${({ theme }) => theme.spacing(0.5)}; +`; + +const CustomizedTag: React.VFC> = (props) => { + const theme = useTheme(); + return ( + + ); +}; + +type Props = { + term: SimpleGlossaryTermModel; +}; + +export const GlossaryTermTags: React.VFC = ({ term }) => { + const { t } = useTranslate(); + + const hasTags = + term.flagNonTranslatable || + term.flagCaseSensitive || + term.flagAbbreviation || + term.flagForbiddenTerm; + + if (!hasTags) { + return null; + } + + return ( + + {term.flagNonTranslatable && ( + + )} + {term.flagCaseSensitive && ( + + )} + {term.flagAbbreviation && ( + + )} + {term.flagForbiddenTerm && ( + + )} + + ); +}; diff --git a/webapp/src/ee/glossary/components/GlossaryViewBody.tsx b/webapp/src/ee/glossary/components/GlossaryViewBody.tsx new file mode 100644 index 0000000000..7cefa8fe3e --- /dev/null +++ b/webapp/src/ee/glossary/components/GlossaryViewBody.tsx @@ -0,0 +1,366 @@ +import { Box, Button, Portal, styled, useMediaQuery } from '@mui/material'; +import { SecondaryBarSearchField } from 'tg.component/layout/SecondaryBarSearchField'; +import { GlossaryViewLanguageSelect } from 'tg.ee.module/glossary/components/GlossaryViewLanguageSelect'; +import { BaseViewAddButton } from 'tg.component/layout/BaseViewAddButton'; +import clsx from 'clsx'; +import { ChevronLeft, ChevronRight } from '@untitled-ui/icons-react'; +import { T, useTranslate } from '@tolgee/react'; +import { GlossaryViewListHeader } from 'tg.ee.module/glossary/components/GlossaryViewListHeader'; +import { ReactList } from 'tg.component/reactList/ReactList'; +import { + estimateGlossaryViewListRowHeight, + GlossaryViewListRow, +} from 'tg.ee.module/glossary/components/GlossaryViewListRow'; +import React, { useRef, useState } from 'react'; +import { components } from 'tg.service/apiSchema.generated'; +import { useResizeObserver } from 'usehooks-ts'; +import { useScrollStatus } from 'tg.component/common/useScrollStatus'; +import { GlossaryBatchToolbar } from 'tg.ee.module/glossary/components/GlossaryBatchToolbar'; +import { useSelectionService } from 'tg.service/useSelectionService'; +import { EmptyListMessage } from 'tg.component/common/EmptyListMessage'; + +type SimpleGlossaryTermWithTranslationsModel = + components['schemas']['SimpleGlossaryTermWithTranslationsModel']; + +const ARROW_SIZE = 50; + +const StyledContainer = styled('div')` + position: relative; + display: grid; + margin: 0px; + border-left: 0px; + border-right: 0px; + background: ${({ theme }) => theme.palette.background.default}; + flex-grow: 1; + + &::before { + content: ''; + height: 100%; + position: absolute; + width: 6px; + background-image: linear-gradient(90deg, #0000002c, transparent); + top: 0px; + left: 0px; + z-index: 10; + pointer-events: none; + opacity: 0; + transition: opacity 100ms ease-in-out; + } + + &::after { + content: ''; + height: 100%; + position: absolute; + width: 6px; + background-image: linear-gradient(-90deg, #0000002c, transparent); + top: 0px; + right: 0px; + z-index: 10; + pointer-events: none; + opacity: 0; + transition: opacity 100ms ease-in-out; + } + + &.scrollLeft { + &::before { + opacity: 1; + } + } + + &.scrollRight { + &::after { + opacity: 1; + } + } +`; + +const StyleTermsCount = styled('div')` + color: ${({ theme }) => theme.palette.text.secondary}; + margin-top: ${({ theme }) => theme.spacing(1)}; + margin-bottom: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledVerticalScroll = styled('div')` + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + scroll-behavior: smooth; +`; + +const StyledContent = styled('div')` + position: relative; +`; + +const StyledContainerInner = styled(Box)` + display: grid; + width: 100%; + margin: 0px auto; + margin-top: 0px; + margin-bottom: 0px; +`; + +const StyledScrollArrow = styled('div')` + position: fixed; + top: 50vh; + width: ${ARROW_SIZE / 2}px; + height: ${ARROW_SIZE}px; + z-index: 5; + cursor: pointer; + border: 1px solid ${({ theme }) => theme.palette.divider1}; + background: ${({ theme }) => theme.palette.background.default}; + opacity: 0; + transition: opacity 150ms ease-in-out; + pointer-events: none; + + display: flex; + align-items: center; + justify-content: center; + + &.right { + border-radius: ${ARROW_SIZE}px 0px 0px ${ARROW_SIZE}px; + padding-left: 4px; + border-right: none; + } + + &.left { + border-radius: 0px ${ARROW_SIZE}px ${ARROW_SIZE}px 0px; + padding-right: 4px; + border-left: none; + } + + &.scrollLeft { + opacity: 1; + pointer-events: all; + } + + &.scrollRight { + opacity: 1; + pointer-events: all; + } +`; + +const StyledBatchToolbarWrapper = styled(Box)` + position: fixed; + bottom: 0; + z-index: ${({ theme }) => theme.zIndex.drawer}; +`; + +type Props = { + loading?: boolean; + data?: SimpleGlossaryTermWithTranslationsModel[]; + fetchDataIds: () => Promise; + totalElements?: number; + baseLanguage?: string; + selectedLanguages?: string[]; + selectedLanguagesWithBaseLanguage?: string[]; + updateSelectedLanguages: (languages: string[]) => void; + onFetchNextPage?: () => void; + onCreate?: () => void; + onSearch?: (search: string) => void; + search?: string; +}; + +export const GlossaryViewBody: React.VFC = ({ + loading, + data = [], + fetchDataIds, + totalElements, + baseLanguage, + selectedLanguages, + selectedLanguagesWithBaseLanguage, + updateSelectedLanguages, + onFetchNextPage, + onCreate, + onSearch, + search, +}) => { + const tableRef = useRef(null); + const verticalScrollRef = useRef(null); + const reactListRef = useRef(null); + const clearSearchRef = useRef<(() => void) | null>(null); + + const { t } = useTranslate(); + + const [editingTranslation, setEditingTranslation] = useState< + [number | undefined, string | undefined] + >([undefined, undefined]); + + const selectionService = useSelectionService({ + totalCount: totalElements, + itemsAll: fetchDataIds, + }); + + const [tablePosition, setTablePosition] = useState({ left: 0, right: 0 }); + useResizeObserver({ + ref: tableRef, + onResize: () => { + const position = tableRef.current?.getBoundingClientRect(); + if (position) { + const left = position?.left; + const right = window.innerWidth - position?.right; + setTablePosition({ left, right }); + } + }, + }); + const hasMinimalHeight = useMediaQuery('(min-height: 400px)'); + + const [scrollLeft, scrollRight] = useScrollStatus(verticalScrollRef, [ + selectedLanguages, + tablePosition, + ]); + + const handleScroll = (direction: 'left' | 'right') => { + const element = verticalScrollRef.current; + if (element) { + const position = element.scrollLeft; + element.scrollTo({ + left: position + (direction === 'left' ? -350 : +350), + }); + } + }; + + const renderItem = (index: number) => { + const row = data[index]; + const isLast = index === data.length - 1; + if (isLast) { + onFetchNextPage?.(); + } + + return ( + { + setEditingTranslation([termId, languageTag]); + }} + editingTranslation={editingTranslation} + selectedLanguages={selectedLanguages} + selectionService={selectionService} + /> + ); + }; + + return ( + <> + {data && ( + + + + + + + + + + + {onCreate && ( + + )} + + + + + )} + + + {hasMinimalHeight && ( + + handleScroll('right')} + > + + + handleScroll('left')} + > + + + + )} + {data.length === 0 ? ( + + + + } + > + + + ) : ( + + + + + + + + + + cache[index] || estimateGlossaryViewListRowHeight(data[index]) + } + // @ts-ignore + scrollParentGetter={() => window} + length={data.length} + useTranslate3d + itemRenderer={renderItem} + /> + + + )} + + + + + + + + ); +}; diff --git a/webapp/src/ee/glossary/components/GlossaryViewLanguageSelect.tsx b/webapp/src/ee/glossary/components/GlossaryViewLanguageSelect.tsx new file mode 100644 index 0000000000..ba6dda6909 --- /dev/null +++ b/webapp/src/ee/glossary/components/GlossaryViewLanguageSelect.tsx @@ -0,0 +1,185 @@ +import { components } from 'tg.service/apiSchema.generated'; +import React, { ComponentProps, useEffect, useMemo, useState } from 'react'; +import Box from '@mui/material/Box'; +import { useTranslate } from '@tolgee/react'; +import { useApiInfiniteQuery, useApiQuery } from 'tg.service/http/useQueryApi'; +import { useDebounce } from 'use-debounce'; +import { LanguageValue } from 'tg.component/languages/LanguageValue'; +import { languageInfo } from '@tginternal/language-util/lib/generated/languageInfo'; +import { InfiniteMultiSearchSelect } from 'tg.component/searchSelect/InfiniteMultiSearchSelect'; +import { MultiselectItem } from 'tg.component/searchSelect/MultiselectItem'; +import { usePreferredOrganization } from 'tg.globalContext/helpers'; +import { useGlossary } from 'tg.ee.module/glossary/hooks/useGlossary'; + +type OrganizationLanguageModel = + components['schemas']['OrganizationLanguageModel']; + +type Props = { + disabled?: boolean; + value: string[] | undefined; + onValueChange: (value: string[]) => void; +} & Omit, 'children'>; + +export const GlossaryViewLanguageSelect: React.VFC = ({ + disabled, + value, + onValueChange, + ...boxProps +}) => { + const { preferredOrganization } = usePreferredOrganization(); + const glossary = useGlossary(); + + const { t } = useTranslate(); + + const [search, setSearch] = useState(''); + const [searchDebounced] = useDebounce(search, 500); + + const priorityDataLoadable = useApiQuery({ + url: '/v2/organizations/{organizationId}/glossaries/{glossaryId}/languages', + method: 'get', + path: { + organizationId: preferredOrganization!.id, + glossaryId: glossary.id, + }, + }); + + const query = { + search: searchDebounced, + size: 30, + }; + const dataLoadable = useApiInfiniteQuery({ + url: '/v2/organizations/{organizationId}/languages', + method: 'get', + path: { organizationId: preferredOrganization!.id }, + query, + options: { + keepPreviousData: true, + refetchOnMount: true, + noGlobalLoading: true, + getNextPageParam: (lastPage) => { + if ( + lastPage.page && + lastPage.page.number! < lastPage.page.totalPages! - 1 + ) { + return { + path: { id: preferredOrganization!.id }, + query: { + ...query, + page: lastPage.page!.number! + 1, + }, + }; + } else { + return null; + } + }, + }, + }); + + useEffect(() => { + if (value === undefined && priorityDataLoadable.data && dataLoadable.data) { + const langs = + priorityDataLoadable.data._embedded?.glossaryLanguageDtoList?.map( + (l) => l.tag + ) ?? []; + const extraLangs = + dataLoadable.data.pages[0]?._embedded?.languages?.map((l) => l.tag) ?? + []; + extraLangs.forEach((l) => { + if (!langs.includes(l)) { + langs.push(l); + } + }); + onValueChange(langs); + } + }, [value, priorityDataLoadable.data, dataLoadable.data]); + + const dataExtra = useMemo(() => { + return dataLoadable.data?.pages.flatMap( + (p) => p._embedded?.languages ?? [] + ); + }, [dataLoadable.data]); + + const data: OrganizationLanguageModel[] = useMemo(() => { + const priorityLangs = + priorityDataLoadable.data?._embedded?.glossaryLanguageDtoList + ?.toSorted((a, b) => { + if (a.base === b.base) return 0; + return a.base ? -1 : 1; + }) + ?.map((l) => { + const languageData = languageInfo[l.tag]; + return { + base: l.base, + tag: l.tag, + flagEmoji: languageData?.flags?.[0] || '', + originalName: languageData?.originalName || l.tag, + name: languageData?.englishName || l.tag, + }; + }) || []; + const extraLangs = + dataExtra + ?.filter((l) => !priorityLangs.some((pl) => pl.tag === l.tag)) + ?.toSorted((a, b) => { + if (a.base === b.base) return 0; + return a.base ? -1 : 1; + }) + ?.map((l) => ({ ...l, base: false })) || []; + return [...priorityLangs, ...extraLangs]; + }, [priorityDataLoadable.data, dataExtra]); + + const handleFetchMore = () => { + if (dataLoadable.hasNextPage && !dataLoadable.isFetching) { + dataLoadable.fetchNextPage(); + } + }; + + const toggleSelected = (item: OrganizationLanguageModel) => { + if (value === undefined) { + onValueChange([item.tag]); + return; + } + + if (value.some((v) => v === item.tag)) { + onValueChange(value.filter((v) => item.tag !== v)); + return; + } + onValueChange([item.tag, ...value]); + }; + + function renderItem(props: any, item: OrganizationLanguageModel) { + const selected = value?.includes(item.tag) || false; + return ( + } + onClick={() => toggleSelected(item)} + /> + ); + } + + function labelItem(item: string) { + return item; + } + + return ( + + item.tag} + search={search} + onClearSelected={() => onValueChange([])} + onSearchChange={setSearch} + onFetchMore={handleFetchMore} + renderItem={renderItem} + labelItem={labelItem} + searchPlaceholder={t('language_search_placeholder')} + disabled={disabled} + minHeight={false} + /> + + ); +}; diff --git a/webapp/src/ee/glossary/components/GlossaryViewListHeader.tsx b/webapp/src/ee/glossary/components/GlossaryViewListHeader.tsx new file mode 100644 index 0000000000..2b603d1b2c --- /dev/null +++ b/webapp/src/ee/glossary/components/GlossaryViewListHeader.tsx @@ -0,0 +1,77 @@ +import { Box, Checkbox, styled } from '@mui/material'; +import { T } from '@tolgee/react'; +import { languageInfo } from '@tginternal/language-util/lib/generated/languageInfo'; +import { + CellLanguage, + CellLanguageModel, +} from 'tg.views/projects/translations/TranslationsTable/CellLanguage'; +import React from 'react'; +import { SelectionService } from 'tg.service/useSelectionService'; + +const StyledHeaderRow = styled('div')` + position: sticky; + background: ${({ theme }) => theme.palette.background.default}; + top: 0px; + margin-bottom: -1px; + display: grid; +`; + +const StyledHeaderCell = styled('div')` + border-top: 1px solid ${({ theme }) => theme.palette.divider1}; + box-sizing: border-box; + display: flex; + flex-grow: 0; + align-content: center; + align-items: center; + overflow: hidden; +`; + +const StyledHeaderLanguageCell = styled(StyledHeaderCell)` + border-left: 1px solid ${({ theme }) => theme.palette.divider1}; +`; + +type Props = { + selectedLanguages: string[] | undefined; + selectionService: SelectionService; +}; + +export const GlossaryViewListHeader: React.VFC = ({ + selectedLanguages, + selectionService, +}) => { + return ( + + + + + + + + {selectedLanguages?.map((tag, i) => { + const languageData = languageInfo[tag]; + const language: CellLanguageModel = { + base: false, + flagEmoji: languageData?.flags?.[0] || '', + name: languageData?.englishName || tag, + }; + return ( + + + + ); + })} + + ); +}; diff --git a/webapp/src/ee/glossary/components/GlossaryViewListRow.tsx b/webapp/src/ee/glossary/components/GlossaryViewListRow.tsx new file mode 100644 index 0000000000..bfd005b7b2 --- /dev/null +++ b/webapp/src/ee/glossary/components/GlossaryViewListRow.tsx @@ -0,0 +1,104 @@ +import { styled } from '@mui/material'; +import React from 'react'; +import { components } from 'tg.service/apiSchema.generated'; +import { GlossaryListTranslationCell } from 'tg.ee.module/glossary/components/GlossaryListTranslationCell'; +import { GlossaryListTermCell } from 'tg.ee.module/glossary/components/GlossaryListTermCell'; +import { SelectionService } from 'tg.service/useSelectionService'; +import { T } from '@tolgee/react'; +import { usePreferredOrganization } from 'tg.globalContext/helpers'; + +type SimpleGlossaryTermWithTranslationsModel = + components['schemas']['SimpleGlossaryTermWithTranslationsModel']; + +const StyledRow = styled('div')` + display: grid; + position: relative; + grid-auto-columns: minmax(350px, 1fr); + grid-auto-flow: column; + + &.deleted { + text-decoration: line-through; + pointer-events: none; + } +`; + +type Props = { + item: SimpleGlossaryTermWithTranslationsModel; + baseLanguage: string | undefined; + editingTranslation: [number | undefined, string | undefined]; + onEditTranslation: (termId?: number, languageTag?: string) => void; + selectedLanguages: string[] | undefined; + selectionService: SelectionService; +}; + +export const GlossaryViewListRow: React.VFC = ({ + item, + baseLanguage, + editingTranslation, + onEditTranslation, + selectedLanguages, + selectionService, +}) => { + const { preferredOrganization } = usePreferredOrganization(); + + const editEnabled = ['OWNER', 'MAINTAINER'].includes( + preferredOrganization?.currentUserRole || '' + ); + + const [editingTermId, editingLanguageTag] = editingTranslation; + + return ( + + + {selectedLanguages?.map((tag, i) => { + const realTag = item.flagNonTranslatable ? baseLanguage : tag; + const translation = item.translations?.find( + (t) => t.languageTag === realTag + ); + return ( + + ) + } + isEditing={editingTermId === item.id && editingLanguageTag === tag} + onEdit={() => onEditTranslation(item.id, tag)} + onCancel={() => onEditTranslation(item.id, undefined)} + onSave={() => onEditTranslation(item.id, undefined)} + /> + ); + })} + + ); +}; + +export const estimateGlossaryViewListRowHeight = ( + row?: SimpleGlossaryTermWithTranslationsModel +): number => { + if (!row) { + return 84; + } + + const base = 58; + const tags = + row.flagNonTranslatable || + row.flagAbbreviation || + row.flagCaseSensitive || + row.flagForbiddenTerm + ? 25 + : 0; + const description = row.description ? 26 : 0; + + return base + tags + description; +}; diff --git a/webapp/src/ee/glossary/components/ProjectSelect.tsx b/webapp/src/ee/glossary/components/ProjectSelect.tsx new file mode 100644 index 0000000000..53d8f7069e --- /dev/null +++ b/webapp/src/ee/glossary/components/ProjectSelect.tsx @@ -0,0 +1,66 @@ +import { components } from 'tg.service/apiSchema.generated'; +import React, { ComponentProps, ReactNode } from 'react'; +import Box from '@mui/material/Box'; +import { FieldLabel } from 'tg.component/FormField'; +import { Select } from 'tg.component/common/Select'; +import { MenuItem } from '@mui/material'; +import { useFormikContext } from 'formik'; +import { useTranslate } from '@tolgee/react'; + +type SimpleProjectModel = components['schemas']['SimpleProjectModel']; + +type Props = { + label?: ReactNode; + name: string; + valueKey?: React.KeyOf; + available: SimpleProjectModel[]; +} & Omit, 'children'>; + +export const ProjectSelect: React.VFC = ({ + label, + name, + valueKey = 'id', + available, + ...boxProps +}) => { + const context = useFormikContext(); + const { t } = useTranslate(); + const value = context.getFieldProps(name).value as React.Key[]; + + return ( + + {label} + + + ); +}; diff --git a/webapp/src/ee/glossary/hooks/GlossaryContext.tsx b/webapp/src/ee/glossary/hooks/GlossaryContext.tsx new file mode 100644 index 0000000000..e864984f25 --- /dev/null +++ b/webapp/src/ee/glossary/hooks/GlossaryContext.tsx @@ -0,0 +1,53 @@ +import { createProvider } from 'tg.fixtures/createProvider'; +import { useGlobalLoading } from 'tg.component/GlobalLoading'; +import { DashboardPage } from 'tg.component/layout/DashboardPage'; +import { GlobalError } from 'tg.error/GlobalError'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { components } from 'tg.service/apiSchema.generated'; + +type Props = { + organizationId?: number; + glossaryId?: number; +}; + +type ContextData = { + glossary: components['schemas']['GlossaryModel']; +}; + +export const [GlossaryContext, useGlossaryActions, useGlossaryContext] = + createProvider(({ organizationId, glossaryId }: Props) => { + const dataAvailable = + organizationId !== undefined && glossaryId !== undefined; + + const glossary = useApiQuery({ + url: '/v2/organizations/{organizationId}/glossaries/{glossaryId}', + method: 'get', + path: { organizationId: organizationId!, glossaryId: glossaryId! }, + options: { + enabled: dataAvailable, + }, + }); + + const isLoading = glossary.isLoading; + + useGlobalLoading(isLoading || !dataAvailable); + + if (isLoading || !dataAvailable) { + return ; + } + + if (glossary.error || !glossary.data) { + throw new GlobalError( + 'Unexpected error occurred', + glossary.error?.code || 'Loadable error' + ); + } + + const contextData: ContextData = { + glossary: glossary.data, + }; + + const actions = {}; + + return [contextData, actions]; + }); diff --git a/webapp/src/ee/glossary/hooks/useGlossary.ts b/webapp/src/ee/glossary/hooks/useGlossary.ts new file mode 100644 index 0000000000..e73ecd24dd --- /dev/null +++ b/webapp/src/ee/glossary/hooks/useGlossary.ts @@ -0,0 +1,6 @@ +import { components } from 'tg.service/apiSchema.generated'; +import { useGlossaryContext } from 'tg.ee.module/glossary/hooks/GlossaryContext'; + +export const useGlossary = (): components['schemas']['GlossaryModel'] => { + return useGlossaryContext((c) => c.glossary); +}; diff --git a/webapp/src/ee/glossary/hooks/useGlossaryTermHighlights.ts b/webapp/src/ee/glossary/hooks/useGlossaryTermHighlights.ts new file mode 100644 index 0000000000..33b6bd6ccb --- /dev/null +++ b/webapp/src/ee/glossary/hooks/useGlossaryTermHighlights.ts @@ -0,0 +1,41 @@ +import type { GlossaryTermHighlightsProps } from '../../../eeSetup/EeModuleType'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { useProject } from 'tg.hooks/useProject'; +import { components } from 'tg.service/apiSchema.generated'; +import { useEnabledFeatures } from 'tg.globalContext/helpers'; + +type GlossaryTermHighlightDto = + components['schemas']['GlossaryTermHighlightDto']; + +export const useGlossaryTermHighlights = ({ + text, + languageTag, + enabled = true, +}: GlossaryTermHighlightsProps): GlossaryTermHighlightDto[] => { + const { isEnabled } = useEnabledFeatures(); + const glossaryFeature = isEnabled('GLOSSARY'); + const project = useProject(); + const hasText = text !== undefined && text !== null && text.length > 0; + const highlights = useApiQuery({ + url: '/v2/projects/{projectId}/glossary-highlights', + method: 'get', + path: { + projectId: project!.id, + }, + query: { + text: text ?? '', + languageTag, + }, + options: { + enabled: glossaryFeature && hasText && enabled, + keepPreviousData: true, + noGlobalLoading: true, + }, + }); + + if (!glossaryFeature || !hasText || !enabled || !highlights.data) { + return []; + } + + return highlights.data._embedded?.glossaryTermHighlightDtoList ?? []; +}; diff --git a/webapp/src/ee/glossary/views/GlossariesListView.tsx b/webapp/src/ee/glossary/views/GlossariesListView.tsx new file mode 100644 index 0000000000..1a75e6df5b --- /dev/null +++ b/webapp/src/ee/glossary/views/GlossariesListView.tsx @@ -0,0 +1,116 @@ +import { LINKS, PARAMS } from 'tg.constants/links'; +import React, { useState } from 'react'; +import { BaseOrganizationSettingsView } from 'tg.views/organizations/components/BaseOrganizationSettingsView'; +import { useTranslate } from '@tolgee/react'; +import { useOrganization } from 'tg.views/organizations/useOrganization'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { GlossaryCreateEditDialog } from 'tg.ee.module/glossary/views/GlossaryCreateEditDialog'; +import { GlossaryListItem } from 'tg.ee.module/glossary/components/GlossaryListItem'; +import { Box, styled } from '@mui/material'; +import { PaginatedHateoasList } from 'tg.component/common/list/PaginatedHateoasList'; +import { GlossariesEmptyListMessage } from 'tg.ee.module/glossary/components/GlossariesEmptyListMessage'; +import { useEnabledFeatures } from 'tg.globalContext/helpers'; +import { DisabledFeatureBanner } from 'tg.component/common/DisabledFeatureBanner'; + +const StyledWrapper = styled('div')` + display: flex; + flex-direction: column; + align-items: stretch; + + & .listWrapper > * > * + * { + border-top: 1px solid ${({ theme }) => theme.palette.divider1}; + } +`; + +export const GlossariesListView = () => { + const [page, setPage] = useState(0); + const [search, setSearch] = useState(''); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + + const { isEnabled } = useEnabledFeatures(); + const glossaryFeature = isEnabled('GLOSSARY'); + + const organization = useOrganization(); + + const { t } = useTranslate(); + + const glossaries = useApiQuery({ + url: '/v2/organizations/{organizationId}/glossaries', + method: 'get', + path: { organizationId: organization!.id }, + query: { + page, + size: 20, + search, + sort: ['id,desc'], + }, + options: { + keepPreviousData: true, + }, + }); + + const items = glossaries?.data?._embedded?.glossaries; + const showSearch = search || (glossaries.data?.page?.totalElements ?? 0) > 5; + + const onCreate = () => { + setCreateDialogOpen(true); + }; + + const canCreate = ['OWNER', 'MAINTAINER'].includes( + organization?.currentUserRole || '' + ); + + return ( + + + {canCreate && createDialogOpen && ( + setCreateDialogOpen(false)} + onFinished={() => setCreateDialogOpen(false)} + /> + )} + {glossaryFeature ? ( + } + emptyPlaceholder={ + + } + /> + ) : ( + + + + )} + + + ); +}; diff --git a/webapp/src/ee/glossary/views/GlossaryCreateEditDialog.tsx b/webapp/src/ee/glossary/views/GlossaryCreateEditDialog.tsx new file mode 100644 index 0000000000..1ad8fb3aeb --- /dev/null +++ b/webapp/src/ee/glossary/views/GlossaryCreateEditDialog.tsx @@ -0,0 +1,276 @@ +import { Button, Dialog, DialogTitle, styled } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; +import { Formik } from 'formik'; + +import { Validation } from 'tg.constants/GlobalValidationSchema'; +import { components } from 'tg.service/apiSchema.generated'; +import { useApiMutation, useApiQuery } from 'tg.service/http/useQueryApi'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; +import { + useEnabledFeatures, + usePreferredOrganization, +} from 'tg.globalContext/helpers'; +import { DisabledFeatureBanner } from 'tg.component/common/DisabledFeatureBanner'; +import { GlossaryCreateForm } from 'tg.ee.module/glossary/views/GlossaryCreateForm'; +import { messageService } from 'tg.service/MessageService'; +import { useEffect, useRef, useState } from 'react'; +import { SpinnerProgress } from 'tg.component/SpinnerProgress'; +import Box from '@mui/material/Box'; +import { languageInfo } from '@tginternal/language-util/lib/generated/languageInfo'; +import { useHistory } from 'react-router-dom'; +import { LINKS, PARAMS } from 'tg.constants/links'; + +type CreateGlossaryRequest = components['schemas']['CreateGlossaryRequest']; +type UpdateGlossaryRequest = components['schemas']['UpdateGlossaryRequest']; +type CreateGlossaryForm = { + name: string; + baseLanguage: + | { + tag: string; + } + | undefined; + assignedProjects: { + id: number; + }[]; +}; + +const StyledContainer = styled('div')` + display: grid; + padding: ${({ theme }) => theme.spacing(3)}; + gap: ${({ theme }) => theme.spacing(0.5, 3)}; + padding-top: ${({ theme }) => theme.spacing(1)}; + width: min(calc(100vw - 64px), 600px); +`; + +const StyledActions = styled('div')` + display: flex; + gap: 8px; + padding-top: 24px; + justify-content: end; +`; + +const StyledLoading = styled(Box)` + display: flex; + justify-content: center; + align-items: center; + height: 350px; +`; + +const glossaryInitialValues: CreateGlossaryForm = { + name: '', + baseLanguage: undefined, + assignedProjects: [], +}; + +type Props = { + open: boolean; + onClose: () => void; + onFinished: () => void; + editGlossaryId?: number; +}; + +export const GlossaryCreateEditDialog = ({ + open, + onClose, + onFinished, + editGlossaryId, +}: Props) => { + const initialGlossaryId = useRef(editGlossaryId).current; + + useEffect(() => { + if (editGlossaryId !== initialGlossaryId) { + // eslint-disable-next-line no-console + console.warn('Changing `editGlossaryId` after mount is not supported.'); + } + }, [editGlossaryId]); + + const { t } = useTranslate(); + const history = useHistory(); + + const { preferredOrganization } = usePreferredOrganization(); + + const { isEnabled } = useEnabledFeatures(); + const glossaryFeature = isEnabled('GLOSSARY'); + + const [saveIsLoading, save] = ( + initialGlossaryId === undefined + ? () => { + const mutation = useApiMutation({ + url: '/v2/organizations/{organizationId}/glossaries', + method: 'post', + invalidatePrefix: '/v2/organizations/{organizationId}/glossaries', + }); + const save = async (values: CreateGlossaryForm) => { + const data: CreateGlossaryRequest = { + name: values.name, + baseLanguageTag: values.baseLanguage!.tag, + assignedProjects: values.assignedProjects?.map(({ id }) => id), + }; + + mutation.mutate( + { + path: { + organizationId: preferredOrganization!.id, + }, + content: { + 'application/json': data, + }, + }, + { + onSuccess({ id }) { + messageService.success( + + ); + history.push( + LINKS.ORGANIZATION_GLOSSARY.build({ + [PARAMS.GLOSSARY_ID]: id, + [PARAMS.ORGANIZATION_SLUG]: preferredOrganization!.slug, + }) + ); + onFinished(); + }, + } + ); + }; + return [mutation.isLoading, save] as const; + } + : () => { + const mutation = useApiMutation({ + url: '/v2/organizations/{organizationId}/glossaries/{glossaryId}', + method: 'put', + invalidatePrefix: '/v2/organizations/{organizationId}/glossaries', + }); + const save = async (values: CreateGlossaryForm) => { + const data: UpdateGlossaryRequest = { + name: values.name, + baseLanguageTag: values.baseLanguage!.tag, + assignedProjects: + values.assignedProjects?.map(({ id }) => id) || [], + }; + + mutation.mutate( + { + path: { + organizationId: preferredOrganization!.id, + glossaryId: initialGlossaryId, + }, + content: { + 'application/json': data, + }, + }, + { + onSuccess() { + messageService.success( + + ); + onFinished(); + }, + } + ); + }; + return [mutation.isLoading, save] as const; + } + )(); + + const [initialValues, setInitialValues] = useState( + initialGlossaryId === undefined ? glossaryInitialValues : undefined + ); + + useApiQuery({ + url: '/v2/organizations/{organizationId}/glossaries/{glossaryId}', + method: 'get', + path: { + organizationId: preferredOrganization!.id, + glossaryId: initialGlossaryId ?? -1, + }, + options: { + enabled: initialGlossaryId !== undefined, + onSuccess(data) { + const language = data.baseLanguageTag + ? { + tag: data.baseLanguageTag, + flagEmoji: languageInfo[data.baseLanguageTag]?.flags?.[0] || '', + name: + languageInfo[data.baseLanguageTag]?.englishName || + data.baseLanguageTag, + } + : undefined; + setInitialValues?.({ + name: data.name, + baseLanguage: language, + assignedProjects: + data.assignedProjects._embedded?.projects?.map((p) => ({ + id: p.id, + name: p.name, + })) || [], + }); + }, + onError(e) { + onClose(); + }, + }, + }); + + const form = + initialValues !== undefined ? ( + + {({ submitForm, values }) => { + return ( + + + + + + {initialGlossaryId === undefined + ? t('create_glossary_submit_button') + : t('edit_glossary_submit_button')} + + + + ); + }} + + ) : ( + + + + ); + + return ( + e.stopPropagation()} + > + {!glossaryFeature && ( + + )} + + {initialGlossaryId === undefined ? ( + + ) : ( + + )} + + + {form} + + ); +}; diff --git a/webapp/src/ee/glossary/views/GlossaryCreateForm.tsx b/webapp/src/ee/glossary/views/GlossaryCreateForm.tsx new file mode 100644 index 0000000000..f3b86e9645 --- /dev/null +++ b/webapp/src/ee/glossary/views/GlossaryCreateForm.tsx @@ -0,0 +1,35 @@ +import { VFC } from 'react'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import Box from '@mui/material/Box'; +import { useTranslate } from '@tolgee/react'; +import { AssignedProjectsSelect } from 'tg.ee.module/glossary/components/AssignedProjectsSelect'; +import { GlossaryBaseLanguageSelect } from 'tg.ee.module/glossary/components/GlossaryBaseLanguageSelect'; + +type Props = { + disabled?: boolean; + withAssignedProjects?: boolean; +}; + +export const GlossaryCreateForm: VFC = ({ + disabled, + withAssignedProjects = false, +}) => { + const { t } = useTranslate(); + + return ( + + + + {withAssignedProjects && ( + + )} + + ); +}; diff --git a/webapp/src/ee/glossary/views/GlossaryRouter.tsx b/webapp/src/ee/glossary/views/GlossaryRouter.tsx new file mode 100644 index 0000000000..5e0392f96e --- /dev/null +++ b/webapp/src/ee/glossary/views/GlossaryRouter.tsx @@ -0,0 +1,21 @@ +import { Route, Switch, useRouteMatch } from 'react-router-dom'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { GlossaryContext } from 'tg.ee.module/glossary/hooks/GlossaryContext'; +import { useOrganization } from 'tg.views/organizations/useOrganization'; +import { GlossaryView } from 'tg.ee.module/glossary/views/GlossaryView'; + +export const GlossaryRouter = () => { + const organization = useOrganization(); + const match = useRouteMatch(); + const glossaryId = match.params[PARAMS.GLOSSARY_ID]; + + return ( + + + + + + + + ); +}; diff --git a/webapp/src/ee/glossary/views/GlossaryTermCreateUpdateDialog.tsx b/webapp/src/ee/glossary/views/GlossaryTermCreateUpdateDialog.tsx new file mode 100644 index 0000000000..6633ef25a0 --- /dev/null +++ b/webapp/src/ee/glossary/views/GlossaryTermCreateUpdateDialog.tsx @@ -0,0 +1,364 @@ +import { Button, Dialog, DialogTitle, styled } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; +import { Formik } from 'formik'; + +import { Validation } from 'tg.constants/GlobalValidationSchema'; +import { useApiMutation, useApiQuery } from 'tg.service/http/useQueryApi'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; +import { messageService } from 'tg.service/MessageService'; +import { GlossaryTermCreateUpdateForm } from 'tg.ee.module/glossary/views/GlossaryTermCreateUpdateForm'; +import { components } from 'tg.service/apiSchema.generated'; +import React, { useEffect, useRef, useState } from 'react'; +import { SpinnerProgress } from 'tg.component/SpinnerProgress'; +import Box from '@mui/material/Box'; +import { confirmation } from 'tg.hooks/confirmation'; +import { usePreferredOrganization } from 'tg.globalContext/helpers'; +import { useGlossary } from 'tg.ee.module/glossary/hooks/useGlossary'; + +type CreateGlossaryTermWithTranslationRequest = + components['schemas']['CreateGlossaryTermWithTranslationRequest']; +type UpdateGlossaryTermWithTranslationRequest = + components['schemas']['UpdateGlossaryTermWithTranslationRequest']; +type CreateOrUpdateGlossaryTermRequest = + CreateGlossaryTermWithTranslationRequest & + UpdateGlossaryTermWithTranslationRequest; + +type SimpleGlossaryTermModel = Omit< + components['schemas']['SimpleGlossaryTermModel'], + 'id' +>; +type GlossaryTermTranslationModel = Omit< + components['schemas']['GlossaryTermTranslationModel'], + 'languageTag' +>; + +const StyledContainer = styled('div')` + display: grid; + padding: ${({ theme }) => theme.spacing(3)}; + gap: ${({ theme }) => theme.spacing(0.5, 3)}; + padding-top: ${({ theme }) => theme.spacing(1)}; + width: min(calc(100vw - 64px), 600px); +`; + +const StyledActions = styled('div')` + display: flex; + gap: 8px; + padding-top: 24px; + justify-content: end; +`; + +const StyledLoading = styled(Box)` + display: flex; + justify-content: center; + align-items: center; + height: 350px; +`; + +type Props = { + open: boolean; + onClose: () => void; + onFinished: () => void; + /** + * When undefined - create new Term; Otherwise edit existing Term + * Immutable prop! + * Don't switch between editing and creating + * mode after initialization + */ + editTermId?: number; +}; + +const translationInitialValues: GlossaryTermTranslationModel = { + text: '', +}; + +const termInitialValues: SimpleGlossaryTermModel = { + description: undefined, + flagNonTranslatable: false, + flagCaseSensitive: false, + flagAbbreviation: false, + flagForbiddenTerm: false, +}; + +export const GlossaryTermCreateUpdateDialog = ({ + open, + onClose, + onFinished, + editTermId, +}: Props) => { + const { preferredOrganization } = usePreferredOrganization(); + const glossary = useGlossary(); + const initialTermId = useRef(editTermId).current; + + useEffect(() => { + if (editTermId !== initialTermId) { + // eslint-disable-next-line no-console + console.warn('Changing `editTermId` after mount is not supported.'); + } + }, [editTermId]); + + const { t } = useTranslate(); + + const [initialTranslationValues, setInitialTranslationValues] = useState( + initialTermId === undefined ? translationInitialValues : undefined + ); + + const [initialTermValues, setInitialTermValues] = useState( + initialTermId === undefined ? termInitialValues : undefined + ); + + const initialValues: CreateOrUpdateGlossaryTermRequest | undefined = + initialTranslationValues && + initialTermValues && { + text: initialTranslationValues.text ?? '', + description: initialTermValues.description, + flagNonTranslatable: initialTermValues.flagNonTranslatable, + flagCaseSensitive: initialTermValues.flagCaseSensitive, + flagAbbreviation: initialTermValues.flagAbbreviation, + flagForbiddenTerm: initialTermValues.flagForbiddenTerm, + }; + + const [saveIsLoading, save] = ( + initialTermId === undefined + ? () => { + const mutation = useApiMutation({ + url: '/v2/organizations/{organizationId}/glossaries/{glossaryId}/terms', + method: 'post', + invalidatePrefix: + '/v2/organizations/{organizationId}/glossaries/{glossaryId}', + }); + const save = async (values: CreateOrUpdateGlossaryTermRequest) => { + mutation.mutate( + { + path: { + organizationId: preferredOrganization!.id, + glossaryId: glossary.id, + }, + content: { + 'application/json': values, + }, + }, + { + onSuccess() { + messageService.success( + + ); + onFinished(); + }, + } + ); + }; + return [mutation.isLoading, save] as const; + } + : () => { + const mutation = useApiMutation({ + url: '/v2/organizations/{organizationId}/glossaries/{glossaryId}/terms/{termId}', + method: 'put', + invalidatePrefix: + '/v2/organizations/{organizationId}/glossaries/{glossaryId}', + }); + const save = async (values: CreateOrUpdateGlossaryTermRequest) => { + const triggerSave = async () => { + mutation.mutate( + { + path: { + organizationId: preferredOrganization!.id, + glossaryId: glossary.id, + termId: initialTermId, + }, + content: { + 'application/json': values, + }, + }, + { + onSuccess() { + messageService.success( + + ); + onFinished(); + }, + } + ); + }; + + if ( + initialTermValues?.flagNonTranslatable === false && + values.flagNonTranslatable + ) { + // User is changing term to non-translatable - we will delete all translations of this term + await new Promise((resolve, reject) => { + confirmation({ + title: ( + + ), + message: ( + + ), + onConfirm() { + triggerSave(); + resolve(undefined); + }, + onCancel() { + reject(undefined); + }, + }); + }); + return; + } + + triggerSave(); + }; + return [mutation.isLoading, save] as const; + } + )(); + + const glossaryQuery = useApiQuery({ + url: '/v2/organizations/{organizationId}/glossaries/{glossaryId}', + method: 'get', + path: { + organizationId: preferredOrganization!.id, + glossaryId: glossary.id, + }, + options: { + enabled: initialTermId !== undefined, + onError(e) { + onClose(); + }, + }, + }); + + useApiQuery({ + url: '/v2/organizations/{organizationId}/glossaries/{glossaryId}/terms/{termId}', + method: 'get', + path: { + organizationId: preferredOrganization!.id, + glossaryId: glossary.id, + termId: initialTermId ?? -1, + }, + options: { + enabled: initialTermId !== undefined, + onSuccess(data) { + setInitialTermValues?.(data); + }, + onError(e) { + onClose(); + }, + }, + }); + + useApiQuery({ + url: '/v2/organizations/{organizationId}/glossaries/{glossaryId}/terms/{termId}/translations/{languageTag}', + method: 'get', + path: { + organizationId: preferredOrganization!.id, + glossaryId: glossary.id, + termId: initialTermId ?? -1, + languageTag: glossaryQuery.data?.baseLanguageTag ?? '', + }, + options: { + enabled: + initialTermId !== undefined && + glossaryQuery.data?.baseLanguageTag !== undefined, + onSuccess(data) { + setInitialTranslationValues?.(data); + }, + onError(e) { + onClose(); + }, + }, + }); + + const deleteMutation = useApiMutation({ + url: '/v2/organizations/{organizationId}/glossaries/{glossaryId}/terms/{termId}', + method: 'delete', + invalidatePrefix: + '/v2/organizations/{organizationId}/glossaries/{glossaryId}', + }); + const onDelete = () => { + const termId = initialTermId; + if (termId === undefined) { + return; + } + confirmation({ + title: , + message: , + onConfirm() { + deleteMutation.mutate( + { + path: { + organizationId: preferredOrganization!.id, + glossaryId: glossary.id, + termId, + }, + }, + { + onSuccess() { + messageService.success( + + ); + onClose(); + }, + } + ); + }, + }); + }; + + const form = + initialValues !== undefined ? ( + + {({ submitForm }) => ( + + + + {initialTermId !== undefined && ( + <> + + + + )} + + + {t('glossary_term_create_submit_button')} + + + + )} + + ) : ( + + + + ); + + return ( + e.stopPropagation()} + > + + {initialTermId === undefined ? ( + + ) : ( + + )} + + + {form} + + ); +}; diff --git a/webapp/src/ee/glossary/views/GlossaryTermCreateUpdateForm.tsx b/webapp/src/ee/glossary/views/GlossaryTermCreateUpdateForm.tsx new file mode 100644 index 0000000000..5d41bd6a95 --- /dev/null +++ b/webapp/src/ee/glossary/views/GlossaryTermCreateUpdateForm.tsx @@ -0,0 +1,59 @@ +import { VFC } from 'react'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { Checkbox } from 'tg.component/common/form/fields/Checkbox'; +import Box from '@mui/material/Box'; +import { useTranslate } from '@tolgee/react'; +import { FormControlLabel, styled } from '@mui/material'; + +const StyledContainer = styled('div')` + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-areas: + 'flagNonTranslatable flagCaseSensitive' + 'flagAbbreviation flagForbiddenTerm'; +`; + +export const GlossaryTermCreateUpdateForm: VFC = () => { + const { t } = useTranslate(); + + return ( + + + + + } + label={t('create_glossary_term_field_non_translatable')} + sx={{ gridArea: 'flagNonTranslatable' }} + /> + } + label={t('create_glossary_term_field_case_sensitive')} + sx={{ gridArea: 'flagCaseSensitive' }} + /> + } + label={t('create_glossary_term_field_abbreviation')} + sx={{ gridArea: 'flagAbbreviation' }} + /> + } + label={t('create_glossary_term_field_forbidden')} + sx={{ gridArea: 'flagForbiddenTerm' }} + /> + + + ); +}; diff --git a/webapp/src/ee/glossary/views/GlossaryView.tsx b/webapp/src/ee/glossary/views/GlossaryView.tsx new file mode 100644 index 0000000000..d0ca64b81d --- /dev/null +++ b/webapp/src/ee/glossary/views/GlossaryView.tsx @@ -0,0 +1,185 @@ +import { LINKS, PARAMS } from 'tg.constants/links'; +import { BaseOrganizationSettingsView } from 'tg.views/organizations/components/BaseOrganizationSettingsView'; +import { useTranslate } from '@tolgee/react'; +import { + useApiInfiniteQuery, + useApiMutation, +} from 'tg.service/http/useQueryApi'; +import React, { useMemo, useState } from 'react'; +import { GlossaryTermCreateUpdateDialog } from 'tg.ee.module/glossary/views/GlossaryTermCreateUpdateDialog'; +import { GlossaryViewBody } from 'tg.ee.module/glossary/components/GlossaryViewBody'; +import { GlossaryEmptyListMessage } from 'tg.ee.module/glossary/components/GlossaryEmptyListMessage'; +import { useUrlSearchState } from 'tg.hooks/useUrlSearchState'; +import { useGlossary } from 'tg.ee.module/glossary/hooks/useGlossary'; +import { usePreferredOrganization } from 'tg.globalContext/helpers'; + +export const GlossaryView = () => { + const [search, setSearch] = useUrlSearchState('search', { + defaultVal: '', + }); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [selectedLanguages, setSelectedLanguages] = useState< + string[] | undefined + >(undefined); + + const { preferredOrganization } = usePreferredOrganization(); + const glossary = useGlossary(); + + const { t } = useTranslate(); + + const selectedLanguagesWithBaseLanguage = useMemo(() => { + if (selectedLanguages === undefined) { + return undefined; + } + return [glossary?.baseLanguageTag || '', ...(selectedLanguages ?? [])]; + }, [selectedLanguages, glossary]); + + const path = { + organizationId: preferredOrganization!.id, + glossaryId: glossary.id, + }; + const query = { + search: search, + languageTags: selectedLanguagesWithBaseLanguage, + size: 30, + sort: ['id,desc'], + }; + const termsLoadable = useApiInfiniteQuery({ + url: '/v2/organizations/{organizationId}/glossaries/{glossaryId}/termsWithTranslations', + method: 'get', + path: path, + query: query, + options: { + keepPreviousData: true, + refetchOnMount: true, + noGlobalLoading: true, + getNextPageParam: (lastPage) => { + if ( + lastPage.page && + lastPage.page.number! < lastPage.page.totalPages! - 1 + ) { + return { + path: path, + query: { + ...query, + page: lastPage.page!.number! + 1, + }, + }; + } else { + return null; + } + }, + }, + }); + + const getTermsIdsMutation = useApiMutation({ + url: '/v2/organizations/{organizationId}/glossaries/{glossaryId}/termsIds', + method: 'get', + }); + + const fetchAllTermsIds = () => { + return new Promise((resolve, reject) => { + getTermsIdsMutation.mutate( + { + path, + query, + }, + { + onSuccess: (data) => { + resolve(data._embedded?.longList ?? []); + }, + onError: (e) => { + reject(e); + }, + } + ); + }); + }; + + const terms = useMemo( + () => + termsLoadable.data?.pages.flatMap( + (p) => p._embedded?.glossaryTerms ?? [] + ) ?? [], + [termsLoadable.data] + ); + + const totalTerms = termsLoadable.data?.pages?.[0]?.page?.totalElements; + + const updateSelectedLanguages = (languages: string[]) => { + setSelectedLanguages( + languages.filter((l) => l !== glossary.baseLanguageTag) + ); + }; + + const onCreate = () => { + setCreateDialogOpen(true); + }; + + const canCreate = ['OWNER', 'MAINTAINER'].includes( + preferredOrganization?.currentUserRole || '' + ); + + const onFetchNextPage = () => { + if (!termsLoadable.isFetching && termsLoadable.hasNextPage) { + termsLoadable.fetchNextPage(); + } + }; + + return ( + + {canCreate && createDialogOpen && preferredOrganization !== undefined && ( + setCreateDialogOpen(false)} + onFinished={() => setCreateDialogOpen(false)} + /> + )} + {(terms.length > 0 || search.length > 0) && + preferredOrganization !== undefined ? ( + + ) : ( + + )} + + ); +}; diff --git a/webapp/src/eeSetup/EeModuleType.ts b/webapp/src/eeSetup/EeModuleType.ts index 8c46b53399..48a961a6f8 100644 --- a/webapp/src/eeSetup/EeModuleType.ts +++ b/webapp/src/eeSetup/EeModuleType.ts @@ -36,3 +36,21 @@ export type TranslationTaskIndicatorProps = { export type PrefilterTaskProps = { taskNumber: number; }; + +export type GlossaryTermHighlightDto = + components['schemas']['GlossaryTermHighlightDto']; + +export type GlossaryTermModel = components['schemas']['GlossaryTermModel']; + +export type GlossaryTermHighlightsProps = { + text: string | null | undefined; + languageTag: string; + enabled?: boolean; +}; + +export type GlossaryTermPreviewProps = { + term: GlossaryTermModel; + languageTag: string; + targetLanguageTag?: string; + showIcon?: boolean; +}; diff --git a/webapp/src/eeSetup/eeModule.ee.tsx b/webapp/src/eeSetup/eeModule.ee.tsx index 35fceab66d..095760de88 100644 --- a/webapp/src/eeSetup/eeModule.ee.tsx +++ b/webapp/src/eeSetup/eeModule.ee.tsx @@ -2,7 +2,7 @@ import { OrganizationSsoView } from '../views/organizations/sso/OrganizationSsoView'; import { RecaptchaProvider } from '../component/common/RecaptchaProvider'; import { T, useTranslate } from '@tolgee/react'; -import { ClipboardCheck } from '@untitled-ui/icons-react'; +import { BookClosed, ClipboardCheck } from '@untitled-ui/icons-react'; import { Link, Route, Switch } from 'react-router-dom'; import { Badge, Box, MenuItem } from '@mui/material'; @@ -47,8 +47,21 @@ import { addProjectMenuItems } from '../views/projects/projectMenu/ProjectMenu'; import { addAdministrationMenuItems } from '../views/administration/components/BaseAdministrationView'; import { SsoLoginView } from '../ee/security/Sso/SsoLoginView'; import { OperationOrderTranslation } from '../views/projects/translations/BatchOperations/OperationOrderTranslation'; -import { BillingMenuItemsProps } from './EeModuleType'; +import { + BillingMenuItemsProps, + GlossaryTermHighlightDto, + GlossaryTermHighlightsProps, + GlossaryTermPreviewProps, +} from './EeModuleType'; import { AdministrationSubscriptionsView } from '../ee/billing/administration/subscriptions/AdministrationSubscriptionsView'; +import { GlossariesListView } from '../ee/glossary/views/GlossariesListView'; +import { useGlossaryTermHighlights as useGlossaryTermHighlightsInternal } from '../ee/glossary/hooks/useGlossaryTermHighlights'; +import { GlossaryTermPreview as GlossaryTermPreviewInternal } from '../ee/glossary/components/GlossaryTermPreview'; +import { + glossariesCount, + GlossariesPanel, +} from '../ee/glossary/components/GlossariesPanel'; +import { GlossaryRouter } from '../ee/glossary/views/GlossaryRouter'; export { TaskReference } from '../ee/task/components/TaskReference'; export { GlobalLimitPopover } from '../ee/billing/limitPopover/GlobalLimitPopover'; @@ -167,6 +180,12 @@ export const routes = { + + + + + + ); }, @@ -249,6 +268,20 @@ export const translationPanelAdder = addPanel( { position: 'after', value: 'history' } ); +export const glossaryPanelAdder = addPanel( + [ + { + id: 'glossaries', + icon: , + name: , + component: GlossariesPanel, + itemsCountFunction: glossariesCount, + displayPanel: ({ editEnabled }) => editEnabled, + }, + ], + { position: 'after', value: 'translation_memory' } +); + export const useAddDeveloperViewItems = () => { const { t } = useTranslate(); return addDeveloperViewItems( @@ -377,3 +410,10 @@ export const useAddAdministrationMenuItems = () => { { position: 'after', value: 'users' } ); }; + +export const useGlossaryTermHighlights = ( + props: GlossaryTermHighlightsProps +): GlossaryTermHighlightDto[] => useGlossaryTermHighlightsInternal(props); + +export const GlossaryTermPreview: React.VFC = + GlossaryTermPreviewInternal; diff --git a/webapp/src/eeSetup/eeModule.oss.tsx b/webapp/src/eeSetup/eeModule.oss.tsx index 26400338ea..4553e4983f 100644 --- a/webapp/src/eeSetup/eeModule.oss.tsx +++ b/webapp/src/eeSetup/eeModule.oss.tsx @@ -1,11 +1,21 @@ -import type { BillingMenuItemsProps } from './EeModuleType'; +import React from 'react'; +import type { + BillingMenuItemsProps, + GlossaryTermHighlightDto, + GlossaryTermHighlightsProps, + GlossaryTermPreviewProps, +} from './EeModuleType'; -const NotIncludedInOss = - (name: string): ((props?: any) => any) => - // eslint-disable-next-line react/display-name - () => { - return
Not included in OSS ({name})
; - }; +const NotIncludedInOss = (name: string): ((props?: any) => any) => { + function NotIncludedInOss(props: any, ref: any) { + return ( +
+ Not included in OSS ({name}) +
+ ); + } + return React.forwardRef(NotIncludedInOss); +}; const Empty: (props?: any) => any = () => { return null; @@ -41,6 +51,7 @@ export const TranslationsTaskDetail = Empty; export const useAddDeveloperViewItems = () => (existingItems) => existingItems; export const useAddBatchOperations = () => (existingItems) => existingItems; export const translationPanelAdder = (existingItems) => existingItems; +export const glossaryPanelAdder = (existingItems) => existingItems; export const useAddProjectMenuItems = () => (existingItems) => existingItems; export const useAddUserMenuItems = () => (existingItems) => existingItems; export const useAddAdministrationMenuItems = () => (existingItems) => @@ -50,3 +61,10 @@ export const TrialChip = Empty; export const TaskInfoMessage = Empty; export const CriticalUsageCircle = Empty; + +export const useGlossaryTermHighlights = ( + props: GlossaryTermHighlightsProps +): GlossaryTermHighlightDto[] => []; + +export const GlossaryTermPreview: React.VFC = + NotIncludedInOss('Glossaries'); diff --git a/webapp/src/hooks/OrganizationContext.tsx b/webapp/src/hooks/OrganizationContext.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index 861ae09c7f..b992a25131 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -1,4 +1,4 @@ -import React, { Suspense } from 'react'; +import React, { StrictMode, Suspense } from 'react'; import CssBaseline from '@mui/material/CssBaseline'; import { StyledEngineProvider } from '@mui/material/styles'; import { @@ -104,7 +104,12 @@ const MainWrapper = () => { ); }; -ReactDOM.render(, document.getElementById('root')); +ReactDOM.render( + + + , + document.getElementById('root') +); // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 775d301eb8..098a6aebf5 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -82,7 +82,7 @@ export interface paths { "/v2/api-keys": { get: operations["allByUser"]; /** Creates new API key with provided scopes */ - post: operations["create_13"]; + post: operations["create_15"]; }; "/v2/api-keys/availableScopes": { get: operations["getScopes"]; @@ -92,15 +92,15 @@ export interface paths { get: operations["getCurrent_1"]; }; "/v2/api-keys/{apiKeyId}": { - put: operations["update_9"]; - delete: operations["delete_13"]; + put: operations["update_11"]; + delete: operations["delete_15"]; }; "/v2/api-keys/{apiKeyId}/regenerate": { put: operations["regenerate_1"]; }; "/v2/api-keys/{keyId}": { /** Returns specific API key info */ - get: operations["get_21"]; + get: operations["get_24"]; }; "/v2/auth-provider": { get: operations["getCurrentAuthProvider"]; @@ -136,7 +136,7 @@ export interface paths { post: operations["upload"]; }; "/v2/image-upload/{ids}": { - delete: operations["delete_12"]; + delete: operations["delete_14"]; }; "/v2/invitations/{code}/accept": { get: operations["acceptInvitation"]; @@ -162,10 +162,10 @@ export interface paths { post: operations["create_12"]; }; "/v2/organizations/{id}": { - get: operations["get_20"]; - put: operations["update_8"]; + get: operations["get_23"]; + put: operations["update_10"]; /** Deletes organization and all its data including projects */ - delete: operations["delete_11"]; + delete: operations["delete_13"]; }; "/v2/organizations/{id}/avatar": { put: operations["uploadAvatar_2"]; @@ -187,9 +187,47 @@ export interface paths { /** Returns all users in organization. The result also contains users who are only members of projects in the organization. */ get: operations["getAllUsers_1"]; }; + "/v2/organizations/{organizationId}/glossaries": { + get: operations["getAll_11"]; + post: operations["create_13"]; + }; + "/v2/organizations/{organizationId}/glossaries/{glossaryId}": { + get: operations["get_20"]; + put: operations["update_8"]; + delete: operations["delete_11"]; + }; + "/v2/organizations/{organizationId}/glossaries/{glossaryId}/languages": { + get: operations["getLanguages"]; + }; + "/v2/organizations/{organizationId}/glossaries/{glossaryId}/terms": { + get: operations["getAll_12"]; + post: operations["create_14"]; + delete: operations["deleteMultiple"]; + }; + "/v2/organizations/{organizationId}/glossaries/{glossaryId}/terms/{termId}": { + get: operations["get_21"]; + put: operations["update_9"]; + delete: operations["delete_12"]; + }; + "/v2/organizations/{organizationId}/glossaries/{glossaryId}/terms/{termId}/translations": { + post: operations["update_12"]; + }; + "/v2/organizations/{organizationId}/glossaries/{glossaryId}/terms/{termId}/translations/{languageTag}": { + get: operations["get_22"]; + }; + "/v2/organizations/{organizationId}/glossaries/{glossaryId}/termsIds": { + get: operations["getAllIds"]; + }; + "/v2/organizations/{organizationId}/glossaries/{glossaryId}/termsWithTranslations": { + get: operations["getAllWithTranslations"]; + }; "/v2/organizations/{organizationId}/invitations": { get: operations["getInvitations"]; }; + "/v2/organizations/{organizationId}/languages": { + /** Returns all languages in use by projects owned by specified organization */ + get: operations["getAllLanguagesInUse"]; + }; "/v2/organizations/{organizationId}/machine-translation-credit-balance": { /** Returns machine translation credit balance for organization */ get: operations["getOrganizationCredits"]; @@ -376,6 +414,9 @@ export interface paths { /** Exports data (post). Useful when exceeding allowed URL size. */ post: operations["exportPost"]; }; + "/v2/projects/{projectId}/glossary-highlights": { + get: operations["getHighlights"]; + }; "/v2/projects/{projectId}/import": { /** Prepares provided files to import. */ post: operations["addFiles"]; @@ -1137,6 +1178,16 @@ export interface components { exportFormats?: components["schemas"]["ExportFormatModel"][]; }; }; + CollectionModelGlossaryLanguageDto: { + _embedded?: { + glossaryLanguageDtoList?: components["schemas"]["GlossaryLanguageDto"][]; + }; + }; + CollectionModelGlossaryTermHighlightDto: { + _embedded?: { + glossaryTermHighlightDtoList?: components["schemas"]["GlossaryTermHighlightDto"][]; + }; + }; CollectionModelImportNamespaceModel: { _embedded?: { namespaces?: components["schemas"]["ImportNamespaceModel"][]; @@ -1182,6 +1233,11 @@ export interface components { languages?: components["schemas"]["LanguageModel"][]; }; }; + CollectionModelLong: { + _embedded?: { + longList?: number[]; + }; + }; CollectionModelOrganizationInvitationModel: { _embedded?: { organizationInvitations?: components["schemas"]["OrganizationInvitationModel"][]; @@ -1207,6 +1263,11 @@ export interface components { organizations?: components["schemas"]["SimpleOrganizationModel"][]; }; }; + CollectionModelSimpleProjectModel: { + _embedded?: { + projects?: components["schemas"]["SimpleProjectModel"][]; + }; + }; CollectionModelUsedNamespaceModel: { _embedded?: { namespaces?: components["schemas"]["UsedNamespaceModel"][]; @@ -1605,6 +1666,29 @@ export interface components { projectId: number; scopes: string[]; }; + CreateGlossaryRequest: { + /** @description Projects assigned to glossary */ + assignedProjects: number[]; + /** + * @description Language tag according to BCP 47 definition + * @example cs-CZ + */ + baseLanguageTag: string; + /** + * @description Glossary name + * @example My glossary + */ + name: string; + }; + CreateGlossaryTermWithTranslationRequest: { + /** @description Glossary term description */ + description?: string; + flagAbbreviation: boolean; + flagCaseSensitive: boolean; + flagForbiddenTerm: boolean; + flagNonTranslatable: boolean; + text: string; + }; CreateKeyDto: { /** * @description Description of the key @@ -1684,6 +1768,10 @@ export interface components { name?: string; type: "TRANSLATE" | "REVIEW"; }; + CreateUpdateGlossaryTermResponse: { + term: components["schemas"]["SimpleGlossaryTermModel"]; + translation?: components["schemas"]["GlossaryTermTranslationModel"]; + }; CreditBalanceModel: { /** Format: int64 */ bucketSize: number; @@ -1721,6 +1809,9 @@ export interface components { DeleteKeysRequest: { keyIds: number[]; }; + DeleteMultipleGlossaryTermsRequest: { + termIds: number[]; + }; DocItem: { description?: string; displayName?: string; @@ -1776,6 +1867,7 @@ export interface components { | "TASKS" | "SSO" | "ORDER_TRANSLATION" + | "GLOSSARY" )[]; isPayAsYouGo: boolean; /** Format: date-time */ @@ -2078,7 +2170,11 @@ export interface components { | "keys_spending_limit_exceeded" | "plan_seat_limit_exceeded" | "instance_not_using_license_key" - | "invalid_path"; + | "invalid_path" + | "glossary_not_found" + | "glossary_term_not_found" + | "glossary_term_translation_not_found" + | "glossary_non_translatable_term_cannot_be_translated"; params?: { [key: string]: unknown }[]; }; ExistenceEntityDescription: { @@ -2225,6 +2321,37 @@ export interface components { /** @description Tags to return language translations in */ languageTags: string[]; }; + GlossaryLanguageDto: { + base: boolean; + tag: string; + }; + GlossaryModel: { + assignedProjects: components["schemas"]["CollectionModelSimpleProjectModel"]; + baseLanguageTag?: string; + /** Format: int64 */ + id: number; + name: string; + organizationOwner: components["schemas"]["SimpleOrganizationModel"]; + }; + GlossaryTermHighlightDto: { + position: components["schemas"]["Position"]; + value: components["schemas"]["GlossaryTermModel"]; + }; + GlossaryTermModel: { + description?: string; + flagAbbreviation: boolean; + flagCaseSensitive: boolean; + flagForbiddenTerm: boolean; + flagNonTranslatable: boolean; + glossary: components["schemas"]["GlossaryModel"]; + /** Format: int64 */ + id: number; + translations: components["schemas"]["GlossaryTermTranslationModel"][]; + }; + GlossaryTermTranslationModel: { + languageTag: string; + text: string; + }; HierarchyItem: { requires: components["schemas"]["HierarchyItem"][]; scope: @@ -3133,6 +3260,30 @@ export interface components { name?: string; roleType: "MEMBER" | "OWNER" | "MAINTAINER"; }; + OrganizationLanguageModel: { + /** @description Whether is base language of any project */ + base: boolean; + /** + * @description Language flag emoji as UTF-8 emoji + * @example 🇨🇿 + */ + flagEmoji?: string; + /** + * @description Language name in english + * @example Czech + */ + name: string; + /** + * @description Language name in this language + * @example čeština + */ + originalName?: string; + /** + * @description Language tag according to BCP 47 definition + * @example cs-CZ + */ + tag: string; + }; OrganizationModel: { avatar?: components["schemas"]["Avatar"]; basePermissions: components["schemas"]["PermissionModel"]; @@ -3252,6 +3403,12 @@ export interface components { }; page?: components["schemas"]["PageMetadata"]; }; + PagedModelOrganizationLanguageModel: { + _embedded?: { + languages?: components["schemas"]["OrganizationLanguageModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; PagedModelOrganizationModel: { _embedded?: { organizations?: components["schemas"]["OrganizationModel"][]; @@ -3282,6 +3439,24 @@ export interface components { }; page?: components["schemas"]["PageMetadata"]; }; + PagedModelSimpleGlossaryModel: { + _embedded?: { + glossaries?: components["schemas"]["SimpleGlossaryModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; + PagedModelSimpleGlossaryTermModel: { + _embedded?: { + glossaryTerms?: components["schemas"]["SimpleGlossaryTermModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; + PagedModelSimpleGlossaryTermWithTranslationsModel: { + _embedded?: { + glossaryTerms?: components["schemas"]["SimpleGlossaryTermWithTranslationsModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; PagedModelSimpleUserAccountModel: { _embedded?: { users?: components["schemas"]["SimpleUserAccountModel"][]; @@ -3533,6 +3708,12 @@ export interface components { scriptUrl: string; url: string; }; + Position: { + /** Format: int32 */ + end: number; + /** Format: int32 */ + start: number; + }; PreTranslationByTmRequest: { keyIds: number[]; targetLanguageIds: number[]; @@ -3573,6 +3754,7 @@ export interface components { | "TASKS" | "SSO" | "ORDER_TRANSLATION" + | "GLOSSARY" )[]; /** Format: int64 */ id: number; @@ -3833,6 +4015,7 @@ export interface components { | "TASKS" | "SSO" | "ORDER_TRANSLATION" + | "GLOSSARY" )[]; free: boolean; /** Format: int64 */ @@ -4142,6 +4325,7 @@ export interface components { | "TASKS" | "SSO" | "ORDER_TRANSLATION" + | "GLOSSARY" )[]; free: boolean; hasYearlyPrice: boolean; @@ -4252,6 +4436,32 @@ export interface components { /** @description Where did the user find us? */ userSource?: string; }; + SimpleGlossaryModel: { + assignedProjects: components["schemas"]["CollectionModelSimpleProjectModel"]; + baseLanguageTag?: string; + /** Format: int64 */ + id: number; + name: string; + }; + SimpleGlossaryTermModel: { + description?: string; + flagAbbreviation: boolean; + flagCaseSensitive: boolean; + flagForbiddenTerm: boolean; + flagNonTranslatable: boolean; + /** Format: int64 */ + id: number; + }; + SimpleGlossaryTermWithTranslationsModel: { + description?: string; + flagAbbreviation: boolean; + flagCaseSensitive: boolean; + flagForbiddenTerm: boolean; + flagNonTranslatable: boolean; + /** Format: int64 */ + id: number; + translations: components["schemas"]["GlossaryTermTranslationModel"][]; + }; SimpleOrganizationModel: { avatar?: components["schemas"]["Avatar"]; basePermissions: components["schemas"]["PermissionModel"]; @@ -4643,7 +4853,11 @@ export interface components { | "keys_spending_limit_exceeded" | "plan_seat_limit_exceeded" | "instance_not_using_license_key" - | "invalid_path"; + | "invalid_path" + | "glossary_not_found" + | "glossary_term_not_found" + | "glossary_term_translation_not_found" + | "glossary_non_translatable_term_cannot_be_translated"; params?: { [key: string]: unknown }[]; success: boolean; }; @@ -4911,6 +5125,40 @@ export interface components { keyIds: number[]; tags: string[]; }; + UpdateGlossaryRequest: { + /** @description Projects assigned to glossary; when null, assigned projects will be kept unchanged. */ + assignedProjects?: number[]; + /** + * @description Language tag according to BCP 47 definition + * @example cs-CZ + */ + baseLanguageTag: string; + /** + * @description Glossary name + * @example My glossary + */ + name: string; + }; + UpdateGlossaryTermTranslationRequest: { + /** + * @description Language tag according to BCP 47 definition + * @example cs-CZ + */ + languageTag: string; + /** + * @description Translation text + * @example Translated text to language of languageTag + */ + text: string; + }; + UpdateGlossaryTermWithTranslationRequest: { + description?: string; + flagAbbreviation?: boolean; + flagCaseSensitive?: boolean; + flagForbiddenTerm?: boolean; + flagNonTranslatable?: boolean; + text?: string; + }; UpdateNamespaceDto: { name: string; }; @@ -6093,7 +6341,7 @@ export interface operations { }; }; /** Creates new API key with provided scopes */ - create_13: { + create_15: { responses: { /** OK */ 200: { @@ -6225,7 +6473,7 @@ export interface operations { }; }; }; - update_9: { + update_11: { parameters: { path: { apiKeyId: number; @@ -6277,7 +6525,7 @@ export interface operations { }; }; }; - delete_13: { + delete_15: { parameters: { path: { apiKeyId: number; @@ -6373,7 +6621,7 @@ export interface operations { }; }; /** Returns specific API key info */ - get_21: { + get_24: { parameters: { path: { keyId: number; @@ -6939,7 +7187,7 @@ export interface operations { }; }; }; - delete_12: { + delete_14: { parameters: { path: { ids: number[]; @@ -7363,7 +7611,7 @@ export interface operations { }; }; }; - get_20: { + get_23: { parameters: { path: { id: number; @@ -7410,7 +7658,7 @@ export interface operations { }; }; }; - update_8: { + update_10: { parameters: { path: { id: number; @@ -7463,7 +7711,7 @@ export interface operations { }; }; /** Deletes organization and all its data including projects */ - delete_11: { + delete_13: { parameters: { path: { id: number; @@ -7819,17 +8067,26 @@ export interface operations { }; }; }; - getInvitations: { + getAll_11: { parameters: { path: { organizationId: number; }; + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelOrganizationInvitationModel"]; + "application/json": components["schemas"]["PagedModelSimpleGlossaryModel"]; }; }; /** Bad Request */ @@ -7866,8 +8123,7 @@ export interface operations { }; }; }; - /** Returns machine translation credit balance for organization */ - getOrganizationCredits: { + create_13: { parameters: { path: { organizationId: number; @@ -7877,7 +8133,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["CreditBalanceModel"]; + "application/json": components["schemas"]["GlossaryModel"]; }; }; /** Bad Request */ @@ -7913,28 +8169,24 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateGlossaryRequest"]; + }; + }; }; - /** Returns all projects (including statistics) where current user has any permission (except none) */ - getAllWithStatistics_2: { + get_20: { parameters: { - query: { - /** Zero-based page index (0..N) */ - page?: number; - /** The size of the page to be returned */ - size?: number; - /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ - sort?: string[]; - search?: string; - }; path: { organizationId: number; + glossaryId: number; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["PagedModelProjectWithStatsModel"]; + "application/json": components["schemas"]["GlossaryModel"]; }; }; /** Bad Request */ @@ -7971,20 +8223,20 @@ export interface operations { }; }; }; - /** Set default granular (scope-based) permissions for organization users, who don't have direct project permissions set. */ - setBasePermissions: { + update_8: { parameters: { path: { organizationId: number; - }; - query: { - /** Granted scopes to all projects for all organization users without direct project permissions set. */ - scopes: string[]; + glossaryId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["GlossaryModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -8018,19 +8270,17 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateGlossaryRequest"]; + }; + }; }; - /** Sets default (level-based) permission for organization */ - setBasePermissions_1: { + delete_11: { parameters: { path: { organizationId: number; - permissionType: - | "NONE" - | "VIEW" - | "TRANSLATE" - | "REVIEW" - | "EDIT" - | "MANAGE"; + glossaryId: number; }; }; responses: { @@ -8070,19 +8320,20 @@ export interface operations { }; }; }; - /** - * This endpoint allows the owner of an organization to connect a Slack workspace to their organization. - * Checks if the Slack integration feature is enabled for the organization and proceeds with the connection. - */ - connectWorkspace: { + getLanguages: { parameters: { path: { organizationId: number; + glossaryId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["CollectionModelGlossaryLanguageDto"]; + }; + }; /** Bad Request */ 400: { content: { @@ -8116,24 +8367,29 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["ConnectToSlackDto"]; - }; - }; }; - /** Returns URL to which user should be redirected to connect Slack workspace */ - connectToSlack: { + getAll_12: { parameters: { path: { organizationId: number; + glossaryId: number; + }; + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + languageTags?: string[]; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["ConnectToSlackUrlModel"]; + "application/json": components["schemas"]["PagedModelSimpleGlossaryTermModel"]; }; }; /** Bad Request */ @@ -8170,18 +8426,18 @@ export interface operations { }; }; }; - /** Returns a list of workspaces connected to the organization */ - getConnectedWorkspaces: { + create_14: { parameters: { path: { organizationId: number; + glossaryId: number; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelWorkspaceModel"]; + "application/json": components["schemas"]["CreateUpdateGlossaryTermResponse"]; }; }; /** Bad Request */ @@ -8217,13 +8473,17 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateGlossaryTermWithTranslationRequest"]; + }; + }; }; - /** Disconnects a workspace from the organization */ - disconnectWorkspace: { + deleteMultiple: { parameters: { path: { - workspaceId: number; organizationId: number; + glossaryId: number; }; }; responses: { @@ -8262,18 +8522,25 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["DeleteMultipleGlossaryTermsRequest"]; + }; + }; }; - findProvider: { + get_21: { parameters: { path: { organizationId: number; + glossaryId: number; + termId: number; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["SsoTenantModel"]; + "application/json": components["schemas"]["SimpleGlossaryTermModel"]; }; }; /** Bad Request */ @@ -8310,7 +8577,868 @@ export interface operations { }; }; }; - setProvider: { + update_9: { + parameters: { + path: { + organizationId: number; + glossaryId: number; + termId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["CreateUpdateGlossaryTermResponse"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateGlossaryTermWithTranslationRequest"]; + }; + }; + }; + delete_12: { + parameters: { + path: { + organizationId: number; + glossaryId: number; + termId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + update_12: { + parameters: { + path: { + organizationId: number; + glossaryId: number; + termId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["GlossaryTermTranslationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateGlossaryTermTranslationRequest"]; + }; + }; + }; + get_22: { + parameters: { + path: { + organizationId: number; + glossaryId: number; + termId: number; + languageTag: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["GlossaryTermTranslationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + getAllIds: { + parameters: { + path: { + organizationId: number; + glossaryId: number; + }; + query: { + search?: string; + languageTags?: string[]; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["CollectionModelLong"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + getAllWithTranslations: { + parameters: { + path: { + organizationId: number; + glossaryId: number; + }; + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + languageTags?: string[]; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PagedModelSimpleGlossaryTermWithTranslationsModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + getInvitations: { + parameters: { + path: { + organizationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["CollectionModelOrganizationInvitationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Returns all languages in use by projects owned by specified organization */ + getAllLanguagesInUse: { + parameters: { + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + }; + path: { + organizationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PagedModelOrganizationLanguageModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Returns machine translation credit balance for organization */ + getOrganizationCredits: { + parameters: { + path: { + organizationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["CreditBalanceModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Returns all projects (including statistics) where current user has any permission (except none) */ + getAllWithStatistics_2: { + parameters: { + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + }; + path: { + organizationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PagedModelProjectWithStatsModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Set default granular (scope-based) permissions for organization users, who don't have direct project permissions set. */ + setBasePermissions: { + parameters: { + path: { + organizationId: number; + }; + query: { + /** Granted scopes to all projects for all organization users without direct project permissions set. */ + scopes: string[]; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Sets default (level-based) permission for organization */ + setBasePermissions_1: { + parameters: { + path: { + organizationId: number; + permissionType: + | "NONE" + | "VIEW" + | "TRANSLATE" + | "REVIEW" + | "EDIT" + | "MANAGE"; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** + * This endpoint allows the owner of an organization to connect a Slack workspace to their organization. + * Checks if the Slack integration feature is enabled for the organization and proceeds with the connection. + */ + connectWorkspace: { + parameters: { + path: { + organizationId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConnectToSlackDto"]; + }; + }; + }; + /** Returns URL to which user should be redirected to connect Slack workspace */ + connectToSlack: { + parameters: { + path: { + organizationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["ConnectToSlackUrlModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Returns a list of workspaces connected to the organization */ + getConnectedWorkspaces: { + parameters: { + path: { + organizationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["CollectionModelWorkspaceModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Disconnects a workspace from the organization */ + disconnectWorkspace: { + parameters: { + path: { + workspaceId: number; + organizationId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + findProvider: { + parameters: { + path: { + organizationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["SsoTenantModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + setProvider: { parameters: { path: { organizationId: number; @@ -11078,6 +12206,57 @@ export interface operations { }; }; }; + getHighlights: { + parameters: { + query: { + text: string; + languageTag: string; + }; + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["CollectionModelGlossaryTermHighlightDto"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; /** Prepares provided files to import. */ addFiles: { parameters: { @@ -13389,6 +14568,8 @@ export interface operations { filterId?: number[]; /** Filter languages without id */ filterNotId?: number[]; + /** Filter languages by name or tag */ + search?: string; }; }; responses: { diff --git a/webapp/src/service/useSelectionService.ts b/webapp/src/service/useSelectionService.ts new file mode 100644 index 0000000000..d20b3fff12 --- /dev/null +++ b/webapp/src/service/useSelectionService.ts @@ -0,0 +1,178 @@ +import { useEffect, useState } from 'react'; + +export type SelectionServiceProps = { + initialSelected?: T[]; + totalCount?: number; + itemsInRange?: (to: T, from: T) => Promise; + itemsAll?: () => Promise; + onChanged?: (selected: T[]) => void; +}; + +export type SelectionService = { + selected: T[]; + total: number; + reset: () => void; + toggle: (item: T) => void; + select: (item: T) => void; + unselect: (item: T) => void; + toggleSelectAll: () => Promise; + toggleAll: () => Promise; + selectAll: () => Promise; + unselectAll: () => void; + toggleMultiple: (items: T[]) => void; + selectMultiple: (items: T[]) => void; + unselectMultiple: (items: T[]) => void; + toggleRange: (to: T, from?: T) => Promise; + selectRange: (to: T, from?: T) => Promise; + unselectRange: (to: T, from?: T) => Promise; + isSelected: (item: T) => boolean; + isAllSelected: boolean; + isSomeSelected: boolean; + isAnySelected: boolean; + isNoneSelected: boolean; + isLoading: boolean; +}; + +export function useSelectionService( + props?: SelectionServiceProps +): SelectionService { + const { + initialSelected = [], + totalCount = 0, + itemsInRange = async (to: T) => [to], + itemsAll = async () => [], + onChanged = () => {}, + } = props || {}; + + const [selected, setSelectedField] = useState(initialSelected); + const [loadingCnt, setLoadingCnt] = useState(0); + const [lastSelected, setLastSelected] = useState(null); + const [warningPrinted, setWarningPrinted] = useState(false); + + const setSelected = (selected: T[]) => { + setSelectedField(selected); + onChanged(selected); + }; + + const resolveRange = async (to: T, from?: T) => { + setLastSelected(to); + setLoadingCnt((c) => c + 1); + const items = await itemsInRange(to, from || lastSelected || to); + setLoadingCnt((c) => c - 1); + return items; + }; + + const resolveAll = async () => { + setLoadingCnt((c) => c + 1); + const items = await itemsAll(); + setLoadingCnt((c) => c - 1); + return items; + }; + + const clearLastSelected = () => { + setLastSelected(null); + }; + + const selectUnsafe = (item: T) => { + setSelected([...selected, item]); + setLastSelected(item); + }; + + const unselect = (item: T) => { + setSelected(selected.filter((i) => i !== item)); + setLastSelected(item); + }; + + const selectAll = async () => { + setSelected(await resolveAll()); + clearLastSelected(); + }; + + const unselectAll = () => { + setSelected([]); + clearLastSelected(); + }; + + const toggleMultiple = (items: T[]) => { + const missing = items.filter((i) => !selected.includes(i)); + setSelected([...selected.filter((i) => !items.includes(i)), ...missing]); + }; + + const selectMultiple = (items: T[]) => { + const missing = items.filter((i) => !selected.includes(i)); + setSelected([...selected, ...missing]); + }; + + const unselectMultiple = (items: T[]) => { + setSelected(selected.filter((i) => !items.includes(i))); + }; + + useEffect(() => { + if (!warningPrinted && selected.length > totalCount) { + // eslint-disable-next-line no-console + console.warn( + `Selected count ${selected.length} is greater than total count ${totalCount}. This is probably a bug.` + ); + setWarningPrinted(true); + } + }, [totalCount, selected.length]); + + const isAllSelected = selected.length >= totalCount; + const isSomeSelected = selected.length > 0 && selected.length < totalCount; + const isAnySelected = selected.length > 0; + const isNoneSelected = selected.length === 0; + const isLoading = loadingCnt > 0; + + return { + selected: selected, + total: totalCount, + reset: () => { + setSelected(initialSelected); + }, + toggle: (item: T) => { + if (selected.includes(item)) { + unselect(item); + } else { + selectUnsafe(item); + } + }, + select: (item: T) => { + if (selected.includes(item)) { + return; + } + selectUnsafe(item); + }, + unselect, + toggleSelectAll: async () => { + if (isAllSelected) { + unselectAll(); + } else { + await selectAll(); + } + setLastSelected(null); + }, + toggleAll: async () => { + toggleMultiple(await resolveAll()); + }, + selectAll, + unselectAll, + toggleMultiple, + selectMultiple, + unselectMultiple, + toggleRange: async (to: T, from?: T) => { + toggleMultiple(await resolveRange(to, from)); + }, + selectRange: async (to: T, from?: T) => { + selectMultiple(await resolveRange(to, from)); + }, + unselectRange: async (to: T, from?: T) => { + unselectMultiple(await resolveRange(to, from)); + }, + isSelected: (item: T) => selected.includes(item), + isAllSelected, + isSomeSelected, + isAnySelected, + isNoneSelected, + isLoading, + }; +} diff --git a/webapp/src/svgs/icons/glossary-empty.svg b/webapp/src/svgs/icons/glossary-empty.svg new file mode 100644 index 0000000000..242857f91b --- /dev/null +++ b/webapp/src/svgs/icons/glossary-empty.svg @@ -0,0 +1,5 @@ + + + diff --git a/webapp/src/translationTools/useFeatures.tsx b/webapp/src/translationTools/useFeatures.tsx index 81dfd60b0d..a37aee9add 100644 --- a/webapp/src/translationTools/useFeatures.tsx +++ b/webapp/src/translationTools/useFeatures.tsx @@ -24,6 +24,7 @@ export function useFeatures() { PROJECT_LEVEL_CONTENT_STORAGES: t( 'billing_subscriptions_project_level_content_storages' ), + GLOSSARY: t('billing_subscriptions_glossary'), ACCOUNT_MANAGER: t('billing_subscriptions_account_manager_feature'), DEDICATED_SLACK_CHANNEL: t('billing_subscriptions_dedicated_slack_channel'), diff --git a/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx b/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx index 8a6d44b709..cc874eb295 100644 --- a/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx +++ b/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx @@ -49,6 +49,9 @@ export const BaseOrganizationSettingsView: React.FC = ({ ); }; + const canManageOrganization = + preferredOrganization?.currentUserRole === 'OWNER' || isAdmin; + const menuItems: SettingsMenuItem[] = [ { link: LINKS.ORGANIZATION_PROFILE.build({ @@ -58,7 +61,7 @@ export const BaseOrganizationSettingsView: React.FC = ({ }, ]; - if (preferredOrganization?.currentUserRole === 'OWNER' || isAdmin) { + if (canManageOrganization) { menuItems.push({ link: LINKS.ORGANIZATION_MEMBERS.build({ [PARAMS.ORGANIZATION_SLUG]: organizationSlug, @@ -71,6 +74,16 @@ export const BaseOrganizationSettingsView: React.FC = ({ }), label: t('organization_menu_member_privileges'), }); + } + + menuItems.push({ + link: LINKS.ORGANIZATION_GLOSSARIES.build({ + [PARAMS.ORGANIZATION_SLUG]: organizationSlug, + }), + label: t('organization_menu_glossaries'), + }); + + if (canManageOrganization) { menuItems.push({ link: LINKS.ORGANIZATION_APPS.build({ [PARAMS.ORGANIZATION_SLUG]: organizationSlug, diff --git a/webapp/src/views/projects/DashboardProjectListItem.tsx b/webapp/src/views/projects/DashboardProjectListItem.tsx index 5f5c4a2647..009152c0be 100644 --- a/webapp/src/views/projects/DashboardProjectListItem.tsx +++ b/webapp/src/views/projects/DashboardProjectListItem.tsx @@ -17,9 +17,9 @@ import { TranslationStatesBar } from 'tg.views/projects/TranslationStatesBar'; import { ProjectListItemMenu } from 'tg.views/projects/ProjectListItemMenu'; import { stopBubble } from 'tg.fixtures/eventHandler'; import { AvatarImg } from 'tg.component/common/avatar/AvatarImg'; -import { ProjectLanguages } from './ProjectLanguages'; import { useGlobalContext } from 'tg.globalContext/GlobalContext'; import { QuickStartHighlight } from 'tg.component/layout/QuickStartGuide/QuickStartHighlight'; +import { CircledLanguageIconList } from 'tg.component/languages/CircledLanguageIconList'; const StyledContainer = styled('div')` display: grid; @@ -172,7 +172,7 @@ const DashboardProjectListItem = (p: ProjectWithStatsModel) => { - + diff --git a/webapp/src/views/projects/ProjectPage.tsx b/webapp/src/views/projects/ProjectPage.tsx index 4014dae9a1..b6b4828499 100644 --- a/webapp/src/views/projects/ProjectPage.tsx +++ b/webapp/src/views/projects/ProjectPage.tsx @@ -17,10 +17,7 @@ export const ProjectPage: FunctionComponent = ({ children }) => { const isAdminAccess = project.computedPermission.origin === 'SERVER_ADMIN'; return ( - } - > + }> {children} ); diff --git a/webapp/src/views/projects/project/components/BaseLanguageSelect.tsx b/webapp/src/views/projects/project/components/BaseLanguageSelect.tsx index c915734a58..3a0d1484a0 100644 --- a/webapp/src/views/projects/project/components/BaseLanguageSelect.tsx +++ b/webapp/src/views/projects/project/components/BaseLanguageSelect.tsx @@ -7,16 +7,21 @@ import { LanguageValue } from 'tg.component/languages/LanguageValue'; import { components } from 'tg.service/apiSchema.generated'; import { FieldLabel } from 'tg.component/FormField'; +type LanguageModel = components['schemas']['LanguageModel']; + export const BaseLanguageSelect: FC<{ - languages: Partial[]; + languages: Partial[]; label?: ReactNode; name: string; - valueKey?: keyof components['schemas']['LanguageModel']; + valueKey?: React.KeyOf; + minHeight?: boolean; + disabled?: boolean; }> = (props) => { const availableLanguages = props.languages.filter((l) => !!l); const context = useFormikContext(); const value = context.getFieldProps(props.name).value; const valueKey = props.valueKey || 'id'; + const minHeight = props.minHeight || false; useEffect(() => { if (value) { @@ -39,7 +44,8 @@ export const BaseLanguageSelect: FC<{ sx={{ mt: 0 }} name={props.name} size="small" - minHeight={false} + minHeight={minHeight} + disabled={props.disabled} renderValue={(v) => { const language = availableLanguages.find( (lang) => lang![valueKey] === v @@ -48,7 +54,7 @@ export const BaseLanguageSelect: FC<{ }} > {availableLanguages.map((l, index) => ( - + ))} diff --git a/webapp/src/views/projects/projectMenu/ProjectMenu.tsx b/webapp/src/views/projects/projectMenu/ProjectMenu.tsx index 70f30b466e..6fd57b8728 100644 --- a/webapp/src/views/projects/projectMenu/ProjectMenu.tsx +++ b/webapp/src/views/projects/projectMenu/ProjectMenu.tsx @@ -22,8 +22,10 @@ import { Integration } from 'tg.component/CustomIcons'; import { FC } from 'react'; import { createAdder } from 'tg.fixtures/pluginAdder'; import { useAddProjectMenuItems } from 'tg.ee'; +import { useProject } from 'tg.hooks/useProject'; -export const ProjectMenu = ({ id }) => { +export const ProjectMenu = () => { + const project = useProject(); const { satisfiesPermission } = useProjectPermissions(); const config = useConfig(); const canPublishCd = satisfiesPermission('content-delivery.publish'); @@ -70,6 +72,18 @@ export const ProjectMenu = ({ id }) => { matchAsPrefix: true, quickStart: { itemKey: 'menu_languages' }, }, + // TODO: Do we want to include glossaries in project menu? + // Also this will need to be in ee module + // { + // id: 'glossaries', + // condition: () => true, + // link: LINKS.ORGANIZATION_GLOSSARIES, + // icon: BookClosed, + // text: t('project_menu_glossaries'), + // dataCy: 'project-menu-item-glossaries', + // matchAsPrefix: true, + // // TODO: quickstart? + // }, { id: 'members', condition: ({ satisfiesPermission }) => @@ -113,7 +127,9 @@ export const ProjectMenu = ({ id }) => { text: t('project_menu_developer'), dataCy: 'project-menu-item-developer', quickStart: { itemKey: 'menu_developer' }, - matchAsPrefix: LINKS.PROJECT_DEVELOPER.build({ [PARAMS.PROJECT_ID]: id }), + matchAsPrefix: LINKS.PROJECT_DEVELOPER.build({ + [PARAMS.PROJECT_ID]: project.id, + }), }, { id: 'integrate', @@ -150,7 +166,10 @@ export const ProjectMenu = ({ id }) => { return ( } data-cy={dataCy} diff --git a/webapp/src/views/projects/translations/Screenshots/Screenshots.tsx b/webapp/src/views/projects/translations/Screenshots/Screenshots.tsx index c31bacf155..798d075e90 100644 --- a/webapp/src/views/projects/translations/Screenshots/Screenshots.tsx +++ b/webapp/src/views/projects/translations/Screenshots/Screenshots.tsx @@ -5,7 +5,7 @@ import clsx from 'clsx'; import { ScreenshotDetail } from './ScreenshotDetail'; import { stopAndPrevent } from 'tg.fixtures/eventHandler'; -import { useScrollStatus } from '../TranslationsTable/useScrollStatus'; +import { useScrollStatus } from 'tg.component/common/useScrollStatus'; import { ChevronLeft, ChevronRight } from '@untitled-ui/icons-react'; import { ScreenshotsList } from './ScreenshotsList'; import { useTranslationsActions } from '../context/TranslationsContext'; diff --git a/webapp/src/views/projects/translations/ToolsPanel/panelsList.tsx b/webapp/src/views/projects/translations/ToolsPanel/panelsList.tsx index eaf69e8636..7027500302 100644 --- a/webapp/src/views/projects/translations/ToolsPanel/panelsList.tsx +++ b/webapp/src/views/projects/translations/ToolsPanel/panelsList.tsx @@ -12,7 +12,7 @@ import { import { PanelConfig } from './common/types'; import { KeyboardShortcuts } from './panels/KeyboardShortcuts/KeyboardShortcuts'; import { createAdder } from 'tg.fixtures/pluginAdder'; -import { translationPanelAdder } from 'tg.ee'; +import { glossaryPanelAdder, translationPanelAdder } from 'tg.ee'; export const PANELS_WHEN_INACTIVE = [ { @@ -56,5 +56,5 @@ const BASE_PANELS = [ export const addPanel = createAdder({ referencingProperty: 'id' }); export function getPanels() { - return translationPanelAdder(BASE_PANELS); + return glossaryPanelAdder(translationPanelAdder(BASE_PANELS)); } diff --git a/webapp/src/views/projects/translations/TranslationFilters/SubfilterNamespaces.tsx b/webapp/src/views/projects/translations/TranslationFilters/SubfilterNamespaces.tsx index c931d9b3b9..bf097fb6e8 100644 --- a/webapp/src/views/projects/translations/TranslationFilters/SubfilterNamespaces.tsx +++ b/webapp/src/views/projects/translations/TranslationFilters/SubfilterNamespaces.tsx @@ -94,7 +94,7 @@ export const SubfilterNamespaces = ({ value, actions, projectId }: Props) => { }; const handleFetchMore = () => { - if (dataLoadable.hasNextPage && dataLoadable.isFetching) { + if (dataLoadable.hasNextPage && !dataLoadable.isFetching) { dataLoadable.fetchNextPage(); } }; @@ -150,12 +150,12 @@ export const SubfilterNamespaces = ({ value, actions, projectId }: Props) => { item.id} maxWidth={400} onSearch={setSearch} search={search} displaySearch={(totalItems ?? 0) > 10} renderOption={renderItem} - getOptionLabel={(o) => o.name} ListboxProps={{ style: { maxHeight: 400, overflow: 'auto' } }} searchPlaceholder={t( 'translations_filters_namespaces_search_placeholder' diff --git a/webapp/src/views/projects/translations/TranslationFilters/SubfilterTags.tsx b/webapp/src/views/projects/translations/TranslationFilters/SubfilterTags.tsx index 0f3ed8a210..5895568b60 100644 --- a/webapp/src/views/projects/translations/TranslationFilters/SubfilterTags.tsx +++ b/webapp/src/views/projects/translations/TranslationFilters/SubfilterTags.tsx @@ -149,12 +149,12 @@ export const SubfilterTags = ({ value, actions, projectId }: Props) => { item.id} maxWidth={400} onSearch={setSearch} search={search} displaySearch={(totalItems ?? 0) > 10} renderOption={renderItem} - getOptionLabel={(o) => o.name} ListboxProps={{ style: { maxHeight: 400, overflow: 'auto' }, }} diff --git a/webapp/src/views/projects/translations/TranslationsList/TranslationRead.tsx b/webapp/src/views/projects/translations/TranslationsList/TranslationRead.tsx index 5b66567e0c..ffe37c160e 100644 --- a/webapp/src/views/projects/translations/TranslationsList/TranslationRead.tsx +++ b/webapp/src/views/projects/translations/TranslationsList/TranslationRead.tsx @@ -58,6 +58,8 @@ export const TranslationRead: React.FC = ({ }) => { const { isEditing, + isEditingRow, + editingLanguageTag, handleOpen, handleClose, setState: handleStateChange, @@ -118,7 +120,9 @@ export const TranslationRead: React.FC = ({ width={width} text={translation?.text} locale={language.tag} + targetLocale={editingLanguageTag} disabled={disabled} + showHighlights={isEditingRow && language.base} isPlural={keyData.keyIsPlural} /> diff --git a/webapp/src/views/projects/translations/TranslationsList/TranslationWrite.tsx b/webapp/src/views/projects/translations/TranslationsList/TranslationWrite.tsx index 594b60b9bc..9d0d54d5a8 100644 --- a/webapp/src/views/projects/translations/TranslationsList/TranslationWrite.tsx +++ b/webapp/src/views/projects/translations/TranslationsList/TranslationWrite.tsx @@ -172,6 +172,7 @@ export const TranslationWrite: React.FC = ({ tools }) => { locale={language.tag} isPlural={keyData.keyIsPlural} disabled={disabled} + showHighlights={language.base} /> )}
diff --git a/webapp/src/views/projects/translations/TranslationsTable/CellLanguage.tsx b/webapp/src/views/projects/translations/TranslationsTable/CellLanguage.tsx index 42e089d5a7..d118899647 100644 --- a/webapp/src/views/projects/translations/TranslationsTable/CellLanguage.tsx +++ b/webapp/src/views/projects/translations/TranslationsTable/CellLanguage.tsx @@ -1,11 +1,15 @@ import React from 'react'; -import { styled, Box } from '@mui/material'; +import { Box, styled } from '@mui/material'; import { components } from 'tg.service/apiSchema.generated'; import { CellStateBar } from '../cell/CellStateBar'; import { FlagImage } from 'tg.component/languages/FlagImage'; type LanguageModel = components['schemas']['LanguageModel']; +export type CellLanguageModel = Pick< + LanguageModel, + 'base' | 'flagEmoji' | 'name' +>; const StyledContent = styled('div')` display: flex; @@ -16,17 +20,11 @@ const StyledContent = styled('div')` `; type Props = { - language: LanguageModel; - colIndex: number; - onResize: (colIndex: number) => void; + language: CellLanguageModel; + onResize?: () => void; }; -export const CellLanguage: React.FC = ({ - language, - onResize, - colIndex, -}) => { - const handleResize = () => onResize(colIndex); +export const CellLanguage: React.FC = ({ language, onResize }) => { return ( <> @@ -35,7 +33,7 @@ export const CellLanguage: React.FC = ({ {language.name} - + {onResize && } ); }; diff --git a/webapp/src/views/projects/translations/TranslationsTable/TranslationRead.tsx b/webapp/src/views/projects/translations/TranslationsTable/TranslationRead.tsx index 9ba9411457..5a120a7d29 100644 --- a/webapp/src/views/projects/translations/TranslationsTable/TranslationRead.tsx +++ b/webapp/src/views/projects/translations/TranslationsTable/TranslationRead.tsx @@ -53,6 +53,8 @@ export const TranslationRead: React.FC = ({ }) => { const { isEditing, + isEditingRow, + editingLanguageTag, handleOpen, handleClose, setState: handleStateChange, @@ -90,7 +92,9 @@ export const TranslationRead: React.FC = ({ width={width} text={translation?.text} locale={language.tag} + targetLocale={editingLanguageTag} disabled={disabled} + showHighlights={isEditingRow && language.base} isPlural={keyData.keyIsPlural} /> diff --git a/webapp/src/views/projects/translations/TranslationsTable/TranslationWrite.tsx b/webapp/src/views/projects/translations/TranslationsTable/TranslationWrite.tsx index cfa41a601c..8f12bbf19c 100644 --- a/webapp/src/views/projects/translations/TranslationsTable/TranslationWrite.tsx +++ b/webapp/src/views/projects/translations/TranslationsTable/TranslationWrite.tsx @@ -132,6 +132,7 @@ export const TranslationWrite: React.FC = ({ tools }) => { locale={language.tag} isPlural={keyData.keyIsPlural} disabled={disabled} + showHighlights={language.base} /> )} diff --git a/webapp/src/views/projects/translations/TranslationsTable/TranslationsTable.tsx b/webapp/src/views/projects/translations/TranslationsTable/TranslationsTable.tsx index 4ca6171a68..7634dba19e 100644 --- a/webapp/src/views/projects/translations/TranslationsTable/TranslationsTable.tsx +++ b/webapp/src/views/projects/translations/TranslationsTable/TranslationsTable.tsx @@ -3,8 +3,8 @@ import { Portal, styled, useMediaQuery } from '@mui/material'; import { T } from '@tolgee/react'; import { - useTranslationsSelector, useTranslationsActions, + useTranslationsSelector, } from '../context/TranslationsContext'; import { ColumnResizer } from '../ColumnResizer'; import { CellLanguage } from './CellLanguage'; @@ -14,9 +14,9 @@ import { useNsBanners } from '../context/useNsBanners'; import { NAMESPACE_BANNER_SPACING } from '../cell/styles'; import { ReactList } from 'tg.component/reactList/ReactList'; import clsx from 'clsx'; -import { useScrollStatus } from './useScrollStatus'; import { useColumns } from '../useColumns'; import { ChevronLeft, ChevronRight } from '@untitled-ui/icons-react'; +import { useScrollStatus } from 'tg.component/common/useScrollStatus'; const ARROW_SIZE = 50; @@ -280,8 +280,7 @@ export const TranslationsTable = ({ width }: Props) => { language && ( startResize(i - 1)} language={language} /> diff --git a/webapp/src/views/projects/translations/translationVisual/TranslationVisual.tsx b/webapp/src/views/projects/translations/translationVisual/TranslationVisual.tsx index 9a0cede699..876a06aa45 100644 --- a/webapp/src/views/projects/translations/translationVisual/TranslationVisual.tsx +++ b/webapp/src/views/projects/translations/translationVisual/TranslationVisual.tsx @@ -18,8 +18,10 @@ type Props = { maxLines?: number; text: string | undefined; locale: string; + targetLocale?: string; width?: number | string; disabled?: boolean; + showHighlights?: boolean; isPlural: boolean; }; @@ -27,8 +29,10 @@ export const TranslationVisual = ({ maxLines, text, locale, + targetLocale, width, disabled, + showHighlights, isPlural, }: Props) => { const project = useProject(); @@ -58,7 +62,9 @@ export const TranslationVisual = ({ content={content} pluralExampleValue={exampleValue} locale={locale} + targetLocale={targetLocale} nested={Boolean(variant)} + showHighlights={showHighlights} /> )} diff --git a/webapp/src/views/projects/translations/translationVisual/TranslationWithPlaceholders.tsx b/webapp/src/views/projects/translations/translationVisual/TranslationWithPlaceholders.tsx index b64b8ffb01..3d2c83ed5f 100644 --- a/webapp/src/views/projects/translations/translationVisual/TranslationWithPlaceholders.tsx +++ b/webapp/src/views/projects/translations/translationVisual/TranslationWithPlaceholders.tsx @@ -1,26 +1,99 @@ import { useMemo } from 'react'; -import { generatePlaceholdersStyle, getPlaceholders } from '@tginternal/editor'; -import { styled, useTheme } from '@mui/material'; +import { + generatePlaceholdersStyle, + getPlaceholders, + Placeholder, + Position, +} from '@tginternal/editor'; +import { styled, Tooltip, useTheme } from '@mui/material'; import { getLanguageDirection } from 'tg.fixtures/getLanguageDirection'; import { placeholderToElement } from './placeholderToElement'; import { useProject } from 'tg.hooks/useProject'; +import { GlossaryTermPreview, useGlossaryTermHighlights } from 'tg.ee'; +import { GlossaryTermHighlightDto } from '../../../../eeSetup/EeModuleType'; +import { TooltipCard } from 'tg.component/common/TooltipCard'; const StyledWrapper = styled('div')` white-space: pre-wrap; `; +const StyledHighlight = styled('span')` + text-decoration: underline; + text-decoration-style: dashed; + text-underline-offset: ${({ theme }) => theme.spacing(0.5)}; +`; + type Props = { content: string; pluralExampleValue?: number | undefined; locale: string; + targetLocale?: string; nested: boolean; + showHighlights?: boolean; }; +type Modifier = { + position: Position; + placeholder?: Placeholder; + highlight?: GlossaryTermHighlightDto; +}; + +function isOverlapping(a: Position, b: Position): boolean { + return a.start <= b.end && a.end >= b.start; +} + +function sortModifiers( + placeholders: Placeholder[], + highlights: GlossaryTermHighlightDto[] +): Modifier[] { + let modifiers: Modifier[] = placeholders.map((placeholder) => ({ + position: placeholder.position, + placeholder: placeholder, + })); + + highlights.forEach((highlight) => { + const overlappingModifiers = modifiers.filter(({ position }) => + isOverlapping(position, highlight.position) + ); + + // Add non-overlapping highlights + if (overlappingModifiers.length === 0) { + modifiers.push({ + position: highlight.position, + highlight: highlight, + }); + return; + } + + // If there is an overlap with only shorter highlights, replace them with the longer one + const highlightLength = highlight.position.end - highlight.position.start; + const areAllOverlapsOnlyShorterHighlights = overlappingModifiers.every( + (modifier) => + modifier.highlight && + modifier.position.end - modifier.position.start < highlightLength + ); + + if (areAllOverlapsOnlyShorterHighlights) { + modifiers = modifiers.filter( + ({ position }) => !isOverlapping(position, highlight.position) + ); + modifiers.push({ + position: highlight.position, + highlight: highlight, + }); + } + }); + + return modifiers.sort((a, b) => a.position.start - b.position.start); +} + export const TranslationWithPlaceholders = ({ content, pluralExampleValue, locale, + targetLocale, nested, + showHighlights, }: Props) => { const project = useProject(); const theme = useTheme(); @@ -32,6 +105,14 @@ export const TranslationWithPlaceholders = ({ return getPlaceholders(content, nested) || []; }, [content, nested]); + const glossaryTerms = useGlossaryTermHighlights({ + text: content, + languageTag: locale, + enabled: showHighlights ?? false, + }); + + const modifiers = sortModifiers(placeholders, glossaryTerms); + const StyledPlaceholdersWrapper = useMemo(() => { return generatePlaceholdersStyle({ styled, @@ -42,14 +123,43 @@ export const TranslationWithPlaceholders = ({ const chunks: React.ReactNode[] = []; let index = 0; - for (const placeholder of placeholders) { - if (placeholder.position.start !== index) { - chunks.push(content.substring(index, placeholder.position.start)); + for (const modifier of modifiers) { + if (modifier.position.start !== index) { + chunks.push(content.substring(index, modifier.position.start)); + } + index = modifier.position.end; + if (modifier.placeholder) { + chunks.push( + placeholderToElement({ + placeholder: modifier.placeholder, + pluralExampleValue, + key: index, + }) + ); + } else if (modifier.highlight) { + const text = content.substring( + modifier.position.start, + modifier.position.end + ); + chunks.push( + + } + > + {text} + + ); } - index = placeholder.position.end; - chunks.push( - placeholderToElement({ placeholder, pluralExampleValue, key: index }) - ); } if (index < content.length) { diff --git a/webapp/src/views/projects/translations/useTranslationCell.ts b/webapp/src/views/projects/translations/useTranslationCell.ts index a1aadcf3b4..3f872a5664 100644 --- a/webapp/src/views/projects/translations/useTranslationCell.ts +++ b/webapp/src/views/projects/translations/useTranslationCell.ts @@ -56,9 +56,7 @@ export const useTranslationCell = ({ const langTag = language.tag; const cursor = useTranslationsSelector((v) => { - return v.cursor?.keyId === keyId && v.cursor.language === language.tag - ? v.cursor - : undefined; + return v.cursor?.keyId === keyId ? v.cursor : undefined; }); const baseLanguage = useTranslationsSelector((c) => @@ -207,6 +205,7 @@ export const useTranslationCell = ({ editVal: isEditing ? cursor : undefined, isEditing, isEditingRow, + editingLanguageTag: cursor?.language, autofocus: true, keyData, canChangeState,