diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/TaskController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/TaskController.kt index eb56d4e4e2..c1965ef37a 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/TaskController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/TaskController.kt @@ -62,7 +62,9 @@ class TaskController( @RequestBody @Valid dto: CreateTaskRequest, ): TaskModel { - val task = taskService.createTask(projectHolder.projectEntity, dto) + val id = taskService.createTask(projectHolder.projectEntity, dto).id + val task = taskService.getTask(projectHolder.projectEntity, id) + return taskModelAssembler.toModel(task) } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerTest.kt index 49a4eb21df..bed1899de2 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/task/TaskControllerTest.kt @@ -1,19 +1,15 @@ package io.tolgee.api.v2.controllers.task -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.tolgee.ProjectAuthControllerTest import io.tolgee.constants.Message import io.tolgee.development.testDataBuilder.data.TaskTestData -import io.tolgee.dtos.request.task.CalculateScopeRequest -import io.tolgee.dtos.request.task.CreateTaskRequest -import io.tolgee.dtos.request.task.UpdateTaskRequest +import io.tolgee.dtos.request.task.* import io.tolgee.fixtures.andAssertThatJson import io.tolgee.fixtures.andIsBadRequest import io.tolgee.fixtures.andIsOk import io.tolgee.fixtures.node import io.tolgee.model.enums.TaskType import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod -import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.math.BigDecimal @@ -63,7 +59,7 @@ class TaskControllerTest : ProjectAuthControllerTest("/v2/projects/") { node("name").isEqualTo("Another task") node("assignees[0].name").isEqualTo(testData.orgMember.self.name) node("language.tag").isEqualTo(testData.englishLanguage.tag) - node("keys").isEqualTo(keys.toSortedSet()) + node("totalItems").isEqualTo(keys.size) } performProjectAuthGet("tasks").andAssertThatJson { @@ -144,24 +140,21 @@ class TaskControllerTest : ProjectAuthControllerTest("/v2/projects/") { @Test @ProjectJWTAuthTestMethod fun `can add keys`() { - val response = - performProjectAuthPut( - "tasks/${testData.createdTask.self.id}", - UpdateTaskRequest( - addKeys = testData.keysOutOfTask.map { it.self.id }.toMutableSet(), - ), - ).andIsOk.andReturn().response.contentAsString - // Parse JSON response - val jsonResponse = jacksonObjectMapper().readTree(response) - - // Extract keys array from JSON response and convert to Set of strings - val actualKeys = jsonResponse["keys"].elements().asSequence().map { it.asLong() }.toSet() - - // Calculate expected keys set - val expectedKeys = (testData.keysInTask union testData.keysOutOfTask).map { it.self.id }.toSet() - - // Assert that actual keys match expected keys - assertEquals(expectedKeys, actualKeys, "The keys in the response should match the expected keys") + performProjectAuthPut( + "tasks/${testData.createdTask.self.id}/keys", + UpdateTaskKeysRequest( + addKeys = testData.keysOutOfTask.map { it.self.id }.toMutableSet(), + ), + ).andIsOk + + performProjectAuthGet( + "tasks/${testData.createdTask.self.id}", + ).andIsOk.andAssertThatJson { + // Calculate expected keys set + val expectedItems = (testData.keysInTask union testData.keysOutOfTask).size + + node("totalItems").isEqualTo(expectedItems) + } } @Test @@ -170,16 +163,18 @@ class TaskControllerTest : ProjectAuthControllerTest("/v2/projects/") { val allKeys = testData.keysInTask.map { it.self.id }.toMutableSet() val keysToRemove = mutableSetOf(allKeys.first()) val remainingKeys = allKeys.subtract(keysToRemove) - val response = - performProjectAuthPut( - "tasks/${testData.createdTask.self.id}", - UpdateTaskRequest( - removeKeys = keysToRemove, - ), - ).andIsOk.andReturn().response.contentAsString - val jsonResponse = jacksonObjectMapper().readTree(response) - val actualKeys = jsonResponse["keys"].elements().asSequence().map { it.asLong() }.toSet() - assertEquals(remainingKeys, actualKeys, "The keys in the response should match the expected keys") + performProjectAuthPut( + "tasks/${testData.createdTask.self.id}/keys", + UpdateTaskKeysRequest( + removeKeys = keysToRemove, + ), + ).andIsOk + + performProjectAuthGet( + "tasks/${testData.createdTask.self.id}", + ).andIsOk.andAssertThatJson { + node("totalItems").isEqualTo(remainingKeys.size) + } } @Test diff --git a/backend/app/src/test/kotlin/io/tolgee/service/queryBuilders/CursorUtilUnitTest.kt b/backend/app/src/test/kotlin/io/tolgee/service/queryBuilders/CursorUtilUnitTest.kt index aee08daa93..31250183b5 100644 --- a/backend/app/src/test/kotlin/io/tolgee/service/queryBuilders/CursorUtilUnitTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/service/queryBuilders/CursorUtilUnitTest.kt @@ -38,6 +38,7 @@ class CursorUtilUnitTest { mtProvider = null, commentCount = 0, unresolvedCommentCount = 1, + assignedTaskId = 1 ), ), contextPresent = false, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/TaskService.kt b/backend/data/src/main/kotlin/io/tolgee/service/TaskService.kt index 60e1e1d09d..ff91188855 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/TaskService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/TaskService.kt @@ -68,6 +68,7 @@ class TaskService( } } + @Transactional fun createTask( project: Project, dto: CreateTaskRequest, @@ -81,7 +82,6 @@ class TaskService( lastErr = e } } - throw lastErr } @@ -208,12 +208,13 @@ class TaskService( } dto.addKeys?.let { toAdd -> - val translationsToAdd = getOnlyProjectKeys(projectEntity, task.language.id, task.type, toAdd) + val translationsToAdd = getOrCreateTranslations(task.language.id, toAdd) + val translationIdsToAdd = translationsToAdd.map { it.id }.toMutableSet() val existingTranslations = task.translations.map { it.translation.id }.toMutableSet() - val nonExistingTranslations = translationsToAdd.subtract(existingTranslations).toMutableSet() - val taskTranslationsToAdd = - nonExistingTranslations - .map { TaskTranslation(task, entityManager.getReference(Translation::class.java, it)) } + val nonExistingTranslationIds = translationIdsToAdd.subtract(existingTranslations).toMutableSet() + val taskTranslationsToAdd = translationsToAdd + .filter { nonExistingTranslationIds.contains(it.id) } + .map { TaskTranslation(task, it) } task.translations = task.translations.union(taskTranslationsToAdd).toMutableSet() taskTranslationRepository.saveAll(taskTranslationsToAdd) } diff --git a/webapp/src/views/projects/translations/BatchOperations/BatchOperations.tsx b/webapp/src/views/projects/translations/BatchOperations/BatchOperations.tsx index 9b383038bd..5baf538537 100644 --- a/webapp/src/views/projects/translations/BatchOperations/BatchOperations.tsx +++ b/webapp/src/views/projects/translations/BatchOperations/BatchOperations.tsx @@ -21,7 +21,9 @@ import { BatchActions, BatchJobModel } from './types'; import { OperationPreTranslate } from './OperationPreTranslate'; import { SelectAllCheckbox } from './SelectAllCheckbox'; import { OperationExportTranslations } from './OperationExportTranslations'; -import { OperationCreateTask } from './OperationCreateTask'; +import { OperationTaskCreate } from './OperationTaskCreate'; +import { OperationTaskAddKeys } from './OperationTaskAddKeys'; +import { OperationTaskRemoveKeys } from './OperationTaskRemoveKeys'; const StyledContainer = styled('div')` position: absolute; @@ -119,8 +121,12 @@ export const BatchOperations = ({ open, onClose }: Props) => { return ; case 'mark_as_reviewed': return ; - case 'create_task': - return ; + case 'task_create': + return ; + case 'task_add_keys': + return ; + case 'task_remove_keys': + return ; case 'add_tags': return ; case 'remove_tags': diff --git a/webapp/src/views/projects/translations/BatchOperations/BatchSelect.tsx b/webapp/src/views/projects/translations/BatchOperations/BatchSelect.tsx index b9629f2826..d1ef4c31e3 100644 --- a/webapp/src/views/projects/translations/BatchOperations/BatchSelect.tsx +++ b/webapp/src/views/projects/translations/BatchOperations/BatchSelect.tsx @@ -77,11 +77,21 @@ export const BatchSelect = ({ value, onChange }: Props) => { enabled: canViewTranslations, }, { - id: 'create_task', + id: 'task_create', label: t('batch_operations_create_task'), divider: true, enabled: canEditKey, }, + { + id: 'task_add_keys', + label: t('batch_operations_task_add_keys'), + enabled: canEditKey, + }, + { + id: 'task_remove_keys', + label: t('batch_operations_task_remove_keys'), + enabled: canEditKey, + }, { id: 'add_tags', label: t('batch_operations_add_tags'), diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationTaskAddKeys.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationTaskAddKeys.tsx new file mode 100644 index 0000000000..c97cc23f27 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationTaskAddKeys.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import { useTranslate } from '@tolgee/react'; + +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { useProject } from 'tg.hooks/useProject'; +import { TextField } from 'tg.component/common/TextField'; + +import { useTranslationsSelector } from '../context/TranslationsContext'; +import { OperationContainer } from './components/OperationContainer'; +import { BatchOperationsSubmit } from './components/BatchOperationsSubmit'; +import { OperationProps } from './types'; +import { messageService } from 'tg.service/MessageService'; +import { usePrefilter } from '../prefilters/usePrefilter'; + +type Props = OperationProps; + +export const OperationTaskAddKeys = ({ disabled, onFinished }: Props) => { + const prefilter = usePrefilter(); + const [task, setTask] = useState(prefilter.task ? prefilter.task : undefined); + const project = useProject(); + const { t } = useTranslate(); + + const selection = useTranslationsSelector((c) => c.selection); + + const addTaskKeysLoadable = useApiMutation({ + url: '/v2/projects/{projectId}/tasks/{taskId}/keys', + method: 'put', + }); + + function handleAddKeys() { + addTaskKeysLoadable.mutate( + { + path: { projectId: project.id, taskId: Number(task) }, + content: { 'application/json': { addKeys: selection } }, + }, + { + onSuccess() { + messageService.success(t('batch_operations_add_task_keys_success')); + onFinished(); + }, + } + ); + } + + return ( + + setTask(Number(e.currentTarget.value))} + /> + + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationCreateTask.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationTaskCreate.tsx similarity index 98% rename from webapp/src/views/projects/translations/BatchOperations/OperationCreateTask.tsx rename to webapp/src/views/projects/translations/BatchOperations/OperationTaskCreate.tsx index 706bf45599..46282392aa 100644 --- a/webapp/src/views/projects/translations/BatchOperations/OperationCreateTask.tsx +++ b/webapp/src/views/projects/translations/BatchOperations/OperationTaskCreate.tsx @@ -69,7 +69,7 @@ const StyledActions = styled('div')` type Props = OperationProps; -export const OperationCreateTask = ({ disabled, onClose }: Props) => { +export const OperationTaskCreate = ({ disabled, onFinished }: Props) => { const project = useProject(); const { t } = useTranslate(); const [dialogOpen, setDialogOpen] = useState(true); @@ -146,7 +146,7 @@ export const OperationCreateTask = ({ disabled, onClose }: Props) => { params={{ count: values.languages.length }} /> ); - onClose(); + onFinished(); }, } ); diff --git a/webapp/src/views/projects/translations/BatchOperations/OperationTaskRemoveKeys.tsx b/webapp/src/views/projects/translations/BatchOperations/OperationTaskRemoveKeys.tsx new file mode 100644 index 0000000000..7f923a2a84 --- /dev/null +++ b/webapp/src/views/projects/translations/BatchOperations/OperationTaskRemoveKeys.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; +import { useTranslate } from '@tolgee/react'; + +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { useProject } from 'tg.hooks/useProject'; +import { TextField } from 'tg.component/common/TextField'; + +import { useTranslationsSelector } from '../context/TranslationsContext'; +import { OperationContainer } from './components/OperationContainer'; +import { BatchOperationsSubmit } from './components/BatchOperationsSubmit'; +import { OperationProps } from './types'; +import { messageService } from 'tg.service/MessageService'; +import { usePrefilter } from '../prefilters/usePrefilter'; + +type Props = OperationProps; + +export const OperationTaskRemoveKeys = ({ disabled, onFinished }: Props) => { + const prefilter = usePrefilter(); + const [task, setTask] = useState(prefilter.task ? prefilter.task : undefined); + const project = useProject(); + const { t } = useTranslate(); + + const selection = useTranslationsSelector((c) => c.selection); + + const addTaskKeysLoadable = useApiMutation({ + url: '/v2/projects/{projectId}/tasks/{taskId}/keys', + method: 'put', + }); + + function handleAddKeys() { + addTaskKeysLoadable.mutate( + { + path: { projectId: project.id, taskId: Number(task) }, + content: { 'application/json': { removeKeys: selection } }, + }, + { + onSuccess() { + messageService.success( + t('batch_operations_remove_task_keys_success') + ); + onFinished(); + }, + } + ); + } + + return ( + + setTask(Number(e.currentTarget.value))} + /> + + + ); +}; diff --git a/webapp/src/views/projects/translations/BatchOperations/types.ts b/webapp/src/views/projects/translations/BatchOperations/types.ts index 13b3e6b7e9..77f2696e70 100644 --- a/webapp/src/views/projects/translations/BatchOperations/types.ts +++ b/webapp/src/views/projects/translations/BatchOperations/types.ts @@ -6,7 +6,9 @@ export type BatchActions = | 'pre_translate' | 'mark_as_reviewed' | 'mark_as_translated' - | 'create_task' + | 'task_create' + | 'task_add_keys' + | 'task_remove_keys' | 'add_tags' | 'remove_tags' | 'change_namespace'