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'