diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/TaskTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/TaskTestData.kt index 764f9b4e75..8082d6a88b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/TaskTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/TaskTestData.kt @@ -2,10 +2,7 @@ package io.tolgee.development.testDataBuilder.data import io.tolgee.development.testDataBuilder.builders.* import io.tolgee.model.Language -import io.tolgee.model.enums.OrganizationRoleType -import io.tolgee.model.enums.ProjectPermissionType -import io.tolgee.model.enums.Scope -import io.tolgee.model.enums.TaskType +import io.tolgee.model.enums.* import io.tolgee.model.task.TaskKey class TaskTestData : BaseTestData("tasksTestUser", "Project with tasks") { @@ -234,6 +231,40 @@ class TaskTestData : BaseTestData("tasksTestUser", "Project with tasks") { } } + fun addTaskInState( + name: String, + state: TaskState, + type: TaskType, + number: Long, + ) { + projectBuilder.apply { + blockedTask = + addTask { + this.number = number + this.name = name + this.type = type + assignees = + mutableSetOf( + projectUser.self, + user, + ) + project = projectBuilder.self + language = englishLanguage + author = projectUser.self + this.state = state + } + + keysInTask.forEach { it -> + addTaskKey { + task = blockedTask.self + key = it.self + done = true + author = user + } + } + } + } + fun createManyOutOfTaskKeys(): List { val keys = (1 until 200).map { diff --git a/backend/data/src/main/kotlin/io/tolgee/model/enums/TaskState.kt b/backend/data/src/main/kotlin/io/tolgee/model/enums/TaskState.kt index 2f4a18c98f..2d363eca2a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/enums/TaskState.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/TaskState.kt @@ -3,6 +3,6 @@ package io.tolgee.model.enums enum class TaskState { NEW, IN_PROGRESS, - DONE, - CLOSED, + FINISHED, + CANCELED, } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/notifications/NotificationType.kt b/backend/data/src/main/kotlin/io/tolgee/model/notifications/NotificationType.kt index 9c2fdd6fce..85671a929f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/notifications/NotificationType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/notifications/NotificationType.kt @@ -5,8 +5,8 @@ import io.tolgee.model.notifications.NotificationTypeGroup.TASKS enum class NotificationType(val group: NotificationTypeGroup) { TASK_ASSIGNED(TASKS), - TASK_COMPLETED(TASKS), - TASK_CLOSED(TASKS), + TASK_FINISHED(TASKS), + TASK_CANCELED(TASKS), MFA_ENABLED(ACCOUNT_SECURITY), MFA_DISABLED(ACCOUNT_SECURITY), PASSWORD_CHANGED(ACCOUNT_SECURITY), diff --git a/backend/data/src/main/kotlin/io/tolgee/service/notification/EmailNotificationComposer.kt b/backend/data/src/main/kotlin/io/tolgee/service/notification/EmailNotificationComposer.kt index 48f7c43f6e..c7315b722b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/notification/EmailNotificationComposer.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/notification/EmailNotificationComposer.kt @@ -18,8 +18,8 @@ class EmailNotificationComposer( fun composeEmailText(notification: Notification) = when (notification.type) { NotificationType.TASK_ASSIGNED, - NotificationType.TASK_COMPLETED, - NotificationType.TASK_CLOSED, + NotificationType.TASK_FINISHED, + NotificationType.TASK_CANCELED, -> taskEmailComposer NotificationType.MFA_ENABLED, NotificationType.MFA_DISABLED, diff --git a/backend/data/src/main/resources/I18n_en.properties b/backend/data/src/main/resources/I18n_en.properties index 83d318e9b8..713a3522a8 100644 --- a/backend/data/src/main/resources/I18n_en.properties +++ b/backend/data/src/main/resources/I18n_en.properties @@ -94,8 +94,8 @@ Tolgee notifications.email.view-in-tolgee-link=View in Tolgee notifications.email.subject.TASK_ASSIGNED=Task has been assigned to you -notifications.email.subject.TASK_COMPLETED=Task has been completed -notifications.email.subject.TASK_CLOSED=Task has been closed +notifications.email.subject.TASK_FINISHED=Task has been finished +notifications.email.subject.TASK_CANCELED=Task has been canceled notifications.email.subject.MFA_ENABLED=Multi-factor authentication has been enabled for your account notifications.email.subject.MFA_DISABLED=Multi-factor authentication has been disabled for your account notifications.email.subject.PASSWORD_CHANGED=Password has been changed for your account @@ -104,8 +104,8 @@ notifications.email.task-type.TRANSLATE=translate notifications.email.task-type.REVIEW=review notifications.email.task-header.TASK_ASSIGNED=You''ve been assigned to a task {0} of type {1}. -notifications.email.task-header.TASK_COMPLETED=Task {0} of type {1} you''ve created has been completed. -notifications.email.task-header.TASK_CLOSED=Task {0} of type {1} you''ve created has been closed. +notifications.email.task-header.TASK_FINISHED=Task {0} of type {1} you''ve created has been finished. +notifications.email.task-header.TASK_CANCELED=Task {0} of type {1} you''ve created has been canceled. notifications.email.my-tasks-link=Check your tasks here. notifications.email.security-settings-link=Check your security settings here. diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 5ab36ee4cb..9a86821743 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -4167,4 +4167,16 @@ + + + UPDATE task SET state = 'CANCELED' WHERE state = 'CLOSED'; + UPDATE task SET state = 'FINISHED' WHERE state = 'DONE'; + + + + + UPDATE notification SET type = 'TASK_CANCELED' WHERE type = 'TASK_CLOSED'; + UPDATE notification SET type = 'TASK_FINISHED' WHERE type = 'TASK_COMPLETED'; + + diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/TaskE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/TaskE2eDataController.kt index da7051639e..26d777c019 100644 --- a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/TaskE2eDataController.kt +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/TaskE2eDataController.kt @@ -3,6 +3,8 @@ package io.tolgee.controllers.internal.e2eData import io.swagger.v3.oas.annotations.Hidden import io.tolgee.development.testDataBuilder.builders.TestDataBuilder import io.tolgee.development.testDataBuilder.data.TaskTestData +import io.tolgee.model.enums.TaskState +import io.tolgee.model.enums.TaskType import org.springframework.transaction.annotation.Transactional import org.springframework.web.bind.annotation.CrossOrigin import org.springframework.web.bind.annotation.GetMapping @@ -17,9 +19,13 @@ import org.springframework.web.bind.annotation.RestController class TaskE2eDataController() : AbstractE2eDataController() { @GetMapping(value = ["/generate"]) @Transactional - fun generateBasicTestData() { + fun generateBasicTestData(): StandardTestDataResult { val data = TaskTestData() + data.addBlockedTask() + data.addTaskInState("Canceled review task", TaskState.CANCELED, TaskType.REVIEW, 4) + data.addTaskInState("Finished review task", TaskState.FINISHED, TaskType.REVIEW, 5) testDataService.saveTestData(data.root) + return getStandardResult(data.root) } override val testData: TestDataBuilder diff --git a/e2e/cypress/common/permissions/translations.ts b/e2e/cypress/common/permissions/translations.ts index 743f50464a..35e95cd260 100644 --- a/e2e/cypress/common/permissions/translations.ts +++ b/e2e/cypress/common/permissions/translations.ts @@ -38,9 +38,15 @@ export function testTranslations({ project, languages }: ProjectInfo) { .findDcy('translations-task-indicator') .should('be.visible') .trigger('mouseover'); - cy.gcy('task-tooltip-content') - .contains('Assigned translate task') - .should('be.visible'); + if (scopes.includes('tasks.view')) { + cy.gcy('task-tooltip-content') + .contains('Unassigned review task') + .should('be.visible'); + } else { + cy.gcy('task-tooltip-content') + .contains('You have no access to view this task') + .should('be.visible'); + } getCell('English text 1') .findDcy('translations-task-indicator') .trigger('mouseout'); diff --git a/e2e/cypress/e2e/notifications/notifications.cy.ts b/e2e/cypress/e2e/notifications/notifications.cy.ts index 364d803252..2bf72162bc 100644 --- a/e2e/cypress/e2e/notifications/notifications.cy.ts +++ b/e2e/cypress/e2e/notifications/notifications.cy.ts @@ -90,15 +90,18 @@ describe('notifications', () => { ); targetPageShouldHaveInUrl('/translations?task='); - generateNotification(userId, 'TASK_COMPLETED'); + generateNotification(userId, 'TASK_FINISHED'); assertNewestEmail( - 'Task has been completed', - "you've created has been completed" + 'Task has been finished', + "you've created has been finished" ); targetPageShouldHaveInUrl('/translations?task='); - generateNotification(userId, 'TASK_CLOSED'); - assertNewestEmail('Task has been closed', "you've created has been closed"); + generateNotification(userId, 'TASK_CANCELED'); + assertNewestEmail( + 'Task has been canceled', + "you've created has been canceled" + ); targetPageShouldHaveInUrl('/translations?task='); generateNotification(userId, 'MFA_ENABLED'); diff --git a/e2e/cypress/e2e/tasks/myTasks.cy.ts b/e2e/cypress/e2e/tasks/myTasks.cy.ts index dae732caf2..6f5d55fb1a 100644 --- a/e2e/cypress/e2e/tasks/myTasks.cy.ts +++ b/e2e/cypress/e2e/tasks/myTasks.cy.ts @@ -61,7 +61,7 @@ describe('my tasks', () => { editCell('Translation 0', 'New translation 0'); getCell('Translation 1').findDcy('translations-cell-task-button').click(); cy.get('#alert-dialog-title') - .contains('All items in the task are finished') + .contains('All items in the task are done') .should('be.visible'); cy.gcy('global-confirmation-confirm').click(); visitMyTasks(); @@ -69,7 +69,7 @@ describe('my tasks', () => { .contains('Translate task') .closestDcy('task-item') .findDcy('task-state') - .should('contain', 'Done'); + .should('contain', 'Finished'); }); it("Organization member can finish Review task (permissions elevated because he's assigned)", () => { @@ -79,7 +79,7 @@ describe('my tasks', () => { cy.waitForDom(); getCell('Překlad 1').findDcy('translations-cell-task-button').click(); cy.get('#alert-dialog-title') - .contains('All items in the task are finished') + .contains('All items in the task are done') .should('be.visible'); cy.gcy('global-confirmation-confirm').click(); visitMyTasks(); @@ -87,7 +87,7 @@ describe('my tasks', () => { .contains('Review task') .closestDcy('task-item') .findDcy('task-state') - .should('contain', 'Done'); + .should('contain', 'Finished'); }); it("Organization member can add comments (permissions elevated because he's assigned)", () => { diff --git a/e2e/cypress/e2e/tasks/tasksEditAlerts.cy.ts b/e2e/cypress/e2e/tasks/tasksEditAlerts.cy.ts new file mode 100644 index 0000000000..59db7f24ef --- /dev/null +++ b/e2e/cypress/e2e/tasks/tasksEditAlerts.cy.ts @@ -0,0 +1,75 @@ +import { login } from '../../common/apiCalls/common'; +import { TestDataStandardResponse } from '../../common/apiCalls/testData/generator'; +import { tasks } from '../../common/apiCalls/testData/testData'; +import { visitMyTasks, visitTasks } from '../../common/tasks'; +import { getCell, visitTranslations } from '../../common/translations'; + +describe('tasks edit alerts', () => { + let testData: TestDataStandardResponse; + beforeEach(() => { + tasks.clean({ failOnStatusCode: false }); + tasks + .generate() + .then((r) => r.body) + .then((data) => { + testData = data; + }); + }); + + function loginAsUser(user: string) { + login( + testData.users.find((u) => [u.username, u.name].includes(user))?.username + ); + } + function goToProject(name: string) { + visitTranslations(testData.projects.find((p) => p.name === name)?.id); + } + + it('user not assigned to a task', () => { + loginAsUser('Organization owner'); + goToProject('Project with tasks'); + getCell('Překlad 1').click(); + + cy.gcy('task-info-message').contains('not assigned to you'); + }); + + it('user assigned to task, but not in task view', () => { + loginAsUser('Tasks test user'); + goToProject('Project with tasks'); + getCell('Překlad 1').click(); + + cy.gcy('task-info-message').contains('This is part of a review task'); + }); + + it('info about blocked task', () => { + loginAsUser('Tasks test user'); + visitMyTasks(); + cy.gcy('task-item').contains('Blocked task').click(); + getCell('Translation 1').click(); + cy.gcy('task-info-message').contains('is blocked'); + }); + + it('info about finished task', () => { + loginAsUser('admin'); + visitTasks( + testData.projects.find((p) => p.name === 'Project with tasks')?.id + ); + + cy.gcy('task-item').contains('Finished review task').click(); + + getCell('Translation 1').click(); + cy.gcy('task-info-message').contains('is finished'); + }); + + it('info about canceled task', () => { + loginAsUser('admin'); + visitTasks( + testData.projects.find((p) => p.name === 'Project with tasks')?.id + ); + + cy.gcy('task-item').contains('Canceled review task').click(); + + getCell('Translation 1').click(); + cy.gcy('task-info-message').contains('is canceled'); + }); +}); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index e50057588f..cdaa598a44 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -542,6 +542,7 @@ declare namespace DataCy { "task-detail" | "task-detail-author" | "task-detail-characters" | + "task-detail-close" | "task-detail-closed-at" | "task-detail-created-at" | "task-detail-download-report" | @@ -554,10 +555,14 @@ declare namespace DataCy { "task-detail-user-keys" | "task-detail-user-words" | "task-detail-words" | + "task-info-message" | "task-item" | "task-item-detail" | "task-item-menu" | "task-label-name" | + "task-menu-item-cancel-task" | + "task-menu-item-mark-as-done" | + "task-menu-item-reopen" | "task-number" | "task-preview" | "task-preview-alert" | diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/TaskController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/TaskController.kt index 88176e08e1..9e94c7c5be 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/TaskController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/TaskController.kt @@ -176,12 +176,12 @@ class TaskController( ): TaskModel { // users can only finish tasks assigned to them securityService.hasTaskEditScopeOrIsAssigned(projectHolder.project.id, taskNumber) - val task = taskService.setTaskState(projectHolder.project.id, taskNumber, TaskState.DONE) + val task = taskService.setTaskState(projectHolder.project.id, taskNumber, TaskState.FINISHED) return taskModelAssembler.toModel(task) } @PutMapping("/{taskNumber}/close") - @Operation(summary = "Close task") + @Operation(summary = "Close task", deprecated = true) @RequiresProjectPermissions([Scope.TASKS_EDIT]) @AllowApiAccess @RequestActivity(ActivityType.TASK_CLOSE) @@ -190,7 +190,21 @@ class TaskController( @PathVariable taskNumber: Long, ): TaskModel { - val task = taskService.setTaskState(projectHolder.project.id, taskNumber, TaskState.CLOSED) + val task = taskService.setTaskState(projectHolder.project.id, taskNumber, TaskState.CANCELED) + return taskModelAssembler.toModel(task) + } + + @PutMapping("/{taskNumber}/cancel") + @Operation(summary = "Close task") + @RequiresProjectPermissions([Scope.TASKS_EDIT]) + @AllowApiAccess + @RequestActivity(ActivityType.TASK_CLOSE) + @OpenApiOrderExtension(7) + fun cancelTask( + @PathVariable + taskNumber: Long, + ): TaskModel { + val task = taskService.setTaskState(projectHolder.project.id, taskNumber, TaskState.CANCELED) return taskModelAssembler.toModel(task) } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/task/TaskFilters.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/task/TaskFilters.kt index e1ee4dd057..35a3ce12a9 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/task/TaskFilters.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/task/TaskFilters.kt @@ -61,12 +61,6 @@ open class TaskFilters { ) var filterAgency: List? = null - @Deprecated("Confusing logic and naming", ReplaceWith("filterNotClosedBefore")) - @field:Parameter( - description = """Exclude "done" tasks which are older than specified timestamp""", - ) - var filterDoneMinClosedAt: Long? = null - @field:Parameter( description = """Exclude tasks which were closed before specified timestamp""", ) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TaskRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TaskRepository.kt index b7c5c2fd0d..e5dba342d2 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TaskRepository.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TaskRepository.kt @@ -75,11 +75,6 @@ private const val TASK_FILTERS = """ where element(tt).key.id in :#{#filters.filterKey} ) ) - and ( - tk.state != 'DONE' - or :#{#filters.filterDoneMinClosedAt} is null - or tk.closedAt > :#{#filters.filterDoneMinClosedAt} - ) and ( :#{#filters.filterNotClosedBefore} is null or tk.closedAt is null @@ -129,7 +124,7 @@ interface TaskRepository : JpaRepository { @Query( nativeQuery = true, value = """ - select distinct on (l.id, tt.key_id) + select distinct on (l.id, tt.key_id, taskAssigned) tt.key_id as keyId, l.id as languageId, l.tag as languageTag, @@ -146,7 +141,7 @@ interface TaskRepository : JpaRepository { tt.key_id in :keyIds and l.deleted_at is null and (t.state = 'IN_PROGRESS' or t.state = 'NEW') - order by l.id, tt.key_id, t.type desc, t.id desc + order by l.id, tt.key_id, taskAssigned, t.type desc, t.id desc """, ) fun getByKeyId( diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TaskService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TaskService.kt index db4e6bd144..ac848f575c 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TaskService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TaskService.kt @@ -232,7 +232,7 @@ class TaskService( ): TaskWithScopeView { val task = findByNumber(projectId, taskNumber) val taskWithScope = getTaskWithScope(task) - if (state == TaskState.DONE && taskWithScope.doneItems != taskWithScope.totalItems) { + if (state == TaskState.FINISHED && taskWithScope.doneItems != taskWithScope.totalItems) { throw BadRequestException(Message.TASK_NOT_FINISHED) } if (state == TaskState.NEW || state == TaskState.IN_PROGRESS) { @@ -297,7 +297,7 @@ class TaskService( ): UpdateTaskKeyResponse { val task = findByNumber(projectId, taskNumber) - if (task.state == TaskState.CLOSED || task.state == TaskState.DONE) { + if (task.state == TaskState.CANCELED || task.state == TaskState.FINISHED) { throw BadRequestException(Message.TASK_NOT_OPEN) } @@ -559,8 +559,8 @@ class TaskService( private fun createNotificationIfApplicable(task: Task) { val notificationType = when (task.state) { - TaskState.DONE -> NotificationType.TASK_COMPLETED - TaskState.CLOSED -> NotificationType.TASK_CLOSED + TaskState.FINISHED -> NotificationType.TASK_FINISHED + TaskState.CANCELED -> NotificationType.TASK_CANCELED else -> return } diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerTest.kt index 2570a8a553..399f62f6de 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/task/TaskControllerTest.kt @@ -11,8 +11,8 @@ import io.tolgee.fixtures.andIsBadRequest import io.tolgee.fixtures.andIsOk import io.tolgee.fixtures.node import io.tolgee.model.enums.TaskType -import io.tolgee.model.notifications.NotificationType.TASK_CLOSED -import io.tolgee.model.notifications.NotificationType.TASK_COMPLETED +import io.tolgee.model.notifications.NotificationType.TASK_CANCELED +import io.tolgee.model.notifications.NotificationType.TASK_FINISHED import io.tolgee.model.task.TaskKey import io.tolgee.repository.TaskKeyRepository import io.tolgee.testing.NotificationTestUtil @@ -364,17 +364,17 @@ class TaskControllerTest : ProjectAuthControllerTest("/v2/projects/") { @Test @ProjectJWTAuthTestMethod - fun `close, reopen and complete task, check notifications`() { + fun `cancel, reopen and finish task, check notifications`() { performProjectAuthPut( - "tasks/${testData.translateTask.self.number}/close", + "tasks/${testData.translateTask.self.number}/cancel", ).andIsOk.andAssertThatJson { - node("state").isEqualTo("CLOSED") + node("state").isEqualTo("CANCELED") } notificationUtil.newestInAppNotification().also { - assertThat(it.type).isEqualTo(TASK_CLOSED) + assertThat(it.type).isEqualTo(TASK_CANCELED) assertThat(it.linkedTask?.id).isEqualTo(testData.translateTask.self.id) } - assertThat(notificationUtil.newestEmailNotification()).contains("has been closed") + assertThat(notificationUtil.newestEmailNotification()).contains("has been canceled") performProjectAuthPut( "tasks/${testData.translateTask.self.number}/reopen", ).andIsOk.andAssertThatJson { @@ -384,23 +384,23 @@ class TaskControllerTest : ProjectAuthControllerTest("/v2/projects/") { performProjectAuthPut( "tasks/${testData.translateTask.self.number}/finish", ).andIsOk.andAssertThatJson { - node("state").isEqualTo("DONE") + node("state").isEqualTo("FINISHED") } notificationUtil.newestInAppNotification().also { - assertThat(it.type).isEqualTo(TASK_COMPLETED) + assertThat(it.type).isEqualTo(TASK_FINISHED) assertThat(it.linkedTask?.id).isEqualTo(testData.translateTask.self.id) } - assertThat(notificationUtil.newestEmailNotification()).contains("has been completed") + assertThat(notificationUtil.newestEmailNotification()).contains("has been finished") } @Test @ProjectJWTAuthTestMethod - fun `closed tasks can be filtered out by timestamp`() { + fun `canceled tasks can be filtered out by timestamp`() { val timeBeforeCreation = System.currentTimeMillis() performProjectAuthPut( - "tasks/${testData.translateTask.self.number}/close", + "tasks/${testData.translateTask.self.number}/cancel", ).andIsOk.andAssertThatJson { - node("state").isEqualTo("CLOSED") + node("state").isEqualTo("CANCELED") } val timeAfterCreation = System.currentTimeMillis() diff --git a/webapp/src/component/layout/Notifications/NotificationTypeMap.tsx b/webapp/src/component/layout/Notifications/NotificationTypeMap.tsx index cb0c5b5317..624b97f2cd 100644 --- a/webapp/src/component/layout/Notifications/NotificationTypeMap.tsx +++ b/webapp/src/component/layout/Notifications/NotificationTypeMap.tsx @@ -1,6 +1,6 @@ import { TaskAssignedItem } from 'tg.component/layout/Notifications/TaskAssignedItem'; -import { TaskCompletedItem } from 'tg.component/layout/Notifications/TaskCompletedItem'; -import { TaskClosedItem } from 'tg.component/layout/Notifications/TaskClosedItem'; +import { TaskFinishedItem } from 'tg.component/layout/Notifications/TaskFinishedItem'; +import { TaskCanceledItem } from 'tg.component/layout/Notifications/TaskCanceledItem'; import { MfaEnabledItem } from 'tg.component/layout/Notifications/MfaEnabledItem'; import { MfaDisabledItem } from 'tg.component/layout/Notifications/MfaDisabledItem'; import { PasswordChangedItem } from 'tg.component/layout/Notifications/PasswordChangedItem'; @@ -15,8 +15,8 @@ type NotificationsComponentMap = Record< export const notificationComponents: NotificationsComponentMap = { TASK_ASSIGNED: TaskAssignedItem, - TASK_COMPLETED: TaskCompletedItem, - TASK_CLOSED: TaskClosedItem, + TASK_FINISHED: TaskFinishedItem, + TASK_CANCELED: TaskCanceledItem, MFA_ENABLED: MfaEnabledItem, MFA_DISABLED: MfaDisabledItem, PASSWORD_CHANGED: PasswordChangedItem, diff --git a/webapp/src/component/layout/Notifications/NotificationsPopup.tsx b/webapp/src/component/layout/Notifications/NotificationsPopup.tsx index fa0c0c188e..65b2fda8e6 100644 --- a/webapp/src/component/layout/Notifications/NotificationsPopup.tsx +++ b/webapp/src/component/layout/Notifications/NotificationsPopup.tsx @@ -181,9 +181,12 @@ export const NotificationsPopup: React.FC = ({ {notifications?.map((notification, i) => { - const Component = notificationComponents[notification.type]!; + const Component = notificationComponents[notification.type]; + return ( - + Boolean(Component) && ( + + ) ); })} {notifications?.length === 0 && ( diff --git a/webapp/src/component/layout/Notifications/TaskClosedItem.tsx b/webapp/src/component/layout/Notifications/TaskCanceledItem.tsx similarity index 65% rename from webapp/src/component/layout/Notifications/TaskClosedItem.tsx rename to webapp/src/component/layout/Notifications/TaskCanceledItem.tsx index 44797c7ec9..6bf3d227e7 100644 --- a/webapp/src/component/layout/Notifications/TaskClosedItem.tsx +++ b/webapp/src/component/layout/Notifications/TaskCanceledItem.tsx @@ -5,15 +5,15 @@ import { TaskItemProps, } from 'tg.component/layout/Notifications/TaskItem'; -type TaskClosedItemProps = TaskItemProps; +type Props = TaskItemProps; -export const TaskClosedItem: FunctionComponent = ({ +export const TaskCanceledItem: FunctionComponent = ({ notification, ...props }) => { return ( - + ); }; diff --git a/webapp/src/component/layout/Notifications/TaskCompletedItem.tsx b/webapp/src/component/layout/Notifications/TaskFinishedItem.tsx similarity index 64% rename from webapp/src/component/layout/Notifications/TaskCompletedItem.tsx rename to webapp/src/component/layout/Notifications/TaskFinishedItem.tsx index 90c2d931ea..7b4fa986ff 100644 --- a/webapp/src/component/layout/Notifications/TaskCompletedItem.tsx +++ b/webapp/src/component/layout/Notifications/TaskFinishedItem.tsx @@ -5,15 +5,15 @@ import { TaskItemProps, } from 'tg.component/layout/Notifications/TaskItem'; -type TaskCompletedItemProps = TaskItemProps; +type Props = TaskItemProps; -export const TaskCompletedItem: FunctionComponent = ({ +export const TaskFinishedItem: FunctionComponent = ({ notification, ...props }) => { return ( - + ); }; diff --git a/webapp/src/component/task/TaskState.tsx b/webapp/src/component/task/TaskState.tsx index 7e6545dae7..89c30ea869 100644 --- a/webapp/src/component/task/TaskState.tsx +++ b/webapp/src/component/task/TaskState.tsx @@ -16,7 +16,7 @@ export const useStateColor = () => { const theme = useTheme(); return (state: TaskState) => - state === 'DONE' + state === 'FINISHED' ? theme.palette.tokens._components.progressbar.task.done : state === 'IN_PROGRESS' ? theme.palette.tokens._components.progressbar.task.inProgress diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index e1c91b2dde..033147f7f7 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -409,7 +409,7 @@ export enum QUERY { TRANSLATIONS_PREFILTERS_ACTIVITY = 'activity', TRANSLATIONS_PREFILTERS_FAILED_JOB = 'failedJob', TRANSLATIONS_PREFILTERS_TASK = 'task', - TRANSLATIONS_PREFILTERS_TASK_HIDE_DONE = 'taskHideDone', + TRANSLATIONS_PREFILTERS_TASK_HIDE_CLOSED = 'taskHideClosed', TRANSLATIONS_TASK_DETAIL = 'taskDetail', TASKS_FILTERS_SHOW_ALL = 'showAll', } diff --git a/webapp/src/ee/task/components/BoardItem.tsx b/webapp/src/ee/task/components/BoardItem.tsx index 8eb232cc50..bf3f993cb7 100644 --- a/webapp/src/ee/task/components/BoardItem.tsx +++ b/webapp/src/ee/task/components/BoardItem.tsx @@ -125,7 +125,7 @@ export const BoardItem = ({ - {task.state === 'IN_PROGRESS' ? ( + {['IN_PROGRESS', 'NEW'].includes(task.state) ? ( ) : ( diff --git a/webapp/src/ee/task/components/PrefilterTask.tsx b/webapp/src/ee/task/components/PrefilterTask.tsx index 04cc78464e..85939cb414 100644 --- a/webapp/src/ee/task/components/PrefilterTask.tsx +++ b/webapp/src/ee/task/components/PrefilterTask.tsx @@ -15,12 +15,12 @@ import { PrefilterContainer } from 'tg.views/projects/translations/prefilters/Co import { useUrlSearchState } from 'tg.hooks/useUrlSearchState'; import { useUser } from 'tg.globalContext/helpers'; import { usePrefilter } from 'tg.views/projects/translations/prefilters/usePrefilter'; -import { TaskState } from 'tg.component/task/TaskState'; import { TaskTooltip } from './TaskTooltip'; import { TaskLabel } from './TaskLabel'; import { PrefilterTaskProps } from '../../../eeSetup/EeModuleType'; import { TASK_ACTIVE_STATES } from 'tg.component/task/taskActiveStates'; import { QUERY } from 'tg.constants/links'; +import { PrefilterTaskHideDoneSwitch } from './PrefilterTaskHideDoneSwitch'; const StyledWarning = styled('div')` display: flex; @@ -57,7 +57,7 @@ export const PrefilterTask = ({ taskNumber }: PrefilterTaskProps) => { const [_, setTaskDetail] = useUrlSearchState(QUERY.TRANSLATIONS_TASK_DETAIL); - const { clear } = usePrefilter(); + const prefilter = usePrefilter(); function handleShowDetails() { setTaskDetail(String(taskNumber)); @@ -80,11 +80,7 @@ export const PrefilterTask = ({ taskNumber }: PrefilterTaskProps) => { {' '} {blockingTasksLoadable.data.map((taskNumber, i) => ( - + #{taskNumber} {i !== blockingTasksLoadable.data.length - 1 && ', '} @@ -104,7 +100,7 @@ export const PrefilterTask = ({ taskNumber }: PrefilterTaskProps) => { } closeButton={ - + @@ -123,15 +119,17 @@ export const PrefilterTask = ({ taskNumber }: PrefilterTaskProps) => { - {!isActive && } - {alert ? ( - - - {alert} - - ) : null} } + controls={} + alert={ + Boolean(alert) && ( + + + {alert} + + ) + } /> ); diff --git a/webapp/src/ee/task/components/PrefilterTaskHideDoneSwitch.tsx b/webapp/src/ee/task/components/PrefilterTaskHideDoneSwitch.tsx index 827f1279da..06c59f0e31 100644 --- a/webapp/src/ee/task/components/PrefilterTaskHideDoneSwitch.tsx +++ b/webapp/src/ee/task/components/PrefilterTaskHideDoneSwitch.tsx @@ -17,7 +17,7 @@ type Props = { export const PrefilterTaskHideDoneSwitch = ({ sx }: Props) => { const [taskHideDone, setTaskHideDone] = useUrlSearchState( - QUERY.TRANSLATIONS_PREFILTERS_TASK_HIDE_DONE + QUERY.TRANSLATIONS_PREFILTERS_TASK_HIDE_CLOSED ); const { t } = useTranslate(); diff --git a/webapp/src/ee/task/components/TaskAllDonePlaceholder.tsx b/webapp/src/ee/task/components/TaskAllDonePlaceholder.tsx index 631223f420..51fd16e72c 100644 --- a/webapp/src/ee/task/components/TaskAllDonePlaceholder.tsx +++ b/webapp/src/ee/task/components/TaskAllDonePlaceholder.tsx @@ -15,7 +15,7 @@ type Props = { export const TaskAllDonePlaceholder = ({ taskNumber, projectId }: Props) => { const [_, setTaskHideDone] = useUrlSearchState( - QUERY.TRANSLATIONS_PREFILTERS_TASK_HIDE_DONE + QUERY.TRANSLATIONS_PREFILTERS_TASK_HIDE_CLOSED ); const { finishTask } = useTranslationsActions(); const user = useUser(); @@ -40,7 +40,9 @@ export const TaskAllDonePlaceholder = ({ taskNumber, projectId }: Props) => { height="0px" hint={ isAssigned && - !['DONE', 'CLOSED'].includes(taskLoadable.data?.state as TaskState) && ( + !['FINISHED', 'CANCELED'].includes( + taskLoadable.data?.state as TaskState + ) && ( diff --git a/webapp/src/ee/task/components/TaskDetail.tsx b/webapp/src/ee/task/components/TaskDetail.tsx index d996e3145a..97806ceece 100644 --- a/webapp/src/ee/task/components/TaskDetail.tsx +++ b/webapp/src/ee/task/components/TaskDetail.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { Formik } from 'formik'; import { T, useTranslate } from '@tolgee/react'; import { @@ -10,7 +9,7 @@ import { Typography, } from '@mui/material'; import { Link } from 'react-router-dom'; -import { DotsVertical } from '@untitled-ui/icons-react'; +import { X } from '@untitled-ui/icons-react'; import { useApiMutation, useApiQuery } from 'tg.service/http/useQueryApi'; import { TextField } from 'tg.component/common/form/fields/TextField'; @@ -20,7 +19,6 @@ import { messageService } from 'tg.service/MessageService'; import { UserAccount } from 'tg.component/UserAccount'; import { ProjectWithAvatar } from 'tg.component/ProjectWithAvatar'; import { useDateFormatter } from 'tg.hooks/useLocale'; -import { stopAndPrevent } from 'tg.fixtures/eventHandler'; import { components } from 'tg.service/apiSchema.generated'; import { TaskDatePicker } from './TaskDatePicker'; @@ -28,9 +26,9 @@ import { AssigneeSearchSelect } from './assigneeSelect/AssigneeSearchSelect'; import { TaskLabel } from './TaskLabel'; import { TaskInfoItem } from './TaskInfoItem'; import { TaskScope } from './TaskScope'; -import { TaskMenu } from './TaskMenu'; import { BoxLoading } from 'tg.component/common/BoxLoading'; import { getTaskUrl } from 'tg.constants/links'; +import { TaskDetailActions } from './TaskDetailActions'; type TaskModel = components['schemas']['TaskModel']; @@ -77,9 +75,16 @@ const StyledTopPart = styled('div')` const StyledActions = styled('div')` display: flex; - gap: 8px; + gap: 32px; padding-top: 24px; - justify-content: end; + justify-content: space-between; +`; + +const StyledActionGroup = styled('div')` + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: end; `; type Props = { @@ -92,7 +97,6 @@ type Props = { export const TaskDetail = ({ onClose, projectId, taskNumber, task }: Props) => { const { t } = useTranslate(); const formatDate = useDateFormatter(); - const [anchorEl, setAnchorEl] = useState(null); const taskLoadable = useApiQuery({ url: '/v2/projects/{projectId}/tasks/{taskNumber}', @@ -127,10 +131,6 @@ export const TaskDetail = ({ onClose, projectId, taskNumber, task }: Props) => { const canEditTask = scopes.includes('tasks.edit'); - const handleClose = () => { - setAnchorEl(null); - }; - const data = taskLoadable.data ?? task; if (!data && taskLoadable.isLoading) { @@ -154,21 +154,12 @@ export const TaskDetail = ({ onClose, projectId, taskNumber, task }: Props) => { setAnchorEl(e.currentTarget))} - data-cy="task-item-menu" + onClick={onClose} + data-cy="task-detail-close" + size="medium" > - + - )} @@ -301,22 +292,30 @@ export const TaskDetail = ({ onClose, projectId, taskNumber, task }: Props) => { data-cy="task-detail-project" /> - - - {canEditTask && ( - submitForm()} - > - {t('task_detail_submit_button')} - - )} + + + + + {canEditTask && ( + submitForm()} + > + {t('task_detail_submit_button')} + + )} + )} diff --git a/webapp/src/ee/task/components/TaskDetailActions.tsx b/webapp/src/ee/task/components/TaskDetailActions.tsx new file mode 100644 index 0000000000..960180ea4b --- /dev/null +++ b/webapp/src/ee/task/components/TaskDetailActions.tsx @@ -0,0 +1,142 @@ +import { Button } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; + +import { confirmation } from 'tg.hooks/confirmation'; +import { components } from 'tg.service/apiSchema.generated'; +import { Scope } from 'tg.fixtures/permissions'; +import { messageService } from 'tg.service/MessageService'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; + +import { useUser } from 'tg.globalContext/helpers'; +import { TASK_ACTIVE_STATES } from 'tg.component/task/taskActiveStates'; + +type TaskModel = components['schemas']['TaskModel']; + +type Props = { + task: TaskModel; + projectId: number; + projectScopes?: Scope[]; +}; + +export const TaskDetailActions = ({ + task, + projectId, + projectScopes, +}: Props) => { + const user = useUser(); + const cancelMutation = useApiMutation({ + url: '/v2/projects/{projectId}/tasks/{taskNumber}/cancel', + method: 'put', + invalidatePrefix: [ + '/v2/projects/{projectId}/translations', + '/v2/projects/{projectId}/tasks', + '/v2/user-tasks', + ], + }); + + const reopenMutation = useApiMutation({ + url: '/v2/projects/{projectId}/tasks/{taskNumber}/reopen', + method: 'put', + invalidatePrefix: [ + '/v2/projects/{projectId}/translations', + '/v2/projects/{projectId}/tasks', + '/v2/user-tasks', + ], + }); + + const finishMutation = useApiMutation({ + url: '/v2/projects/{projectId}/tasks/{taskNumber}/finish', + method: 'put', + invalidatePrefix: [ + '/v2/projects/{projectId}/translations', + '/v2/projects/{projectId}/tasks', + '/v2/user-tasks', + ], + }); + + const canEditTask = projectScopes?.includes('tasks.edit'); + const canMarkAsDone = + projectScopes?.includes('tasks.edit') || + Boolean(task.assignees.find((u) => u.id === user?.id)); + + function handleClose() { + confirmation({ + title: , + onConfirm() { + cancelMutation.mutate( + { + path: { projectId, taskNumber: task.number }, + }, + { + onSuccess() { + messageService.success(); + }, + } + ); + }, + }); + } + + function handleReopen() { + reopenMutation.mutate( + { + path: { projectId, taskNumber: task.number }, + }, + { + onSuccess() { + messageService.success(); + }, + } + ); + } + + function handleMarkAsDone() { + finishMutation.mutate( + { + path: { projectId, taskNumber: task.number }, + }, + { + onSuccess() { + messageService.success(); + }, + } + ); + } + + const { t } = useTranslate(); + return ( + <> + {TASK_ACTIVE_STATES.includes(task.state) && ( + + )} + + {TASK_ACTIVE_STATES.includes(task.state) ? ( + + ) : ( + + )} + + ); +}; diff --git a/webapp/src/ee/task/components/TaskInfoMessage.tsx b/webapp/src/ee/task/components/TaskInfoMessage.tsx new file mode 100644 index 0000000000..df6728e24c --- /dev/null +++ b/webapp/src/ee/task/components/TaskInfoMessage.tsx @@ -0,0 +1,185 @@ +import { Alert, AlertColor, styled } from '@mui/material'; +import { T } from '@tolgee/react'; +import { components } from 'tg.service/apiSchema.generated'; +import { useProject } from 'tg.hooks/useProject'; +import { TaskTooltip } from './TaskTooltip'; + +type KeyTaskViewModel = components['schemas']['KeyTaskViewModel']; +type TaskModel = components['schemas']['TaskModel']; + +const StyledTaskId = styled('span')` + color: ${({ theme }) => theme.palette.primary.main}; + cursor: default; + text-decoration-line: underline; + text-decoration-style: solid; + text-underline-position: from-font; +`; + +const TaskLink = (props: { task: number }) => { + const project = useProject(); + return ( + + #{props.task} + + ); +}; + +type Props = { + tasks: KeyTaskViewModel[] | undefined; + currentTask: TaskModel | undefined; +}; + +function getMessage( + tasks: KeyTaskViewModel[] | undefined, + currentTask: TaskModel | undefined +): + | { + severity: AlertColor; + content: React.ReactNode; + } + | undefined { + const firstTask = tasks?.[0]; + const userAssignedTask = tasks?.find((t) => t.userAssigned); + + if (currentTask?.state === 'FINISHED') { + return { + severity: 'error', + content: ( + , + }} + /> + ), + }; + } else if (currentTask?.state === 'CANCELED') { + return { + severity: 'error', + content: ( + , + }} + /> + ), + }; + } + + if (currentTask && firstTask && firstTask.number !== currentTask.number) { + return { + severity: 'error', + content: ( + , + blockingTask: , + }} + /> + ), + }; + } + + if (currentTask && firstTask && firstTask.number !== currentTask.number) { + return { + severity: 'error', + content: ( + , + blockingTask: , + }} + /> + ), + }; + } + + if ( + firstTask && + !firstTask?.userAssigned && + userAssignedTask && + currentTask?.number !== firstTask.number + ) { + return { + severity: 'error', + content: ( + , + blockingTask: , + }} + /> + ), + }; + } + + if (firstTask && firstTask.userAssigned && !currentTask) { + if (firstTask.type === 'TRANSLATE') { + return { + severity: 'info', + content: ( + }} + /> + ), + }; + } else { + return { + severity: 'info', + content: ( + }} + /> + ), + }; + } + } + + if (firstTask && !firstTask?.userAssigned) { + if (firstTask.type === 'TRANSLATE') { + return { + severity: 'error', + content: ( + }} + /> + ), + }; + } else { + return { + severity: 'error', + content: ( + }} + /> + ), + }; + } + } +} + +export const TaskInfoMessage = ({ tasks, currentTask }: Props) => { + const message = getMessage(tasks, currentTask); + + if (message) { + return ( + + {message.content} + + ); + } + + return null; +}; diff --git a/webapp/src/ee/task/components/TaskItem.tsx b/webapp/src/ee/task/components/TaskItem.tsx index 742624edd9..99d02a7f8f 100644 --- a/webapp/src/ee/task/components/TaskItem.tsx +++ b/webapp/src/ee/task/components/TaskItem.tsx @@ -97,7 +97,7 @@ export const TaskItem = ({ {t('task_word_count', { value: task.baseWordCount })} - {task.state === 'IN_PROGRESS' ? ( + {['IN_PROGRESS', 'NEW'].includes(task.state) ? ( ) : ( diff --git a/webapp/src/ee/task/components/TaskLabel.tsx b/webapp/src/ee/task/components/TaskLabel.tsx index 0b9d9385d3..ed21f48551 100644 --- a/webapp/src/ee/task/components/TaskLabel.tsx +++ b/webapp/src/ee/task/components/TaskLabel.tsx @@ -5,6 +5,7 @@ import { TaskNumber, TaskNumberWithLink } from './TaskId'; import { TaskTypeChip } from 'tg.component/task/TaskTypeChip'; import { AgencyLabel } from 'tg.ee'; import { useTranslate } from '@tolgee/react'; +import clsx from 'clsx'; type TaskModel = components['schemas']['TaskModel']; type SimpleProjectModel = components['schemas']['SimpleProjectModel']; @@ -16,6 +17,14 @@ const StyledContainer = styled(Box)` justify-content: start; gap: 8px; font-size: 16px; + + &.canceled { + opacity: 0.6; + filter: grayscale(1); + } + &.finished { + opacity: 0.6; + } `; const StyledTaskName = styled(Box)` @@ -43,7 +52,15 @@ export const TaskLabel = ({ }: Props) => { const { t } = useTranslate(); return ( - + >(); const [taskDetail, setTaskDetail] = useState(); const closeMutation = useApiMutation({ - url: '/v2/projects/{projectId}/tasks/{taskNumber}/close', + url: '/v2/projects/{projectId}/tasks/{taskNumber}/cancel', method: 'put', invalidatePrefix: [ '/v2/projects/{projectId}/translations', @@ -113,7 +113,7 @@ export const TaskMenu = ({ function handleClose() { confirmation({ - title: , + title: , onConfirm() { onClose(); closeMutation.mutate( @@ -122,7 +122,7 @@ export const TaskMenu = ({ }, { onSuccess() { - messageService.success(); + messageService.success(); }, } ); @@ -216,17 +216,26 @@ export const TaskMenu = ({ - {t('task_menu_mark_as_done')} + {t('task_menu_mark_as_finished')} ) : ( - + {t('task_menu_mark_as_in_progress')} )} {TASK_ACTIVE_STATES.includes(task.state) && ( - - {t('task_menu_close_task')} + + {t('task_menu_cancel_task')} )} {!hideTaskDetail && ( diff --git a/webapp/src/ee/task/components/TaskReference.tsx b/webapp/src/ee/task/components/TaskReference.tsx index 2a6bd46d52..4a74a5a462 100644 --- a/webapp/src/ee/task/components/TaskReference.tsx +++ b/webapp/src/ee/task/components/TaskReference.tsx @@ -15,11 +15,7 @@ export const TaskReference: React.FC = ({ data }) => { const project = useProject(); return ( - + ; actions?: Action[] | React.ReactNode | ((task: TaskModel) => React.ReactNode); - newTaskActions: boolean; } & Omit, 'title'>; export const TaskTooltip = ({ @@ -29,7 +27,6 @@ export const TaskTooltip = ({ project, children, actions = ['open', 'detail'], - newTaskActions, ...tooltipProps }: Props) => { const [taskDetailData, setTaskDetailData] = useState(); @@ -66,7 +63,7 @@ export const TaskTooltip = ({ size="small" onClick={() => setTaskDetailData(task)} > - + )} diff --git a/webapp/src/ee/task/components/TaskTooltipContent.tsx b/webapp/src/ee/task/components/TaskTooltipContent.tsx index 309f7d2014..cec98bf8f0 100644 --- a/webapp/src/ee/task/components/TaskTooltipContent.tsx +++ b/webapp/src/ee/task/components/TaskTooltipContent.tsx @@ -20,7 +20,7 @@ type TaskModel = components['schemas']['TaskModel']; const StyledProgress = styled(Box)` display: flex; - justify-content: space-between + justify-content: space-between; align-items: center; gap: 24px; `; diff --git a/webapp/src/ee/task/components/TasksBoard.tsx b/webapp/src/ee/task/components/TasksBoard.tsx index 99034972ba..cbe1c999ad 100644 --- a/webapp/src/ee/task/components/TasksBoard.tsx +++ b/webapp/src/ee/task/components/TasksBoard.tsx @@ -4,13 +4,12 @@ import { LoadingButton } from '@mui/lab'; import { components } from 'tg.service/apiSchema.generated'; import { BoxLoading } from 'tg.component/common/BoxLoading'; -import { useTaskStateTranslation } from 'tg.translationTools/useTaskStateTranslation'; import { useEnabledFeatures } from 'tg.globalContext/helpers'; import { DisabledFeatureBanner } from 'tg.component/common/DisabledFeatureBanner'; -import { useStateColor } from 'tg.component/task/TaskState'; import { useProjectBoardTasks } from '../views/projectTasks/useProjectBoardTasks'; import { BoardColumn } from './BoardColumn'; +import { LabelHint } from 'tg.component/common/LabelHint'; type TaskModel = components['schemas']['TaskModel']; type SimpleProjectModel = components['schemas']['SimpleProjectModel']; @@ -50,8 +49,6 @@ export const TasksBoard = ({ }: Props) => { const theme = useTheme(); const { t } = useTranslate(); - const translateState = useTaskStateTranslation(); - const stateColor = useStateColor(); const { isEnabled } = useEnabledFeatures(); const tasksFeature = isEnabled('TASKS'); @@ -114,27 +111,14 @@ export const TasksBoard = ({ newTaskActions={newTaskActions} /> - - {translateState('DONE')} - - - {' & '} - {translateState('CLOSED')} - - - ) : ( - - - {translateState('DONE')} - - - {' & '} - {translateState('CLOSED')} + + + + {t('task_board_closed_column_title')} + + {!showAll && ( - - ) + )} + } tasks={doneTasks.items} total={doneTasks.data?.pages?.[0]?.page?.totalElements ?? 0} project={project} onDetailOpen={onOpenDetail} - emptyMessage={t('task_board_empty_completed')} + emptyMessage={t('task_board_empty_closed')} newTaskActions={newTaskActions} /> diff --git a/webapp/src/ee/task/components/TasksPanel.tsx b/webapp/src/ee/task/components/TasksPanel.tsx index dbf5822357..7a2f577178 100644 --- a/webapp/src/ee/task/components/TasksPanel.tsx +++ b/webapp/src/ee/task/components/TasksPanel.tsx @@ -51,7 +51,6 @@ export const TasksPanel: React.FC = ({ taskNumber={task.number} project={project} enterDelay={1000} - newTaskActions={false} > {task && ( - + (existingItems) => existingItems; export const TrialAnnouncement = Empty; export const TrialChip = Empty; +export const TaskInfoMessage = Empty; diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 41959fd05d..47d79fd480 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -656,6 +656,9 @@ export interface paths { /** If the tasks is blocked by other tasks, it returns numbers of these tasks. */ get: operations["getBlockingTasks"]; }; + "/v2/projects/{projectId}/tasks/{taskNumber}/cancel": { + put: operations["cancelTask"]; + }; "/v2/projects/{projectId}/tasks/{taskNumber}/close": { put: operations["closeTask"]; }; @@ -2987,8 +2990,8 @@ export interface components { project?: components["schemas"]["SimpleProjectModel"]; type: | "TASK_ASSIGNED" - | "TASK_COMPLETED" - | "TASK_CLOSED" + | "TASK_FINISHED" + | "TASK_CANCELED" | "MFA_ENABLED" | "MFA_DISABLED" | "PASSWORD_CHANGED"; @@ -4573,7 +4576,7 @@ export interface components { name?: string; /** Format: int64 */ number: number; - state: "NEW" | "IN_PROGRESS" | "DONE" | "CLOSED"; + state: "NEW" | "IN_PROGRESS" | "FINISHED" | "CANCELED"; /** Format: int64 */ totalItems: number; type: "TRANSLATE" | "REVIEW"; @@ -4609,7 +4612,7 @@ export interface components { /** Format: int64 */ number: number; project: components["schemas"]["SimpleProjectModel"]; - state: "NEW" | "IN_PROGRESS" | "DONE" | "CLOSED"; + state: "NEW" | "IN_PROGRESS" | "FINISHED" | "CANCELED"; /** Format: int64 */ totalItems: number; type: "TRANSLATE" | "REVIEW"; @@ -14861,9 +14864,9 @@ export interface operations { parameters: { query: { /** Filter tasks by state */ - filterState?: ("NEW" | "IN_PROGRESS" | "DONE" | "CLOSED")[]; + filterState?: ("NEW" | "IN_PROGRESS" | "FINISHED" | "CANCELED")[]; /** Filter tasks without state */ - filterNotState?: ("NEW" | "IN_PROGRESS" | "DONE" | "CLOSED")[]; + filterNotState?: ("NEW" | "IN_PROGRESS" | "FINISHED" | "CANCELED")[]; /** Filter tasks by assignee */ filterAssignee?: number[]; /** Filter tasks by type */ @@ -14882,8 +14885,6 @@ export interface operations { filterKey?: number[]; /** Filter tasks by agency */ filterAgency?: number[]; - /** Exclude "done" tasks which are older than specified timestamp */ - filterDoneMinClosedAt?: number; /** Exclude tasks which were closed before specified timestamp */ filterNotClosedBefore?: number; /** Zero-based page index (0..N) */ @@ -15334,6 +15335,54 @@ export interface operations { }; }; }; + cancelTask: { + parameters: { + path: { + taskNumber: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["TaskModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; closeTask: { parameters: { path: { @@ -18675,9 +18724,9 @@ export interface operations { parameters: { query: { /** Filter tasks by state */ - filterState?: ("NEW" | "IN_PROGRESS" | "DONE" | "CLOSED")[]; + filterState?: ("NEW" | "IN_PROGRESS" | "FINISHED" | "CANCELED")[]; /** Filter tasks without state */ - filterNotState?: ("NEW" | "IN_PROGRESS" | "DONE" | "CLOSED")[]; + filterNotState?: ("NEW" | "IN_PROGRESS" | "FINISHED" | "CANCELED")[]; /** Filter tasks by assignee */ filterAssignee?: number[]; /** Filter tasks by type */ @@ -18696,8 +18745,6 @@ export interface operations { filterKey?: number[]; /** Filter tasks by agency */ filterAgency?: number[]; - /** Exclude "done" tasks which are older than specified timestamp */ - filterDoneMinClosedAt?: number; /** Exclude tasks which were closed before specified timestamp */ filterNotClosedBefore?: number; /** Zero-based page index (0..N) */ diff --git a/webapp/src/service/billingApiSchema.generated.ts b/webapp/src/service/billingApiSchema.generated.ts index bcdba5ca43..dd318c1227 100644 --- a/webapp/src/service/billingApiSchema.generated.ts +++ b/webapp/src/service/billingApiSchema.generated.ts @@ -738,7 +738,8 @@ export interface components { | "subscription_not_scheduled_for_cancellation" | "cannot_cancel_trial" | "cannot_update_without_modification" - | "current_subscription_is_not_trialing"; + | "current_subscription_is_not_trialing" + | "sorting_and_paging_is_not_supported_when_using_cursor"; params?: { [key: string]: unknown }[]; }; ExampleItem: { diff --git a/webapp/src/translationTools/useTaskStateTranslation.ts b/webapp/src/translationTools/useTaskStateTranslation.ts index a0dfa00d92..16352595c1 100644 --- a/webapp/src/translationTools/useTaskStateTranslation.ts +++ b/webapp/src/translationTools/useTaskStateTranslation.ts @@ -8,10 +8,10 @@ export function useTaskStateTranslation() { return (code: TaskType) => { switch (code) { - case 'CLOSED': - return t('task_state_closed'); - case 'DONE': - return t('task_state_done'); + case 'CANCELED': + return t('task_state_canceled'); + case 'FINISHED': + return t('task_state_finished'); case 'IN_PROGRESS': return t('task_state_in_progress'); case 'NEW': diff --git a/webapp/src/views/projects/TaskRedirect.tsx b/webapp/src/views/projects/TaskRedirect.tsx index 95614491d5..6938d661e6 100644 --- a/webapp/src/views/projects/TaskRedirect.tsx +++ b/webapp/src/views/projects/TaskRedirect.tsx @@ -35,7 +35,7 @@ export const TaskRedirect = () => { task.assignees.find((u) => u.id === user?.id) && (task.state === 'IN_PROGRESS' || task.state === 'NEW') ) { - url += `&${QUERY.TRANSLATIONS_PREFILTERS_TASK_HIDE_DONE}=true`; + url += `&${QUERY.TRANSLATIONS_PREFILTERS_TASK_HIDE_CLOSED}=true`; } url += diff --git a/webapp/src/views/projects/translations/BatchOperations/SelectAllCheckbox.tsx b/webapp/src/views/projects/translations/BatchOperations/SelectAllCheckbox.tsx index 85c41c4d7f..dcbebfaacd 100644 --- a/webapp/src/views/projects/translations/BatchOperations/SelectAllCheckbox.tsx +++ b/webapp/src/views/projects/translations/BatchOperations/SelectAllCheckbox.tsx @@ -8,7 +8,7 @@ import { const StyledToggleAllButton = styled(Box)` width: 38px; height: 38px; - margin: 0px -12px 0px -12.5px; + margin: -1px -12px 0px -12.5px; `; export const SelectAllCheckbox = () => { diff --git a/webapp/src/views/projects/translations/ToolsPanel/FloatingToolsPanel.tsx b/webapp/src/views/projects/translations/ToolsPanel/FloatingToolsPanel.tsx index 245c6ceffc..dfe1901ac6 100644 --- a/webapp/src/views/projects/translations/ToolsPanel/FloatingToolsPanel.tsx +++ b/webapp/src/views/projects/translations/ToolsPanel/FloatingToolsPanel.tsx @@ -33,6 +33,9 @@ export const FloatingToolsPanel = ({ width }: Props) => { const languageTag = useTranslationsSelector((c) => c.cursor?.language); const languages = useTranslationsSelector((c) => c.languages); const [fixedTopDistance, setFixedTopDistance] = useState(0); + const needsNamespaceMargin = useTranslationsSelector( + (c) => Boolean(c.translations?.[0].keyNamespace) && c.view === 'LIST' + ); useEffect(() => { function recalculate() { @@ -72,6 +75,7 @@ export const FloatingToolsPanel = ({ width }: Props) => { floatingBannerHeight )}px + 100vh)`, width, + marginTop: needsNamespaceMargin ? 7 : 0, }} ref={containerRef} > diff --git a/webapp/src/views/projects/translations/TranslationHeader/StickyHeader.tsx b/webapp/src/views/projects/translations/TranslationHeader/StickyHeader.tsx index 73108bfedf..bc5cfc6288 100644 --- a/webapp/src/views/projects/translations/TranslationHeader/StickyHeader.tsx +++ b/webapp/src/views/projects/translations/TranslationHeader/StickyHeader.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { styled } from '@mui/material'; +import { styled, useTheme } from '@mui/material'; import { useHeaderNsActions, @@ -9,13 +9,11 @@ import { NamespaceContent } from '../Namespace/NamespaceContent'; import { useGlobalContext } from 'tg.globalContext/GlobalContext'; const StyledContainer = styled('div')` - position: sticky; box-sizing: border-box; - margin: -12px -5px -10px -5px; + margin-bottom: -10px; margin-left: ${({ theme }) => theme.spacing(-2)}; margin-right: ${({ theme }) => theme.spacing(-2)}; background: ${({ theme }) => theme.palette.background.default}; - z-index: ${({ theme }) => theme.zIndex.appBar + 1}; transition: transform 0.2s ease-in-out; overflow: visible; display: grid; @@ -23,6 +21,7 @@ const StyledContainer = styled('div')` const StyledControls = styled('div')` padding: ${({ theme }) => theme.spacing(0, 1.5)}; + padding-top: 5px; background: ${({ theme }) => theme.palette.background.default}; `; @@ -38,6 +37,7 @@ const StyledNs = styled('div')` const StyledShadow = styled('div')` background: ${({ theme }) => theme.palette.divider1}; height: 1px; + margin-bottom: 10px; position: sticky; z-index: ${({ theme }) => theme.zIndex.appBar}; margin-left: ${({ theme }) => theme.spacing(-1)}; @@ -51,13 +51,19 @@ const StyledShadow = styled('div')` type Props = { height: number; + marginBottom?: number; }; -export const StickyHeader: React.FC = ({ height, children }) => { +export const StickyHeader: React.FC = ({ + height, + children, + marginBottom, +}) => { const { setFloatingBannerHeight } = useHeaderNsActions(); const topNamespace = useHeaderNsContext((c) => c.topNamespace); const topBannerHeight = useGlobalContext((c) => c.layout.topBannerHeight); const topBarHidden = useGlobalContext((c) => !c.layout.topBarHeight); + const theme = useTheme(); useEffect(() => { setFloatingBannerHeight(height); @@ -69,6 +75,9 @@ export const StickyHeader: React.FC = ({ height, children }) => { style={{ top: 50 + topBannerHeight, height: height + 5, + position: 'sticky', + zIndex: theme.zIndex.appBar + 1, + marginBottom, transform: topBarHidden ? `translate(0px, -55px)` : `translate(0px, 0px)`, diff --git a/webapp/src/views/projects/translations/TranslationHeader/TranslationControls.tsx b/webapp/src/views/projects/translations/TranslationHeader/TranslationControls.tsx index ff3b446087..0f911dd8df 100644 --- a/webapp/src/views/projects/translations/TranslationHeader/TranslationControls.tsx +++ b/webapp/src/views/projects/translations/TranslationHeader/TranslationControls.tsx @@ -1,5 +1,5 @@ import { LayoutGrid02, LayoutLeft, Plus } from '@untitled-ui/icons-react'; -import { Box, Button, ButtonGroup, styled } from '@mui/material'; +import { Button, ButtonGroup, styled } from '@mui/material'; import { T, useTranslate } from '@tolgee/react'; import { LanguagesSelect } from 'tg.component/common/form/LanguagesSelect/LanguagesSelect'; @@ -7,20 +7,16 @@ import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; import { TranslationFilters } from 'tg.component/translation/translationFilters/TranslationFilters'; import { QuickStartHighlight } from 'tg.component/layout/QuickStartGuide/QuickStartHighlight'; import { HeaderSearchField } from 'tg.component/layout/HeaderSearchField'; -import { PrefilterTaskShowDoneSwitch } from 'tg.ee'; import { useTranslationsActions, useTranslationsSelector, } from '../context/TranslationsContext'; -import { StickyHeader } from './StickyHeader'; const StyledContainer = styled('div')` display: grid; - grid-template-columns: auto 1fr auto; + grid-template-columns: 1fr auto; align-items: start; - padding-bottom: 8px; - padding-top: 13px; `; const StyledSpaced = styled('div')` @@ -56,81 +52,66 @@ export const TranslationControls: React.FC = ({ onDialogOpen }) => { const { setFilters } = useTranslationsActions(); const selectedLanguagesMapped = allLanguages?.filter((l) => selectedLanguages?.includes(l.tag)) ?? []; - const taskPrefilter = useTranslationsSelector( - (c) => c.prefilter?.task !== undefined - ); const handleAddTranslation = () => { onDialogOpen(); }; return ( - - - - - - + + + + + - - {taskPrefilter && ( - - )} - + + + + changeView('LIST')} + data-cy="translations-view-list-button" + > + + + changeView('TABLE')} + data-cy="translations-view-table-button" + > + + + - - - - changeView('LIST')} - data-cy="translations-view-list-button" - > - - - changeView('TABLE')} - data-cy="translations-view-table-button" + {canCreateKeys && ( + + - - )} - - - + + + + )} + + ); }; diff --git a/webapp/src/views/projects/translations/TranslationHeader/TranslationControlsCompact.tsx b/webapp/src/views/projects/translations/TranslationHeader/TranslationControlsCompact.tsx index fe677a1711..1c66285c35 100644 --- a/webapp/src/views/projects/translations/TranslationHeader/TranslationControlsCompact.tsx +++ b/webapp/src/views/projects/translations/TranslationHeader/TranslationControlsCompact.tsx @@ -8,14 +8,7 @@ import { LayoutGrid02, LayoutLeft, } from '@untitled-ui/icons-react'; -import { - Badge, - Box, - Button, - ButtonGroup, - IconButton, - styled, -} from '@mui/material'; +import { Badge, Button, ButtonGroup, IconButton, styled } from '@mui/material'; import { useTranslate } from '@tolgee/react'; import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; @@ -25,26 +18,22 @@ import { getActiveFilters } from 'tg.component/translation/translationFilters/ge import { FiltersMenu } from 'tg.component/translation/translationFilters/FiltersMenu'; import { useFiltersContent } from 'tg.component/translation/translationFilters/useFiltersContent'; import { HeaderSearchField } from 'tg.component/layout/HeaderSearchField'; -import { PrefilterTaskShowDoneSwitch } from 'tg.ee'; import { useTranslationsActions, useTranslationsSelector, } from '../context/TranslationsContext'; import { ViewMode } from '../context/types'; -import { StickyHeader } from './StickyHeader'; const StyledContainer = styled('div')` display: grid; - grid-template-columns: auto 1fr auto; + grid-template-columns: 1fr auto; align-items: center; margin-left: ${({ theme }) => theme.spacing(-1)}; margin-right: ${({ theme }) => theme.spacing(-2)}; padding: ${({ theme }) => theme.spacing(0, 1.5)}; z-index: ${({ theme }) => theme.zIndex.appBar + 1}; transition: transform 0.2s ease-in-out; - padding-bottom: 4px; - padding-top: 9px; `; const StyledSpaced = styled('div')` @@ -112,9 +101,6 @@ export const TranslationControlsCompact: React.FC = ({ setSearch(value); }; const filters = useTranslationsSelector((c) => c.filters); - const taskPrefilter = useTranslationsSelector( - (c) => c.prefilter?.task !== undefined - ); const activeFilters = getActiveFilters(filters); const { setFilters } = useTranslationsActions(); const selectedLanguagesMapped = @@ -138,118 +124,106 @@ export const TranslationControlsCompact: React.FC = ({ }; return ( - - - {searchOpen ? ( - - + {searchOpen ? ( + + + setSearchOpen(false)}> + + + + ) : ( + <> + + + + setSearchOpen(true)} + > + + + + + + + + setAnchorFiltersEl(e.currentTarget)} + > + + + + + setAnchorFiltersEl(null)} + filtersContent={filtersContent} + onChange={setFilters} /> - setSearchOpen(false)}> - + + + + setAnchorLanguagesEl(e.currentTarget)} + > + - - ) : ( - <> - - - - setSearchOpen(true)} - > - - - - - - - - setAnchorFiltersEl(e.currentTarget)} - > - - - - - setAnchorFiltersEl(null)} - filtersContent={filtersContent} - onChange={setFilters} - /> - - - - {taskPrefilter && ( - - )} - - - - setAnchorLanguagesEl(e.currentTarget)} + + setAnchorLanguagesEl(null)} + onChange={handleLanguageChange} + value={selectedLanguages} + languages={languages} + /> + + + handleViewChange('LIST')} + data-cy="translations-view-list-button" > - - - - setAnchorLanguagesEl(null)} - onChange={handleLanguageChange} - value={selectedLanguages} - languages={languages} - /> - - - handleViewChange('LIST')} - data-cy="translations-view-list-button" - > - - - handleViewChange('TABLE')} - data-cy="translations-view-table-button" + + + handleViewChange('TABLE')} + data-cy="translations-view-table-button" + > + + + + + {projectPermissions.satisfiesPermission('keys.edit') && ( + + - - - - - {projectPermissions.satisfiesPermission('keys.edit') && ( - - - - - - )} - - - )} - - + + + + )} + + + )} + ); }; diff --git a/webapp/src/views/projects/translations/TranslationHeader/TranslationsHeader.tsx b/webapp/src/views/projects/translations/TranslationHeader/TranslationsHeader.tsx index 30cb864af2..9d936335a6 100644 --- a/webapp/src/views/projects/translations/TranslationHeader/TranslationsHeader.tsx +++ b/webapp/src/views/projects/translations/TranslationHeader/TranslationsHeader.tsx @@ -1,4 +1,4 @@ -import { Typography, Dialog, useMediaQuery, styled } from '@mui/material'; +import { Typography, Dialog, useMediaQuery, styled, Box } from '@mui/material'; import { T } from '@tolgee/react'; import { useUrlSearchState } from 'tg.hooks/useUrlSearchState'; @@ -12,9 +12,11 @@ import { useState } from 'react'; import { confirmation } from 'tg.hooks/confirmation'; import { useGlobalContext } from 'tg.globalContext/GlobalContext'; import { SelectAllCheckbox } from '../BatchOperations/SelectAllCheckbox'; +import { StickyHeader } from './StickyHeader'; +import { Prefilter } from '../prefilters/Prefilter'; const StyledResultCount = styled('div')` - padding: 9px 0px 4px 0px; + padding: 0px 0px 4px 0px; margin-left: 15px; display: flex; align-items: center; @@ -26,6 +28,7 @@ const StyledDialog = styled(Dialog)` `; export const TranslationsHeader = () => { + const prefilter = useTranslationsSelector((c) => c.prefilter); const [newCreateDialog, setNewCreateDialog] = useUrlSearchState('create', { defaultVal: 'false', }); @@ -61,12 +64,32 @@ export const TranslationsHeader = () => { } } + const controls = isSmall ? ( + + + + ) : ( + + + + ); + return ( <> - {isSmall ? ( - + {prefilter && ( + <> + + + + + + + )} + + {!prefilter ? ( + {controls} ) : ( - + {controls} )} {dataReady && translationsTotal ? ( diff --git a/webapp/src/views/projects/translations/Translations.tsx b/webapp/src/views/projects/translations/Translations.tsx index d476eebb06..e8731f1adb 100644 --- a/webapp/src/views/projects/translations/Translations.tsx +++ b/webapp/src/views/projects/translations/Translations.tsx @@ -25,7 +25,6 @@ import { BaseProjectView } from '../BaseProjectView'; import { TranslationsToolbar } from './TranslationsToolbar'; import { BatchOperationsChangeIndicator } from './BatchOperations/BatchOperationsChangeIndicator'; import { FloatingToolsPanel } from './ToolsPanel/FloatingToolsPanel'; -import { Prefilter } from './prefilters/Prefilter'; import { TranslationsTaskDetail } from 'tg.ee'; import { TaskAllDonePlaceholder } from 'tg.ee'; import { EmptyState } from 'tg.component/common/EmptyState'; @@ -159,10 +158,9 @@ export const Translations = () => { }), ], ]} - wrapperProps={{ pb: 0 }} + wrapperProps={{ style: { paddingBottom: 0, paddingTop: '3px' } }} > - {prefilter && } {translationsEmpty ? ( diff --git a/webapp/src/views/projects/translations/TranslationsList/TranslationWrite.tsx b/webapp/src/views/projects/translations/TranslationsList/TranslationWrite.tsx index 820d2c9a2d..594b60b9bc 100644 --- a/webapp/src/views/projects/translations/TranslationsList/TranslationWrite.tsx +++ b/webapp/src/views/projects/translations/TranslationsList/TranslationWrite.tsx @@ -1,7 +1,10 @@ import { useRef, useState } from 'react'; import { Box, IconButton, Tooltip, styled } from '@mui/material'; import { Placeholder } from '@tginternal/editor'; +import { useTranslate } from '@tolgee/react'; +import { HelpCircle } from '@untitled-ui/icons-react'; +import { TaskInfoMessage } from 'tg.ee'; import { ControlsEditorMain } from '../cell/ControlsEditorMain'; import { ControlsEditorSmall } from '../cell/ControlsEditorSmall'; import { useTranslationsSelector } from '../context/TranslationsContext'; @@ -14,8 +17,6 @@ import { useMissingPlaceholders } from '../cell/useMissingPlaceholders'; import { TranslationVisual } from '../translationVisual/TranslationVisual'; import { ControlsEditorReadOnly } from '../cell/ControlsEditorReadOnly'; import { useBaseTranslation } from '../useBaseTranslation'; -import { HelpCircle } from '@untitled-ui/icons-react'; -import { useTranslate } from '@tolgee/react'; const StyledContainer = styled('div')` display: grid; @@ -48,9 +49,14 @@ const StyledContainer = styled('div')` const StyledBottom = styled(Box)` grid-area: controls-b; padding: 0px 12px 4px 16px; + display: grid; + gap: 8px; + margin-bottom: 8px; +`; + +const StyledControls = styled(Box)` display: flex; justify-content: space-between; - margin-bottom: 8px; flex-wrap: wrap; gap: 8px; align-items: center; @@ -83,7 +89,11 @@ export const TranslationWrite: React.FC = ({ tools }) => { const editVal = tools.editVal!; const state = translation?.state || 'UNTRANSLATED'; const activeVariant = editVal.activeVariant; - + const prefilteredTask = useTranslationsSelector((c) => + c.prefilteredTask?.language.id === language.id + ? c.prefilteredTask + : undefined + ); const [mode, setMode] = useState<'placeholders' | 'syntax'>('placeholders'); const editorRef = useRef(null); const baseLanguage = useTranslationsSelector((c) => c.baseLanguage); @@ -127,6 +137,10 @@ export const TranslationWrite: React.FC = ({ tools }) => { } }; + const translationTasks = keyData.tasks?.filter( + (t) => t.languageTag === language.tag + ); + return ( = ({ tools }) => { e.preventDefault()}> {editEnabled ? ( <> - - - - - - - - - handleClose(true)} - tasks={keyData.tasks?.filter( - (t) => t.languageTag === language.tag - )} + + + + + + + + + + + handleClose(true)} + tasks={translationTasks} + currentTask={prefilteredTask?.number} + /> + ) : ( = ({ tools }) => { const editorRef = useRef(null); const baseLanguage = useTranslationsSelector((c) => c.baseLanguage); const nested = Boolean(editVal.value.parameter); + const prefilteredTask = useTranslationsSelector((c) => + c.prefilteredTask?.language.id === language.id + ? c.prefilteredTask + : undefined + ); const baseTranslation = useBaseTranslation( activeVariant, @@ -81,6 +92,10 @@ export const TranslationWrite: React.FC = ({ tools }) => { enabled: baseLanguage !== language.tag, }); + const translationTasks = keyData.tasks?.filter( + (t) => t.languageTag === language.tag + ); + const handleModeToggle = () => { setMode((mode) => (mode === 'syntax' ? 'placeholders' : 'syntax')); }; @@ -122,43 +137,52 @@ export const TranslationWrite: React.FC = ({ tools }) => { - {Boolean(missingPlaceholders.length) && ( - )} + + {Boolean(missingPlaceholders.length) && ( + + )} - - e.preventDefault(), - }} - state={state} - mode={mode} - isBaseLanguage={language.base} - stateChangeEnabled={canChangeState} - onInsertBase={editEnabled ? handleInsertBase : undefined} - onStateChange={setState} - onModeToggle={editEnabled ? handleModeToggle : undefined} - tasks={keyData.tasks?.filter((t) => t.languageTag === language.tag)} - onTaskStateChange={setAssignedTaskState} - /> - {editEnabled ? ( - handleClose(true)} + + e.preventDefault(), + }} + state={state} + mode={mode} + isBaseLanguage={language.base} + stateChangeEnabled={canChangeState} + onInsertBase={editEnabled ? handleInsertBase : undefined} + onStateChange={setState} + onModeToggle={editEnabled ? handleModeToggle : undefined} tasks={keyData.tasks?.filter( (t) => t.languageTag === language.tag )} + onTaskStateChange={setAssignedTaskState} /> - ) : ( - handleClose(true)} /> - )} - + {editEnabled ? ( + handleClose(true)} + tasks={translationTasks} + currentTask={prefilteredTask?.number} + /> + ) : ( + handleClose(true)} /> + )} + + ); diff --git a/webapp/src/views/projects/translations/cell/ControlsEditorMain.tsx b/webapp/src/views/projects/translations/cell/ControlsEditorMain.tsx index 7ad4b1c1c7..47e1ccab9c 100644 --- a/webapp/src/views/projects/translations/cell/ControlsEditorMain.tsx +++ b/webapp/src/views/projects/translations/cell/ControlsEditorMain.tsx @@ -27,6 +27,7 @@ type ControlsProps = { onCancel?: () => void; className?: string; tasks: TaskModel[] | undefined; + currentTask: number | undefined; }; export const ControlsEditorMain: React.FC = ({ @@ -34,15 +35,15 @@ export const ControlsEditorMain: React.FC = ({ onCancel, className, tasks, + currentTask, }) => { const isEditLoading = useTranslationsSelector((c) => c.isEditLoading); const anchorEl = useRef(null); const [open, setOpen] = useState(false); const task = tasks?.[0]; - const prefilteredTask = useTranslationsSelector((c) => c.prefilter?.task); const displayTaskControls = + (currentTask === undefined || currentTask === task?.number) && task && - task.number === prefilteredTask && task.userAssigned && !task.done && task.type === 'TRANSLATE'; diff --git a/webapp/src/views/projects/translations/context/TranslationsContext.ts b/webapp/src/views/projects/translations/context/TranslationsContext.ts index e384a27a1f..c3e2b2e105 100644 --- a/webapp/src/views/projects/translations/context/TranslationsContext.ts +++ b/webapp/src/views/projects/translations/context/TranslationsContext.ts @@ -68,6 +68,18 @@ export const [ const { satisfiesLanguageAccess } = useProjectPermissions(); + const prefilteredTaskLoadable = useApiQuery({ + url: '/v2/projects/{projectId}/tasks/{taskNumber}', + method: 'get', + path: { + projectId: props.projectId, + taskNumber: props.prefilter?.task as number, + }, + options: { + enabled: props.prefilter?.task !== undefined, + }, + }); + const languagesLoadable = useApiQuery({ url: '/v2/projects/{projectId}/languages', method: 'get', @@ -108,7 +120,6 @@ export const [ const stateService = useStateService({ translations: translationService, taskService, - prefilter: props.prefilter, }); const positionService = usePositionService({ @@ -121,7 +132,6 @@ export const [ translationService, viewRefs, taskService, - prefilter: props.prefilter, }); const tagsService = useTagsService({ @@ -303,6 +313,7 @@ export const [ elementsRef: viewRefs.elementsRef, reactList: viewRefs.reactList, prefilter: props.prefilter, + prefilteredTask: prefilteredTaskLoadable.data, layout, }; diff --git a/webapp/src/views/projects/translations/context/services/useEditService.tsx b/webapp/src/views/projects/translations/context/services/useEditService.tsx index 1debb2e53c..8dc3f5d5e6 100644 --- a/webapp/src/views/projects/translations/context/services/useEditService.tsx +++ b/webapp/src/views/projects/translations/context/services/useEditService.tsx @@ -15,8 +15,7 @@ import { useTranslationsService } from './useTranslationsService'; import { useRefsService } from './useRefsService'; import { AfterCommand, ChangeValue, SetEdit } from '../types'; import { useTaskService } from './useTaskService'; -import { PrefilterType } from '../../prefilters/usePrefilter'; -import { composeValue } from './utils'; +import { composeValue, taskEditControlsShouldBeVisible } from './utils'; import { usePositionService } from './usePositionService'; type Props = { @@ -24,14 +23,12 @@ type Props = { translationService: ReturnType; viewRefs: ReturnType; taskService: ReturnType; - prefilter: PrefilterType | undefined; }; export const useEditService = ({ positionService, translationService, taskService, - prefilter, }: Props) => { const { position, @@ -131,22 +128,16 @@ export const useEditService = ({ ]); } - if (language && !data.preventTaskResolution && prefilter?.task) { + if (language && !data.preventTaskResolution) { const key = translationService.fixedTranslations?.find( (k) => k.keyId === keyId ); - const task = key?.tasks?.find((t) => t.languageTag === language); - - if ( - task && - prefilter.task === task.number && - !task.done && - task.userAssigned && - task.type === 'TRANSLATE' - ) { + const firstTask = key?.tasks?.find((t) => t.languageTag === language); + + if (firstTask && taskEditControlsShouldBeVisible(firstTask)) { await taskService.setTaskTranslationState({ keyId: position.keyId, - taskNumber: task.number, + taskNumber: firstTask.number, done: true, }); } diff --git a/webapp/src/views/projects/translations/context/services/useStateService.tsx b/webapp/src/views/projects/translations/context/services/useStateService.tsx index 5f6468c97b..f0cc1e3cdf 100644 --- a/webapp/src/views/projects/translations/context/services/useStateService.tsx +++ b/webapp/src/views/projects/translations/context/services/useStateService.tsx @@ -4,19 +4,14 @@ import { useProject } from 'tg.hooks/useProject'; import { SetTranslationState } from '../types'; import { useTranslationsService } from './useTranslationsService'; import { useTaskService } from './useTaskService'; -import { PrefilterType } from '../../prefilters/usePrefilter'; +import { taskReviewControlsShouldBeVisible } from './utils'; type Props = { translations: ReturnType; taskService: ReturnType; - prefilter: PrefilterType | undefined; }; -export const useStateService = ({ - translations, - taskService, - prefilter, -}: Props) => { +export const useStateService = ({ translations, taskService }: Props) => { const putTranslationState = usePutTranslationState(); const project = useProject(); @@ -37,17 +32,17 @@ export const useStateService = ({ const key = translations.fixedTranslations?.find( (k) => k.keyId === data.keyId ); - const task = key?.tasks?.find((t) => t.languageTag === data.language); + const firstTask = key?.tasks?.find( + (t) => t.languageTag === data.language + ); if ( data.state === 'REVIEWED' && - task?.userAssigned && - prefilter?.task === task?.number && - task.type === 'REVIEW' && - !task.done + firstTask && + taskReviewControlsShouldBeVisible(firstTask) ) { taskService.setTaskTranslationState({ keyId: data.keyId, - taskNumber: task.number, + taskNumber: firstTask.number, done: true, }); } diff --git a/webapp/src/views/projects/translations/context/services/utils.tsx b/webapp/src/views/projects/translations/context/services/utils.tsx index c8586dcc34..ccb4119525 100644 --- a/webapp/src/views/projects/translations/context/services/utils.tsx +++ b/webapp/src/views/projects/translations/context/services/utils.tsx @@ -9,6 +9,9 @@ import { Edit, EditorProps, } from '../types'; +import { components } from 'tg.service/apiSchema.generated'; + +type KeyTaskViewModel = components['schemas']['KeyTaskViewModel']; export function generateCurrentValue( position: EditorProps, @@ -74,3 +77,21 @@ export function updateReactListSizes(list: ReactList, currentIndex: number) { list.setState((state) => ({ ...state })); } } + +export function taskEditControlsShouldBeVisible(firstTask: KeyTaskViewModel) { + return ( + firstTask && + !firstTask.done && + firstTask.userAssigned && + firstTask.type === 'TRANSLATE' + ); +} + +export function taskReviewControlsShouldBeVisible(firstTask: KeyTaskViewModel) { + return ( + firstTask && + !firstTask.done && + firstTask.userAssigned && + firstTask.type === 'REVIEW' + ); +} diff --git a/webapp/src/views/projects/translations/prefilters/ContainerPrefilter.tsx b/webapp/src/views/projects/translations/prefilters/ContainerPrefilter.tsx index 5ec0368c15..ea7d8f87ed 100644 --- a/webapp/src/views/projects/translations/prefilters/ContainerPrefilter.tsx +++ b/webapp/src/views/projects/translations/prefilters/ContainerPrefilter.tsx @@ -1,5 +1,5 @@ import { FilterLines } from '@untitled-ui/icons-react'; -import { Button, styled, useMediaQuery } from '@mui/material'; +import { Box, Button, styled, useMediaQuery } from '@mui/material'; import { T } from '@tolgee/react'; import { usePrefilter } from './usePrefilter'; @@ -7,8 +7,6 @@ import { useGlobalContext } from 'tg.globalContext/GlobalContext'; import React from 'react'; const StyledContainer = styled('div')` - margin-top: -4px; - margin-bottom: 12px; background: ${({ theme }) => theme.palette.revisionFilterBanner.background}; padding: 0px 4px 0px 14px; border-radius: 4px; @@ -48,6 +46,8 @@ type Props = { content: React.ReactNode; icon?: React.ReactNode; closeButton?: React.ReactNode; + controls?: React.ReactNode; + alert?: React.ReactNode; }; export const PrefilterContainer = ({ @@ -55,8 +55,10 @@ export const PrefilterContainer = ({ content, icon, closeButton, + controls, + alert, }: Props) => { - const { clear } = usePrefilter(); + const prefilter = usePrefilter(); const rightPanelWidth = useGlobalContext((c) => c.layout.rightPanelWidth); const isSmall = useMediaQuery( @@ -69,10 +71,14 @@ export const PrefilterContainer = ({ {icon ?? } {title} - {!isSmall && content} + + {!isSmall && content} + {controls} + {!isSmall && alert} + {closeButton ?? ( - )} diff --git a/webapp/src/views/projects/translations/prefilters/usePrefilter.ts b/webapp/src/views/projects/translations/prefilters/usePrefilter.ts index d98ef2d4fa..92b6a3c18b 100644 --- a/webapp/src/views/projects/translations/prefilters/usePrefilter.ts +++ b/webapp/src/views/projects/translations/prefilters/usePrefilter.ts @@ -17,7 +17,7 @@ const stringToNumber = (input: string | undefined) => { return undefined; }; -export const usePrefilter = (): PrefilterType => { +export const usePrefilter = (): PrefilterType | undefined => { const [activity, setActivity] = useUrlSearchState( QUERY.TRANSLATIONS_PREFILTERS_ACTIVITY, { @@ -41,10 +41,9 @@ export const usePrefilter = (): PrefilterType => { ); const [taskHideDone, setTaskHideDone] = useUrlSearchState( - QUERY.TRANSLATIONS_PREFILTERS_TASK_HIDE_DONE, + QUERY.TRANSLATIONS_PREFILTERS_TASK_HIDE_CLOSED, { defaultVal: undefined, - history: true, } ); @@ -59,20 +58,23 @@ export const usePrefilter = (): PrefilterType => { setTaskHideDone(undefined); } - const result: PrefilterType = { - clear, - }; - if (activityId !== undefined) { - result.activity = activityId; + return { + activity: activityId, + clear, + }; } else if (failedJobId !== undefined) { - result.failedJob = failedJobId; + return { + failedJob: failedJobId, + clear, + }; } else if (taskNumber !== undefined) { - result.task = taskNumber; - if (taskHideDone !== undefined) { - result.taskFilterNotDone = taskHideDone === 'true'; - } + return { + task: taskNumber, + taskFilterNotDone: taskHideDone === 'true', + clear, + }; } - return result; + return undefined; }; diff --git a/webapp/src/views/projects/translations/useTranslationCell.ts b/webapp/src/views/projects/translations/useTranslationCell.ts index ae21dbb18b..a1aadcf3b4 100644 --- a/webapp/src/views/projects/translations/useTranslationCell.ts +++ b/webapp/src/views/projects/translations/useTranslationCell.ts @@ -148,6 +148,9 @@ export const useTranslationCell = ({ const translation = langTag ? keyData?.translations[langTag] : undefined; const firstTask = keyData.tasks?.find((t) => t.languageTag === language.tag); + const assignedTask = keyData.tasks?.find( + (t) => t.languageTag === language.tag && t.userAssigned + ); const setAssignedTaskState = (done: boolean) => { if (firstTask) { @@ -179,12 +182,12 @@ export const useTranslationCell = ({ } const canChangeState = - (firstTask?.userAssigned && firstTask.type === 'REVIEW') || + (assignedTask?.userAssigned && assignedTask.type === 'REVIEW') || satisfiesLanguageAccess('translations.state-edit', language.id); const disabled = translation?.state === 'DISABLED'; const editEnabled = - ((firstTask?.userAssigned && firstTask.type === 'TRANSLATE') || + ((assignedTask?.userAssigned && assignedTask.type === 'TRANSLATE') || satisfiesLanguageAccess('translations.edit', language.id)) && !disabled;