diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/dataImport/V2ImportController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/dataImport/V2ImportController.kt index 572bce9570..37c5727b35 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/dataImport/V2ImportController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/dataImport/V2ImportController.kt @@ -82,24 +82,27 @@ class V2ImportController( filteredFiles.map { ImportFileDto(it.originalFilename ?: "", it.inputStream.readAllBytes()) } - val errors = + val (errors, warnings) = importService.addFiles( files = fileDtos, project = projectHolder.projectEntity, userAccount = authenticationFacade.authenticatedUserEntity, params = params, ) - return getImportAddFilesResultModel(errors) + return getImportAddFilesResultModel(errors, warnings) } - private fun getImportAddFilesResultModel(errors: List): ImportAddFilesResultModel { + private fun getImportAddFilesResultModel( + errors: List, + warnings: List, + ): ImportAddFilesResultModel { val result: PagedModel? = try { this.getImportResult(PageRequest.of(0, 100)) } catch (e: NotFoundException) { null } - return ImportAddFilesResultModel(errors, result) + return ImportAddFilesResultModel(errors, warnings, result) } @PutMapping("/apply") diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/keys/KeyController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/keys/KeyController.kt index 9ff3650991..ca9624cc54 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/keys/KeyController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/keys/KeyController.kt @@ -6,6 +6,7 @@ import io.tolgee.activity.RequestActivity import io.tolgee.activity.data.ActivityType import io.tolgee.api.v2.controllers.IController import io.tolgee.component.KeyComplexEditHelper +import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.LanguageDto import io.tolgee.dtos.queryResults.KeyView import io.tolgee.dtos.request.GetKeysRequestDto @@ -16,6 +17,7 @@ import io.tolgee.dtos.request.key.DeleteKeysDto import io.tolgee.dtos.request.key.EditKeyDto import io.tolgee.dtos.request.translation.ImportKeysDto import io.tolgee.dtos.request.translation.importKeysResolvable.ImportKeysResolvableDto +import io.tolgee.dtos.request.validators.exceptions.ValidationException import io.tolgee.exceptions.NotFoundException import io.tolgee.hateoas.key.KeyImportResolvableResultModel import io.tolgee.hateoas.key.KeyModel @@ -114,6 +116,7 @@ class KeyController( checkTranslatePermission(dto) checkCanStoreBigMeta(dto) checkStateChangePermission(dto) + checkNamespaceFeature(dto.namespace) val key = keyService.create(projectHolder.projectEntity, dto) return ResponseEntity(keyWithDataModelAssembler.toModel(key), HttpStatus.CREATED) @@ -162,6 +165,7 @@ class KeyController( ): KeyModel { val key = keyService.findOptional(id).orElseThrow { NotFoundException() } key.checkInProject() + checkNamespaceFeature(dto.namespace) keyService.edit(id, dto) val view = KeyView(key.id, key.name, key?.namespace?.name, key.keyMeta?.description, key.keyMeta?.custom) return keyModelAssembler.toModel(view) @@ -194,6 +198,7 @@ class KeyController( @RequestBody @Valid dto: ComplexEditKeyDto, ): KeyWithDataModel { + checkNamespaceFeature(dto.namespace) return KeyComplexEditHelper(applicationContext, id, dto).doComplexUpdate() } @@ -382,4 +387,10 @@ class KeyController( projectHolder.projectEntity.checkScreenshotsUploadPermission() } } + + private fun checkNamespaceFeature(namespace: String?) { + if (!projectHolder.projectEntity.useNamespaces && namespace != null) { + throw ValidationException(Message.NAMESPACE_CANNOT_BE_USED_WHEN_FEATURE_IS_DISABLED) + } + } } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportAddFilesResultModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportAddFilesResultModel.kt index 2aeb65c315..819ce6124f 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportAddFilesResultModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportAddFilesResultModel.kt @@ -8,5 +8,6 @@ import org.springframework.hateoas.server.core.Relation @Relation(collectionRelation = "fileIssues", itemRelation = "fileIssue") open class ImportAddFilesResultModel( val errors: List, + val warnings: List, val result: PagedModel?, ) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModel.kt index a62056c0d8..0ad68dad4b 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModel.kt @@ -21,6 +21,7 @@ open class ProjectModel( val avatar: Avatar?, val organizationOwner: SimpleOrganizationModel?, val baseLanguage: LanguageModel?, + val useNamespaces: Boolean, val defaultNamespace: NamespaceModel?, val organizationRole: OrganizationRoleType?, @Schema(description = "Current user's direct permission", example = "MANAGE") diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt index 5de117c1ac..6a88f41cfe 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt @@ -58,6 +58,7 @@ class ProjectModelAssembler( organizationRole = view.organizationRole, organizationOwner = view.organizationOwner.let { simpleOrganizationModelAssembler.toModel(it) }, baseLanguage = baseLanguage.let { languageModelAssembler.toModel(LanguageDto.fromEntity(it, it.id)) }, + useNamespaces = view.useNamespaces, defaultNamespace = defaultNamespace, directPermission = view.directPermission?.let { permissionModelAssembler.toModel(it) }, computedPermission = computedPermissionModelAssembler.toModel(computedPermissions), diff --git a/backend/app/src/test/kotlin/io/tolgee/StartupImportCommandLineRunnerTest.kt b/backend/app/src/test/kotlin/io/tolgee/StartupImportCommandLineRunnerTest.kt index a469e0d9e8..06b04016a3 100644 --- a/backend/app/src/test/kotlin/io/tolgee/StartupImportCommandLineRunnerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/StartupImportCommandLineRunnerTest.kt @@ -60,6 +60,7 @@ class StartupImportCommandLineRunnerTest : AbstractSpringTest() { assertThat(translationService.getAllByLanguageId(it.id)).hasSize(10) } assertThat(project.apiKeys.first().keyHash).isEqualTo("Zy98PdrKTEla1Ix7I1WbZPRoIDttk+Byk77tEjgRIzs=") + assertThat(project.useNamespaces).isFalse() } } @@ -70,6 +71,7 @@ class StartupImportCommandLineRunnerTest : AbstractSpringTest() { assertThat(projects).isNotEmpty val project = projects.first() project.namespaces.assert.hasSize(7) + assertThat(project.useNamespaces).isTrue() } } @@ -80,6 +82,7 @@ class StartupImportCommandLineRunnerTest : AbstractSpringTest() { assertThat(projects).isNotEmpty val project = projects.first() project.baseLanguage!!.tag.assert.isEqualTo("de") + assertThat(project.useNamespaces).isFalse() } } } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/SingleStepImportControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/SingleStepImportControllerTest.kt index 199a56d6e7..5d28d0fe7e 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/SingleStepImportControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/SingleStepImportControllerTest.kt @@ -142,6 +142,7 @@ class SingleStepImportControllerTest : ProjectAuthControllerTest("/v2/projects/" @ProjectJWTAuthTestMethod fun `maps namespace`() { saveAndPrepare() + enableNamespaces() performImport( projectId = testData.project.id, listOf(Pair(jsonFileName, simpleJson)), @@ -152,10 +153,35 @@ class SingleStepImportControllerTest : ProjectAuthControllerTest("/v2/projects/" } } + @Test + @ProjectJWTAuthTestMethod + fun `namespace mapping fails if namespaces are disabled`() { + saveAndPrepare() + performImport( + projectId = testData.project.id, + listOf(Pair(jsonFileName, simpleJson)), + getFileMappings(jsonFileName, namespace = "test"), + ).andIsBadRequest.andHasErrorMessage(Message.NAMESPACE_CANNOT_BE_USED_WHEN_FEATURE_IS_DISABLED) + } + + @Test + @ProjectJWTAuthTestMethod + fun `detected namespaces are ignored if namespaces are disabled`() { + saveAndPrepare() + performImport( + projectId = testData.project.id, + listOf(Pair("test-namespace/$jsonFileName", simpleJson)), + ).andIsOk + executeInNewTransaction { + getTestTranslation(namespace = null).assert.isNotNull + } + } + @Test @ProjectJWTAuthTestMethod fun `maps null namespace from non-null mapping`() { saveAndPrepare() + enableNamespaces() val fileName = "guessed-ns/en.json" performImport( projectId = testData.project.id, @@ -166,6 +192,13 @@ class SingleStepImportControllerTest : ProjectAuthControllerTest("/v2/projects/" executeInNewTransaction { getTestTranslation().assert.isNotNull } + + performImport( + projectId = testData.project.id, + listOf(Pair(fileName, simpleJson)), + getFileMappings(fileName, namespace = ""), + ).andIsOk + performImport( projectId = testData.project.id, listOf(Pair(fileName, simpleJson)), @@ -366,4 +399,10 @@ class SingleStepImportControllerTest : ProjectAuthControllerTest("/v2/projects/" userAccount = testData.user projectSupplier = { testData.project } } + + private fun enableNamespaces() { + val fetchedProject = projectService.find(testData.project.id)!! + fetchedProject.useNamespaces = true + projectService.save(fetchedProject) + } } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerAddFilesTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerAddFilesTest.kt index ac5caf71be..856d60c8d5 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerAddFilesTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerAddFilesTest.kt @@ -15,6 +15,7 @@ import io.tolgee.testing.assert import io.tolgee.testing.assertions.Assertions.assertThat import io.tolgee.util.InMemoryFileStorage import io.tolgee.util.performImport +import net.javacrumbs.jsonunit.core.internal.Node.JsonMap import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Value @@ -241,6 +242,8 @@ class V2ImportControllerAddFilesTest : ProjectAuthControllerTest("/v2/projects/" @Test fun `pre-selects namespaces and languages correctly`() { val base = dbPopulator.createBase() + base.project.useNamespaces = true + projectService.save(base.project) commitTransaction() tolgeeProperties.maxTranslationTextLength = 20 @@ -248,7 +251,9 @@ class V2ImportControllerAddFilesTest : ProjectAuthControllerTest("/v2/projects/" performImport( projectId = base.project.id, listOf(Pair("namespaces.zip", namespacesZip)), - ).andIsOk + ).andIsOk.andAssertThatJson { + node("warnings").isArray.isEmpty() + } } executeInNewTransaction { @@ -262,6 +267,27 @@ class V2ImportControllerAddFilesTest : ProjectAuthControllerTest("/v2/projects/" } } + @Test + fun `returns warning and blank namespaces when namespaces are detected but disabled`() { + val base = dbPopulator.createBase() + base.project.useNamespaces = false + projectService.save(base.project) + commitTransaction() + + executeInNewTransaction { + performImport( + projectId = base.project.id, + listOf(Pair("namespaces.zip", namespacesZip)), + ).andIsOk.andAssertThatJson { + node("warnings").isArray.hasSize(1) + node("warnings[0].code").isEqualTo("namespace_cannot_be_used_when_feature_is_disabled") + node("result._embedded.languages").isArray.allSatisfy { + (it as JsonMap)["namespace"].assert.isNull() + } + } + } + } + @Test fun `works fine with Mac generated zip`() { val base = dbPopulator.createBase() @@ -322,6 +348,23 @@ class V2ImportControllerAddFilesTest : ProjectAuthControllerTest("/v2/projects/" } } + @Test + fun `import gets deleted after namespaces feature is toggled`() { + val base = dbPopulator.createBase() + + performImport(projectId = base.project.id, listOf("simple.json" to simpleJson)) + .andIsOk + + assertThat(importService.getAllByProject(base.project.id)).isNotEmpty() + + val project = projectService.get(base.project.id) + project.useNamespaces = !project.useNamespaces + projectService.save(project) + commitTransaction() + + assertThat(importService.getAllByProject(project.id)).isEmpty() + } + private fun validateSavedJsonImportData( project: Project, userAccount: UserAccount, diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerWithNamespacesTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerWithNamespacesTest.kt index 1697bdbdc6..31620cde2f 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerWithNamespacesTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerWithNamespacesTest.kt @@ -38,6 +38,7 @@ class KeyControllerWithNamespacesTest : ProjectAuthControllerTest("/v2/projects/ @ProjectJWTAuthTestMethod @Test fun `creates key and namespace`() { + enableNamespaces() performProjectAuthPost("keys", mapOf("name" to "super_key", "namespace" to "new_ns")) .andIsCreated.andAssertThatJson { node("name").isEqualTo("super_key") @@ -50,6 +51,7 @@ class KeyControllerWithNamespacesTest : ProjectAuthControllerTest("/v2/projects/ @ProjectJWTAuthTestMethod @Test fun `blank namespace doesn't create ns`() { + enableNamespaces() performProjectAuthPost("keys", CreateKeyDto(name = "super_key", namespace = "")) .andIsCreated namespaceService.find("", project.id).assert.isNull() @@ -58,6 +60,7 @@ class KeyControllerWithNamespacesTest : ProjectAuthControllerTest("/v2/projects/ @ProjectJWTAuthTestMethod @Test fun `creates key in existing namespace`() { + enableNamespaces() performProjectAuthPost("keys", CreateKeyDto(name = "super_key", namespace = "ns-1")) .andIsCreated.andAssertThatJson { node("name").isEqualTo("super_key") @@ -69,6 +72,7 @@ class KeyControllerWithNamespacesTest : ProjectAuthControllerTest("/v2/projects/ @ProjectJWTAuthTestMethod @Test fun `does not create key when not unique in ns`() { + enableNamespaces() performProjectAuthPost("keys", CreateKeyDto(name = "key", "ns-1")) .andAssertError .isCustomValidation.hasMessage("key_exists") @@ -77,6 +81,7 @@ class KeyControllerWithNamespacesTest : ProjectAuthControllerTest("/v2/projects/ @ProjectJWTAuthTestMethod @Test fun `updates key in ns`() { + enableNamespaces() performProjectAuthPut("keys/${testData.keyInNs1.id}", EditKeyDto(name = "super_k", "ns-2")) .andIsOk.andAssertThatJson { node("id").isValidId @@ -109,6 +114,7 @@ class KeyControllerWithNamespacesTest : ProjectAuthControllerTest("/v2/projects/ @ProjectJWTAuthTestMethod @Test fun `throws error when moving key to default ns where a key with same name already exists`() { + enableNamespaces() val keyName = "super_ultra_cool_key" val namespace = "super_ultra_cool_namespace" @@ -155,6 +161,7 @@ class KeyControllerWithNamespacesTest : ProjectAuthControllerTest("/v2/projects/ @ProjectJWTAuthTestMethod @Test fun `deletes ns when empty on update`() { + enableNamespaces() performProjectAuthPut("keys/${testData.singleKeyInNs2.id}", EditKeyDto(name = "super_k", "ns-1")) .andIsOk namespaceService.find("ns-2", project.id).assert.isNull() @@ -171,4 +178,57 @@ class KeyControllerWithNamespacesTest : ProjectAuthControllerTest("/v2/projects/ namespaceService.getAllInProject(testData.projectBuilder.self.id).assert.isEmpty() } + + @ProjectJWTAuthTestMethod + @Test + fun `key with namespace cannot be created when useNamespaces feature is disabled`() { + performProjectAuthPost("keys", mapOf("name" to "super_key", "namespace" to "")) + .andIsCreated.andAssertThatJson { + node("namespace").isNull() + } + + performProjectAuthPost("keys", mapOf("name" to "super_key", "namespace" to "new_ns")) + .andIsBadRequest + .andAssertError + .isCustomValidation.hasMessage("namespace_cannot_be_used_when_feature_is_disabled") + } + + @ProjectJWTAuthTestMethod + @Test + fun `key with namespace cannot be edited when useNamespaces feature is disabled`() { + performProjectAuthPut("keys/${testData.keyWithoutNs.id}", EditKeyDto(name = "super_k", "")) + .andIsOk.andAssertThatJson { + node("namespace").isNull() + } + + performProjectAuthPut("keys/${testData.keyWithoutNs.id}", EditKeyDto(name = "super_k", "ns-2")) + .andIsBadRequest + .andAssertError + .isCustomValidation.hasMessage("namespace_cannot_be_used_when_feature_is_disabled") + } + + @ProjectJWTAuthTestMethod + @Test + fun `key with namespace cannot be complex-edited when useNamespaces feature is disabled`() { + performProjectAuthPut( + "keys/${testData.keyWithoutNs.id}/complex-update", + mapOf("name" to "new-name", "namespace" to ""), + ).andIsOk.andAssertThatJson { + node("namespace").isNull() + } + + performProjectAuthPut( + "keys/${testData.keyWithoutNs.id}/complex-update", + mapOf("name" to "new-name", "namespace" to "ns-2"), + ) + .andIsBadRequest + .andAssertError + .isCustomValidation.hasMessage("namespace_cannot_be_used_when_feature_is_disabled") + } + + private fun enableNamespaces() { + val projectFetched = projectService.get(project.id) + projectFetched.useNamespaces = true + projectService.save(projectFetched) + } } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerEditTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerEditTest.kt index b39f679015..b5b0a7d0f7 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerEditTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/ProjectsControllerEditTest.kt @@ -22,12 +22,14 @@ class ProjectsControllerEditTest : AuthorizedControllerTest() { baseLanguageId = base.project.languages.toList()[1].id, slug = "new-slug", icuPlaceholders = true, + useNamespaces = true, ) performAuthPut("/v2/projects/${base.project.id}", content).andPrettyPrint.andIsOk.andAssertThatJson { node("name").isEqualTo(content.name) node("slug").isEqualTo(content.slug) node("baseLanguage.id").isEqualTo(content.baseLanguageId) node("icuPlaceholders").isEqualTo(content.icuPlaceholders) + node("useNamespaces").isEqualTo(content.useNamespaces) } } @@ -56,4 +58,18 @@ class ProjectsControllerEditTest : AuthorizedControllerTest() { node("baseLanguage.id").isEqualTo(base.project.languages.toList()[0].id) } } + + @Test + fun `fail validation on disabling namespaces when a namespace exists`() { + val base = dbPopulator.createBase() + dbPopulator.createNamespace(base.project) + val content = + EditProjectRequest( + name = "test", + useNamespaces = false, + ) + performAuthPut("/v2/projects/${base.project.id}", content).andPrettyPrint.andIsBadRequest.andAssertThatJson { + node("CUSTOM_VALIDATION.namespaces_cannot_be_disabled_when_namespace_exists").isNotNull + } + } } 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 5ea7c8d8ea..d9d5de1885 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -255,6 +255,8 @@ enum class Message { INVITATION_ORGANIZATION_MISMATCH, USER_IS_MANAGED_BY_ORGANIZATION, CANNOT_SET_SSO_PROVIDER_MISSING_FIELDS, + NAMESPACES_CANNOT_BE_DISABLED_WHEN_NAMESPACE_EXISTS, + NAMESPACE_CANNOT_BE_USED_WHEN_FEATURE_IS_DISABLED, ; val code: String diff --git a/backend/data/src/main/kotlin/io/tolgee/development/DbPopulatorReal.kt b/backend/data/src/main/kotlin/io/tolgee/development/DbPopulatorReal.kt index 95dae6fd28..adaec52488 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/DbPopulatorReal.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/DbPopulatorReal.kt @@ -14,6 +14,7 @@ import io.tolgee.model.key.Key import io.tolgee.model.translation.Translation import io.tolgee.repository.UserAccountRepository import io.tolgee.security.InitialPasswordManager +import io.tolgee.service.key.NamespaceService import io.tolgee.service.language.LanguageService import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.organization.OrganizationService @@ -40,6 +41,7 @@ class DbPopulatorReal( private val initialPasswordManager: InitialPasswordManager, private val slugGenerator: SlugGenerator, private val organizationRoleService: OrganizationRoleService, + private val namespaceService: NamespaceService, private val projectService: ProjectService, private val organizationService: OrganizationService, private val apiKeyService: ApiKeyService, @@ -315,6 +317,13 @@ class DbPopulatorReal( entityManager.flush() } + fun createNamespace( + project: Project, + name: String = UUID.randomUUID().toString(), + ) { + namespaceService.create(name, project.id) + } + companion object { const val API_KEY = "this_is_dummy_api_key" } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/NamespacesTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/NamespacesTestData.kt index 513a2ebc45..ccb6e796ec 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/NamespacesTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/NamespacesTestData.kt @@ -8,6 +8,7 @@ import io.tolgee.model.key.Key import io.tolgee.model.key.Namespace class NamespacesTestData : BaseTestData() { + var keyWithoutNs: Key var keyInNs1: Key var singleKeyInNs2: Key lateinit var defaultUnusedProject: Project @@ -24,7 +25,7 @@ class NamespacesTestData : BaseTestData() { } projectBuilder.apply { - addKeyWithTranslation("key", null) + keyWithoutNs = addKeyWithTranslation("key", null) keyInNs1 = addKeyWithTranslation("key", "ns-1") singleKeyInNs2 = addKeyWithTranslation("key", "ns-2") addKeyWithTranslation("key2", null) @@ -38,6 +39,7 @@ class NamespacesTestData : BaseTestData() { root.apply { addProject { name = "Project 2" + useNamespaces = true }.build { addKeyWithTranslation("key", null) addKeyWithTranslation("key", "ns-1") @@ -46,6 +48,7 @@ class NamespacesTestData : BaseTestData() { root.apply { addProject { name = "Project 3" + useNamespaces = true defaultUnusedProject = this }.build { addKeyWithTranslation("key", "ns-1") @@ -54,6 +57,7 @@ class NamespacesTestData : BaseTestData() { root.apply { addProject { name = "Project 4" + useNamespaces = true dotProject = this }.build { addKeyWithTranslation("key", "ns.1") diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PermissionsTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PermissionsTestData.kt index 2e99e4ae43..8be65b5a39 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PermissionsTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PermissionsTestData.kt @@ -97,6 +97,7 @@ class PermissionsTestData { translateLanguageTags: List? = null, stateChangeLanguageTags: List? = null, organizationBaseScopes: List? = null, + useNamespaces: Boolean = false, ): UserAccount { val me = root.addUserAccount { @@ -114,6 +115,8 @@ class PermissionsTestData { } } + projectBuilder.self.useNamespaces = useNamespaces + if (organizationBaseScopes != null) { organizationBuilder.self.basePermission.type = null organizationBuilder.self.basePermission.scopes = organizationBaseScopes.toTypedArray() diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/TranslationNsAndTagsData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/TranslationNsAndTagsData.kt index 2a1e92c74c..40c24132dc 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/TranslationNsAndTagsData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/TranslationNsAndTagsData.kt @@ -63,7 +63,7 @@ class TranslationNsAndTagsData { text = "Překlad $paddedNum" state = TranslationState.REVIEWED } - + newProject.useNamespaces = true setNamespace("Namespace $paddedNum") addMeta { self { diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportNamespacesTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportNamespacesTestData.kt index 941caf4038..4a45b8b028 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportNamespacesTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportNamespacesTestData.kt @@ -140,6 +140,7 @@ class ImportNamespacesTestData { addProject { name = "test" project = this + useNamespaces = true }.build project@{ addPermission { project = this@project.self diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportTestData.kt index 0a82e16bf3..38c6ec380d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportTestData.kt @@ -396,6 +396,7 @@ class ImportTestData { importBuilder.addImportFile { name = "file.json" namespace = "homepage" + detectedNamespace = "homepage" }.build { addImportLanguage { name = "fr" diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/ProjectDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/ProjectDto.kt index ae9d65fc26..494b5f75e2 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/ProjectDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/ProjectDto.kt @@ -13,6 +13,7 @@ data class ProjectDto( var aiTranslatorPromptDescription: String?, override var avatarHash: String? = null, override var icuPlaceholders: Boolean, + var useNamespaces: Boolean, ) : Serializable, ISimpleProject { companion object { fun fromEntity(entity: Project) = @@ -25,6 +26,7 @@ data class ProjectDto( aiTranslatorPromptDescription = entity.aiTranslatorPromptDescription, avatarHash = entity.avatarHash, icuPlaceholders = entity.icuPlaceholders, + useNamespaces = entity.useNamespaces, ) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/EditProjectRequest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/EditProjectRequest.kt index 9546b7e00f..1cd3e8e549 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/EditProjectRequest.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/EditProjectRequest.kt @@ -13,6 +13,7 @@ data class EditProjectRequest( @field:Pattern(regexp = "^[a-z0-9-]*[a-z]+[a-z0-9-]*$", message = "invalid_pattern") var slug: String? = null, var baseLanguageId: Long? = null, + var useNamespaces: Boolean = false, var defaultNamespaceId: Long? = null, @field:Size(min = 3, max = 2000) var description: String? = null, 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 a01b6bda4a..abf7c25a32 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/Project.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/Project.kt @@ -125,6 +125,10 @@ class Project( @ColumnDefault("true") override var icuPlaceholders: Boolean = true + @ColumnDefault("false") + @ActivityLoggedProp + var useNamespaces: Boolean = false + @ColumnDefault("0") var lastTaskNumber: Long = 0 diff --git a/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportFile.kt b/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportFile.kt index 901dc2557f..c5b7e41111 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportFile.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportFile.kt @@ -39,6 +39,11 @@ class ImportFile( var namespace: String? = null + /** + * The actual detected namespace, even if we decide we want to use a different value in the `namespace` column. + */ + var detectedNamespace: String? = null + @ColumnDefault("false") var needsParamConversion = false diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectView.kt index 8687f55477..fa7cfde646 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectView.kt @@ -11,6 +11,7 @@ interface ProjectView { val description: String? val slug: String? val avatarHash: String? + val useNamespaces: Boolean val defaultNamespace: Namespace? val organizationOwner: Organization val organizationRole: OrganizationRoleType? diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithLanguagesView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithLanguagesView.kt index 4e9594f10d..ca09aa2f6e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithLanguagesView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithLanguagesView.kt @@ -11,6 +11,7 @@ open class ProjectWithLanguagesView( override val description: String?, override val slug: String?, override val avatarHash: String?, + override val useNamespaces: Boolean, override val defaultNamespace: Namespace?, override val organizationOwner: Organization, override val organizationRole: OrganizationRoleType?, @@ -29,6 +30,7 @@ open class ProjectWithLanguagesView( description = view.description, slug = view.slug, avatarHash = view.avatarHash, + useNamespaces = view.useNamespaces, defaultNamespace = view.defaultNamespace, organizationOwner = view.organizationOwner, organizationRole = view.organizationRole, diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithStatsView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithStatsView.kt index f9a2f8e518..5af576b044 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithStatsView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithStatsView.kt @@ -13,6 +13,7 @@ class ProjectWithStatsView( view.description, view.slug, view.avatarHash, + view.useNamespaces, view.defaultNamespace, view.organizationOwner, view.organizationRole, diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt index 73c1ed1aa3..e29c1f4202 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt @@ -18,6 +18,7 @@ interface ProjectRepository : JpaRepository { companion object { const val BASE_VIEW_QUERY = """select r.id as id, r.name as name, r.description as description, r.slug as slug, r.avatarHash as avatarHash, + r.useNamespaces as useNamespaces, dn as defaultNamespace, o as organizationOwner, role.type as organizationRole, p as directPermission, r.icuPlaceholders as icuPlaceholders from Project r diff --git a/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt b/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt index aacceae2f8..c47812001d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt @@ -61,6 +61,7 @@ class StartupImportService( createImplicitApiKey(userAccount, project) assignProjectHolder(project) importData(fileDtos, project, userAccount) + disableNamespacesIfNoneWereImported(project.id) return } logger.info("Not Importing initial project $projectName - project already exists") @@ -148,6 +149,7 @@ class StartupImportService( ) setBaseLanguage(project) + project.useNamespaces = true projectService.save(project) return project @@ -192,4 +194,15 @@ class StartupImportService( .findActive(properties.authentication.initialUsername) return userAccount } + + private fun disableNamespacesIfNoneWereImported(projectId: Long) { + val project = projectService.find(projectId)!! + + if (project.namespaces.isNotEmpty()) { + return + } + + project.useNamespaces = false + projectService.save(project) + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/CoreImportFilesProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/CoreImportFilesProcessor.kt index 97db5e9b29..b4070a26fd 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/CoreImportFilesProcessor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/CoreImportFilesProcessor.kt @@ -12,11 +12,14 @@ import io.tolgee.exceptions.ErrorResponseBody import io.tolgee.exceptions.ImportCannotParseFileException import io.tolgee.formats.ImportFileProcessor import io.tolgee.formats.ImportFileProcessorFactory -import io.tolgee.model.dataImport.* +import io.tolgee.model.dataImport.Import +import io.tolgee.model.dataImport.ImportFile +import io.tolgee.model.dataImport.ImportKey +import io.tolgee.model.dataImport.ImportLanguage +import io.tolgee.model.dataImport.ImportTranslation import io.tolgee.model.dataImport.issues.issueTypes.FileIssueType import io.tolgee.model.dataImport.issues.paramTypes.FileIssueParamType import io.tolgee.service.dataImport.processors.FileProcessorContext -import io.tolgee.service.key.KeyService import io.tolgee.service.language.LanguageService import io.tolgee.util.Logging import io.tolgee.util.filterFiles @@ -33,7 +36,6 @@ class CoreImportFilesProcessor( // single step import doesn't save data val saveData: Boolean = true, ) : Logging { - private val keyService: KeyService by lazy { applicationContext.getBean(KeyService::class.java) } private val importService: ImportService by lazy { applicationContext.getBean(ImportService::class.java) } private val importFileProcessorFactory: ImportFileProcessorFactory by lazy { applicationContext.getBean( @@ -55,7 +57,15 @@ class CoreImportFilesProcessor( ) } - fun processFiles(files: Collection?): MutableList { + val errors = mutableListOf() + val warnings = mutableListOf() + + fun processFiles(files: Collection?) { + errors.addAll(processFilesRecursive(files)) + renderPossibleNamespacesWarning() + } + + private fun processFilesRecursive(files: Collection?): List { val errors = mutableListOf() files?.forEach { try { @@ -70,7 +80,7 @@ class CoreImportFilesProcessor( return errors } - private fun processFileOrArchive(file: ImportFileDto): MutableList { + private fun processFileOrArchive(file: ImportFileDto): List { val errors = mutableListOf() if (file.isArchive) { @@ -78,7 +88,7 @@ class CoreImportFilesProcessor( } processFile(file) - return mutableListOf() + return listOf() } private fun processFile(file: ImportFileDto) { @@ -99,6 +109,22 @@ class CoreImportFilesProcessor( savedFileEntity.updateFileEntity(fileProcessorContext) } + private fun renderPossibleNamespacesWarning() { + if (import.project.useNamespaces) { + return + } + + val anyHasDetectedNamespace = import.files.any { it.detectedNamespace != null } + val allLanguages = import.files.flatMap { file -> file.languages.map { language -> language.name } } + val languageDuplicated = allLanguages.size != allLanguages.distinct().size + + if (!languageDuplicated && !anyHasDetectedNamespace) { + return + } + + warnings.add(ErrorResponseBody(Message.NAMESPACE_CANNOT_BE_USED_WHEN_FEATURE_IS_DISABLED.code, listOf())) + } + private fun ImportFile.updateFileEntity(fileProcessorContext: FileProcessorContext) { if (fileProcessorContext.needsParamConversion) { this.needsParamConversion = fileProcessorContext.needsParamConversion @@ -115,7 +141,7 @@ class CoreImportFilesProcessor( val processor = importFileProcessorFactory.getArchiveProcessor(archive) val files = processor.process(archive) val filtered = filterFiles(files.map { it.name to it }) - errors.addAll(processFiles(filtered)) + errors.addAll(processFilesRecursive(filtered)) return errors } @@ -147,16 +173,29 @@ class CoreImportFilesProcessor( } private fun FileProcessorContext.preselectNamespace() { - this.fileEntity.namespace = getNamespaceToPreselect() + val namespace = getNamespaceToPreselect() + if (namespace != null) { + fileEntity.detectedNamespace = namespace + if (import.project.useNamespaces) { + fileEntity.namespace = namespace + } + } } private fun FileProcessorContext.getNamespaceToPreselect(): String? { val mappedNamespace = findMappedNamespace() if (mappedNamespace != null) { + if (!import.project.useNamespaces && mappedNamespace.isNotEmpty()) { + throw BadRequestException(Message.NAMESPACE_CANNOT_BE_USED_WHEN_FEATURE_IS_DISABLED) + } return getSafeNamespace(mappedNamespace) } + return getGuessedNamespace() + } + + private fun FileProcessorContext.getGuessedNamespace(): String? { if (this.namespace != null) { // namespace was selected by processor return getSafeNamespace(this.namespace) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/ImportService.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/ImportService.kt index 9b26317897..5c57d9bb35 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/ImportService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/ImportService.kt @@ -12,6 +12,7 @@ import io.tolgee.dtos.dataImport.ImportAddFilesParams import io.tolgee.dtos.dataImport.ImportFileDto import io.tolgee.dtos.request.SingleStepImportRequest import io.tolgee.events.OnImportSoftDeleted +import io.tolgee.events.OnProjectActivityEvent import io.tolgee.exceptions.BadRequestException import io.tolgee.exceptions.ErrorResponseBody import io.tolgee.exceptions.ImportConflictNotResolvedException @@ -19,21 +20,13 @@ import io.tolgee.exceptions.NotFoundException import io.tolgee.model.Language import io.tolgee.model.Project import io.tolgee.model.UserAccount -import io.tolgee.model.dataImport.Import -import io.tolgee.model.dataImport.ImportFile -import io.tolgee.model.dataImport.ImportKey -import io.tolgee.model.dataImport.ImportLanguage -import io.tolgee.model.dataImport.ImportTranslation +import io.tolgee.model.dataImport.* import io.tolgee.model.dataImport.issues.ImportFileIssue import io.tolgee.model.dataImport.issues.ImportFileIssueParam import io.tolgee.model.views.ImportFileIssueView import io.tolgee.model.views.ImportLanguageView import io.tolgee.model.views.ImportTranslationView -import io.tolgee.repository.dataImport.ImportFileRepository -import io.tolgee.repository.dataImport.ImportKeyRepository -import io.tolgee.repository.dataImport.ImportLanguageRepository -import io.tolgee.repository.dataImport.ImportRepository -import io.tolgee.repository.dataImport.ImportTranslationRepository +import io.tolgee.repository.dataImport.* import io.tolgee.repository.dataImport.issues.ImportFileIssueParamRepository import io.tolgee.repository.dataImport.issues.ImportFileIssueRepository import io.tolgee.service.dataImport.status.ImportApplicationStatus @@ -41,6 +34,7 @@ import io.tolgee.util.getSafeNamespace import jakarta.persistence.EntityManager import org.springframework.context.ApplicationContext import org.springframework.context.annotation.Lazy +import org.springframework.context.event.EventListener import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.jdbc.core.JdbcTemplate @@ -80,7 +74,7 @@ class ImportService( project: Project, userAccount: UserAccount, params: ImportAddFilesParams = ImportAddFilesParams(), - ): MutableList { + ): Pair, List> { val import = findNotExpired(project.id, userAccount.id) ?: Import(project).also { it.author = userAccount @@ -105,12 +99,12 @@ class ImportService( projectIcuPlaceholdersEnabled = project.icuPlaceholders, importSettings = importSettingsService.get(userAccount, project.id), ) - val errors = fileProcessor.processFiles(files) + fileProcessor.processFiles(files) if (findLanguages(import).isEmpty()) { TransactionInterceptor.currentTransactionStatus().setRollbackOnly() } - return errors + return fileProcessor.errors to fileProcessor.warnings } @Transactional @@ -141,11 +135,11 @@ class ImportService( importSettings = params, saveData = false, ) - val errors = fileProcessor.processFiles(files) + fileProcessor.processFiles(files) - if (errors.isNotEmpty()) { + if (fileProcessor.errors.isNotEmpty()) { @Suppress("UNCHECKED_CAST") - throw BadRequestException(Message.IMPORT_FAILED, errors as List) + throw BadRequestException(Message.IMPORT_FAILED, fileProcessor.errors as List) } if (fileProcessor.importDataManager.storedLanguages.isEmpty()) { @@ -413,6 +407,20 @@ class ImportService( dataManager.resetCollisionsBetweenFiles(language, null) } + @EventListener + fun deleteExistingImportsOnUseNamespacesModification(event: OnProjectActivityEvent) { + val modifiedProjects = event.modifiedEntities[Project::class] + + modifiedProjects + ?.values + ?.filter { project -> project.modifications.containsKey("useNamespaces") } + ?.forEach { project -> + getAllByProject(project.entityId).forEach { import -> + deleteImport(import) + } + } + } + fun findTranslation(translationId: Long): ImportTranslation? { return importTranslationRepository.findById(translationId).orElse(null) } 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 0ffa1a5354..bbbb6f18c3 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 @@ -10,6 +10,7 @@ import io.tolgee.dtos.cacheable.ProjectDto import io.tolgee.dtos.request.project.CreateProjectRequest import io.tolgee.dtos.request.project.EditProjectRequest import io.tolgee.dtos.request.project.ProjectFilters +import io.tolgee.dtos.request.validators.exceptions.ValidationException import io.tolgee.dtos.response.ProjectDTO import io.tolgee.dtos.response.ProjectDTO.Companion.fromEntityAndPermission import io.tolgee.exceptions.BadRequestException @@ -177,9 +178,15 @@ class ProjectService( val project = projectRepository.findById(id) .orElseThrow { NotFoundException() }!! + + if (!dto.useNamespaces && project.namespaces.isNotEmpty()) { + throw ValidationException(Message.NAMESPACES_CANNOT_BE_DISABLED_WHEN_NAMESPACE_EXISTS) + } + project.name = dto.name project.description = dto.description project.icuPlaceholders = dto.icuPlaceholders + project.useNamespaces = dto.useNamespaces if (project.defaultNamespace != null) { namespaceService.deleteUnusedNamespaces(listOf(project.defaultNamespace!!)) diff --git a/backend/data/src/main/resources/db/changelog/enableUseNamespacesForProjectsWithNamespaces.sql b/backend/data/src/main/resources/db/changelog/enableUseNamespacesForProjectsWithNamespaces.sql new file mode 100644 index 0000000000..576b31bd83 --- /dev/null +++ b/backend/data/src/main/resources/db/changelog/enableUseNamespacesForProjectsWithNamespaces.sql @@ -0,0 +1,10 @@ +-- Namespaces are now disabled by default, but we want to enable them for projects which already use namespaces +-- not to disturb working workflows. +UPDATE project +SET use_namespaces = TRUE +WHERE project.id IN ( + SELECT project.id + FROM project LEFT JOIN namespace ON project.id = namespace.project_id + GROUP BY project.id + HAVING COUNT(namespace.project_id) > 0 +) \ No newline at end of file diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index b0bea1ed13..e13f6efa55 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -4031,4 +4031,17 @@ alter column score set default 10000; + + + + + + + + + + + + + diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt index c381a37e3f..de81eec61d 100644 --- a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PermissionsE2eDataController.kt @@ -28,6 +28,7 @@ class PermissionsE2eDataController() : AbstractE2eDataController() { @RequestParam viewLanguageTags: List?, @RequestParam translateLanguageTags: List?, @RequestParam stateChangeLanguageTags: List?, + @RequestParam useNamespaces: Boolean = false, ): StandardTestDataResult { val user = this.permissionsTestData.addUserWithPermissions( @@ -36,6 +37,7 @@ class PermissionsE2eDataController() : AbstractE2eDataController() { viewLanguageTags = viewLanguageTags, translateLanguageTags = translateLanguageTags, stateChangeLanguageTags = stateChangeLanguageTags, + useNamespaces = useNamespaces, ) this.permissionsTestData.addTasks( mutableSetOf(user, permissionsTestData.serverAdmin.self), diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/ProjectsE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/ProjectsE2eDataController.kt index f2ec2fab72..91bc4afc42 100644 --- a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/ProjectsE2eDataController.kt +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/ProjectsE2eDataController.kt @@ -25,10 +25,7 @@ import io.tolgee.util.executeInNewRepeatableTransaction import jakarta.persistence.EntityManager import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.annotation.Transactional -import org.springframework.web.bind.annotation.CrossOrigin -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* @RestController @CrossOrigin(origins = ["*"]) @@ -154,6 +151,16 @@ class ProjectsE2eDataController( } } + @PutMapping(value = ["/enable-namespaces"]) + fun enableNamespaces( + @RequestParam("projectId") projectId: Long, + @RequestParam("enable", required = false, defaultValue = "true") enable: Boolean, + ) { + val project = projectService.get(projectId) + project.useNamespaces = enable + projectService.save(project) + } + companion object { data class PermittedUserData( val userName: String, diff --git a/e2e/cypress/common/apiCalls/common.ts b/e2e/cypress/common/apiCalls/common.ts index 72d2df5cd0..2e093e8093 100644 --- a/e2e/cypress/common/apiCalls/common.ts +++ b/e2e/cypress/common/apiCalls/common.ts @@ -156,6 +156,13 @@ export const createTestProject = () => ], }); +export function enableNamespaces(projectId: number) { + internalFetch(`e2e-data/projects/enable-namespaces?projectId=${projectId}`, { + method: 'PUT', + }); + cy.reload(); +} + type CreateKeyOptions = { isPlural?: boolean; }; diff --git a/e2e/cypress/common/apiCalls/testData/testData.ts b/e2e/cypress/common/apiCalls/testData/testData.ts index 4fa3280d49..3d1e6f6b9a 100644 --- a/e2e/cypress/common/apiCalls/testData/testData.ts +++ b/e2e/cypress/common/apiCalls/testData/testData.ts @@ -126,13 +126,14 @@ export type PermissionsOptions = { }; export const generatePermissionsData = { - generate: (options: Partial) => { + generate: (options: Partial, useNamespaces = false) => { const params = new URLSearchParams(); Object.entries(options).forEach(([key, values]) => { values.forEach((value) => { params.append(key, value); }); }); + params.append('useNamespaces', String(useNamespaces)); return internalFetch( `e2e-data/permissions/generate-with-user?${params.toString()}` ); diff --git a/e2e/cypress/common/permissions/main.ts b/e2e/cypress/common/permissions/main.ts index 10a2a217d5..ac4444819f 100644 --- a/e2e/cypress/common/permissions/main.ts +++ b/e2e/cypress/common/permissions/main.ts @@ -88,12 +88,13 @@ export function loginAndGetInfo(user: UserMail, projectId: number) { export function visitProjectWithPermissions( options: Partial, + useNamespaces = false, user: UserMail = 'me@me.me' ): Promise { return new Cypress.Promise((resolve) => { generatePermissionsData .clean() - .then(() => generatePermissionsData.generate(options)) + .then(() => generatePermissionsData.generate(options, useNamespaces)) .then((res) => { return res.body.projects[0].id; }) diff --git a/e2e/cypress/common/translations.ts b/e2e/cypress/common/translations.ts index 93644130dc..6269080f89 100644 --- a/e2e/cypress/common/translations.ts +++ b/e2e/cypress/common/translations.ts @@ -8,8 +8,8 @@ import { HOST } from './constants'; import { ProjectDTO } from '../../../webapp/src/service/response.types'; import { waitForGlobalLoading } from './loading'; import { assertMessage, dismissMenu, gcyAdvanced } from './shared'; -import Chainable = Cypress.Chainable; import { selectNamespace } from './namespace'; +import Chainable = Cypress.Chainable; export function getCellCancelButton() { return cy.gcy('translations-cell-cancel-button'); @@ -44,6 +44,7 @@ type Props = { namespace?: string; description?: string; variableName?: string; + assertPresenceOfNamespaceSelectBox?: boolean; }; export function createTranslation({ @@ -53,9 +54,15 @@ export function createTranslation({ namespace, description, variableName, + assertPresenceOfNamespaceSelectBox, }: Props) { waitForGlobalLoading(); cy.gcy('translations-add-button').click(); + if (assertPresenceOfNamespaceSelectBox != undefined) { + cy.gcy('namespaces-selector').should( + assertPresenceOfNamespaceSelectBox ? 'exist' : 'not.exist' + ); + } cy.gcy('translation-create-key-input').type(key); if (namespace) { selectNamespace(namespace); diff --git a/e2e/cypress/e2e/import/importAddingFiles.cy.ts b/e2e/cypress/e2e/import/importAddingFiles.cy.ts index e12a3f44ad..bc530dc3e1 100644 --- a/e2e/cypress/e2e/import/importAddingFiles.cy.ts +++ b/e2e/cypress/e2e/import/importAddingFiles.cy.ts @@ -6,15 +6,17 @@ import { visitImport, } from '../../common/import'; import { importTestData } from '../../common/apiCalls/testData/testData'; -import { login } from '../../common/apiCalls/common'; +import { enableNamespaces, login } from '../../common/apiCalls/common'; describe('Import Adding files', () => { + let projectId: number; beforeEach(() => { importTestData.clean(); importTestData.generateBase().then((project) => { login('franta'); - visitImport(project.body.id); + projectId = project.body.id; + visitImport(projectId); }); }); @@ -31,6 +33,7 @@ describe('Import Adding files', () => { }); it('uploads .zip with namespaces', () => { + enableNamespaces(projectId); cy.get('[data-cy=dropzone]').attachFile('import/namespaces.zip', { subjectType: 'drag-n-drop', }); @@ -53,6 +56,15 @@ describe('Import Adding files', () => { ).should('have.length', 2); }); + it('uploads .zip with namespaces when namespaces are disabled', () => { + cy.get('[data-cy=dropzone]').attachFile('import/namespaces.zip', { + subjectType: 'drag-n-drop', + }); + + cy.gcy('import-file-warnings').should('be.visible'); + cy.gcy('namespaces-selector').should('not.exist'); + }); + it( 'uploads multiple xliffs', { diff --git a/e2e/cypress/e2e/import/importResultManupulation.cy.ts b/e2e/cypress/e2e/import/importResultManipulation.cy.ts similarity index 100% rename from e2e/cypress/e2e/import/importResultManupulation.cy.ts rename to e2e/cypress/e2e/import/importResultManipulation.cy.ts diff --git a/e2e/cypress/e2e/projects/permissions/permissionsAdmin.2.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsAdmin.2.cy.ts index 05d7ac7643..56c41ede69 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsAdmin.2.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsAdmin.2.cy.ts @@ -7,19 +7,21 @@ import { describe('Permissions admin 2', () => { it('admin', () => { - visitProjectWithPermissions({ scopes: ['admin'] }).then((projectInfo) => { - checkPermissions(projectInfo, { - 'project-menu-item-dashboard': SKIP, - 'project-menu-item-translations': RUN, - 'project-menu-item-tasks': SKIP, - 'project-menu-item-settings': SKIP, - 'project-menu-item-languages': SKIP, - 'project-menu-item-members': SKIP, - 'project-menu-item-import': SKIP, - 'project-menu-item-export': SKIP, - 'project-menu-item-developer': SKIP, - 'project-menu-item-integrate': SKIP, - }); - }); + visitProjectWithPermissions({ scopes: ['admin'] }, true).then( + (projectInfo) => { + checkPermissions(projectInfo, { + 'project-menu-item-dashboard': SKIP, + 'project-menu-item-translations': RUN, + 'project-menu-item-tasks': SKIP, + 'project-menu-item-settings': SKIP, + 'project-menu-item-languages': SKIP, + 'project-menu-item-members': SKIP, + 'project-menu-item-import': SKIP, + 'project-menu-item-export': SKIP, + 'project-menu-item-developer': SKIP, + 'project-menu-item-integrate': SKIP, + }); + } + ); }); }); diff --git a/e2e/cypress/e2e/projects/permissions/permissionsBatchJobs.1.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsBatchJobs.1.cy.ts index 0cacce02e6..1827ca5970 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsBatchJobs.1.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsBatchJobs.1.cy.ts @@ -7,9 +7,12 @@ import { describe('Batch jobs permissions 1', () => { it('translations.batch-machine', () => { - visitProjectWithPermissions({ - scopes: ['translations.batch-machine'], - }).then((projectInfo) => { + visitProjectWithPermissions( + { + scopes: ['translations.batch-machine'], + }, + true + ).then((projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, 'project-menu-item-translations': RUN, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsBatchJobs.2.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsBatchJobs.2.cy.ts index 9df6299dbf..03ae7ec5ab 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsBatchJobs.2.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsBatchJobs.2.cy.ts @@ -7,9 +7,12 @@ import { describe('Batch jobs permissions 2', () => { it('translations.batch-by-mt', () => { - visitProjectWithPermissions({ - scopes: ['translations.batch-by-tm'], - }).then((projectInfo) => { + visitProjectWithPermissions( + { + scopes: ['translations.batch-by-tm'], + }, + true + ).then((projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, 'project-menu-item-translations': RUN, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsKeys.1.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsKeys.1.cy.ts index b09c4036e6..326abf9ed7 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsKeys.1.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsKeys.1.cy.ts @@ -7,7 +7,7 @@ import { describe('Keys permissions 1', () => { it('screenshots.view', { retries: { runMode: 3 } }, () => { - visitProjectWithPermissions({ scopes: ['screenshots.view'] }).then( + visitProjectWithPermissions({ scopes: ['screenshots.view'] }, true).then( (projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': SKIP, @@ -19,7 +19,7 @@ describe('Keys permissions 1', () => { }); it('screenshots.upload', { retries: { runMode: 3 } }, () => { - visitProjectWithPermissions({ scopes: ['screenshots.upload'] }).then( + visitProjectWithPermissions({ scopes: ['screenshots.upload'] }, true).then( (projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, @@ -31,7 +31,7 @@ describe('Keys permissions 1', () => { }); it('screenshots.delete', () => { - visitProjectWithPermissions({ scopes: ['screenshots.delete'] }).then( + visitProjectWithPermissions({ scopes: ['screenshots.delete'] }, true).then( (projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': SKIP, @@ -43,7 +43,7 @@ describe('Keys permissions 1', () => { }); it('keys.view', () => { - visitProjectWithPermissions({ scopes: ['keys.view'] }).then( + visitProjectWithPermissions({ scopes: ['keys.view'] }, true).then( (projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsKeys.2.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsKeys.2.cy.ts index b4776c5bd4..cee7801ec5 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsKeys.2.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsKeys.2.cy.ts @@ -7,7 +7,7 @@ import { describe('Keys permissions 2', () => { it('keys.edit', () => { - visitProjectWithPermissions({ scopes: ['keys.edit'] }).then( + visitProjectWithPermissions({ scopes: ['keys.edit'] }, true).then( (projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': SKIP, @@ -19,7 +19,7 @@ describe('Keys permissions 2', () => { }); it('keys.delete', () => { - visitProjectWithPermissions({ scopes: ['keys.delete'] }).then( + visitProjectWithPermissions({ scopes: ['keys.delete'] }, true).then( (projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': SKIP, @@ -31,7 +31,7 @@ describe('Keys permissions 2', () => { }); it('keys.create', () => { - visitProjectWithPermissions({ scopes: ['keys.create'] }).then( + visitProjectWithPermissions({ scopes: ['keys.create'] }, true).then( (projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': SKIP, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsPerLanguage.1.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsPerLanguage.1.cy.ts index bac2331bf0..dbfb96019c 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsPerLanguage.1.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsPerLanguage.1.cy.ts @@ -6,10 +6,13 @@ import { describe('Permissions per language 1', () => { it('translations.view', () => { - visitProjectWithPermissions({ - scopes: ['translations.view'], - viewLanguageTags: ['en', 'de'], - }).then((projectInfo) => { + visitProjectWithPermissions( + { + scopes: ['translations.view'], + viewLanguageTags: ['en', 'de'], + }, + true + ).then((projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, 'project-menu-item-translations': RUN, @@ -20,10 +23,13 @@ describe('Permissions per language 1', () => { }); it('translations.edit', () => { - visitProjectWithPermissions({ - scopes: ['translations.edit'], - translateLanguageTags: ['de'], - }).then((projectInfo) => { + visitProjectWithPermissions( + { + scopes: ['translations.edit'], + translateLanguageTags: ['de'], + }, + true + ).then((projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, 'project-menu-item-translations': RUN, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsPerLanguage.2.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsPerLanguage.2.cy.ts index 1e3d44129d..5ae588400b 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsPerLanguage.2.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsPerLanguage.2.cy.ts @@ -6,10 +6,13 @@ import { describe('Permissions per language 2', () => { it('translations.state-edit', () => { - visitProjectWithPermissions({ - scopes: ['translations.view', 'translations.state-edit'], - stateChangeLanguageTags: ['de'], - }).then((projectInfo) => { + visitProjectWithPermissions( + { + scopes: ['translations.view', 'translations.state-edit'], + stateChangeLanguageTags: ['de'], + }, + true + ).then((projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, 'project-menu-item-translations': RUN, @@ -20,11 +23,14 @@ describe('Permissions per language 2', () => { }); it('combined translations.edit and translations.state-edit', () => { - visitProjectWithPermissions({ - scopes: ['translations.edit', 'translations.state-edit'], - translateLanguageTags: ['cs'], - stateChangeLanguageTags: ['de'], - }).then((projectInfo) => { + visitProjectWithPermissions( + { + scopes: ['translations.edit', 'translations.state-edit'], + translateLanguageTags: ['cs'], + stateChangeLanguageTags: ['de'], + }, + true + ).then((projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, 'project-menu-item-translations': RUN, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.2.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.2.cy.ts index 18ae5a2a6e..b9aff1cf36 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.2.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsServerAdmin.2.cy.ts @@ -8,21 +8,23 @@ import { describe('Server admin 2', () => { it('Server admin', () => { - visitProjectWithPermissions({ scopes: ['admin'] }).then((projectInfo) => { - // login as admin - login('Server admin', 'admin'); - checkPermissions(projectInfo, { - 'project-menu-item-dashboard': SKIP, - 'project-menu-item-translations': RUN, - 'project-menu-item-tasks': SKIP, - 'project-menu-item-settings': SKIP, - 'project-menu-item-languages': SKIP, - 'project-menu-item-members': SKIP, - 'project-menu-item-import': SKIP, - 'project-menu-item-export': SKIP, - 'project-menu-item-developer': SKIP, - 'project-menu-item-integrate': SKIP, - }); - }); + visitProjectWithPermissions({ scopes: ['admin'] }, true).then( + (projectInfo) => { + // login as admin + login('Server admin', 'admin'); + checkPermissions(projectInfo, { + 'project-menu-item-dashboard': SKIP, + 'project-menu-item-translations': RUN, + 'project-menu-item-tasks': SKIP, + 'project-menu-item-settings': SKIP, + 'project-menu-item-languages': SKIP, + 'project-menu-item-members': SKIP, + 'project-menu-item-import': SKIP, + 'project-menu-item-export': SKIP, + 'project-menu-item-developer': SKIP, + 'project-menu-item-integrate': SKIP, + }); + } + ); }); }); diff --git a/e2e/cypress/e2e/projects/permissions/permissionsTask.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsTask.cy.ts index 9c1174a4c8..910481254e 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsTask.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsTask.cy.ts @@ -6,7 +6,7 @@ import { describe('Permissions task', () => { it('tasks.view', () => { - visitProjectWithPermissions({ scopes: ['tasks.view'] }).then( + visitProjectWithPermissions({ scopes: ['tasks.view'] }, true).then( (projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, @@ -20,7 +20,7 @@ describe('Permissions task', () => { }); it('tasks.edit', () => { - visitProjectWithPermissions({ scopes: ['tasks.edit'] }).then( + visitProjectWithPermissions({ scopes: ['tasks.edit'] }, true).then( (projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsTranslations.1.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsTranslations.1.cy.ts index 5c9b1a2ac4..08c2feb709 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsTranslations.1.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsTranslations.1.cy.ts @@ -6,7 +6,7 @@ import { describe('Permissions translations 1', () => { it('translations.view', () => { - visitProjectWithPermissions({ scopes: ['translations.view'] }).then( + visitProjectWithPermissions({ scopes: ['translations.view'] }, true).then( (projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, @@ -19,7 +19,7 @@ describe('Permissions translations 1', () => { }); it('translations.edit', () => { - visitProjectWithPermissions({ scopes: ['translations.edit'] }).then( + visitProjectWithPermissions({ scopes: ['translations.edit'] }, true).then( (projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': RUN, diff --git a/e2e/cypress/e2e/projects/permissions/permissionsTranslations.2.cy.ts b/e2e/cypress/e2e/projects/permissions/permissionsTranslations.2.cy.ts index 9d8881ee37..fe55db2de4 100644 --- a/e2e/cypress/e2e/projects/permissions/permissionsTranslations.2.cy.ts +++ b/e2e/cypress/e2e/projects/permissions/permissionsTranslations.2.cy.ts @@ -7,46 +7,52 @@ import { describe('Permissions translations 2', () => { it('translations.state-edit', () => { - visitProjectWithPermissions({ scopes: ['translations.state-edit'] }).then( - (projectInfo) => { - checkPermissions(projectInfo, { - 'project-menu-item-dashboard': RUN, - 'project-menu-item-export': RUN, - }); - } - ); + visitProjectWithPermissions( + { scopes: ['translations.state-edit'] }, + true + ).then((projectInfo) => { + checkPermissions(projectInfo, { + 'project-menu-item-dashboard': RUN, + 'project-menu-item-export': RUN, + }); + }); }); it('translation-comments.add', () => { - visitProjectWithPermissions({ scopes: ['translation-comments.add'] }).then( - (projectInfo) => { - checkPermissions(projectInfo, { - 'project-menu-item-dashboard': SKIP, - 'project-menu-item-translations': RUN, - 'project-menu-item-export': SKIP, - 'project-menu-item-integrate': SKIP, - }); - } - ); + visitProjectWithPermissions( + { scopes: ['translation-comments.add'] }, + true + ).then((projectInfo) => { + checkPermissions(projectInfo, { + 'project-menu-item-dashboard': SKIP, + 'project-menu-item-translations': RUN, + 'project-menu-item-export': SKIP, + 'project-menu-item-integrate': SKIP, + }); + }); }); it('translation-comments.edit', () => { - visitProjectWithPermissions({ scopes: ['translation-comments.edit'] }).then( - (projectInfo) => { - checkPermissions(projectInfo, { - 'project-menu-item-dashboard': SKIP, - 'project-menu-item-translations': RUN, - 'project-menu-item-export': SKIP, - 'project-menu-item-integrate': SKIP, - }); - } - ); + visitProjectWithPermissions( + { scopes: ['translation-comments.edit'] }, + true + ).then((projectInfo) => { + checkPermissions(projectInfo, { + 'project-menu-item-dashboard': SKIP, + 'project-menu-item-translations': RUN, + 'project-menu-item-export': SKIP, + 'project-menu-item-integrate': SKIP, + }); + }); }); it('translation-comments.set-state', () => { - visitProjectWithPermissions({ - scopes: ['translation-comments.set-state'], - }).then((projectInfo) => { + visitProjectWithPermissions( + { + scopes: ['translation-comments.set-state'], + }, + true + ).then((projectInfo) => { checkPermissions(projectInfo, { 'project-menu-item-dashboard': SKIP, 'project-menu-item-translations': RUN, diff --git a/e2e/cypress/e2e/projects/projectMembers.cy.ts b/e2e/cypress/e2e/projects/projectMembers.cy.ts index 3e9ec54657..4eefa69e31 100644 --- a/e2e/cypress/e2e/projects/projectMembers.cy.ts +++ b/e2e/cypress/e2e/projects/projectMembers.cy.ts @@ -122,6 +122,7 @@ describe('Project members', () => { { scopes: ['activity.view'], }, + true, 'admin@admin.com' ).then((infoData) => { info = infoData; diff --git a/e2e/cypress/e2e/projects/settings.cy.ts b/e2e/cypress/e2e/projects/settings.cy.ts index a655424e86..bb0b30a262 100644 --- a/e2e/cypress/e2e/projects/settings.cy.ts +++ b/e2e/cypress/e2e/projects/settings.cy.ts @@ -1,6 +1,7 @@ import { createTestProject, deleteProject, + enableNamespaces, login, } from '../../common/apiCalls/common'; import { HOST } from '../../common/constants'; @@ -30,6 +31,10 @@ describe('Projects Basics', () => { .first() .type('Test description'); + cy.gcy('default-namespace-select').should('not.exist'); + cy.gcy('project-settings-use-namespaces-checkbox').click(); + cy.gcy('default-namespace-select').should('be.visible'); + cy.gcy('global-form-save-button').click(); cy.reload(); cy.gcy('project-settings-name') @@ -40,6 +45,10 @@ describe('Projects Basics', () => { .contains('Test description') .should('be.visible'); + cy.gcy('project-settings-use-namespaces-checkbox') + .find('input[type=checkbox]') + .should('be.checked'); + // shows description on dashboard page cy.visit(`${HOST}/projects/${projectId}`); cy.gcy('project-dashboard-description') @@ -48,6 +57,8 @@ describe('Projects Basics', () => { }); it('update default namespace properly', () => { + enableNamespaces(projectId); + cy.visit(`${HOST}/projects/${projectId}/translations`); createTranslation({ namespace: 'test_namespace', key: 'test' }); @@ -62,6 +73,8 @@ describe('Projects Basics', () => { }); it('remove default namespace when all keys are removed and selected "none" as a default', () => { + enableNamespaces(projectId); + cy.visit(`${HOST}/projects/${projectId}/translations`); createTranslation({ namespace: 'test_namespace', key: 'test' }); @@ -81,6 +94,8 @@ describe('Projects Basics', () => { }); it('remove default namespace when all keys are removed and selected other as a default', () => { + enableNamespaces(projectId); + cy.visit(`${HOST}/projects/${projectId}/translations`); createTranslation({ namespace: 'test_namespace1', key: 'test1' }); createTranslation({ namespace: 'test_namespace2', key: 'test2' }); @@ -102,6 +117,8 @@ describe('Projects Basics', () => { }); it('default namespace works correctly with single key view', () => { + enableNamespaces(projectId); + const key = 'test1'; const namespace = 'test_namespace1'; cy.visit(`${HOST}/projects/${projectId}/translations`); diff --git a/e2e/cypress/e2e/translations/base.cy.ts b/e2e/cypress/e2e/translations/base.cy.ts index 4e2a3af98f..1f6e3906d1 100644 --- a/e2e/cypress/e2e/translations/base.cy.ts +++ b/e2e/cypress/e2e/translations/base.cy.ts @@ -8,7 +8,7 @@ import { visitTranslations, } from '../../common/translations'; import { waitForGlobalLoading } from '../../common/loading'; -import { deleteProject } from '../../common/apiCalls/common'; +import { deleteProject, enableNamespaces } from '../../common/apiCalls/common'; import { getAnyContainingText, getClosestContainingText, @@ -42,7 +42,7 @@ describe('Translations Base', () => { }); it( - 'will create translation', + 'will create translation without namespace', { retries: { openMode: 0, runMode: 10 }, }, @@ -52,6 +52,7 @@ describe('Translations Base', () => { createTranslation({ key: 'Test key', translation: 'Translated test key', + assertPresenceOfNamespaceSelectBox: false, }); cy.contains('Key created').should('be.visible'); cy.wait(100); @@ -112,12 +113,14 @@ describe('Translations Base', () => { }); it('will create translation with namespace', () => { + enableNamespaces(project.id); cy.wait(100); cy.gcy('global-empty-list').should('be.visible'); createTranslation({ key: 'Test key', translation: 'Translated test key', namespace: 'test-ns', + assertPresenceOfNamespaceSelectBox: true, }); cy.gcy('translations-namespace-banner') diff --git a/e2e/cypress/e2e/translations/batchJobs.cy.ts b/e2e/cypress/e2e/translations/batchJobs.cy.ts index 2ece77d678..e29a5084d1 100644 --- a/e2e/cypress/e2e/translations/batchJobs.cy.ts +++ b/e2e/cypress/e2e/translations/batchJobs.cy.ts @@ -9,7 +9,7 @@ import { selectOperation, } from '../../common/batchOperations'; import { TestDataStandardResponse } from '../../common/apiCalls/testData/generator'; -import { login } from '../../common/apiCalls/common'; +import { enableNamespaces, login } from '../../common/apiCalls/common'; import { selectNamespace } from '../../common/namespace'; import { assertHasState } from '../../common/state'; import { @@ -51,6 +51,7 @@ describe('Batch jobs', { scrollBehavior: false }, () => { }); it('will change namespace', () => { + enableNamespaces(project.id); selectAll(); selectOperation('Change namespace'); selectNamespace('new-namespace'); diff --git a/e2e/cypress/e2e/translations/namespaces.cy.ts b/e2e/cypress/e2e/translations/namespaces.cy.ts index c8fdbbac20..ac27763d19 100644 --- a/e2e/cypress/e2e/translations/namespaces.cy.ts +++ b/e2e/cypress/e2e/translations/namespaces.cy.ts @@ -1,6 +1,6 @@ import { waitForGlobalLoading } from '../../common/loading'; import { namespaces } from '../../common/apiCalls/testData/testData'; -import { login } from '../../common/apiCalls/common'; +import { enableNamespaces, login } from '../../common/apiCalls/common'; import { createTranslation, visitTranslations, @@ -24,6 +24,7 @@ describe('namespaces in translations', () => { const testProject = projects.find( ({ name }) => name === 'test_project' ); + enableNamespaces(testProject.id); visitTranslations(testProject.id); }); waitForGlobalLoading(); diff --git a/e2e/cypress/e2e/translations/with5translations/withViews.cy.ts b/e2e/cypress/e2e/translations/with5translations/withViews.cy.ts index 8765cb4dbb..054e5d7e06 100644 --- a/e2e/cypress/e2e/translations/with5translations/withViews.cy.ts +++ b/e2e/cypress/e2e/translations/with5translations/withViews.cy.ts @@ -15,6 +15,7 @@ import { } from '../../../common/translations'; import { gcy } from '../../../common/shared'; import { selectNamespace } from '../../../common/namespace'; +import { enableNamespaces } from '../../../common/apiCalls/common'; describe('Views with 5 Translations', () => { let project: ProjectDTO = null; @@ -65,6 +66,7 @@ describe('Views with 5 Translations', () => { }); it('will edit key namespace', () => { + enableNamespaces(project.id); getCell('Cool key 01').click(); selectNamespace('test-ns'); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 77c2c2874e..f2455ff52b 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -223,6 +223,7 @@ declare namespace DataCy { "import-file-input" | "import-file-issues-button" | "import-file-issues-dialog" | + "import-file-warnings" | "import-override-key-descriptions-checkbox" | "import-progress" | "import-progress-overlay" | @@ -451,6 +452,7 @@ declare namespace DataCy { "project-settings-menu-general" | "project-settings-name" | "project-settings-transfer-button" | + "project-settings-use-namespaces-checkbox" | "project-settings-use-tolgee-placeholders-checkbox" | "project-states-bar-bar" | "project-states-bar-dot" | diff --git a/webapp/src/component/common/Checkbox.tsx b/webapp/src/component/common/Checkbox.tsx new file mode 100644 index 0000000000..741a3ac87d --- /dev/null +++ b/webapp/src/component/common/Checkbox.tsx @@ -0,0 +1,11 @@ +import { FunctionComponent } from 'react'; +import { Checkbox as MUICheckbox } from '@mui/material'; +import React from 'react'; + +export type CheckboxProps = React.ComponentProps; + +export const Checkbox: FunctionComponent = React.forwardRef( + function Checkbox(props, ref) { + return ; + } +); diff --git a/webapp/src/component/common/form/fields/Checkbox.tsx b/webapp/src/component/common/form/fields/Checkbox.tsx new file mode 100644 index 0000000000..98327ba518 --- /dev/null +++ b/webapp/src/component/common/form/fields/Checkbox.tsx @@ -0,0 +1,15 @@ +import { FunctionComponent } from 'react'; +import { Checkbox as TolgeeCheckbox } from 'tg.component/common/Checkbox'; +import { useField } from 'formik'; + +interface PGCheckboxProps { + name: string; +} + +type Props = PGCheckboxProps & React.ComponentProps; + +export const Checkbox: FunctionComponent = (props) => { + const [field] = useField(props.name); + + return ; +}; diff --git a/webapp/src/component/translation/translationFilters/useAvailableFilters.tsx b/webapp/src/component/translation/translationFilters/useAvailableFilters.tsx index 47f339821e..84d07fe4a1 100644 --- a/webapp/src/component/translation/translationFilters/useAvailableFilters.tsx +++ b/webapp/src/component/translation/translationFilters/useAvailableFilters.tsx @@ -32,40 +32,45 @@ export const useAvailableFilters = ( path: { projectId: project.id }, }); + const optionsToSend = [ + { + label: t('translations_filters_heading_tags'), + value: null, + submenu: + tags.data?._embedded?.tags?.map((val) => { + return { + label: val.name, + value: encodeFilter({ + filter: 'filterTag', + value: val.name, + }), + }; + }) || [], + }, + ]; + + if (project.useNamespaces) { + optionsToSend.push({ + label: t('translations_filters_heading_namespaces'), + value: null, + submenu: + namespaces.data?._embedded?.namespaces?.map((val) => { + return { + label: val.name || t('namespace_default'), + value: encodeFilter({ + filter: 'filterNamespace', + value: val.name || '', + }), + }; + }) || [], + }); + } + const availableFilters: GroupType[] = [ { name: null, type: 'multi', - options: [ - { - label: t('translations_filters_heading_tags'), - value: null, - submenu: - tags.data?._embedded?.tags?.map((val) => { - return { - label: val.name, - value: encodeFilter({ - filter: 'filterTag', - value: val.name, - }), - }; - }) || [], - }, - { - label: t('translations_filters_heading_namespaces'), - value: null, - submenu: - namespaces.data?._embedded?.namespaces?.map((val) => { - return { - label: val.name || t('namespace_default'), - value: encodeFilter({ - filter: 'filterNamespace', - value: val.name || '', - }), - }; - }) || [], - }, - ], + options: optionsToSend, }, ]; diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 3e4f69bb96..660fa3ffc2 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -1127,7 +1127,9 @@ export interface components { | "native_authentication_disabled" | "invitation_organization_mismatch" | "user_is_managed_by_organization" - | "cannot_set_sso_provider_missing_fields"; + | "cannot_set_sso_provider_missing_fields" + | "namespaces_cannot_be_disabled_when_namespace_exists" + | "namespace_cannot_be_used_when_feature_is_disabled"; params?: { [key: string]: unknown }[]; }; ErrorResponseBody: { @@ -1184,6 +1186,7 @@ export interface components { slug?: string; /** Format: int64 */ baseLanguageId?: number; + useNamespaces: boolean; /** Format: int64 */ defaultNamespaceId?: number; description?: string; @@ -1198,18 +1201,6 @@ export interface components { | "ORGANIZATION_OWNER" | "NONE" | "SERVER_ADMIN"; - /** @description The user's permission type. This field is null if uses granular permissions */ - type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; - /** - * @description List of languages user can translate to. If null, all languages editing is permitted. - * @example 200001,200004 - */ - translateLanguageIds?: number[]; - /** - * @description List of languages user can change state to. If null, changing state of all language values is permitted. - * @example 200001,200004 - */ - stateChangeLanguageIds?: number[]; /** * @deprecated * @description Deprecated (use translateLanguageIds). @@ -1252,11 +1243,23 @@ export interface components { | "tasks.view" | "tasks.edit" )[]; + /** + * @description List of languages user can translate to. If null, all languages editing is permitted. + * @example 200001,200004 + */ + translateLanguageIds?: number[]; /** * @description List of languages user can view. If null, all languages view is permitted. * @example 200001,200004 */ viewLanguageIds?: number[]; + /** + * @description List of languages user can change state to. If null, changing state of all language values is permitted. + * @example 200001,200004 + */ + stateChangeLanguageIds?: number[]; + /** @description The user's permission type. This field is null if uses granular permissions */ + type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; }; LanguageModel: { /** Format: int64 */ @@ -1368,6 +1371,7 @@ export interface components { avatar?: components["schemas"]["Avatar"]; organizationOwner?: components["schemas"]["SimpleOrganizationModel"]; baseLanguage?: components["schemas"]["LanguageModel"]; + useNamespaces: boolean; defaultNamespace?: components["schemas"]["NamespaceModel"]; organizationRole?: "MEMBER" | "OWNER"; directPermission?: components["schemas"]["PermissionModel"]; @@ -2197,12 +2201,12 @@ export interface components { createNewKeys: boolean; }; ImportSettingsModel: { - /** @description If true, placeholders from other formats will be converted to ICU when possible */ - convertPlaceholdersToIcu: boolean; /** @description If true, key descriptions will be overridden by the import */ overrideKeyDescriptions: boolean; /** @description If false, only updates keys, skipping the creation of new keys */ createNewKeys: boolean; + /** @description If true, placeholders from other formats will be converted to ICU when possible */ + convertPlaceholdersToIcu: boolean; }; TranslationCommentModel: { /** @@ -2360,16 +2364,16 @@ export interface components { RevealedPatModel: { token: string; /** Format: int64 */ - id: number; - description: string; + createdAt: number; + /** Format: int64 */ + updatedAt: number; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; /** Format: int64 */ - createdAt: number; - /** Format: int64 */ - updatedAt: number; + id: number; + description: string; }; SetOrganizationRoleDto: { roleType: "MEMBER" | "OWNER"; @@ -2526,19 +2530,19 @@ export interface components { RevealedApiKeyModel: { /** @description Resulting user's api key */ key: string; - /** Format: int64 */ - id: number; + userFullName?: string; + projectName: string; username?: string; - description: string; - scopes: string[]; /** Format: int64 */ projectId: number; + scopes: string[]; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; - projectName: string; - userFullName?: string; + /** Format: int64 */ + id: number; + description: string; }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ @@ -2986,7 +2990,9 @@ export interface components { | "native_authentication_disabled" | "invitation_organization_mismatch" | "user_is_managed_by_organization" - | "cannot_set_sso_provider_missing_fields"; + | "cannot_set_sso_provider_missing_fields" + | "namespaces_cannot_be_disabled_when_namespace_exists" + | "namespace_cannot_be_used_when_feature_is_disabled"; params?: { [key: string]: unknown }[]; }; UntagKeysRequest: { @@ -3198,6 +3204,7 @@ export interface components { }; ImportAddFilesResultModel: { errors: components["schemas"]["ErrorResponseBody"][]; + warnings: components["schemas"]["ErrorResponseBody"][]; result?: components["schemas"]["PagedModelImportLanguageModel"]; }; ImportLanguageModel: { @@ -3735,22 +3742,22 @@ export interface components { | "ORDER_TRANSLATION" )[]; quickStart?: components["schemas"]["QuickStartModel"]; - /** @example Beautiful organization */ - name: string; - /** Format: int64 */ - id: number; - /** @example This is a beautiful organization full of beautiful and clever people */ - description?: string; - avatar?: components["schemas"]["Avatar"]; + basePermissions: components["schemas"]["PermissionModel"]; /** @example btforg */ slug: string; + avatar?: components["schemas"]["Avatar"]; /** * @description The role of currently authorized user. * * Can be null when user has direct access to one of the projects owned by the organization. */ currentUserRole?: "MEMBER" | "OWNER"; - basePermissions: components["schemas"]["PermissionModel"]; + /** @example Beautiful organization */ + name: string; + /** Format: int64 */ + id: number; + /** @example This is a beautiful organization full of beautiful and clever people */ + description?: string; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -3922,23 +3929,23 @@ export interface components { formalitySupported: boolean; }; KeySearchResultView: { + baseTranslation?: string; + translation?: string; name: string; /** Format: int64 */ id: number; - namespace?: string; description?: string; - translation?: string; - baseTranslation?: string; + namespace?: string; }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; + baseTranslation?: string; + translation?: string; name: string; /** Format: int64 */ id: number; - namespace?: string; description?: string; - translation?: string; - baseTranslation?: string; + namespace?: string; }; PagedModelKeySearchSearchResultModel: { _embedded?: { @@ -4538,16 +4545,16 @@ export interface components { PatWithUserModel: { user: components["schemas"]["SimpleUserAccountModel"]; /** Format: int64 */ - id: number; - description: string; + createdAt: number; + /** Format: int64 */ + updatedAt: number; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; /** Format: int64 */ - createdAt: number; - /** Format: int64 */ - updatedAt: number; + id: number; + description: string; }; PagedModelOrganizationModel: { _embedded?: { @@ -4664,19 +4671,19 @@ export interface components { * @description Languages for which user has translate permission. */ permittedLanguageIds?: number[]; - /** Format: int64 */ - id: number; + userFullName?: string; + projectName: string; username?: string; - description: string; - scopes: string[]; /** Format: int64 */ projectId: number; + scopes: string[]; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; - projectName: string; - userFullName?: string; + /** Format: int64 */ + id: number; + description: string; }; PagedModelUserAccountModel: { _embedded?: { diff --git a/webapp/src/translationTools/TranslatedWarningBox.tsx b/webapp/src/translationTools/TranslatedWarningBox.tsx new file mode 100644 index 0000000000..07cc0d950d --- /dev/null +++ b/webapp/src/translationTools/TranslatedWarningBox.tsx @@ -0,0 +1,18 @@ +import { Alert, AlertTitle } from '@mui/material'; +import { useImportWarningTranslation } from 'tg.translationTools/useImportWarningTranslation'; + +type Props = { + code: string; +}; + +export function TranslatedWarningBox({ code }: Props) { + const importWarningTranslation = useImportWarningTranslation(); + const translation = importWarningTranslation(code); + + return ( + + {translation.title} + {translation.message} + + ); +} diff --git a/webapp/src/translationTools/useErrorTranslation.ts b/webapp/src/translationTools/useErrorTranslation.ts index 3f8414c241..e27d4f9970 100644 --- a/webapp/src/translationTools/useErrorTranslation.ts +++ b/webapp/src/translationTools/useErrorTranslation.ts @@ -151,6 +151,10 @@ export function useErrorTranslation() { return t('user_is_managed_by_organization'); case 'cannot_set_sso_provider_missing_fields': return t('cannot_set_sso_provider_missing_fields'); + case 'namespace_cannot_be_used_when_feature_is_disabled': + return t('namespace_cannot_be_used_when_feature_is_disabled'); + case 'namespaces_cannot_be_disabled_when_namespace_exists': + return t('namespaces_cannot_be_disabled_when_namespace_exists'); default: return code; } diff --git a/webapp/src/translationTools/useImportWarningTranslation.ts b/webapp/src/translationTools/useImportWarningTranslation.ts new file mode 100644 index 0000000000..e72f771064 --- /dev/null +++ b/webapp/src/translationTools/useImportWarningTranslation.ts @@ -0,0 +1,24 @@ +import { useTranslate } from '@tolgee/react'; + +export function useImportWarningTranslation() { + const { t } = useTranslate(); + + return (code: string) => { + switch (code.toLocaleLowerCase()) { + case 'namespace_cannot_be_used_when_feature_is_disabled': + return { + title: t( + 'warning_header_namespace_cannot_be_used_when_feature_is_disabled' + ), + message: t( + 'warning_message_namespace_cannot_be_used_when_feature_is_disabled' + ), + }; + default: + return { + title: `warning_header_${code}`, + message: `warning_message_${code}`, + }; + } + }; +} diff --git a/webapp/src/views/projects/export/components/NsSelector.tsx b/webapp/src/views/projects/export/components/NsSelector.tsx index b76ea874aa..44d603acd9 100644 --- a/webapp/src/views/projects/export/components/NsSelector.tsx +++ b/webapp/src/views/projects/export/components/NsSelector.tsx @@ -12,6 +12,7 @@ import { import { useTranslate } from '@tolgee/react'; import { StateType } from 'tg.constants/translationStates'; +import { useProject } from 'tg.hooks/useProject'; type Props = { namespaces: string[] | undefined; @@ -20,6 +21,7 @@ type Props = { export const NsSelector: React.FC = ({ namespaces, className }) => { const { t } = useTranslate(); + const project = useProject(); const [field, meta, helper] = useField('namespaces'); @@ -31,6 +33,10 @@ export const NsSelector: React.FC = ({ namespaces, className }) => { helper.setValue(e.target.value); }; + if (!project.useNamespaces) { + return null; + } + return ( diff --git a/webapp/src/views/projects/import/ImportView.tsx b/webapp/src/views/projects/import/ImportView.tsx index 867265732b..28c5c4075a 100644 --- a/webapp/src/views/projects/import/ImportView.tsx +++ b/webapp/src/views/projects/import/ImportView.tsx @@ -21,6 +21,7 @@ import { useImportDataHelper } from './hooks/useImportDataHelper'; import { BaseProjectView } from '../BaseProjectView'; import { ImportResultLoadingOverlay } from './component/ImportResultLoadingOverlay'; import { ImportSettingsPanel } from './component/ImportSettingsPanel'; +import { TranslatedWarningBox } from 'tg.translationTools/TranslatedWarningBox'; export const ImportView: FunctionComponent = () => { const dataHelper = useImportDataHelper(); @@ -123,6 +124,11 @@ export const ImportView: FunctionComponent = () => { addFilesMutation={dataHelper.addFilesMutation} /> ))} + {dataHelper.addFilesMutation.data?.warnings.map((item) => ( + + + + ))} diff --git a/webapp/src/views/projects/import/component/ImportResult.tsx b/webapp/src/views/projects/import/component/ImportResult.tsx index f3a920dd2d..a3cf4807f8 100644 --- a/webapp/src/views/projects/import/component/ImportResult.tsx +++ b/webapp/src/views/projects/import/component/ImportResult.tsx @@ -17,6 +17,7 @@ import { components } from 'tg.service/apiSchema.generated'; import { ImportFileIssuesDialog } from './ImportFileIssuesDialog'; import { ImportResultRow } from './ImportResultRow'; import { ImportTranslationsDialog } from './ImportTranslationsDialog'; +import { useProject } from 'tg.hooks/useProject'; type ImportResultProps = { result?: components['schemas']['PagedModelImportLanguageModel']; @@ -31,6 +32,7 @@ const StyledTable = styled(Table)` `; export const ImportResult: FunctionComponent = (props) => { + const project = useProject(); const rows = props.result?._embedded?.languages; const [viewFileIssuesRow, setViewFileIssuesRow] = useState( undefined as components['schemas']['ImportLanguageModel'] | undefined @@ -63,9 +65,11 @@ export const ImportResult: FunctionComponent = (props) => { - - - + {project.useNamespaces && ( + + + + )} diff --git a/webapp/src/views/projects/import/component/ImportResultRow.tsx b/webapp/src/views/projects/import/component/ImportResultRow.tsx index 7dcc3a3b54..f15f09c7d6 100644 --- a/webapp/src/views/projects/import/component/ImportResultRow.tsx +++ b/webapp/src/views/projects/import/component/ImportResultRow.tsx @@ -22,6 +22,7 @@ import { components } from 'tg.service/apiSchema.generated'; import { LanguageSelector } from './LanguageSelector'; import { ImportNamespaceSelector } from './ImportNamespaceSelector'; import { useImportLanguageHelper } from '../hooks/useImportLanguageHelper'; +import { useProject } from 'tg.hooks/useProject'; const StyledTableRow = styled(TableRow)` &:hover { @@ -72,6 +73,7 @@ export const ImportResultRow = (props: { onShowData: () => void; }) => { const helper = useImportLanguageHelper(props.row); + const project = useProject(); return ( @@ -82,9 +84,11 @@ export const ImportResultRow = (props: { row={props.row} /> - - - + {project.useNamespaces && ( + + + + )} {props.row.importFileName} ({props.row.name}) diff --git a/webapp/src/views/projects/project/ProjectSettingsAdvanced.tsx b/webapp/src/views/projects/project/ProjectSettingsAdvanced.tsx index 209afcebaa..8995465c2b 100644 --- a/webapp/src/views/projects/project/ProjectSettingsAdvanced.tsx +++ b/webapp/src/views/projects/project/ProjectSettingsAdvanced.tsx @@ -37,6 +37,7 @@ export const ProjectSettingsAdvanced = () => { name: project.name, description: project.description, baseLanguageId: project.baseLanguage!.id, + useNamespaces: project.useNamespaces, }, }, }); diff --git a/webapp/src/views/projects/project/ProjectSettingsGeneral.tsx b/webapp/src/views/projects/project/ProjectSettingsGeneral.tsx index 5b7812601d..9aa8e7199a 100644 --- a/webapp/src/views/projects/project/ProjectSettingsGeneral.tsx +++ b/webapp/src/views/projects/project/ProjectSettingsGeneral.tsx @@ -10,16 +10,19 @@ import { Validation } from 'tg.constants/GlobalValidationSchema'; import LoadingButton from 'tg.component/common/form/LoadingButton'; import { useLeaveProject } from '../useLeaveProject'; import { TextField } from 'tg.component/common/form/fields/TextField'; +import { Checkbox } from 'tg.component/common/form/fields/Checkbox'; import { FieldLabel } from 'tg.component/FormField'; -import { Box, styled } from '@mui/material'; +import { Box, FormControlLabel, styled, Typography } from '@mui/material'; import { ProjectLanguagesProvider } from 'tg.hooks/ProjectLanguagesProvider'; import { useProjectNamespaces } from 'tg.hooks/useProjectNamespaces'; import { DefaultNamespaceSelect } from './components/DefaultNamespaceSelect'; +import { useField } from 'formik'; type FormValues = { name: string; description: string | undefined; baseLanguageId: number | undefined; + useNamespaces: boolean | false; defaultNamespaceId: number | ''; }; @@ -50,12 +53,15 @@ const LanguageSelect = () => { }; const NamespaceSelect = () => { + const [useNamespacesField] = useField('useNamespaces'); const { allNamespacesWithNone } = useProjectNamespaces(); + return ( } name="defaultNamespaceId" namespaces={allNamespacesWithNone} + hidden={!useNamespacesField.value} /> ); }; @@ -69,6 +75,7 @@ export const ProjectSettingsGeneral = () => { name: project.name, baseLanguageId: project.baseLanguage?.id, description: project.description ?? '', + useNamespaces: project.useNamespaces ?? false, defaultNamespaceId: defaultNamespace?.id ?? '', } satisfies FormValues; @@ -76,12 +83,16 @@ export const ProjectSettingsGeneral = () => { url: '/v2/projects/{projectId}', method: 'put', invalidatePrefix: '/v2/projects', + fetchOptions: { + disableErrorNotification: true, + }, }); const updateProjectSettings = (values: FormValues) => { const data = { ...values, description: values.description || undefined, + useNamespaces: values.useNamespaces || false, defaultNamespaceId: values.defaultNamespaceId === 0 ? undefined : values.defaultNamespaceId, }; @@ -158,6 +169,35 @@ export const ProjectSettingsGeneral = () => { + + + } + label={} + data-cy="project-settings-use-namespaces-checkbox" + /> + + { + + ), + }} + /> + } + + diff --git a/webapp/src/views/projects/project/components/DefaultNamespaceSelect.tsx b/webapp/src/views/projects/project/components/DefaultNamespaceSelect.tsx index 38f1ced019..747e502024 100644 --- a/webapp/src/views/projects/project/components/DefaultNamespaceSelect.tsx +++ b/webapp/src/views/projects/project/components/DefaultNamespaceSelect.tsx @@ -34,8 +34,14 @@ export const DefaultNamespaceSelect: FC<{ label?: ReactNode; name: string; valueKey?: keyof NamespaceModel; + hidden: boolean; }> = (props) => { const { t } = useTranslate(); + + if (props.hidden) { + return null; + } + const namespaces = props.namespaces.map(({ id, name }) => ({ value: id ?? ('' as const), label: name ?? t('namespace_default'), diff --git a/webapp/src/views/projects/translations/BatchOperations/BatchSelect.tsx b/webapp/src/views/projects/translations/BatchOperations/BatchSelect.tsx index 2df2316f6d..eaa8304d9e 100644 --- a/webapp/src/views/projects/translations/BatchOperations/BatchSelect.tsx +++ b/webapp/src/views/projects/translations/BatchOperations/BatchSelect.tsx @@ -8,8 +8,9 @@ import { } from '@mui/material'; import { useTranslate } from '@tolgee/react'; import { getTextWidth } from 'tg.fixtures/getTextWidth'; -import { useBatchOperations } from './operations'; +import { BatchOperation, useBatchOperations } from './operations'; import { BatchActions } from './types'; +import { useProject } from 'tg.hooks/useProject'; const StyledSeparator = styled('div')` width: 100%; @@ -25,9 +26,14 @@ type Props = { export const BatchSelect = ({ value, onChange }: Props) => { const theme = useTheme(); const { t } = useTranslate(); - + const project = useProject(); const { operations } = useBatchOperations(); + hideChangeNamespaceOperationIfNamespacesAreDisabled( + project.useNamespaces, + operations + ); + const option = operations.find((o) => o.id === value); const width = useMemo(() => { @@ -75,3 +81,15 @@ export const BatchSelect = ({ value, onChange }: Props) => { /> ); }; + +function hideChangeNamespaceOperationIfNamespacesAreDisabled( + useNamespaces: boolean, + operations: BatchOperation[] +) { + const changeNamespaceOperation = operations.find( + (operation) => operation.id === 'change_namespace' + ); + if (changeNamespaceOperation != undefined) { + changeNamespaceOperation.hidden = !useNamespaces; + } +} diff --git a/webapp/src/views/projects/translations/KeyCreateForm/FormBody.tsx b/webapp/src/views/projects/translations/KeyCreateForm/FormBody.tsx index 77a41bb8e9..2391773192 100644 --- a/webapp/src/views/projects/translations/KeyCreateForm/FormBody.tsx +++ b/webapp/src/views/projects/translations/KeyCreateForm/FormBody.tsx @@ -7,6 +7,8 @@ import { } from 'formik'; import { Box, Button, styled } from '@mui/material'; import { T, useTranslate } from '@tolgee/react'; +import { useState } from 'react'; +import clsx from 'clsx'; import { NamespaceSelector } from 'tg.component/NamespaceSelector/NamespaceSelector'; import { EditorWrapper } from 'tg.component/editor/EditorWrapper'; @@ -25,7 +27,6 @@ import { PluralEditor } from '../translationVisual/PluralEditor'; import type { ValuesCreateType } from './KeyCreateForm'; import { PluralFormCheckbox } from 'tg.component/common/form/PluralFormCheckbox'; import { ControlsEditorSmall } from '../cell/ControlsEditorSmall'; -import { useState } from 'react'; const StyledContainer = styled('div')` display: grid; @@ -35,8 +36,13 @@ const StyledContainer = styled('div')` const StyledKeyNsContainer = styled('div')` display: grid; - grid-template-columns: 1fr 300px; gap: 0px 16px; + grid-template-columns: 1fr; + + &.useNamespaces { + grid-template-columns: 1fr 300px; + } + @media (max-width: 800px) { grid-template-columns: 1fr; } @@ -84,7 +90,9 @@ export const FormBody: React.FC = ({ onCancel, autofocus }) => { return ( <> - + {({ field, form, meta }: FieldProps) => { return ( @@ -122,27 +130,29 @@ export const FormBody: React.FC = ({ onCancel, autofocus }) => { }} - - {({ field, form }: FieldProps) => { - return ( -
- - - - - - - - form.setFieldValue(field.name, value) - } - /> - -
- ); - }} -
+ {project.useNamespaces && ( + + {({ field, form }: FieldProps) => { + return ( +
+ + + + + + + + form.setFieldValue(field.name, value) + } + /> + +
+ ); + }} +
+ )}
diff --git a/webapp/src/views/projects/translations/KeyEdit/KeyGeneral.tsx b/webapp/src/views/projects/translations/KeyEdit/KeyGeneral.tsx index 9453d3e923..32f78efa14 100644 --- a/webapp/src/views/projects/translations/KeyEdit/KeyGeneral.tsx +++ b/webapp/src/views/projects/translations/KeyEdit/KeyGeneral.tsx @@ -12,13 +12,20 @@ import { Tag } from '../Tags/Tag'; import { RequiredField } from 'tg.component/common/form/RequiredField'; import { LabelHint } from 'tg.component/common/LabelHint'; import { PluralFormCheckbox } from 'tg.component/common/form/PluralFormCheckbox'; +import { useProject } from 'tg.hooks/useProject'; +import clsx from 'clsx'; const StyledSection = styled('div')``; const StyledKeyNsContainer = styled('div')` display: grid; - grid-template-columns: 1fr 300px; gap: 0px 16px; + grid-template-columns: 1fr; + + &.useNamespaces { + grid-template-columns: 1fr 300px; + } + @media (max-width: 800px) { grid-template-columns: 1fr; } @@ -39,13 +46,16 @@ const StyledTags = styled('div')` export const KeyGeneral = () => { const { t } = useTranslate(); + const project = useProject(); const { values, setFieldValue, submitForm, errors } = useFormikContext(); const theme = useTheme(); return ( <> - + {t('translations_key_edit_label')} @@ -67,23 +77,27 @@ export const KeyGeneral = () => { - - - - {t('translations_key_edit_label_namespace')} - - - setFieldValue('namespace', value)} - SearchSelectProps={{ - SelectProps: { - sx: { background: theme.palette.background.default }, - }, - }} - /> - - + {project.useNamespaces && ( + + + + {t('translations_key_edit_label_namespace')} + + + setFieldValue('namespace', value)} + SearchSelectProps={{ + SelectProps: { + sx: { background: theme.palette.background.default }, + }, + }} + /> + + + )} diff --git a/webapp/src/views/projects/translations/KeySingle/KeyEditForm.tsx b/webapp/src/views/projects/translations/KeySingle/KeyEditForm.tsx index 5b241a39b1..dee5d5f0d6 100644 --- a/webapp/src/views/projects/translations/KeySingle/KeyEditForm.tsx +++ b/webapp/src/views/projects/translations/KeySingle/KeyEditForm.tsx @@ -190,15 +190,17 @@ export const KeyEditForm: React.FC = () => { -
- - - - -
+ {project.useNamespaces && ( +
+ + + + +
+ )}